catacomb/print.html
2026-06-07 03:32:00 -07:00

767 lines
54 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>