โ† Kevin Yoder
Personal, Family & Home

YouTube โ†’ Jellyfin Pipeline

Download a YouTube video or a whole playlist and it lands, organized by channel, in the family Jellyfin library โ€” and "subscribe" to a channel to turn the home server into a personal YouTube PVR that keeps pulling new uploads on its own.

In home use Live since early 2026 Self-hosted on home server LAN-only ยท no auth

โš™ How it works ๐Ÿ“Š Results ๐Ÿ–ผ Screenshots

01 Overview

A small self-hosted web app that archives YouTube onto the family media server. Paste a video or playlist URL and it downloads at the quality you pick, remuxes to MP4 for broad device compatibility, and files it under a per-channel folder that Jellyfin indexes as a "YouTube" library. The standout piece is subscriptions: point it at a channel and a daily scheduler keeps downloading that creator's new uploads on its own โ€” an ad-free, offline personal PVR for the kids' channels. The whole service is a single Flask file and one HTML template.

one-off

Add downloads

Paste one or more video or playlist URLs, preview the thumbnail, duration, and uploader, then pick a quality from 480p to 4K โ€” or audio-only M4A.

the queue

Download queue

A SQLite-backed queue that survives restarts, with live per-item progress bars, speed and ETA, status filters, and one-at-a-time downloading.

the PVR

Channel subscriptions

Subscribe to a channel and a daily check auto-queues its newest uploads into Jellyfin, with a per-channel activity log โ€” a personal YouTube PVR.

02 Why I built it

The family Jellyfin box already held our movies and shows; the kids' YouTube was the loud, ad-riddled exception. I wanted the same living-room experience for a handful of channels they actually watch โ€” no ads, no autoplay rabbit holes, watchable offline, and sitting right next to everything else on the TV. Manually re-downloading each creator's new videos would have defeated the point, so the real goal was the subscribe-and-forget behavior: tell it a channel once and let the server quietly keep the library current.

03 What I built & how it works

One Flask process, a persistent worker thread, and a daily scheduler โ€” feeding a folder Jellyfin watches.

Browser UI index.html ยท 3 tabs ยท 5s polling REST ยท /api/* Flask app.py worker thread ยท drains the queue one at a time APScheduler ยท daily channel check, every 24h yt-dlp + ffmpeg ยท download โ†’ remux to MP4 (H.264 + AAC) SQLite ytdl.db ยท WAL mode on the media drive download โ†’ remux Per-channel folders /downloads/<channel>/<title>.mp4 indexed as a library Jellyfin "YouTube" library ยท on the TV, ad-free

Fig. 1 โ€” the app queues and downloads; the daily scheduler tops up subscriptions; Jellyfin indexes the same per-channel folders on the shared media drive.

  1. Paste or subscribe โ€” one or more URLs (videos or whole playlists) at a chosen quality, or a channel to follow.
  2. Queue โ€” items land in a SQLite queue that survives restarts; a single worker drains them serially, and interrupted items are marked failed on the next boot.
  3. Download โ€” yt-dlp pulls the video with live percent, speed, and ETA, throttling progress writes to the database to every 5%.
  4. Remux & file โ€” ffmpeg remuxes to MP4, and the file is written under a filename-safe per-channel folder on the media drive.
  5. Top up โ€” a daily scheduler re-checks each subscribed channel and auto-queues any new uploads, de-duplicated against what's already been fetched.
  6. Watch โ€” Jellyfin indexes the folders as a "YouTube" library, so the family watches on the TV, ad-free and offline.

04 ๐Ÿ›  Skills & tech used

Languages
Python 3.11JavaScript (ES2017, vanilla)HTML / CSSSQL / SQLite
Libraries
Flaskyt-dlpAPSchedulerffmpeg
Infra / Ops
Docker (python:3.11-slim + ffmpeg)docker-compose healthcheck512 MB mem_limitHomepage dashboard tileNTFS USB media volume
Frontend
hand-rolled tabbed SPA5s pollinglive progress barsXSS-escaping helperper-channel log modaldata-URI SVG favicon
Data
SQLite WAL modeUNIQUE-constraint dedupIntegrityError as flow controlthrottled progress writes
Techniques
producer/consumer worker threadthreading.Event signallingcrash recovery on startuprate-limit evasionyt-dlp URL normalization

05 Notable challenges & decisions

Most of the work was in staying on the right side of YouTube and yt-dlp, both of which move under you.

Rate limits

"Sign in to confirm you're not a bot"

Batch downloads started tripping YouTube's bot detection. The fix was politeness, not cleverness: a 60-second delay between downloads (the first item is exempt so a single quick download stays snappy), retries 10 plus fragment_retries 10, and a single serial worker rather than parallel fetches. Slower on purpose, and far more reliable.

Extraction

@handle URLs don't return videos

Subscribing by a channel's @handle quietly returned the channel's tabs, not its uploads, under yt-dlp's flat extraction. The fix normalizes bare handles by appending /videos and validates every entry against a strict 11-character video-ID regex โ€” plus catching a wrong option key (playlist_end should be playlistend). After the fix, a batch of broken channels correctly queued 200 items.

Dependency churn

yt-dlp changes under you

A yt-dlp release silently retired FFmpegVideoConvertor, so remuxing broke with no error โ€” switched to FFmpegVideoRemuxer. Separately, yt-dlp's display progress fields could carry ANSI codes or read "N/A", so the progress hook was rewritten to read raw byte counters and format speed and ETA itself.

The invisible bug

A remounted USB drive Jellyfin couldn't see

After the media USB drive was remounted, Jellyfin's YouTube library went empty โ€” the container had captured the mount namespace at start and was still reading the bare SSD underneath the mount point. The channel folders were all fine on disk; the durable fix is simply docker restart jellyfin, which re-reads the current mount.

Deliberately unhurried, and honest about trust. A single serial worker trades speed for politeness; the SQLite database lives on the media drive so the queue history travels with the archive; per-channel folders are the contract Jellyfin organizes around; and there is no authentication โ€” it trusts the home LAN, which is fine for a family tool and nothing more.

06 Results

573 GB
archived to the home media drive
584
videos downloaded & imported (9 failed)
6
channels subscribed, auto-checked daily
16
per-channel folders indexed by Jellyfin
60s
delay between downloads to stay under rate limits

Sources: the queue SQLite database and archive folder on the media drive (as of 2026-07-31), plus the container config; all six subscriptions last checked 2026-07-30, and the container was up ten days healthy at survey.

07 Screenshots

No screenshots are shared here โ€” these are placeholders for captures of the running app.

[ queue tab, mid-download ]
thumbnail rows with live progress bars,
speed / ETA, and Queued ยท Active ยท Done ยท Failed badges
The queue: several items in flight with per-item progress, speed and ETA, and status filters โ€” the download experience the polling UI is built around.
[ channels tab ]
subscription list + per-channel
"Done: N new video(s) queued" log modal
Channels: the subscription list with a "Check Now" action and a per-channel activity log showing what each daily check queued.
[ jellyfin ]
"YouTube" library showing
channel folders as series
The end of the pipeline: Jellyfin presenting the per-channel folders as a browsable "YouTube" library on the TV.

08 Honest status

The pipeline runs continuously on my home server and is in regular family use โ€” a 573 GB archive across 16 channel folders, with six subscriptions the daily scheduler still checks. It is deliberately LAN-only with no authentication of any kind, so it is not exposed publicly and there is no live link here; on an open network anyone could queue or delete downloads, which is an acceptable trade for a home tool but not for the internet. A few rough edges are fair to name: a couple of homepage-dashboard health-pings point at a route that does not exist, so the tile can read as down even while the service is healthy (cosmetic only); a later batch of progress-display improvements lives only in the working tree and was never committed; and a "cancelled" state is styled in the UI but the cancel button was never actually built. It is a practical home utility, not a hardened product.

youtube-jellyfin โ€” Build Recipe

Take a bare machine to a running YouTube โ†’ media-library pipeline: a small Flask web app that downloads YouTube videos and whole playlists with yt-dlp, organizes them by channel into a downloads/ folder, shows a live download queue, and can "subscribe" to a channel so a daily background job pulls new uploads. Mount that downloads/ folder into any media server (Jellyfin, Plex, โ€ฆ) and the videos appear as a per-channel library. Key-free โ€” no accounts, no API keys.

Status: โœ… Verified 2026-08-02 โ€” built from this clone with docker compose up -d --build, came up healthy, the web UI served on :8085, and a real public-domain YouTube video downloaded end-to-end into downloads/<Channel>/ (1080p MP4). All three tiers covered; the prebuilt tarball (docker save youtube-jellyfin-clone) is ~785 MB (python-slim + ffmpeg + yt-dlp; ffmpeg dominates). The clean clone lives in clone/.

Sensitive data: none. No API keys, no accounts, no personal media paths โ€” the original's home-server media mount path and host names were removed; downloads go to a local downloads/ folder you control. downloads/ is git/docker-ignored.


What it is

A single Flask app (clone/app.py, ~650 lines) plus one HTML template. It wraps yt-dlp for downloads, ffmpeg for the 1080p-MP4 remux, APScheduler for the daily channel check, and SQLite for the queue + subscriptions. There is no build step for the frontend, no ML, no GPU, and no external service โ€” "reproducing" it is just running the container.

Piece Role
app.py Flask UI + REST API, yt-dlp worker thread, APScheduler daily check, SQLite store
templates/index.html the single-page UI (queue, progress, subscribe)
downloads/ output volume โ€” one subfolder per channel; also holds ytdl.db

Prerequisites

New machine? Install the base tools first โ€” see ../SETUP.md. Then, per tier:

Tier You need
1 โ€” prebuilt container (recommended) Docker (see ../SETUP.md). Nothing else.
2 โ€” build from source Same as Tier 1, plus this source bundle.
3 โ€” bare-metal Python 3.11+ and ffmpeg on PATH.

Configuration โ€” nothing required

The app is key-free; docker compose up needs no .env. To change the web port, copy clone/.env.example to clone/.env and set YTJ_PORT (default 8085).


Tier 1 โ€” Run the prebuilt container (recommended)

Availability: the prebuilt image bundle is available on request โ€” it is not published or linked anywhere. Ask Kevin for it, or build from source via Tier 2 below.

Load the on-request youtube-jellyfin-image.tar bundle and run โ€” no build step:

docker load -i youtube-jellyfin-image.tar
cd clone
docker compose up -d                  # โ†’ http://localhost:8085

Tier 2 โ€” Build the image from source

cd clone
docker compose up -d --build          # โ†’ http://localhost:8085

Open http://localhost:8085, paste a YouTube URL, and click Download โ€” or paste a channel URL and Subscribe to auto-pull new uploads daily. Files land in clone/downloads/<Channel Name>/. Point your media server at that folder.

Tier 3 โ€” Bare-metal (one process)

cd clone
python -m venv .venv && . .venv/bin/activate      # Windows: .venv\Scripts\activate
pip install -r requirements.txt                   # flask, yt-dlp, apscheduler
# ffmpeg must be on PATH (apt install ffmpeg / brew install ffmpeg / choco install ffmpeg)
mkdir -p downloads
python app.py                                     # โ†’ http://localhost:5000

Verify

curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8085/      # โ†’ 200
# then in the UI, download a short public-domain clip and confirm it appears in downloads/

Provenance โ€” data & models

  • No datasets, no models, no GPU. The app downloads whatever public YouTube URL you give it; nothing is bundled. yt-dlp and ffmpeg are the only heavy dependencies and both install from their public package sources.

What was stripped from the personal version (de-identification)

  • Personal media paths โ€” the original mounted a media folder on the author's home server and documented that server's install paths and hostname. Replaced with a local ./downloads volume and machine-agnostic instructions.
  • Runtime artifacts โ€” the SQLite DB, downloaded media, __pycache__, and *.bak files are git/docker-ignored; the clone ships only source.

Known limitations (stated honestly)

Personal-LAN tool: no authentication and permissive by default โ€” put it behind a reverse proxy with auth before exposing it. YouTube occasionally changes its player and may rate-limit; the app already paces downloads (60 s between items) and yt-dlp is pinned to a recent release, but a very old yt-dlp can break โ€” pip install -U yt-dlp if a download fails. Respect YouTube's Terms of Service and copyright; download only content you have the right to.