diff --git a/core/plex_client.py b/core/plex_client.py index 29310ab9..39c81b8a 100644 --- a/core/plex_client.py +++ b/core/plex_client.py @@ -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 diff --git a/web_server.py b/web_server.py index 6855d969..b2296eec 100644 --- a/web_server.py +++ b/web_server.py @@ -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: diff --git a/webui/static/pages-extra.js b/webui/static/pages-extra.js index 9ccc3579..9581a379 100644 --- a/webui/static/pages-extra.js +++ b/webui/static/pages-extra.js @@ -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 + ? `` + : `
`; + return `
${i + 1} + ${art}
${esc(t.title || 'Unknown')} ${esc(t.artist || '')}
-
`).join(''); + `; + }).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 ? `
Align this playlist to the source order
diff --git a/webui/static/style.css b/webui/static/style.css index 0f63c94a..24fe21f1 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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); }