Server playlist editor: surface 'accurate but out of order' + read-only server-order view

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).
This commit is contained in:
BoulderBadgeDad 2026-06-24 12:15:47 -07:00
parent 462a722cce
commit ecd2500c39
5 changed files with 218 additions and 4 deletions

View file

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

View file

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

View file

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

View file

@ -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 ` +
`<button type="button" class="server-order-badge" onclick="_showServerOrder()" ` +
`title="These tracks match the source, but the playlist is in a different order on ${serverLabel}. Click to view the actual server order.">` +
`&#9888; out of order</button>`;
} 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) => `
<div class="server-order-row">
<span class="server-order-num">${i + 1}</span>
<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('');
const overlay = document.createElement('div');
overlay.id = 'server-order-modal';
overlay.className = 'server-order-overlay';
overlay.innerHTML = `
<div class="server-order-dialog" onclick="event.stopPropagation()">
<div class="server-order-head">
<div>
<div class="server-order-h1">${esc(serverLabel)} playlist order</div>
<div class="server-order-sub">the actual order on your server · source order stays the source of truth</div>
</div>
<button type="button" class="server-order-close" onclick="document.getElementById('server-order-modal').remove()">&times;</button>
</div>
<div class="server-order-list">${rows || '<div class="server-order-empty">No server tracks.</div>'}</div>
</div>`;
overlay.onclick = () => overlay.remove();
document.body.appendChild(overlay);
}
function _formatDurationMs(ms) {
if (!ms) return '';
const s = Math.round(ms / 1000);

View file

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