Commit graph

164 commits

Author SHA1 Message Date
Luna
816da05962 web: persist login sessions in SQLite so a restart doesn't log everyone out
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>
2026-06-17 19:18:23 -07:00
Luna
dd48e899fa web UI: on 401, reload to the login page instead of a cryptic error toast
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>
2026-06-17 18:09:49 -07:00
Luna
615c088b43 web UI: maintenance modal → "diagnostics console"
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>
2026-06-17 00:39:20 -07:00
Luna
6f61821d1e web UI: stats modal → maximalist "observatory" data dashboard
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>
2026-06-16 19:29:24 -07:00
Luna
c8cb700f7e web UI: "cinematheque" reskin — embedded editorial fonts + atmosphere + motion
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>
2026-06-16 16:34:57 -07:00
Luna
983864f421 macOS: osxcross packaging path (scripts/package.sh mac → .app .zip)
Add a `mac` target that cross-compiles an Apple binary via osxcross
(MAC_ARCH=arm64 default, or x86_64), assembles an unsigned yt-offline.app
(Info.plist + Mach-O + best-effort .icns via png2icns), and zips it. The
crypto stack is ring, which builds against the osxcross SDK cleanly.

- Sets the per-target linker + cc-rs CC/CXX/AR env from the osxcross
  wrappers (oa64-clang / o64-clang); discovers the versioned ar triple.
- Gated on the toolchain being present: `all` folds it in only when an
  Apple rust target + an osxcross wrapper + zip exist, and a bare `mac`
  run skips cleanly (exit 0) otherwise — verified.
- Local-only, not in CI: the macOS SDK can't be hosted in a public image.
- Refresh the stale Windows + macOS sections of docs/PACKAGING.md (they
  still described the pre-cfg-gating blocked state) and the CI section;
  note a possible future MacPorts port. ROADMAP 3.1 updated.

Untested end-to-end (no osxcross/SDK on this box); the scaffolding + skip
path are verified and `all` on a stock box is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 03:09:30 -07:00
Luna
207013e957 Windows: shippable cross-compiled .zip + console fix + release CI
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>
2026-06-16 02:31:38 -07:00
Luna
8b2578783a Library sorting: download-date sort + grouped sort options (both UIs)
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>
2026-06-15 20:15:27 -07:00
Luna
015d03751c Library scan: leave one core free at startup
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>
2026-06-15 18:58:56 -07:00
Luna
5ffdb179f7 Download modal: diff-aware repaint + Retry-all
- 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>
2026-06-15 17:20:26 -07:00
Luna
9ed62935f5 Download manager: cancel/retry/queue controls + dedup off-switch
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>
2026-06-15 16:35:05 -07:00
Luna
c3ff9121f6 web player: seekable transcode playback + custom controls
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>
2026-06-13 06:47:40 -07:00
Luna
033572d3bd Federation: read-only browsing of a peer instance's library (3.5)
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>
2026-06-10 17:51:44 -07:00
Luna
d797f2a698 Cross-compile groundwork: make the tree build for Windows (3.1)
`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>
2026-06-10 04:22:18 -07:00
Luna
ec8cf6f934 Smart auto-tagging: suggest folder groups for unfiled channels (3.4)
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>
2026-06-10 03:38:45 -07:00
Luna
aed577ea2f web/desktop: grid + subtitle fixes, comment toggle, faster startup
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>
2026-06-10 01:43:50 -07:00
Luna
fdc7493eb9 web: bind the listener before the initial library scan
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>
2026-06-08 00:04:36 -07:00
Luna
9ba375f7b1 Add a renderer escape hatch (wgpu default, glow fallback)
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>
2026-06-08 00:04:24 -07:00
Luna
2692fa2c9e Podcast / RSS feeds for the library (web, part 1)
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>
2026-06-07 07:49:48 -07:00
Luna
4800a0fc22 ROADMAP: mark 3.6/3.7 DONE, log search + transcript work (3.9)
Reflect this session's shipped Phase 3 features: comment-viewer
enhancements (3.6), perceptual-hash dedup (3.7), and the beyond-plan
full-text search + transcript tooling (3.9). Update the vs-Tartube table
(+3 'we lead' rows: full-text search, transcript viewer, content-aware
dedup; score 17 ahead / 1 behind / 13 tied), the Recently-shipped list,
and the closing next-moves note (now RSS feed / smart auto-tagging /
Windows-macOS).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 07:25:18 -07:00
Luna
a5613b36ae CLAUDE.md: document search/dedup subsystems + FTS migration caveat
Bring the guide up to date with this session's work: the hang watchdog in
poll(); the three (path,mtime)-keyed caches (info_cache, search index,
fingerprints); the FTS5 drop+recreate migration exception to the
ALTER-only rule; a Search & perceptual-dedup subsystem section
(fingerprint.rs, vtt.rs, the shared rebuild_and_group pipeline, the
background-job pattern); tests/api.rs integration tests + the opt-in
real-ffmpeg test; the node --check tip for the embedded SPA; and
sponsorblock_mode / fingerprint / vtt in the worked-example + module
lists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 07:05:20 -07:00
Luna
685dcad556 README: bring features in line with current state
Document the work since the last README pass: full-text library search
(titles/descriptions/transcripts via FTS5), searchable transcript pane +
desktop transcript window, threaded comment viewer, configurable
SponsorBlock, per-channel overrides, post-download format conversion, the
anti-bot/POT stack, auto-retry + adaptive throttle, perceptual
'similar content' dedup, a Reliability section (hang watchdog, disk-full
preflight, error classifier, crash log, backup/restore), folders/notes/
bulk-tagging, desktop UI zoom, and a link to the docs site. Config example
gains sponsorblock_mode / POT / player-clients / ui_scale.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 07:00:24 -07:00
Luna
e840d55332 Index transcripts into library search (FTS), both UIs
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>
2026-06-07 06:55:00 -07:00
Luna
4136a9468b Perceptual-hash dedup: desktop UI + shared pipeline (3.7, part 3)
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>
2026-06-07 06:23:59 -07:00
Luna
f30e32707e Perceptual-hash dedup: web UI + background job (3.7, part 2)
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>
2026-06-07 06:17:58 -07:00
Luna
6d5c1cae33 Perceptual-hash dedup: fingerprint engine + storage (3.7, part 1)
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>
2026-06-07 06:03:53 -07:00
Luna
fcd202fde0 Transcript viewer + search, in both UIs (3.x)
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>
2026-06-07 05:44:33 -07:00
Luna
a47c7991b4 Library-wide full-text search (SQLite FTS5), in both UIs (3.x)
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>
2026-06-07 05:21:07 -07:00
Luna
3031b5b0e5 Enhance comment viewer + make SponsorBlock configurable (3.6 + parity)
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>
2026-06-07 03:53:49 -07:00
Luna
6f174f703b Docs/roadmap: correct CI claims — Codeberg runs Woodpecker, not Forgejo Actions
The .forgejo/workflows/* definitions don't execute on Codeberg (no
runner). Docs publish via scripts/publish-docs.sh; tests/packages run
locally. Fixes claims introduced earlier today.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 03:27:04 -07:00
Luna
9da16ca65f docs: local publish script for Codeberg Pages; drop inert CI workflow
Codeberg doesn't execute Forgejo Actions (no runners), so the docs.yml
workflow never ran. Replace it with scripts/publish-docs.sh: build the
mdBook and force-push docs/book/ to the `pages` branch that Codeberg
Pages serves. Installs mdbook on demand, derives the Pages URL from the
origin remote, and honors CODEBERG_TOKEN for non-interactive auth.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 03:22:57 -07:00
Luna
0861c8e394 ROADMAP: Phase 2 complete (2.1 integration tests + 2.2 docs site shipped)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 02:56:43 -07:00
Luna
e06c2ef826 Documentation site: mdBook user + contributor guide (2.2)
An mdBook under docs/ rendering eight pages: introduction,
installation, first-run/config, downloading, an anti-bot guide that
captures the hard-won cookies/curl_cffi/POT/player-client knowledge,
troubleshooting (the nine error classes + non-download issues),
architecture (the two-front-ends/one-engine design, settings flow,
layout invariant, persistence), and packaging.

- .forgejo/workflows/docs.yml builds the book and publishes docs/book/
  to the `pages` branch for Codeberg Pages on docs/ changes.
- docs/book/ is gitignored (rendered output).
- README: fix the stale backup.directory comment — every platform nests
  under the one library root, not a channels/ sibling split.
- Add CLAUDE.md (repo guidance for Claude Code).

Builds clean with mdbook 0.5; intra-doc anchor links verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 02:56:14 -07:00
Luna
7906d7d07f Integration tests: drive the real --web server end-to-end (2.1)
Unit tests cover parsers/resolvers but not whether the axum handlers,
SQLite layer, and config persistence actually wire together. New
tests/api.rs spawns the compiled binary in --web mode against a scratch
library dir and exercises the real HTTP API the way a browser does —
no network / yt-dlp needed (every endpoint here is local state).

Coverage: index + /api/library served; ETag conditional GET → 304;
settings round-trip (POST → GET reflects → config.toml on disk updated);
folder CRUD + the self-parent cycle guard (→ 400); notes upsert/delete;
channel-options store + is_empty() clear; backup/db returns a real
SQLite file.

HTTP via curl (transparently handles the server's gzip + chunked
encoding; already a CI dependency) with a graceful skip if curl is
absent. Each test gets its own server, port (bind :0 to grab a free
one), and tempdir, so they run in parallel and clean up on drop.

Added .forgejo/workflows/test.yml so `cargo test` (unit + integration)
runs on every push/PR — the release workflow only fired on tags. 105
tests pass (98 unit + 7 integration).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 02:14:17 -07:00
Luna
39850059cb Update ROADMAP: Phase 1 (Tartube parity) complete
Reconcile with what shipped since 2026-06-01: format conversion (1.7),
filter presets (1.3+), hang watchdog + poison-recover locks (2.5),
desktop UI scale.

- State table: format conversion + filter UI → Tied; stability row
  expanded (crash log + disk-full + watchdog + lock recovery); added
  desktop UI scale row. Score 14 ahead / 1 behind / 13 tied — the lone
  "behind" is edge-case maturity, not a feature.
- Phase 1 marked COMPLETE; the two last parity items recorded.
- Phase 2: 2.5 done; only 2.1 (integration tests) + 2.2 (docs) remain.
- Phase 3: unblocked 3.1 (packaging shipped) and reframed it around the
  real blocker (Linux-only tray/file-dialog); expanded 3.7 dedup with
  the perceptual-hash design.
- Refreshed intro + footer to "at parity, now surpassing."

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 02:06:19 -07:00
Luna
ffa864c470 Robustness: hang watchdog kills stalled yt-dlp/ffmpeg jobs (2.5)
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>
2026-06-07 01:39:00 -07:00
Luna
111d09b130 Robustness: recover from poisoned locks instead of cascading panics (2.5)
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>
2026-06-06 23:49:56 -07:00
Luna
3cd4827ff0 Web UI: named filter presets (1.3+)
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>
2026-06-05 11:36:06 -07:00
Luna
b8a742535b Desktop: global UI scale (egui zoom_factor), persisted
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>
2026-06-04 19:14:23 -07:00
Luna
fd9063b5cb Format conversion pipeline (Tartube parity 1.7)
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>
2026-06-03 23:20:06 -07:00
Luna
b58d393d98 Update ROADMAP: reflect a session's worth of shipped work
The roadmap had drifted far behind reality. Reconcile it:

State table → 16 ahead / 2 behind / 11 tied (was 11/4/6). Newly tied or
led: per-distro packaging, N-level folders, notes, restore, error
classification, subtitles; new "we lead" rows for the anti-bot stack,
cookie freshness, auto-retry/throttle, and player-client config.

Phase 1: only format conversion (1.7) remains as a real Tartube parity
gap. Notes (1.5), packaging (1.8), and N-level nesting (1.2+) moved to
"shipped". Phase 2: 2.3 / 2.4 marked done, 2.5 mostly done (crash log +
disk-full preflight + auto-retry shipped; lock-poisoning audit + hang
watchdog remain).

Expanded "Recently shipped" with this session's anti-bot + subtitle +
packaging + perf + wgpu work.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:53:19 -07:00
Luna
d098e5f6c5 Anti-bot: cookie freshness + anonymous-jar warning
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>
2026-06-01 15:15:51 -07:00
Luna
2f711604f0 Anti-bot: configurable YouTube player clients (global + per-channel)
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>
2026-06-01 09:27:57 -07:00
Luna
fe229bc98d Anti-bot: auto-retry transient failures + adaptive throttle
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>
2026-06-01 09:04:29 -07:00
Luna
71466b21bd Classify YouTube captcha wall as rate-limited, not "not found"
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>
2026-06-01 08:47:14 -07:00
Luna
a8f59fe854 Subtitle settings: web UI (global section + per-channel overrides)
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>
2026-06-01 06:07:19 -07:00
Luna
0814ba0c3b Subtitle settings: global [subtitles] config + per-channel overrides
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>
2026-06-01 05:44:33 -07:00
Luna
1e95102ec8 Fix "captcha challenge before playback" errors: drop player_client=web
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>
2026-06-01 05:32:27 -07:00
Luna
6cefc0c0e5 Per-channel "skip auth check" toggle for the youtubetab warning
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>
2026-06-01 05:25:06 -07:00
Luna
5cb011a13d Fix crash on maximize: switch eframe to wgpu (Vulkan) renderer
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>
2026-06-01 05:14:06 -07:00