diff --git a/core/navidrome_client.py b/core/navidrome_client.py index e042788a..98536950 100644 --- a/core/navidrome_client.py +++ b/core/navidrome_client.py @@ -1022,6 +1022,34 @@ class NavidromeClient(MediaServerClient): logger.error(f"Error {'updating' if playlist_id else 'creating'} Navidrome playlist '{name}': {e}") return False + def rewrite_playlist_order(self, playlist_id: str, name: str, ordered_song_ids) -> bool: + """Rewrite a playlist's tracks to an exact ordered id list (Subsonic has no + per-track move — the only reorder primitive is overwriting the whole song + list). Overwrites in place via createPlaylist + playlistId, so the playlist + identity (id/name) survives. Used ONLY by the 'Align playlists' path with + ids already present in the playlist — never adds a new track. + + NOTE: like every createPlaylist+playlistId overwrite (same as replace-mode + sync), Navidrome may not carry the playlist's comment forward — the caller + re-applies it if needed.""" + if not self.ensure_connection(): + return False + ids = [str(i) for i in (ordered_song_ids or []) if str(i)] + if not ids: + logger.warning(f"rewrite_playlist_order: no song ids for '{name}'") + return False + try: + params = {'name': name, 'songId': ids, 'playlistId': playlist_id} + response = self._make_request('createPlaylist', params) + if response and response.get('status') == 'ok': + logger.info(f"Aligned Navidrome playlist '{name}' order ({len(ids)} tracks)") + return True + logger.error(f"rewrite_playlist_order failed for '{name}'") + return False + except Exception as e: + logger.error(f"Error rewriting Navidrome playlist order '{name}': {e}") + return False + def copy_playlist(self, source_name: str, target_name: str) -> bool: """Copy a playlist to create a backup""" if not self.ensure_connection(): diff --git a/core/sync/playlist_edit.py b/core/sync/playlist_edit.py index a3423302..aacb7e7e 100644 --- a/core/sync/playlist_edit.py +++ b/core/sync/playlist_edit.py @@ -137,6 +137,36 @@ def plan_playlist_append( return out +def plan_align_rewrite(current_ids, matched_ids, keep_extras: bool = False): + """Plan the ordered server-track id list to rewrite a playlist as, to align its + ORDER to the source. Pure — no I/O, no metadata. Used only by the "Align + playlists" path (never the normal sync); it reshuffles tracks already on the + server by id and never adds one. + + ``matched_ids`` — server ids of the source-matched tracks, IN SOURCE ORDER + (the desired sequence). Every one MUST already be in the + playlist — align never injects a track that isn't there. + ``current_ids`` — the playlist's current server ids, in current server order. + ``keep_extras`` — True: server tracks not in ``matched_ids`` (extras) are + appended after the aligned block, in their current order. + False: extras are dropped (server mirrors the source). + + Returns the ordered id list, or ``None`` if any matched id isn't currently in + the playlist (stale editor data — the caller should reject and have the user + reload rather than write a list referencing a vanished track). + """ + current = [str(c) for c in current_ids] + current_set = set(current) + matched = [str(m) for m in matched_ids] + if any(m not in current_set for m in matched): + return None + ordered = list(matched) + if keep_extras: + matched_set = set(matched) + ordered.extend(c for c in current if c not in matched_set) + return ordered + + VALID_SYNC_MODES = ("replace", "append", "reconcile") @@ -158,6 +188,7 @@ __all__ = [ "remove_one_occurrence", "plan_playlist_reconcile", "plan_playlist_append", + "plan_align_rewrite", "normalize_sync_mode", "VALID_SYNC_MODES", ] diff --git a/tests/test_playlist_edit.py b/tests/test_playlist_edit.py index 7c402992..8c3fa749 100644 --- a/tests/test_playlist_edit.py +++ b/tests/test_playlist_edit.py @@ -4,12 +4,55 @@ from __future__ import annotations from core.sync.playlist_edit import ( normalize_sync_mode, + plan_align_rewrite, plan_playlist_add, plan_playlist_reconcile, remove_one_occurrence, ) +# ── plan_align_rewrite: "Align playlists" ordered rewrite (order-only) ───────── + +def test_align_mirror_reorders_and_drops_extras(): + # Server: [A, C, B, X(extra)]; source order wants [A, B, C]. Mirror => A,B,C, X dropped. + out = plan_align_rewrite(current_ids=["A", "C", "B", "X"], matched_ids=["A", "B", "C"], keep_extras=False) + assert out == ["A", "B", "C"] + + +def test_align_keep_extras_parks_them_at_end(): + out = plan_align_rewrite(current_ids=["A", "C", "B", "X"], matched_ids=["A", "B", "C"], keep_extras=True) + assert out == ["A", "B", "C", "X"] # X kept, after the aligned block + + +def test_align_keep_extras_preserves_extra_current_order(): + out = plan_align_rewrite(current_ids=["X1", "A", "X2", "B"], matched_ids=["A", "B"], keep_extras=True) + assert out == ["A", "B", "X1", "X2"] # extras in their existing server order + + +def test_align_rejects_when_matched_id_not_in_playlist(): + # Stale editor data: a matched id that's no longer on the server -> None (reject). + assert plan_align_rewrite(current_ids=["A", "B"], matched_ids=["A", "GONE"]) is None + + +def test_align_never_injects_foreign_track(): + # Every output id must already be in the playlist (order-only, never adds). + out = plan_align_rewrite(current_ids=["A", "B", "C"], matched_ids=["C", "A", "B"], keep_extras=True) + assert set(out) <= {"A", "B", "C"} + assert out == ["C", "A", "B"] # pure reorder, full membership + + +def test_align_handles_partial_membership_order_only(): + # Server is missing nothing relevant; matched is a subset (some source tracks + # missing on server). Mirror keeps only the present matched, in source order. + out = plan_align_rewrite(current_ids=["B", "A"], matched_ids=["A", "B"], keep_extras=False) + assert out == ["A", "B"] + + +def test_align_ids_coerced_to_str(): + out = plan_align_rewrite(current_ids=[1, 2, 3], matched_ids=[3, 1], keep_extras=True) + assert out == ["3", "1", "2"] + + # ── plan_playlist_add: link must not duplicate ──────────────────────────── def test_link_to_existing_track_does_not_insert(): diff --git a/web_server.py b/web_server.py index d6c6b991..6855d969 100644 --- a/web_server.py +++ b/web_server.py @@ -19759,6 +19759,51 @@ def get_server_playlist_tracks(playlist_id): return jsonify({"success": False, "error": str(e)}), 500 +@app.route('/api/server/playlist//align', methods=['POST']) +def server_playlist_align(playlist_id): + """Align a server playlist's ORDER to the source ('Align playlists'). + + Order-only and metadata-free: the client sends the matched server-track ids in + SOURCE order (`matched_ids`) plus the extras choice (`keep_extras`); the server + validates every id is currently in the playlist (so this can only reorder/drop + tracks already there — never inject one) and rewrites the playlist in that order + via the overwrite primitive. Does NOT touch membership-completeness — missing + tracks are the normal sync's job. Navidrome only for now.""" + try: + data = request.get_json() or {} + playlist_name = (data.get('playlist_name') or '').strip() + matched_ids = data.get('matched_ids') or [] + keep_extras = bool(data.get('keep_extras', False)) + if not playlist_name: + return jsonify({"success": False, "error": "playlist_name required"}), 400 + if not matched_ids: + 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'): + return jsonify({"success": False, + "error": "Align is only supported on Navidrome right now"}), 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)] + + from core.sync.playlist_edit import plan_align_rewrite + ordered = plan_align_rewrite(current_ids, matched_ids, keep_extras=keep_extras) + if ordered is None: + 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 not ok: + return jsonify({"success": False, "error": "Failed to rewrite playlist order"}), 500 + return jsonify({"success": True, "track_count": len(ordered), + "kept_extras": keep_extras}) + except Exception as e: + logger.error(f"Error aligning server playlist '{playlist_id}': {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + @app.route('/api/server/playlist//replace-track', methods=['POST']) def server_playlist_replace_track(playlist_id): """Replace a track in a server playlist. Rebuilds the playlist with the swap.""" diff --git a/webui/static/pages-extra.js b/webui/static/pages-extra.js index 6db43b79..9ccc3579 100644 --- a/webui/static/pages-extra.js +++ b/webui/static/pages-extra.js @@ -1533,6 +1533,25 @@ function _showServerOrder() { `).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 this playlist to the source order
+
+ + +
+
Missing tracks aren't added here — run a normal sync for those.
+
` : ''; + const overlay = document.createElement('div'); overlay.id = 'server-order-modal'; overlay.className = 'server-order-overlay'; @@ -1546,11 +1565,48 @@ function _showServerOrder() {
${rows || '
No server tracks.
'}
+ ${alignFoot} `; overlay.onclick = () => overlay.remove(); document.body.appendChild(overlay); } +// Align the server playlist's ORDER to the source (the "Align playlists" action). +// Sends the matched server-track ids in SOURCE order + the extras choice; the +// backend validates they're all in the playlist and rewrites. Order-only. +async function _alignPlaylist(keepExtras) { + const st = _serverEditorState; + if (!st || !st.playlistId) return; + const matchedIds = (Array.isArray(st.tracks) ? st.tracks : []) + .filter(t => t.match_status === 'matched' && t.server_track && t.server_track.id != null) + .map(t => String(t.server_track.id)); + if (!matchedIds.length) { + if (typeof showToast === 'function') showToast('Nothing to align', 'warning'); + return; + } + try { + const resp = await fetch(`/api/server/playlist/${st.playlistId}/align`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + playlist_name: st.playlistName || '', + matched_ids: matchedIds, + keep_extras: !!keepExtras, + }), + }); + const data = await resp.json(); + if (data && data.success) { + if (typeof showToast === 'function') showToast(`Playlist order aligned (${data.track_count} tracks)`, 'success'); + document.getElementById('server-order-modal')?.remove(); + _serverEditorRefresh(); + } else { + if (typeof showToast === 'function') showToast((data && data.error) || 'Align failed', 'error'); + } + } catch (e) { + if (typeof showToast === 'function') showToast('Align failed: ' + e.message, 'error'); + } +} + function _formatDurationMs(ms) { if (!ms) return ''; const s = Math.round(ms / 1000); diff --git a/webui/static/style.css b/webui/static/style.css index bda1c974..3aeefc48 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -58607,6 +58607,20 @@ tr.tag-diff-same { .server-order-title { font-size: 13px; 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 { padding: 14px 16px 16px; border-top: 1px solid rgba(255, 255, 255, 0.07); } +.server-order-foot-label { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.4px; + color: rgba(255, 255, 255, 0.5); margin-bottom: 10px; } +.server-order-actions { display: flex; gap: 10px; } +.server-align-btn { + flex: 1; display: flex; flex-direction: column; gap: 3px; text-align: left; + padding: 10px 12px; border-radius: 10px; cursor: pointer; + background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.12); + transition: background 0.15s ease, border-color 0.15s ease; +} +.server-align-btn:hover { background: rgba(76, 175, 80, 0.14); border-color: rgba(76, 175, 80, 0.5); } +.server-align-btn-t { font-size: 13px; font-weight: 700; color: #fff; } +.server-align-btn-d { font-size: 11px; color: rgba(255, 255, 255, 0.5); line-height: 1.35; } +.server-order-foot-note { font-size: 11px; color: rgba(255, 255, 255, 0.4); margin-top: 9px; } .server-editor-stat-label { font-size: 9px;