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:
parent
462a722cce
commit
ecd2500c39
5 changed files with 218 additions and 4 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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.">` +
|
||||
`⚠ 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()">×</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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue