Align playlists: add Jellyfin support (in-place reorder via Move endpoint)

Completes align across all three servers. Jellyfin reorders in place: DELETE the
extra entries (Mirror) then POST /Playlists/{id}/Items/{entryId}/Move/{index} for
each desired track in ascending order — so the playlist's poster/name/Id survive
(no delete-recreate), same as Plex/Navidrome. Mirrors the existing reconcile path's
entry-id handling (PlaylistItemId via /Playlists/{id}/Items).

- jellyfin reorder_playlist() + get_playlist_track_ids(); reuses the shared, tested
  plan_align_rewrite planner (no new pure logic).
- /align endpoint + frontend gate now cover navidrome|plex|jellyfin.

UNTESTED LIVE: no Jellyfin instance to verify against (same status as the Navidrome
path). Plex is the only one confirmed working end-to-end so far.
This commit is contained in:
BoulderBadgeDad 2026-06-24 13:20:32 -07:00
parent bac5da9177
commit 49d3c77808
3 changed files with 81 additions and 4 deletions

View file

@ -1711,6 +1711,81 @@ class JellyfinClient(MediaServerClient):
logger.error(f"Error reconciling Jellyfin playlist '{playlist_name}': {e}")
return False
def get_playlist_track_ids(self, playlist_id: str) -> List[str]:
"""The playlist's current track ids (Item Ids), in current order. [] on miss."""
if not self.ensure_connection():
return []
try:
resp = self._make_request(f'/Playlists/{playlist_id}/Items', {'UserId': self.user_id})
if not resp:
return []
return [str(i.get('Id')) for i in resp.get('Items', []) if i.get('Id')]
except Exception as e:
logger.error(f"Error getting Jellyfin playlist track ids '{playlist_id}': {e}")
return []
def reorder_playlist(self, playlist_id, playlist_name: str, ordered_ids) -> bool:
"""In-place reorder a playlist to an exact ordered track-id list ('Align
playlists'). Removes any current entry whose track NOT in ``ordered_ids``
('Mirror source' drops extras; 'Keep extras' keeps them in the list), then
moves each desired track to its target index via the Jellyfin Move endpoint.
Operates on the existing playlist (DELETE EntryIds + Items/{entryId}/Move/{i})
so its poster, name and Id survive no delete/recreate."""
if not self.ensure_connection():
return False
try:
import requests
ordered = [str(i) for i in (ordered_ids or []) if str(i)]
ordered_set = set(ordered)
# Entries carry both the track Id and the PlaylistItemId (entry id);
# move/remove operate on the entry id.
entries = [] # (track_id, entry_id) in current order
resp = self._make_request(f'/Playlists/{playlist_id}/Items', {'UserId': self.user_id})
if resp:
for item in resp.get('Items', []):
tid = str(item.get('Id') or '')
eid = str(item.get('PlaylistItemId') or '')
if tid:
entries.append((tid, eid))
if not entries:
logger.error(f"Jellyfin reorder: no entries for playlist {playlist_id}")
return False
by_tid = {tid: eid for tid, eid in entries}
hdr = {'X-Emby-Token': self.api_key}
# Drop entries not in the desired list (extras, for Mirror source).
extra_eids = [eid for tid, eid in entries if tid not in ordered_set and eid]
for i in range(0, len(extra_eids), 100):
batch = extra_eids[i:i + 100]
r = requests.delete(
f"{self.base_url}/Playlists/{playlist_id}/Items",
params={'EntryIds': ','.join(batch)}, headers=hdr, timeout=30,
)
if r.status_code not in (200, 204):
logger.error(f"Jellyfin reorder remove failed: HTTP {r.status_code}")
return False
# Move each desired track to its target index, ascending — each move
# lands the item exactly at index i without disturbing 0..i-1.
for idx, tid in enumerate(ordered):
eid = by_tid.get(tid)
if not eid:
continue
r = requests.post(
f"{self.base_url}/Playlists/{playlist_id}/Items/{eid}/Move/{idx}",
headers=hdr, timeout=30,
)
if r.status_code not in (200, 204):
logger.warning(f"Jellyfin reorder move failed for {tid}: HTTP {r.status_code}")
return False
logger.info(f"Aligned Jellyfin playlist '{playlist_name}' order ({len(ordered)} tracks)")
return True
except Exception as e:
logger.error(f"Error reordering Jellyfin playlist '{playlist_name}': {e}")
return False
def update_playlist(self, playlist_name: str, tracks) -> bool:
"""Update an existing playlist or create it if it doesn't exist"""
if not self.ensure_connection():

View file

@ -19781,7 +19781,7 @@ def server_playlist_align(playlist_id):
active_server = config_manager.get_active_media_server()
client = media_server_engine.client(active_server) if active_server else None
if active_server not in ('navidrome', 'plex') or not client:
if active_server not in ('navidrome', 'plex', 'jellyfin') or not client:
return jsonify({"success": False,
"error": "Align isn't supported on this server yet"}), 400
@ -19789,8 +19789,10 @@ def server_playlist_align(playlist_id):
if active_server == '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)]
else: # plex
elif active_server == 'plex':
current_ids = client.get_playlist_track_ids(playlist_id, playlist_name)
else: # jellyfin
current_ids = client.get_playlist_track_ids(playlist_id)
from core.sync.playlist_edit import plan_align_rewrite
ordered = plan_align_rewrite(current_ids, matched_ids, keep_extras=keep_extras)
@ -19800,7 +19802,7 @@ def server_playlist_align(playlist_id):
if active_server == 'navidrome':
ok = client.rewrite_playlist_order(playlist_id, playlist_name, ordered)
else: # plex — in-place moveItem/removeItems reorder
else: # plex / jellyfin — in-place reorder
ok = client.reorder_playlist(playlist_id, playlist_name, ordered)
if not ok:
return jsonify({"success": False, "error": "Failed to reorder playlist"}), 500

View file

@ -1542,7 +1542,7 @@ function _showServerOrder() {
// Align actions — reorder the server playlist to the source order. Two choices
// for server-only "extra" tracks. Order-only: never adds the missing tracks
// (that's the normal sync's job). Supported where reorder is implemented.
const canAlign = serverType === 'navidrome' || serverType === 'plex';
const canAlign = serverType === 'navidrome' || serverType === 'plex' || serverType === 'jellyfin';
const alignFoot = canAlign ? `
<div class="server-order-foot">
<div class="server-order-foot-label">Align this playlist to the source order</div>