From ecd2500c39bda7b556e7b75c79c2b5764c9c856f Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 24 Jun 2026 12:15:47 -0700 Subject: [PATCH] Server playlist editor: surface 'accurate but out of order' + read-only server-order view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor renders the server column in SOURCE order (reconcile_playlist pairs each server track to its source row), so a reordered-but-same-membership playlist read as '5 matched / in sync' when Navidrome's real order actually differed — the reorder never reaching the server was invisible. - compute_order_status() (pure, tested): matched tracks' server positions must be strictly ascending in source order; uses RELATIVE order so missing/extra tracks never false-flag. reconcile entries now carry server_index (additive). - endpoint returns order_status + server_order (the server's actual sequence). - editor shows an amber 'out of order' badge on the server column when membership matches but sequence differs, opening a read-only modal of the real server order. One-way: source order stays the source of truth; no server-side editing. Tests reproduce the reported 'Real Love Baby moved to #2' case + guard against false-flagging on missing/extra. The actual 'sync order' WRITE is a separate follow-up (membership/extra semantics + live identity-preservation test pending). --- core/sync/playlist_reconcile.py | 35 ++++++++++++++++- tests/test_playlist_reconcile.py | 66 +++++++++++++++++++++++++++++++- web_server.py | 19 ++++++++- webui/static/pages-extra.js | 55 +++++++++++++++++++++++++- webui/static/style.css | 47 +++++++++++++++++++++++ 5 files changed, 218 insertions(+), 4 deletions(-) diff --git a/core/sync/playlist_reconcile.py b/core/sync/playlist_reconcile.py index eb402b6d..4cea1b0a 100644 --- a/core/sync/playlist_reconcile.py +++ b/core/sync/playlist_reconcile.py @@ -112,6 +112,7 @@ def reconcile_playlist( 'match_status': 'matched', 'confidence': 1.0, 'override': True, + 'server_index': j, # position in the server playlist (for order-status) }) continue @@ -134,6 +135,7 @@ def reconcile_playlist( 'server_track': server_tracks[best_idx], 'match_status': 'matched', 'confidence': 1.0, + 'server_index': best_idx, }) else: idx = len(combined) @@ -142,6 +144,7 @@ def reconcile_playlist( 'server_track': None, 'match_status': 'missing', 'confidence': 0.0, + 'server_index': None, }) # Carry the canonical artist for the fuzzy pass. unmatched_source.append((idx, src_entry, _canon_artist or src_artist)) @@ -168,6 +171,7 @@ def reconcile_playlist( 'server_track': server_tracks[best_j], 'match_status': 'matched', 'confidence': round(best_score, 3), + 'server_index': best_j, } # Extra: server tracks no source claimed. @@ -178,6 +182,7 @@ def reconcile_playlist( 'server_track': svr, 'match_status': 'extra', 'confidence': 0.0, + 'server_index': j, }) # #766: a source row with no art of its own (e.g. a YouTube source, which @@ -194,4 +199,32 @@ def reconcile_playlist( return combined -__all__ = ["reconcile_playlist", "norm_title"] +def compute_order_status(combined: List[Dict[str, Any]]) -> Dict[str, Any]: + """Whether the server's MATCHED tracks sit in the same *relative* order as the + source. Pure. + + Reads each matched entry's ``server_index`` (its position in the server + playlist) off the combined view — which is already in source order — and checks + that sequence is strictly ascending. Relative order is used on purpose: missing + and extra tracks shift absolute positions, so comparing positions directly would + false-flag any playlist that isn't a perfect 1:1. The model is one-way (source + order is truth; the server may have drifted), so we only ever report whether the + server is *behind* the source order — never the reverse. + + Returns ``{matched, in_order, out_of_order}``. ``out_of_order`` is True only when + there are >= 2 matched tracks AND their server positions aren't ascending — so a + playlist with 0 or 1 matches (nothing to compare) is never flagged. + """ + positions = [ + e['server_index'] for e in combined + if e.get('match_status') == 'matched' and e.get('server_index') is not None + ] + in_order = all(a < b for a, b in zip(positions, positions[1:], strict=False)) + return { + 'matched': len(positions), + 'in_order': in_order, + 'out_of_order': len(positions) >= 2 and not in_order, + } + + +__all__ = ["reconcile_playlist", "compute_order_status", "norm_title"] diff --git a/tests/test_playlist_reconcile.py b/tests/test_playlist_reconcile.py index 0966f74c..2d9f99df 100644 --- a/tests/test_playlist_reconcile.py +++ b/tests/test_playlist_reconcile.py @@ -8,7 +8,7 @@ source_track_id echo (Bug B), and parity with the original three-pass behavior from __future__ import annotations -from core.sync.playlist_reconcile import norm_title, reconcile_playlist +from core.sync.playlist_reconcile import compute_order_status, norm_title, reconcile_playlist def _src(name, artist, sid="", **kw): @@ -160,3 +160,67 @@ def test_norm_title_helper_parity(): assert norm_title("Stay (feat. X)") == "stay" assert norm_title("Song (2019 Remaster)") == "song" assert norm_title("Album (Deluxe Edition)") == "album" + + +# ── order status: server playlist accurate-but-out-of-order detection ───────── +# The editor renders the server column in SOURCE order, so a reordered-but-same- +# membership playlist used to read "in sync" when the real Navidrome order differed. +# compute_order_status surfaces that drift (one-way: source order is truth). + +def test_reconcile_attaches_server_index_to_matched(): + source = [_src("Yellow", "Coldplay", "s1")] + server = [_svr("Filler", "X", "nv0"), _svr("Yellow", "Coldplay", "nv1")] + combined = reconcile_playlist(source, server) + matched = [c for c in combined if c["match_status"] == "matched"][0] + assert matched["server_index"] == 1 # Yellow is at server position 1 + + +def test_in_order_when_server_matches_source_sequence(): + titles = ["Mandinka", "Real Love Baby", "Liquid Indian", "Heaven or Las Vegas", "hospital beach"] + source = [_src(t, "A", f"s{i}") for i, t in enumerate(titles)] + server = [_svr(t, "A", f"nv{i}") for i, t in enumerate(titles)] # same order + status = compute_order_status(reconcile_playlist(source, server)) + assert status == {"matched": 5, "in_order": True, "out_of_order": False} + + +def test_out_of_order_reproduces_real_love_baby_case(): + # Source (Spotify): Real Love Baby at position 2. Server (Navidrome): still last. + src_titles = ["Mandinka", "Real Love Baby", "Liquid Indian", "Heaven or Las Vegas", "hospital beach"] + svr_titles = ["Mandinka", "Liquid Indian", "Heaven or Las Vegas", "hospital beach", "Real Love Baby"] + source = [_src(t, "A", f"s{i}") for i, t in enumerate(src_titles)] + server = [_svr(t, "A", f"nv{i}") for i, t in enumerate(svr_titles)] + status = compute_order_status(reconcile_playlist(source, server)) + assert status["matched"] == 5 + assert status["out_of_order"] is True # the bug: looked synced, wasn't + + +def test_missing_tracks_do_not_false_flag_out_of_order(): + # 2 tracks missing on the server, but the present ones are in the right relative + # order -> NOT out of order (membership is a separate axis). + source = [_src(t, "A", f"s{i}") for i, t in enumerate(["one", "two", "three", "four"])] + server = [_svr("one", "A", "nv0"), _svr("three", "A", "nv1")] # two/four missing, order ok + status = compute_order_status(reconcile_playlist(source, server)) + assert status["matched"] == 2 + assert status["out_of_order"] is False + + +def test_missing_and_shuffled_still_flags_out_of_order(): + source = [_src(t, "A", f"s{i}") for i, t in enumerate(["one", "two", "three", "four"])] + server = [_svr("three", "A", "nv0"), _svr("one", "A", "nv1")] # present pair is reversed + status = compute_order_status(reconcile_playlist(source, server)) + assert status["matched"] == 2 + assert status["out_of_order"] is True + + +def test_extras_ignored_for_order(): + # An extra server track (not in source) must not affect the order verdict. + source = [_src("a", "A", "s0"), _src("b", "A", "s1")] + server = [_svr("a", "A", "nv0"), _svr("zzz extra", "A", "nv1"), _svr("b", "A", "nv2")] + status = compute_order_status(reconcile_playlist(source, server)) + assert status["matched"] == 2 and status["out_of_order"] is False + + +def test_fewer_than_two_matches_never_out_of_order(): + assert compute_order_status([])["out_of_order"] is False + one = reconcile_playlist([_src("a", "A", "s0")], [_svr("a", "A", "nv0")]) + assert compute_order_status(one)["out_of_order"] is False diff --git a/web_server.py b/web_server.py index a282de9a..d6c6b991 100644 --- a/web_server.py +++ b/web_server.py @@ -19697,7 +19697,7 @@ def get_server_playlist_tracks(playlist_id): # core.sync.playlist_reconcile (pure + tested) — fixes #768 (YouTube # "Artist - Title" sources now match, and source_track_id is echoed # back so manual "Find & add" overrides persist). - from core.sync.playlist_reconcile import reconcile_playlist + from core.sync.playlist_reconcile import compute_order_status, reconcile_playlist # Pass 0: User-confirmed match overrides from sync_match_cache. # When a user previously picked a local file via "Find & Add", @@ -19729,6 +19729,21 @@ def get_server_playlist_tracks(playlist_id): combined = reconcile_playlist(source_tracks, server_tracks, _override_pairs) + # Order status: the editor renders the server column in SOURCE order, so a + # reordered-but-same-membership playlist reads "in sync" when Navidrome's real + # order differs. Surface that (one-way: source order is truth). `server_order` + # is the server's ACTUAL sequence, for the read-only "view server order" view. + order_status = compute_order_status(combined) + server_order = [ + { + "title": t.get("title"), + "artist": t.get("artist"), + "thumb": t.get("thumb"), + "id": t.get("id"), + } + for t in server_tracks if isinstance(t, dict) + ] + return jsonify({ "success": True, "server_type": active_server, @@ -19736,6 +19751,8 @@ def get_server_playlist_tracks(playlist_id): "tracks": combined, "server_track_count": len(server_tracks), "source_track_count": len(source_tracks), + "order_status": order_status, + "server_order": server_order, }) except Exception as e: logger.error(f"Error getting server playlist tracks: {e}") diff --git a/webui/static/pages-extra.js b/webui/static/pages-extra.js index 4e2633a9..6db43b79 100644 --- a/webui/static/pages-extra.js +++ b/webui/static/pages-extra.js @@ -1424,6 +1424,11 @@ async function _openServerCompareView(playlistId, playlistName, mirroredPlaylist _serverEditorState.tracks = data.tracks || []; _serverEditorState.serverType = data.server_type; + // Order status: the columns render in SOURCE order, so a same-tracks-but- + // reordered server playlist looks in-sync. order_status flags that drift; + // server_order is the server's ACTUAL sequence for the read-only view. + _serverEditorState.orderStatus = data.order_status || null; + _serverEditorState.serverOrder = data.server_order || []; const tracks = _serverEditorState.tracks; const serverLabel = data.server_type ? data.server_type.charAt(0).toUpperCase() + data.server_type.slice(1) : 'Server'; @@ -1456,7 +1461,19 @@ async function _openServerCompareView(playlistId, playlistName, mirroredPlaylist if (srcCountEl) srcCountEl.textContent = `${data.source_track_count || 0} tracks`; if (svrIconEl) svrIconEl.textContent = serverIconMap[data.server_type] || '💻'; if (svrLabelEl) svrLabelEl.textContent = serverLabel; - if (svrCountEl) svrCountEl.textContent = `${data.server_track_count || 0} tracks`; + if (svrCountEl) { + const os = _serverEditorState.orderStatus; + if (os && os.out_of_order) { + // Accurate membership but different order than the source. Read-only: + // source order is the source of truth; the badge opens the real order. + svrCountEl.innerHTML = `${data.server_track_count || 0} tracks ` + + ``; + } else { + svrCountEl.textContent = `${data.server_track_count || 0} tracks`; + } + } // Render columns _renderCompareColumns(tracks); @@ -1498,6 +1515,42 @@ function _updateCompareStats(tracks) { if (footer) footer.textContent = `${matched}/${matched + missing} matched${extra > 0 ? ` · ${extra} extra on server` : ''}`; } +// Read-only view of the server playlist's ACTUAL order. Source order is the source +// of truth; this just lets the user SEE how the server currently differs (no editing). +function _showServerOrder() { + const esc = typeof _esc === 'function' ? _esc : s => s; + const order = (_serverEditorState && _serverEditorState.serverOrder) || []; + const serverType = (_serverEditorState && _serverEditorState.serverType) || 'server'; + const serverLabel = serverType.charAt(0).toUpperCase() + serverType.slice(1); + + document.getElementById('server-order-modal')?.remove(); + const rows = order.map((t, i) => ` +
+ ${i + 1} +
+ ${esc(t.title || 'Unknown')} + ${esc(t.artist || '')} +
+
`).join(''); + + const overlay = document.createElement('div'); + overlay.id = 'server-order-modal'; + overlay.className = 'server-order-overlay'; + overlay.innerHTML = ` +
+
+
+
${esc(serverLabel)} playlist order
+
the actual order on your server · source order stays the source of truth
+
+ +
+
${rows || '
No server tracks.
'}
+
`; + overlay.onclick = () => overlay.remove(); + document.body.appendChild(overlay); +} + 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 47a0e66c..bda1c974 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -58561,6 +58561,53 @@ tr.tag-diff-same { .server-editor-stat-num.missing { color: #ef5350; } .server-editor-stat-num.extra { color: #ffb74d; } +/* "out of order" badge — server playlist has the right tracks but a different + sequence than the source. Opens a read-only view of the real server order. */ +.server-order-badge { + margin-left: 8px; padding: 2px 9px; border-radius: 999px; cursor: pointer; + font-size: 11px; font-weight: 700; letter-spacing: 0.2px; white-space: nowrap; + color: #ffb74d; background: rgba(255, 183, 77, 0.12); + border: 1px solid rgba(255, 183, 77, 0.4); + transition: background 0.15s ease; +} +.server-order-badge:hover { background: rgba(255, 183, 77, 0.22); } + +/* read-only server-order modal */ +.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); +} +.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; +} +.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); +} +.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-close { + background: none; border: none; color: rgba(255, 255, 255, 0.5); font-size: 22px; + line-height: 1; cursor: pointer; padding: 0 4px; +} +.server-order-close:hover { color: #fff; } +.server-order-list { overflow-y: auto; padding: 8px 8px 12px; } +.server-order-row { + display: flex; align-items: center; gap: 12px; padding: 8px 10px; border-radius: 8px; +} +.server-order-row:nth-child(odd) { background: rgba(255, 255, 255, 0.02); } +.server-order-num { + flex: 0 0 24px; text-align: right; font-size: 12px; font-weight: 700; + color: rgba(255, 255, 255, 0.35); +} +.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-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-editor-stat-label { font-size: 9px; text-transform: uppercase;