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>
The login page was the last surface still on the old flat style. Give it the
same identity as the rest of the UI: serif wordmark with the glowing crimson
"recording" dot, an italic "your private archive" tagline, accent aurora +
film-grain atmosphere on a gradient card, a themed focus ring and accent
button, and a soft entrance. Login JS is unchanged; honours reduced-motion.
Standalone page, so the display font is a system serif stack (no CDN, no
duplicating the SPA's embedded woff2).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the browser's native <video controls> (used for direct /files/
playback) with one custom player for both direct and transcode streams, so
the chrome matches the reskin and gains features it never had. All seeking
still routes through playerSeek/effTime, so the transcode reload-at-offset
path is unchanged.
- Custom scrubber: accent played fill + buffered fill + chapter tick markers
+ a hover time tooltip; click/drag to seek (pointer events, works in both
modes — commits the seek on release so transcode only reloads once).
- Playback speed popover (0.5–2×) and persistent volume + speed + captions
across videos (localStorage).
- Captions (CC) toggle for overlaid <track> subtitles (off by default; the
searchable 📄 transcript pane is unchanged), PiP, fullscreen.
- Auto-hiding controls + cursor after idle while playing; a center play/pause
flash; gradient scrim.
- Expanded keyboard: space/k, j/l ±10, ←/→ ±5, ↑/↓ volume, m, c, p, f,
< > speed, 0–9 seek-to-percent — now active for direct videos too (they
previously relied on native controls).
Verified: node --check passes, release builds clean, live 8081 serves it, and
a headless screenshot of the control chrome (extracted CSS) renders correctly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sessions were an in-memory HashMap seeded empty at startup, so every restart
or upgrade invalidated every browser's cookie (the cause of the recent
"settings save → error" report: a restart out from under a logged-in tab).
Mirror sessions to a new `sessions(token, issued_at)` table:
- store issued-at as a UNIX timestamp (the map switches from Instant→u64 so it
can round-trip the DB; is_authed prunes by wall-clock age vs SESSION_TTL),
- insert on login, delete on logout, clear on password change,
- rehydrate the map at startup via load_sessions(), which also prunes rows
past the TTL. DB writes are best-effort — a failure just means that token
won't survive a restart, not that login breaks.
Verified end-to-end: set a password, log in, restart the server against the
same DB, and the original cookie still authenticates (200) while a cookieless
request is still 401. 128 unit + 11 integration tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sessions are in-memory, so a server restart (or a session TTL expiry) leaves
a still-open SPA tab holding a stale cookie: cached views keep rendering, but
every mutating call 401s and surfaced only as an "authentication required"
toast (e.g. saving Settings) — a confusing dead end.
Make api() treat 401 as "session gone": flash a notice and location.reload(),
which the server answers with the login page. No reload loop (the login page
isn't the SPA), and authed sessions are unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reskin the Library-health modal into a systems-diagnostics readout, sibling
to the stats observatory and sharing its sx-* section language. Presentation
only — every id/class/handler the logic depends on (dup-chk, sim-chk, at-chk,
dedup-area, autotag-area, the async polling) is untouched.
- A pulsing health verdict ("Healthy" / "N items to review") whose count and
status colour update live as the async subsystems resolve.
- Four status tiles (Duplicate IDs, Missing assets, Similar content, Unfiled
groups) with health-coloured dots + top rules; sim/at start pending and are
filled by their render functions.
- Sections become instrument panels: duplicate/similar groups as cards with
KEEP/REMOVE pills, missing-asset rows, a gradient dedup progress bar, and
auto-tag groups with confidence dots. Modal widened to 920px.
- Status colour flows through --mx-c off a data-status attribute, scoped to
.mx-body so it can't leak onto other nodes; pulse honours reduced-motion.
Verified: JS passes node --check, release builds clean, live 8081 serves it,
and a headless screenshot of a harness running the extracted CSS+JS against a
mock report confirms the verdict math, tiles, pills and panels all render.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rebuild the Library statistics modal from plain tiles + flat bars + tables
into a real data dashboard, driven by the same /api/stats payload and the
theme variables (so all 10 themes inherit it).
- Metric cards with giant serif numbers that count up from zero on open
(size/runtime spin up through their units), an accent top-rule, and a
glowing hero tile.
- A self-drawing SVG area chart for downloads-per-week: Catmull-Rom-smoothed
line that animates in via stroke-dashoffset, a gradient area fill,
gridlines, a date axis, and hover dots with tooltips.
- A growing-column histogram for videos-by-upload-year with value labels.
- A ranked channel leaderboard with animated meter fills, gold/silver/bronze
ranks, and a Size/Count segmented toggle that re-animates on switch.
- Reveal animations gate behind .sx-go (added post-layout) so they play once
per open, not on re-render; honours prefers-reduced-motion; modal widened
to 1040px.
Verified: JS passes node --check, release builds clean, the live 8081 server
serves it, and a headless screenshot of a harness running the *extracted*
dashboard CSS+JS against a mock payload confirms the chart math, count-up,
histogram and leaderboard all render correctly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A presentational redesign of the web SPA, appended as a design layer so it
overrides by source order while every colour still flows through the existing
7 per-theme CSS variables — so all 10 themes inherit it and no element
id/class the JS relies on changes.
- Typography: embed Instrument Serif (display) + Hanken Grotesk (body) as
base64 woff2 (SIL OFL) — offline-safe, no CDN, no privacy leak. Serif
wordmark/headers/empty-state; clean grotesque for UI.
- Identity: a glowing crimson "recording" dot before the wordmark; a soft
accent aurora + fine SVG film-grain fixed behind content (negative z, so
it never sits under text).
- Depth & motion: translucent blurred masthead with a hairline accent
underglow; cinematic card hover (lift + accent ring + thumbnail zoom +
shadow); one-time staggered shell reveal; themed focus rings; thin themed
scrollbars; restrained button hover (no full crimson flood). Honours
prefers-reduced-motion.
Verified: JS still passes node --check, release builds clean, and a headless
chromium screenshot of the live 8081 server confirms it renders (fonts +
atmosphere + masthead) without breaking the existing layout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The tree already links a working x86_64-pc-windows-gnu exe; turn that into
a shipped artifact:
- scripts/package.sh gains a `win` target (and `all` picks it up when the
mingw toolchain is present) that cross-compiles and zips the exe +
LICENSE + a README listing the yt-dlp/ffmpeg/mpv PATH deps.
- Forgejo release workflow installs mingw-w64 + zip + the rust target, so
a tag push produces yt-offline-<ver>-x86_64-windows.zip from the same
Linux container — no Windows runner needed.
- main.rs: attach_windows_console() (cfg(windows), windows-sys
AttachConsole) reattaches a release build to the launching terminal so
--web/CLI output is visible, while a double-click stays windowless
(release sets windows_subsystem = "windows").
- ROADMAP 3.1 updated: Windows ships; macOS deferred (needs a Mac runner).
Native Linux build + 128 unit / 11 integration tests still green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add "download date" sorting (file mtime, distinct from upload date) plus a
broader set of options. Web select is now grouped via <optgroup>; desktop
gains SortMode::DownloadDesc/DownloadAsc/ChannelAsc with matching arms and
toolbar entries.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The startup channel scan used available_parallelism() (every core), which
could briefly peg the whole box on a cold/large library. Cap it at cores-1
so the machine stays responsive; the scan is disk-I/O-bound and runs as a
background task, so one fewer thread costs little. Mirrors the headroom the
fingerprint pass and thumbnail pool already leave.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Patch progress/speed in place on each tick instead of rebuilding the whole
modal body, so open per-job logs no longer collapse and the bar doesn't
flicker while downloading. Full rebuild only on a structural change
(job added/removed, state or retryability changed, queue length changed).
- "↻ Retry all failed" header button that re-issues every retryable failed
job (re-snapshots between retries since indices shift).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Download management UI (web + desktop):
- Cancel a running job (SIGKILL, marked "cancelled" so it isn't auto-retried
and shows a distinct state rather than a misleading error class)
- Cancel a still-queued job before it starts
- Manually retry a failed/cancelled job (fresh auto-retry budget)
- Live speed · ETA · % on running jobs, parsed from the yt-dlp progress line
- Expandable full per-job log fetched on demand
New endpoints: POST /api/jobs/:idx/{cancel,retry}, GET /api/jobs/:idx/log,
DELETE /api/queued/:idx.
Perceptual-dedup off-switch:
- backup.dedup_enabled (default true) hard-disables the "similar content"
scan in both UIs; the web scan endpoint returns {disabled:true} and the
desktop scan no-ops with a note. Wired through all five settings touchpoints.
Docs: README seeking note + ROADMAP recently-shipped updates for the prior
seekable-playback / federation / auto-tagging work.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Transcoded videos stream through a live ffmpeg pipe with no byte ranges, so
the browser couldn't seek/rewind them at all. Fix it end to end:
- get_transcode accepts ?start=<secs> and passes -ss before -i (fast keyframe
seek), rebasing timestamps to 0 from there.
- The player gets a custom control bar for transcode sources: play/pause, a
scrubber driven by the known duration, current/total time, ±10s, mute +
volume, and fullscreen, plus space / arrows / j / l / f keyboard shortcuts.
Seeking re-requests the stream at the new offset; effective time is
offset + element.currentTime. Every seek path (scrubber, chapters,
transcript click, resume, save-position, chapter/transcript highlight) now
routes through one offset-aware playerSeek()/effTime().
- Direct /files/ videos keep native <video controls> (already seekable).
Verified server-side: start=0 vs start=120 yield different streams.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Configure peers as [[remote]] entries (name, url, optional password) and
browse their libraries from this instance, read-only.
- remote.rs: RemoteClient (blocking reqwest + rustls + cookie jar). Fetches
the peer's /api/library, logging in first if the peer has a password and
retrying on a 401. Media is not proxied: the library JSON's media URLs
(/files/, /music-files/, /api/transcode/, /api/sub-vtt/) are rewritten to
absolute peer URLs with the peer's read-only feed token appended, so video
streams straight from the peer to the browser/mpv while only the small JSON
passes through us.
- web.rs: auth_middleware now also accepts the feed token for GET
/api/transcode/ and /api/sub-vtt/ (read-only media, same class as /files/),
so a transcoded peer streams remotely. New GET /api/remotes and
/api/remotes/:id/library (the RemoteClient runs under spawn_blocking).
- web UI: a 🌐 Remotes sidebar switcher; remote mode is read-only (hides the
download bar + per-card mutating actions, keeps Play).
- desktop: a 🌐 Remotes screen that fetches a peer's library off-thread and
plays via mpv on the tokenized URL.
- config.rs: [[remote]] section list; reqwest dep added (rustls/blocking/
cookies, no system OpenSSL).
Verified end-to-end against a second local instance, including a transcoded
stream returning HTTP 200 via the token. ROADMAP 3.5 + CLAUDE.md updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`cargo check --target x86_64-pc-windows-gnu` is now green (only the
upstream egui f32:From<f64> warnings). The Linux-only desktop deps are
target-gated so the rest of the stack — which already cross-compiles —
can build off Linux:
- Cargo.toml: ksni and rfd's xdg-portal backend move to
[target.'cfg(target_os = "linux")'.dependencies]; non-Linux gets rfd
with its native Win32/AppKit backend (default features).
- tray.rs: cfg-split. The ksni/SNI implementation stays Linux-only; other
OSes get a no-op `start() -> None`, i.e. windowed-only (identical to the
Linux no-SNI-host path). TrayEvent/TrayHandle stay cross-platform.
- plex.rs: add a Windows `symlink_file` arm to make_symlink (also fixes an
unused-variable warning on non-unix).
The rest was already portable (disk_space/statvfs, mpv IPC UnixStream,
chmod/PermissionsExt guards all cfg(unix)). macOS is Unix so those apply
there unchanged. Still not a *shipped* binary: needs a real per-OS tray
backend and a linking CI matrix. ROADMAP 3.1 + CLAUDE.md updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New autotag.rs classifies each unfiled channel from already-scanned
metadata — source platform + median video duration + upload cadence —
into a suggested folder group (Music / Shorts / Long-form & Podcasts /
Streams & VODs), with a confidence and a human-readable reason. Mid-length
YouTube is left unsuggested rather than guessed at; channels already in a
folder are skipped. Pure arithmetic over the in-memory library, computed
on demand (no background job).
Surfaced in both Maintenance views:
- web: GET /api/autotag/suggest + POST /api/autotag/apply (create/reuse the
named folder and assign), rendered with per-channel checkboxes and an
"Apply -> group" button that re-analyzes after applying.
- desktop: a section listing each group with a "Move all -> group" button.
Apply mirrors post_assign_folder: DB write + in-memory library update +
ETag bump. Reuses an existing folder of the same name (case-insensitive).
Unit tests cover the classifier; verified the apply/revert round-trip live.
ROADMAP 3.4 marked DONE.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Several related UI and startup fixes from one session:
- Web grid: channel and music views rendered in a single column because
their content was wrapped in one nested div that became a single cell of
the outer CSS grid. Span the wrappers (and .empty) across all columns
with grid-column:1/-1.
- Subtitles: route every track through /api/sub-vtt and strip per-cue
position/alignment settings (align:start position:0% etc.) from both SRT
and VTT, so auto-generated captions render centered instead of left-
aligned. New strip_cue_settings/normalize_vtt + sub_vtt_url helpers.
- Comment downloads: add a global backup.fetch_comments toggle (the
per-channel option already existed; it's now a tri-state override that
defers to the global). Full five-touchpoint wiring across config,
download_options, the downloader's apply_comments resolver, both UIs'
global + per-channel controls, and downloader seeding.
- Desktop startup: run the initial library scan + search-index sync on a
background thread (mirrors the web server's deferred bind) so the window
appears immediately instead of blocking on a cold-cache scan; update()
swaps the library in when it lands.
- Perceptual dedup: cap worker threads via fingerprint::default_workers()
(~half the cores, always leaving at least one free) instead of using
every core, so the hashing pass doesn't starve the UI.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
serve() ran a full library scan + search-index rebuild before binding the
HTTP listener. On a large library with a cold cache (e.g. on a slow mount)
that scan is disk-bound for minutes, during which nothing listens on the
port and the server looks dead on the network (notably over Tailscale).
Build WebState with an empty library, bind + print the URL immediately,
then run the scan in a spawn_blocking task that swaps the result in and
bumps the library version (same pattern as post_rescan). The server is now
reachable in ~1s; content appears once the first scan lands and clients
pick it up via the bumped version/ETag.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The desktop forces the wgpu/Vulkan renderer because glow/OpenGL crashes
on NVIDIA + Wayland maximize. But on some systems wgpu creates the window
and never presents a frame, so nothing appears. Keep wgpu the default but
compile glow back in and let it be selected via `--renderer glow` or
`YT_OFFLINE_RENDERER=glow` for those machines.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Subscribe to the whole library or a single channel in any podcast/media
app.
- src/feed.rs: pure RSS 2.0 + iTunes-namespace renderer with absolute
enclosure URLs, MIME-by-extension, HH:MM:SS durations, and a
chrono-free RFC-2822 date (civil-date algorithm). Unit-tested
(mime / escape / duration / date / render).
- web.rs: GET /feed.xml (whole library, newest first, capped at 300) and
GET /feed/:platform/:handle (one channel); /api/feed-info returns the
token for the UI. Enclosure + thumbnail URLs are absolute (derived from
Host + X-Forwarded-Proto) and carry the feed token.
- Auth: a stable read-only `feed_token` (persisted setting) lets a
tokenized `?token=` GET reach /feed*, /files, and /music-files even when
a password is set — podcast clients can't do the cookie login. Scoped to
reads of feeds + media; never /api mutations.
- Web UI: a "📡 Podcast feed" section in Settings (library URL + Copy) and
a "📡 Feed URL" button in each channel's options dialog.
Integration tests cover the served RSS (enclosure path, MIME, pubDate),
the channel feed + 404, and the full token gate (401 without token once a
password is set, 200 with it, /files reachable with it). 127 tests pass.
Desktop URL display follows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 🔍 library search now matches spoken words, not just titles /
channels / descriptions.
- database.rs: add a `transcript` column to the `video_search` FTS5 index.
Fresh DBs get it directly; existing ones are migrated (FTS5 has no ADD
COLUMN, so the table is recreated and search_meta cleared to force a
one-time reindex). `sync_search_index` reads each video's first subtitle
(mtime-gated like the description), flattens it via the shared `vtt`
parser (`transcript_text`, capped at 256 KB), and indexes it. Search
snippets now use column -1 so the excerpt comes from whichever column
matched (description or transcript).
- library.rs: `build_search_entries` passes the first subtitle path.
- Both UIs: search copy updated to mention transcripts.
Tests: a unit test (a word only in the .vtt is found), a migration test
(seed an old 5-column index + stale meta, open, confirm the recreated
index indexes transcript text), and the search integration test now seeds
a subtitle and asserts a spoken-only word hits. Also bumped the test
server's startup budget so the ffmpeg-heavy dedup test can't starve a
sibling server into a flaky timeout. 120 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extracts the dedup pipeline (mtime-gate -> parallel fingerprint -> prune
-> group) into `fingerprint::rebuild_and_group`, returning groups of file
paths. Both front-ends now call it and map paths to their own review
rows — web's run_dedup is refactored onto it (no behaviour change; the
integration test still passes).
Desktop: the 🩺 Maintenance screen gains a "Similar content (perceptual)"
section — a Scan button that runs the fingerprint job on a background
thread (live "N / total" progress via shared atomics + an mpsc result
channel), grouped results with a recommended-keep marker, and a per-group
"Remove non-recommended copies" that deletes via the existing
remove_files path and re-scans. Closes the desktop/web parity gap for
content-aware dedup.
118 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wires the fingerprint engine into the web Maintenance view.
- web.rs: a DedupState job (one at a time) on its own thread. POST
/api/maintenance/dedup/scan snapshots the library, mtime-gates against
stored fingerprints, fingerprints the new/changed videos in parallel
(progress counter), prunes vanished entries, groups by visual
similarity, and builds review rows (title/channel/size/files + a
recommended-keep = largest copy). GET /api/maintenance/dedup/status
polls progress + results. Deletion reuses /api/maintenance/remove.
- index.html: a "Similar content (perceptual)" section in the Maintenance
modal — Scan button, live progress bar, grouped results with
checkboxes (recommended-keep pre-unchecked), and a re-scan that drops
deleted copies. Poller self-cancels when the modal closes.
Integration test (ffmpeg-gated): generates orig + CRF-38 downscaled
re-encode + an unrelated clip, runs the real scan end-to-end, and asserts
the first two group while the third stays out. 118 tests pass.
Desktop Maintenance UI next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The core of content-aware duplicate detection — finding the same video
under a different ID (reupload / mirror / re-encode), which the ID-based
maintenance scan can't.
- src/fingerprint.rs: samples 6 frames per video via ffmpeg keyframe seek
(fast — never full-decodes), downscales to a 9x8 grayscale grid, and
dHashes each to a 64-bit frame hash. `group_similar` clusters videos
whose frame hashes match within a Hamming threshold, comparing only
within a duration-tolerance window (sorted sliding window + union-find)
so it stays near-linear instead of O(n²). Parallel `compute_batch` over
a hand-rolled scoped thread pool (no rayon), with a progress counter.
- src/database.rs: `video_fingerprint` table keyed by (path, mtime) like
info_cache — hash once, reuse forever; new downloads hashed as they
arrive. Store / load / mtime-gate / prune helpers.
Unit tests cover dHash, Hamming, duration-bucketed + transitive grouping,
and DB round-trip/replace/prune. An opt-in `real_ffmpeg_groups_reencodes`
test (run with --ignored) generates a video + a downscaled CRF-38
re-encode + an unrelated clip and asserts the first two group and the
third doesn't — measured ~0.5s/video serial (≈65ms/video across 8 cores).
UI wiring (background build + review screen in both front-ends) follows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Surface the downloaded .vtt subtitles as a searchable, click-to-seek
transcript — a thing Tartube doesn't do at all.
Web (pure frontend): a 📄 button in the player opens a transcript pane
beside the video. It fetches the already-served .vtt, parses cues
client-side, and offers full-text search (highlighted), click-to-seek,
and a live highlight + auto-scroll of the current line as the video
plays. Reuses the chapters-pane layout; hidden on narrow screens.
Desktop (egui): a 📄 Transcript button on the detail panel opens a
floating window that reads the .vtt off disk (new `vtt` parser module),
shows the searchable cue list, and seeks the running mpv via its
JSON-IPC socket when you click a line.
New `src/vtt.rs`: a small, tolerant WebVTT/SRT cue parser (start time +
tag-stripped text, consecutive-duplicate collapse) with unit tests.
Closes the desktop/web parity gap for transcripts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A new FTS5 index (`video_search`) over every video's title, channel, and
description — searchable across the whole library, not just the loaded
grid. `search_meta` tracks each video's mtime so a routine rescan only
re-reads a description sidecar when the video actually changed; vanished
videos are evicted. The index is refreshed after every scan in both
front-ends via the shared `library::build_search_entries`.
- database.rs: schema + `sync_search_index` (mtime-gated, one txn) +
`search_videos` (ranked, with a highlighted snippet) + a safe prefix
MATCH builder. Unit-tested (index/search/prefix/AND/evict/garbage).
- web.rs: `GET /api/search?q=&limit=`; index synced after the initial
scan, rescan, and maintenance-remove.
- Web UI: a 🔍 header button + `f` shortcut open a debounced search modal
with ranked results and highlighted description snippets; clicking a
result jumps to it.
- Desktop (egui): a 🔎 Search button opens a floating results window
querying the same index; clicking a result plays the video. Closes the
desktop/web parity gap for search.
Integration test seeds a real video + description, rescans, and asserts
title / prefix / description-only matches hit and unrelated misses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment viewer (web UI): the flat tree gains search (highlight + keep
ancestor context), sort (threaded / top / newest / oldest), per-thread
collapse/expand with collapse-all/expand-all, an OP badge for the
uploader, and a "new since last visit" highlight + count (localStorage
per-video timestamp). API now returns each comment's `timestamp` and
`author_is_uploader`.
SponsorBlock: was a hardcoded `--sponsorblock-mark all`. Now a real
setting (off / mark / remove) with a per-channel override, threaded
through the full five touchpoints — config `[backup].sponsorblock_mode`
(default "mark", preserving prior behavior), DownloadOptions override,
a downloader resolver (apply_sponsorblock), and both UIs (egui Settings
+ channel dialog; web Settings modal + channel dialog + SettingsPayload).
Integration tests extended: settings round-trip asserts the sponsorblock
default + persistence; channel-options round-trip asserts the override.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A yt-dlp process can stall with the OS process alive but no progress — a
wedged TLS handshake, an unresponsive server, a dead socket mid-fragment.
Before, that job hung forever, holding a concurrency slot until the user
noticed and cancelled.
Now each Job tracks last_activity (bumped on any stdout/stderr line or
progress message). poll() runs check_watchdog(): a Running job silent for
HANG_TIMEOUT (5 min) gets SIGKILLed by pid via libc::kill. The threshold
sits well above any legitimate quiet gap — yt-dlp prints a line per
download chunk, polls every 30s under --wait-for-video, and our longest
sleep is ~30s — so 5 min of total silence is a real stall.
To capture the pid, spawn_job + enqueue_transcode now spawn the child
*before* the reader thread (the thread still owns it for the blocking
wait()). A watchdog kill sets watchdog_killed, and drain() forces the
resulting failure to NetworkError (retryable) instead of the Other that
classify() would return for an output-less kill — so the existing
auto-retry path re-queues it (a hang is transient).
Non-Unix builds keep the flagging but can't force-kill (no portable
pid-kill; Windows isn't first-class). JobState gains Debug for tests.
2 tests: watchdog kill → retryable class; normal failures still
classify from the log. 98 pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The web server holds several long-lived Mutexes in WebState (library,
downloader, watched, flags, positions, sessions, …). Every access used
.lock().unwrap(), so a single panic while any one was held would poison
it and turn EVERY subsequent request into a panic — one handler's bug
killing the whole server until restart.
New util::LockExt::lock_recover() acquires the guard but recovers the
inner data via PoisonError::into_inner() on poison. Our critical
sections are short and don't leave half-updated invariants, so the data
stays usable and a stuck request beats a stuck process. Applied to all
70 lock sites in web.rs.
Test poisons a mutex from a panicking thread and confirms lock_recover
still returns usable data. 96 pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The chip filters (watch/date/size/subs/chapters) were session-only.
Add saveable named presets, finishing the last small Phase-1 line.
- ★ Save preset button (shown when any filter is active) → names the
current chip set via prompt and stores it.
- Saved presets render as chips in the filter row; click to apply,
the inline ✕ deletes. The preset matching the current filters is
highlighted.
- Persisted under their own `filter-presets` localStorage key so they
survive clearFilters() and aren't entangled with view state.
Handlers are index-based (not name-based) so preset names containing
quotes/apostrophes can't break the inline onclick — the list re-renders
after every change so indices always track the DOM. Same-name save
overwrites rather than duplicating.
Web-only: the desktop UI has no chip filters. Verified the save/apply/
delete/match/overwrite logic in isolation; JS syntax-checked; builds.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The card-density slider only resized the channel-grid cards — the
sidebar, settings, detail panel, and all text stayed fixed. Add a real
global scale that grows/shrinks the *entire* UI crisply.
egui already handles Ctrl + / Ctrl - / Ctrl 0 via zoom_factor (it
re-rasterizes fonts rather than bitmap-stretching), but it wasn't
persisted and had no visible control. Now:
- New `[ui] ui_scale` config (default 1.0), applied with
set_zoom_factor at startup.
- update() mirrors ctx.zoom_factor() back into config each frame and
saves on a 500ms debounce, so both the slider and the built-in
keyboard zoom persist across restarts without writing config.toml
every frame of a drag.
- A "UI scale" slider (0.5–3.0×) + Reset button in the Settings screen,
driving set_zoom_factor live.
The card-density "Size:" slider stays as a separate grid-density
preference (it composes with the global zoom). 95 tests pass; GUI
verified running with the per-frame zoom-sync path.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Post-download ffmpeg pass with three modes, global + per-channel.
Closes the last real Tartube parity gap.
## Modes
- remux-mp4: fast container change mkv→mp4 (stream-copy video, aac audio)
- h264-mp4: re-encode to H.264/AAC at a configurable CRF + x264 preset
- audio: extract to mp3 / m4a / opus / flac
## Design (separate ffmpeg pass)
The main download adds `--print after_move:CONVERT_PATH:%(filepath)s`
when conversion is active. poll() scans freshly-Done download jobs for
those lines and enqueues a distinct ffmpeg transcode Job per finished
file — so the UI shows transcoding as its own phase. On success the
source is renamed to <name>.original.<ext> (keep_original) or deleted;
the converted file lands next to it. Transcodes don't auto-retry and
don't themselves convert (no infinite loop).
## Config
New [convert] section (mode / crf / preset / audio_format /
keep_original) + per-channel DownloadOptions.convert_mode override
(None = defer to global, "off" = force-disable for that channel).
ConvertSpec::resolve merges them; CRF 0→23, empty preset→medium, empty
audio→mp3 defaults.
## UI
Desktop + web: a "Format conversion (global defaults)" Settings section
with mode-dependent rows (CRF/preset for h264, format for audio, keep-
original toggle), and a per-channel Convert dropdown in the channel-
options dialog.
Verified: all three ffmpeg modes produce valid output on a real library
file (remux instant, h264 CRF23 ~12s for a 3.4min clip, mp3 extraction);
settings round-trip (global → config.toml [convert] → echo; per-channel
override saves + reads back). 4 resolver tests; 95 pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Stale or anonymous cookies make YouTube's bot-detection WORSE than no
cookies — so warn the user before downloads start captcha-failing.
cookies_freshness() parses the Netscape jar and reports:
- expires_at / expired / days_left: earliest expiry among the auth
cookies that actually gate login (SID, SAPISID, __Secure-*PSID,
LOGIN_INFO, …). Non-auth cookies (PREF, consent) are ignored so their
expiry doesn't trigger false warnings.
- no_auth_cookies: the jar has cookies but NONE of the login ones — i.e.
it's an anonymous/visitor session, not signed in. This is the worst
case for bot-detection and a common foot-gun (exporting cookies from a
browser that wasn't logged in to YouTube).
Surfaced in GET /api/cookies and both Settings UIs (web + desktop) with
graded severity: anonymous (red) > expired (red) > expiring-soon
(yellow) > fine.
5 tests cover expired, fresh, session/non-auth ignored, and the
anonymous-jar case. 91 pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
YouTube's bot-detection cracks down on different player clients over
time — the `web` client gets captcha-walled, then later `tv`/`mweb` are
the safe ones, etc. Let the user route around whichever client is
currently being targeted instead of being stuck with yt-dlp's pick.
- New backup.youtube_player_clients config (comma-separated, blank =
yt-dlp default). Maps to --extractor-args youtube:player_client=…
- Per-channel DownloadOptions.youtube_player_clients override (None =
defer to global). Resolved in the downloader's apply_player_client,
which omits the flag entirely when empty so yt-dlp keeps its default
client set (the recommended baseline).
- Threaded into the live downloader at construction + settings save
(app + web).
UI: a "YouTube player clients" field in both global Settings (desktop +
web) and the per-channel options dialog, with hints pointing at
"tv,mweb" as the current least-bot-checked combo for captcha-prone
channels.
Verified the settings round-trip (global persists to config + echoes;
per-channel override saves + reads back). 87 tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two measures to keep a long channel scan going when YouTube's
bot-detection trips mid-batch.
## Auto-retry on transient failures
When a download fails with a retryable class (RateLimited — which now
includes captcha walls — or NetworkError), the downloader re-queues it
automatically after a cooldown, up to MAX_AUTO_RETRIES (2). Linear
backoff: 1st retry waits 90 s, 2nd waits 180 s. Permanent classes
(NotFound, MembersOnly, Geoblocked, CodecMissing, DiskFull, BadCookies)
are never retried — re-running changes nothing.
Implementation: start() captures a RetrySpec (url/info/flags/quality/
opts) onto the Job; poll() scans freshly-failed jobs, schedules due
retries into a cooldown queue, and fires them by rebuilding the command
through start(). Live recordings and synthetic preflight failures opt
out. A log line announces each scheduled retry so it's visible in the
Downloads panel.
## Adaptive throttle
After any rate-limit hit, rate_limited_backoff engages and the sleep
flags roughly triple for the rest of the batch (--sleep-requests 1→3,
--sleep-interval 2-6 s → 8-20 s), easing off an already-suspicious
endpoint instead of hammering it. Clears once downloads go idle so the
next fresh batch starts at normal speed.
2 new tests (retryable-class matrix, backoff sleep values). 87 pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The captcha/bot-challenge error reads:
Video unavailable. YouTube is requiring a captcha challenge before playback
Because it contains "Video unavailable", the NotFound rule grabbed it
and showed the misleading "removed by the uploader — nothing to
download" hint. But the upload isn't gone; it's a bot-detection wall.
Add captcha / "not a bot" fingerprints to the RateLimited rule, which
runs before NotFound so it wins the "Video unavailable" overlap. Also
broadened the RateLimited hint to mention captchas + the POT provider
as remedies alongside cookies + waiting.
New test pins the captcha line → RateLimited; the genuine
"removed by the uploader" line still maps to NotFound. 85 pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Completes the subtitle feature started in 0814ba0 (backend + desktop).
## Global settings (web Settings modal)
- New "Subtitles (global defaults)" section: enable toggle that reveals
auto-captions / embed / languages / convert-format controls.
- SettingsPayload gains the 5 subtitle fields; get_settings reads them
from [subtitles] config, post_settings writes them back, saves config,
and pushes the live value onto downloader.subtitle_defaults.
## Per-channel overrides (channel-options dialog)
- Tri-state selects (Default / On / Off) for download-subtitles,
auto-captions, embed; a convert-format text field. "Default" defers
to the global setting.
- triSelect() / triValue() helpers map Option<bool> ⇄ select value.
readChannelOptionsForm includes the four new fields; they serialize
straight onto DownloadOptions via serde (post_channel_options already
takes the whole struct).
## Verified end-to-end (live server)
- Global: GET defaults → POST (auto off, embed on, langs=en, format=srt)
→ echoed back → written to config.toml [subtitles] → persists across
restart.
- Per-channel: POST overrides → read back exactly; all-default body
hits the is_empty() delete path and clears the row.
84 tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds full subtitle control (backend + desktop UI; web UI follows).
## Model
- New [subtitles] config section: enabled / auto_generated / embed /
format / langs. Sensible defaults (subs on, auto on, no embed/convert,
all langs).
- DownloadOptions gains per-channel Option overrides: subtitles_enabled,
subtitles_auto, subtitles_embed, subtitle_format (subtitle_langs
already existed). None = defer to global.
## Resolver
- Downloader gains subtitle_defaults (the global section) + a single
apply_subtitle_flags() that merges global + per-channel and emits the
yt-dlp flags (--write-subs / --write-auto-subs / --sub-langs /
--convert-subs / --embed-subs). Disabled = emit nothing.
- Replaces the hardcoded --write-subs --write-auto-subs at the main
download + repair builders. Moved the --sub-langs emission out of
DownloadOptions::apply into the resolver so global + override merge in
one place.
## Desktop UI
- Settings screen: "Subtitles (global defaults)" section.
- Channel-options dialog: tri-state (Default/On/Off) combos for each
override + a convert-format field.
## Wiring
- subtitle_defaults seeded at Downloader construction (app + web) and
refreshed on settings save (desktop).
4 resolver tests cover global defaults, disabled, global format/embed/
langs, and per-channel override-wins. 84 pass.
Web UI rows are the remaining piece (next commit).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Symptom: mid-batch (e.g. item 34 of 40) channel scans failed with
ERROR: [youtube] <id>: Video unavailable. YouTube is requiring a
captcha challenge before playback
Root cause: we hardcoded `--extractor-args youtube:player_client=web`
on every download. That flag dates from when `web` was the
throttle-avoidance client, but YouTube has since made `web` the MOST
aggressively bot-checked client — it's exactly the one that demands a
captcha. Verified directly: forcing player_client=web throws the
captcha error on a specific video that resolves fine without it.
Fix:
- Remove player_client=web from all three download builders (main,
repair, music). Modern yt-dlp auto-selects better clients
(android_vr, tv, web_safari) that aren't captcha-walled, and our POT
provider already serves tokens for them (web_safari in the logs).
- Add a randomized 2-6 s --sleep-interval between videos. The jitter
(vs a fixed cadence) is what keeps a long channel scan from looking
robotic and tripping the wall around request ~30.
Verified: both video IDs from the bug report (4JJ434QQoE4, Gr4bTC7DZWA)
that captcha'd under player_client=web now resolve cleanly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
yt-dlp prints a scary-looking ERROR when it can't confirm a channel
tab loaded authenticated:
ERROR: [youtube:tab] @Channel: Playlists that require authentication
may not extract correctly without a successful webpage download…
pass "--extractor-args youtubetab:skip=authcheck" to skip this check
For PUBLIC channels that's just noise — there's no auth-gated content
to miss, and the flag silences it without changing which videos are
found. But for members-only/private channels archived with cookies,
the warning is a real "your cookies may not be working, you might be
getting an incomplete list" signal worth keeping.
So make it a per-channel toggle rather than a blanket setting:
- DownloadOptions gains `skip_auth_check: bool`; apply() emits
`--extractor-args youtubetab:skip=authcheck` when set. (Its own flag;
the youtubetab: namespace is distinct from the POT provider's
youtubepot-bgutilhttp: one, so they coexist.)
- Desktop + web channel-options dialogs gain a checkbox with a tooltip
spelling out the public-vs-private trade-off.
1 new test; 81 pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Maximizing the window crashed the GUI immediately with:
Error: Glutin(Error { raw_code: Some(12291), kind: OutOfMemory })
12291 is EGL_BAD_ALLOC. The default eframe `glow` (OpenGL) renderer
fails to reallocate its surface when the window resizes to maximized
dimensions on NVIDIA + Wayland — a well-known fragility of NVIDIA's
GL-on-Wayland path. It surfaces as a clean Err from run_native (not a
panic), which is why nothing landed in yt-offline.crash.log.
Switch to the wgpu (Vulkan) renderer, which reconfigures the swapchain
cleanly on resize:
- Cargo.toml: drop eframe's default glow feature, enable wgpu, keep
accesskit / default_fonts / wayland / x11.
- main.rs: NativeOptions.renderer = Renderer::Wgpu.
Verified on the NVIDIA GTX 1650 SUPER + Wayland session that triggered
the crash: app launches and maximizes cleanly, no EGL error.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Symptom: with the bundled yt-dlp, every --impersonate target showed
"(unavailable)" and impersonation silently did nothing.
Root cause: it's a yt-dlp ⇄ curl_cffi version-gate mismatch, not a
curl_cffi problem. curl_cffi 0.15.0 was installed and works on its own
(a direct impersonate request returns 200), but stable yt-dlp 2026.3.17
caps support at curl_cffi <0.15:
ImportError: Only curl_cffi versions 0.5.10 and 0.10.x through 0.14.x
are supported
…so yt-dlp refuses to load the impersonate backend and disables every
target. yt-dlp master already raised the gate to <0.16, but that's only
in nightly, not stable.
Fix: install the nightly yt-dlp via `pip install --pre`. Nightly is
yt-dlp's recommended track for keeping up with YouTube's anti-bot
changes anyway (newly relevant now that we ship POT support), and it
accepts current curl_cffi so we don't have to pin a fragile version.
Verified on the bundled venv: nightly 2026.05.25 + curl_cffi 0.15.1b1
lists all impersonate targets, and `--impersonate chrome` resolves a
real video end-to-end. The install script's existing "grep chrome in
--list-impersonate-targets" verification now passes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The original integration pip-installed `bgutil-ytdlp-pot-provider` from
PyPI (Brainicism's upstream, currently 1.3.1). But the Rust server
(jim60105's port) is at 0.8.1, and yt-dlp REFUSES to use a token when
the plugin and server major versions mismatch:
WARNING: Plugin and HTTP server major versions are mismatched.
(plugin: 1.3.1, HTTP server: 0.8.1)
…so the feature silently produced no POT tokens.
jim60105 ships a version-matched plugin zip
(bgutil-ytdlp-pot-provider-rs.zip) in each release. install_command now:
- downloads that zip from the SAME release as the server binary
- uninstalls any stale PyPI plugin + removes old getpot_bgutil*.py
- resolves the venv site-packages via `python -c sysconfig` (version
independent) and unpacks the plugin's yt_dlp_plugins/ tree there
Verified end to end on a clean install: server mints a real BotGuard
token, yt-dlp loads bgutil:http-0.8.1 (matched), no mismatch warning,
and a 4K format URL resolves through the POT-gated path:
[pot:bgutil:http] Generating a gvs PO Token … via bgutil HTTP server
Rick Astley … | 401+251 | 3840x2160
Adds an `unzip` check to the install script (already required for the
bundled deno install).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Folders were flat — one level under each platform. Tartube supports
arbitrary nesting; now we do too.
## Schema
Add `folders.parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE`
via an idempotent migration (ALTER TABLE, swallow the duplicate-column
error on already-migrated DBs). NULL = top-level. Cascade means deleting
a parent removes its subtree's folder rows; member channels revert to
Unfiled via the existing channel_assignments cascade.
## DB
- FolderRecord gains parent_id (flows to both UIs via serde).
- set_folder_parent(id, parent) reparents, with a cycle guard: walks the
ancestor chain of the proposed parent and refuses if `id` appears
(you can't nest a folder inside its own descendant). Self-parent also
rejected. Returns a friendly SQLITE_CONSTRAINT error the web layer
maps to 400.
## API
POST /api/folders/:id/parent { parent_id } — reparent or null for top.
## Web UI
- Sidebar renders the folder tree recursively: each folder indents one
step under its parent, member channels indent under their folder, and
the count shown is the whole subtree. A seen-set guards against any
malformed cycle in the data.
- Folder manager gains a per-row "parent" dropdown; choices that would
cycle (self + descendants) are excluded.
## Desktop UI
- Channel-panel sidebar builds the same tree with an iterative DFS
(depth-indented folder headers, subtree counts).
- Folder manager gains a parent ComboBox per row with the same
cycle-exclusion.
Verified the migration runs against an existing real DB (parent_id added,
no data loss). 1 new test covers nesting + both cycle cases; 80 pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Free-text annotations on any channel or video, searchable from the
global filter box.
## Storage
New `notes` table keyed by (target_kind, target_id):
- target_kind: "channel" or "video"
- target_id: "platform/handle" for channels, video ID for videos
- empty body deletes the row, so the table only holds real notes
DB methods: get_note, set_note (upsert-or-delete), get_all_notes.
## API
- GET /api/notes → { "video:<id>": body, "channel:<plat>/<handle>": body }
fetched once on page load, held in memory for indicators + search
- POST /api/notes/:kind/:id { body } → upsert/delete
## Web UI
- 📝 button on every video card; turns accent-colored when a note
exists. Opens an edit-in-place modal.
- 📝 Add/Edit note… sub-action under each channel in the sidebar.
- Global search now also matches note bodies, so you can find a clip
by what you wrote about it.
## Desktop UI
- Note field in the video detail panel (bottom dock). Loads lazily
from the DB when the selection changes; persists on focus-loss.
## Restore
Backup import merges the notes table too (keep-later-timestamp,
same as watched/positions). Guarded with a table-exists check so
pre-notes backups still import cleanly. RestoreSummary gains
notes_added; both UIs show it.
4 new tests (2 notes DB behavior, restore merge already covered the
shape). 79 pass total.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
YouTube increasingly requires a per-video Proof-of-Origin token bound
to each video ID before handing back format URLs. Without one, yt-dlp
sees empty formats and downloads fail. The upstream solution is the
Brainicism bgutil-ytdlp-pot-provider Python plugin paired with a
long-running server that mints tokens via BotGuard.
We integrate the Rust port (jim60105/bgutil-ytdlp-pot-provider-rs) so
the server is a single static binary, no Node.js required.
## Architecture
- bgutil-pot binary lives alongside the bundled deno + yt-dlp at
~/.local/share/yt-offline/bin/bgutil-pot.
- The matching Python plugin is pip-installed into the bundled venv:
python -m pip install bgutil-ytdlp-pot-provider.
- Downloader spawns the server lazily on first job (port 4416,
loopback only) and kills it on Drop.
- Each yt-dlp invocation gets
--extractor-args "youtubepot-bgutilhttp:base_url=http://127.0.0.1:4416"
appended in spawn_job when use_pot_provider is on and the server
child is alive.
## UX
- Settings → "POT token provider" row with toggle + Install/Update
button (mirrors the bundled yt-dlp row).
- Disabled unless use_bundled_ytdlp is also on (plugin lives in that
venv).
- Refuses install if the bundled yt-dlp venv isn't there yet, with a
helpful 428 error.
## Lifecycle
- Lazy spawn: ensure_pot_server() runs at the top of start() so the
server is up before yt-dlp queries the plugin.
- Drop: kill_server() on Downloader drop so we don't leave an orphaned
process bound to 4416.
- start_pot_provider_update() kills the running child first so the
install can overwrite the binary in place (no ETXTBSY).
3 new unit tests cover URL/extractor-args formatting; 77 total pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously platform_root resolved YouTube to channels_root verbatim and
non-YouTube to siblings under channels_root.parent(). That assumed the
user's library was split across two levels:
/library/yt-offline/ ← config.backup.directory (YouTube)
/library/bandcamp/ ← sibling
/library/tiktok/ ← sibling
In practice users (myself included) keep everything under one umbrella:
/library/yt-offline/
channels/ ← YouTube
bandcamp/
tiktok/
music/
…
Make that the canonical layout. platform_root now returns
channels_root.join(dir_name) for every platform including YouTube
(which keeps its `channels` dir_name for back-compat with existing
on-disk trees that already have the channels/ subdir populated).
library_root is now == channels_root in both app.rs and web.rs — the
two-level concept is gone. The field stays as a distinct name so
file_url() and the /files/ static-file mount keep working without
churn at every call site. music_root and music dir also moved from
channels_root.with_file_name("music") to channels_root.join("music").
Tests in platform.rs updated to the new layout. 74 pass.
Note: this is a breaking change for installs that had the sibling
layout. The fix is to mv the platform dirs into the channels_root.
The next commit walks the user through that for my install.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The page-bottom footer holding the URL input + quality picker + flags
was a fixed 50-ish-pixel strip eating screen space on every screen,
even though most of the time users aren't starting a new download.
Move all of it into the ⬇ Downloads modal as a "new download" section
above the active jobs list. Same controls (URL, quality, Fast/Live/
Clips), same element IDs so existing helpers (previewDownload,
fullScan, dlLive, refreshTwitchClipsVisibility, updateDlMode) keep
working unchanged. URL input auto-focuses on modal open so the
paste-and-Enter flow is one motion.
Footer shrinks to just the AGPL §13 source notice (required for
deployment compliance, ~12px tall now).
updateDlMode is hardened with null-checks since the labels only exist
while the modal is open.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Four changes that compound — most users will notice these on first
launch of a large library.
## Release profile
- opt-level: 2 → 3 (more inlining + vectorization, ~5-15% on hot paths)
- lto = "thin" (cross-crate inlining; thin avoids the rust-lld + bundled
sqlite link breakage that full LTO previously caused, which is what
the !lto note was about)
- codegen-units = 1 (whole-crate inlining, ~5% runtime, +30s build)
- strip = "debuginfo" (drops ~30 MB from the binary)
## Thumbnail decode pool
The single decoder thread was the bottleneck on grid fill for libraries
with hundreds of channels. Replace with a worker pool sized at
clamp(2, cores/2, 8). Workers share the request channel behind a Mutex;
lock contention is negligible because each worker spends ~all its time
in image::open + resize.
## info.json parse cache
Library scans re-parse every video's info.json sidecar on every rescan —
serde_json::from_str is the dominant cost on a warm-filesystem scan
(file reads themselves are OS-cached). Add a `info_cache` SQLite table
keyed by (path, mtime); enrich_with_cache hits it first and only parses
on miss. mtime-keyed invalidation means yt-dlp re-writing an info.json
auto-invalidates without explicit eviction.
Each scan worker checks out its own r2d2 connection so lookups don't
serialize. Database is now Clone (the inner Pool is Arc, so cloning is
cheap) so we can hand handles to parallel workers.
## /api/library body cache
The ETag short-circuit catches clients with a matching cached version;
this catches clients without one (curl, freshly-opened tabs, mobile
WebViews that don't store ETags reliably). When the version matches
the cached entry's, we hand back the previously-serialized bytes
without re-walking the channel tree or re-running serde_json::to_string.
A post-build version check prevents poisoning the cache with stale
bytes when the library changes during serialization.
74 tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>