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:
BoulderBadgeDad 2026-06-24 13:12:09 -07:00
parent 8afbfbfeab
commit bac5da9177
4 changed files with 141 additions and 30 deletions

View file

@ -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

View file

@ -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:

View file

@ -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=&quot;server-order-art server-order-art-ph&quot;>&#9835;</div>'">`
: `<div class="server-order-art server-order-art-ph">&#9835;</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">

View file

@ -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); }