Align playlists: add Plex support + cover art + modal redesign
The align buttons were gated to Navidrome, so Plex users (the actual tester) never saw them. Plex reorders in place via plexapi moveItem/removeItems — preserves the playlist's poster/summary/ratingKey (no delete-recreate), same spirit as Navidrome's overwrite. - plex_client.reorder_playlist(): moves each desired track into sequence, removes any current item not in the ordered list (Mirror drops extras; Keep includes them). get_playlist_track_ids() feeds the shared tested plan_align_rewrite. - /align endpoint dispatches navidrome + plex; reuses the pure planner for both. - frontend gate opened to navidrome|plex. - modal redesigned: cover art per row, gradient header, pop/fade animation, hover rows, real polish (was a plain numbered list). plexapi moveItem/removeItems signatures verified against the installed version.
This commit is contained in:
parent
8afbfbfeab
commit
bac5da9177
4 changed files with 141 additions and 30 deletions
|
|
@ -714,6 +714,88 @@ class PlexClient(MediaServerClient):
|
|||
logger.error(f"Error reconciling Plex playlist '{playlist_name}': {e}")
|
||||
return False
|
||||
|
||||
def get_playlist_track_ids(self, playlist_id, playlist_name: str = "") -> List[str]:
|
||||
"""The playlist's current track ratingKeys, in current order. [] if missing."""
|
||||
if not self.ensure_connection():
|
||||
return []
|
||||
try:
|
||||
playlist = None
|
||||
try:
|
||||
playlist = self.server.fetchItem(int(playlist_id))
|
||||
except Exception:
|
||||
playlist = None
|
||||
if playlist is None and playlist_name:
|
||||
try:
|
||||
playlist = self.server.playlist(playlist_name)
|
||||
except Exception:
|
||||
playlist = None
|
||||
if playlist is None:
|
||||
return []
|
||||
return [str(i.ratingKey) for i in playlist.items() if hasattr(i, 'ratingKey')]
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting Plex playlist track ids '{playlist_name}': {e}")
|
||||
return []
|
||||
|
||||
def reorder_playlist(self, playlist_id, playlist_name: str, ordered_ids) -> bool:
|
||||
"""In-place reorder a playlist to an exact ordered ratingKey list ('Align
|
||||
playlists'). Moves items into sequence and removes any current item NOT in
|
||||
``ordered_ids`` (so 'Mirror source' drops extras; 'Keep extras' includes
|
||||
them in the list). Uses moveItem/removeItems so the playlist's poster,
|
||||
summary and ratingKey survive — never deletes/recreates. Order-only: every
|
||||
id in ``ordered_ids`` is already in the playlist (the caller validated)."""
|
||||
if not self.ensure_connection():
|
||||
return False
|
||||
try:
|
||||
playlist = None
|
||||
try:
|
||||
playlist = self.server.fetchItem(int(playlist_id))
|
||||
except Exception as e:
|
||||
logger.debug("Plex reorder fetchItem failed: %s", e)
|
||||
if playlist is None and playlist_name:
|
||||
try:
|
||||
playlist = self.server.playlist(playlist_name)
|
||||
except Exception as e:
|
||||
logger.debug("Plex reorder by-name failed: %s", e)
|
||||
if playlist is None:
|
||||
logger.error(f"Plex reorder: playlist not found (id={playlist_id}, name='{playlist_name}')")
|
||||
return False
|
||||
|
||||
ordered = [str(i) for i in (ordered_ids or []) if str(i)]
|
||||
ordered_set = set(ordered)
|
||||
items = [i for i in playlist.items() if hasattr(i, 'ratingKey')]
|
||||
by_rk = {str(i.ratingKey): i for i in items}
|
||||
|
||||
# Drop items not in the desired list (extras, for Mirror source).
|
||||
to_remove = [i for i in items if str(i.ratingKey) not in ordered_set]
|
||||
if to_remove:
|
||||
try:
|
||||
playlist.removeItems(to_remove)
|
||||
except (AttributeError, TypeError):
|
||||
for it in to_remove:
|
||||
try:
|
||||
playlist.removeItem(it)
|
||||
except Exception as one_err:
|
||||
logger.debug("Plex reorder: removeItem failed: %s", one_err)
|
||||
|
||||
# Move each desired item into place: first to the front (after=None),
|
||||
# then each subsequent one after its predecessor.
|
||||
prev = None
|
||||
for sid in ordered:
|
||||
it = by_rk.get(sid)
|
||||
if it is None:
|
||||
continue
|
||||
try:
|
||||
playlist.moveItem(it, after=prev)
|
||||
except Exception as mv_err:
|
||||
logger.warning(f"Plex reorder moveItem failed for {sid}: {mv_err}")
|
||||
return False
|
||||
prev = it
|
||||
logger.info(f"Aligned Plex playlist '{playlist_name}' order ({len(ordered)} tracks)")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error reordering Plex playlist '{playlist_name}': {e}")
|
||||
return False
|
||||
|
||||
def update_playlist(self, playlist_name: str, tracks: List[TrackInfo]) -> bool:
|
||||
if not self.ensure_connection():
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -19780,13 +19780,17 @@ def server_playlist_align(playlist_id):
|
|||
return jsonify({"success": False, "error": "no matched tracks to align"}), 400
|
||||
|
||||
active_server = config_manager.get_active_media_server()
|
||||
if active_server != 'navidrome' or not media_server_engine.client('navidrome'):
|
||||
client = media_server_engine.client(active_server) if active_server else None
|
||||
if active_server not in ('navidrome', 'plex') or not client:
|
||||
return jsonify({"success": False,
|
||||
"error": "Align is only supported on Navidrome right now"}), 400
|
||||
"error": "Align isn't supported on this server yet"}), 400
|
||||
|
||||
client = media_server_engine.client('navidrome')
|
||||
current_tracks = client.get_playlist_tracks(playlist_id) or []
|
||||
current_ids = [str(t.ratingKey) for t in current_tracks if getattr(t, 'ratingKey', None)]
|
||||
# Current playlist track ids (in current server order) — per server.
|
||||
if active_server == 'navidrome':
|
||||
current_tracks = client.get_playlist_tracks(playlist_id) or []
|
||||
current_ids = [str(t.ratingKey) for t in current_tracks if getattr(t, 'ratingKey', None)]
|
||||
else: # plex
|
||||
current_ids = client.get_playlist_track_ids(playlist_id, playlist_name)
|
||||
|
||||
from core.sync.playlist_edit import plan_align_rewrite
|
||||
ordered = plan_align_rewrite(current_ids, matched_ids, keep_extras=keep_extras)
|
||||
|
|
@ -19794,9 +19798,12 @@ def server_playlist_align(playlist_id):
|
|||
return jsonify({"success": False,
|
||||
"error": "Playlist changed on the server — reload and try again"}), 409
|
||||
|
||||
ok = client.rewrite_playlist_order(playlist_id, playlist_name, ordered)
|
||||
if active_server == 'navidrome':
|
||||
ok = client.rewrite_playlist_order(playlist_id, playlist_name, ordered)
|
||||
else: # plex — in-place moveItem/removeItems reorder
|
||||
ok = client.reorder_playlist(playlist_id, playlist_name, ordered)
|
||||
if not ok:
|
||||
return jsonify({"success": False, "error": "Failed to rewrite playlist order"}), 500
|
||||
return jsonify({"success": False, "error": "Failed to reorder playlist"}), 500
|
||||
return jsonify({"success": True, "track_count": len(ordered),
|
||||
"kept_extras": keep_extras})
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -1524,19 +1524,26 @@ function _showServerOrder() {
|
|||
const serverLabel = serverType.charAt(0).toUpperCase() + serverType.slice(1);
|
||||
|
||||
document.getElementById('server-order-modal')?.remove();
|
||||
const rows = order.map((t, i) => `
|
||||
const rows = order.map((t, i) => {
|
||||
const art = t.thumb
|
||||
? `<img class="server-order-art" src="${esc(t.thumb)}" alt="" loading="lazy" onerror="this.outerHTML='<div class="server-order-art server-order-art-ph">♫</div>'">`
|
||||
: `<div class="server-order-art server-order-art-ph">♫</div>`;
|
||||
return `
|
||||
<div class="server-order-row">
|
||||
<span class="server-order-num">${i + 1}</span>
|
||||
${art}
|
||||
<div class="server-order-meta">
|
||||
<span class="server-order-title">${esc(t.title || 'Unknown')}</span>
|
||||
<span class="server-order-artist">${esc(t.artist || '')}</span>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Align actions (Navidrome only for now) — reorder the server playlist to the
|
||||
// source order. Two choices for server-only "extra" tracks. Order-only: it never
|
||||
// adds the missing tracks (that's the normal sync's job).
|
||||
const alignFoot = serverType === 'navidrome' ? `
|
||||
// Align actions — reorder the server playlist to the source order. Two choices
|
||||
// for server-only "extra" tracks. Order-only: never adds the missing tracks
|
||||
// (that's the normal sync's job). Supported where reorder is implemented.
|
||||
const canAlign = serverType === 'navidrome' || serverType === 'plex';
|
||||
const alignFoot = canAlign ? `
|
||||
<div class="server-order-foot">
|
||||
<div class="server-order-foot-label">Align this playlist to the source order</div>
|
||||
<div class="server-order-actions">
|
||||
|
|
|
|||
|
|
@ -58576,35 +58576,50 @@ tr.tag-diff-same {
|
|||
.server-order-overlay {
|
||||
position: fixed; inset: 0; z-index: 10000; display: flex;
|
||||
align-items: center; justify-content: center; padding: 24px;
|
||||
background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(3px);
|
||||
background: rgba(8, 9, 13, 0.72); backdrop-filter: blur(6px);
|
||||
animation: serverOrderFade 0.16s ease;
|
||||
}
|
||||
@keyframes serverOrderFade { from { opacity: 0; } to { opacity: 1; } }
|
||||
.server-order-dialog {
|
||||
width: min(440px, 100%); max-height: 80vh; display: flex; flex-direction: column;
|
||||
background: #1a1c22; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 14px;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6); overflow: hidden;
|
||||
width: min(460px, 100%); max-height: 82vh; display: flex; flex-direction: column;
|
||||
background: linear-gradient(180deg, #20232b, #16181e);
|
||||
border: 1px solid rgba(255, 255, 255, 0.09); border-radius: 18px;
|
||||
box-shadow: 0 30px 80px -20px rgba(0, 0, 0, 0.8), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
overflow: hidden; animation: serverOrderPop 0.2s cubic-bezier(0.2, 0.7, 0.2, 1.1);
|
||||
}
|
||||
@keyframes serverOrderPop { from { opacity: 0; transform: translateY(10px) scale(0.98); } to { opacity: 1; transform: none; } }
|
||||
.server-order-head {
|
||||
display: flex; align-items: flex-start; justify-content: space-between; gap: 12px;
|
||||
padding: 16px 18px; border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||
padding: 18px 20px 15px;
|
||||
background: linear-gradient(180deg, rgba(255, 183, 77, 0.08), transparent);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.server-order-h1 { font-size: 15px; font-weight: 800; color: #fff; }
|
||||
.server-order-sub { font-size: 11.5px; color: rgba(255, 255, 255, 0.45); margin-top: 3px; }
|
||||
.server-order-h1 { font-size: 16px; font-weight: 800; color: #fff; letter-spacing: -0.01em; }
|
||||
.server-order-sub { font-size: 11.5px; color: rgba(255, 255, 255, 0.45); margin-top: 4px; line-height: 1.4; }
|
||||
.server-order-close {
|
||||
background: none; border: none; color: rgba(255, 255, 255, 0.5); font-size: 22px;
|
||||
line-height: 1; cursor: pointer; padding: 0 4px;
|
||||
flex: 0 0 auto; width: 30px; height: 30px; border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.05); border: none; color: rgba(255, 255, 255, 0.55);
|
||||
font-size: 20px; line-height: 1; cursor: pointer; transition: all 0.15s ease;
|
||||
}
|
||||
.server-order-close:hover { color: #fff; }
|
||||
.server-order-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 8px 8px 12px; }
|
||||
.server-order-close:hover { color: #fff; background: rgba(255, 255, 255, 0.12); }
|
||||
.server-order-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 8px 10px 12px; scrollbar-width: thin; }
|
||||
.server-order-row {
|
||||
display: flex; align-items: center; gap: 12px; padding: 8px 10px; border-radius: 8px;
|
||||
display: flex; align-items: center; gap: 12px; padding: 7px 8px; border-radius: 10px;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
.server-order-row:nth-child(odd) { background: rgba(255, 255, 255, 0.02); }
|
||||
.server-order-row:hover { background: rgba(255, 255, 255, 0.04); }
|
||||
.server-order-num {
|
||||
flex: 0 0 24px; text-align: right; font-size: 12px; font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
flex: 0 0 22px; text-align: right; font-size: 12px; font-weight: 800;
|
||||
color: rgba(255, 255, 255, 0.3); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.server-order-meta { min-width: 0; display: flex; flex-direction: column; }
|
||||
.server-order-title { font-size: 13px; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.server-order-art {
|
||||
flex: 0 0 40px; width: 40px; height: 40px; border-radius: 7px; object-fit: cover;
|
||||
background: rgba(255, 255, 255, 0.05); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.server-order-art-ph { display: flex; align-items: center; justify-content: center;
|
||||
font-size: 17px; color: rgba(255, 255, 255, 0.3); }
|
||||
.server-order-meta { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
|
||||
.server-order-title { font-size: 13.5px; font-weight: 600; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.server-order-artist { font-size: 11.5px; color: rgba(255, 255, 255, 0.45); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.server-order-empty { padding: 20px; text-align: center; color: rgba(255, 255, 255, 0.4); font-size: 13px; }
|
||||
.server-order-foot { flex: 0 0 auto; padding: 14px 16px 16px; border-top: 1px solid rgba(255, 255, 255, 0.07); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue