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',
|
'match_status': 'matched',
|
||||||
'confidence': 1.0,
|
'confidence': 1.0,
|
||||||
'override': True,
|
'override': True,
|
||||||
|
'server_index': j, # position in the server playlist (for order-status)
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -134,6 +135,7 @@ def reconcile_playlist(
|
||||||
'server_track': server_tracks[best_idx],
|
'server_track': server_tracks[best_idx],
|
||||||
'match_status': 'matched',
|
'match_status': 'matched',
|
||||||
'confidence': 1.0,
|
'confidence': 1.0,
|
||||||
|
'server_index': best_idx,
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
idx = len(combined)
|
idx = len(combined)
|
||||||
|
|
@ -142,6 +144,7 @@ def reconcile_playlist(
|
||||||
'server_track': None,
|
'server_track': None,
|
||||||
'match_status': 'missing',
|
'match_status': 'missing',
|
||||||
'confidence': 0.0,
|
'confidence': 0.0,
|
||||||
|
'server_index': None,
|
||||||
})
|
})
|
||||||
# Carry the canonical artist for the fuzzy pass.
|
# Carry the canonical artist for the fuzzy pass.
|
||||||
unmatched_source.append((idx, src_entry, _canon_artist or src_artist))
|
unmatched_source.append((idx, src_entry, _canon_artist or src_artist))
|
||||||
|
|
@ -168,6 +171,7 @@ def reconcile_playlist(
|
||||||
'server_track': server_tracks[best_j],
|
'server_track': server_tracks[best_j],
|
||||||
'match_status': 'matched',
|
'match_status': 'matched',
|
||||||
'confidence': round(best_score, 3),
|
'confidence': round(best_score, 3),
|
||||||
|
'server_index': best_j,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Extra: server tracks no source claimed.
|
# Extra: server tracks no source claimed.
|
||||||
|
|
@ -178,6 +182,7 @@ def reconcile_playlist(
|
||||||
'server_track': svr,
|
'server_track': svr,
|
||||||
'match_status': 'extra',
|
'match_status': 'extra',
|
||||||
'confidence': 0.0,
|
'confidence': 0.0,
|
||||||
|
'server_index': j,
|
||||||
})
|
})
|
||||||
|
|
||||||
# #766: a source row with no art of its own (e.g. a YouTube source, which
|
# #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
|
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 __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):
|
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("Stay (feat. X)") == "stay"
|
||||||
assert norm_title("Song (2019 Remaster)") == "song"
|
assert norm_title("Song (2019 Remaster)") == "song"
|
||||||
assert norm_title("Album (Deluxe Edition)") == "album"
|
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
|
# core.sync.playlist_reconcile (pure + tested) — fixes #768 (YouTube
|
||||||
# "Artist - Title" sources now match, and source_track_id is echoed
|
# "Artist - Title" sources now match, and source_track_id is echoed
|
||||||
# back so manual "Find & add" overrides persist).
|
# 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.
|
# Pass 0: User-confirmed match overrides from sync_match_cache.
|
||||||
# When a user previously picked a local file via "Find & Add",
|
# 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)
|
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({
|
return jsonify({
|
||||||
"success": True,
|
"success": True,
|
||||||
"server_type": active_server,
|
"server_type": active_server,
|
||||||
|
|
@ -19736,6 +19751,8 @@ def get_server_playlist_tracks(playlist_id):
|
||||||
"tracks": combined,
|
"tracks": combined,
|
||||||
"server_track_count": len(server_tracks),
|
"server_track_count": len(server_tracks),
|
||||||
"source_track_count": len(source_tracks),
|
"source_track_count": len(source_tracks),
|
||||||
|
"order_status": order_status,
|
||||||
|
"server_order": server_order,
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting server playlist tracks: {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.tracks = data.tracks || [];
|
||||||
_serverEditorState.serverType = data.server_type;
|
_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 tracks = _serverEditorState.tracks;
|
||||||
const serverLabel = data.server_type ? data.server_type.charAt(0).toUpperCase() + data.server_type.slice(1) : 'Server';
|
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 (srcCountEl) srcCountEl.textContent = `${data.source_track_count || 0} tracks`;
|
||||||
if (svrIconEl) svrIconEl.textContent = serverIconMap[data.server_type] || '💻';
|
if (svrIconEl) svrIconEl.textContent = serverIconMap[data.server_type] || '💻';
|
||||||
if (svrLabelEl) svrLabelEl.textContent = serverLabel;
|
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
|
// Render columns
|
||||||
_renderCompareColumns(tracks);
|
_renderCompareColumns(tracks);
|
||||||
|
|
@ -1498,6 +1515,42 @@ function _updateCompareStats(tracks) {
|
||||||
if (footer) footer.textContent = `${matched}/${matched + missing} matched${extra > 0 ? ` · ${extra} extra on server` : ''}`;
|
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) {
|
function _formatDurationMs(ms) {
|
||||||
if (!ms) return '';
|
if (!ms) return '';
|
||||||
const s = Math.round(ms / 1000);
|
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.missing { color: #ef5350; }
|
||||||
.server-editor-stat-num.extra { color: #ffb74d; }
|
.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 {
|
.server-editor-stat-label {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue