โ 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.
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.
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.
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.
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.
- Paste or subscribe โ one or more URLs (videos or whole playlists) at a chosen quality, or a channel to follow.
- 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.
- Download โ yt-dlp pulls the video with live percent, speed, and ETA, throttling progress writes to the database to every 5%.
- Remux & file โ ffmpeg remuxes to MP4, and the file is written under a filename-safe per-channel folder on the media drive.
- Top up โ a daily scheduler re-checks each subscribed channel and auto-queues any new uploads, de-duplicated against what's already been fetched.
- Watch โ Jellyfin indexes the folders as a "YouTube" library, so the family watches on the TV, ad-free and offline.
04 ๐ Skills & tech used
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.
"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.
@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.
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.
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
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.
thumbnail rows with live progress bars,
speed / ETA, and Queued ยท Active ยท Done ยท Failed badges
subscription list + per-channel
"Done: N new video(s) queued" log modal
"YouTube" library showing
channel folders as series
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 intodownloads/<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 inclone/.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-dlpandffmpegare 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
./downloadsvolume and machine-agnostic instructions. - Runtime artifacts โ the SQLite DB, downloaded media,
__pycache__, and*.bakfiles 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.