Align playlists: reorder a server playlist to the source order (Navidrome)
Adds the 'Align playlists' action to the out-of-order modal — a dedicated,
order-only write path that does NOT touch the normal sync. Subsonic has no
per-track move, so it overwrites the song list in source order via createPlaylist
+ playlistId (same primitive replace-mode uses; identity/id preserved).
- plan_align_rewrite() (pure, tested): matched server ids in source order; every
one must already be in the playlist (never injects a track); extras either
dropped ('Mirror source') or parked at the end ('Keep extras'); returns None on
stale data so a vanished track can't be written.
- navidrome rewrite_playlist_order() primitive (raw ordered ids).
- /api/server/playlist/<id>/align: validates ids are in the live playlist, then
rewrites. Navidrome-only for now (Plex/Jellyfin reorder = follow-up).
- modal gets two explained options; missing tracks are NOT added (normal sync's
job) and that's stated. Metadata-free by design — it only reshuffles existing
server ids, so there's no sync-parity surface.
Open: confirm createPlaylist+playlistId preserves the playlist comment/image on a
live Navidrome (same risk as replace mode); add a re-apply step if it doesn't.
This commit is contained in:
parent
ecd2500c39
commit
606d1f951d
6 changed files with 217 additions and 0 deletions
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -19759,6 +19759,51 @@ def get_server_playlist_tracks(playlist_id):
|
|||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/server/playlist/<playlist_id>/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/<playlist_id>/replace-track', methods=['POST'])
|
||||
def server_playlist_replace_track(playlist_id):
|
||||
"""Replace a track in a server playlist. Rebuilds the playlist with the swap."""
|
||||
|
|
|
|||
|
|
@ -1533,6 +1533,25 @@ function _showServerOrder() {
|
|||
</div>
|
||||
</div>`).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' ? `
|
||||
<div class="server-order-foot">
|
||||
<div class="server-order-foot-label">Align this playlist to the source order</div>
|
||||
<div class="server-order-actions">
|
||||
<button type="button" class="server-align-btn" onclick="_alignPlaylist(false)">
|
||||
<span class="server-align-btn-t">Mirror source</span>
|
||||
<span class="server-align-btn-d">reorder to match the source · remove server-only tracks</span>
|
||||
</button>
|
||||
<button type="button" class="server-align-btn" onclick="_alignPlaylist(true)">
|
||||
<span class="server-align-btn-t">Keep extras</span>
|
||||
<span class="server-align-btn-d">reorder to match the source · keep server-only tracks at the end</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="server-order-foot-note">Missing tracks aren't added here — run a normal sync for those.</div>
|
||||
</div>` : '';
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'server-order-modal';
|
||||
overlay.className = 'server-order-overlay';
|
||||
|
|
@ -1546,11 +1565,48 @@ function _showServerOrder() {
|
|||
<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>
|
||||
${alignFoot}
|
||||
</div>`;
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue