Commit graph

17 commits

Author SHA1 Message Date
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
4cead6cf93 Web UI: move download URL bar from page footer into Downloads modal
The page-bottom footer holding the URL input + quality picker + flags
was a fixed 50-ish-pixel strip eating screen space on every screen,
even though most of the time users aren't starting a new download.

Move all of it into the ⬇ Downloads modal as a "new download" section
above the active jobs list. Same controls (URL, quality, Fast/Live/
Clips), same element IDs so existing helpers (previewDownload,
fullScan, dlLive, refreshTwitchClipsVisibility, updateDlMode) keep
working unchanged. URL input auto-focuses on modal open so the
paste-and-Enter flow is one motion.

Footer shrinks to just the AGPL §13 source notice (required for
deployment compliance, ~12px tall now).

updateDlMode is hardened with null-checks since the labels only exist
while the modal is open.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 03:45:02 -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
5fd63de903 Web UI: filter chips (watch state / date / size / has-subs / has-chapters)
Tartube parity 1.3 — the grid now sits under a row of filter chips that
narrow what's shown without changing the sort or sidebar view.

Dimensions:
- Watch: All / Unwatched / In progress / Watched
- Date: All / Today / Week / Month / Year / Older (against upload_date)
- Size: All / < 100 MB / 100 MB – 1 GB / > 1 GB
- 💬 Subs toggle: only videos with subtitle tracks
- 🔖 Chapters toggle: only videos with chapter markers

Filters AND together and persist to localStorage alongside view/sort.
They apply across every grid mode (channel, continue, recent, smart
folders) so a single chip selection follows you between views.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:09:31 -07:00
Luna
0d7ae83873 Web UI: move downloads from bottom bar to ⬇ modal
The fixed bottom #jobs bar ate up to 40vh of screen real estate even
when only one job was running, and on mobile it competed with the URL
input. Replace it with a ⬇ button in the header that:

- shows a badge with active+queued count (turns accent-colored when >0)
- opens a modal listing all jobs when clicked (or with the 'd' shortcut)
- repaints live from the same WS/polling snapshot that drove the old bar

The footer URL input + quality picker stay where they are — that's the
entry point, not the status display.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:03:58 -07:00
Luna
9e64297da6 WebSocket job progress + mobile-responsive web UI
WebSocket progress
- New `/ws/progress` route using axum 0.7's `ws` feature. Upgrades to
  a WebSocket that streams the same `ProgressResponse` payload as the
  HTTP polling endpoint, pushed instantly whenever a job moves.
- `WebState.progress_tx` is a `tokio::sync::broadcast::Sender<String>`
  (capacity 16, lossy — Lagged subscribers resync on the next tick).
- A background tokio task ticks every 500 ms while any download is
  running or queued, every 5 s when idle. Skips the JSON encode when
  no subscribers are listening, so a single-user install pays zero
  CPU for the broadcast when the browser tab is closed.
- JS replaces the per-interval HTTP poll with a WebSocket connection.
  The polling path stays as fallback for mobile carriers / reverse
  proxies that strip WS — when the socket closes or fails to open,
  schedulePoll() resumes immediately and a reconnect attempt fires
  after 5 s.
- Initial snapshot is sent right after subscribe so the UI is live
  before the next tick.

Mobile-responsive web UI
- Existing `@media(max-width:640px)` block tightened:
  - Hide the H1, AGPL footer notice, sort dropdown, and library-size
    counter — they don't fit and aren't actionable.
  - Header buttons get min 34×34 hit targets (Apple HIG / Material).
  - `.card-foot` wraps and grows to 34×34 buttons so the five action
    icons (play / watched / favourite / bookmark / waiting) stay
    tappable instead of cramming into thumb-unfriendly 22 px chips.
  - Modals go full-screen (no 10 px page-margin) so the iPhone safe
    area doesn't crop content.
  - Footer (URL bar + download button) wraps onto two rows so neither
    squeezes the other off-screen.
  - Bulk-actions toolbar wraps and gets bigger buttons.
  - Per-job stdout preview hides on small screens.
- New `@media(max-width:380px)` collapses the grid to single column
  for very narrow phones.

55 unit tests still pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 00:59:48 -07:00
Luna
6c6b55f3cc Shuffle button: play a random unwatched video
🎲 button in the header picks a random video from
{unwatched + downloaded + has video_url} and opens it directly in the
player. Falls through to "any downloaded video" when everything is
already watched, with a status message that lets the user know.

Tartube has nothing like it — small bit of extra "surpass" beyond
plain parity.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 22:36:27 -07:00
Luna
3adcc60b78 Persist web UI state across reloads
Reloading the tab no longer blows away the user's context. The active
view (channels / continue / recent / favourites / bookmarks / waiting /
specific channel / specific playlist), the current filter input, and
the chosen sort all survive a hard refresh via localStorage.

- New `saveUiState()` serializes `{view, search, sort}` after every
  interaction that could change them: each setX() view switch, search
  oninput, sort onchange.
- `currentViewToken()` collapses the seven `showX` booleans + the two
  index integers into a single string. Channel-view tokens reference
  the channel by name (`channel:<name>|<playlistIdx>`) instead of by
  array index, so a library re-order doesn't strand the user on the
  wrong channel.
- `restoreUiState()` runs once after the first library load completes —
  the channel resolution needs the `library` array populated, so the
  init block was wrapped in an async IIFE to sequence the two steps.

Channels deleted between sessions fall back to "All" cleanly because
`findIndex` returns -1 and we just leave the show* booleans at their
default.

55 unit tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 22:35:17 -07:00
Luna
171553c137 Keyboard shortcuts for the web UI
Four shortcuts: `/` focuses the filter input, `r` triggers a library
rescan, `Esc` closes the topmost modal, `?` opens a help dialog
listing the bindings. All disabled while the focus is inside a text
input / textarea / contentEditable element so typing a video title
doesn't accidentally fire actions — except Esc, which always closes
modals (even from an input).

A new `?` button next to the existing rescan / maintenance / settings
icons makes the shortcuts discoverable; the help dialog is plain HTML
table with monospace key chips.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 22:15:54 -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