feat(#144): Native playlist support for Plex, Jellyfin, and Emby#554
Draft
dialmaster wants to merge 38 commits intodevfrom
Draft
feat(#144): Native playlist support for Plex, Jellyfin, and Emby#554dialmaster wants to merge 38 commits intodevfrom
dialmaster wants to merge 38 commits intodevfrom
Conversation
…gex, audio_format on new channel insert
Adds doPlaylistDownloads(playlist) which queries PlaylistVideo for non-ignored entries, skips already-downloaded videos, calls playlistModule.ensureSourceChannel for videos whose source channel is absent in the DB, then delegates to doSpecificDownloads with the remaining video URLs.
Add jellyfinEnabled, jellyfinUrl, jellyfinApiKey, jellyfinUserId, jellyfinVideoLibraryIds, embyEnabled, embyUrl, embyApiKey, embyUserId, and embyVideoLibraryIds to config.example.json so they are merged into existing configs on startup. Add jellyfinApiKey and embyApiKey to Pino redaction paths so they are stripped from all log output.
Previously serverRegistry only recognized Plex when config.plexUrl was a non-empty string. Most installations use the legacy plexIP/plexPort/plexViaHttps fields (with plexUrl empty), so Plex appeared 'not configured' to the playlist sync path even when fully set up. Delegate URL resolution to plexModule.getBaseUrl which handles all three input patterns plus the PLEX_URL env override, and pass the resolved URL into the adapter via a cloned config.
Plex, Jellyfin, and Emby all reject creating a playlist with zero items. When a user subscribes to a playlist, the initial sync fires before any videos are downloaded — itemIds is empty and createPlaylist would fail. Skip creation in that case; the post-download hook will sync again once videos exist. Replacing items on an existing playlist with an empty list is still allowed — that covers the 'user ignored all videos' case where the playlist should become empty on the media server.
Manually trigger download of all not-yet-downloaded videos for a playlist. Fire-and-forget: returns 202 immediately; downloadModule runs asynchronously and the existing afterDownloadHook handles playlist sync + M3U regen once videos finalize. Useful for testing the end-to-end flow without waiting on the cron, and the eventual frontend will wire a 'Download now' button to it.
…e channel URL channelModule.upsertChannel expects channelData.id (matching yt-dlp's metadata shape), not channelData.channel_id. ensureSourceChannel was passing channel_id, which caused a Sequelize 'WHERE parameter channel_id has invalid undefined value' error on the first playlist download. Also synthesize the canonical channel URL (https://www.youtube.com/channel/<UCxxx>) when only a channel_id is available — doPlaylistDownloads has only channel_id from the playlistvideo row, and yt-dlp resolves the synthesized URL correctly. Uploader name remains null until the user activates or refreshes the channel.
…doSpecificDownloads
doSpecificDownloads expects either an Express request (reads req.body.urls)
or a job-data object (reads .data.urls). doPlaylistDownloads was passing
a bare { urls } which matched neither and crashed with 'Cannot read
properties of undefined (reading urls)' on line 435.
Wrap the urls in { body: { urls } } to match the Express-request shape
that doSpecificDownloads handles. Update the unit test to assert on
body.urls so this regression can't slip through again.
…ENTINEL Auto-created channels had sub_folder=null, which resolveEffectiveSubfolder treats as 'root' (backwards compatibility), not 'use the configured default subfolder'. Users with a default subfolder configured (and a media-server library pointed at that subfolder) would find playlist-sourced files landing in the root of their media directory — where the media server can't see them. Seed the sentinel GLOBAL_DEFAULT_SENTINEL when the playlist itself has no default_sub_folder, so the channel inherits the user's configured default. An explicit playlist.default_sub_folder (when set) still wins.
resolveItemIdByFilepath in all three adapters compared the full filepath for equality. Youtarr sees its files at the container mount (e.g. /usr/src/app/data/...) while Plex/Jellyfin/Emby see them at whatever host path they've been configured with — so the prefixes practically always differ and strict equality never matches. Compare by basename instead. YouTube video IDs are embedded in the filenames yt-dlp writes (e.g. 'Title [abc123].mp4') and video IDs are globally unique, so basename is a reliable key across mount views.
…dows Node's path.basename() is OS-aware. On a Linux container it doesn't recognize '\' as a separator, so when Plex on Windows reports a path like Q:\Media\Channel\file.mp4, path.basename returns the whole string rather than just 'file.mp4'. Mismatch, resolution fails. Introduced extractBasename helper in baseAdapter.js that splits on either / or \ via regex. All three adapters use it. Works symmetrically for Windows-Plex + Linux-Youtarr, Linux-Plex + Windows-Youtarr, and same-OS combos.
…failure state Two issues surfaced during smoke testing: 1. Emby's POST /Playlists expects query params with Ids as a comma-delimited string, not a JSON body with an array (the Jellyfin shape). Sending the JSON-body form yields a 500 Internal Server Error on Emby. Use query params with CSV Ids, matching Emby's REST API. 2. If sync_to_<server> failed on a prior attempt, a playlist_sync_state row exists with last_error set but no server_playlist_id. The next sync would crash with a unique-constraint violation (playlist_id, server_type) when calling PlaylistSyncState.create. Update the existing row in place instead, so sync heals automatically once the underlying issue is fixed.
…cope On unclaimed Plex servers with 'allow unauthenticated access on this network' enabled, Plex Web uses no token and playlists are scoped to that anonymous session. Youtarr's configured plexApiKey maps to a different account, so playlists Youtarr creates are invisible in Plex Web even though they exist on the server. Add a plexPlaylistToken config override for playlist-scoped operations (resolveItemId, getPlaylistByName, createPlaylist, replacePlaylistItems): - undefined/null (default): falls back to plexApiKey — no behavior change - "" (empty string): sends requests with no X-Plex-Token, matching the anonymous session Plex Web uses on unauth-LAN servers - "some-token": uses that token for playlist scope, e.g. to route Youtarr's playlists through a non-admin user account testConnection and library scans continue to use plexApiKey since those are server-level operations unaffected by per-account playlist scoping.
…mode Empty-string semantics (null=fallback, ""=no-token) were easy to confuse — a user toggling the field might assume null and "" are equivalent and wonder why their playlists vanish. Introduce a distinctive sentinel value "UNCLAIMED_SERVER" that unambiguously requests anonymous playlist calls, while null/""/undefined all fall back to plexApiKey. Only users running unclaimed Plex dev servers with 'allow unauthenticated access on this network' would ever need the sentinel — the standard claimed-server OAuth flow continues to use plexApiKey as before.
Jellyfin's DELETE /Playlists/{id}/Items endpoint returns 400 'Error processing
request.' on current versions regardless of param casing (tested EntryIds,
entryIds, with and without userId, with header auth and api_key query).
Version-specific and not worth chasing. Emby has the same quirk per shared
API lineage.
Replace semantics now: delete the entire playlist via DELETE /Items/{id}
and recreate it fresh. The new server-side id is returned from
replacePlaylistItems so mediaServerSync can update playlist_sync_state
to track the new id.
Plex continues to replace in place (delete items + PUT uri, same id);
return { id } for a consistent contract across adapters. Signature
now takes opts with { name, public } so recreating adapters have what
they need to construct the replacement.
…stItems When the stored sync-state id points at a playlist that no longer exists on the media server (user deleted it manually, server state drifted, created under a different auth context), DELETE returns 404/400/403 and the sync would crash with that error recorded but no repair. All three adapters now catch the delete failure, log a warning, and fall back to createPlaylist — so sync heals automatically on the next run rather than requiring manual DB intervention.
… ChannelManager -> Subscriptions Work-in-progress checkpoint for native playlist support across Plex, Jellyfin, and Emby. Feature is incomplete. - Add PlaylistPage with sync chips, video table, settings dialog, and a "no media server configured" warning; wire route /playlist/:id - Rename ChannelManager -> Subscriptions across components, routes (/channels -> /subscriptions, with redirects), tests, stories, and nav labels (Channels -> Channels & Playlists); selection state now also activates on /playlist/* paths - Add Subscriptions filter, AddPlaylistDialog, and PlaylistListBlock for managing tracked playlists alongside channels - Add Configuration sections for Jellyfin and Emby (shared MediaServerPlaylistSection with test-connection and fetch-users helpers); register settings routes /settings/jellyfin and /settings/emby - Add advanced "Plex Playlist Token" override field to PlexIntegrationSection (UNCLAIMED_SERVER sentinel or per-user token) - Extend CONFIG_FIELDS with plexPlaylistToken plus jellyfin*/emby* (enabled, url, apiKey, userId, videoLibraryIds) - Add hooks: usePlaylistDetail, usePlaylistList, usePlaylistMutations, useMediaServerStatus (60s poll); add types/playlist.ts - Backend: add GET /api/playlists/:playlistId; extend GET /api/playlists/:playlistId/videos to return denormalized metadata (title, thumbnail, duration, channel_name, published_at) overlaid with downloaded-status from Videos - playlistModule: persist denormalized video metadata into playlistvideos on fetch; backfill playlist-level thumbnail from first entry's hqdefault when null (covers --playlist-items 0 case) - Migration 20260419063113: add title/thumbnail/duration/channel_name/published_at columns to playlistvideos (idempotent via describeTable) - Docs: new docs/MEDIA_SERVER_PLAYLISTS.md; update CLAUDE.md, docs/CONFIG.md, docs/DATABASE.md, docs/MEDIA_SERVERS.md to mention new models, routes, modules, and config fields
Contributor
📊 Test Coverage ReportBackend Coverage
Frontend Coverage
Coverage Requirements
Coverage report generated for commit 5ecf6f9 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Status
Work in progress. Not ready to merge. Opening for visibility.
This probably won't be ready for a few days to a week, there is still a lot left to do :)
What this adds
Youtarr can now subscribe to YouTube playlists and mirror them as native playlists in Plex, Jellyfin, and Emby (with an
.m3ufallback for everything else). This PR is the front-end and wiring layer on top of the playlist sync engine that landed in earlier commits on this branch.Highlights
/playlist/:id) with per-server sync status, video table, and settings dialog.Channelssection renamed toChannels & Playlists. The oldChannelManagercomponent is nowSubscriptionsand lives at/subscriptions(old/channelsURLs redirect).