Skip to content

feat(#144): Native playlist support for Plex, Jellyfin, and Emby#554

Draft
dialmaster wants to merge 38 commits intodevfrom
feat/144-add-playlist-support
Draft

feat(#144): Native playlist support for Plex, Jellyfin, and Emby#554
dialmaster wants to merge 38 commits intodevfrom
feat/144-add-playlist-support

Conversation

@dialmaster
Copy link
Copy Markdown
Collaborator

@dialmaster dialmaster commented Apr 19, 2026

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 .m3u fallback 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

  • New Playlist detail page (/playlist/:id) with per-server sync status, video table, and settings dialog.
  • Channels section renamed to Channels & Playlists. The old ChannelManager component is now Subscriptions and lives at /subscriptions (old /channels URLs redirect).
  • New Settings pages for Jellyfin and Emby (test connection, fetch users, pick library IDs), plus an advanced Plex playlist-token override.
  • Backend: playlist detail endpoint, denormalized playlist video metadata so the page renders before videos are downloaded, and a migration to back the new columns.

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
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 19, 2026

📊 Test Coverage Report

Backend Coverage

Type Coverage Status
Lines 81.99% 🟢
Statements 81.77% 🟢
Functions 82.75% 🟢
Branches 74.39% 🟡

Frontend Coverage

Type Coverage Status
Lines 83.03% 🟢
Statements 81.65% 🟢
Functions 73.01% 🟡
Branches 75.29% 🟡

Coverage Requirements

  • Minimum threshold: 70% line coverage
  • Backend: ✅ Passes
  • Frontend: ✅ Passes

Coverage report generated for commit 5ecf6f9

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant