yt-offline
A self-hosted media archive for YouTube and friends. Paste any URL, it routes the download to the right folder by source, tracks what you’ve watched, and plays everything back from a desktop GUI or a browser — even offline, or after the source video is taken down.
Built on yt-dlp; written in Rust as a single binary that is both a desktop app and a headless web server.
What it backs up
| Platform | Channels | Playlists | Single videos |
|---|---|---|---|
| YouTube | ✅ | ✅ | ✅ |
| TikTok | ✅ | — | ✅ |
| Twitch (VODs + clips) | ✅ | — | ✅ |
| Vimeo | ✅ | ✅ | ✅ |
| Bandcamp | ✅ (artist) | ✅ (albums) | ✅ (tracks) |
| SoundCloud | ✅ | ✅ (sets) | ✅ |
| Odysee | ✅ | — | ✅ |
| Anything else yt-dlp accepts | other/ | other/ | other/ |
Why it exists
Tartube is the mature open-source yt-dlp GUI and the benchmark in this space. yt-offline matches its feature set while adding things Tartube doesn’t have: a real web UI reachable from any device, a single-binary distribution with a bundled toolchain, a modern security model (password-gated UI, Argon2, rate-limited login), a built-in anti-bot stack (TLS impersonation + Proof-of-Origin tokens), and one-click post-download format conversion.
How to read these docs
- New here? Installation → First run → Downloading.
- Hitting captchas or “Video unavailable”? Go straight to Anti-bot and Troubleshooting.
- Hacking on it? Architecture.
Installation
yt-offline is a single Rust binary. You can install a prebuilt package, build from source, or grab the AppImage.
Runtime dependencies
Whichever way you install, these are invoked as subprocesses at runtime:
- yt-dlp — the download engine. You can use the system one or let yt-offline manage a bundled copy (see First run).
- ffmpeg — muxing, format conversion, on-the-fly transcode for the web player.
- mpv — the default desktop player (any player taking a file path works; set it in Settings).
- xdg-utils —
xdg-openfor “Show in file manager”.
Prebuilt packages (Linux)
Releases attach .deb, .rpm, and .AppImage artifacts.
# Debian / Ubuntu / Mint
sudo apt install ./yt-offline_*_amd64.deb
# Fedora / RHEL / openSUSE (ffmpeg via RPM Fusion)
sudo dnf install ./yt-offline-*.x86_64.rpm
# Any Linux — AppImage
chmod +x yt-offline-*-x86_64.AppImage
./yt-offline-*-x86_64.AppImage
Arch / CachyOS / Manjaro
A PKGBUILD ships in the repo root. Build it from a clean directory:
mkdir build && cd build
cp /path/to/repo/PKGBUILD .
makepkg -si
For repeated builds after pulling new commits, always pass -C
(cleanbuild) so makepkg re-checks out the latest source instead of
reusing a stale cached clone.
From source
# Debian/Ubuntu build deps
sudo apt install build-essential pkg-config curl git python3-venv \
libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev \
libxkbcommon-dev libssl-dev
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env"
git clone https://codeberg.org/anassaeneroi/yt-offline
cd yt-offline
cargo build --release
./target/release/yt-offline # desktop GUI
./target/release/yt-offline --web 8080 # headless web server
python3-venv is only needed for the bundled-yt-dlp install path; skip
it if you’ll always use system yt-dlp.
Windows / macOS
Not first-class yet. The Linux-only system tray (ksni) and file dialog
(rfd xdg-portal) need per-OS backends before a clean cross-build; the
rest of the stack already compiles. See the
architecture notes.
First run & configuration
The config file
yt-offline reads config.toml from its working directory (the
directory you launch it from), not a fixed path. The same goes for
cookies.txt. Everything in config.toml is also editable in Settings;
edits there are written back to the file.
[backup]
directory = "/path/to/library" # the umbrella dir; all platforms nest under it
max_concurrent = 3 # parallel yt-dlp processes
use_bundled_ytdlp = false # true = use the managed venv (see below)
use_pot_provider = false # YouTube Proof-of-Origin tokens (see Anti-bot)
youtube_player_clients = "" # e.g. "tv,mweb" to route around captchas
[player]
command = "mpv" # any executable taking a file path as its last arg
browser = "firefox" # cookie source when no cookies.txt is set (see Anti-bot)
[ui]
theme = "dark" # dark | light | dracula | trans | emo-nocturnal | emo-coffin | emo-scene-queen
ui_scale = 1.0 # global zoom for the whole desktop UI
[scheduler]
enabled = false
interval_hours = 24 # auto re-check every channel for new uploads
[web]
port = 8080
bind = "127.0.0.1" # 127.0.0.1 | 0.0.0.0 | a Tailscale/LAN address
transcode = false # MKV → MP4 on the fly for browsers that can't decode MKV
[subtitles]
enabled = true
auto_generated = true # include machine captions
embed = false # also embed into the container
format = "" # "" = native; "srt" for Plex compatibility
langs = "" # "" = all; "en" or "en,ja" to filter
[convert]
mode = "" # "" / "remux-mp4" / "h264-mp4" / "audio"
crf = 23 # for h264-mp4 (lower = bigger/better)
preset = "medium"
audio_format = "mp3" # for audio mode
keep_original = false # keep <name>.original.<ext> after converting
[plex]
library_path = "/path/to/plex/TV/youtube" # leave unset to disable
The library layout
Everything nests under the one backup.directory:
<backup.directory>/
channels/ ← YouTube creators
tiktok/ twitch/ vimeo/ bandcamp/ soundcloud/ odysee/ other/
music/ ← audio-only "Music mode" downloads, by artist
archive.txt ← yt-dlp's global download archive
cookies.txt ← optional, if you set one
yt-offline.db ← watched/positions/flags/folders/notes/cache + password hash
Each creator folder gets a hidden .source-url sidecar so re-checks
always know the exact URL to refresh from.
Bundled vs system yt-dlp
In Settings → yt-dlp binary you choose:
- System — uses whatever
yt-dlpis on yourPATH. - Bundled — click Install and yt-offline builds a self-contained
venv at
~/.local/share/yt-offline/: nightlyyt-dlp[default]+curl_cffi(TLS impersonation) + a bundleddeno(player-JS). The same button updates it later.
The bundled path is recommended — it installs nightly yt-dlp, which keeps pace with YouTube’s frequent anti-bot changes (stable lags). It’s also required for the POT token provider.
The two front-ends
yt-offline— desktop GUI (eframe/egui).yt-offline --web [PORT]— headless web server. Bind to127.0.0.1(default) for localhost-only, a Tailscale address for your tailnet, or0.0.0.0for the LAN. Set a password (Settings) before exposing it beyond localhost — the UI and all/apiroutes are then gated behind an Argon2-hashed, rate-limited login.
Downloading
Starting a download
Paste any supported URL into the download bar (desktop) or the ⬇ Downloads modal (web). yt-offline classifies the URL by platform, routes it to the right folder, and starts yt-dlp. A channel/playlist URL pulls the whole thing; a single-video URL pulls just that one.
Quality picker: Best / 1080p / 720p / 480p / 360p, or Music mode
for audio-only extraction into music/<artist>/.
Fast mode stops at the first already-downloaded video (quick routine re-checks). Turn it off for a full gap-filling scan.
Per-channel options
Right-click a channel (or use the ⚙ on its sidebar row) for overrides that apply to scheduled re-checks and the “Check for new videos” action:
- Quality cap, audio-only, bandwidth cap, min/max file size, date cutoff.
- A free-form
--match-filter(e.g.duration > 60 & view_count > 100). - Subtitle overrides, YouTube player-client override, post-download convert mode — each defaulting to the global setting.
- Skip auth check — silences yt-dlp’s “playlists that require authentication” warning for public channels (see Troubleshooting).
Per-channel options ride along in library backup/restore.
Subtitles
Global defaults (Settings → Subtitles) + per-channel overrides control:
download on/off, auto-generated captions, embedding into the container,
language filter, and format conversion (srt is the most
Plex/player-compatible). Subtitles are written as sidecar files and
optionally embedded.
Format conversion
A post-download ffmpeg pass (Settings → Format conversion, or per channel):
- Remux → mp4 — instant container change, no re-encode (device/Plex compatibility).
- Re-encode → H.264 mp4 — shrink large 4K files at a chosen CRF + x264 preset.
- Extract audio — mp3 / m4a / opus / flac.
It runs as a distinct transcode job after the download. Keep original
preserves <name>.original.<ext> alongside the converted file; otherwise
the source is removed once the convert succeeds.
Resilience
Downloads are hardened against YouTube’s flakiness automatically:
- Retry + backoff on connection resets (
--retries 30, linear retry-sleep). - Jittered throttle between videos so a long channel scan doesn’t look robotic and trip the captcha wall.
- Auto-retry of transient (rate-limit / network / captcha) failures after a cooldown, with adaptive slow-down for the rest of the batch.
- Hang watchdog kills a job that produces no output for 5 minutes (a wedged request) and re-queues it.
Failures are classified and shown with a one-line suggested fix — see Troubleshooting.
Scheduler
Enable it (Settings → Auto-check channels) to re-check every channel for new uploads on an interval. Each channel uses its own stored options. There’s also a per-folder “Check all” action.
Staying ahead of YouTube’s bot detection
YouTube increasingly fingerprints and rate-limits automated clients. yt-offline ships a layered defense; understanding the layers makes the difference between “everything downloads” and “constant captchas.”
In rough order of impact:
1. Be logged in (cookies)
This is the single biggest factor. Anonymous requests get captcha-
walled the hardest. Provide a cookies.txt exported from a browser
where you are signed in to YouTube, or point yt-offline at the
browser profile directly.
A valid logged-in jar contains the auth cookies SID, SAPISID,
__Secure-1PSID, __Secure-3PSID, LOGIN_INFO, etc. A jar with only
VISITOR_INFO1_LIVE, PREF, YSC is anonymous — it’s not signed in
and actually makes detection worse than no cookies. yt-offline’s
Settings → Cookies panel warns you when your jar is anonymous or expired.
Two ways to supply cookies:
-
Export a
cookies.txtwith a browser extension like Get cookies.txt LOCALLY, then paste/upload it in Settings → Cookies. A file in the working directory takes precedence over the browser option. -
Read the browser profile live by setting the cookie browser to a yt-dlp
--cookies-from-browserspec. Plainfirefox/chrome/braveworks for default profiles; for a non-default profile (e.g. Brave’s beta channel) use the full form:brave:/home/you/.config/BraveSoftware/Brave-Origin-BetaThe path is the profile root (yt-dlp appends the
Defaultsubdirectory itself). The advantage: cookies are read fresh from the live session each download, so they never go stale.
Cookies are session credentials — yt-offline never commits or transmits
cookies.txtunprompted, and redacts the cookie path out of any log line shown in the UI.
2. TLS impersonation (curl_cffi)
yt-dlp’s --impersonate makes requests carry a real browser’s TLS
fingerprint (via curl_cffi), so the connection doesn’t look like a
script. The bundled install sets this up automatically and yt-offline
picks an impersonation target per platform.
If impersonation silently does nothing, it’s almost always a yt-dlp ⇄ curl_cffi version mismatch — which is exactly why the bundled install uses nightly yt-dlp (it accepts current curl_cffi; stable lags and disables all impersonate targets when a newer curl_cffi is present). See Troubleshooting → impersonation.
3. POT tokens (Proof-of-Origin)
YouTube increasingly binds a per-video Proof-of-Origin token to playback; without one, format URLs come back empty. yt-offline can run bgutil-pot, a loopback HTTP server that mints these tokens, and point yt-dlp at it.
Enable Settings → Use POT token provider (requires the bundled yt-dlp; the matching plugin installs into its venv) and click Install.
Version-skew footgun: the yt-dlp plugin must come from the same release as the bgutil-pot server binary — not the PyPI package, which versions independently and silently produces no tokens on a mismatch. yt-offline’s installer handles this by unpacking the version-matched plugin zip from the server’s release.
4. Player-client selection
YouTube cracks down on different internal “player clients” over time —
the web client is currently the most captcha-prone, while tv and
mweb are the least. yt-offline no longer forces web; it lets yt-dlp
pick good defaults. If a specific channel keeps hitting captchas, set a
client override (global or per-channel):
tv,mweb
5. Throttling
A burst of ~30 rapid requests is a classic trip-wire. yt-offline inserts a small jittered pause between videos (a fixed cadence looks robotic; a random one looks human) and, after any rate-limit hit, triples the sleeps for the rest of the batch before recovering.
TL;DR for a clean setup: bundled (nightly) yt-dlp + fresh logged-in cookies + POT provider enabled. That combination resolves the vast majority of captcha / “Video unavailable” failures.
Troubleshooting
yt-offline classifies failed downloads into one of nine classes and shows a one-line suggested fix next to the failed job. This page expands on the most common ones, plus a few non-download issues.
“Video unavailable. YouTube is requiring a captcha challenge”
Class: rate-limited. Not a removed video — it’s the bot-detection wall. In order of effectiveness:
- Use fresh, logged-in cookies. Anonymous cookies are the usual culprit — see Anti-bot → cookies. Settings → Cookies warns when your jar is anonymous or expired.
- Switch to bundled (nightly) yt-dlp if you’re on system stable.
- Enable the POT token provider.
- Try a player-client override of
tv,mwebfor that channel. - If it’s a one-off, just wait — yt-offline auto-retries transient rate-limits after a cooldown.
Impersonate targets show “(unavailable)”
yt-dlp --list-impersonate-targets lists every target as (unavailable)
even though curl_cffi is installed.
Cause: a yt-dlp ⇄ curl_cffi version gate. Stable yt-dlp caps the curl_cffi version it accepts; a newer curl_cffi makes it disable all impersonate targets.
Fix: use the bundled yt-dlp (it installs nightly via --pre,
which accepts current curl_cffi), or pin curl_cffi to a compatible
version in your own environment.
POT provider produces no tokens
You enabled the POT provider and installed it, but downloads still fail as if no token was generated. yt-dlp logs a “plugin and HTTP server major versions are mismatched” warning.
Cause: the yt-dlp plugin came from PyPI (Brainicism’s package, which versions independently) instead of the jim60105 Rust server’s release.
Fix: re-run the POT Install/Update button — yt-offline installs
the version-matched plugin zip from the same release as the server
binary. Don’t pip install bgutil-ytdlp-pot-provider yourself.
The youtubetab authentication warning
ERROR: [youtube:tab] @Channel: Playlists that require authentication may
not extract correctly without a successful webpage download...
Despite the ERROR: prefix this is a soft warning, usually a symptom of
the bot-detection issues above (YouTube served an incomplete page). It
does not change which videos are found.
Fix: for public channels, enable Skip auth check in that
channel’s options (adds --extractor-args youtubetab:skip=authcheck) to
silence it. Leave it off for members-only/private channels you
archive with cookies — there the warning is a real “your cookies may not
be working” signal.
“Sign in to confirm you’re not a bot”
Same family as the captcha wall. Fix with fresh logged-in cookies + POT; see Anti-bot.
Downloads stall forever
A job sits running with no progress. yt-offline’s hang watchdog auto-kills any job silent for 5 minutes and re-queues it, so this should self-heal. If it recurs on a specific URL, it’s usually a server-side issue with that source; check the job log in the Downloads panel.
Disk fills up / downloads fail with ENOSPC
yt-offline runs a disk-full preflight and refuses to start a download when the target filesystem has less than ~500 MB free, surfacing it as a clear “disk full” failure rather than a half-written file. Free space and retry.
A whole platform folder shows up as one “channel”
If you see bandcamp, tiktok, or channels listed as a single channel
in the sidebar, your library directory predates the current layout. All
platforms must nest under the one backup.directory
(<dir>/channels/, <dir>/tiktok/, …). Move stray creator folders into
their platform’s subdir; see First run → library layout.
The desktop window crashes on maximize
Older builds crashed with a Glutin EGL_BAD_ALLOC on NVIDIA + Wayland
when maximized. Current builds use the wgpu (Vulkan) renderer, which
handles the resize cleanly. Make sure you have a working Vulkan driver
(vulkan-icd-loader + your GPU’s Vulkan package), which any desktop with
working graphics already has.
The web UI looks like an old version after an upgrade
The SPA is served Cache-Control: no-store, so a hard reload
(Ctrl+Shift+R) always picks up the new binary’s UI. If you upgraded the
binary, also restart the running --web process — the HTML is baked
into the binary at compile time, so the old process keeps serving the old
UI until restarted.
Where to look next
- The job log — every download/transcode job keeps its full yt-dlp / ffmpeg output in the Downloads panel (expand the job).
yt-offline.crash.log— next to youryt-offline.db. A panic in any thread (UI, web worker, download) is appended here with a timestamp, so it survives a GUI launched without a terminal.
Architecture
For contributors. The repo’s CLAUDE.md is the terse version of this;
read both.
Two front-ends, one engine
main.rs dispatches: --web → web::run() (axum, blocks forever);
otherwise app::App (eframe/egui desktop GUI). Both share
downloader::Downloader, the single source of truth for the yt-dlp job
lifecycle.
Downloader is not async — it spawns an OS thread per yt-dlp process,
streams stdout/stderr back over an mpsc channel into each Job’s log
buffer, and the caller pumps Downloader::poll() regularly (the egui
frame loop and a web background task both do). Anything you add to
Downloader is automatically available to both UIs.
poll() also drives the cross-cutting job machinery: auto-retry of
transient failures (cooldown + adaptive throttle), the hang watchdog, and
the post-download ffmpeg transcode pass. These work by capturing specs
onto the Job at start() time (RetrySpec, ConvertSpec) and acting
on them when the job changes state.
The settings flow (the easy thing to get wrong)
Almost every configurable feature has the same five-touchpoint shape — miss one and it silently half-works:
config.rs— a field/section + itsDefault+ thedefault_with_dirconstructor.download_options.rs— anOption<…>per-channel override (None = defer to global).downloader.rs— a resolver merging global config + per-channel override into yt-dlp/ffmpeg args, plus apubfield onDownloaderholding the global default.- Both UIs render the global setting and the per-channel override:
desktop in
app.rs, web inweb_ui/index.htmlandweb.rs’sSettingsPayload(GET reads config, POST writes config + pushes onto the liveDownloader). - Seed the
Downloaderfield at construction and on settings-save, in bothapp.rsandweb.rs.
subtitle_defaults, youtube_player_clients, and convert_defaults are
complete worked examples — grep one end-to-end before adding a setting.
Filesystem layout
platform::platform_root(channels_root, platform) =
channels_root.join(dir_name). All platforms (including YouTube,
whose dir_name is channels) nest under the one configured
backup.directory. .source-url sidecars in each creator folder let
re-checks recover the exact URL. Library scanning (library.rs) is
parallel and consults a (path, mtime) SQLite cache to skip re-parsing
unchanged info.json sidecars.
Persistence
database.rs wraps an r2d2 SQLite pool. Database is cheaply Clone
(the pool is an Arc), so the parallel scanner takes its own handle.
Schema lives in init_schema(); new columns are added via idempotent
ALTER TABLE … ADD COLUMN that swallows the duplicate-column error (no
migration framework). The web UI keeps library/notes snapshots in memory;
mutating endpoints mirror DB writes onto those caches and bump a version
counter (the /api/library ETag) so reads stay consistent without a
rescan.
The long-lived WebState mutexes are accessed via
util::LockExt::lock_recover(), which recovers a poisoned lock instead of
cascading one handler’s panic into a dead server.
Web UI is one embedded file
web_ui/index.html is the entire SPA (HTML+CSS+JS), include_str!-baked
into the binary at compile time — editing it requires a rebuild. Served
Cache-Control: no-store so binary upgrades don’t strand stale tabs.
Progress streams over /ws/progress (WebSocket) with an HTTP-poll
fallback.
Anti-bot subsystems
ytdlp_bin.rs manages the optional self-contained venv at
~/.local/share/yt-offline/ (nightly yt-dlp[default] + curl_cffi +
bundled deno). pot_provider.rs runs bgutil-pot for Proof-of-Origin
tokens — its yt-dlp plugin must come from the same release as the server
binary. error_class.rs pattern-matches yt-dlp stderr into actionable
classes (order matters in classify(): the captcha “Video unavailable”
wall is RateLimited, not NotFound).
Tests
- Unit tests are inline
#[cfg(test)]modules (parsers, resolvers, the error classifier, DB merge logic). tests/api.rsspawns the real--webbinary against a scratch dir and drives the HTTP API with curl — genuine end-to-end coverage of the axum + SQLite + config stack.
cargo test runs both. (A .forgejo/workflows/test.yml CI definition
exists, but Codeberg runs Woodpecker rather than Forgejo Actions, so it
doesn’t execute there without a self-hosted runner — run the suite
locally.)
Platform support
Tray (ksni) and file dialogs (rfd xdg-portal) are Linux-only / no-GTK
by design — that’s why packaging avoids a GTK dependency. Windows/macOS
aren’t first-class yet: the tray needs a per-OS backend before a clean
cross-build. The rest (eframe/wgpu, axum, rusqlite-bundled) already
compiles cross-platform, and ytdlp_bin already has cfg!(windows)
branches.
Packaging
Build distributable Linux packages with one script:
scripts/package.sh all # .deb + .rpm + .AppImage → dist/
scripts/package.sh deb # just the .deb
scripts/package.sh rpm
scripts/package.sh appimage
It builds the release binary once and reuses it for every format,
installing cargo-deb / cargo-generate-rpm on demand and downloading
appimagetool to dist/tools/ on first AppImage build. Per-format
failures are isolated and summarized at the end. Output (gitignored)
lands in dist/.
Formats
.deb— built by cargo-deb from[package.metadata.deb]inCargo.toml..rpm— built by cargo-generate-rpm from[package.metadata.generate-rpm]. (ffmpegon Fedora is in RPM Fusion.)- AppImage — a hand-rolled AppDir + appimagetool. Bundles the GUI
binary’s shared-library closure only;
yt-dlp/ffmpeg/mpvstay host PATH deps, same as the package declarations. - Arch — use the repo’s
PKGBUILD(not this script); runmakepkgfrom a clean directory.
CI
The repo ships .forgejo/workflows/ definitions (test.yml,
release.yml), but Codeberg executes Woodpecker rather than Forgejo
Actions — so they don’t run there without a self-hosted runner. Until
then, build packages locally with scripts/package.sh and publish docs
with scripts/publish-docs.sh.
The repo’s docs/PACKAGING.md
has the per-distro install commands and the Windows/macOS status in full.