yt-plex: An Arr for Everything That Isn't a Torrent
Why: Damien wanted an arr-style web page for YouTube (and other yt-dlp) content: paste URLs, tag each with a library, hit Go, and have the files land in Plex already named correctly, including YouTube shows that TVDB tracks as real series.
We built yt-plex, a small FastAPI app at /home/plex/yt-plex. It runs as a systemd user service on port 8430. The page is a queue. You paste a URL and it immediately runs a yt-dlp metadata lookup (title, thumbnail, description, upload date, available resolutions, approximate size), then a fresh input appears underneath for the next URL. Each row has a library dropdown, a quality picker, an editable name box and its own Go button, and Go All at the bottom sweeps up everything not yet started. Progress streams live to the page.
1. How it works
- Downloads go to
staging/<id>/first. The move into the library happens at the end and reads the name box at that moment, so renaming mid-download just works. - Every item gets its own folder. Non-TV:
<Library>/Title (Year)/Title (Year).mp4+.en.srt. The thumbnail is embedded in the MP4 and the sidecar JPG is discarded. - Finished rows stay editable. Renaming or changing library moves the whole folder and renames the files inside, then asks Plex for a partial scan of both the old and new locations. A custom name survives a non-TV → non-TV move. Switching into or out of TV regenerates the name, because the two naming schemes differ.
- Libraries are a fixed list in
ytplex/config.py: TV Shows (/media/plex2/TV), then Movies, Documentaries, Live, Rare, Standup, Plexxx (/media/plex1/Pr0n) and VR on the DAS. The finalize step refuses to write if/media/plex1isn't a real, non-empty mount, so a detached DAS can't get a library materialised on the root disk. The staged files wait until you hit Retry. - Re-adding a URL that was downloaded before shows a warning with the old path, but doesn't block the download. "Clear finished" hides rows while keeping them in history for this check.
- Login is a single hardcoded user and password from
/home/plex/yt-plex/.env(mode 600), with a 30-day signed session cookie and a 2-second penalty on a bad password.
2. TV shows and TVDB matching
Scraping thetvdb.com was fragile and the v4 API wants a paid key. Instead we used the service Sonarr already relies on: Skyhook (skyhook.sonarr.tv/v1/tvdb/shows/en/<tvdbId>) returns a show's full episode list with air dates and needs no key. Matching looks for an episode within ±1 day of the YouTube upload date and breaks ties by title similarity. If nothing is that close, it falls back to title similarity alone (≥60%). The row shows how it matched, and an episode dropdown lets you override it. The name becomes S2026E29 Everyone Hates Flock Cameras, and the files, including the JPG, go into a folder of that name under the show.
Seeded shows: Some More News (335826), Philosophy Tube (370485), Patrick (H) Willems (365605 → folder Patrick H Willems), ContraPoints (370198 → Contrapoints) and hbomberguy (370652). The TV Shows dialog adds more from a TVDB URL, an ID or a search. TVDB slugs drift (Damien's SMN link says some-news but the current slug is some-more-news), so slug URLs are resolved by reading the series ID off the page. Channel names are matched to shows automatically, so pasting a Some More News link preselects TV Shows, the show and the episode.
A known gap: TVDB volunteers sometimes list an episode a day or two late. The row then says so, and ↻ re-checks.
3. The app's own yt-dlp
The system /usr/local/bin/yt-dlp zipapp got HTTP 429 on every subtitle fetch, because it has no curl_cffi for browser impersonation. It also reads Damien's personal config: --restrict-filenames would mangle titles into underscores, and --download-archive would silently skip re-downloads. So the app runs --ignore-config with its own yt-dlp installed in the venv as yt-dlp[default,curl-cffi], using deno (~/.deno/bin/deno) as the JS runtime.
- Format:
-f bv*+ba/b -S res,ext:mp4:m4a, merged or remuxed to MP4. That's maximum resolution, preferring MP4-native streams at equal resolution. On YouTube it often picks the high-bitrate "Premium" 1080p HLS stream (format 616). The E29 test came out at 1.7 GB for 63 minutes. - Subtitles are a separate, non-fatal step with retries. English manual subs are preferred, then auto-captions (
en-orig, thenen). Both are fetched as YouTube'sjson3and converted to SRT by the app. That avoids the rolling duplicated lines that yt-dlp's VTT→SRT conversion produces for auto-captions. - Action: I added a cron job at
50 5 * * *that upgrades the venv's yt-dlp nightly, because YouTube breaks old versions regularly.
4. Verification
- The real Some More News video auto-matched S2026E29 Everyone Hates Flock Cameras by air date and landed with
.mp4,.en.srtand.jpg. - A 144p throwaway test in Rare was renamed after completion, moved Rare → Live → Rare, and confirmed the duplicate warning. Then I deleted it and its history rows.
- Damien's DJ set (HÖR Berlin, Nikolina) went into Live as a real download. It has no English captions, and the row says so.
5. Going live (completed September 29)
- Firewall: I added
ufw allow from 172.18.0.0/16 to any port 8430 proto tcp(the same pattern as Home Assistant's 8123 rule). Before that the NPM container just timed out. - NPM proxy host (created by Damien):
yt.skyhouse.dev→192.168.1.136:8430, with an SSL certificate. No access list is needed because the app has its own login. - Gotcha: the app reads
.envonly at startup, so a changed password needssystemctl --user restart yt-plex. That tripped us up on day one. - Playlist URLs are deliberately rejected with a message for now. The row-per-video queue is the natural shape for batching later.
Net effect: YouTube content now has the same paste-and-forget path into Plex that the arrs give torrents, with TVDB-correct episode naming for YouTube series.
← Back to Admin Hub