Commit graph

61 commits

Author SHA1 Message Date
Luna
23ed80a3b7
perf: desktop row virtualization, phase 2
Render List/Card/Grid through ScrollArea::show_rows so only the visible
row range is laid out per frame. Fixed-size cells (allocate_ui_with_layout
+ set_min_size) enforce a uniform lattice; titles truncate to one line with
the full title on hover. List separators are painted hlines inside the
cell; Grid is a manual horizontal run of fixed cells per lattice row with
cols computed from available width before show_rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 02:52:04 -07:00
Luna
d4bed019b2
perf: runtime performance pass, phase 1 (DB pragmas, batched scan writes)
Executes docs/superpowers/specs/2026-07-07-runtime-performance-pass-design.md:

- database.rs: every pooled connection now runs WAL + synchronous=NORMAL
  + busy_timeout=5000 + foreign_keys=ON via with_init (in-memory pools
  get the latter two). Fixes the SQLITE_BUSY failure mode under the
  parallel scanner and makes FK cascades work on every connection, not
  just whichever one last ran the old one-shot pragma in delete_folder.
- database.rs: prepare_cached for the per-video info_cache queries; new
  info_cache_put_many bulk upsert (single transaction) replaces the
  per-row autocommitted info_cache_put, with a round-trip unit test.
- library.rs: enrich_with_cache collects cache misses and flushes them
  once per channel; title sort uses sort_by_cached_key.
- app.rs: Title/ChannelAsc sorts use sort_by_cached_key instead of
  allocating two lowercased strings per comparison.
- Cargo.toml: panic=abort in release (crash-hook still fires; Cargo
  forces unwind for test harnesses). PACKAGING.md documents the local
  RUSTFLAGS target-cpu=native opt-in; repo stays portable.
- .gitignore: catacomb.db-wal / catacomb.db-shm sidecars.

Verified: 146 tests pass (integration suite exercises the pragmas
end-to-end), x86_64-pc-windows-gnu check green, live smoke shows
journal_mode=wal + batched cache writes landing correct rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 01:22:40 -07:00
Luna
f3e6d3b5b7 feat(app): add egui context for background thread repaint requests 2026-07-04 02:54:00 -07:00
Luna
4de452c503 feat(tray): integrate egui context for system tray events to ensure immediate UI updates 2026-07-04 02:54:00 -07:00
Luna
5091d0de96 fix(app): Grid mode no longer paints thumbnail twice; drop unused ThemeAccents::LEGACY 2026-06-27 19:30:02 -07:00
Luna
29d449eb9b style(app): bump base spacing + card title size for legibility 2026-06-27 19:21:10 -07:00
Luna
5aaa760ab4 feat(app): Settings picker for default view mode 2026-06-27 19:19:12 -07:00
Luna
4b011f8584 feat(app): view-mode toolbar toggle with per-view override 2026-06-27 19:17:00 -07:00
Luna
ada5b6d802 feat(app): List/Card/Grid video list render modes 2026-06-27 19:14:45 -07:00
Luna
91f3d8500d feat(app): unified theme-tinted placeholder thumbnails 2026-06-27 10:58:29 -07:00
Luna
0336615fa3 feat(app): ViewMode enum + global default + per-view overrides 2026-06-27 10:55:53 -07:00
Luna
1c858a3271 feat(app): use theme-aware accent rings for select/play/bulk 2026-06-27 10:51:03 -07:00
Luna
5fb8cc7036 feat(app): wire ThemeAccents snapshot, recompute on theme change 2026-06-27 10:48:27 -07:00
Luna
3f611849af docs: rename yt-offline → Catacomb across README, ROADMAP, guides, packaging
Prose + commands updated to the new name: brand reads "Catacomb", while
binary/path/package references are the lowercase `catacomb` (commands, deb/rpm
names, ~/.local/share/catacomb, catacomb.db, catacomb.desktop). Codeberg repo
URLs left pointing at the existing repo. Also folds in the pending doc edits
from earlier in the session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 03:34:18 -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
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
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
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
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
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
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
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
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
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
23f7ba7ddd N-level folder nesting (1.2+)
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>
2026-05-31 21:28:10 -07:00
Luna
865ad87b22 Per-channel / per-video notes (Tartube parity 1.5)
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>
2026-05-31 07:13:17 -07:00
Luna
2696a51cc8 Add YouTube POT token provider (bgutil-pot, opt-in)
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>
2026-05-31 06:08:25 -07:00
Luna
4f15ab873f Nest all platforms under channels_root (was: siblings)
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>
2026-05-27 10:38:01 -07:00
Luna
0fe6063f6b Performance: faster scans, thumbnails, library JSON, and codegen
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>
2026-05-27 03:28:40 -07:00
Luna
55b90a22b6 Library restore — idempotent backup import (2.4)
Backup direction has shipped for a while; this adds the import side
so users can actually round-trip their snapshot.

Database::restore_from_backup attaches the uploaded file and merges
each table with per-table semantics:
- watched / positions / channel_options: keep the later timestamp
- video_flags: bitwise-OR each flag (favourite + bookmark across two
  devices both stick)
- folders: insert names that don't exist yet
- channel_assignments: insert when (platform, handle) is unassigned;
  folder_id is re-resolved by name so backup/live ID drift is fine
- settings: deliberately skipped (password hash + source_url are
  per-deployment state, not user data)

Re-importing the same snapshot is a no-op. Schema validation rejects
non-yt-offline SQLite files up front with a 400.

POST /api/restore/db takes raw .db bytes (100 MB cap, magic-header
check) and returns a RestoreSummary with per-table added counts.
After success, the in-memory watched/positions/flags caches are
refreshed and the library version is bumped so cached /api/library
responses revalidate.

Desktop: 📂 Import library backup… button in Settings, mirrors the
existing 💾 save button. Triggers rescan after the merge so channel
folder assignments take effect.

Web UI: 📂 Import library snapshot… button next to the download
button. Hidden file input + confirm dialog + result summary.

Includes 4 new unit tests in src/database.rs covering the watched +
positions merge, flag OR-ing, idempotency on re-run, and schema
rejection.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:43:18 -07:00
Luna
d9a7007f34 Classify yt-dlp failures into actionable buckets (2.3)
When a job fails, today the user sees a raw stderr line — often
something like "ERROR: [youtube] dQw4w9WgXcQ: Sign in to confirm
you're not a bot" that doesn't tell a non-expert what to do.

New src/error_class.rs scans the failed job's log buffer for the
nine well-known yt-dlp fingerprints and returns:
- an ErrorClass enum (rate-limited, members-only, geo-blocked,
  not-found, codec-missing, disk-full, network-error, bad-cookies,
  other)
- a one-line human-readable suggested action paired with each class

Auto-populated in Job::drain when state transitions to Failed.
Exposed on JobSnapshot as error_class + error_hint. Rendered in
the web UI Downloads modal (red badge + hint box) and the desktop
downloads panel (colored label + hint text).

Other (no fingerprint matched) is filtered out of the badge so the
existing raw last_line keeps doing the talking for unknown failures.

Includes 10 unit tests covering each pattern + the walks-from-end
priority case.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:32:45 -07:00
Luna
615bc0cd4b Tray: minimize-to-tray is opt-in via config
Make ui.minimize_to_tray default-off so users on desktops without a
working StatusNotifier host (GNOME sans AppIndicator extension being
the common case) don't get stuck — without an opt-in, clicking X
would hide an invisible window with no obvious way back.

The tray menu's Show/Hide/Quit and Ctrl+Q still work regardless of
the flag; the toggle only affects what happens when the user clicks
the window's close button.

Added a settings-screen checkbox for the flag with a tooltip explaining
the SNI dependency.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:18:28 -07:00
Luna
abe7d63794 Add system tray with minimize-to-tray (Tartube parity 1.6)
Uses ksni (StatusNotifierItem via zbus) so we keep our pure-Rust /
no-GTK stack — zbus is already pulled in by notify-rust and rfd.

Behavior:
- Tray icon shows the bundled icon.png; left-click brings the window
  back to focus
- Right-click menu: Show / Hide / Quit
- Clicking the window's X button minimizes to tray instead of exiting
- Ctrl+Q (or the tray's Quit item) actually exits

Failure modes are silent and non-blocking: no DBus → app behaves as if
tray flag was off. No StatusNotifier host (e.g. GNOME without the
AppIndicator extension) → icon invisible but app still runs normally,
and the X-button close cancellation only fires when the tray spawn
returned a handle.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:17:10 -07:00
Luna
980ffbc4e7 Desktop: convert Settings/Stats/Maintenance to scrollable screens
The three big dialogs were floating egui::Windows that crammed everything
into a fixed-height popover and hid the library underneath. They're now
full CentralPanel views with a "← Library" back button and a vertical
ScrollArea, so long settings panels and big stats reports scroll cleanly.

Top-bar nav uses a Screen enum instead of a trio of show_* booleans;
clicking the active screen returns to Library. Floating dialogs that are
genuinely modal (channel options, folder manager, move-to-folder) stay
as windows since they're actions, not screens.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 01:21:26 -07:00
Luna
de122817af Desktop: shuffle + library backup + theme contrast fixes
Three asks the user surfaced after this session's web-UI wave: bring
the desktop UI to parity with what shipped over there, and fix the
theme contrast bugs where button labels disappeared on certain
themes.

Shuffle (desktop)
- 🎲 button next to ⟳ Rescan in the top bar.
- New `App::shuffle_play` mirrors the web logic: pick a random video
  from {unwatched + has video_path}, fall back to {any downloaded
  video} when everything's been watched, status message either way.
- Uses `rand::seq::SliceRandom::choose` — `rand` was already a
  dependency (via hash_password).
- Plays via the existing `play_with_tracking` path so mpv IPC +
  resume position both work.

Library backup (desktop)
- New "💾 Save library backup…" button under a new "Backup" section
  in the Settings panel.
- Opens an rfd save dialog off the UI thread (mirroring the existing
  cookies file-picker pattern), default filename
  `yt-offline-<unix-ts>.db`.
- New `backup_save_tx/rx` mpsc channel; `update()` polls it and
  `std::fs::copy`s `yt-offline.db` into the chosen destination,
  surfacing the byte count in the status line.

Theme contrast
- Root cause: four of the seven themes set `override_text_color`
  globally, which shadows the per-widget `fg_stroke.color`. That
  meant the "active" widget state (e.g. Dracula's light-purple
  button) rendered light-grey text on a light background —
  invisible. Trans was the worst offender: hot-pink override on
  a light-pink inactive button.
- Fix: set `override_text_color = None` on dracula, trans,
  emo_nocturnal, and emo_coffin. Each already had per-widget
  fg_stroke colours tuned for high contrast against the matching
  bg_fill — those just weren't being used.
- Trans theme additionally gets a pure-black `inactive.fg_stroke`
  (over the light pink button) and a darker `noninteractive.fg_stroke`
  (over the pale pink panel) for maximum legibility. Emo Scene Queen
  was already correct (no override) and stays as-is.

55 unit tests still pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 03:09:50 -07:00
Luna
c86e9b90a5 Bulk tagging + channel-name search
Bulk tagging
- Desktop bulk-mode toolbar grows three new buttons next to ✓ Watched
  and ○ Unwatched: ★ Favourite, 🔖 Bookmark,  Waiting. Each applies
  the corresponding flag to every video in the current bulk selection.
- New `App::bulk_set_flag` mirrors the existing `bulk_mark_watched`:
  iterates selection, persists each flag via `Database::set_video_flag`,
  mirrors into the in-memory `flags` bundle, busts the cards-cache so
  the smart-folder counts refresh on the next render.
- Web bulk-actions row gets the same three buttons. New `bulkFlag(flag)`
  fires the POSTs in parallel via Promise.all and reloads the library
  once they complete.
- Tagging in bulk only ever sets a flag — no toggle. Removing flags in
  bulk is rarely what users want; the per-card buttons handle un-flag.

Channel-name search
- Both UIs already filter by video title + id; extend to match the
  channel name too. Searching "Linus" now surfaces every video from a
  channel called "Linus Tech Tips" without typing the title.
- Description matching deliberately skipped — would require reading the
  .description sidecar per-video per-keystroke. Mentioned in the code
  comment as a future "load descriptions into the search index on
  rescan" pass if real users want it.
- New JS helper `matchesSearch(v, chName, q)` centralises the four
  call sites in `currentVideos()` (continue / recent / smart-folder /
  default).

55 unit tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 22:14:30 -07:00
Luna
9d414919cd Per-folder "Check all" action + DB backup download
Two small follow-ups to the folder hierarchy that round out the
folder-as-a-profile workflow and give the user a one-click way to keep
an offsite copy of their library state.

Per-folder check
- New `POST /api/folders/:id/check` mirrors the existing
  /api/scheduler/run endpoint but scoped to one folder's members.
  Each channel's stored DownloadOptions (quality / rate limit /
  match-filter / extra args) applies just like a scheduled re-check.
  Returns 409 when downloads are already running.
- Web folder-manager modal: each non-empty folder gets a "⬇ Check"
  button next to Rename / Delete. Status line reports the count of
  channels queued.
- Desktop folder-manager window: same — a small ⬇ icon-button per row,
  visible only for folders with at least one member. Same options-
  honouring re-check path as the right-click "Check for new videos"
  action.
- This effectively gives us "Profiles" lite: organize channels into a
  folder, then trigger a batch re-check whenever you want.

DB backup
- New `GET /api/backup/db` reads `<channels_root>/yt-offline.db` and
  streams it back with `Content-Disposition: attachment` so the
  browser downloads instead of rendering. Filename includes the unix
  timestamp so downloads don't clobber. Returns 404 when running in
  in-memory mode (no DB file to dump).
- We deliberately don't bundle config.toml + cookies.txt here:
  config is short to recreate and cookies.txt carries live session
  credentials that shouldn't fly over the wire unprompted.
- Web settings modal gains a "⬇ Download library snapshot" button in
  a new Backup section, with a hint explaining what's covered (watched
  / favourites / bookmarks / waiting / channel-options / folders).

55 unit tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 22:11:01 -07:00
Luna
4b0e4b3b07 Channel folder hierarchy (Tartube parity 1.2)
Users can now organize channels into named folders independent of the
underlying platform groupings. v1 ships single-level (no nesting) since
that's where ~90% of the organizational value lives — nested folders +
drag-to-reparent + per-folder cascading options are deferred to v2 once
we have real-world feedback on what the workflow needs.

Data layer
- New `folders` SQLite table: id, name (UNIQUE), position, created_at.
- New `channel_assignments` table: (platform, handle) → folder_id with
  ON DELETE CASCADE so deleting a folder cleanly unfiles its channels.
- Database methods: create_folder, rename_folder, delete_folder (with
  PRAGMA foreign_keys ON for the cascade), list_folders,
  set_channel_folder, get_all_channel_assignments.
- New `FolderRecord` struct (id/name/position) is `Serialize` so it
  rides on the /api/library response.

Library
- `Channel` gains `folder_id: Option<i64>` (None = Unfiled).
- New `library::apply_channel_folders` mirror of `apply_channel_options`
  populates it from the bulk DB map after every scan / rescan.

Web API
- `POST /api/folders` create with `{name}` → `{id}`.
- `POST /api/folders/:id/rename` rename with `{name}`.
- `DELETE /api/folders/:id` cascade-deletes assignments.
- `POST /api/channels/:platform/:handle/folder` upsert/clear via
  `{folder_id: <i64|null>}`. All endpoints bump the library ETag.
- `/api/library` response now carries a `folders: [...]` array so the
  client can render the sidebar grouping without a second round trip.

Web UI
- Sidebar gains a "📁 Folders" section above the platform groupings.
  Each folder lists its member channels (with platform icon prefix);
  channels with `folder_id` are pulled out of their platform section
  to avoid duplication. Unfiled channels keep platform grouping.
- "manage" / "new" button next to the Folders heading opens a modal
  with the full folder list (member counts inline) plus rename /
  delete buttons and a Create input.
- New per-channel sub-action "📁 Move to folder…" opens a small
  picker modal with one row per folder + "— Unfiled —". Current
  assignment highlighted.

Desktop UI
- Sidebar refactored to build a `Vec<SidebarItem>` ordered list
  (FolderHeader / FolderManageBtn / PlatformHeader / Channel) and
  iterate it once, instead of the previous flat
  `for i in 0..library.len()` loop. Folder section renders above
  the platform sections; channels with `folder_id` are skipped from
  the platform-grouped loop so they appear in exactly one place.
- New `folder_manager_window` + `move_to_folder_window` egui windows
  cover create / rename (via the create-buffer field) / delete and
  per-channel folder picking. Right-click context menu on a channel
  gains "📁 Move to folder…".
- App struct gains: `folders`, `show_folder_manager`,
  `folder_create_buffer`, `show_move_to_folder`, `move_to_folder_target`.

Tartube spec checklist
- Folder hierarchy box ticked (with the v2 deferrals noted in the
  inline annotation).

55 unit tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 22:07:42 -07:00
Luna
8281246420 Per-video state flags + smart folders + comments capture (Tartube parity 1.3/1.4)
Three Tartube-parity items shipped together since they touch the same
schema and rendering paths.

Per-video state flags
- New `video_flags` SQLite table with `bookmark`/`favourite`/`waiting`/
  `archive` columns (one row per video that has at least one flag set).
- `Database::set_video_flag` validates the flag name against an
  allow-list before interpolating into the SQL — column names can't be
  parameterised in SQLite, but the allow-list keeps it injection-free.
- `Database::get_video_flags` bulk-loads into a new
  `VideoFlagsBundle { bookmark, favourite, waiting, archive: HashSet<String> }`
  so subsequent reads are O(1) in-memory hits.
- `WebState.flags` (Mutex<VideoFlagsBundle>) + `App.flags`
  (VideoFlagsBundle, GUI is single-threaded) populated at startup.
- `POST /api/videos/:id/flags/:flag` toggles a flag; the in-memory
  bundle is mirrored immediately so the next /api/library reflects the
  change without a rescan. Library ETag is bumped.
- `VideoInfo` JSON gains `bookmark` / `favourite` / `waiting` /
  `archive` boolean fields.
- `archive` is wired through everywhere but no UI gate yet — it's
  reserved for the future auto-delete feature.

Smart folders
- Both UIs gain "★ Favourites", "🔖 Bookmarks", " Waiting" sidebar
  entries between Continue Watching and Channels. Each is hidden when
  its set is empty, so a fresh install isn't polluted with zero-count
  rows.
- Desktop: new `SidebarView::Favourites / Bookmarks / Waiting`
  variants. `compute_cards` filters the library by the matching
  `self.flags.<set>` HashSet and returns the resulting cards using the
  same rendering path as the other views.
- Web: `showFavourites/Bookmarks/Waiting` flags + smart-folder branch
  in `currentVideos()`. Refactored the view-mode selectors through a
  new `resetViewSelectors()` helper so adding three more booleans
  didn't quadruple every `setX` function.

Per-card flag toggles
- Web: each video card grows three new action buttons (☆/★ favourite,
  🔖 bookmark,  waiting) next to the existing watched toggle.
  Optimistic UI — flip in-memory immediately, undo on server error.
- Desktop: same three buttons next to Watched in the card row, deferred
  through a `toggle_flag_card: Option<&'static str>` to keep the
  borrow checker happy. New `App::toggle_video_flag` helper persists
  the flip to SQLite and invalidates the card cache.

Comments capture
- `DownloadOptions` gains `fetch_comments: bool`. When set, the
  downloader emits `--write-comments` so yt-dlp embeds the full
  comment tree into info.json.
- New `GET /api/comments/:id` reads the array out of info.json and
  returns a slimmed-down version (id, author, text, likes, parent,
  time) — strips the multi-MB extras yt-dlp can otherwise inline.
- Web player modal grows a 💬 button that opens a separate modal
  with a threaded viewer: replies indented by parent id, "N comments"
  header, helpful message when nothing's captured pointing at the
  channel-options toggle.
- Channel-options dialog in both UIs gains a "Fetch comments"
  checkbox + hint about the slowdown.

Tartube spec checklist
- Three more boxes ticked: per-video state flags, smart folders,
  comments capture. Score now: 5 ahead, 5 behind, 1 tied + matching
  on 4 parity items. (Half done.)

55 unit tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 21:57:52 -07:00
Luna
9335e0faa8 Per-channel download options (Tartube parity Phase 1.1)
Closes the single largest gap with Tartube documented in docs/tartube-spec.md.
Users can now attach a small set of yt-dlp overrides to any individual
channel; the overrides apply automatically during scheduled re-checks
and the right-click "Check for new videos" action.

Data layer
- New `channel_options` SQLite table keyed on (platform, handle), with
  `options_json TEXT` blob + updated_at. PK ensures one row per channel.
- Database gains get_channel_options / set_channel_options /
  delete_channel_options / get_all_channel_options. The bulk getter is
  used by the library scanner so each rescan hydrates options without
  per-channel SQL round-trips.
- `DownloadQuality` derives Serialize/Deserialize so it can roundtrip
  through the options blob.

New module: src/download_options.rs
- `DownloadOptions` struct with 9 fields:
    quality (Option<DownloadQuality>) — per-channel quality cap
    audio_only (bool) — force --extract-audio chain
    limit_rate_kb (Option<u32>) — --limit-rate
    min_filesize_mb / max_filesize_mb — yt-dlp filesize filters
    date_after (Option<String>) — --dateafter YYYYMMDD
    match_filter (Option<String>) — yt-dlp --match-filter passthrough
    subtitle_langs (Vec<String>) — --sub-langs CSV
    extra_args (Vec<String>) — raw yt-dlp passthrough (Tartube's
        `extra_cmd_string`)
- `apply(&mut Command)` appends the right flags conditionally.
- `is_empty()` lets the API layer collapse no-op writes into DELETEs so
  we don't keep useless rows around.
- `from_json()` falls back to defaults on malformed rows so a schema
  drift doesn't take a channel offline.
- 8 new unit tests cover apply-emits-correct-flags, JSON roundtrip,
  corrupt-fallback, and is_empty.

Library
- `Channel` gains `download_options: DownloadOptions` (default empty).
- New `library::apply_channel_options(channels, map)` helper that the
  caller invokes after `scan_channels` to hydrate from the bulk DB load.
- All five `scan_channels` call sites updated.

Downloader
- `Downloader::start` gains a new `channel_options: Option<&DownloadOptions>`
  parameter. Applied last so per-channel overrides win over global
  defaults. The explicit "submit from URL bar" flow passes None
  (channel unknown until yt-dlp resolves it). Scheduled re-checks +
  right-click checks pass the channel's stored options. Channel-options'
  `quality` field overrides the hard-coded DownloadQuality::Best for
  re-checks.

Web API
- New routes on /api/channels/:platform/:handle/options:
    GET    — fetch (returns defaults when no row exists)
    POST   — upsert (empty body collapses to DELETE)
    DELETE — clear overrides
- All three update the in-memory library snapshot immediately so the
  next re-check sees the change without waiting for a rescan, and bump
  the library_version ETag so polling clients pick up the new state.

Web UI
- Sidebar each channel's expanded section gains a "⚙ Channel options…"
  entry next to "Check for new videos".
- openChannelOptions() fetches the current options + renders a modal
  with one input per field. Save / Clear / Cancel buttons. The Clear
  button confirms via window.confirm() because it's destructive.
- All form fields validate locally before POSTing; empty numeric
  fields map to null.

Desktop UI
- New `channel_options_window` egui::Window opened from the existing
  channel right-click context menu (new "⚙ Channel options…" item).
- Form mirrors the web side: ComboBox for quality, checkbox for
  audio-only, TextEdit (singleline + multiline) for the other fields.
- Save / Clear / Cancel buttons; saves go straight to SQLite and the
  live `App::library` so the next scheduled-check or right-click runs
  with the new overrides.

Documentation
- `docs/tartube-spec.md` checklist: per-target download options ticked
  with a note describing the v1 scope.

55 unit tests pass (47 + 8 new).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 10:47:57 -07:00
Luna
375a24262c Upload date in UI; extract HTML_UI; Twitch clips mode; DB connection pool
Four nice-to-have items, plus surfacing the upload date in both UIs
since the user explicitly asked.

Upload date display
- Desktop: card meta row now includes a `YYYY-MM-DD` cell between the
  ID and the duration; detail panel header shows it as `📅 YYYY-MM-DD`.
- Web: video player modal title gains a date+duration subline so the
  viewer has context without opening the metadata pane (card meta
  already shows the date).
- New `format_upload_date()` helper on the desktop side; the web side
  reuses the existing `fmtDate()` JS.

Extract HTML_UI to include_str!
- `LOGIN_HTML` and `HTML_UI` moved out of `src/web.rs` into
  `src/web_ui/login.html` and `src/web_ui/index.html`. The Rust file
  drops from 2915 lines to 1806; editors now syntax-highlight the
  HTML/CSS/JS, and UI diffs no longer overwhelm the Rust diff view.
- `include_str!` bakes them into the binary at compile time, so there
  is no runtime fs lookup and no extra deploy artifacts.

Twitch clips-only mode
- `classify_twitch` now recognises `twitch.tv/<user>/clips` as a
  Channel URL (was Unknown), so yt-dlp's TwitchClips extractor is
  reachable from our normal download pipeline.
- Both UIs gain a "Clips only" toggle visible only when the URL is
  a Twitch channel (not a specific VOD/clip). On submit, the URL is
  rewritten to `twitch.tv/<user>/clips` before dispatch.
- 1 new platform test covers the clips-listing classification.

DB connection pool (r2d2_sqlite)
- `Database` now wraps an `r2d2`-managed SQLite pool. File-backed
  databases get up to 4 connections; the in-memory fallback is capped
  at 1 (since each in-memory connection is a fresh empty DB by default
  in SQLite).
- `WebState.db` drops its outer `Mutex` — concurrent handlers each
  check out their own connection from the pool. Static-file fetches
  through `/files/` no longer serialize on the watched/positions/
  password lookups that the auth middleware performs.
- Pool init failures are converted to `rusqlite::Error` so the
  existing `Result<T>` return types still work.

47 unit tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 01:17:08 -07:00
Luna
b24ef4be67 Live-stream recording, recent-additions feed, per-platform impersonate profiles
Three roadmap items together since they touch overlapping code:

Live-stream recording
- New `live: bool` argument on Downloader::start. When true, attaches
  `--live-from-start --wait-for-video 30`, suppresses
  `--break-on-existing` (every recording is a unique file), and
  prepends a UTC timestamp suffix to the output filename so
  re-recordings of the same broadcaster don't collide.
- The job label gains a "🔴 LIVE" marker so a long-running record is
  obviously different from a VOD pull.
- Both UIs gain a "🔴 Live stream" checkbox in the download panel,
  hidden when Music mode is selected. Works for Twitch *and* YouTube
  Live since yt-dlp recognizes both from the URL.
- Web `POST /api/download` accepts a `live: bool` body field; all
  other callers (scheduled re-check, right-click "Check for new
  videos") pass false.

Recent-additions feed
- `library::Video` gains `mtime_unix: Option<u64>` — populated from
  the metadata we already read for `file_size`, so no extra fs hit.
- New `SidebarView::Recent` (desktop) + `showRecent` (web) sort the
  whole library by mtime descending and cap at 100 entries.
- Sidebar entry "🕒 Recent additions" appears only when the library
  has any dated content, so a fresh install doesn't show an empty view.

Per-platform impersonation
- New `Platform::impersonate_target()`:
  - Twitch → None (skip impersonation; their OAuth/Helix dislikes
    mismatched TLS fingerprints).
  - TikTok → "Chrome-Android-131" (mobile profile matches their
    first-party app surface).
  - Everything else → "Chrome-146:Macos-26".
- `Downloader::apply_impersonation` now takes the platform and routes
  through the override; `repair()` (always YouTube-implicit) and
  `start_music()` (uses classify_url on the URL) wire through too.
- `/api/preview` does the same classification before dispatching to
  yt-dlp.
- 3 new tests confirm Twitch → None, TikTok → mobile, YouTube → desktop.

44 unit tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 00:17:59 -07:00
Luna
12c32f642b Add multi-platform support: TikTok, Twitch, Vimeo, Bandcamp, SoundCloud, Odysee
yt-dlp already speaks ~1,800 sites; the changes here teach the rest of
the app to route them through platform-specific output folders and
remember where each creator was downloaded from.

New `platform` module
- `Platform` enum: YouTube, TikTok, Twitch, Vimeo, Bandcamp, SoundCloud,
  Odysee, Other.
- `classify_url` returns both the source platform and a `UrlKind`
  (Channel / Playlist / Video / Unknown) — per-platform path parsing
  for each supported host.
- `platform_root(channels_root, platform)` returns the on-disk folder
  per platform. YouTube keeps the legacy `channels/` for backward
  compat; the others live as siblings (`tiktok/`, `twitch/`, …).
- `write_source_url` / `read_source_url` persist the originating URL
  in a `.source-url` sidecar at each creator folder so re-checks no
  longer rely on the YouTube-only "UC + 22 chars" heuristic.

Downloader
- `Downloader::start` now takes `&UrlInfo` instead of `&UrlKind` and
  routes the yt-dlp `-o` template into the right platform folder.
- For channel downloads it also writes `.source-url` so future
  re-checks recover the exact URL.
- Output template uses `%(uploader,channel,creator|Unknown)s` so
  non-YouTube sites that don't expose `channel` still get a sensible
  creator folder.
- Per-platform `archive.txt` keeps cross-platform IDs (TikTok numeric
  vs YouTube base64) from colliding.
- New `recheck_url(&Channel)` prefers `source_url` and falls back to
  the legacy YouTube heuristic for libraries created before this
  change.

Library scanner
- `scan_channels` now walks every platform's folder and tags each
  `Channel` with its `platform` + `source_url`. Sort key puts the
  platform first, then name, so the sidebar groups cleanly.
- `Channel` gains `platform: Platform` and `source_url: Option<String>`
  fields, populated from the on-disk sidecar.

Web UI
- `WebState` gains `library_root` (= parent of channels_root). The
  `/files/` ServeDir is mounted at library_root so non-YouTube videos
  resolve at `/files/<platform>/<creator>/<file>`. Existing YouTube
  URLs still work — they're now `/files/channels/...` instead of
  `/files/...` and the server-rendered JSON updates accordingly.
- Maintenance scan + remove use `library_root` so non-YouTube content
  is included.
- `ChannelInfo` JSON exposes `platform`, `platform_label`,
  `platform_icon`, and `source_url`.
- Sidebar groups channels by platform with each platform's icon.
- "Check for new videos" now uses the stored `source_url` (with the
  YouTube heuristic as a last-resort fallback).
- Download dialog preview shows the detected source + destination
  folder.

Desktop UI
- App gains a `library_root` field mirroring the web side; maintenance
  ops scan/remove against it.
- Sidebar channel labels are prefixed with the platform icon for
  non-YouTube channels; tooltip carries the platform name.
- Download URL field surfaces both source platform and folder path
  preview as you type.

Plex
- Non-YouTube creators get their show folder prefixed with the
  platform name (e.g., `TikTok - cooluser`) so a YouTube channel and
  a TikTok account with the same name don't collide.

Backward compatibility
- Existing YouTube libraries (`channels/<handle>/...`) are untouched.
  New platform folders are created on first run as siblings.
- Channels without a `.source-url` (i.e., everything that pre-dates
  this commit) fall through to the YouTube heuristic, preserving the
  existing re-check behavior.

Tests
- 14 new `platform` tests cover URL classification per platform,
  channel handle extraction, and `platform_root` layout. The old
  `detect_url_kind` / `extract_after` tests move into platform.rs
  since the canonical implementation lives there now.
- 41 unit tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:16:40 -07:00
Luna
4d83999edd Add statistics view, editable source_url, rewrite security audit
Statistics
- New `stats` module computes totals, top-N channels, per-year upload
  histogram, and per-week download activity from the in-memory library
  + watched/positions data.
- GET /api/stats endpoint returns the full report as JSON.
- Web UI: 📊 button in the header opens a stats modal with summary
  tiles, two CSS bar charts (weeks + years), and side-by-side top-N
  tables.
- Desktop GUI: 📊 Stats toggle in the top bar opens a window with
  the same data, rendered via egui rects (stdlib calendar math so
  there's no chrono dep).
- Bar chart helper `draw_bars` is reusable across stats sections.

Source URL (AGPL §13)
- `web.source_url` is now editable in both Settings UIs — was
  previously documented-only and required hand-editing config.toml.
- Settings POST persists it and the footer link updates immediately.
- Default config.toml ships with the Codeberg URL set.

SECURITY_AUDIT.md
- Re-audited the codebase as of commit 5999673; rewrote the file to
  document the threat model and which adversary each defense addresses.
- Resolved-from-2026-05-17 column shows what landed: session expiry,
  Secure cookie, rate-limit, body cap, CSP, X-Frame-Options,
  X-Content-Type-Options, safeUrl(), SHA-256 verify, chmod 0600 db.
- Documented accepted risks (plain-HTTP LAN, cookies.txt umask,
  job log stderr verbatim).
- Future-hardening checklist preserved for posterity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 05:34:29 -07:00