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:
parent
bac5da9177
commit
49d3c77808
3 changed files with 81 additions and 4 deletions
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue