From 49d3c77808921765ab0c3337390b4ef5b0a6a2b9 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 24 Jun 2026 13:20:32 -0700 Subject: [PATCH] Align playlists: add Jellyfin support (in-place reorder via Move endpoint) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- core/jellyfin_client.py | 75 +++++++++++++++++++++++++++++++++++++ web_server.py | 8 ++-- webui/static/pages-extra.js | 2 +- 3 files changed, 81 insertions(+), 4 deletions(-) diff --git a/core/jellyfin_client.py b/core/jellyfin_client.py index f24020d5..be4a7a26 100644 --- a/core/jellyfin_client.py +++ b/core/jellyfin_client.py @@ -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(): diff --git a/web_server.py b/web_server.py index b2296eec..594df17a 100644 --- a/web_server.py +++ b/web_server.py @@ -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 diff --git a/webui/static/pages-extra.js b/webui/static/pages-extra.js index 9581a379..143f656a 100644 --- a/webui/static/pages-extra.js +++ b/webui/static/pages-extra.js @@ -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 ? `
Align this playlist to the source order