docs build 2026-06-07T10:32:00Z

This commit is contained in:
Luna 2026-06-07 03:32:00 -07:00
commit 8110d15793
44 changed files with 7975 additions and 0 deletions

767
print.html Normal file
View file

@ -0,0 +1,767 @@
<!DOCTYPE HTML>
<html lang="en" class="navy sidebar-visible" dir="ltr">
<head>
<!-- Book generated using mdBook -->
<meta charset="UTF-8">
<title>yt-offline</title>
<meta name="robots" content="noindex">
<!-- Custom HTML head -->
<meta name="description" content="Self-hosted archive for YouTube, TikTok, Twitch and more. Setup, usage, anti-bot, troubleshooting, and architecture.">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#ffffff">
<link rel="icon" href="favicon-de23e50b.svg">
<link rel="shortcut icon" href="favicon-8114d1fc.png">
<link rel="stylesheet" href="css/variables-8adf115d.css">
<link rel="stylesheet" href="css/general-0392ca55.css">
<link rel="stylesheet" href="css/chrome-fc474251.css">
<link rel="stylesheet" href="css/print-9e4910d8.css" media="print">
<!-- Fonts -->
<link rel="stylesheet" href="fonts/fonts-9644e21d.css">
<!-- Highlight.js Stylesheets -->
<link rel="stylesheet" id="mdbook-highlight-css" href="highlight-493f70e1.css">
<link rel="stylesheet" id="mdbook-tomorrow-night-css" href="tomorrow-night-4c0ae647.css">
<link rel="stylesheet" id="mdbook-ayu-highlight-css" href="ayu-highlight-3fdfc3ac.css">
<!-- Custom theme stylesheets -->
<!-- Provide site root and default themes to javascript -->
<script>
const path_to_root = "";
const default_light_theme = "navy";
const default_dark_theme = "navy";
window.path_to_searchindex_js = "searchindex-4723df7c.js";
</script>
<!-- Start loading toc.js asap -->
<script src="toc-0334b8fe.js"></script>
</head>
<body>
<div id="mdbook-help-container">
<div id="mdbook-help-popup">
<h2 class="mdbook-help-title">Keyboard shortcuts</h2>
<div>
<p>Press <kbd></kbd> or <kbd></kbd> to navigate between chapters</p>
<p>Press <kbd>S</kbd> or <kbd>/</kbd> to search in the book</p>
<p>Press <kbd>?</kbd> to show this help</p>
<p>Press <kbd>Esc</kbd> to hide this help</p>
</div>
</div>
</div>
<div id="mdbook-body-container">
<!-- Work around some values being stored in localStorage wrapped in quotes -->
<script>
try {
let theme = localStorage.getItem('mdbook-theme');
let sidebar = localStorage.getItem('mdbook-sidebar');
if (theme.startsWith('"') && theme.endsWith('"')) {
localStorage.setItem('mdbook-theme', theme.slice(1, theme.length - 1));
}
if (sidebar.startsWith('"') && sidebar.endsWith('"')) {
localStorage.setItem('mdbook-sidebar', sidebar.slice(1, sidebar.length - 1));
}
} catch (e) { }
</script>
<!-- Set the theme before any content is loaded, prevents flash -->
<script>
const default_theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? default_dark_theme : default_light_theme;
let theme;
try { theme = localStorage.getItem('mdbook-theme'); } catch(e) { }
if (theme === null || theme === undefined) { theme = default_theme; }
const html = document.documentElement;
html.classList.remove('navy')
html.classList.add(theme);
html.classList.add("js");
</script>
<input type="checkbox" id="mdbook-sidebar-toggle-anchor" class="hidden">
<!-- Hide / unhide sidebar before it is displayed -->
<script>
let sidebar = null;
const sidebar_toggle = document.getElementById("mdbook-sidebar-toggle-anchor");
if (document.body.clientWidth >= 1080) {
try { sidebar = localStorage.getItem('mdbook-sidebar'); } catch(e) { }
sidebar = sidebar || 'visible';
} else {
sidebar = 'hidden';
sidebar_toggle.checked = false;
}
if (sidebar === 'visible') {
sidebar_toggle.checked = true;
} else {
html.classList.remove('sidebar-visible');
}
</script>
<nav id="mdbook-sidebar" class="sidebar" aria-label="Table of contents">
<!-- populated by js -->
<mdbook-sidebar-scrollbox class="sidebar-scrollbox"></mdbook-sidebar-scrollbox>
<noscript>
<iframe class="sidebar-iframe-outer" src="toc.html"></iframe>
</noscript>
<div id="mdbook-sidebar-resize-handle" class="sidebar-resize-handle">
<div class="sidebar-resize-indicator"></div>
</div>
</nav>
<div id="mdbook-page-wrapper" class="page-wrapper">
<div class="page">
<div id="mdbook-menu-bar-hover-placeholder"></div>
<div id="mdbook-menu-bar" class="menu-bar sticky">
<div class="left-buttons">
<label id="mdbook-sidebar-toggle" class="icon-button" for="mdbook-sidebar-toggle-anchor" title="Toggle Table of Contents" aria-label="Toggle Table of Contents" aria-controls="mdbook-sidebar">
<span class=fa-svg><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2022 Fonticons, Inc. --><path d="M0 96C0 78.3 14.3 64 32 64H416c17.7 0 32 14.3 32 32s-14.3 32-32 32H32C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32H416c17.7 0 32 14.3 32 32s-14.3 32-32 32H32c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32H32c-17.7 0-32-14.3-32-32s14.3-32 32-32H416c17.7 0 32 14.3 32 32z"/></svg></span>
</label>
<button id="mdbook-theme-toggle" class="icon-button" type="button" title="Change theme" aria-label="Change theme" aria-haspopup="true" aria-expanded="false" aria-controls="mdbook-theme-list">
<span class=fa-svg><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--! Font Awesome Free 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2022 Fonticons, Inc. --><path d="M371.3 367.1c27.3-3.9 51.9-19.4 67.2-42.9L600.2 74.1c12.6-19.5 9.4-45.3-7.6-61.2S549.7-4.4 531.1 9.6L294.4 187.2c-24 18-38.2 46.1-38.4 76.1L371.3 367.1zm-19.6 25.4l-116-104.4C175.9 290.3 128 339.6 128 400c0 3.9 .2 7.8 .6 11.6c1.8 17.5-10.2 36.4-27.8 36.4H96c-17.7 0-32 14.3-32 32s14.3 32 32 32H240c61.9 0 112-50.1 112-112c0-2.5-.1-5-.2-7.5z"/></svg></span>
</button>
<ul id="mdbook-theme-list" class="theme-popup" aria-label="Themes" role="menu">
<li role="none"><button role="menuitem" class="theme" id="mdbook-theme-default_theme">Auto</button></li>
<li role="none"><button role="menuitem" class="theme" id="mdbook-theme-light">Light</button></li>
<li role="none"><button role="menuitem" class="theme" id="mdbook-theme-rust">Rust</button></li>
<li role="none"><button role="menuitem" class="theme" id="mdbook-theme-coal">Coal</button></li>
<li role="none"><button role="menuitem" class="theme" id="mdbook-theme-navy">Navy</button></li>
<li role="none"><button role="menuitem" class="theme" id="mdbook-theme-ayu">Ayu</button></li>
</ul>
<button id="mdbook-search-toggle" class="icon-button" type="button" title="Search (`/`)" aria-label="Toggle Searchbar" aria-expanded="false" aria-keyshortcuts="/ s" aria-controls="mdbook-searchbar">
<span class=fa-svg><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2022 Fonticons, Inc. --><path d="M416 208c0 45.9-14.9 88.3-40 122.7L502.6 457.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L330.7 376c-34.4 25.2-76.8 40-122.7 40C93.1 416 0 322.9 0 208S93.1 0 208 0S416 93.1 416 208zM208 352c79.5 0 144-64.5 144-144s-64.5-144-144-144S64 128.5 64 208s64.5 144 144 144z"/></svg></span>
</button>
</div>
<h1 class="menu-title">yt-offline</h1>
<div class="right-buttons">
<a href="print.html" title="Print this book" aria-label="Print this book">
<span class=fa-svg id="print-button"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2022 Fonticons, Inc. --><path d="M128 0C92.7 0 64 28.7 64 64v96h64V64H354.7L384 93.3V160h64V93.3c0-17-6.7-33.3-18.7-45.3L400 18.7C388 6.7 371.7 0 354.7 0H128zM384 352v32 64H128V384 368 352H384zm64 32h32c17.7 0 32-14.3 32-32V256c0-35.3-28.7-64-64-64H64c-35.3 0-64 28.7-64 64v96c0 17.7 14.3 32 32 32H64v64c0 35.3 28.7 64 64 64H384c35.3 0 64-28.7 64-64V384zm-16-88c-13.3 0-24-10.7-24-24s10.7-24 24-24s24 10.7 24 24s-10.7 24-24 24z"/></svg></span>
</a>
<a href="https://codeberg.org/anassaeneroi/yt-offline" title="Git repository" aria-label="Git repository">
<span class=fa-svg><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><!--! Font Awesome Free 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2022 Fonticons, Inc. --><path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"/></svg></span>
</a>
</div>
</div>
<div id="mdbook-search-wrapper" class="hidden">
<form id="mdbook-searchbar-outer" class="searchbar-outer">
<div class="search-wrapper">
<input type="search" id="mdbook-searchbar" name="searchbar" placeholder="Search this book ..." aria-controls="mdbook-searchresults-outer" aria-describedby="searchresults-header">
<div class="spinner-wrapper">
<span class=fa-svg id="fa-spin"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2022 Fonticons, Inc. --><path d="M304 48c0-26.5-21.5-48-48-48s-48 21.5-48 48s21.5 48 48 48s48-21.5 48-48zm0 416c0-26.5-21.5-48-48-48s-48 21.5-48 48s21.5 48 48 48s48-21.5 48-48zM48 304c26.5 0 48-21.5 48-48s-21.5-48-48-48s-48 21.5-48 48s21.5 48 48 48zm464-48c0-26.5-21.5-48-48-48s-48 21.5-48 48s21.5 48 48 48s48-21.5 48-48zM142.9 437c18.7-18.7 18.7-49.1 0-67.9s-49.1-18.7-67.9 0s-18.7 49.1 0 67.9s49.1 18.7 67.9 0zm0-294.2c18.7-18.7 18.7-49.1 0-67.9S93.7 56.2 75 75s-18.7 49.1 0 67.9s49.1 18.7 67.9 0zM369.1 437c18.7 18.7 49.1 18.7 67.9 0s18.7-49.1 0-67.9s-49.1-18.7-67.9 0s-18.7 49.1 0 67.9z"/></svg></span>
</div>
</div>
</form>
<div id="mdbook-searchresults-outer" class="searchresults-outer hidden">
<div id="mdbook-searchresults-header" class="searchresults-header"></div>
<ul id="mdbook-searchresults">
</ul>
</div>
</div>
<!-- Apply ARIA attributes after the sidebar and the sidebar toggle button are added to the DOM -->
<script>
document.getElementById('mdbook-sidebar-toggle').setAttribute('aria-expanded', sidebar === 'visible');
document.getElementById('mdbook-sidebar').setAttribute('aria-hidden', sidebar !== 'visible');
Array.from(document.querySelectorAll('#mdbook-sidebar a')).forEach(function(link) {
link.setAttribute('tabIndex', sidebar === 'visible' ? 0 : -1);
});
</script>
<div id="mdbook-content" class="content">
<main>
<h1 id="yt-offline"><a class="header" href="#yt-offline">yt-offline</a></h1>
<p>A self-hosted media archive for YouTube and friends. Paste any URL, it
routes the download to the right folder by source, tracks what youve
watched, and plays everything back from a desktop GUI <strong>or</strong> a browser —
even offline, or after the source video is taken down.</p>
<p>Built on <a href="https://github.com/yt-dlp/yt-dlp">yt-dlp</a>; written in Rust as a
single binary that is <strong>both</strong> a desktop app and a headless web server.</p>
<h2 id="what-it-backs-up"><a class="header" href="#what-it-backs-up">What it backs up</a></h2>
<div class="table-wrapper">
<table>
<thead>
<tr><th>Platform</th><th>Channels</th><th>Playlists</th><th>Single videos</th></tr>
</thead>
<tbody>
<tr><td>YouTube</td><td></td><td></td><td></td></tr>
<tr><td>TikTok</td><td></td><td></td><td></td></tr>
<tr><td>Twitch (VODs + clips)</td><td></td><td></td><td></td></tr>
<tr><td>Vimeo</td><td></td><td></td><td></td></tr>
<tr><td>Bandcamp</td><td>✅ (artist)</td><td>✅ (albums)</td><td>✅ (tracks)</td></tr>
<tr><td>SoundCloud</td><td></td><td>✅ (sets)</td><td></td></tr>
<tr><td>Odysee</td><td></td><td></td><td></td></tr>
<tr><td>Anything else yt-dlp accepts</td><td><code>other/</code></td><td><code>other/</code></td><td><code>other/</code></td></tr>
</tbody>
</table>
</div>
<h2 id="why-it-exists"><a class="header" href="#why-it-exists">Why it exists</a></h2>
<p>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
doesnt 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.</p>
<h2 id="how-to-read-these-docs"><a class="header" href="#how-to-read-these-docs">How to read these docs</a></h2>
<ul>
<li>New here? <strong><a href="#installation">Installation</a></strong>
<strong><a href="#first-run--configuration">First run</a></strong><strong><a href="#downloading">Downloading</a></strong>.</li>
<li>Hitting captchas or “Video unavailable”? Go straight to
<strong><a href="#staying-ahead-of-youtubes-bot-detection">Anti-bot</a></strong> and <strong><a href="#troubleshooting">Troubleshooting</a></strong>.</li>
<li>Hacking on it? <strong><a href="#architecture">Architecture</a></strong>.</li>
</ul>
<div style="break-before: page; page-break-before: always;"></div>
<h1 id="installation"><a class="header" href="#installation">Installation</a></h1>
<p>yt-offline is a single Rust binary. You can install a prebuilt package,
build from source, or grab the AppImage.</p>
<h2 id="runtime-dependencies"><a class="header" href="#runtime-dependencies">Runtime dependencies</a></h2>
<p>Whichever way you install, these are invoked as subprocesses at runtime:</p>
<ul>
<li><strong>yt-dlp</strong> — the download engine. You can use the system one <em>or</em> let
yt-offline manage a bundled copy (see <a href="#first-run--configuration">First run</a>).</li>
<li><strong>ffmpeg</strong> — muxing, format conversion, on-the-fly transcode for the
web player.</li>
<li><strong>mpv</strong> — the default desktop player (any player taking a file path
works; set it in Settings).</li>
<li><strong>xdg-utils</strong><code>xdg-open</code> for “Show in file manager”.</li>
</ul>
<h2 id="prebuilt-packages-linux"><a class="header" href="#prebuilt-packages-linux">Prebuilt packages (Linux)</a></h2>
<p>Releases attach <code>.deb</code>, <code>.rpm</code>, and <code>.AppImage</code> artifacts.</p>
<pre><code class="language-sh"># 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
</code></pre>
<h2 id="arch--cachyos--manjaro"><a class="header" href="#arch--cachyos--manjaro">Arch / CachyOS / Manjaro</a></h2>
<p>A <code>PKGBUILD</code> ships in the repo root. Build it from a <strong>clean</strong> directory:</p>
<pre><code class="language-sh">mkdir build &amp;&amp; cd build
cp /path/to/repo/PKGBUILD .
makepkg -si
</code></pre>
<p>For repeated builds after pulling new commits, always pass <code>-C</code>
(cleanbuild) so makepkg re-checks out the latest source instead of
reusing a stale cached clone.</p>
<h2 id="from-source"><a class="header" href="#from-source">From source</a></h2>
<pre><code class="language-sh"># 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
</code></pre>
<p><code>python3-venv</code> is only needed for the bundled-yt-dlp install path; skip
it if youll always use system yt-dlp.</p>
<h2 id="windows--macos"><a class="header" href="#windows--macos">Windows / macOS</a></h2>
<p>Not first-class yet. The Linux-only system tray (<code>ksni</code>) and file dialog
(<code>rfd</code> xdg-portal) need per-OS backends before a clean cross-build; the
rest of the stack already compiles. See the
<a href="#platform-support">architecture notes</a>.</p>
<div style="break-before: page; page-break-before: always;"></div>
<h1 id="first-run--configuration"><a class="header" href="#first-run--configuration">First run &amp; configuration</a></h1>
<h2 id="the-config-file"><a class="header" href="#the-config-file">The config file</a></h2>
<p>yt-offline reads <code>config.toml</code> <strong>from its working directory</strong> (the
directory you launch it from), not a fixed path. The same goes for
<code>cookies.txt</code>. Everything in <code>config.toml</code> is also editable in Settings;
edits there are written back to the file.</p>
<pre><code class="language-toml">[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 &lt;name&gt;.original.&lt;ext&gt; after converting
[plex]
library_path = "/path/to/plex/TV/youtube" # leave unset to disable
</code></pre>
<h2 id="the-library-layout"><a class="header" href="#the-library-layout">The library layout</a></h2>
<p>Everything nests under the one <code>backup.directory</code>:</p>
<pre><code class="language-text">&lt;backup.directory&gt;/
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
</code></pre>
<p>Each creator folder gets a hidden <code>.source-url</code> sidecar so re-checks
always know the exact URL to refresh from.</p>
<h2 id="bundled-vs-system-yt-dlp"><a class="header" href="#bundled-vs-system-yt-dlp">Bundled vs system yt-dlp</a></h2>
<p>In <strong>Settings → yt-dlp binary</strong> you choose:</p>
<ul>
<li><strong>System</strong> — uses whatever <code>yt-dlp</code> is on your <code>PATH</code>.</li>
<li><strong>Bundled</strong> — click <strong>Install</strong> and yt-offline builds a self-contained
venv at <code>~/.local/share/yt-offline/</code>: nightly <code>yt-dlp[default]</code> +
<code>curl_cffi</code> (TLS impersonation) + a bundled <code>deno</code> (player-JS). The
same button updates it later.</li>
</ul>
<p>The bundled path is recommended — it installs <strong>nightly</strong> yt-dlp, which
keeps pace with YouTubes frequent anti-bot changes (stable lags). Its
also required for the <a href="#3-pot-tokens-proof-of-origin">POT token provider</a>.</p>
<h2 id="the-two-front-ends"><a class="header" href="#the-two-front-ends">The two front-ends</a></h2>
<ul>
<li><code>yt-offline</code> — desktop GUI (eframe/egui).</li>
<li><code>yt-offline --web [PORT]</code> — headless web server. Bind to <code>127.0.0.1</code>
(default) for localhost-only, a Tailscale address for your tailnet, or
<code>0.0.0.0</code> for the LAN. <strong>Set a password</strong> (Settings) before exposing it
beyond localhost — the UI and all <code>/api</code> routes are then gated behind an
Argon2-hashed, rate-limited login.</li>
</ul>
<div style="break-before: page; page-break-before: always;"></div>
<h1 id="downloading"><a class="header" href="#downloading">Downloading</a></h1>
<h2 id="starting-a-download"><a class="header" href="#starting-a-download">Starting a download</a></h2>
<p>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.</p>
<p><strong>Quality picker:</strong> Best / 1080p / 720p / 480p / 360p, or <strong>Music mode</strong>
for audio-only extraction into <code>music/&lt;artist&gt;/</code>.</p>
<p><strong>Fast mode</strong> stops at the first already-downloaded video (quick routine
re-checks). Turn it off for a full gap-filling scan.</p>
<h2 id="per-channel-options"><a class="header" href="#per-channel-options">Per-channel options</a></h2>
<p>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:</p>
<ul>
<li>Quality cap, audio-only, bandwidth cap, min/max file size, date cutoff.</li>
<li>A free-form <code>--match-filter</code> (e.g. <code>duration &gt; 60 &amp; view_count &gt; 100</code>).</li>
<li>Subtitle overrides, YouTube player-client override, post-download
convert mode — each defaulting to the global setting.</li>
<li><strong>Skip auth check</strong> — silences yt-dlps “playlists that require
authentication” warning for <strong>public</strong> channels (see
<a href="#the-youtubetab-authentication-warning">Troubleshooting</a>).</li>
</ul>
<p>Per-channel options ride along in library backup/restore.</p>
<h2 id="subtitles"><a class="header" href="#subtitles">Subtitles</a></h2>
<p>Global defaults (Settings → Subtitles) + per-channel overrides control:
download on/off, auto-generated captions, embedding into the container,
language filter, and format conversion (<code>srt</code> is the most
Plex/player-compatible). Subtitles are written as sidecar files and
optionally embedded.</p>
<h2 id="format-conversion"><a class="header" href="#format-conversion">Format conversion</a></h2>
<p>A post-download ffmpeg pass (Settings → Format conversion, or per
channel):</p>
<ul>
<li><strong>Remux → mp4</strong> — instant container change, no re-encode (device/Plex
compatibility).</li>
<li><strong>Re-encode → H.264 mp4</strong> — shrink large 4K files at a chosen CRF +
x264 preset.</li>
<li><strong>Extract audio</strong> — mp3 / m4a / opus / flac.</li>
</ul>
<p>It runs as a distinct transcode job after the download. <strong>Keep original</strong>
preserves <code>&lt;name&gt;.original.&lt;ext&gt;</code> alongside the converted file; otherwise
the source is removed once the convert succeeds.</p>
<h2 id="resilience"><a class="header" href="#resilience">Resilience</a></h2>
<p>Downloads are hardened against YouTubes flakiness automatically:</p>
<ul>
<li><strong>Retry + backoff</strong> on connection resets (<code>--retries 30</code>, linear
retry-sleep).</li>
<li><strong>Jittered throttle</strong> between videos so a long channel scan doesnt
look robotic and trip the captcha wall.</li>
<li><strong>Auto-retry</strong> of transient (rate-limit / network / captcha) failures
after a cooldown, with adaptive slow-down for the rest of the batch.</li>
<li><strong>Hang watchdog</strong> kills a job that produces no output for 5 minutes (a
wedged request) and re-queues it.</li>
</ul>
<p>Failures are classified and shown with a one-line suggested fix — see
<a href="#troubleshooting">Troubleshooting</a>.</p>
<h2 id="scheduler"><a class="header" href="#scheduler">Scheduler</a></h2>
<p>Enable it (Settings → Auto-check channels) to re-check every channel for
new uploads on an interval. Each channel uses its own stored options.
Theres also a per-folder “Check all” action.</p>
<div style="break-before: page; page-break-before: always;"></div>
<h1 id="staying-ahead-of-youtubes-bot-detection"><a class="header" href="#staying-ahead-of-youtubes-bot-detection">Staying ahead of YouTubes bot detection</a></h1>
<p>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.”</p>
<p>In rough order of impact:</p>
<h2 id="1-be-logged-in-cookies"><a class="header" href="#1-be-logged-in-cookies">1. Be logged in (cookies)</a></h2>
<p>This is the single biggest factor. <strong>Anonymous requests get captcha-
walled the hardest.</strong> Provide a <code>cookies.txt</code> exported from a browser
where you are <strong>signed in to YouTube</strong>, or point yt-offline at the
browser profile directly.</p>
<p>A <em>valid</em> logged-in jar contains the auth cookies <code>SID</code>, <code>SAPISID</code>,
<code>__Secure-1PSID</code>, <code>__Secure-3PSID</code>, <code>LOGIN_INFO</code>, etc. A jar with only
<code>VISITOR_INFO1_LIVE</code>, <code>PREF</code>, <code>YSC</code> is <strong>anonymous</strong> — its not signed in
and actually makes detection <em>worse</em> than no cookies. yt-offlines
Settings → Cookies panel warns you when your jar is anonymous or expired.</p>
<p>Two ways to supply cookies:</p>
<ul>
<li>
<p><strong>Export a <code>cookies.txt</code></strong> with a browser extension like <em>Get
cookies.txt LOCALLY</em>, then paste/upload it in Settings → Cookies. A
file in the working directory takes precedence over the browser option.</p>
</li>
<li>
<p><strong>Read the browser profile live</strong> by setting the cookie browser to a
yt-dlp <code>--cookies-from-browser</code> spec. Plain <code>firefox</code>/<code>chrome</code>/<code>brave</code>
works for default profiles; for a non-default profile (e.g. Braves
beta channel) use the full form:</p>
<pre><code>brave:/home/you/.config/BraveSoftware/Brave-Origin-Beta
</code></pre>
<p>The path is the <strong>profile root</strong> (yt-dlp appends the <code>Default</code>
subdirectory itself). The advantage: cookies are read fresh from the
live session each download, so they never go stale.</p>
</li>
</ul>
<blockquote>
<p>Cookies are session credentials — yt-offline never commits or transmits
<code>cookies.txt</code> unprompted, and redacts the cookie path out of any log
line shown in the UI.</p>
</blockquote>
<h2 id="2-tls-impersonation-curl_cffi"><a class="header" href="#2-tls-impersonation-curl_cffi">2. TLS impersonation (curl_cffi)</a></h2>
<p>yt-dlps <code>--impersonate</code> makes requests carry a real browsers TLS
fingerprint (via <code>curl_cffi</code>), so the connection doesnt <em>look</em> like a
script. The bundled install sets this up automatically and yt-offline
picks an impersonation target per platform.</p>
<p>If impersonation silently does nothing, its almost always a
<strong>yt-dlp ⇄ curl_cffi version mismatch</strong> — which is exactly why the
bundled install uses <strong>nightly</strong> yt-dlp (it accepts current curl_cffi;
stable lags and disables all impersonate targets when a newer curl_cffi
is present). See
<a href="#impersonate-targets-show-unavailable">Troubleshooting → impersonation</a>.</p>
<h2 id="3-pot-tokens-proof-of-origin"><a class="header" href="#3-pot-tokens-proof-of-origin">3. POT tokens (Proof-of-Origin)</a></h2>
<p>YouTube increasingly binds a per-video <strong>Proof-of-Origin token</strong> to
playback; without one, format URLs come back empty. yt-offline can run
<a href="https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs">bgutil-pot</a>, a
loopback HTTP server that mints these tokens, and point yt-dlp at it.</p>
<p>Enable <strong>Settings → Use POT token provider</strong> (requires the bundled
yt-dlp; the matching plugin installs into its venv) and click <strong>Install</strong>.</p>
<blockquote>
<p><strong>Version-skew footgun:</strong> the yt-dlp plugin must come from the <em>same
release</em> as the bgutil-pot server binary — <strong>not</strong> the PyPI package,
which versions independently and silently produces no tokens on a
mismatch. yt-offlines installer handles this by unpacking the
version-matched plugin zip from the servers release.</p>
</blockquote>
<h2 id="4-player-client-selection"><a class="header" href="#4-player-client-selection">4. Player-client selection</a></h2>
<p>YouTube cracks down on different internal “player clients” over time —
the <code>web</code> client is currently the most captcha-prone, while <code>tv</code> and
<code>mweb</code> are the least. yt-offline no longer forces <code>web</code>; it lets yt-dlp
pick good defaults. If a specific channel keeps hitting captchas, set a
client override (global or per-channel):</p>
<pre><code>tv,mweb
</code></pre>
<h2 id="5-throttling"><a class="header" href="#5-throttling">5. Throttling</a></h2>
<p>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.</p>
<hr>
<p><strong>TL;DR for a clean setup:</strong> bundled (nightly) yt-dlp + fresh
<strong>logged-in</strong> cookies + POT provider enabled. That combination resolves
the vast majority of captcha / “Video unavailable” failures.</p>
<div style="break-before: page; page-break-before: always;"></div>
<h1 id="troubleshooting"><a class="header" href="#troubleshooting">Troubleshooting</a></h1>
<p>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.</p>
<h2 id="video-unavailable-youtube-is-requiring-a-captcha-challenge"><a class="header" href="#video-unavailable-youtube-is-requiring-a-captcha-challenge">“Video unavailable. YouTube is requiring a captcha challenge”</a></h2>
<p><strong>Class:</strong> rate-limited. <strong>Not</strong> a removed video — its the bot-detection
wall. In order of effectiveness:</p>
<ol>
<li><strong>Use fresh, logged-in cookies.</strong> Anonymous cookies are the usual
culprit — see <a href="#1-be-logged-in-cookies">Anti-bot → cookies</a>.
Settings → Cookies warns when your jar is anonymous or expired.</li>
<li><strong>Switch to bundled (nightly) yt-dlp</strong> if youre on system stable.</li>
<li><strong>Enable the POT token provider.</strong></li>
<li><strong>Try a player-client override</strong> of <code>tv,mweb</code> for that channel.</li>
<li>If its a one-off, just wait — yt-offline auto-retries transient
rate-limits after a cooldown.</li>
</ol>
<h2 id="impersonate-targets-show-unavailable"><a class="header" href="#impersonate-targets-show-unavailable">Impersonate targets show “(unavailable)”</a></h2>
<p><code>yt-dlp --list-impersonate-targets</code> lists every target as <code>(unavailable)</code>
even though <code>curl_cffi</code> is installed.</p>
<p><strong>Cause:</strong> a yt-dlp ⇄ curl_cffi version gate. Stable yt-dlp caps the
curl_cffi version it accepts; a newer curl_cffi makes it disable <em>all</em>
impersonate targets.</p>
<p><strong>Fix:</strong> use the <strong>bundled</strong> yt-dlp (it installs nightly via <code>--pre</code>,
which accepts current curl_cffi), or pin curl_cffi to a compatible
version in your own environment.</p>
<h2 id="pot-provider-produces-no-tokens"><a class="header" href="#pot-provider-produces-no-tokens">POT provider produces no tokens</a></h2>
<p>You enabled the POT provider and installed it, but downloads still fail
as if no token was generated. yt-dlp logs a <em>“plugin and HTTP server
major versions are mismatched”</em> warning.</p>
<p><strong>Cause:</strong> the yt-dlp plugin came from PyPI (Brainicisms package, which
versions independently) instead of the jim60105 Rust servers release.</p>
<p><strong>Fix:</strong> re-run the POT <strong>Install/Update</strong> button — yt-offline installs
the version-matched plugin zip from the same release as the server
binary. Dont <code>pip install bgutil-ytdlp-pot-provider</code> yourself.</p>
<h2 id="the-youtubetab-authentication-warning"><a class="header" href="#the-youtubetab-authentication-warning">The youtubetab authentication warning</a></h2>
<pre><code>ERROR: [youtube:tab] @Channel: Playlists that require authentication may
not extract correctly without a successful webpage download...
</code></pre>
<p>Despite the <code>ERROR:</code> prefix this is a soft warning, usually a symptom of
the bot-detection issues above (YouTube served an incomplete page). It
does <strong>not</strong> change which videos are found.</p>
<p><strong>Fix:</strong> for <strong>public</strong> channels, enable <strong>Skip auth check</strong> in that
channels options (adds <code>--extractor-args youtubetab:skip=authcheck</code>) to
silence it. Leave it <strong>off</strong> for members-only/private channels you
archive with cookies — there the warning is a real “your cookies may not
be working” signal.</p>
<h2 id="sign-in-to-confirm-youre-not-a-bot"><a class="header" href="#sign-in-to-confirm-youre-not-a-bot">“Sign in to confirm youre not a bot”</a></h2>
<p>Same family as the captcha wall. Fix with fresh logged-in cookies + POT;
see <a href="#staying-ahead-of-youtubes-bot-detection">Anti-bot</a>.</p>
<h2 id="downloads-stall-forever"><a class="header" href="#downloads-stall-forever">Downloads stall forever</a></h2>
<p>A job sits running with no progress. yt-offlines <strong>hang watchdog</strong>
auto-kills any job silent for 5 minutes and re-queues it, so this should
self-heal. If it recurs on a specific URL, its usually a server-side
issue with that source; check the job log in the Downloads panel.</p>
<h2 id="disk-fills-up--downloads-fail-with-enospc"><a class="header" href="#disk-fills-up--downloads-fail-with-enospc">Disk fills up / downloads fail with ENOSPC</a></h2>
<p>yt-offline runs a <strong>disk-full preflight</strong> 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.</p>
<h2 id="a-whole-platform-folder-shows-up-as-one-channel"><a class="header" href="#a-whole-platform-folder-shows-up-as-one-channel">A whole platform folder shows up as one “channel”</a></h2>
<p>If you see <code>bandcamp</code>, <code>tiktok</code>, or <code>channels</code> listed as a single channel
in the sidebar, your library directory predates the current layout. All
platforms must <strong>nest under</strong> the one <code>backup.directory</code>
(<code>&lt;dir&gt;/channels/</code>, <code>&lt;dir&gt;/tiktok/</code>, …). Move stray creator folders into
their platforms subdir; see <a href="#the-library-layout">First run → library layout</a>.</p>
<h2 id="the-desktop-window-crashes-on-maximize"><a class="header" href="#the-desktop-window-crashes-on-maximize">The desktop window crashes on maximize</a></h2>
<p>Older builds crashed with a Glutin <code>EGL_BAD_ALLOC</code> on NVIDIA + Wayland
when maximized. Current builds use the <strong>wgpu (Vulkan)</strong> renderer, which
handles the resize cleanly. Make sure you have a working Vulkan driver
(<code>vulkan-icd-loader</code> + your GPUs Vulkan package), which any desktop with
working graphics already has.</p>
<h2 id="the-web-ui-looks-like-an-old-version-after-an-upgrade"><a class="header" href="#the-web-ui-looks-like-an-old-version-after-an-upgrade">The web UI looks like an old version after an upgrade</a></h2>
<p>The SPA is served <code>Cache-Control: no-store</code>, so a hard reload
(Ctrl+Shift+R) always picks up the new binarys UI. If you upgraded the
binary, also <strong>restart the running <code>--web</code> process</strong> — the HTML is baked
into the binary at compile time, so the old process keeps serving the old
UI until restarted.</p>
<h2 id="where-to-look-next"><a class="header" href="#where-to-look-next">Where to look next</a></h2>
<ul>
<li><strong>The job log</strong> — every download/transcode job keeps its full yt-dlp /
ffmpeg output in the Downloads panel (expand the job).</li>
<li><strong><code>yt-offline.crash.log</code></strong> — next to your <code>yt-offline.db</code>. A panic in
any thread (UI, web worker, download) is appended here with a
timestamp, so it survives a GUI launched without a terminal.</li>
</ul>
<div style="break-before: page; page-break-before: always;"></div>
<h1 id="architecture"><a class="header" href="#architecture">Architecture</a></h1>
<p>For contributors. The repos <code>CLAUDE.md</code> is the terse version of this;
read both.</p>
<h2 id="two-front-ends-one-engine"><a class="header" href="#two-front-ends-one-engine">Two front-ends, one engine</a></h2>
<p><code>main.rs</code> dispatches: <code>--web</code><code>web::run()</code> (axum, blocks forever);
otherwise <code>app::App</code> (eframe/egui desktop GUI). <strong>Both share
<code>downloader::Downloader</code></strong>, the single source of truth for the yt-dlp job
lifecycle.</p>
<p><code>Downloader</code> is <strong>not</strong> async — it spawns an OS thread per yt-dlp process,
streams stdout/stderr back over an <code>mpsc</code> channel into each <code>Job</code>s log
buffer, and the caller pumps <code>Downloader::poll()</code> regularly (the egui
frame loop and a web background task both do). Anything you add to
<code>Downloader</code> is automatically available to both UIs.</p>
<p><code>poll()</code> 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 <code>Job</code> at <code>start()</code> time (<code>RetrySpec</code>, <code>ConvertSpec</code>) and acting
on them when the job changes state.</p>
<h2 id="the-settings-flow-the-easy-thing-to-get-wrong"><a class="header" href="#the-settings-flow-the-easy-thing-to-get-wrong">The settings flow (the easy thing to get wrong)</a></h2>
<p>Almost every configurable feature has the same five-touchpoint shape —
miss one and it silently half-works:</p>
<ol>
<li><code>config.rs</code> — a field/section + its <code>Default</code> + the <code>default_with_dir</code>
constructor.</li>
<li><code>download_options.rs</code> — an <code>Option&lt;&gt;</code> per-channel override (None =
defer to global).</li>
<li><code>downloader.rs</code> — a resolver merging global config + per-channel
override into yt-dlp/ffmpeg args, plus a <code>pub</code> field on <code>Downloader</code>
holding the global default.</li>
<li><strong>Both</strong> UIs render the global setting <em>and</em> the per-channel override:
desktop in <code>app.rs</code>, web in <code>web_ui/index.html</code> <strong>and</strong> <code>web.rs</code>s
<code>SettingsPayload</code> (GET reads config, POST writes config + pushes onto
the live <code>Downloader</code>).</li>
<li>Seed the <code>Downloader</code> field at construction <strong>and</strong> on settings-save,
in <strong>both</strong> <code>app.rs</code> and <code>web.rs</code>.</li>
</ol>
<p><code>subtitle_defaults</code>, <code>youtube_player_clients</code>, and <code>convert_defaults</code> are
complete worked examples — grep one end-to-end before adding a setting.</p>
<h2 id="filesystem-layout"><a class="header" href="#filesystem-layout">Filesystem layout</a></h2>
<p><code>platform::platform_root(channels_root, platform)</code> =
<code>channels_root.join(dir_name)</code>. <strong>All</strong> platforms (including YouTube,
whose <code>dir_name</code> is <code>channels</code>) nest under the one configured
<code>backup.directory</code>. <code>.source-url</code> sidecars in each creator folder let
re-checks recover the exact URL. Library scanning (<code>library.rs</code>) is
parallel and consults a <code>(path, mtime)</code> SQLite cache to skip re-parsing
unchanged <code>info.json</code> sidecars.</p>
<h2 id="persistence"><a class="header" href="#persistence">Persistence</a></h2>
<p><code>database.rs</code> wraps an r2d2 SQLite pool. <code>Database</code> is cheaply <code>Clone</code>
(the pool is an <code>Arc</code>), so the parallel scanner takes its own handle.
Schema lives in <code>init_schema()</code>; new columns are added via idempotent
<code>ALTER TABLE … ADD COLUMN</code> 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 <code>/api/library</code> ETag) so reads stay consistent without a
rescan.</p>
<p>The long-lived <code>WebState</code> mutexes are accessed via
<code>util::LockExt::lock_recover()</code>, which recovers a poisoned lock instead of
cascading one handlers panic into a dead server.</p>
<h2 id="web-ui-is-one-embedded-file"><a class="header" href="#web-ui-is-one-embedded-file">Web UI is one embedded file</a></h2>
<p><code>web_ui/index.html</code> is the entire SPA (HTML+CSS+JS), <code>include_str!</code>-baked
into the binary at compile time — editing it requires a rebuild. Served
<code>Cache-Control: no-store</code> so binary upgrades dont strand stale tabs.
Progress streams over <code>/ws/progress</code> (WebSocket) with an HTTP-poll
fallback.</p>
<h2 id="anti-bot-subsystems"><a class="header" href="#anti-bot-subsystems">Anti-bot subsystems</a></h2>
<p><code>ytdlp_bin.rs</code> manages the optional self-contained venv at
<code>~/.local/share/yt-offline/</code> (nightly <code>yt-dlp[default]</code> + <code>curl_cffi</code> +
bundled <code>deno</code>). <code>pot_provider.rs</code> runs <code>bgutil-pot</code> for Proof-of-Origin
tokens — its yt-dlp plugin must come from the same release as the server
binary. <code>error_class.rs</code> pattern-matches yt-dlp stderr into actionable
classes (order matters in <code>classify()</code>: the captcha “Video unavailable”
wall is RateLimited, not NotFound).</p>
<h2 id="tests"><a class="header" href="#tests">Tests</a></h2>
<ul>
<li>Unit tests are inline <code>#[cfg(test)]</code> modules (parsers, resolvers, the
error classifier, DB merge logic).</li>
<li><code>tests/api.rs</code> spawns the <strong>real</strong> <code>--web</code> binary against a scratch dir
and drives the HTTP API with curl — genuine end-to-end coverage of the
axum + SQLite + config stack.</li>
</ul>
<p><code>cargo test</code> runs both. (A <code>.forgejo/workflows/test.yml</code> CI definition
exists, but Codeberg runs Woodpecker rather than Forgejo Actions, so it
doesnt execute there without a self-hosted runner — run the suite
locally.)</p>
<h2 id="platform-support"><a class="header" href="#platform-support">Platform support</a></h2>
<p>Tray (<code>ksni</code>) and file dialogs (<code>rfd</code> xdg-portal) are Linux-only / no-GTK
by design — thats why packaging avoids a GTK dependency. Windows/macOS
arent 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 <code>ytdlp_bin</code> already has <code>cfg!(windows)</code>
branches.</p>
<div style="break-before: page; page-break-before: always;"></div>
<h1 id="packaging"><a class="header" href="#packaging">Packaging</a></h1>
<p>Build distributable Linux packages with one script:</p>
<pre><code class="language-sh">scripts/package.sh all # .deb + .rpm + .AppImage → dist/
scripts/package.sh deb # just the .deb
scripts/package.sh rpm
scripts/package.sh appimage
</code></pre>
<p>It builds the release binary once and reuses it for every format,
installing <code>cargo-deb</code> / <code>cargo-generate-rpm</code> on demand and downloading
<code>appimagetool</code> to <code>dist/tools/</code> on first AppImage build. Per-format
failures are isolated and summarized at the end. Output (gitignored)
lands in <code>dist/</code>.</p>
<h2 id="formats"><a class="header" href="#formats">Formats</a></h2>
<ul>
<li><strong><code>.deb</code></strong> — built by <a href="https://github.com/kornelski/cargo-deb">cargo-deb</a>
from <code>[package.metadata.deb]</code> in <code>Cargo.toml</code>.</li>
<li><strong><code>.rpm</code></strong> — built by
<a href="https://github.com/cat-in-136/cargo-generate-rpm">cargo-generate-rpm</a>
from <code>[package.metadata.generate-rpm]</code>. (<code>ffmpeg</code> on Fedora is in
RPM Fusion.)</li>
<li><strong>AppImage</strong> — a hand-rolled AppDir + appimagetool. Bundles the GUI
binarys shared-library closure only; <code>yt-dlp</code>/<code>ffmpeg</code>/<code>mpv</code> stay host
PATH deps, same as the package declarations.</li>
<li><strong>Arch</strong> — use the repos <code>PKGBUILD</code> (not this script); run <code>makepkg</code>
from a clean directory.</li>
</ul>
<h2 id="ci"><a class="header" href="#ci">CI</a></h2>
<p>The repo ships <code>.forgejo/workflows/</code> definitions (<code>test.yml</code>,
<code>release.yml</code>), but Codeberg executes Woodpecker rather than Forgejo
Actions — so they dont run there without a self-hosted runner. Until
then, build packages locally with <code>scripts/package.sh</code> and publish docs
with <code>scripts/publish-docs.sh</code>.</p>
<p>The repos <a href="https://codeberg.org/anassaeneroi/yt-offline/src/branch/main/docs/PACKAGING.md"><code>docs/PACKAGING.md</code></a>
has the per-distro install commands and the Windows/macOS status in full.</p>
</main>
<nav class="nav-wrapper" aria-label="Page navigation">
<!-- Mobile navigation buttons -->
<div style="clear: both"></div>
</nav>
</div>
</div>
<nav class="nav-wide-wrapper" aria-label="Page navigation">
</nav>
</div>
<template id=fa-eye><span class=fa-svg><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--! Font Awesome Free 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2022 Fonticons, Inc. --><path d="M288 32c-80.8 0-145.5 36.8-192.6 80.6C48.6 156 17.3 208 2.5 243.7c-3.3 7.9-3.3 16.7 0 24.6C17.3 304 48.6 356 95.4 399.4C142.5 443.2 207.2 480 288 480s145.5-36.8 192.6-80.6c46.8-43.5 78.1-95.4 93-131.1c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C433.5 68.8 368.8 32 288 32zM432 256c0 79.5-64.5 144-144 144s-144-64.5-144-144s64.5-144 144-144s144 64.5 144 144zM288 192c0 35.3-28.7 64-64 64c-11.5 0-22.3-3-31.6-8.4c-.2 2.8-.4 5.5-.4 8.4c0 53 43 96 96 96s96-43 96-96s-43-96-96-96c-2.8 0-5.6 .1-8.4 .4c5.3 9.3 8.4 20.1 8.4 31.6z"/></svg></span></template>
<template id=fa-eye-slash><span class=fa-svg><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><!--! Font Awesome Free 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2022 Fonticons, Inc. --><path d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L525.6 386.7c39.6-40.6 66.4-86.1 79.9-118.4c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C465.5 68.8 400.8 32 320 32c-68.2 0-125 26.3-169.3 60.8L38.8 5.1zM223.1 149.5C248.6 126.2 282.7 112 320 112c79.5 0 144 64.5 144 144c0 24.9-6.3 48.3-17.4 68.7L408 294.5c5.2-11.8 8-24.8 8-38.5c0-53-43-96-96-96c-2.8 0-5.6 .1-8.4 .4c5.3 9.3 8.4 20.1 8.4 31.6c0 10.2-2.4 19.8-6.6 28.3l-90.3-70.8zm223.1 298L373 389.9c-16.4 6.5-34.3 10.1-53 10.1c-79.5 0-144-64.5-144-144c0-6.9 .5-13.6 1.4-20.2L83.1 161.5C60.3 191.2 44 220.8 34.5 243.7c-3.3 7.9-3.3 16.7 0 24.6c14.9 35.7 46.2 87.7 93 131.1C174.5 443.2 239.2 480 320 480c47.8 0 89.9-12.9 126.2-32.5z"/></svg></span></template>
<template id=fa-copy><span class=fa-svg><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2022 Fonticons, Inc. --><path d="M502.6 70.63l-61.25-61.25C435.4 3.371 427.2 0 418.7 0H255.1c-35.35 0-64 28.66-64 64l.0195 256C192 355.4 220.7 384 256 384h192c35.2 0 64-28.8 64-64V93.25C512 84.77 508.6 76.63 502.6 70.63zM464 320c0 8.836-7.164 16-16 16H255.1c-8.838 0-16-7.164-16-16L239.1 64.13c0-8.836 7.164-16 16-16h128L384 96c0 17.67 14.33 32 32 32h47.1V320zM272 448c0 8.836-7.164 16-16 16H63.1c-8.838 0-16-7.164-16-16L47.98 192.1c0-8.836 7.164-16 16-16H160V128H63.99c-35.35 0-64 28.65-64 64l.0098 256C.002 483.3 28.66 512 64 512h192c35.2 0 64-28.8 64-64v-32h-47.1L272 448z"/></svg></span></template>
<template id=fa-play><span class=fa-svg><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--! Font Awesome Free 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2022 Fonticons, Inc. --><path d="M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80V432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z"/></svg></span></template>
<template id=fa-clock-rotate-left><span class=fa-svg><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2022 Fonticons, Inc. --><path d="M75 75L41 41C25.9 25.9 0 36.6 0 57.9V168c0 13.3 10.7 24 24 24H134.1c21.4 0 32.1-25.9 17-41l-30.8-30.8C155 85.5 203 64 256 64c106 0 192 86 192 192s-86 192-192 192c-40.8 0-78.6-12.7-109.7-34.4c-14.5-10.1-34.4-6.6-44.6 7.9s-6.6 34.4 7.9 44.6C151.2 495 201.7 512 256 512c141.4 0 256-114.6 256-256S397.4 0 256 0C185.3 0 121.3 28.7 75 75zm181 53c-13.3 0-24 10.7-24 24V256c0 6.4 2.5 12.5 7 17l72 72c9.4 9.4 24.6 9.4 33.9 0s9.4-24.6 0-33.9l-65-65V152c0-13.3-10.7-24-24-24z"/></svg></span></template>
<script>
window.playground_copyable = true;
</script>
<script src="elasticlunr-ef4e11c1.min.js"></script>
<script src="mark-09e88c2c.min.js"></script>
<script src="searcher-09f2665d.js"></script>
<script src="clipboard-1626706a.min.js"></script>
<script src="highlight-abc7f01d.js"></script>
<script src="book-c22b7243.js"></script>
<!-- Custom JS scripts -->
<script>
window.addEventListener('load', function() {
window.setTimeout(window.print, 100);
});
</script>
</div>
</body>
</html>