docs: rename yt-offline → Catacomb across README, ROADMAP, guides, packaging

Prose + commands updated to the new name: brand reads "Catacomb", while
binary/path/package references are the lowercase `catacomb` (commands, deb/rpm
names, ~/.local/share/catacomb, catacomb.db, catacomb.desktop). Codeberg repo
URLs left pointing at the existing repo. Also folds in the pending doc edits
from earlier in the session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-06-19 03:34:18 -07:00
parent 404362bfb0
commit 3f611849af
72 changed files with 1513 additions and 293 deletions

View file

@ -42,7 +42,7 @@ jobs:
- name: Add Windows cross-compile target - name: Add Windows cross-compile target
# package.sh's `all` only builds the Windows zip when this target + # package.sh's `all` only builds the Windows zip when this target +
# mingw + zip are all present (win_available), so this step is what # mingw + zip are all present (win_available), so this step is what
# opts the release build into producing yt-offline-*-x86_64-windows.zip. # opts the release build into producing catacomb-*-x86_64-windows.zip.
run: rustup target add x86_64-pc-windows-gnu run: rustup target add x86_64-pc-windows-gnu
- name: Cache cargo registry + build - name: Cache cargo registry + build
@ -87,7 +87,7 @@ jobs:
- `.deb` — Debian / Ubuntu / Mint - `.deb` — Debian / Ubuntu / Mint
- `.rpm` — Fedora / RHEL / openSUSE - `.rpm` — Fedora / RHEL / openSUSE
- `.AppImage` — any Linux (chmod +x and run) - `.AppImage` — any Linux (chmod +x and run)
- `-x86_64-windows.zip` — Windows 10/11 (unzip, run yt-offline.exe) - `-x86_64-windows.zip` — Windows 10/11 (unzip, run catacomb.exe)
Runtime requirements: yt-dlp, ffmpeg, mpv on PATH (plus Runtime requirements: yt-dlp, ffmpeg, mpv on PATH (plus
xdg-utils on Linux). The Windows zip's README lists the same. xdg-utils on Linux). The Windows zip's README lists the same.

6
.gitignore vendored
View file

@ -9,8 +9,8 @@
# makepkg build artifacts (finished .pkg.tar.zst is NOT ignored) # makepkg build artifacts (finished .pkg.tar.zst is NOT ignored)
/pkg/ /pkg/
/src/yt-offline/ /src/catacomb/
/yt-offline/ /catacomb/
# Rendered mdBook output (source lives in docs/src/) # Rendered mdBook output (source lives in docs/src/)
/docs/book/ /docs/book/
@ -27,4 +27,4 @@ cookies.txt
config.toml config.toml
# Runtime database (watched status, positions, password hash) — never commit # Runtime database (watched status, positions, password hash) — never commit
yt-offline.db catacomb.db

8
.vscode/launch.json vendored
View file

@ -5,25 +5,25 @@
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Debug executable 'yt-offline'", "name": "Debug executable 'catacomb'",
"type": "lldb", "type": "lldb",
"request": "launch", "request": "launch",
"cargo": { "cargo": {
"args": [ "args": [
"run", "run",
"--bin=yt-offline" "--bin=catacomb"
] ]
}, },
"args": [] "args": []
}, },
{ {
"name": "Debug unit tests in executable 'yt-offline'", "name": "Debug unit tests in executable 'catacomb'",
"type": "lldb", "type": "lldb",
"request": "launch", "request": "launch",
"cargo": { "cargo": {
"args": [ "args": [
"test", "test",
"--bin=yt-offline" "--bin=catacomb"
] ]
} }
} }

View file

@ -4,7 +4,7 @@ This file provides guidance to Codex (Codex.ai/code) when working with code in t
## What this is ## What this is
`yt-offline` — a single Rust binary that is **both** a desktop GUI (eframe/egui) `catacomb` — a single Rust binary that is **both** a desktop GUI (eframe/egui)
and a headless web server (axum), wrapping `yt-dlp` to archive YouTube/TikTok/ and a headless web server (axum), wrapping `yt-dlp` to archive YouTube/TikTok/
Twitch/etc. AGPL-3.0. North-star goal and feature-parity tracking live in Twitch/etc. AGPL-3.0. North-star goal and feature-parity tracking live in
[ROADMAP.md](ROADMAP.md); a structured analysis of the Tartube benchmark is in [ROADMAP.md](ROADMAP.md); a structured analysis of the Tartube benchmark is in
@ -17,8 +17,8 @@ cargo build --release # the binary (release profile is opt-level=3 + t
cargo test --release # unit tests + tests/api.rs integration tests (no network) cargo test --release # unit tests + tests/api.rs integration tests (no network)
cargo test --release <name> # single test by substring, e.g. `cargo test --release subs_disabled` cargo test --release <name> # single test by substring, e.g. `cargo test --release subs_disabled`
cargo test --release real_ffmpeg -- --ignored # opt-in: real-ffmpeg fingerprint accuracy/speed check cargo test --release real_ffmpeg -- --ignored # opt-in: real-ffmpeg fingerprint accuracy/speed check
./target/release/yt-offline # desktop GUI mode (default) ./target/release/catacomb # desktop GUI mode (default)
./target/release/yt-offline --web 8080 # headless web server on a port ./target/release/catacomb --web 8080 # headless web server on a port
scripts/package.sh [deb|rpm|appimage|all] # build distro packages → dist/ (see docs/PACKAGING.md) scripts/package.sh [deb|rpm|appimage|all] # build distro packages → dist/ (see docs/PACKAGING.md)
``` ```
@ -103,6 +103,12 @@ in memory and mutating endpoints mirror DB writes onto those caches +
`bump_library_version()` (the ETag) so `/api/library` stays consistent without a `bump_library_version()` (the ETag) so `/api/library` stays consistent without a
rescan. rescan.
Web login sessions are runtime-owned but restart-persistent: `web.rs` keeps the
live `HashMap<String, u64>` and mirrors tokens into the `sessions` table, then
rehydrates non-expired rows at startup. Logout and password changes clear those
rows. The DB is plain SQLite, not encrypted, so `/api/backup/db` exports a
sensitive file; at-rest DB encryption is deliberately deferred to Phase 4.
Three `(path|video_id, mtime)`-keyed caches let expensive work happen once and be Three `(path|video_id, mtime)`-keyed caches let expensive work happen once and be
skipped on unchanged files: `info_cache` (parsed `info.json` fields), skipped on unchanged files: `info_cache` (parsed `info.json` fields),
`search_meta` + the `video_search` FTS5 index (full-text title/channel/ `search_meta` + the `video_search` FTS5 index (full-text title/channel/
@ -123,7 +129,7 @@ whole SPA — a `cargo build` won't catch it since the HTML is just a string).
### Bundled toolchain & anti-bot ### Bundled toolchain & anti-bot
`ytdlp_bin.rs` manages an optional self-contained venv at `ytdlp_bin.rs` manages an optional self-contained venv at
`~/.local/share/yt-offline/` (nightly `yt-dlp[default]` via `--pre` + `curl_cffi` `~/.local/share/catacomb/` (nightly `yt-dlp[default]` via `--pre` + `curl_cffi`
for TLS impersonation + bundled `deno`). `pot_provider.rs` runs `bgutil-pot` (a for TLS impersonation + bundled `deno`). `pot_provider.rs` runs `bgutil-pot` (a
loopback HTTP server) for YouTube Proof-of-Origin tokens; **its yt-dlp plugin loopback HTTP server) for YouTube Proof-of-Origin tokens; **its yt-dlp plugin
must come from the same release as the server binary, not PyPI** (version skew must come from the same release as the server binary, not PyPI** (version skew
@ -154,14 +160,17 @@ both the transcript indexer and the transcript viewers.
## Conventions ## Conventions
- **Never commit** `cookies.txt` (live session creds), `config.toml` (user- - **Never commit** `cookies.txt` (live session creds), `config.toml` (user-
specific), or `yt-offline.db` (contains the Argon2 password hash). All specific), or `catacomb.db` (plain SQLite app state, including the Argon2
gitignored. password hash and restart-persistent session tokens). All gitignored.
- Redact the absolute cookies path out of any log line surfaced to the UI/API - Redact the absolute cookies path out of any log line surfaced to the UI/API
(`redact_sensitive` in `downloader.rs`) — it leaks `$HOME`. (`redact_sensitive` in `downloader.rs`) — it leaks `$HOME`.
- `app.rs` and `web.rs` are large (~34k lines) because each owns a full UI; new - `app.rs` and `web.rs` are large (~34k lines) because each owns a full UI; new
desktop code goes in `app.rs`, web handlers in `web.rs`, shared logic in the desktop code goes in `app.rs`, web handlers in `web.rs`, shared logic in the
focused modules (`downloader`, `database`, `library`, `platform`, `fingerprint`, focused modules (`downloader`, `database`, `library`, `platform`, `fingerprint`,
`vtt`, …). `vtt`, …).
- Tray (`ksni`) and file dialogs (`rfd` xdg-portal) are Linux-only/no-GTK by - Tray (`ksni`) and the `rfd` xdg-portal dialog backend are Linux-only/no-GTK
design; keep that posture (it's why packaging avoids a GTK dep). Windows/macOS by design (it's why packaging avoids a GTK dep). They're target-gated in
are not yet first-class — the tray would need a per-OS backend. `Cargo.toml`; off Linux, `rfd` uses its native backend and `tray::start` is a
no-op. Windows now ships as a cross-compiled zip; macOS has a local osxcross
packaging path but is not in CI and still needs signing/notarization + a real
tray backend.

View file

@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## What this is ## What this is
`yt-offline` — a single Rust binary that is **both** a desktop GUI (eframe/egui) `catacomb` — a single Rust binary that is **both** a desktop GUI (eframe/egui)
and a headless web server (axum), wrapping `yt-dlp` to archive YouTube/TikTok/ and a headless web server (axum), wrapping `yt-dlp` to archive YouTube/TikTok/
Twitch/etc. AGPL-3.0. North-star goal and feature-parity tracking live in Twitch/etc. AGPL-3.0. North-star goal and feature-parity tracking live in
[ROADMAP.md](ROADMAP.md); a structured analysis of the Tartube benchmark is in [ROADMAP.md](ROADMAP.md); a structured analysis of the Tartube benchmark is in
@ -17,8 +17,8 @@ cargo build --release # the binary (release profile is opt-level=3 + t
cargo test --release # unit tests + tests/api.rs integration tests (no network) cargo test --release # unit tests + tests/api.rs integration tests (no network)
cargo test --release <name> # single test by substring, e.g. `cargo test --release subs_disabled` cargo test --release <name> # single test by substring, e.g. `cargo test --release subs_disabled`
cargo test --release real_ffmpeg -- --ignored # opt-in: real-ffmpeg fingerprint accuracy/speed check cargo test --release real_ffmpeg -- --ignored # opt-in: real-ffmpeg fingerprint accuracy/speed check
./target/release/yt-offline # desktop GUI mode (default) ./target/release/catacomb # desktop GUI mode (default)
./target/release/yt-offline --web 8080 # headless web server on a port ./target/release/catacomb --web 8080 # headless web server on a port
scripts/package.sh [deb|rpm|appimage|all] # build distro packages → dist/ (see docs/PACKAGING.md) scripts/package.sh [deb|rpm|appimage|all] # build distro packages → dist/ (see docs/PACKAGING.md)
``` ```
@ -103,6 +103,12 @@ in memory and mutating endpoints mirror DB writes onto those caches +
`bump_library_version()` (the ETag) so `/api/library` stays consistent without a `bump_library_version()` (the ETag) so `/api/library` stays consistent without a
rescan. rescan.
Web login sessions are runtime-owned but restart-persistent: `web.rs` keeps the
live `HashMap<String, u64>` and mirrors tokens into the `sessions` table, then
rehydrates non-expired rows at startup. Logout and password changes clear those
rows. The DB is plain SQLite, not encrypted, so `/api/backup/db` exports a
sensitive file; at-rest DB encryption is deliberately deferred to Phase 4.
Three `(path|video_id, mtime)`-keyed caches let expensive work happen once and be Three `(path|video_id, mtime)`-keyed caches let expensive work happen once and be
skipped on unchanged files: `info_cache` (parsed `info.json` fields), skipped on unchanged files: `info_cache` (parsed `info.json` fields),
`search_meta` + the `video_search` FTS5 index (full-text title/channel/ `search_meta` + the `video_search` FTS5 index (full-text title/channel/
@ -123,7 +129,7 @@ whole SPA — a `cargo build` won't catch it since the HTML is just a string).
### Bundled toolchain & anti-bot ### Bundled toolchain & anti-bot
`ytdlp_bin.rs` manages an optional self-contained venv at `ytdlp_bin.rs` manages an optional self-contained venv at
`~/.local/share/yt-offline/` (nightly `yt-dlp[default]` via `--pre` + `curl_cffi` `~/.local/share/catacomb/` (nightly `yt-dlp[default]` via `--pre` + `curl_cffi`
for TLS impersonation + bundled `deno`). `pot_provider.rs` runs `bgutil-pot` (a for TLS impersonation + bundled `deno`). `pot_provider.rs` runs `bgutil-pot` (a
loopback HTTP server) for YouTube Proof-of-Origin tokens; **its yt-dlp plugin loopback HTTP server) for YouTube Proof-of-Origin tokens; **its yt-dlp plugin
must come from the same release as the server binary, not PyPI** (version skew must come from the same release as the server binary, not PyPI** (version skew
@ -154,8 +160,8 @@ both the transcript indexer and the transcript viewers.
## Conventions ## Conventions
- **Never commit** `cookies.txt` (live session creds), `config.toml` (user- - **Never commit** `cookies.txt` (live session creds), `config.toml` (user-
specific), or `yt-offline.db` (contains the Argon2 password hash). All specific), or `catacomb.db` (plain SQLite app state, including the Argon2
gitignored. password hash and restart-persistent session tokens). All gitignored.
- Redact the absolute cookies path out of any log line surfaced to the UI/API - Redact the absolute cookies path out of any log line surfaced to the UI/API
(`redact_sensitive` in `downloader.rs`) — it leaks `$HOME`. (`redact_sensitive` in `downloader.rs`) — it leaks `$HOME`.
- `app.rs` and `web.rs` are large (~34k lines) because each owns a full UI; new - `app.rs` and `web.rs` are large (~34k lines) because each owns a full UI; new
@ -172,6 +178,7 @@ both the transcript indexer and the transcript viewers.
cross-compiles — `cargo check --target x86_64-pc-windows-gnu` is green — so cross-compiles — `cargo check --target x86_64-pc-windows-gnu` is green — so
keep new platform-specific code behind `cfg(unix)`/`cfg(windows)`/ keep new platform-specific code behind `cfg(unix)`/`cfg(windows)`/
`cfg(target_os = …)` with a fallback, the way `disk_space`, `plex`, the mpv `cfg(target_os = …)` with a fallback, the way `disk_space`, `plex`, the mpv
IPC, and the `chmod` guards already do. Windows/macOS still aren't *shipped* IPC, and the `chmod` guards already do. Windows now ships as a cross-compiled
(no tray backend, no link/CI yet), but don't reintroduce an unconditional zip from CI; macOS has a local osxcross packaging path but is not in CI and
Linux-only dep. still needs signing/notarization + a real tray backend. Don't reintroduce an
unconditional Linux-only dep.

54
Cargo.lock generated
View file

@ -727,6 +727,33 @@ dependencies = [
"wayland-client", "wayland-client",
] ]
[[package]]
name = "catacomb"
version = "0.1.0"
dependencies = [
"argon2",
"axum",
"eframe",
"image",
"ksni",
"libc",
"notify-rust",
"r2d2",
"r2d2_sqlite",
"rand 0.8.6",
"reqwest",
"rfd",
"rusqlite",
"serde",
"serde_json",
"tokio",
"tokio-stream",
"tokio-util",
"toml",
"tower-http 0.5.2",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.62" version = "1.2.62"
@ -5408,33 +5435,6 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "yt-offline"
version = "0.1.0"
dependencies = [
"argon2",
"axum",
"eframe",
"image",
"ksni",
"libc",
"notify-rust",
"r2d2",
"r2d2_sqlite",
"rand 0.8.6",
"reqwest",
"rfd",
"rusqlite",
"serde",
"serde_json",
"tokio",
"tokio-stream",
"tokio-util",
"toml",
"tower-http 0.5.2",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "zbus" name = "zbus"
version = "4.4.0" version = "4.4.0"

View file

@ -1,5 +1,5 @@
[package] [package]
name = "yt-offline" name = "catacomb"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
description = "Self-hosted archive for YouTube, TikTok, Twitch, Vimeo, Bandcamp, SoundCloud, Odysee and more. Desktop GUI + web UI, bundled yt-dlp with curl_cffi impersonation, Plex export, SQLite-backed resume tracking. AGPL-3.0." description = "Self-hosted archive for YouTube, TikTok, Twitch, Vimeo, Bandcamp, SoundCloud, Odysee and more. Desktop GUI + web UI, bundled yt-dlp with curl_cffi impersonation, Plex export, SQLite-backed resume tracking. AGPL-3.0."
@ -97,10 +97,10 @@ priority = "optional"
depends = "libc6, libxcb1, yt-dlp, ffmpeg, mpv, xdg-utils" depends = "libc6, libxcb1, yt-dlp, ffmpeg, mpv, xdg-utils"
recommends = "libnotify4" recommends = "libnotify4"
assets = [ assets = [
["target/release/yt-offline", "usr/bin/", "755"], ["target/release/catacomb", "usr/bin/", "755"],
["youtube-backup.desktop", "usr/share/applications/yt-offline.desktop", "644"], ["catacomb.desktop", "usr/share/applications/catacomb.desktop", "644"],
["icon.png", "usr/share/pixmaps/yt-offline.png", "644"], ["icon.png", "usr/share/pixmaps/catacomb.png", "644"],
["README.md", "usr/share/doc/yt-offline/README.md", "644"], ["README.md", "usr/share/doc/catacomb/README.md", "644"],
] ]
# ── Fedora / RHEL / openSUSE package (cargo-generate-rpm) ───────────────────── # ── Fedora / RHEL / openSUSE package (cargo-generate-rpm) ─────────────────────
@ -113,9 +113,9 @@ license = "AGPLv3"
# Fedora; ffmpeg lives in RPM Fusion but we still declare the dep. # Fedora; ffmpeg lives in RPM Fusion but we still declare the dep.
requires = { yt-dlp = "*", ffmpeg = "*", mpv = "*", "xdg-utils" = "*" } requires = { yt-dlp = "*", ffmpeg = "*", mpv = "*", "xdg-utils" = "*" }
assets = [ assets = [
{ source = "target/release/yt-offline", dest = "/usr/bin/yt-offline", mode = "755" }, { source = "target/release/catacomb", dest = "/usr/bin/catacomb", mode = "755" },
{ source = "youtube-backup.desktop", dest = "/usr/share/applications/yt-offline.desktop", mode = "644" }, { source = "catacomb.desktop", dest = "/usr/share/applications/catacomb.desktop", mode = "644" },
{ source = "icon.png", dest = "/usr/share/pixmaps/yt-offline.png", mode = "644" }, { source = "icon.png", dest = "/usr/share/pixmaps/catacomb.png", mode = "644" },
{ source = "README.md", dest = "/usr/share/doc/yt-offline/README.md", mode = "644" }, { source = "README.md", dest = "/usr/share/doc/catacomb/README.md", mode = "644" },
{ source = "LICENSE", dest = "/usr/share/licenses/yt-offline/LICENSE", mode = "644" }, { source = "LICENSE", dest = "/usr/share/licenses/catacomb/LICENSE", mode = "644" },
] ]

163
HANDOFF.md Normal file
View file

@ -0,0 +1,163 @@
# Session handoff — Catacomb
> Working notes for continuing this work in another session/agent (e.g. Codex).
> Written 2026-06-19. Delete or update freely; this is not a tracked spec.
## TL;DR — where things are
- Project: `catacomb` — one Rust binary that is **both** an egui desktop GUI
and an axum web server wrapping `yt-dlp`. See [CLAUDE.md](CLAUDE.md) for the
authoritative architecture; [ROADMAP.md](ROADMAP.md) for the plan.
- Recent work has been almost entirely on the **web UI** (`src/web_ui/index.html`,
a single `include_str!`-baked SPA) plus a few `web.rs`/`database.rs` changes.
- Branch: `main`. Last commit: `2f95e7f` (unified cinematic video player).
- A dev web server is usually running on **:8081** against the user's real
library. It keeps getting reaped between turns — just restart it (see below).
## How to build / run / test
```bash
cargo build --release # ~1.5 min (opt-level=3 + thin LTO)
cargo test --release # 128 unit + 11 integration (tests/api.rs); no network
./target/release/catacomb --web 8081 # headless web server (what the user uses)
./target/release/catacomb # desktop GUI (default)
```
### CRITICAL gotchas (learned this session)
1. **The web SPA is one big embedded file**`src/web_ui/index.html`, baked in
at compile time via `include_str!`. **Editing it requires a `cargo build`** to
take effect. A JS syntax error there will NOT be caught by `cargo build` (the
HTML is just a string), so after every edit:
```bash
awk '/<script>/{f=1;next}/<\/script>/{f=0}f' src/web_ui/index.html > /tmp/spa.js && node --check /tmp/spa.js
```
2. **DB location**: the server opens the DB at `channels_root.join("catacomb.db")`
(`web.rs` ~L3127), i.e. **`<backup.directory>/catacomb.db`**. For this user
that's `/mnt/InannaBeloved/youtube-backup/catacomb.db` (~64 MB, real data —
do NOT clobber). `config.toml` is read from the **process CWD**. A stray
0-byte `catacomb.db` may sit in the repo dir; it is unused — ignore it.
3. **Offline-first**: this is a self-hosted, possibly-no-internet archiver.
**Never load fonts/CSS/JS from a CDN.** UI fonts are embedded as base64 woff2
(SIL OFL) directly in the `<style>` block. Keep it that way.
4. **All 10 themes** are driven by 7 CSS variables (`--bg --panel --card --accent
--text --muted --border`) set per `.theme-*` class. Any new UI must use those
vars (and the design-layer tokens below) so every theme inherits it.
5. **Background server reaping**: in the sandbox, `run_in_background` servers get
killed between turns and shell `&`/`nohup` is unreliable. Just relaunch
`./target/release/catacomb --web 8081` each time you need it up. `pkill -f`
first to avoid a port clash.
6. **Verifying UI visually without a browser driver** (no puppeteer/playwright):
extract the relevant CSS/JS from `index.html` into a `/tmp/*.html` harness with
a mock payload and screenshot with headless chromium:
```bash
chromium --headless=new --no-sandbox --disable-gpu --hide-scrollbars \
--window-size=1100,1300 --virtual-time-budget=3000 \
--screenshot=/tmp/out.png "file:///tmp/harness.html"
```
Note: `--virtual-time-budget` can capture entry animations mid-flight; add a
"settle" override (disable transitions, force final state) for a clean shot.
## Settings flow (the easy thing to get wrong)
Adding a setting touches **five** places (grep an existing one like
`dedup_enabled` or `sponsorblock_mode` end-to-end first):
1. `config.rs` — field + `Default` + `default_with_dir()`.
2. `download_options.rs``Option<…>` per-channel override (None = use global).
3. `downloader.rs` — resolver merging global+override + a `pub` field on `Downloader`.
4. Both UIs: `app.rs` (egui Settings + channel-options dialog) AND
`web_ui/index.html` (Settings modal + channel dialog) AND `web.rs`
`SettingsPayload` (GET reads config, POST writes config + pushes to live `Downloader`).
5. Seed the `Downloader` field at construction AND on settings-save, in BOTH
`app.rs` and `web.rs`.
## What this session shipped (committed)
Newest first (all on `main`):
- `2f95e7f` **Unified cinematic video player.** Replaced native `<video controls>`
(direct `/files/` playback) with ONE custom player for both direct + transcode.
Custom scrubber (played/buffered/chapter-ticks/hover tooltip, drag-to-seek via
pointer events, commits on release), playback-speed popover (0.52×), persistent
volume/speed/captions (localStorage `plPrefs`), CC toggle, PiP, fullscreen,
auto-hiding controls + center play/pause flash, expanded keyboard
(space/k j/l ←→ ↑↓ m c p f `<` `>` 09). All seeking routes through the
existing `playerSeek`/`effTime` so the transcode reload-at-offset path is intact.
CSS prefix `.pl-*`; JS around `playVideo`/`updateVctrl`/`plBindScrubber`.
- `816da05` **Persist login sessions in SQLite.** New `sessions(token, issued_at)`
table; in-memory map switched `Instant``u64` unix secs; insert on login, delete
on logout, clear on password change, rehydrate at startup via `load_sessions()`.
Fixes "restart logs everyone out". (`database.rs` + `web.rs`.)
- `dd48e89` **api() 401 → reload to login** instead of a cryptic "error" toast.
- `615c088` **Maintenance modal → "diagnostics console"** (CSS prefix `.mx-*`):
pulsing health verdict + status tiles + instrument-panel sections. Presentation
only; all handler ids/classes (`dup-chk` `sim-chk` `at-chk` `dedup-area`
`autotag-area`, polling) untouched.
- `6f61821` **Stats modal → "observatory" dashboard** (CSS prefix `.sx-*`):
count-up metric cards, self-drawing SVG area chart (Catmull-Rom), growing
histogram, ranked leaderboard with Size/Count toggle.
- `c8cb700` **"Cinematheque" reskin**: embedded Instrument Serif + Hanken Grotesk
(base64 OFL), accent aurora + film-grain atmosphere, cinematic card hover,
recording-dot wordmark. Design-layer CSS appended after the functional rules so
it overrides by source order; everything flows through the theme variables.
- `983864f` **macOS osxcross packaging path** (`scripts/package.sh mac``.app`
zip; local-only, not in CI — needs osxcross + SDK).
- `207013e` **Windows shippable**: cross-compiled `.zip` via mingw + console
reattach fix (`attach_windows_console` in `main.rs`) + Windows build in the
Forgejo release CI.
- `8b25787` **Library sorting**: download-date sort (file mtime) + grouped sort
options, both UIs.
- earlier: `015d037` scan leaves one core free; `5ffdb17` download-modal
diff-aware repaint + Retry-all; `9ed6293` download cancel/retry/queue + dedup
off-switch.
### Design-layer conventions (web UI)
- CSS is appended near the end of the `<style>` block, AFTER the functional
rules, so equal-specificity overrides win by source order. Prefixes:
`.sx-*` = stats observatory, `.mx-*` = maintenance console, `.pl-*` = player.
- Reveal animations gate behind a class added post-layout (e.g. `.sx-go`) so they
play once per open, not on every re-render. All honor
`@media (prefers-reduced-motion: reduce)` (a global rule at the end of `<style>`).
- Fonts: `--font-display` (Instrument Serif), `--font-body` (Hanken Grotesk).
Extra tokens: `--radius`, `--radius-sm`, `--ring`, `--glow`, `--shadow`, `--hair`.
## Uncommitted changes (REVIEW + COMMIT before continuing)
`git status` shows modified-but-uncommitted (mostly docs, made by the user/linter
— do NOT revert, just review and commit):
- Docs/prose: `AGENTS.md`, `CLAUDE.md`, `README.md`, `ROADMAP.md`,
`SECURITY_AUDIT.md`, `docs/src/{architecture,first-run,installation,packaging}.md`
- Small code touches: `src/database.rs` (~4 lines — schema doc/comment),
`src/web_ui/index.html` (6/6 — minor).
Run `git diff` to confirm these are intended, then commit them. They look like
documentation catch-up for the session's features.
## Suggested next steps (pick up here)
Highest-leverage, all buildable + verifiable locally:
1. **Login page reskin**`LOGIN_HTML` in `web.rs` is a plain form and is the one
surface that still clashes with the cinematheque aesthetic. Small, high impact.
2. **Search overlay** (`openSearch` in the SPA) — could become a command-palette
experience to match the new players.
3. Commit the pending doc changes (above).
Roadmap "surpass" items still open (see [ROADMAP.md](ROADMAP.md) §3):
- **3.1 macOS binary** — osxcross scaffolding done (`scripts/package.sh mac`);
needs the toolchain + SDK installed, then verify. Windows already ships.
- **3.2 Android client** (big), **3.8 plugin/scripting hooks** (architectural).
- Phase 4 blue-sky: AI transcript summarisation (FTS transcript index already
exists), TV-mode layout, multi-user accounts.
- Federation follow-up (3.5): in-UI "add remote" editor (peers are config-only).
## Watch-outs / open questions
- The video player's seek/speed/captions are verified by code-path review +
static chrome screenshot, NOT live playback (no headless video). Worth a real
click-through in the app.
- Session persistence: the user's *current* browser cookie predates the
persistence commit, so they must log in once more; logins after that survive
restarts. (They may have a download password set or not — it has toggled during
testing; that's user-driven, not a bug.)
- Don't commit `cookies.txt`, `config.toml`, or `catacomb.db` (all gitignored;
contain creds / the Argon2 password hash).

View file

@ -1,5 +1,5 @@
# Maintainer: InannaBeloved <anassaeneroi@pm.me> # Maintainer: InannaBeloved <anassaeneroi@pm.me>
pkgname=yt-offline pkgname=catacomb
pkgver=0.1.0 pkgver=0.1.0
pkgrel=1 pkgrel=1
pkgdesc="Self-hosted archive for YouTube, TikTok, Twitch, Vimeo, Bandcamp, SoundCloud, Odysee and more. Desktop GUI + web UI." pkgdesc="Self-hosted archive for YouTube, TikTok, Twitch, Vimeo, Bandcamp, SoundCloud, Odysee and more. Desktop GUI + web UI."
@ -23,7 +23,10 @@ makedepends=('rust' 'cargo')
# previously broke the rusqlite bundled-sqlite link; thin LTO in Cargo # previously broke the rusqlite bundled-sqlite link; thin LTO in Cargo
# does the cross-crate inlining we actually want. # does the cross-crate inlining we actually want.
options=('!lto') options=('!lto')
source=("git+https://codeberg.org/anassaeneroi/yt-offline.git#branch=main") # Force the checkout dir to $pkgname (catacomb); the Codeberg repo is still
# named yt-offline, so without the `$pkgname::` prefix the clone dir wouldn't
# match the `cd "$pkgname"` below.
source=("$pkgname::git+https://codeberg.org/anassaeneroi/yt-offline.git#branch=main")
sha256sums=('SKIP') sha256sums=('SKIP')
pkgver() { pkgver() {
@ -44,9 +47,9 @@ build() {
package() { package() {
cd "$pkgname" cd "$pkgname"
install -Dm755 target/release/yt-offline "$pkgdir/usr/bin/yt-offline" install -Dm755 target/release/catacomb "$pkgdir/usr/bin/catacomb"
install -Dm644 youtube-backup.desktop "$pkgdir/usr/share/applications/yt-offline.desktop" install -Dm644 catacomb.desktop "$pkgdir/usr/share/applications/catacomb.desktop"
install -Dm644 icon.png "$pkgdir/usr/share/pixmaps/yt-offline.png" install -Dm644 icon.png "$pkgdir/usr/share/pixmaps/catacomb.png"
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md" install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
} }

View file

@ -1,6 +1,6 @@
# yt-offline # Catacomb
![yt-offline icon](icon.png) ![catacomb icon](icon.png)
A self-hosted media archive for YouTube and friends. Pastes any URL, routes it A self-hosted media archive for YouTube and friends. Pastes any URL, routes it
to the right folder by source, tracks what you've watched, and lets you play to the right folder by source, tracks what you've watched, and lets you play
@ -9,7 +9,7 @@ the source video has been taken down.
Built on [yt-dlp](https://github.com/yt-dlp/yt-dlp); written in Rust. Built on [yt-dlp](https://github.com/yt-dlp/yt-dlp); written in Rust.
📖 **Documentation:** <https://anassaeneroi.codeberg.page/yt-offline/> — setup, 📖 **Documentation:** <https://anassaeneroi.codeberg.page/catacomb/> — setup,
first run, the anti-bot stack, troubleshooting, and architecture. first run, the anti-bot stack, troubleshooting, and architecture.
## What it backs up ## What it backs up
@ -65,10 +65,11 @@ exact URL to refresh from.
- **Desktop GUI** launches your configured player (mpv by default; mpv gets - **Desktop GUI** launches your configured player (mpv by default; mpv gets
resume-position tracking via JSON-IPC). A floating **transcript window** resume-position tracking via JSON-IPC). A floating **transcript window**
lets you search the subtitles and click any line to seek the running mpv. lets you search the subtitles and click any line to seek the running mpv.
- **Web UI** plays videos inline in any browser, with subtitle tracks, a - **Web UI** plays videos inline in any browser with one custom player for
**chapters** panel, and a searchable **transcript** pane — click any line direct files and transcoded streams: speed control, caption toggle,
to seek, and the current line highlights as the video plays. Optional keyboard seeking, PiP, fullscreen, saved volume/speed, a **chapters**
on-the-fly **ffmpeg transcoding** for browsers that can't decode MKV. panel, and a searchable **transcript** pane. Optional on-the-fly
**ffmpeg transcoding** covers browsers that can't decode MKV.
- **Comment viewer** (web) — for videos archived with comments: threaded - **Comment viewer** (web) — for videos archived with comments: threaded
with collapse/expand, in-comment search, sort (top / newest / oldest), an with collapse/expand, in-comment search, sort (top / newest / oldest), an
uploader badge, and a "new since last visit" highlight. uploader badge, and a "new since last visit" highlight.
@ -111,12 +112,13 @@ exact URL to refresh from.
- **9-class error classifier** — every failure gets a one-line suggested fix - **9-class error classifier** — every failure gets a one-line suggested fix
(rate-limited, members-only, geoblocked, bad cookies, codec missing, disk (rate-limited, members-only, geoblocked, bad cookies, codec missing, disk
full, …) shown in both UIs. full, …) shown in both UIs.
- **Crash log** — any thread panic is appended to `yt-offline.crash.log` - **Crash log** — any thread panic is appended to `catacomb.crash.log`
next to the database, so a GUI launched without a terminal still leaves a next to the database, so a GUI launched without a terminal still leaves a
trace. trace.
- **Library backup & restore** — download a snapshot of the SQLite database; - **Library backup & restore** — download a snapshot of the SQLite database;
restore does a schema-validated, idempotent merge (watched / positions / restore does a schema-validated, idempotent merge (watched / positions /
flags / folders / notes). flags / folders / notes). The DB is plain SQLite, so treat backups as
sensitive.
- Poison-recovering locks keep one handler's panic from taking down the - Poison-recovering locks keep one handler's panic from taking down the
long-running web server. long-running web server.
@ -127,8 +129,8 @@ exact URL to refresh from.
- **Bind interface picker** — localhost only (default), Tailscale, LAN, or - **Bind interface picker** — localhost only (default), Tailscale, LAN, or
all interfaces. all interfaces.
- **Password-gated UI** when enabled — Argon2 hashed, 256-bit session - **Password-gated UI** when enabled — Argon2 hashed, 256-bit session
tokens, 30-day TTL with lazy pruning, per-IP rate-limit on `/api/login` tokens persisted across restarts, 30-day TTL with lazy pruning, per-IP
(5 failures → 60s lockout). rate-limit on `/api/login` (5 failures → 60s lockout).
- **Security headers:** Content-Security-Policy, X-Frame-Options DENY, - **Security headers:** Content-Security-Policy, X-Frame-Options DENY,
X-Content-Type-Options nosniff, Referrer-Policy no-referrer. X-Content-Type-Options nosniff, Referrer-Policy no-referrer.
- **`Secure` cookie flag** when `X-Forwarded-Proto: https` is present - **`Secure` cookie flag** when `X-Forwarded-Proto: https` is present
@ -166,15 +168,15 @@ plus three Emo/Scene flavours (Nocturnal, Coffin, Scene Queen).
## Quick start ## Quick start
```bash ```bash
git clone https://codeberg.org/anassaeneroi/yt-offline git clone https://codeberg.org/anassaeneroi/catacomb
cd yt-offline cd catacomb
cargo build --release cargo build --release
# Desktop GUI # Desktop GUI
./target/release/yt-offline ./target/release/catacomb
# Or web server only (headless) # Or web server only (headless)
./target/release/yt-offline --web 8080 ./target/release/catacomb --web 8080
``` ```
On first run a `config.toml` is created in the working directory. Settings On first run a `config.toml` is created in the working directory. Settings
@ -184,8 +186,8 @@ can also be changed from inside either UI — no manual editing required.
1. Open **Settings**. 1. Open **Settings**.
2. Under **yt-dlp binary**, choose **Bundled** and click **Install**. 2. Under **yt-dlp binary**, choose **Bundled** and click **Install**.
3. The installer creates `~/.local/share/yt-offline/venv/` with 3. The installer creates `~/.local/share/catacomb/venv/` with
`yt-dlp[default]` + `curl_cffi`, plus `~/.local/share/yt-offline/bin/deno`. `yt-dlp[default]` + `curl_cffi`, plus `~/.local/share/catacomb/bin/deno`.
Progress streams into a regular job entry. Progress streams into a regular job entry.
Updates are the same button. Switching to **System** uses whatever `yt-dlp` Updates are the same button. Switching to **System** uses whatever `yt-dlp`
@ -197,8 +199,8 @@ is on PATH instead.
```bash ```bash
sudo pacman -S --needed rust mpv # python3 + python-pip already present sudo pacman -S --needed rust mpv # python3 + python-pip already present
git clone https://codeberg.org/anassaeneroi/yt-offline git clone https://codeberg.org/anassaeneroi/catacomb
cd yt-offline cd catacomb
makepkg -si # or: cargo build --release makepkg -si # or: cargo build --release
``` ```
@ -236,8 +238,8 @@ Tools with the **Desktop development with C++** workload, then:
```powershell ```powershell
winget install mpv.net python.python.3.12 winget install mpv.net python.python.3.12
git clone https://codeberg.org/anassaeneroi/yt-offline git clone https://codeberg.org/anassaeneroi/catacomb
cd yt-offline cd catacomb
cargo build --release cargo build --release
``` ```
@ -276,7 +278,7 @@ interval_hours = 24
port = 8080 port = 8080
bind = "127.0.0.1" # 127.0.0.1 | 0.0.0.0 | a Tailscale/LAN address 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 (needs ffmpeg) transcode = false # MKV → MP4 on the fly for browsers (needs ffmpeg)
source_url = "https://codeberg.org/anassaeneroi/yt-offline" # AGPL §13 footer link source_url = "https://codeberg.org/anassaeneroi/catacomb" # AGPL §13 footer link
[plex] [plex]
library_path = "/path/to/plex/TV/youtube" # leave unset to disable library_path = "/path/to/plex/TV/youtube" # leave unset to disable
@ -342,7 +344,7 @@ the session cookie is HttpOnly + SameSite=Strict, valid 30 days.
├── music/ ├── music/
│ └── <artist>/ │ └── <artist>/
│ └── Track Title [xyz].opus │ └── Track Title [xyz].opus
└── yt-offline.db ← watched + positions + password hash └── catacomb.db ← plain SQLite app state (watched, positions, password hash, sessions, caches)
``` ```
## Troubleshooting ## Troubleshooting
@ -360,7 +362,7 @@ switch to **Bundled** in Settings.
Sessions cleared after the password was changed. Refresh the page. Sessions cleared after the password was changed. Refresh the page.
**Videos play but seeking is approximate** **Videos play but seeking is approximate**
That's usually transcoding mode (`web.transcode = true`). yt-offline can That's usually transcoding mode (`web.transcode = true`). catacomb can
scrub those live ffmpeg streams by re-requesting the stream at a `start=` scrub those live ffmpeg streams by re-requesting the stream at a `start=`
offset, but ffmpeg's fast seek lands on a nearby keyframe rather than an offset, but ffmpeg's fast seek lands on a nearby keyframe rather than an
exact byte position. Turn transcoding off if your browser can play the exact byte position. Turn transcoding off if your browser can play the

View file

@ -9,7 +9,7 @@ configuration surface lives at [`docs/tartube-spec.md`](docs/tartube-spec.md);
it's what the Phase 1 parity work traced back to. it's what the Phase 1 parity work traced back to.
Tartube is the mature open-source yt-dlp GUI and the obvious benchmark for a Tartube is the mature open-source yt-dlp GUI and the obvious benchmark for a
project in this space. yt-offline has architectural advantages Tartube can't project in this space. catacomb has architectural advantages Tartube can't
catch up on quickly (Rust + axum + a real web UI + bundled toolchain + a catch up on quickly (Rust + axum + a real web UI + bundled toolchain + a
modern security model). **As of 2026-06 we're at feature parity** — every modern security model). **As of 2026-06 we're at feature parity** — every
Tartube subsystem is matched or led; the remaining gap is years-of-edge- Tartube subsystem is matched or led; the remaining gap is years-of-edge-
@ -78,17 +78,19 @@ Every Phase 2 item is now done — 2.1 / 2.2 shipped alongside 2.3 / 2.4 /
### 2.1 Integration test coverage — DONE ### 2.1 Integration test coverage — DONE
98 unit tests cover parsers/helpers/resolvers; `tests/api.rs` adds 7 128 passing unit tests cover parsers/helpers/resolvers, plus one ignored
end-to-end tests that spawn the **real** `--web` binary against a scratch real-ffmpeg fingerprint check for opt-in local validation. `tests/api.rs`
tempdir and drive the HTTP API with curl (index/library serving, ETag adds 11 end-to-end tests that spawn the **real** `--web` binary against a
304, settings round-trip + persistence, folders CRUD + cycle guard, notes scratch tempdir and drive the HTTP API with curl (index/library serving,
round-trip, channel-options round-trip + clear, DB backup). Each test ETag 304, settings round-trip + persistence, folders CRUD + cycle guard,
gets its own server/port/tempdir, so they run in parallel — `cargo test` notes round-trip, channel-options round-trip + clear, DB backup, full-text
runs the lot. (A `.forgejo/workflows/test.yml` CI definition exists, but search, podcast/feed-token auth, RSS enclosures, and perceptual dedup).
Codeberg executes Woodpecker, not Forgejo Actions, so it's inert there Each test gets its own server/port/tempdir, so they run in parallel —
without a self-hosted runner; tests run locally.) Stretch left: a `cargo test --release` runs the lot. (A `.forgejo/workflows/test.yml` CI
recorded-fixture corpus for the download pipeline and a headless web-UI definition exists, but Codeberg executes Woodpecker, not Forgejo Actions,
test. so it's inert there without a self-hosted runner; tests run locally.)
Stretch left: a recorded-fixture corpus for the download pipeline,
a restart-persistent-session regression test, and a headless web-UI test.
### 2.2 Documentation site — DONE ### 2.2 Documentation site — DONE
@ -130,10 +132,10 @@ Once we're at parity, we push past Tartube on its own ground.
**Windows is now a shippable artifact.** `cargo build --release --target **Windows is now a shippable artifact.** `cargo build --release --target
x86_64-pc-windows-gnu` doesn't just check — it *links* a working 22 MB x86_64-pc-windows-gnu` doesn't just check — it *links* a working 22 MB
`yt-offline.exe` via mingw-w64 (only the upstream egui `f32: From<f64>` `catacomb.exe` via mingw-w64 (only the upstream egui `f32: From<f64>`
warnings). `scripts/package.sh win` (and `all`, when the cross toolchain warnings). `scripts/package.sh win` (and `all`, when the cross toolchain
is present) cross-compiles and bundles it into is present) cross-compiles and bundles it into
`yt-offline-<ver>-x86_64-windows.zip` (exe + LICENSE + a README listing `catacomb-<ver>-x86_64-windows.zip` (exe + LICENSE + a README listing
the yt-dlp/ffmpeg/mpv PATH deps). The Forgejo release workflow installs the yt-dlp/ffmpeg/mpv PATH deps). The Forgejo release workflow installs
`mingw-w64` + `zip` + the rust target so a tag push produces the Windows `mingw-w64` + `zip` + the rust target so a tag push produces the Windows
zip alongside the Linux packages — all from the one Linux container, no zip alongside the Linux packages — all from the one Linux container, no
@ -156,7 +158,7 @@ Linux; `disk_space` (`statvfs`), `plex` (symlinks, with a Windows
**macOS has a packaging path, pending the toolchain.** `scripts/package.sh **macOS has a packaging path, pending the toolchain.** `scripts/package.sh
mac` cross-compiles an Apple target (`MAC_ARCH=arm64` default, or mac` cross-compiles an Apple target (`MAC_ARCH=arm64` default, or
`x86_64`) via osxcross, assembles an unsigned `yt-offline.app` `x86_64`) via osxcross, assembles an unsigned `catacomb.app`
(`Info.plist` + Mach-O + best-effort `.icns`), and zips it. The crypto (`Info.plist` + Mach-O + best-effort `.icns`), and zips it. The crypto
stack is `ring`, which builds against the osxcross SDK fine. It's gated on stack is `ring`, which builds against the osxcross SDK fine. It's gated on
the operator having osxcross built with a macOS SDK on PATH (the SDK comes the operator having osxcross built with a macOS SDK on PATH (the SDK comes
@ -245,15 +247,47 @@ surfaced as a 🔍 search in both UIs. Plus a searchable, click-to-seek
current line) and a floating desktop window that seeks mpv over IPC — current line) and a floating desktop window that seeks mpv over IPC —
backed by a shared `vtt` WebVTT/SRT parser. backed by a shared `vtt` WebVTT/SRT parser.
## Phase 4 — Stretch / blue-sky ## Phase 4 — Long-Horizon Bets
Probably never, or much later. Phase 4 is where bigger strategic bets live after Phase 3's "surpass"
work is banked. These are not required for the current product to be
healthy; they either change the deployment model, add a new consumption
surface, or raise the security/privacy bar enough to deserve their own
design pass.
- A web UI built around a "TV mode" remote-friendly layout. ### 4.1 Living-room mode
- AI summarisation of videos (transcript → bullet points).
- Multi-user accounts with per-user watched/positions (currently single-user). A remote-friendly web UI for couch/TV use: large focus states, D-pad
- Integration with Plex / Jellyfin / Kodi as a *source plugin* rather than a navigation, queue/playlist controls, "continue watching" as the default
symlink generator. view, and no dense maintenance/settings chrome.
### 4.2 Local intelligence over the archive
Transcript-derived summaries, topic extraction, "find the clip where..."
search, and maybe offline embedding indexes. This should stay optional and
local-first; no cloud dependency should become required for normal archive
use.
### 4.3 Multi-user library state
Per-user watched state, resume positions, flags, notes, and maybe
permissions. This is a schema/auth redesign, not a bolt-on setting, because
the app is currently intentionally single-user.
### 4.4 At-rest privacy hardening
Encrypted `catacomb.db` or encrypted backups. This likely means SQLCipher
or an export/import backup format, plus key management, migration from
plaintext DBs, and packaging validation across Linux/Windows/macOS. Until
that design exists, the DB remains plain SQLite protected by filesystem
permissions.
### 4.5 Media-server integration as a real source
Plex / Jellyfin / Kodi integration as a plugin/source surface rather than
only a symlink/NFO generator. The goal would be first-class browsing and
playback integration without making the archive layout depend on one media
server's conventions.
## Recently shipped (highlights) ## Recently shipped (highlights)
@ -263,6 +297,13 @@ Roughly reverse-chronological. Items that closed out a roadmap line.
`start=` offset and the web player uses custom controls for live ffmpeg `start=` offset and the web player uses custom controls for live ffmpeg
streams, so MKV/H.264 transcode mode can scrub, resume, jump chapters, streams, so MKV/H.264 transcode mode can scrub, resume, jump chapters,
and sync transcript highlights even though the pipe has no byte ranges. and sync transcript highlights even though the pipe has no byte ranges.
- **Unified web video player** — direct `/files/` playback and live
`/api/transcode/` streams now share one custom player with speed control,
caption toggle, keyboard seeking, PiP, fullscreen, saved volume/speed, and
chapter ticks.
- **Restart-safe web sessions** — login tokens are mirrored into SQLite and
rehydrated at startup, so a server restart or upgrade no longer logs out
active browsers; password changes and logout still clear sessions.
- **Federation / multi-host** (3.5) — read-only peer libraries with - **Federation / multi-host** (3.5) — read-only peer libraries with
tokenized direct media URLs, so videos stream from the peer rather than tokenized direct media URLs, so videos stream from the peer rather than
proxying through this instance. proxying through this instance.
@ -345,7 +386,10 @@ Roughly reverse-chronological. Items that closed out a roadmap line.
search + transcript tooling (3.9), and the **Windows** half of 3.1 search + transcript tooling (3.9), and the **Windows** half of 3.1
(cross-compiled .zip + release CI). Still open: a **macOS** binary (cross-compiled .zip + release CI). Still open: a **macOS** binary
(3.1; needs a Mac runner), Android (3.2), and plugin hooks (3.8). (3.1; needs a Mac runner), Android (3.2), and plugin hooks (3.8).
- **Phase 4** items might be valuable, but commit to nothing. - **Phase 4** is reserved for bigger future bets: living-room mode, local
archive intelligence, multi-user state, at-rest privacy hardening, and
deeper media-server integration. Treat these as design projects, not
quick backlog items.
Items inside a phase are loosely ordered by user-visible impact, not strict Items inside a phase are loosely ordered by user-visible impact, not strict
prerequisite. With Windows banked, the highest-leverage remaining moves are prerequisite. With Windows banked, the highest-leverage remaining moves are

View file

@ -1,6 +1,7 @@
# Security Audit — yt-offline # Security Audit — catacomb
**Date:** 2026-05-23 (re-audit after security hardening commit `5999673`) **Date:** 2026-05-23 (re-audit after security hardening commit `5999673`)
**Docs refreshed:** 2026-06-18 (persisted sessions / DB backup caveat)
**Earlier audit:** 2026-05-17 (commit pre-dating session auth, CSP, SHA-256 verify, etc.) **Earlier audit:** 2026-05-17 (commit pre-dating session auth, CSP, SHA-256 verify, etc.)
**Scope:** Rust codebase + embedded web UI + dependencies **Scope:** Rust codebase + embedded web UI + dependencies
**Threat model:** Self-hosted personal archiving tool. Primary deployment is **Threat model:** Self-hosted personal archiving tool. Primary deployment is
@ -49,7 +50,7 @@ interpretation. Sites audited:
- yt-dlp downloads: `src/downloader.rs:start`, `start_music`, `repair` - yt-dlp downloads: `src/downloader.rs:start`, `start_music`, `repair`
- yt-dlp preview: `src/web.rs:get_preview` (now goes through `apply_cookie_flags` and respects bundled-binary setting) - yt-dlp preview: `src/web.rs:get_preview` (now goes through `apply_cookie_flags` and respects bundled-binary setting)
- yt-dlp bundled install: `src/ytdlp_bin.rs:install_command` runs `bash -c` with a static script whose only interpolated values are paths under `~/.local/share/yt-offline/` and constant GitHub URLs — no user input flows in. - yt-dlp bundled install: `src/ytdlp_bin.rs:install_command` runs `bash -c` with a static script whose only interpolated values are paths under `~/.local/share/catacomb/` and constant GitHub URLs — no user input flows in.
- ffmpeg transcoder: `src/web.rs:get_transcode` (kill_on_drop set; child terminates when stream is dropped). - ffmpeg transcoder: `src/web.rs:get_transcode` (kill_on_drop set; child terminates when stream is dropped).
Path interpolation in the install script uses single quotes around Path interpolation in the install script uses single quotes around
@ -84,7 +85,11 @@ the `settings` table was added.
- Argon2-hashed password in the `settings` table (never in `config.toml`). - Argon2-hashed password in the `settings` table (never in `config.toml`).
- 256-bit `rand::thread_rng()` (CSPRNG) session tokens, hex-encoded. - 256-bit `rand::thread_rng()` (CSPRNG) session tokens, hex-encoded.
- `Mutex<HashMap<String, Instant>>` of issued tokens, lazily pruned past 30-day TTL — no unbounded growth. - Runtime `Mutex<HashMap<String, u64>>` of issued tokens, lazily pruned past
30-day TTL — no unbounded growth.
- Session tokens are mirrored to the SQLite `sessions` table and rehydrated
at startup, so server restarts/upgrades do not log users out. Logout and
password changes delete the corresponding persisted sessions.
- `Set-Cookie` includes `HttpOnly`, `SameSite=Strict`, `Path=/`, `Max-Age=2592000`. `Secure` is added when `X-Forwarded-Proto: https` is present (so reverse-proxied deployments get it; plain-HTTP LAN does not, which is correct since browsers would otherwise refuse to send the cookie). - `Set-Cookie` includes `HttpOnly`, `SameSite=Strict`, `Path=/`, `Max-Age=2592000`. `Secure` is added when `X-Forwarded-Proto: https` is present (so reverse-proxied deployments get it; plain-HTTP LAN does not, which is correct since browsers would otherwise refuse to send the cookie).
- All routes layered behind `auth_middleware` when a password is configured. `/api/login` and unauthenticated `GET /` (serves login page) are the only exceptions. - All routes layered behind `auth_middleware` when a password is configured. `/api/login` and unauthenticated `GET /` (serves login page) are the only exceptions.
- `password_required` is cached in an `AtomicBool` so the auth middleware doesn't hit SQLite on every static-file fetch. - `password_required` is cached in an `AtomicBool` so the auth middleware doesn't hit SQLite on every static-file fetch.
@ -125,9 +130,9 @@ unencrypted over the network. Defenses:
**Mitigation if you need stronger guarantees:** put a reverse proxy with TLS in front (Caddy, nginx, Traefik) and set the `X-Forwarded-Proto` header. The app will then issue cookies with the `Secure` attribute automatically. **Mitigation if you need stronger guarantees:** put a reverse proxy with TLS in front (Caddy, nginx, Traefik) and set the `X-Forwarded-Proto` header. The app will then issue cookies with the `Secure` attribute automatically.
### ⚠️ `cookies.txt` and `yt-offline.db` file permissions ### ⚠️ `cookies.txt` and `catacomb.db` file permissions
- `yt-offline.db`: now `chmod 0600` at open time on Unix (`src/database.rs`). Contains the Argon2 password hash and resume positions. Other local users on the same machine cannot read it. - `catacomb.db`: now `chmod 0600` at open time on Unix (`src/database.rs`). It is **plain SQLite**, not encrypted. It contains the Argon2 password hash, watched/resume state, notes/options/flags, derived caches, and restart-persistent web session tokens. Other local users on the same machine cannot read it under normal Unix permissions, but copied DB files and downloaded DB backups remain sensitive.
- `cookies.txt`: not auto-chmodded because the user pastes / file-picks it in, and our `write_cookies()` uses `std::fs::write` which respects the user's umask. **Recommendation in README:** `chmod 600 cookies.txt` after import. (Tracking issue for future automation.) - `cookies.txt`: not auto-chmodded because the user pastes / file-picks it in, and our `write_cookies()` uses `std::fs::write` which respects the user's umask. **Recommendation in README:** `chmod 600 cookies.txt` after import. (Tracking issue for future automation.)
### ⚠️ Job log echoes yt-dlp stderr verbatim ### ⚠️ Job log echoes yt-dlp stderr verbatim
@ -164,7 +169,7 @@ root, at which point they own the data anyway. Accepted.
| Optional HTTP password auth | ✅ added (Argon2 + sessions + rate limit) | | Optional HTTP password auth | ✅ added (Argon2 + sessions + rate limit) |
| Filter yt-dlp stderr | ⚠️ still echoed (accepted) | | Filter yt-dlp stderr | ⚠️ still echoed (accepted) |
| chmod 600 on cookies.txt | ⚠️ documented; not automated | | chmod 600 on cookies.txt | ⚠️ documented; not automated |
| chmod 600 on yt-offline.db | ✅ added (`Database::open`) | | chmod 600 on catacomb.db | ✅ added (`Database::open`) |
| Session token TTL + GC | ✅ added | | Session token TTL + GC | ✅ added |
| `Secure` cookie flag for HTTPS | ✅ added (gated on `X-Forwarded-Proto`) | | `Secure` cookie flag for HTTPS | ✅ added (gated on `X-Forwarded-Proto`) |
| Login rate limiting | ✅ added (5 / 60 s per IP) | | Login rate limiting | ✅ added (5 / 60 s per IP) |
@ -194,6 +199,13 @@ These are nice-to-haves; none are blocking the current deployment.
7. **TLS terminator in-process.** Optional `rustls` integration so the 7. **TLS terminator in-process.** Optional `rustls` integration so the
app can serve HTTPS without a reverse proxy. Currently considered app can serve HTTPS without a reverse proxy. Currently considered
out of scope. out of scope.
8. **Hash persisted session tokens or sanitize DB backups.** The current
`sessions` table stores bearer tokens so restart-safe sessions work; a
copied DB backup can therefore carry live web sessions until logout,
password change, or TTL expiry.
9. **Optional at-rest DB encryption.** Tracked as a Phase 4 design item,
not an active hardening requirement. It needs key-management, migration,
backup/restore, and packaging decisions.
--- ---

View file

@ -1,10 +1,10 @@
[Desktop Entry] [Desktop Entry]
Type=Application Type=Application
Name=YT Offline Name=Catacomb
GenericName=Video Archiver GenericName=Video Archiver
Comment=Self-hosted archive for YouTube, TikTok, Twitch, and more Comment=Self-hosted archive for YouTube, TikTok, Twitch, and more
Exec=yt-offline Exec=catacomb
Icon=yt-offline Icon=catacomb
Categories=AudioVideo;Video;Network; Categories=AudioVideo;Video;Network;
Keywords=youtube;yt-dlp;archive;backup;download; Keywords=youtube;yt-dlp;archive;backup;download;
StartupNotify=true StartupNotify=true

View file

@ -1,4 +1,4 @@
# Packaging yt-offline # Packaging Catacomb
This document covers building distributable packages. The one-liner: This document covers building distributable packages. The one-liner:
@ -23,7 +23,7 @@ on demand. Runtime deps declared: `yt-dlp`, `ffmpeg`, `mpv`, `xdg-utils`,
```sh ```sh
scripts/package.sh deb scripts/package.sh deb
sudo apt install ./dist/yt-offline_*_amd64.deb sudo apt install ./dist/catacomb_*_amd64.deb
``` ```
### .rpm (Fedora / RHEL / openSUSE) ### .rpm (Fedora / RHEL / openSUSE)
@ -34,7 +34,7 @@ already-built (and release-profile-stripped) binary.
```sh ```sh
scripts/package.sh rpm scripts/package.sh rpm
sudo dnf install ./dist/yt-offline-*.x86_64.rpm sudo dnf install ./dist/catacomb-*.x86_64.rpm
``` ```
Note: `ffmpeg` on Fedora lives in [RPM Fusion](https://rpmfusion.org/); Note: `ffmpeg` on Fedora lives in [RPM Fusion](https://rpmfusion.org/);
@ -50,8 +50,8 @@ would balloon the image and tangle the licensing.
```sh ```sh
scripts/package.sh appimage scripts/package.sh appimage
chmod +x dist/yt-offline-*-x86_64.AppImage chmod +x dist/catacomb-*-x86_64.AppImage
./dist/yt-offline-*-x86_64.AppImage ./dist/catacomb-*-x86_64.AppImage
``` ```
## Arch Linux ## Arch Linux
@ -82,11 +82,11 @@ sudo apt install mingw-w64 zip # or your distro's equivalent
scripts/package.sh win scripts/package.sh win
``` ```
This produces `dist/yt-offline-<ver>-x86_64-windows.zip` containing This produces `dist/catacomb-<ver>-x86_64-windows.zip` containing
`yt-offline.exe`, `LICENSE.txt`, and a `README.txt` listing the runtime `catacomb.exe`, `LICENSE.txt`, and a `README.txt` listing the runtime
PATH deps (yt-dlp / ffmpeg / mpv). Release builds link as a GUI-subsystem PATH deps (yt-dlp / ffmpeg / mpv). Release builds link as a GUI-subsystem
app (no console window); the binary reattaches to the launching terminal app (no console window); the binary reattaches to the launching terminal
at runtime (`attach_windows_console` in `main.rs`) so `yt-offline.exe at runtime (`attach_windows_console` in `main.rs`) so `catacomb.exe
--web 8080` from PowerShell prints its logs, while a double-click stays --web 8080` from PowerShell prints its logs, while a double-click stays
windowless. windowless.
@ -110,12 +110,12 @@ rustup target add aarch64-apple-darwin # or x86_64-apple-darwin
MAC_ARCH=arm64 scripts/package.sh mac # arm64 (default) | x86_64 MAC_ARCH=arm64 scripts/package.sh mac # arm64 (default) | x86_64
``` ```
This cross-compiles, assembles an unsigned `yt-offline.app` This cross-compiles, assembles an unsigned `catacomb.app`
(`Info.plist` + the Mach-O binary; an `.icns` icon too if `png2icns` is (`Info.plist` + the Mach-O binary; an `.icns` icon too if `png2icns` is
available), and zips it to `dist/yt-offline-<ver>-<arch>-macos.zip`. The available), and zips it to `dist/catacomb-<ver>-<arch>-macos.zip`. The
`.app` is **not codesigned or notarized**, so on the target Mac it must be `.app` is **not codesigned or notarized**, so on the target Mac it must be
opened the first time via right-click → Open, or cleared with `xattr -dr opened the first time via right-click → Open, or cleared with `xattr -dr
com.apple.quarantine yt-offline.app`. Runtime deps (yt-dlp / ffmpeg / mpv) com.apple.quarantine catacomb.app`. Runtime deps (yt-dlp / ffmpeg / mpv)
are expected on PATH as on the other platforms. are expected on PATH as on the other platforms.
A `.dmg` and codesigning/notarization (and a `tray-icon`-based macOS tray) A `.dmg` and codesigning/notarization (and a `tray-icon`-based macOS tray)

View file

@ -1,5 +1,5 @@
[book] [book]
title = "yt-offline" title = "Catacomb"
description = "Self-hosted archive for YouTube, TikTok, Twitch and more. Setup, usage, anti-bot, troubleshooting, and architecture." description = "Self-hosted archive for YouTube, TikTok, Twitch and more. Setup, usage, anti-bot, troubleshooting, and architecture."
authors = ["InannaBeloved"] authors = ["InannaBeloved"]
language = "en" language = "en"
@ -8,5 +8,5 @@ src = "src"
[output.html] [output.html]
default-theme = "navy" default-theme = "navy"
preferred-dark-theme = "navy" preferred-dark-theme = "navy"
git-repository-url = "https://codeberg.org/anassaeneroi/yt-offline" git-repository-url = "https://codeberg.org/anassaeneroi/catacomb"
edit-url-template = "https://codeberg.org/anassaeneroi/yt-offline/_edit/main/docs/{path}" edit-url-template = "https://codeberg.org/anassaeneroi/catacomb/_edit/main/docs/{path}"

View file

@ -2,7 +2,7 @@
[Introduction](./introduction.md) [Introduction](./introduction.md)
# Using yt-offline # Using catacomb
- [Installation](./installation.md) - [Installation](./installation.md)
- [First run & configuration](./first-run.md) - [First run & configuration](./first-run.md)

View file

@ -1,7 +1,7 @@
# Staying ahead of YouTube's bot detection # Staying ahead of YouTube's bot detection
YouTube increasingly fingerprints and rate-limits automated clients. YouTube increasingly fingerprints and rate-limits automated clients.
yt-offline ships a layered defense; understanding the layers makes the catacomb ships a layered defense; understanding the layers makes the
difference between "everything downloads" and "constant captchas." difference between "everything downloads" and "constant captchas."
In rough order of impact: In rough order of impact:
@ -10,13 +10,13 @@ In rough order of impact:
This is the single biggest factor. **Anonymous requests get captcha- This is the single biggest factor. **Anonymous requests get captcha-
walled the hardest.** Provide a `cookies.txt` exported from a browser walled the hardest.** Provide a `cookies.txt` exported from a browser
where you are **signed in to YouTube**, or point yt-offline at the where you are **signed in to YouTube**, or point catacomb at the
browser profile directly. browser profile directly.
A *valid* logged-in jar contains the auth cookies `SID`, `SAPISID`, A *valid* logged-in jar contains the auth cookies `SID`, `SAPISID`,
`__Secure-1PSID`, `__Secure-3PSID`, `LOGIN_INFO`, etc. A jar with only `__Secure-1PSID`, `__Secure-3PSID`, `LOGIN_INFO`, etc. A jar with only
`VISITOR_INFO1_LIVE`, `PREF`, `YSC` is **anonymous** — it's not signed in `VISITOR_INFO1_LIVE`, `PREF`, `YSC` is **anonymous** — it's not signed in
and actually makes detection *worse* than no cookies. yt-offline's and actually makes detection *worse* than no cookies. catacomb's
Settings → Cookies panel warns you when your jar is anonymous or expired. Settings → Cookies panel warns you when your jar is anonymous or expired.
Two ways to supply cookies: Two ways to supply cookies:
@ -37,7 +37,7 @@ Two ways to supply cookies:
subdirectory itself). The advantage: cookies are read fresh from the subdirectory itself). The advantage: cookies are read fresh from the
live session each download, so they never go stale. live session each download, so they never go stale.
> Cookies are session credentials — yt-offline never commits or transmits > Cookies are session credentials — catacomb never commits or transmits
> `cookies.txt` unprompted, and redacts the cookie path out of any log > `cookies.txt` unprompted, and redacts the cookie path out of any log
> line shown in the UI. > line shown in the UI.
@ -45,7 +45,7 @@ Two ways to supply cookies:
yt-dlp's `--impersonate` makes requests carry a real browser's TLS yt-dlp's `--impersonate` makes requests carry a real browser's TLS
fingerprint (via `curl_cffi`), so the connection doesn't *look* like a fingerprint (via `curl_cffi`), so the connection doesn't *look* like a
script. The bundled install sets this up automatically and yt-offline script. The bundled install sets this up automatically and catacomb
picks an impersonation target per platform. picks an impersonation target per platform.
If impersonation silently does nothing, it's almost always a If impersonation silently does nothing, it's almost always a
@ -58,7 +58,7 @@ is present). See
## 3. POT tokens (Proof-of-Origin) ## 3. POT tokens (Proof-of-Origin)
YouTube increasingly binds a per-video **Proof-of-Origin token** to YouTube increasingly binds a per-video **Proof-of-Origin token** to
playback; without one, format URLs come back empty. yt-offline can run playback; without one, format URLs come back empty. catacomb can run
[bgutil-pot](https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs), a [bgutil-pot](https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs), a
loopback HTTP server that mints these tokens, and point yt-dlp at it. loopback HTTP server that mints these tokens, and point yt-dlp at it.
@ -68,14 +68,14 @@ yt-dlp; the matching plugin installs into its venv) and click **Install**.
> **Version-skew footgun:** the yt-dlp plugin must come from the *same > **Version-skew footgun:** the yt-dlp plugin must come from the *same
> release* as the bgutil-pot server binary — **not** the PyPI package, > release* as the bgutil-pot server binary — **not** the PyPI package,
> which versions independently and silently produces no tokens on a > which versions independently and silently produces no tokens on a
> mismatch. yt-offline's installer handles this by unpacking the > mismatch. catacomb's installer handles this by unpacking the
> version-matched plugin zip from the server's release. > version-matched plugin zip from the server's release.
## 4. Player-client selection ## 4. Player-client selection
YouTube cracks down on different internal "player clients" over time — YouTube cracks down on different internal "player clients" over time —
the `web` client is currently the most captcha-prone, while `tv` and the `web` client is currently the most captcha-prone, while `tv` and
`mweb` are the least. yt-offline no longer forces `web`; it lets yt-dlp `mweb` are the least. catacomb no longer forces `web`; it lets yt-dlp
pick good defaults. If a specific channel keeps hitting captchas, set a pick good defaults. If a specific channel keeps hitting captchas, set a
client override (global or per-channel): client override (global or per-channel):
@ -85,7 +85,7 @@ tv,mweb
## 5. Throttling ## 5. Throttling
A burst of ~30 rapid requests is a classic trip-wire. yt-offline inserts A burst of ~30 rapid requests is a classic trip-wire. catacomb inserts
a small jittered pause between videos (a fixed cadence looks robotic; a a small jittered pause between videos (a fixed cadence looks robotic; a
random one looks human) and, after any rate-limit hit, triples the random one looks human) and, after any rate-limit hit, triples the
sleeps for the rest of the batch before recovering. sleeps for the rest of the batch before recovering.

View file

@ -80,7 +80,7 @@ fallback.
## Anti-bot subsystems ## Anti-bot subsystems
`ytdlp_bin.rs` manages the optional self-contained venv at `ytdlp_bin.rs` manages the optional self-contained venv at
`~/.local/share/yt-offline/` (nightly `yt-dlp[default]` + `curl_cffi` + `~/.local/share/catacomb/` (nightly `yt-dlp[default]` + `curl_cffi` +
bundled `deno`). `pot_provider.rs` runs `bgutil-pot` for Proof-of-Origin bundled `deno`). `pot_provider.rs` runs `bgutil-pot` for Proof-of-Origin
tokens — its yt-dlp plugin must come from the same release as the server tokens — its yt-dlp plugin must come from the same release as the server
binary. `error_class.rs` pattern-matches yt-dlp stderr into actionable binary. `error_class.rs` pattern-matches yt-dlp stderr into actionable
@ -95,7 +95,7 @@ wall is RateLimited, not NotFound).
and drives the HTTP API with curl — genuine end-to-end coverage of the and drives the HTTP API with curl — genuine end-to-end coverage of the
axum + SQLite + config stack. axum + SQLite + config stack.
`cargo test` runs both. (A `.forgejo/workflows/test.yml` CI definition `cargo test --release` runs both. (A `.forgejo/workflows/test.yml` CI definition
exists, but Codeberg runs Woodpecker rather than Forgejo Actions, so it exists, but Codeberg runs Woodpecker rather than Forgejo Actions, so it
doesn't execute there without a self-hosted runner — run the suite doesn't execute there without a self-hosted runner — run the suite
locally.) locally.)
@ -103,8 +103,9 @@ locally.)
## Platform support ## Platform support
Tray (`ksni`) and file dialogs (`rfd` xdg-portal) are Linux-only / no-GTK Tray (`ksni`) and file dialogs (`rfd` xdg-portal) are Linux-only / no-GTK
by design — that's why packaging avoids a GTK dependency. Windows/macOS by design — that's why packaging avoids a GTK dependency. Those Linux-only
aren't first-class yet: the tray needs a per-OS backend before a clean pieces are target-gated: Windows builds use native file dialogs and a no-op
cross-build. The rest (eframe/wgpu, axum, rusqlite-bundled) already tray stub, and the repo now ships a cross-compiled Windows zip. macOS has a
compiles cross-platform, and `ytdlp_bin` already has `cfg!(windows)` local osxcross packaging path, but still needs on-hardware validation,
branches. codesigning/notarization, and a real tray backend before it is a normal
release artifact.

View file

@ -3,7 +3,7 @@
## Starting a download ## Starting a download
Paste any supported URL into the download bar (desktop) or the ⬇ Paste any supported URL into the download bar (desktop) or the ⬇
Downloads modal (web). yt-offline classifies the URL by platform, routes Downloads modal (web). catacomb classifies the URL by platform, routes
it to the right folder, and starts yt-dlp. A channel/playlist URL pulls 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. the whole thing; a single-video URL pulls just that one.

View file

@ -2,7 +2,7 @@
## The config file ## The config file
yt-offline reads `config.toml` **from its working directory** (the catacomb reads `config.toml` **from its working directory** (the
directory you launch it from), not a fixed path. The same goes for directory you launch it from), not a fixed path. The same goes for
`cookies.txt`. Everything in `config.toml` is also editable in Settings; `cookies.txt`. Everything in `config.toml` is also editable in Settings;
edits there are written back to the file. edits there are written back to the file.
@ -61,7 +61,7 @@ Everything nests under the one `backup.directory`:
music/ ← audio-only "Music mode" downloads, by artist music/ ← audio-only "Music mode" downloads, by artist
archive.txt ← yt-dlp's global download archive archive.txt ← yt-dlp's global download archive
cookies.txt ← optional, if you set one cookies.txt ← optional, if you set one
yt-offline.db ← watched/positions/flags/folders/notes/cache + password hash catacomb.db ← plain SQLite app state (watched/positions/flags/notes, password hash, sessions, caches)
``` ```
Each creator folder gets a hidden `.source-url` sidecar so re-checks Each creator folder gets a hidden `.source-url` sidecar so re-checks
@ -72,8 +72,8 @@ always know the exact URL to refresh from.
In **Settings → yt-dlp binary** you choose: In **Settings → yt-dlp binary** you choose:
- **System** — uses whatever `yt-dlp` is on your `PATH`. - **System** — uses whatever `yt-dlp` is on your `PATH`.
- **Bundled** — click **Install** and yt-offline builds a self-contained - **Bundled** — click **Install** and catacomb builds a self-contained
venv at `~/.local/share/yt-offline/`: nightly `yt-dlp[default]` + venv at `~/.local/share/catacomb/`: nightly `yt-dlp[default]` +
`curl_cffi` (TLS impersonation) + a bundled `deno` (player-JS). The `curl_cffi` (TLS impersonation) + a bundled `deno` (player-JS). The
same button updates it later. same button updates it later.
@ -83,8 +83,8 @@ also required for the [POT token provider](./anti-bot.md#3-pot-tokens-proof-of-o
## The two front-ends ## The two front-ends
- `yt-offline` — desktop GUI (eframe/egui). - `catacomb` — desktop GUI (eframe/egui).
- `yt-offline --web [PORT]` — headless web server. Bind to `127.0.0.1` - `catacomb --web [PORT]` — headless web server. Bind to `127.0.0.1`
(default) for localhost-only, a Tailscale address for your tailnet, or (default) for localhost-only, a Tailscale address for your tailnet, or
`0.0.0.0` for the LAN. **Set a password** (Settings) before exposing it `0.0.0.0` for the LAN. **Set a password** (Settings) before exposing it
beyond localhost — the UI and all `/api` routes are then gated behind an beyond localhost — the UI and all `/api` routes are then gated behind an

View file

@ -1,6 +1,6 @@
# Installation # Installation
yt-offline is a single Rust binary. You can install a prebuilt package, Catacomb is a single Rust binary. You can install a prebuilt package,
build from source, or grab the AppImage. build from source, or grab the AppImage.
## Runtime dependencies ## Runtime dependencies
@ -8,7 +8,7 @@ build from source, or grab the AppImage.
Whichever way you install, these are invoked as subprocesses at runtime: Whichever way you install, these are invoked as subprocesses at runtime:
- **yt-dlp** — the download engine. You can use the system one *or* let - **yt-dlp** — the download engine. You can use the system one *or* let
yt-offline manage a bundled copy (see [First run](./first-run.md)). catacomb manage a bundled copy (see [First run](./first-run.md)).
- **ffmpeg** — muxing, format conversion, on-the-fly transcode for the - **ffmpeg** — muxing, format conversion, on-the-fly transcode for the
web player. web player.
- **mpv** — the default desktop player (any player taking a file path - **mpv** — the default desktop player (any player taking a file path
@ -21,14 +21,14 @@ Releases attach `.deb`, `.rpm`, and `.AppImage` artifacts.
```sh ```sh
# Debian / Ubuntu / Mint # Debian / Ubuntu / Mint
sudo apt install ./yt-offline_*_amd64.deb sudo apt install ./catacomb_*_amd64.deb
# Fedora / RHEL / openSUSE (ffmpeg via RPM Fusion) # Fedora / RHEL / openSUSE (ffmpeg via RPM Fusion)
sudo dnf install ./yt-offline-*.x86_64.rpm sudo dnf install ./catacomb-*.x86_64.rpm
# Any Linux — AppImage # Any Linux — AppImage
chmod +x yt-offline-*-x86_64.AppImage chmod +x catacomb-*-x86_64.AppImage
./yt-offline-*-x86_64.AppImage ./catacomb-*-x86_64.AppImage
``` ```
## Arch / CachyOS / Manjaro ## Arch / CachyOS / Manjaro
@ -55,11 +55,11 @@ sudo apt install build-essential pkg-config curl git python3-venv \
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env" source "$HOME/.cargo/env"
git clone https://codeberg.org/anassaeneroi/yt-offline git clone https://codeberg.org/anassaeneroi/catacomb
cd yt-offline cd catacomb
cargo build --release cargo build --release
./target/release/yt-offline # desktop GUI ./target/release/catacomb # desktop GUI
./target/release/yt-offline --web 8080 # headless web server ./target/release/catacomb --web 8080 # headless web server
``` ```
`python3-venv` is only needed for the bundled-yt-dlp install path; skip `python3-venv` is only needed for the bundled-yt-dlp install path; skip
@ -67,7 +67,13 @@ it if you'll always use system yt-dlp.
## Windows / macOS ## Windows / macOS
Not first-class yet. The Linux-only system tray (`ksni`) and file dialog Windows is available as a release zip when built through
(`rfd` xdg-portal) need per-OS backends before a clean cross-build; the `scripts/package.sh win` or tag-release CI. It includes `catacomb.exe`,
rest of the stack already compiles. See the the license, and a short runtime-dependency README; install `yt-dlp`,
[architecture notes](./architecture.md#platform-support). `ffmpeg`, and `mpv` / `mpv.net` on PATH.
macOS has a local osxcross packaging path (`scripts/package.sh mac`) that
assembles an unsigned `.app` zip when you provide an Apple SDK locally.
It is not built in public CI because the SDK cannot be redistributed, and
codesigning/notarization remain follow-ups. See
[packaging](./packaging.md) and the [architecture notes](./architecture.md#platform-support).

View file

@ -1,4 +1,4 @@
# yt-offline # Catacomb
A self-hosted media archive for YouTube and friends. Paste any URL, it A self-hosted media archive for YouTube and friends. Paste any URL, it
routes the download to the right folder by source, tracks what you've routes the download to the right folder by source, tracks what you've
@ -24,7 +24,7 @@ single binary that is **both** a desktop app and a headless web server.
## Why it exists ## Why it exists
Tartube is the mature open-source yt-dlp GUI and the benchmark in this 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 space. catacomb matches its feature set while adding things Tartube
doesn't have: a real web UI reachable from any device, a single-binary doesn't have: a real web UI reachable from any device, a single-binary
distribution with a bundled toolchain, a modern security model distribution with a bundled toolchain, a modern security model
(password-gated UI, Argon2, rate-limited login), a built-in anti-bot (password-gated UI, Argon2, rate-limited login), a built-in anti-bot

View file

@ -3,17 +3,20 @@
Build distributable Linux packages with one script: Build distributable Linux packages with one script:
```sh ```sh
scripts/package.sh all # .deb + .rpm + .AppImage → dist/ scripts/package.sh all # Linux packages + Windows zip when toolchains exist
scripts/package.sh deb # just the .deb scripts/package.sh deb # just the .deb
scripts/package.sh rpm scripts/package.sh rpm
scripts/package.sh appimage scripts/package.sh appimage
scripts/package.sh win # Windows .zip via mingw-w64
scripts/package.sh mac # unsigned macOS .app .zip via local osxcross SDK
``` ```
It builds the release binary once and reuses it for every format, It builds the release binary once and reuses it for every format,
installing `cargo-deb` / `cargo-generate-rpm` on demand and downloading installing `cargo-deb` / `cargo-generate-rpm` on demand and downloading
`appimagetool` to `dist/tools/` on first AppImage build. Per-format `appimagetool` to `dist/tools/` on first AppImage build. Windows is included
failures are isolated and summarized at the end. Output (gitignored) when the mingw target/toolchain is present; macOS is local-only because
lands in `dist/`. osxcross needs an Apple SDK. Per-format failures are isolated and summarized
at the end. Output (gitignored) lands in `dist/`.
## Formats ## Formats
@ -37,5 +40,5 @@ Actions — so they don't run there without a self-hosted runner. Until
then, build packages locally with `scripts/package.sh` and publish docs then, build packages locally with `scripts/package.sh` and publish docs
with `scripts/publish-docs.sh`. with `scripts/publish-docs.sh`.
The repo's [`docs/PACKAGING.md`](https://codeberg.org/anassaeneroi/yt-offline/src/branch/main/docs/PACKAGING.md) The repo's [`docs/PACKAGING.md`](https://codeberg.org/anassaeneroi/catacomb/src/branch/main/docs/PACKAGING.md)
has the per-distro install commands and the Windows/macOS status in full. has the per-distro install commands and the Windows/macOS status in full.

View file

@ -1,6 +1,6 @@
# Troubleshooting # Troubleshooting
yt-offline classifies failed downloads into one of nine classes and shows catacomb 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 a one-line suggested fix next to the failed job. This page expands on the
most common ones, plus a few non-download issues. most common ones, plus a few non-download issues.
@ -15,7 +15,7 @@ wall. In order of effectiveness:
2. **Switch to bundled (nightly) yt-dlp** if you're on system stable. 2. **Switch to bundled (nightly) yt-dlp** if you're on system stable.
3. **Enable the POT token provider.** 3. **Enable the POT token provider.**
4. **Try a player-client override** of `tv,mweb` for that channel. 4. **Try a player-client override** of `tv,mweb` for that channel.
5. If it's a one-off, just wait — yt-offline auto-retries transient 5. If it's a one-off, just wait — catacomb auto-retries transient
rate-limits after a cooldown. rate-limits after a cooldown.
## Impersonate targets show "(unavailable)" ## Impersonate targets show "(unavailable)"
@ -40,7 +40,7 @@ major versions are mismatched"* warning.
**Cause:** the yt-dlp plugin came from PyPI (Brainicism's package, which **Cause:** the yt-dlp plugin came from PyPI (Brainicism's package, which
versions independently) instead of the jim60105 Rust server's release. versions independently) instead of the jim60105 Rust server's release.
**Fix:** re-run the POT **Install/Update** button — yt-offline installs **Fix:** re-run the POT **Install/Update** button — catacomb installs
the version-matched plugin zip from the same release as the server the version-matched plugin zip from the same release as the server
binary. Don't `pip install bgutil-ytdlp-pot-provider` yourself. binary. Don't `pip install bgutil-ytdlp-pot-provider` yourself.
@ -68,14 +68,14 @@ see [Anti-bot](./anti-bot.md).
## Downloads stall forever ## Downloads stall forever
A job sits running with no progress. yt-offline's **hang watchdog** A job sits running with no progress. catacomb's **hang watchdog**
auto-kills any job silent for 5 minutes and re-queues it, so this should auto-kills any job silent for 5 minutes and re-queues it, so this should
self-heal. If it recurs on a specific URL, it's usually a server-side self-heal. If it recurs on a specific URL, it's usually a server-side
issue with that source; check the job log in the Downloads panel. issue with that source; check the job log in the Downloads panel.
## Disk fills up / downloads fail with ENOSPC ## Disk fills up / downloads fail with ENOSPC
yt-offline runs a **disk-full preflight** and refuses to start a download catacomb runs a **disk-full preflight** and refuses to start a download
when the target filesystem has less than ~500 MB free, surfacing it as a 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 clear "disk full" failure rather than a half-written file. Free space and
retry. retry.
@ -103,13 +103,13 @@ broken Vulkan stack, the OS can create the window while wgpu never
presents a frame. Try the OpenGL renderer: presents a frame. Try the OpenGL renderer:
```bash ```bash
YT_OFFLINE_RENDERER=glow yt-offline YT_OFFLINE_RENDERER=glow catacomb
``` ```
or: or:
```bash ```bash
yt-offline --renderer glow catacomb --renderer glow
``` ```
If that works, update/reinstall your Vulkan driver later and switch back If that works, update/reinstall your Vulkan driver later and switch back
@ -129,6 +129,6 @@ UI until restarted.
- **The job log** — every download/transcode job keeps its full yt-dlp / - **The job log** — every download/transcode job keeps its full yt-dlp /
ffmpeg output in the Downloads panel (expand the job). ffmpeg output in the Downloads panel (expand the job).
- **`yt-offline.crash.log`** — next to your `yt-offline.db`. A panic in - **`catacomb.crash.log`** — next to your `catacomb.db`. A panic in
any thread (UI, web worker, download) is appended here with a any thread (UI, web worker, download) is appended here with a
timestamp, so it survives a GUI launched without a terminal. timestamp, so it survives a GUI launched without a terminal.

View file

@ -2,7 +2,7 @@
A structured analysis of [Tartube v2.5.231](https://github.com/axcore/tartube) A structured analysis of [Tartube v2.5.231](https://github.com/axcore/tartube)
(2026-05-24), our project's primary feature benchmark. Written to give (2026-05-24), our project's primary feature benchmark. Written to give
yt-offline a concrete target to work toward — every section ends with a catacomb a concrete target to work toward — every section ends with a
"What we can do" note about whether to adopt, adapt, or skip. "What we can do" note about whether to adopt, adapt, or skip.
Tartube codebase: ~146,000 lines of Python 3 across 19 modules under Tartube codebase: ~146,000 lines of Python 3 across 19 modules under
@ -452,7 +452,7 @@ The 791 boolean flags in `mainapp.py` cover all of these plus their
**What we can do:** A handful of these tabs map to features we **What we can do:** A handful of these tabs map to features we
literally don't have (FFmpeg presets, comments, profiles, scheduling literally don't have (FFmpeg presets, comments, profiles, scheduling
calendar). They're roadmap items, not config surface. The actual calendar). They're roadmap items, not config surface. The actual
*configuration surface* of yt-offline is currently: *configuration surface* of Catacomb is currently:
- backup dir, max concurrent, bundled-or-system yt-dlp - backup dir, max concurrent, bundled-or-system yt-dlp
- player command, browser (for `--cookies-from-browser`) - player command, browser (for `--cookies-from-browser`)
@ -568,7 +568,7 @@ For the record (to keep momentum honest):
## 12. Specification for "Tartube parity" milestone ## 12. Specification for "Tartube parity" milestone
Working definition: a yt-offline release is "at Tartube parity" when Working definition: a catacomb release is "at Tartube parity" when
the following are all true. the following are all true.
- [x] **Per-target download options** with cascade resolution and a - [x] **Per-target download options** with cascade resolution and a

View file

@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Build distributable packages for yt-offline. # Build distributable packages for catacomb.
# #
# Usage: # Usage:
# scripts/package.sh [deb|rpm|appimage|win|mac|all] # scripts/package.sh [deb|rpm|appimage|win|mac|all]
@ -23,7 +23,7 @@
# macOS notes: cross-compiling for Apple targets needs osxcross # macOS notes: cross-compiling for Apple targets needs osxcross
# (https://github.com/tpoechtrager/osxcross) built with a macOS SDK, and its # (https://github.com/tpoechtrager/osxcross) built with a macOS SDK, and its
# wrapper compilers (oa64-clang / o64-clang) on PATH. Set MAC_ARCH=arm64 # wrapper compilers (oa64-clang / o64-clang) on PATH. Set MAC_ARCH=arm64
# (default) or x86_64. The result is an unsigned yt-offline.app inside a .zip; # (default) or x86_64. The result is an unsigned catacomb.app inside a .zip;
# it is *not* codesigned or notarized, so on the target Mac it must be opened # it is *not* codesigned or notarized, so on the target Mac it must be opened
# via right-click → Open (or `xattr -dr com.apple.quarantine`) the first time. # via right-click → Open (or `xattr -dr com.apple.quarantine`) the first time.
# See docs/PACKAGING.md. # See docs/PACKAGING.md.
@ -39,7 +39,7 @@ mkdir -p "$DIST" "$TOOLS"
# Pull version + name straight from Cargo.toml so packages stay in sync. # Pull version + name straight from Cargo.toml so packages stay in sync.
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)" VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)"
PKG="yt-offline" PKG="catacomb"
ARCH="$(uname -m)" ARCH="$(uname -m)"
say() { printf '\033[1;36m==>\033[0m %s\n' "$*"; } say() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
@ -124,9 +124,9 @@ build_appimage() {
mkdir -p "$appdir/usr/bin" "$appdir/usr/share/applications" "$appdir/usr/share/icons/hicolor/256x256/apps" mkdir -p "$appdir/usr/bin" "$appdir/usr/share/applications" "$appdir/usr/share/icons/hicolor/256x256/apps"
install -Dm755 "target/release/$PKG" "$appdir/usr/bin/$PKG" install -Dm755 "target/release/$PKG" "$appdir/usr/bin/$PKG"
install -Dm644 youtube-backup.desktop "$appdir/usr/share/applications/$PKG.desktop" install -Dm644 catacomb.desktop "$appdir/usr/share/applications/$PKG.desktop"
# AppImage wants the desktop file + icon at the AppDir root too. # AppImage wants the desktop file + icon at the AppDir root too.
cp youtube-backup.desktop "$appdir/$PKG.desktop" cp catacomb.desktop "$appdir/$PKG.desktop"
install -Dm644 icon.png "$appdir/usr/share/icons/hicolor/256x256/apps/$PKG.png" install -Dm644 icon.png "$appdir/usr/share/icons/hicolor/256x256/apps/$PKG.png"
cp icon.png "$appdir/$PKG.png" cp icon.png "$appdir/$PKG.png"
@ -134,7 +134,7 @@ build_appimage() {
cat > "$appdir/AppRun" <<'APPRUN' cat > "$appdir/AppRun" <<'APPRUN'
#!/usr/bin/env bash #!/usr/bin/env bash
HERE="$(dirname "$(readlink -f "${0}")")" HERE="$(dirname "$(readlink -f "${0}")")"
exec "$HERE/usr/bin/yt-offline" "$@" exec "$HERE/usr/bin/catacomb" "$@"
APPRUN APPRUN
chmod +x "$appdir/AppRun" chmod +x "$appdir/AppRun"
@ -185,14 +185,14 @@ build_win() {
install -m755 "$exe" "$staging/$PKG.exe" install -m755 "$exe" "$staging/$PKG.exe"
install -m644 LICENSE "$staging/LICENSE.txt" install -m644 LICENSE "$staging/LICENSE.txt"
cat > "$staging/README.txt" <<README cat > "$staging/README.txt" <<README
yt-offline ${VERSION} — Windows build Catacomb ${VERSION} — Windows build
Run the GUI by double-clicking yt-offline.exe. Run the GUI by double-clicking catacomb.exe.
To run the headless web server, open PowerShell or Command Prompt in this To run the headless web server, open PowerShell or Command Prompt in this
folder and run: folder and run:
.\\yt-offline.exe --web 8080 .\\catacomb.exe --web 8080
then browse to http://localhost:8080. (Launched from a terminal the server then browse to http://localhost:8080. (Launched from a terminal the server
prints its logs to that terminal; double-clicked it runs windowless.) prints its logs to that terminal; double-clicked it runs windowless.)
@ -231,7 +231,7 @@ win_available() {
} }
# ── macOS .app .zip (cross-compiled via osxcross) ──────────────────────────── # ── macOS .app .zip (cross-compiled via osxcross) ────────────────────────────
# Builds an unsigned yt-offline.app for one Apple arch (MAC_ARCH=arm64 default, # Builds an unsigned catacomb.app for one Apple arch (MAC_ARCH=arm64 default,
# or x86_64) and zips it. Requires osxcross's wrapper compilers on PATH; the # or x86_64) and zips it. Requires osxcross's wrapper compilers on PATH; the
# crypto stack is `ring`, which cross-builds against osxcross's SDK fine. Like # crypto stack is `ring`, which cross-builds against osxcross's SDK fine. Like
# the other formats we don't bundle yt-dlp/ffmpeg/mpv — the .app's README and # the other formats we don't bundle yt-dlp/ffmpeg/mpv — the .app's README and
@ -322,9 +322,9 @@ build_mac() {
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CFBundleName</key> <string>yt-offline</string> <key>CFBundleName</key> <string>Catacomb</string>
<key>CFBundleDisplayName</key> <string>yt-offline</string> <key>CFBundleDisplayName</key> <string>Catacomb</string>
<key>CFBundleIdentifier</key> <string>org.codeberg.yt-offline</string> <key>CFBundleIdentifier</key> <string>org.codeberg.catacomb</string>
<key>CFBundleVersion</key> <string>${VERSION}</string> <key>CFBundleVersion</key> <string>${VERSION}</string>
<key>CFBundleShortVersionString</key> <string>${VERSION}</string> <key>CFBundleShortVersionString</key> <string>${VERSION}</string>
<key>CFBundleExecutable</key> <string>${PKG}</string> <key>CFBundleExecutable</key> <string>${PKG}</string>

View file

@ -392,7 +392,7 @@ impl App {
// Open the DB first so the library scanner can use info_cache to // Open the DB first so the library scanner can use info_cache to
// skip re-parsing unchanged info.json sidecars. Cold start still // skip re-parsing unchanged info.json sidecars. Cold start still
// pays full cost; every subsequent launch is faster. // pays full cost; every subsequent launch is faster.
let db_path = channels_root.join("yt-offline.db"); let db_path = channels_root.join("catacomb.db");
let db = Database::open(&db_path) let db = Database::open(&db_path)
.unwrap_or_else(|_| Database::open_in_memory().expect("in-memory db failed")); .unwrap_or_else(|_| Database::open_in_memory().expect("in-memory db failed"));
// Defer the (potentially multi-minute, disk-bound) library scan + // Defer the (potentially multi-minute, disk-bound) library scan +
@ -409,7 +409,7 @@ impl App {
let channels_root = channels_root.clone(); let channels_root = channels_root.clone();
let ctx = cc.egui_ctx.clone(); let ctx = cc.egui_ctx.clone();
std::thread::Builder::new() std::thread::Builder::new()
.name("yt-offline-libscan".into()) .name("catacomb-libscan".into())
.spawn(move || { .spawn(move || {
let mut library = library::scan_channels_with_cache(&channels_root, Some(&db)); let mut library = library::scan_channels_with_cache(&channels_root, Some(&db));
// Hydrate per-channel download options + folder assignments // Hydrate per-channel download options + folder assignments
@ -493,7 +493,7 @@ impl App {
let tx = thumb_result_tx.clone(); let tx = thumb_result_tx.clone();
let ctx = cc.egui_ctx.clone(); let ctx = cc.egui_ctx.clone();
std::thread::Builder::new() std::thread::Builder::new()
.name(format!("yt-offline-thumb-{worker_id}")) .name(format!("catacomb-thumb-{worker_id}"))
.spawn(move || { .spawn(move || {
loop { loop {
// Hold the lock only for the recv itself; release // Hold the lock only for the recv itself; release
@ -1185,7 +1185,7 @@ impl App {
self.status = "Play this video first, then click a line to seek".into(); self.status = "Play this video first, then click a line to seek".into();
return; return;
} }
let sock = format!("/tmp/yt-offline-{id}.sock"); let sock = format!("/tmp/catacomb-{id}.sock");
match UnixStream::connect(&sock) { match UnixStream::connect(&sock) {
Ok(mut s) => { Ok(mut s) => {
let cmd = format!("{{\"command\":[\"seek\",{secs},\"absolute\"]}}\n"); let cmd = format!("{{\"command\":[\"seek\",{secs},\"absolute\"]}}\n");
@ -1216,7 +1216,7 @@ impl App {
let use_mpv_ipc = exe == "mpv" || exe == "mpv.exe"; let use_mpv_ipc = exe == "mpv" || exe == "mpv.exe";
#[cfg(unix)] #[cfg(unix)]
let sock_path = format!("/tmp/yt-offline-{video_id}.sock"); let sock_path = format!("/tmp/catacomb-{video_id}.sock");
let mut child_cmd = Command::new(&cmd); let mut child_cmd = Command::new(&cmd);
@ -1415,7 +1415,7 @@ impl App {
format!("Download failed: {label}") format!("Download failed: {label}")
}; };
let _ = notify_rust::Notification::new() let _ = notify_rust::Notification::new()
.summary("yt-offline") .summary("Catacomb")
.body(&summary) .body(&summary)
.timeout(notify_rust::Timeout::Milliseconds(4000)) .timeout(notify_rust::Timeout::Milliseconds(4000))
.show(); .show();
@ -1430,7 +1430,7 @@ impl App {
egui::TopBottomPanel::top("top_bar").show(ctx, |ui| { egui::TopBottomPanel::top("top_bar").show(ctx, |ui| {
ui.add_space(2.0); ui.add_space(2.0);
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.heading("yt-offline"); ui.heading("Catacomb");
ui.separator(); ui.separator();
ui.label("🔍"); ui.label("🔍");
ui.add( ui.add(
@ -2481,7 +2481,7 @@ impl App {
ui.heading("🌐 Remote libraries"); ui.heading("🌐 Remote libraries");
}); });
ui.label(egui::RichText::new( ui.label(egui::RichText::new(
"Browse another yt-offline instance read-only. Playback streams from the peer.") "Browse another Catacomb instance read-only. Playback streams from the peer.")
.weak().small()); .weak().small());
ui.separator(); ui.separator();
ui.horizontal_wrapped(|ui| { ui.horizontal_wrapped(|ui| {
@ -3234,7 +3234,7 @@ impl App {
ui.radio_value(&mut self.config.backup.use_bundled_ytdlp, false, "System") ui.radio_value(&mut self.config.backup.use_bundled_ytdlp, false, "System")
.on_hover_text("Use whatever yt-dlp is on PATH."); .on_hover_text("Use whatever yt-dlp is on PATH.");
ui.radio_value(&mut self.config.backup.use_bundled_ytdlp, true, "Bundled") ui.radio_value(&mut self.config.backup.use_bundled_ytdlp, true, "Bundled")
.on_hover_text("Use the yt-dlp + deno installed under ~/.local/share/yt-offline/bin/."); .on_hover_text("Use the yt-dlp + deno installed under ~/.local/share/catacomb/bin/.");
let installed = crate::ytdlp_bin::bundled_installed(); let installed = crate::ytdlp_bin::bundled_installed();
let btn_label = if installed { "Update" } else { "Install" }; let btn_label = if installed { "Update" } else { "Install" };
if ui.button(btn_label) if ui.button(btn_label)
@ -3559,7 +3559,7 @@ impl App {
ui.add_space(4.0); ui.add_space(4.0);
ui.label( ui.label(
egui::RichText::new( egui::RichText::new(
"Saves a copy of yt-offline.db (watched / favourites / bookmarks / \ "Saves a copy of catacomb.db (watched / favourites / bookmarks / \
waiting / channel-options / folders). Restore by copying it back \ waiting / channel-options / folders). Restore by copying it back \
into your channels directory before launch.", into your channels directory before launch.",
) )
@ -3572,7 +3572,7 @@ impl App {
// cookies file-picker pattern. // cookies file-picker pattern.
let tx = self.backup_save_tx.clone(); let tx = self.backup_save_tx.clone();
let default_name = format!( let default_name = format!(
"yt-offline-{}.db", "catacomb-{}.db",
std::time::SystemTime::now() std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs()).unwrap_or(0), .map(|d| d.as_secs()).unwrap_or(0),
@ -4517,9 +4517,9 @@ impl eframe::App for App {
Err(e) => self.status = format!("Could not read {}: {e}", path.display()), Err(e) => self.status = format!("Could not read {}: {e}", path.display()),
} }
} }
// User picked a backup destination — copy yt-offline.db there. // User picked a backup destination — copy catacomb.db there.
while let Ok(dest) = self.backup_save_rx.try_recv() { while let Ok(dest) = self.backup_save_rx.try_recv() {
let src = self.channels_root.join("yt-offline.db"); let src = self.channels_root.join("catacomb.db");
match std::fs::copy(&src, &dest) { match std::fs::copy(&src, &dest) {
Ok(bytes) => self.status = format!( Ok(bytes) => self.status = format!(
"Backup saved ({} → {})", "Backup saved ({} → {})",

View file

@ -24,14 +24,14 @@ pub struct Config {
pub subtitles: SubtitlesSection, pub subtitles: SubtitlesSection,
#[serde(default)] #[serde(default)]
pub convert: ConvertSection, pub convert: ConvertSection,
/// Federation: other yt-offline instances whose libraries can be browsed /// Federation: other catacomb instances whose libraries can be browsed
/// read-only from this one (see [`crate::remote`]). Each is a /// read-only from this one (see [`crate::remote`]). Each is a
/// `[[remote]]` table in config.toml. /// `[[remote]]` table in config.toml.
#[serde(default, rename = "remote")] #[serde(default, rename = "remote")]
pub remotes: Vec<RemoteSection>, pub remotes: Vec<RemoteSection>,
} }
/// One `[[remote]]` entry — a peer yt-offline instance to browse read-only. /// One `[[remote]]` entry — a peer catacomb instance to browse read-only.
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RemoteSection { pub struct RemoteSection {
/// Display name in the UI's remote switcher. /// Display name in the UI's remote switcher.
@ -122,8 +122,8 @@ pub struct BackupSection {
/// automatically when a slot opens. Set to 0 for no limit (not recommended). /// automatically when a slot opens. Set to 0 for no limit (not recommended).
#[serde(default = "default_max_concurrent")] #[serde(default = "default_max_concurrent")]
pub max_concurrent: usize, pub max_concurrent: usize,
/// If true, use the bundled yt-dlp + deno binaries managed by yt-offline /// If true, use the bundled yt-dlp + deno binaries managed by catacomb
/// (installed under `~/.local/share/yt-offline/bin/`). If false, use the /// (installed under `~/.local/share/catacomb/bin/`). If false, use the
/// `yt-dlp` found on the system PATH. /// `yt-dlp` found on the system PATH.
#[serde(default)] #[serde(default)]
pub use_bundled_ytdlp: bool, pub use_bundled_ytdlp: bool,

View file

@ -1,9 +1,9 @@
//! Persistent panic logging so a crashed yt-offline leaves a paper trail. //! Persistent panic logging so a crashed catacomb leaves a paper trail.
//! //!
//! GUI users don't see stderr (the binary is launched without a terminal //! GUI users don't see stderr (the binary is launched without a terminal
//! from a `.desktop` file or the IDE), so panics today vanish entirely. //! from a `.desktop` file or the IDE), so panics today vanish entirely.
//! This module installs a `panic::set_hook` early in `main` that appends //! This module installs a `panic::set_hook` early in `main` that appends
//! a structured entry to `<channels_root>/yt-offline.crash.log` for every //! a structured entry to `<channels_root>/catacomb.crash.log` for every
//! panic from any thread. //! panic from any thread.
//! //!
//! The default panic handler still runs after ours so dev runs keep //! The default panic handler still runs after ours so dev runs keep
@ -36,7 +36,7 @@ use std::path::{Path, PathBuf};
/// dozen panics is what a bug-report attacher actually wants. /// dozen panics is what a bug-report attacher actually wants.
const LOG_SIZE_CAP_BYTES: u64 = 256 * 1024; const LOG_SIZE_CAP_BYTES: u64 = 256 * 1024;
/// Install a global panic hook that logs to `<dir>/yt-offline.crash.log` /// Install a global panic hook that logs to `<dir>/catacomb.crash.log`
/// while preserving the default stderr behavior. /// while preserving the default stderr behavior.
/// ///
/// `dir` is the same directory the SQLite database lives in (typically /// `dir` is the same directory the SQLite database lives in (typically
@ -47,7 +47,7 @@ const LOG_SIZE_CAP_BYTES: u64 = 256 * 1024;
/// Safe to call once at process startup. Calling it more than once /// Safe to call once at process startup. Calling it more than once
/// replaces the previous hook (which is fine: the new dir wins). /// replaces the previous hook (which is fine: the new dir wins).
pub fn install(dir: &Path) { pub fn install(dir: &Path) {
let path = dir.join("yt-offline.crash.log"); let path = dir.join("catacomb.crash.log");
// Don't fail startup if the parent dir doesn't exist yet — log it, // Don't fail startup if the parent dir doesn't exist yet — log it,
// continue. The DB-open path further down the chain will surface a // continue. The DB-open path further down the chain will surface a
// permission / missing-dir error in a more legible way. // permission / missing-dir error in a more legible way.
@ -194,10 +194,10 @@ mod tests {
// (`std::thread::spawn` so the panic doesn't unwind the test // (`std::thread::spawn` so the panic doesn't unwind the test
// runner), and assert the file contents afterward. // runner), and assert the file contents afterward.
let mut tmp = std::env::temp_dir(); let mut tmp = std::env::temp_dir();
tmp.push(format!("yt-offline-crash-test-{}", std::process::id())); tmp.push(format!("catacomb-crash-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp); let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap(); std::fs::create_dir_all(&tmp).unwrap();
let log = tmp.join("yt-offline.crash.log"); let log = tmp.join("catacomb.crash.log");
// Install the hook scoped to this test. We swap it back at the // Install the hook scoped to this test. We swap it back at the
// end so other tests run on the default hook. // end so other tests run on the default hook.

View file

@ -1,6 +1,7 @@
//! Persistent storage for watched status, playback positions, and settings. //! Persistent storage for watched status, playback positions, settings, and
//! restart-persistent web sessions.
//! //!
//! Backed by a bundled SQLite database (`yt-offline.db`). Access goes through //! Backed by a bundled SQLite database (`catacomb.db`). Access goes through
//! a small `r2d2`-managed pool of connections rather than a single shared //! a small `r2d2`-managed pool of connections rather than a single shared
//! `Connection` — that way concurrent read queries from different axum //! `Connection` — that way concurrent read queries from different axum
//! handlers don't serialize on a mutex, and write queries still take their //! handlers don't serialize on a mutex, and write queries still take their
@ -13,6 +14,7 @@
//! | `watched` | `video_id` (PK), `watched_at` | Records videos the user has marked watched | //! | `watched` | `video_id` (PK), `watched_at` | Records videos the user has marked watched |
//! | `positions` | `video_id` (PK), `position_secs`, `updated_at` | Stores resume positions | //! | `positions` | `video_id` (PK), `position_secs`, `updated_at` | Stores resume positions |
//! | `settings` | `key` (PK), `value` | Persistent app settings (password hash, etc.) | //! | `settings` | `key` (PK), `value` | Persistent app settings (password hash, etc.) |
//! | `sessions` | `token` (PK), `issued_at` | Restart-persistent web login sessions |
use r2d2_sqlite::SqliteConnectionManager; use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::{Connection, Result}; use rusqlite::{Connection, Result};
@ -790,7 +792,7 @@ impl Database {
); );
} }
/// Idempotently merge another yt-offline database into this one. /// Idempotently merge another catacomb database into this one.
/// ///
/// Designed for "Import library backup…" — the user uploads a snapshot /// Designed for "Import library backup…" — the user uploads a snapshot
/// produced by `GET /api/backup/db`, and we merge its rows in without /// produced by `GET /api/backup/db`, and we merge its rows in without
@ -858,7 +860,7 @@ impl Database {
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_MISMATCH), rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_MISMATCH),
Some(format!( Some(format!(
"backup is missing required table `{table}` — \ "backup is missing required table `{table}` — \
not a yt-offline snapshot, or from an incompatible version" not a catacomb snapshot, or from an incompatible version"
)), )),
)); ));
} }
@ -1392,7 +1394,7 @@ mod restore_tests {
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0); static N: AtomicU64 = AtomicU64::new(0);
let id = N.fetch_add(1, Ordering::Relaxed); let id = N.fetch_add(1, Ordering::Relaxed);
p.push(format!("yt-offline-test-{}-{}-{}", std::process::id(), id, name)); p.push(format!("catacomb-test-{}-{}-{}", std::process::id(), id, name));
let _ = std::fs::remove_dir_all(&p); let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap(); std::fs::create_dir_all(&p).unwrap();
ScratchDir(p) ScratchDir(p)
@ -1478,7 +1480,7 @@ mod restore_tests {
let live = Database::open(&dir.join("live.db")).unwrap(); let live = Database::open(&dir.join("live.db")).unwrap();
// Create a SQLite file with a completely different schema. // Create a SQLite file with a completely different schema.
let bad = dir.join("not-yt-offline.db"); let bad = dir.join("not-catacomb.db");
let conn = Connection::open(&bad).unwrap(); let conn = Connection::open(&bad).unwrap();
conn.execute("CREATE TABLE foo (x INT)", []).unwrap(); conn.execute("CREATE TABLE foo (x INT)", []).unwrap();
drop(conn); drop(conn);

View file

@ -1467,7 +1467,7 @@ impl Downloader {
impl Drop for Downloader { impl Drop for Downloader {
/// Tear down the bgutil-pot child if we spawned one. Without this /// Tear down the bgutil-pot child if we spawned one. Without this
/// the server keeps running after yt-offline exits — orphaned and /// the server keeps running after catacomb exits — orphaned and
/// still bound to port 4416, blocking the next launch from /// still bound to port 4416, blocking the next launch from
/// re-spawning. /// re-spawning.
fn drop(&mut self) { fn drop(&mut self) {
@ -1594,7 +1594,7 @@ mod tests {
#[test] #[test]
fn redact_sensitive_strips_cookie_path() { fn redact_sensitive_strips_cookie_path() {
let abs = "/home/luna/.config/yt-offline/cookies.txt"; let abs = "/home/luna/.config/catacomb/cookies.txt";
let input = format!("Unable to load cookies from {abs}"); let input = format!("Unable to load cookies from {abs}");
let out = redact_sensitive(&input, abs); let out = redact_sensitive(&input, abs);
assert_eq!(out, "Unable to load cookies from cookies.txt"); assert_eq!(out, "Unable to load cookies from cookies.txt");

View file

@ -215,7 +215,7 @@ mod tests {
#[test] #[test]
fn renders_valid_rss() { fn renders_valid_rss() {
let feed = Feed { let feed = Feed {
title: "yt-offline — Demo & co".into(), title: "catacomb — Demo & co".into(),
description: "archive".into(), description: "archive".into(),
link: "http://host/".into(), link: "http://host/".into(),
items: vec![FeedItem { items: vec![FeedItem {
@ -232,7 +232,7 @@ mod tests {
}; };
let xml = render(&feed); let xml = render(&feed);
assert!(xml.starts_with("<?xml")); assert!(xml.starts_with("<?xml"));
assert!(xml.contains("<title>yt-offline — Demo &amp; co</title>")); assert!(xml.contains("<title>catacomb — Demo &amp; co</title>"));
assert!(xml.contains("<title>Episode &lt;1&gt;</title>")); assert!(xml.contains("<title>Episode &lt;1&gt;</title>"));
assert!(xml.contains(r#"<enclosure url="http://host/files/channels/Demo/a.mkv" length="12345" type="video/x-matroska"/>"#)); assert!(xml.contains(r#"<enclosure url="http://host/files/channels/Demo/a.mkv" length="12345" type="video/x-matroska"/>"#));
assert!(xml.contains("<itunes:duration>1:05</itunes:duration>")); assert!(xml.contains("<itunes:duration>1:05</itunes:duration>"));

View file

@ -1,9 +1,9 @@
//! yt-offline — desktop and web app for archiving YouTube content with yt-dlp. //! catacomb — desktop and web app for archiving YouTube content with yt-dlp.
//! //!
//! # Usage //! # Usage
//! //!
//! * **GUI mode** (default): `yt-offline` //! * **GUI mode** (default): `catacomb`
//! * **Web mode**: `yt-offline --web [PORT]` — starts a headless HTTP server //! * **Web mode**: `catacomb --web [PORT]` — starts a headless HTTP server
//! on the configured port (default 8080). //! on the configured port (default 8080).
//! //!
//! Configuration is read from `config.toml` in the current working directory. //! Configuration is read from `config.toml` in the current working directory.
@ -60,7 +60,7 @@ fn renderer_from_args(args: &[String]) -> eframe::Renderer {
if requested.is_some() { if requested.is_some() {
renderer_from_name(requested) renderer_from_name(requested)
} else { } else {
let env_renderer = std::env::var("YT_OFFLINE_RENDERER").ok(); let env_renderer = std::env::var("CATACOMB_RENDERER").ok();
renderer_from_name(env_renderer.as_deref()) renderer_from_name(env_renderer.as_deref())
} }
} }
@ -90,6 +90,33 @@ fn attach_windows_console() {
#[cfg(not(windows))] #[cfg(not(windows))]
fn attach_windows_console() {} fn attach_windows_console() {}
/// Adopt the pre-rename `yt-offline` data paths under the new `catacomb` names
/// so the rename doesn't strand an existing library or bundled toolchain.
/// Best-effort: only renames when the new path is absent and the old one still
/// exists, so it's a no-op on fresh installs and after the first migrated run.
fn migrate_legacy_paths(channels_root: &std::path::Path) {
fn adopt(old: std::path::PathBuf, new: std::path::PathBuf) {
if !new.exists() && old.exists() {
match std::fs::rename(&old, &new) {
Ok(()) => eprintln!("migrated {}{}", old.display(), new.display()),
Err(e) => eprintln!("warning: could not migrate {}{}: {e}", old.display(), new.display()),
}
}
}
// SQLite DB plus its WAL/SHM sidecars (if any), in the library root.
for suffix in ["", "-wal", "-shm"] {
adopt(
channels_root.join(format!("yt-offline.db{suffix}")),
channels_root.join(format!("catacomb.db{suffix}")),
);
}
// Bundled venv + binaries under the XDG data dir.
if let Some(home) = std::env::var_os("HOME") {
let share = std::path::PathBuf::from(home).join(".local").join("share");
adopt(share.join("yt-offline"), share.join("catacomb"));
}
}
fn main() -> eframe::Result<()> { fn main() -> eframe::Result<()> {
attach_windows_console(); attach_windows_console();
@ -104,6 +131,11 @@ fn main() -> eframe::Result<()> {
let mut cfg = config::Config::load(&cfg_path) let mut cfg = config::Config::load(&cfg_path)
.unwrap_or_else(|_| config::Config::default_with_dir(cwd.join("channels"))); .unwrap_or_else(|_| config::Config::default_with_dir(cwd.join("channels")));
// One-time adoption of the pre-rename `yt-offline` data paths so existing
// installs keep their library + bundled toolchain after the rename to
// Catacomb. Runs before anything opens the DB or the venv.
migrate_legacy_paths(&cfg.backup.directory);
// Install the persistent panic logger. Logs go alongside the SQLite // Install the persistent panic logger. Logs go alongside the SQLite
// database; the parent of channels_root is the same "library root" // database; the parent of channels_root is the same "library root"
// the desktop UI shows. // the desktop UI shows.
@ -137,16 +169,16 @@ fn main() -> eframe::Result<()> {
viewport: eframe::egui::ViewportBuilder::default() viewport: eframe::egui::ViewportBuilder::default()
.with_inner_size([1280.0, 820.0]) .with_inner_size([1280.0, 820.0])
.with_min_inner_size([800.0, 500.0]) .with_min_inner_size([800.0, 500.0])
.with_title("yt-offline"), .with_title("Catacomb"),
// Default to wgpu (Vulkan): the glow/OpenGL path crashes on some // Default to wgpu (Vulkan): the glow/OpenGL path crashes on some
// NVIDIA + Wayland maximizes (Glutin EGL_BAD_ALLOC). Keep a launch // NVIDIA + Wayland maximizes (Glutin EGL_BAD_ALLOC). Keep a launch
// escape hatch for systems where Vulkan/wgpu opens a blank window: // escape hatch for systems where Vulkan/wgpu opens a blank window:
// `YT_OFFLINE_RENDERER=glow yt-offline` or `yt-offline --renderer glow`. // `CATACOMB_RENDERER=glow catacomb` or `catacomb --renderer glow`.
renderer: renderer_from_args(&args), renderer: renderer_from_args(&args),
..Default::default() ..Default::default()
}; };
eframe::run_native( eframe::run_native(
"yt-offline", "Catacomb",
native_options, native_options,
Box::new(|cc| Ok(Box::new(app::App::new(cc, tray)))), Box::new(|cc| Ok(Box::new(app::App::new(cc, tray)))),
) )
@ -182,7 +214,7 @@ mod tests {
#[test] #[test]
fn renderer_arg_overrides_env_parser_path() { fn renderer_arg_overrides_env_parser_path() {
let args = vec![ let args = vec![
"yt-offline".to_string(), "Catacomb".to_string(),
"--renderer".to_string(), "--renderer".to_string(),
"gl".to_string(), "gl".to_string(),
]; ];

View file

@ -242,7 +242,7 @@ mod tests {
#[test] #[test]
fn is_within_accepts_inside_path() { fn is_within_accepts_inside_path() {
let tmp = std::env::temp_dir().join("yt-offline-iw-test-1"); let tmp = std::env::temp_dir().join("catacomb-iw-test-1");
let _ = fs::create_dir_all(&tmp); let _ = fs::create_dir_all(&tmp);
let inside = tmp.join("foo.txt"); let inside = tmp.join("foo.txt");
let _ = fs::write(&inside, "x"); let _ = fs::write(&inside, "x");
@ -253,7 +253,7 @@ mod tests {
#[test] #[test]
fn is_within_rejects_outside_path() { fn is_within_rejects_outside_path() {
let tmp = std::env::temp_dir().join("yt-offline-iw-test-2"); let tmp = std::env::temp_dir().join("catacomb-iw-test-2");
let _ = fs::create_dir_all(&tmp); let _ = fs::create_dir_all(&tmp);
// The target's parent canonicalises to /tmp, which doesn't start with tmp. // The target's parent canonicalises to /tmp, which doesn't start with tmp.
let outside = std::env::temp_dir().join("not-our-dir-xyz.txt"); let outside = std::env::temp_dir().join("not-our-dir-xyz.txt");
@ -264,7 +264,7 @@ mod tests {
#[test] #[test]
fn is_within_handles_missing_target() { fn is_within_handles_missing_target() {
// Target doesn't exist; parent dir does and is inside root. // Target doesn't exist; parent dir does and is inside root.
let tmp = std::env::temp_dir().join("yt-offline-iw-test-3"); let tmp = std::env::temp_dir().join("catacomb-iw-test-3");
let _ = fs::create_dir_all(&tmp); let _ = fs::create_dir_all(&tmp);
let ghost = tmp.join("does-not-exist.txt"); let ghost = tmp.join("does-not-exist.txt");
assert!(is_within(&tmp, &ghost)); assert!(is_within(&tmp, &ghost));

View file

@ -355,9 +355,9 @@ fn extract_after<'a>(url: &'a str, marker: &str) -> Option<&'a str> {
/// ///
/// All platforms (including YouTube) are nested as subdirectories of the /// All platforms (including YouTube) are nested as subdirectories of the
/// configured `channels_root`. So a config with /// configured `channels_root`. So a config with
/// `directory = "/mnt/library/yt-offline"` puts YouTube channels at /// `directory = "/mnt/library/catacomb"` puts YouTube channels at
/// `/mnt/library/yt-offline/channels/<creator>/` and Bandcamp artists at /// `/mnt/library/catacomb/channels/<creator>/` and Bandcamp artists at
/// `/mnt/library/yt-offline/bandcamp/<artist>/`. This keeps everything a /// `/mnt/library/catacomb/bandcamp/<artist>/`. This keeps everything a
/// user might want to archive under one tidy umbrella directory. /// user might want to archive under one tidy umbrella directory.
/// ///
/// The function's name predates the layout change — `channels_root` is /// The function's name predates the layout change — `channels_root` is

View file

@ -24,7 +24,7 @@
//! Lives next to the bundled deno + yt-dlp: //! Lives next to the bundled deno + yt-dlp:
//! //!
//! ```text //! ```text
//! ~/.local/share/yt-offline/ //! ~/.local/share/catacomb/
//! bin/ //! bin/
//! bgutil-pot ← the Rust HTTP server binary //! bgutil-pot ← the Rust HTTP server binary
//! venv/ ← reused — plugin zip unpacked into site-packages //! venv/ ← reused — plugin zip unpacked into site-packages
@ -65,7 +65,7 @@ pub fn server_url() -> String {
/// Path to the bgutil-pot binary inside the bundled bin dir. /// Path to the bgutil-pot binary inside the bundled bin dir.
/// ///
/// Co-locates with the bundled `deno` so a single bundled-dir cleanup /// Co-locates with the bundled `deno` so a single bundled-dir cleanup
/// (currently just `rm -rf ~/.local/share/yt-offline/bin`) removes /// (currently just `rm -rf ~/.local/share/catacomb/bin`) removes
/// both. /// both.
pub fn bin_path() -> PathBuf { pub fn bin_path() -> PathBuf {
let mut p = crate::ytdlp_bin::bundled_dir(); let mut p = crate::ytdlp_bin::bundled_dir();

View file

@ -1,4 +1,4 @@
//! Federation — read-only browsing of a *peer* yt-offline instance's library. //! Federation — read-only browsing of a *peer* catacomb instance's library.
//! //!
//! A [`RemoteClient`] talks to another instance over its existing web API: //! A [`RemoteClient`] talks to another instance over its existing web API:
//! it fetches `/api/library`, logging in first if the peer has a password //! it fetches `/api/library`, logging in first if the peer has a password
@ -61,7 +61,7 @@ impl RemoteClient {
let client = reqwest::blocking::Client::builder() let client = reqwest::blocking::Client::builder()
.cookie_store(true) .cookie_store(true)
.timeout(Duration::from_secs(30)) .timeout(Duration::from_secs(30))
.user_agent("yt-offline-federation") .user_agent("catacomb-federation")
.build() .build()
.unwrap_or_else(|_| reqwest::blocking::Client::new()); .unwrap_or_else(|_| reqwest::blocking::Client::new());
RemoteClient { RemoteClient {

View file

@ -62,14 +62,14 @@ struct TrayModel {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
impl ksni::Tray for TrayModel { impl ksni::Tray for TrayModel {
fn id(&self) -> String { fn id(&self) -> String {
"yt-offline".into() "Catacomb".into()
} }
fn title(&self) -> String { fn title(&self) -> String {
"yt-offline".into() "Catacomb".into()
} }
fn tool_tip(&self) -> ksni::ToolTip { fn tool_tip(&self) -> ksni::ToolTip {
ksni::ToolTip { ksni::ToolTip {
title: "yt-offline".into(), title: "Catacomb".into(),
description: "Self-hosted archive for YouTube + friends".into(), description: "Self-hosted archive for YouTube + friends".into(),
..Default::default() ..Default::default()
} }
@ -92,7 +92,7 @@ impl ksni::Tray for TrayModel {
use ksni::menu::StandardItem; use ksni::menu::StandardItem;
vec![ vec![
StandardItem { StandardItem {
label: "Show yt-offline".into(), label: "Show Catacomb".into(),
activate: Box::new(|m: &mut Self| { activate: Box::new(|m: &mut Self| {
let _ = m.tx.send(TrayEvent::Show); let _ = m.tx.send(TrayEvent::Show);
}), }),
@ -153,7 +153,7 @@ pub fn start(icon_png_bytes: &[u8]) -> Option<TrayHandle> {
}; };
std::thread::Builder::new() std::thread::Builder::new()
.name("yt-offline-tray".into()) .name("catacomb-tray".into())
.spawn(move || { .spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread() let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all() .enable_all()

View file

@ -401,7 +401,7 @@ struct SettingsPayload {
/// Maximum simultaneous yt-dlp processes. 0 = unlimited. Ignored if 0 on POST. /// Maximum simultaneous yt-dlp processes. 0 = unlimited. Ignored if 0 on POST.
#[serde(default)] #[serde(default)]
max_concurrent: usize, max_concurrent: usize,
/// If true, invoke the bundled yt-dlp under `~/.local/share/yt-offline/bin/` /// If true, invoke the bundled yt-dlp under `~/.local/share/catacomb/bin/`
/// instead of the system PATH yt-dlp. /// instead of the system PATH yt-dlp.
#[serde(default)] #[serde(default)]
use_bundled_ytdlp: bool, use_bundled_ytdlp: bool,
@ -806,7 +806,7 @@ pub fn write_cookies(text: &str) -> Result<usize, String> {
std::fs::write(&path, &content).map_err(|e| e.to_string())?; std::fs::write(&path, &content).map_err(|e| e.to_string())?;
// cookies.txt carries live session credentials — tighten the mode so it // cookies.txt carries live session credentials — tighten the mode so it
// isn't world-readable on multi-user systems. Best-effort, like the // isn't world-readable on multi-user systems. Best-effort, like the
// similar guard on yt-offline.db. // similar guard on catacomb.db.
#[cfg(unix)] #[cfg(unix)]
{ {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
@ -2406,7 +2406,7 @@ async fn get_feed_all(State(state): State<Arc<WebState>>, headers: HeaderMap) ->
.collect(); .collect();
let items = build_feed_items(all, root, &base, &state.feed_token, FEED_LIMIT); let items = build_feed_items(all, root, &base, &state.feed_token, FEED_LIMIT);
let feed = crate::feed::Feed { let feed = crate::feed::Feed {
title: "yt-offline — Library".into(), title: "Catacomb — Library".into(),
description: "Your archived videos as a podcast feed.".into(), description: "Your archived videos as a podcast feed.".into(),
link: format!("{base}/"), link: format!("{base}/"),
items, items,
@ -2430,7 +2430,7 @@ async fn get_feed_channel(
.chain(ch.playlists.iter().flat_map(|p| p.videos.iter())).collect(); .chain(ch.playlists.iter().flat_map(|p| p.videos.iter())).collect();
let items = build_feed_items(vids, root, &base, &state.feed_token, FEED_LIMIT); let items = build_feed_items(vids, root, &base, &state.feed_token, FEED_LIMIT);
let feed = crate::feed::Feed { let feed = crate::feed::Feed {
title: format!("yt-offline — {}", ch.name), title: format!("Catacomb — {}", ch.name),
description: format!("Archived videos from {}.", ch.name), description: format!("Archived videos from {}.", ch.name),
link: format!("{base}/"), link: format!("{base}/"),
items, items,
@ -2696,7 +2696,7 @@ async fn delete_folder(
/// session credentials that shouldn't fly over the wire unprompted, and /// session credentials that shouldn't fly over the wire unprompted, and
/// keeping the endpoint to one file means no extra deps for tar/zip. /// keeping the endpoint to one file means no extra deps for tar/zip.
async fn get_backup_db(State(state): State<Arc<WebState>>) -> Response { async fn get_backup_db(State(state): State<Arc<WebState>>) -> Response {
let db_path = state.channels_root.join("yt-offline.db"); let db_path = state.channels_root.join("catacomb.db");
let Ok(bytes) = std::fs::read(&db_path) else { let Ok(bytes) = std::fs::read(&db_path) else {
return (StatusCode::NOT_FOUND, "no db file on disk (running in-memory?)").into_response(); return (StatusCode::NOT_FOUND, "no db file on disk (running in-memory?)").into_response();
}; };
@ -2704,7 +2704,7 @@ async fn get_backup_db(State(state): State<Arc<WebState>>) -> Response {
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs()) .map(|d| d.as_secs())
.unwrap_or(0); .unwrap_or(0);
let filename = format!("yt-offline-{now_secs}.db"); let filename = format!("catacomb-{now_secs}.db");
( (
[ [
(header::CONTENT_TYPE, "application/x-sqlite3".to_string()), (header::CONTENT_TYPE, "application/x-sqlite3".to_string()),
@ -2719,7 +2719,7 @@ async fn get_backup_db(State(state): State<Arc<WebState>>) -> Response {
/// rows into the live DB. Mirrors `GET /api/backup/db` in the opposite /// rows into the live DB. Mirrors `GET /api/backup/db` in the opposite
/// direction. /// direction.
/// ///
/// The body is the raw `yt-offline.db` bytes — same shape as what /// The body is the raw `catacomb.db` bytes — same shape as what
/// `get_backup_db` produces. We write it to a sibling temp file, hand it /// `get_backup_db` produces. We write it to a sibling temp file, hand it
/// to [`Database::restore_from_backup`] for the actual merge, then /// to [`Database::restore_from_backup`] for the actual merge, then
/// refresh the in-memory watched / positions / flags caches so the next /// refresh the in-memory watched / positions / flags caches so the next
@ -2750,7 +2750,7 @@ async fn post_restore_db(
// Write to a sibling tmp file so it lives on the same filesystem as // Write to a sibling tmp file so it lives on the same filesystem as
// the live DB — keeps ATTACH happy and avoids cross-device rename // the live DB — keeps ATTACH happy and avoids cross-device rename
// issues if we ever extend this to atomic-replace semantics. // issues if we ever extend this to atomic-replace semantics.
let tmp_path = state.channels_root.join(".yt-offline.restore.tmp"); let tmp_path = state.channels_root.join(".catacomb.restore.tmp");
if let Err(e) = std::fs::write(&tmp_path, &body) { if let Err(e) = std::fs::write(&tmp_path, &body) {
return (StatusCode::INTERNAL_SERVER_ERROR, return (StatusCode::INTERNAL_SERVER_ERROR,
format!("write tmp file: {e}")).into_response(); format!("write tmp file: {e}")).into_response();
@ -3124,7 +3124,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
let dir = crate::platform::platform_root(&channels_root, p); let dir = crate::platform::platform_root(&channels_root, p);
let _ = std::fs::create_dir_all(&dir); let _ = std::fs::create_dir_all(&dir);
} }
let db_path = channels_root.join("yt-offline.db"); let db_path = channels_root.join("catacomb.db");
let config_path = std::env::current_dir() let config_path = std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from(".")) .unwrap_or_else(|_| PathBuf::from("."))
.join("config.toml"); .join("config.toml");
@ -3374,7 +3374,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
return; return;
} }
}; };
println!("yt-offline web UI: http://localhost:{port}"); println!("Catacomb web UI: http://localhost:{port}");
// Now that we're accepting connections, run the initial library scan in // Now that we're accepting connections, run the initial library scan in
// the background so a cold-cache scan of a large library doesn't delay // the background so a cold-cache scan of a large library doesn't delay

View file

@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>yt-offline</title> <title>Catacomb</title>
<!-- Embedded UI fonts (base64 below, offline-safe — no CDN): Instrument Serif <!-- Embedded UI fonts (base64 below, offline-safe — no CDN): Instrument Serif
by Rodrigo Fuenzalida and Hanken Grotesk by Alfredo Marco Pradil, both by Rodrigo Fuenzalida and Hanken Grotesk by Alfredo Marco Pradil, both
under the SIL Open Font License 1.1. --> under the SIL Open Font License 1.1. -->
@ -87,9 +87,9 @@
.modal-hdr h2{flex:1;font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .modal-hdr h2{flex:1;font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.modal-body{display:flex;gap:12px;overflow:hidden;flex:1;min-height:0} .modal-body{display:flex;gap:12px;overflow:hidden;flex:1;min-height:0}
.modal video{flex:1;min-width:0;max-height:75vh;background:#000} .modal video{flex:1;min-width:0;max-height:75vh;background:#000}
/* Custom player (used for non-seekable transcode streams): the live ffmpeg /* Custom player: direct files and non-seekable transcode streams now share
pipe has no byte ranges, so we drive a scrubber off the known duration and the same chrome. The ffmpeg pipe has no byte ranges, so transcode scrubbing
re-request the stream at an offset on seek. */ re-requests the stream at an offset while direct files set currentTime. */
.vid-wrap{flex:1;min-width:0;display:flex;flex-direction:column;gap:6px} .vid-wrap{flex:1;min-width:0;display:flex;flex-direction:column;gap:6px}
.vid-wrap video{flex:1;min-height:0;max-height:72vh;width:100%;background:#000} .vid-wrap video{flex:1;min-height:0;max-height:72vh;width:100%;background:#000}
.vctrl{display:flex;align-items:center;gap:8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:6px 8px;flex-wrap:wrap} .vctrl{display:flex;align-items:center;gap:8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:6px 8px;flex-wrap:wrap}
@ -499,7 +499,7 @@
<body> <body>
<header> <header>
<button id="menu-btn" onclick="toggleSidebar()"></button> <button id="menu-btn" onclick="toggleSidebar()"></button>
<h1>yt-offline</h1> <h1>Catacomb</h1>
<input type="search" id="search" placeholder="Filter…" oninput="saveUiState();renderGrid()"> <input type="search" id="search" placeholder="Filter…" oninput="saveUiState();renderGrid()">
<select id="sort" onchange="saveUiState();renderGrid()"> <select id="sort" onchange="saveUiState();renderGrid()">
<optgroup label="Download date"> <optgroup label="Download date">
@ -598,7 +598,7 @@ function closeSidebar(){document.getElementById('sidebar').classList.remove('ope
/* ── API ────────────────────────────────────────────────────────── */ /* ── API ────────────────────────────────────────────────────────── */
async function api(path,opts){ async function api(path,opts){
const r=await fetch(path,opts); const r=await fetch(path,opts);
// Session gone (expired, or the server restarted — sessions are in-memory): // Session gone (expired, cleared by password change/logout, or otherwise absent):
// the cached SPA would otherwise just throw cryptic "authentication required" // the cached SPA would otherwise just throw cryptic "authentication required"
// toasts on every action. Reload instead so the server serves the login page. // toasts on every action. Reload instead so the server serves the login page.
if(r.status===401){ if(r.status===401){
@ -1632,8 +1632,8 @@ function shufflePlay(){
} }
playVideo(pick.id); playVideo(pick.id);
} }
// Player state for the custom transcode controls. For direct /files/ videos // Player state for the custom controls. For direct /files/ videos `isTranscode`
// `isTranscode` is false and native <video controls> handle everything. // is false and seeking sets currentTime; transcode seeks by reloading at start=.
let player={isTranscode:false,base:'',seekBase:0,duration:0}; let player={isTranscode:false,base:'',seekBase:0,duration:0};
function transcodeUrlAt(t){const u=player.base;const sep=u.includes('?')?'&':'?';return safeUrl(u+sep+'start='+Math.max(0,t).toFixed(2));} function transcodeUrlAt(t){const u=player.base;const sep=u.includes('?')?'&':'?';return safeUrl(u+sep+'start='+Math.max(0,t).toFixed(2));}
// Effective playback position: a transcode element's currentTime restarts at 0 // Effective playback position: a transcode element's currentTime restarts at 0
@ -2289,7 +2289,7 @@ async function openSettings(){
<label for="cf-ytdlp-bundled">Use bundled yt-dlp + deno</label> <label for="cf-ytdlp-bundled">Use bundled yt-dlp + deno</label>
<input type="checkbox" id="cf-ytdlp-bundled" ${cur.use_bundled_ytdlp?'checked':''}> <input type="checkbox" id="cf-ytdlp-bundled" ${cur.use_bundled_ytdlp?'checked':''}>
</div> </div>
<div class="settings-hint" style="margin-bottom:6px">Bundled binaries live in ~/.local/share/yt-offline/bin/ and include a JS runtime (deno) needed for YouTube signature deciphering. When off, the yt-dlp on your PATH is used.</div> <div class="settings-hint" style="margin-bottom:6px">Bundled binaries live in ~/.local/share/catacomb/bin/ and include a JS runtime (deno) needed for YouTube signature deciphering. When off, the yt-dlp on your PATH is used.</div>
<div style="display:flex;gap:6px;align-items:center;margin-bottom:4px"> <div style="display:flex;gap:6px;align-items:center;margin-bottom:4px">
<button onclick="updateYtdlp(this)">${cur.bundled_ytdlp_installed?'⟳ Update bundled':'⤓ Install bundled'}</button> <button onclick="updateYtdlp(this)">${cur.bundled_ytdlp_installed?'⟳ Update bundled':'⤓ Install bundled'}</button>
<span style="font-size:11px;color:var(--muted)">${cur.bundled_ytdlp_installed?'✓ installed':'not installed'}</span> <span style="font-size:11px;color:var(--muted)">${cur.bundled_ytdlp_installed?'✓ installed':'not installed'}</span>

View file

@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>yt-offline — Sign in</title> <title>Catacomb — Sign in</title>
<style> <style>
/* Standalone page (the SPA's embedded fonts aren't available here), so the /* Standalone page (the SPA's embedded fonts aren't available here), so the
display face is a system serif stack — editorial without extra bytes. The display face is a system serif stack — editorial without extra bytes. The
@ -47,7 +47,7 @@
</head> </head>
<body> <body>
<div class="box"> <div class="box">
<div class="brand"><span class="dot"></span><h1>yt-offline</h1></div> <div class="brand"><span class="dot"></span><h1>Catacomb</h1></div>
<div class="tag">your private archive</div> <div class="tag">your private archive</div>
<div class="field"><input type="password" id="pwd" placeholder="Password" autofocus onkeydown="if(event.key==='Enter')login()"></div> <div class="field"><input type="password" id="pwd" placeholder="Password" autofocus onkeydown="if(event.key==='Enter')login()"></div>
<button onclick="login()">Sign in</button> <button onclick="login()">Sign in</button>

View file

@ -4,13 +4,13 @@
//! app invokes its own yt-dlp instead of whatever's on PATH. To get the //! app invokes its own yt-dlp instead of whatever's on PATH. To get the
//! full feature set — most importantly `curl_cffi`-backed `--impersonate` //! full feature set — most importantly `curl_cffi`-backed `--impersonate`
//! support — we install yt-dlp into a self-contained Python virtualenv //! support — we install yt-dlp into a self-contained Python virtualenv
//! under `~/.local/share/yt-offline/venv/`. A bundled `deno` lives at //! under `~/.local/share/catacomb/venv/`. A bundled `deno` lives at
//! `~/.local/share/yt-offline/bin/deno` so yt-dlp can evaluate the //! `~/.local/share/catacomb/bin/deno` so yt-dlp can evaluate the
//! JavaScript signature-deciphering code YouTube serves with the player. //! JavaScript signature-deciphering code YouTube serves with the player.
//! //!
//! Layout: //! Layout:
//! ```text //! ```text
//! ~/.local/share/yt-offline/ //! ~/.local/share/catacomb/
//! bin/ ← prepended to PATH so yt-dlp finds deno //! bin/ ← prepended to PATH so yt-dlp finds deno
//! deno //! deno
//! venv/ //! venv/
@ -30,7 +30,7 @@ pub fn bundled_root() -> PathBuf {
let home = std::env::var_os("HOME") let home = std::env::var_os("HOME")
.map(PathBuf::from) .map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(".")); .unwrap_or_else(|| PathBuf::from("."));
home.join(".local").join("share").join("yt-offline") home.join(".local").join("share").join("catacomb")
} }
/// Directory holding non-Python binaries (currently just `deno`). Also the /// Directory holding non-Python binaries (currently just `deno`). Also the

View file

@ -16,7 +16,7 @@ use std::sync::atomic::{AtomicU16, Ordering};
/// Absolute path to the binary under test (set by Cargo for integration /// Absolute path to the binary under test (set by Cargo for integration
/// tests of a crate that builds a binary). /// tests of a crate that builds a binary).
const BIN: &str = env!("CARGO_BIN_EXE_yt-offline"); const BIN: &str = env!("CARGO_BIN_EXE_catacomb");
/// True if `curl` is usable; the tests no-op otherwise so a machine /// True if `curl` is usable; the tests no-op otherwise so a machine
/// without curl doesn't show spurious failures. /// without curl doesn't show spurious failures.
@ -32,7 +32,7 @@ fn have_ffmpeg() -> bool {
.status().map(|s| s.success()).unwrap_or(false) .status().map(|s| s.success()).unwrap_or(false)
} }
/// A running `yt-offline --web` child against a scratch dir. Killed and /// A running `catacomb --web` child against a scratch dir. Killed and
/// its dir removed on drop. /// its dir removed on drop.
struct Server { struct Server {
child: Child, child: Child,
@ -59,7 +59,7 @@ impl Server {
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::null()) .stderr(Stdio::null())
.spawn() .spawn()
.expect("spawn yt-offline --web"); .expect("spawn catacomb --web");
let s = Server { child, port, dir }; let s = Server { child, port, dir };
s.wait_ready(); s.wait_ready();
s s
@ -176,7 +176,7 @@ fn index_and_library_served() {
let (code, body) = s.get("/"); let (code, body) = s.get("/");
assert_eq!(code, 200, "GET / should serve the SPA"); assert_eq!(code, 200, "GET / should serve the SPA");
assert!(body.contains("yt-offline"), "index should mention yt-offline"); assert!(body.contains("Catacomb"), "index should mention Catacomb");
let (code, body) = s.get("/api/library"); let (code, body) = s.get("/api/library");
assert_eq!(code, 200); assert_eq!(code, 200);

0
yt-offline.db Normal file
View file

2
yt-offline/FETCH_HEAD Normal file
View file

@ -0,0 +1,2 @@
17c149c21a91e639919901ef7504bbc3df87ef33 not-for-merge branch 'main' of https://codeberg.org/anassaeneroi/yt-offline
f3c136a0183cdd64a7adc71c4d4dcea24e3fefcf not-for-merge branch 'master' of https://codeberg.org/anassaeneroi/yt-offline

1
yt-offline/HEAD Normal file
View file

@ -0,0 +1 @@
ref: refs/heads/main

9
yt-offline/config Normal file
View file

@ -0,0 +1,9 @@
[core]
repositoryformatversion = 0
filemode = true
bare = true
[remote "origin"]
url = https://codeberg.org/anassaeneroi/yt-offline.git
tagOpt = --no-tags
fetch = +refs/*:refs/*
mirror = true

1
yt-offline/description Normal file
View file

@ -0,0 +1 @@
Unnamed repository; edit this file 'description' to name the repository.

View file

@ -0,0 +1,15 @@
#!/bin/sh
#
# An example hook script to check the commit log message taken by
# applypatch from an e-mail message.
#
# The hook should exit with non-zero status after issuing an
# appropriate message if it wants to stop the commit. The hook is
# allowed to edit the commit message file.
#
# To enable this hook, rename this file to "applypatch-msg".
. git-sh-setup
commitmsg="$(git rev-parse --git-path hooks/commit-msg)"
test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"}
:

View file

@ -0,0 +1,74 @@
#!/bin/sh
#
# An example hook script to check the commit log message.
# Called by "git commit" with one argument, the name of the file
# that has the commit message. The hook should exit with non-zero
# status after issuing an appropriate message if it wants to stop the
# commit. The hook is allowed to edit the commit message file.
#
# To enable this hook, rename this file to "commit-msg".
# Uncomment the below to add a Signed-off-by line to the message.
# Doing this in a hook is a bad idea in general, but the prepare-commit-msg
# hook is more suited to it.
#
# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1"
# This example catches duplicate Signed-off-by lines and messages that
# would confuse 'git am'.
ret=0
test "" = "$(grep '^Signed-off-by: ' "$1" |
sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || {
echo >&2 Duplicate Signed-off-by lines.
ret=1
}
comment_re="$(
{
git config --get-regexp "^core\.comment(char|string)\$" ||
echo '#'
} | sed -n -e '
${
s/^[^ ]* //
s|[][*./\]|\\&|g
s/^auto$/[#;@!$%^&|:]/
p
}'
)"
scissors_line="^${comment_re} -\{8,\} >8 -\{8,\}\$"
comment_line="^${comment_re}.*"
blank_line='^[ ]*$'
# Disallow lines starting with "diff -" or "Index: " in the body of the
# message. Stop looking if we see a scissors line.
line="$(sed -n -e "
# Skip comments and blank lines at the start of the file.
/${scissors_line}/q
/${comment_line}/d
/${blank_line}/d
# The first paragraph will become the subject header so
# does not need to be checked.
: subject
n
/${scissors_line}/q
/${blank_line}/!b subject
# Check the body of the message for problematic
# prefixes.
: body
n
/${scissors_line}/q
/${comment_line}/b body
/^diff -/{p;q;}
/^Index: /{p;q;}
b body
" "$1")"
if test -n "$line"
then
echo >&2 "Message contains a diff that will confuse 'git am'."
echo >&2 "To fix this indent the diff."
ret=1
fi
exit $ret

View file

@ -0,0 +1,168 @@
#!/usr/bin/perl
use strict;
use warnings;
use IPC::Open2;
# An example hook script to integrate Watchman
# (https://facebook.github.io/watchman/) with git to speed up detecting
# new and modified files.
#
# The hook is passed a version (currently 2) and last update token
# formatted as a string and outputs to stdout a new update token and
# all files that have been modified since the update token. Paths must
# be relative to the root of the working tree and separated by a single NUL.
#
# To enable this hook, rename this file to "query-watchman" and set
# 'git config core.fsmonitor .git/hooks/query-watchman'
#
my ($version, $last_update_token) = @ARGV;
# Uncomment for debugging
# print STDERR "$0 $version $last_update_token\n";
# Check the hook interface version
if ($version ne 2) {
die "Unsupported query-fsmonitor hook version '$version'.\n" .
"Falling back to scanning...\n";
}
my $git_work_tree = get_working_dir();
my $json_pkg;
eval {
require JSON::XS;
$json_pkg = "JSON::XS";
1;
} or do {
require JSON::PP;
$json_pkg = "JSON::PP";
};
launch_watchman();
sub launch_watchman {
my $o = watchman_query();
if (is_work_tree_watched($o)) {
output_result($o->{clock}, @{$o->{files}});
}
}
sub output_result {
my ($clockid, @files) = @_;
# Uncomment for debugging watchman output
# open (my $fh, ">", ".git/watchman-output.out");
# binmode $fh, ":utf8";
# print $fh "$clockid\n@files\n";
# close $fh;
binmode STDOUT, ":utf8";
print $clockid;
print "\0";
local $, = "\0";
print @files;
}
sub watchman_clock {
my $response = qx/watchman clock "$git_work_tree"/;
die "Failed to get clock id on '$git_work_tree'.\n" .
"Falling back to scanning...\n" if $? != 0;
return $json_pkg->new->utf8->decode($response);
}
sub watchman_query {
my $pid = open2(\*CHLD_OUT, \*CHLD_IN, 'watchman -j --no-pretty')
or die "open2() failed: $!\n" .
"Falling back to scanning...\n";
# In the query expression below we're asking for names of files that
# changed since $last_update_token but not from the .git folder.
#
# To accomplish this, we're using the "since" generator to use the
# recency index to select candidate nodes and "fields" to limit the
# output to file names only. Then we're using the "expression" term to
# further constrain the results.
my $last_update_line = "";
if (substr($last_update_token, 0, 1) eq "c") {
$last_update_token = "\"$last_update_token\"";
$last_update_line = qq[\n"since": $last_update_token,];
}
my $query = <<" END";
["query", "$git_work_tree", {$last_update_line
"fields": ["name"],
"expression": ["not", ["dirname", ".git"]]
}]
END
# Uncomment for debugging the watchman query
# open (my $fh, ">", ".git/watchman-query.json");
# print $fh $query;
# close $fh;
print CHLD_IN $query;
close CHLD_IN;
my $response = do {local $/; <CHLD_OUT>};
# Uncomment for debugging the watch response
# open ($fh, ">", ".git/watchman-response.json");
# print $fh $response;
# close $fh;
die "Watchman: command returned no output.\n" .
"Falling back to scanning...\n" if $response eq "";
die "Watchman: command returned invalid output: $response\n" .
"Falling back to scanning...\n" unless $response =~ /^\{/;
return $json_pkg->new->utf8->decode($response);
}
sub is_work_tree_watched {
my ($output) = @_;
my $error = $output->{error};
if ($error and $error =~ m/unable to resolve root .* directory (.*) is not watched/) {
my $response = qx/watchman watch "$git_work_tree"/;
die "Failed to make watchman watch '$git_work_tree'.\n" .
"Falling back to scanning...\n" if $? != 0;
$output = $json_pkg->new->utf8->decode($response);
$error = $output->{error};
die "Watchman: $error.\n" .
"Falling back to scanning...\n" if $error;
# Uncomment for debugging watchman output
# open (my $fh, ">", ".git/watchman-output.out");
# close $fh;
# Watchman will always return all files on the first query so
# return the fast "everything is dirty" flag to git and do the
# Watchman query just to get it over with now so we won't pay
# the cost in git to look up each individual file.
my $o = watchman_clock();
$error = $o->{error};
die "Watchman: $error.\n" .
"Falling back to scanning...\n" if $error;
output_result($o->{clock}, ("/"));
return 0;
}
die "Watchman: $error.\n" .
"Falling back to scanning...\n" if $error;
return 1;
}
sub get_working_dir {
my $working_dir;
if ($^O =~ 'msys' || $^O =~ 'cygwin') {
$working_dir = Win32::GetCwd();
$working_dir =~ tr/\\/\//;
} else {
require Cwd;
$working_dir = Cwd::cwd();
}
return $working_dir;
}

View file

@ -0,0 +1,8 @@
#!/bin/sh
#
# An example hook script to prepare a packed repository for use over
# dumb transports.
#
# To enable this hook, rename this file to "post-update".
exec git update-server-info

View file

@ -0,0 +1,14 @@
#!/bin/sh
#
# An example hook script to verify what is about to be committed
# by applypatch from an e-mail message.
#
# The hook should exit with non-zero status after issuing an
# appropriate message if it wants to stop the commit.
#
# To enable this hook, rename this file to "pre-applypatch".
. git-sh-setup
precommit="$(git rev-parse --git-path hooks/pre-commit)"
test -x "$precommit" && exec "$precommit" ${1+"$@"}
:

View file

@ -0,0 +1,49 @@
#!/bin/sh
#
# An example hook script to verify what is about to be committed.
# Called by "git commit" with no arguments. The hook should
# exit with non-zero status after issuing an appropriate message if
# it wants to stop the commit.
#
# To enable this hook, rename this file to "pre-commit".
if git rev-parse --verify HEAD >/dev/null 2>&1
then
against=HEAD
else
# Initial commit: diff against an empty tree object
against=$(git hash-object -t tree /dev/null)
fi
# If you want to allow non-ASCII filenames set this variable to true.
allownonascii=$(git config --type=bool hooks.allownonascii)
# Redirect output to stderr.
exec 1>&2
# Cross platform projects tend to avoid non-ASCII filenames; prevent
# them from being added to the repository. We exploit the fact that the
# printable range starts at the space character and ends with tilde.
if [ "$allownonascii" != "true" ] &&
# Note that the use of brackets around a tr range is ok here, (it's
# even required, for portability to Solaris 10's /usr/bin/tr), since
# the square bracket bytes happen to fall in the designated range.
test $(git diff-index --cached --name-only --diff-filter=A -z $against |
LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0
then
cat <<\EOF
Error: Attempt to add a non-ASCII file name.
This can cause problems if you want to work with people on other platforms.
To be portable it is advisable to rename the file.
If you know what you are doing you can disable this check using:
git config hooks.allownonascii true
EOF
exit 1
fi
# If there are whitespace errors, print the offending file names and fail.
exec git diff-index --check --cached $against --

View file

@ -0,0 +1,13 @@
#!/bin/sh
#
# An example hook script to verify what is about to be committed.
# Called by "git merge" with no arguments. The hook should
# exit with non-zero status after issuing an appropriate message to
# stderr if it wants to stop the merge commit.
#
# To enable this hook, rename this file to "pre-merge-commit".
. git-sh-setup
test -x "$GIT_DIR/hooks/pre-commit" &&
exec "$GIT_DIR/hooks/pre-commit"
:

View file

@ -0,0 +1,53 @@
#!/bin/sh
# An example hook script to verify what is about to be pushed. Called by "git
# push" after it has checked the remote status, but before anything has been
# pushed. If this script exits with a non-zero status nothing will be pushed.
#
# This hook is called with the following parameters:
#
# $1 -- Name of the remote to which the push is being done
# $2 -- URL to which the push is being done
#
# If pushing without using a named remote those arguments will be equal.
#
# Information about the commits which are being pushed is supplied as lines to
# the standard input in the form:
#
# <local ref> <local oid> <remote ref> <remote oid>
#
# This sample shows how to prevent push of commits where the log message starts
# with "WIP" (work in progress).
remote="$1"
url="$2"
zero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0')
while read local_ref local_oid remote_ref remote_oid
do
if test "$local_oid" = "$zero"
then
# Handle delete
:
else
if test "$remote_oid" = "$zero"
then
# New branch, examine all commits
range="$local_oid"
else
# Update to existing branch, examine new commits
range="$remote_oid..$local_oid"
fi
# Check for WIP commit
commit=$(git rev-list -n 1 --grep '^WIP' "$range")
if test -n "$commit"
then
echo >&2 "Found WIP commit in $local_ref, not pushing"
exit 1
fi
fi
done
exit 0

View file

@ -0,0 +1,169 @@
#!/bin/sh
#
# Copyright (c) 2006, 2008 Junio C Hamano
#
# The "pre-rebase" hook is run just before "git rebase" starts doing
# its job, and can prevent the command from running by exiting with
# non-zero status.
#
# The hook is called with the following parameters:
#
# $1 -- the upstream the series was forked from.
# $2 -- the branch being rebased (or empty when rebasing the current branch).
#
# This sample shows how to prevent topic branches that are already
# merged to 'next' branch from getting rebased, because allowing it
# would result in rebasing already published history.
publish=next
basebranch="$1"
if test "$#" = 2
then
topic="refs/heads/$2"
else
topic=`git symbolic-ref HEAD` ||
exit 0 ;# we do not interrupt rebasing detached HEAD
fi
case "$topic" in
refs/heads/??/*)
;;
*)
exit 0 ;# we do not interrupt others.
;;
esac
# Now we are dealing with a topic branch being rebased
# on top of master. Is it OK to rebase it?
# Does the topic really exist?
git show-ref -q "$topic" || {
echo >&2 "No such branch $topic"
exit 1
}
# Is topic fully merged to master?
not_in_master=`git rev-list --pretty=oneline ^master "$topic"`
if test -z "$not_in_master"
then
echo >&2 "$topic is fully merged to master; better remove it."
exit 1 ;# we could allow it, but there is no point.
fi
# Is topic ever merged to next? If so you should not be rebasing it.
only_next_1=`git rev-list ^master "^$topic" ${publish} | sort`
only_next_2=`git rev-list ^master ${publish} | sort`
if test "$only_next_1" = "$only_next_2"
then
not_in_topic=`git rev-list "^$topic" master`
if test -z "$not_in_topic"
then
echo >&2 "$topic is already up to date with master"
exit 1 ;# we could allow it, but there is no point.
else
exit 0
fi
else
not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"`
/usr/bin/perl -e '
my $topic = $ARGV[0];
my $msg = "* $topic has commits already merged to public branch:\n";
my (%not_in_next) = map {
/^([0-9a-f]+) /;
($1 => 1);
} split(/\n/, $ARGV[1]);
for my $elem (map {
/^([0-9a-f]+) (.*)$/;
[$1 => $2];
} split(/\n/, $ARGV[2])) {
if (!exists $not_in_next{$elem->[0]}) {
if ($msg) {
print STDERR $msg;
undef $msg;
}
print STDERR " $elem->[1]\n";
}
}
' "$topic" "$not_in_next" "$not_in_master"
exit 1
fi
<<\DOC_END
This sample hook safeguards topic branches that have been
published from being rewound.
The workflow assumed here is:
* Once a topic branch forks from "master", "master" is never
merged into it again (either directly or indirectly).
* Once a topic branch is fully cooked and merged into "master",
it is deleted. If you need to build on top of it to correct
earlier mistakes, a new topic branch is created by forking at
the tip of the "master". This is not strictly necessary, but
it makes it easier to keep your history simple.
* Whenever you need to test or publish your changes to topic
branches, merge them into "next" branch.
The script, being an example, hardcodes the publish branch name
to be "next", but it is trivial to make it configurable via
$GIT_DIR/config mechanism.
With this workflow, you would want to know:
(1) ... if a topic branch has ever been merged to "next". Young
topic branches can have stupid mistakes you would rather
clean up before publishing, and things that have not been
merged into other branches can be easily rebased without
affecting other people. But once it is published, you would
not want to rewind it.
(2) ... if a topic branch has been fully merged to "master".
Then you can delete it. More importantly, you should not
build on top of it -- other people may already want to
change things related to the topic as patches against your
"master", so if you need further changes, it is better to
fork the topic (perhaps with the same name) afresh from the
tip of "master".
Let's look at this example:
o---o---o---o---o---o---o---o---o---o "next"
/ / / /
/ a---a---b A / /
/ / / /
/ / c---c---c---c B /
/ / / \ /
/ / / b---b C \ /
/ / / / \ /
---o---o---o---o---o---o---o---o---o---o---o "master"
A, B and C are topic branches.
* A has one fix since it was merged up to "next".
* B has finished. It has been fully merged up to "master" and "next",
and is ready to be deleted.
* C has not merged to "next" at all.
We would want to allow C to be rebased, refuse A, and encourage
B to be deleted.
To compute (1):
git rev-list ^master ^topic next
git rev-list ^master next
if these match, topic has not merged in next at all.
To compute (2):
git rev-list master..topic
if this is empty, it is fully merged to "master".
DOC_END

View file

@ -0,0 +1,24 @@
#!/bin/sh
#
# An example hook script to make use of push options.
# The example simply echoes all push options that start with 'echoback='
# and rejects all pushes when the "reject" push option is used.
#
# To enable this hook, rename this file to "pre-receive".
if test -n "$GIT_PUSH_OPTION_COUNT"
then
i=0
while test "$i" -lt "$GIT_PUSH_OPTION_COUNT"
do
eval "value=\$GIT_PUSH_OPTION_$i"
case "$value" in
echoback=*)
echo "echo from the pre-receive-hook: ${value#*=}" >&2
;;
reject)
exit 1
esac
i=$((i + 1))
done
fi

View file

@ -0,0 +1,42 @@
#!/bin/sh
#
# An example hook script to prepare the commit log message.
# Called by "git commit" with the name of the file that has the
# commit message, followed by the description of the commit
# message's source. The hook's purpose is to edit the commit
# message file. If the hook fails with a non-zero status,
# the commit is aborted.
#
# To enable this hook, rename this file to "prepare-commit-msg".
# This hook includes three examples. The first one removes the
# "# Please enter the commit message..." help message.
#
# The second includes the output of "git diff --name-status -r"
# into the message, just before the "git status" output. It is
# commented because it doesn't cope with --amend or with squashed
# commits.
#
# The third example adds a Signed-off-by line to the message, that can
# still be edited. This is rarely a good idea.
COMMIT_MSG_FILE=$1
COMMIT_SOURCE=$2
SHA1=$3
/usr/bin/perl -i.bak -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' "$COMMIT_MSG_FILE"
# case "$COMMIT_SOURCE,$SHA1" in
# ,|template,)
# /usr/bin/perl -i.bak -pe '
# print "\n" . `git diff --cached --name-status -r`
# if /^#/ && $first++ == 0' "$COMMIT_MSG_FILE" ;;
# *) ;;
# esac
# SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
# git interpret-trailers --in-place --trailer "$SOB" "$COMMIT_MSG_FILE"
# if test -z "$COMMIT_SOURCE"
# then
# /usr/bin/perl -i.bak -pe 'print "\n" if !$first_line++' "$COMMIT_MSG_FILE"
# fi

View file

@ -0,0 +1,78 @@
#!/bin/sh
# An example hook script to update a checked-out tree on a git push.
#
# This hook is invoked by git-receive-pack(1) when it reacts to git
# push and updates reference(s) in its repository, and when the push
# tries to update the branch that is currently checked out and the
# receive.denyCurrentBranch configuration variable is set to
# updateInstead.
#
# By default, such a push is refused if the working tree and the index
# of the remote repository has any difference from the currently
# checked out commit; when both the working tree and the index match
# the current commit, they are updated to match the newly pushed tip
# of the branch. This hook is to be used to override the default
# behaviour; however the code below reimplements the default behaviour
# as a starting point for convenient modification.
#
# The hook receives the commit with which the tip of the current
# branch is going to be updated:
commit=$1
# It can exit with a non-zero status to refuse the push (when it does
# so, it must not modify the index or the working tree).
die () {
echo >&2 "$*"
exit 1
}
# Or it can make any necessary changes to the working tree and to the
# index to bring them to the desired state when the tip of the current
# branch is updated to the new commit, and exit with a zero status.
#
# For example, the hook can simply run git read-tree -u -m HEAD "$1"
# in order to emulate git fetch that is run in the reverse direction
# with git push, as the two-tree form of git read-tree -u -m is
# essentially the same as git switch or git checkout that switches
# branches while keeping the local changes in the working tree that do
# not interfere with the difference between the branches.
# The below is a more-or-less exact translation to shell of the C code
# for the default behaviour for git's push-to-checkout hook defined in
# the push_to_deploy() function in builtin/receive-pack.c.
#
# Note that the hook will be executed from the repository directory,
# not from the working tree, so if you want to perform operations on
# the working tree, you will have to adapt your code accordingly, e.g.
# by adding "cd .." or using relative paths.
if ! git update-index -q --ignore-submodules --refresh
then
die "Up-to-date check failed"
fi
if ! git diff-files --quiet --ignore-submodules --
then
die "Working directory has unstaged changes"
fi
# This is a rough translation of:
#
# head_has_history() ? "HEAD" : EMPTY_TREE_SHA1_HEX
if git cat-file -e HEAD 2>/dev/null
then
head=HEAD
else
head=$(git hash-object -t tree --stdin </dev/null)
fi
if ! git diff-index --quiet --cached --ignore-submodules $head --
then
die "Working directory has staged changes"
fi
if ! git read-tree -u -m "$commit"
then
die "Could not update working tree to new HEAD"
fi

View file

@ -0,0 +1,77 @@
#!/bin/sh
# An example hook script to validate a patch (and/or patch series) before
# sending it via email.
#
# The hook should exit with non-zero status after issuing an appropriate
# message if it wants to prevent the email(s) from being sent.
#
# To enable this hook, rename this file to "sendemail-validate".
#
# By default, it will only check that the patch(es) can be applied on top of
# the default upstream branch without conflicts in a secondary worktree. After
# validation (successful or not) of the last patch of a series, the worktree
# will be deleted.
#
# The following config variables can be set to change the default remote and
# remote ref that are used to apply the patches against:
#
# sendemail.validateRemote (default: origin)
# sendemail.validateRemoteRef (default: HEAD)
#
# Replace the TODO placeholders with appropriate checks according to your
# needs.
validate_cover_letter () {
file="$1"
# TODO: Replace with appropriate checks (e.g. spell checking).
true
}
validate_patch () {
file="$1"
# Ensure that the patch applies without conflicts.
git am -3 "$file" || return
# TODO: Replace with appropriate checks for this patch
# (e.g. checkpatch.pl).
true
}
validate_series () {
# TODO: Replace with appropriate checks for the whole series
# (e.g. quick build, coding style checks, etc.).
true
}
# main -------------------------------------------------------------------------
if test "$GIT_SENDEMAIL_FILE_COUNTER" = 1
then
remote=$(git config --default origin --get sendemail.validateRemote) &&
ref=$(git config --default HEAD --get sendemail.validateRemoteRef) &&
worktree=$(mktemp --tmpdir -d sendemail-validate.XXXXXXX) &&
git worktree add -fd --checkout "$worktree" "refs/remotes/$remote/$ref" &&
git config --replace-all sendemail.validateWorktree "$worktree"
else
worktree=$(git config --get sendemail.validateWorktree)
fi || {
echo "sendemail-validate: error: failed to prepare worktree" >&2
exit 1
}
unset GIT_DIR GIT_WORK_TREE
cd "$worktree" &&
if grep -q "^diff --git " "$1"
then
validate_patch "$1"
else
validate_cover_letter "$1"
fi &&
if test "$GIT_SENDEMAIL_FILE_COUNTER" = "$GIT_SENDEMAIL_FILE_TOTAL"
then
git config --unset-all sendemail.validateWorktree &&
trap 'git worktree remove -ff "$worktree"' EXIT &&
validate_series
fi

128
yt-offline/hooks/update.sample Executable file
View file

@ -0,0 +1,128 @@
#!/bin/sh
#
# An example hook script to block unannotated tags from entering.
# Called by "git receive-pack" with arguments: refname sha1-old sha1-new
#
# To enable this hook, rename this file to "update".
#
# Config
# ------
# hooks.allowunannotated
# This boolean sets whether unannotated tags will be allowed into the
# repository. By default they won't be.
# hooks.allowdeletetag
# This boolean sets whether deleting tags will be allowed in the
# repository. By default they won't be.
# hooks.allowmodifytag
# This boolean sets whether a tag may be modified after creation. By default
# it won't be.
# hooks.allowdeletebranch
# This boolean sets whether deleting branches will be allowed in the
# repository. By default they won't be.
# hooks.denycreatebranch
# This boolean sets whether remotely creating branches will be denied
# in the repository. By default this is allowed.
#
# --- Command line
refname="$1"
oldrev="$2"
newrev="$3"
# --- Safety check
if [ -z "$GIT_DIR" ]; then
echo "Don't run this script from the command line." >&2
echo " (if you want, you could supply GIT_DIR then run" >&2
echo " $0 <ref> <oldrev> <newrev>)" >&2
exit 1
fi
if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then
echo "usage: $0 <ref> <oldrev> <newrev>" >&2
exit 1
fi
# --- Config
allowunannotated=$(git config --type=bool hooks.allowunannotated)
allowdeletebranch=$(git config --type=bool hooks.allowdeletebranch)
denycreatebranch=$(git config --type=bool hooks.denycreatebranch)
allowdeletetag=$(git config --type=bool hooks.allowdeletetag)
allowmodifytag=$(git config --type=bool hooks.allowmodifytag)
# check for no description
projectdesc=$(sed -e '1q' "$GIT_DIR/description")
case "$projectdesc" in
"Unnamed repository"* | "")
echo "*** Project description file hasn't been set" >&2
exit 1
;;
esac
# --- Check types
# if $newrev is 0000...0000, it's a commit to delete a ref.
zero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0')
if [ "$newrev" = "$zero" ]; then
newrev_type=delete
else
newrev_type=$(git cat-file -t $newrev)
fi
case "$refname","$newrev_type" in
refs/tags/*,commit)
# un-annotated tag
short_refname=${refname##refs/tags/}
if [ "$allowunannotated" != "true" ]; then
echo "*** The un-annotated tag, $short_refname, is not allowed in this repository" >&2
echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2
exit 1
fi
;;
refs/tags/*,delete)
# delete tag
if [ "$allowdeletetag" != "true" ]; then
echo "*** Deleting a tag is not allowed in this repository" >&2
exit 1
fi
;;
refs/tags/*,tag)
# annotated tag
if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1
then
echo "*** Tag '$refname' already exists." >&2
echo "*** Modifying a tag is not allowed in this repository." >&2
exit 1
fi
;;
refs/heads/*,commit)
# branch
if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then
echo "*** Creating a branch is not allowed in this repository" >&2
exit 1
fi
;;
refs/heads/*,delete)
# delete branch
if [ "$allowdeletebranch" != "true" ]; then
echo "*** Deleting a branch is not allowed in this repository" >&2
exit 1
fi
;;
refs/remotes/*,commit)
# tracking branch
;;
refs/remotes/*,delete)
# delete tracking branch
if [ "$allowdeletebranch" != "true" ]; then
echo "*** Deleting a tracking branch is not allowed in this repository" >&2
exit 1
fi
;;
*)
# Anything else (is there anything else?)
echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2
exit 1
;;
esac
# --- Finished
exit 0

View file

@ -0,0 +1 @@
* -export-subst -export-ignore

6
yt-offline/info/exclude Normal file
View file

@ -0,0 +1,6 @@
# git ls-files --others --exclude-from=.git/info/exclude
# Lines that start with '#' are comments.
# For a project mostly in C, the following would be a good set of
# exclude patterns (uncomment them if you want to use them):
# *.[oa]
# *~

3
yt-offline/packed-refs Normal file
View file

@ -0,0 +1,3 @@
# pack-refs with: peeled fully-peeled sorted
8b4796b518296287bc3748413981ee65c8d328b2 refs/heads/main
f3c136a0183cdd64a7adc71c4d4dcea24e3fefcf refs/heads/master

View file

@ -0,0 +1 @@
17c149c21a91e639919901ef7504bbc3df87ef33