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:
BoulderBadgeDad 2026-06-24 12:58:01 -07:00
parent ecd2500c39
commit 606d1f951d
6 changed files with 217 additions and 0 deletions

View file

@ -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():

View file

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

View file

@ -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():

View file

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

View file

@ -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 &middot; 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 &middot; keep server-only tracks at the end</span>
</button>
</div>
<div class="server-order-foot-note">Missing tracks aren't added here &mdash; 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()">&times;</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);

View file

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