Commit graph

159 commits

Author SHA1 Message Date
BoulderBadgeDad
bb1074cc60 video enrichment: add a GLOBAL 'Retry all failed' (all workers, all kinds)
Alongside the per-worker 'Retry all failed', the worker modal now has a topbar
'Retry all failed' that re-queues every failed/not_found item across ALL workers and
kinds in one click — one-shot recovery after an API outage left lots errored.

- db.retry_all_failed() derives the full service+kind set from the same _ENRICH /
  _BACKFILL maps the workers use (tmdb/tvdb + omdb + fanart/opensubtitles/trakt/
  tvmaze/anilist/wikidata + ryd/sponsorblock/dearrow), loops enrichment_retry, returns
  the total re-queued. POST /api/video/enrichment/retry-all-failed.
- Topbar button (amber, text) → calls it, toasts the count, refreshes the modal.

DB test (resets across matcher + backfill + youtube service, deterministic count) +
frontend wiring test. ruff + node --check clean.
2026-06-20 14:15:18 -07:00
BoulderBadgeDad
7061001f66 video: add 'Add to Watchlist' button to person detail pages
Person follows are already supported (video_watchlist kind='person', add/remove/check
API, and the button shows on person CARDS) — but the person DETAIL page had no way to
follow or see if a person is followed. Added the standard watchlist button to the
person hero: renderWatchlist() builds it + lazily checks the followed state,
toggleWatch() adds/removes via the person-kind watchlist API, wired through the page's
delegated click handler. Same chrome as the movie/show pages.

4 wiring tests; node --check clean.
2026-06-20 13:50:34 -07:00
BoulderBadgeDad
f6f5561d0b video downloads: resume tracking on modal reopen + entirely new result-list design
Two fixes from feedback:

1) Reopening the download modal now KNOWS a download is already running. The view
   gets a persistent active-download banner at the top that looks the title up by
   media identity (/downloads/status?media_id=) on every open and polls while active
   — progress bar + release name + 'Track on Downloads ↗'. Suppressed while a result
   card is already tracking inline (fresh-grab case) so there's never a double
   indicator.

2) Result cards completely redesigned (third time's the charm): dropped the rounded
   cards + big resolution tile for a flat, release-list layout (Radarr/Prowlarr
   style) — hairline dividers, a small colour quality tag + source word on the left,
   the RELEASE NAME as the hero line, dense inline meta (codec · audio · HDR ·
   uploader · group) under it, then size · a compact ✓/✕ verdict flag · a compact
   accent 'Get' pill. The selected/auto/grabbed row tints + rings in place, and the
   live tracker still docks under the chosen row.

All video-only. node --check clean; 16 tracking/auto wiring tests + the status
endpoint test green.
2026-06-20 09:51:43 -07:00
BoulderBadgeDad
7e504e03c6 video downloads: live tracking on the grabbed result + movie detail, redesigned cards
After a grab (manual or Auto) the user now SEES what happened and can follow it:
- The chosen result card is spotlighted (Auto scrolls it into view) and grows a live
  tracker: a progress bar that follows the real download + a 'Track on Downloads ↗'
  button that closes the modal and jumps to the Downloads page. Polls the new
  GET /api/video/downloads/status?id= until the download reaches a terminal state.
- A movie's detail page shows a live download chip (progress bar + %) for any in-flight
  download of that title; clicking it jumps to Downloads. Looks up by media identity
  via /downloads/status?media_id=&media_source=, polls while active, clears on
  navigate-away. (video-detail.js)
- Result CARDS redesigned (the part you didn't like): a column card with a colour
  resolution badge, a green 'accepted' edge, cleaner hierarchy, and the grab button is
  now an accent 'Get' pill matching the source Auto button language.

Plumbing: new lightweight /downloads/status endpoint (by id, or by media for detail
pages); soulsync:video-navigate event in video-side.js to reach a top-level page from
anywhere; VideoGet.close exported so the tracker can dismiss the modal. All video-only.

Tests: status endpoint (by id + by media + null cases) in test_video_api.py;
tracking/detail/nav wiring in test_video_download_tracking.py. node --check clean;
isolation guards green.
2026-06-20 09:41:05 -07:00
BoulderBadgeDad
afe3d0d04d video download modal: redesign the Sources area + Manual/Auto buttons
The sources half felt weak next to the animated top half (quality chips + glowing
verdict), and the dark-text lightning Auto button looked off. Redesigned the whole
sources block:

- Buttons are now a cohesive pair: Manual = quiet ghost (outline), Auto = hero —
  brand-filled gradient, white text w/ shadow for legibility on bright brands, a
  soft continuous brand glow (vdlAutoGlow, --glow set per element) + a sheen sweep
  and a sparkle twinkle on hover. Swapped the harsh  for a clean monochrome ✦
  that inherits the button colour. 'Manual all'/'Auto all' header buttons speak the
  same language (ghost vs accent hero).
- Source rows: richer brand card — bigger glassy icon tile w/ inner highlight +
  halo, a stronger brand gradient, an inset top highlight, a glowing left brand
  edge (::before), and the status is now a brand-tinted pill (was bare dot+text)
  with state colours (scanning/done/none). Scan bar moved to ::after.
- Section labels get a small accent tick so both halves read 'designed'.
- Reduced-motion + mobile (full-width stacked buttons) handled.

Icon+label split into spans for finer control. node --check clean; 7 tests
updated/green.
2026-06-20 09:18:04 -07:00
BoulderBadgeDad
55658f15da video download modal: per-source 'Auto' button (search + grab the best release)
Each source in the movie/YouTube download view had one 'Search' button (manual —
you pick a release). Adds a second 'Auto' button beside it that runs the SAME
search and then auto-grabs the best release for your quality profile; renamed the
pair to 'Manual' / 'Auto' for clarity (+ a matching 'Auto all' beside 'Manual all').

How 'best' is chosen: the backend already returns hits ranked best-first
(accepted → score → availability — see test_downloads_search_endpoint_ranks_and_filters),
so Auto just waits for the search to settle, then takes the first accepted hit
that has an uploader and grabs it. The chosen release card gets a ring + the row
shows Auto-grabbing → Sent, so the pick is transparent.

- searchInto/_pollSearch gain an onDone callback (fires when results settle); the
  immediate (mock) path fires it too.
- doGrab refactored into shared buildGrabPayload + sendGrab so the manual button
  and _autoPick send an identical /grab request (incl. the auto-retry candidate pool).
- searchInto now drops stale _rows on start so an empty Auto search can't grab a
  prior search's hit.

Soulseek-grab-only for now (same as the manual button); non-soulseek sources say
'no release met your profile' until that grab path lands. TV show view (separate
onShowClick, still stub searches) untouched. 7 wiring tests; node --check clean.
2026-06-20 00:03:11 -07:00
BoulderBadgeDad
483cc61e08 video Automations hub: empty the music-specific tabs (keep Reference)
The Automation Hub's Pipelines / Singles / Quick Start / Tips panes are music content
(playlist pipelines, music recipes/guides). The video side will get its own content
there; for now those four panes are emptied to a 'Video … coming soon' placeholder
(scoped to the detached hub element, no id clash). Reference stays — it's generic
automation reference. Hub tabs + structure otherwise unchanged. Balance clean.
2026-06-19 18:18:31 -07:00
BoulderBadgeDad
5d8db8c5a4 video Automations page: match the music page's exact layout
Was a bespoke .vauto- header; now mirrors the music automations page structure 1:1 —
.page-shell.automations-container > .dashboard-header (sweep + automation.png icon +
header-title/subtitle) > .automations-stats ('N Active · N System') > .automations-list
holding the protected '.automations-section.section-protected' System group (chevron +
label + count + collapse, persisted) with the same .automation-card rows. Reuses every
music class; driven by video JS via data-vauto-* hooks (no #id clash). Removed the dead
.vauto-* CSS. Balance clean.
2026-06-19 18:05:00 -07:00
BoulderBadgeDad
c78b90826a video: Automations page (shared system automations, video view)
Adds an Automations nav entry + page to the video side. The automation engine is
app-wide, so this surfaces the SAME system automations the music side runs — filtered
to is_system, EXCLUDING Refresh Beatport Cache and any user/playlist-pipeline ones.
Reads the shared /api/automations (no music imports), reuses the music .automation-*
card look, and supports run-now + enable toggle (system automations aren't editable).
Polls every 5s while the page is open. Frontend-only; balance clean.

Next: the library-refresh/scan automation wiring (Boulder has questions on how server
scanning works first).
2026-06-19 18:01:23 -07:00
BoulderBadgeDad
48f6cc5e3c video Downloads cards: poster + movie details + Open-page button
Grabs now carry the movie's identity so the Downloads cards are rich, not anonymous:
- video_downloads gains media_id / media_source / year / poster_url (schema v15 +
  migration); grab stores them (passed from the get-modal → download view → grab).
- Cards show the POSTER in the art tile (emoji fallback), 'Title (Year)', a quality
  chip (1080p · BluRay · X265), and an ↗ Open button that jumps to the movie/show
  detail page (dispatches soulsync:video-open-detail). Cancel/retry unchanged.
16 tests green, ruff + balance clean.
2026-06-19 16:29:27 -07:00
BoulderBadgeDad
1c3f255a66 video Downloads page: actually match the music page (.adl-* layout, full width)
Scrapped the bespoke centered .vdpg- design and reused the music downloads page's
.adl-* classes for real parity: full-width .adl-layout, the segmented .adl-filter-pills,
the title with the accent download glyph, and the compact .adl-row (44px art tile +
.adl-row-info title/meta/error + .adl-row-status dot+label) — driven by the video JS
via data-vdpg-* hooks (no #id clashes with the music page). Per-row cancel reuses the
music hover-reveal .adl-row-cancel; retry mirrors it in accent. Kept the smooth
in-place patching (slim progress line for active rows). Removed the old .vdpg-* block;
balance clean.
2026-06-19 15:47:02 -07:00
BoulderBadgeDad
d25be2c3c6 video downloads Phase B: page redesign — filter tabs, cancel/retry, depth
Brings the Downloads page up toward the music page's depth:
- Filter tabs (music-style pills): All / Active / Completed / Failed, each with a live
  count; clicking filters the list. Cancelled rolls under Failed.
- Header actions: Cancel all (active) + Clear finished, shown only when relevant; a
  live 'N active · N done · N failed' subline.
- Per-row actions: ✕ Cancel on active rows (→ /downloads/cancel), ↻ Retry on
  failed/cancelled rows (→ /downloads/retry, re-grabs the release).
- Cancelled status styling (pill + dimmed row). Still the smooth in-place patching
  (no blink), adaptive polling, empty + filtered-empty states.
Balance clean. Phase C (auto-retry + alternate-query retry) next.
2026-06-19 15:34:50 -07:00
BoulderBadgeDad
42c67a6d4d video search: stream slskd results (start + poll) — fixes 'no results'
The old search did a 4.5s slskd search + 8s wait; slskd responses trickle in over
10-30s, so the window closed before results arrived (you'd see them in slskd but the
panel said none). Now it works like the music side:
- slskd_search.start_search() (uses the shared soulseek.search_timeout) + poll_responses().
- POST /downloads/search/start (mock = immediate; soulseek = returns a search id) +
  GET /downloads/search/poll. Shared _evaluate_hits ranks each poll's hits.
- UI streams: starts the search, polls every 1.3s, renders results live with a
  'searching…' badge, stops when results plateau (4 stable polls) or ~32s. Live
  re-renders suppress per-card entrance so it doesn't blink.
Tests green, ruff clean.
2026-06-19 15:23:47 -07:00
BoulderBadgeDad
7e0726d5eb video Downloads page: smooth in-place updates (no more blink) + polish
The blink was every poll re-running each card's entrance animation via innerHTML
churn. Now cards are created ONCE and PATCHED in place (data-st attribute swaps,
progress width glides over a 1.4s transition, meta only rewrites when its text
changes), so nothing re-animates on a tick — the music-downloads smoothness.

Also: adaptive polling (1.5s while active, 6s idle), border/pill colours TRANSITION
on status change, downloading cards get an accent tint + glow, a relative 'started Xs
ago', and the % moved inline. Balance clean.
2026-06-19 15:10:04 -07:00
BoulderBadgeDad
fff6448a14 video downloads: wire Grab + build the live Downloads page
Phase 3 — the UX:
- Grab button now actually grabs (Soulseek results only — they carry the slskd
  username/filename): POSTs /downloads/grab with the result + search context, shows
  ✓ on success, toasts 'Sent to Downloads', fires a refresh event. Endpoint returns
  size_bytes for the payload.
- New Downloads PAGE (video-downloads-page.js, .vdpg-*): every grab lands here.
  Polls /downloads/active while open and shows live status — type icon (🎬/📺/▶️),
  title + release, a pulsing 'Downloading' pill with a shimmering progress bar, then
  'Completed' with the file's library destination (→ /media/movies/…), or 'Failed'
  with the reason. Clear-finished button, empty state, staggered entrance, vibes.
  Reduced-motion honored. JS/CSS/HTML balance clean (verified w/ a real tokenizer).
2026-06-19 14:59:22 -07:00
BoulderBadgeDad
86fde7d62c video search UI: show slskd peers/username, live vs demo, errors
- Result meta now adapts to the source: Soulseek shows '👤 uploader · N peers · N
  slots' (real slskd availability); torrent/usenet keep '▲ seeders' (still mock).
- Results header shows a '● live' badge for real slskd searches vs an amber 'demo
  data' badge for the still-mocked sources — so it's never ambiguous again.
- slskd errors ('not configured', network) surface as a clear ⚠ line in the panel.
JS/CSS balance clean.
2026-06-19 14:17:45 -07:00
BoulderBadgeDad
bf17ef3fab video search: per-source result panels + redesigned, readable cards
- Each source now has its OWN results panel under its row (movie + episode), and
  the search passes the source through — so Soulseek / Torrent / Usenet each show
  their own distinct hits instead of one shared identical list.
- Card redesign for readability: a big colour-coded RESOLUTION tile (4K gold /
  1080p accent / 720p blue / SD) anchors each row; a plain-English quality summary
  leads (e.g. 'BluRay · X265 · DTS-HD' + an HDR/REPACK tag); the raw release name is
  demoted to a single muted truncated line; size · seeders · group sit below; the
  verdict pill (✓ Meets profile / ✕ reason) and ⤓ Grab stay on the right.

Dropped the old break-all mono title + badge-row layout. JS/CSS balance clean.
2026-06-19 13:58:43 -07:00
BoulderBadgeDad
c5bffe51a5 video search UI: real result cards across movie/episode/season/show scopes
Wires the four scopes to the /downloads/search pipeline and renders beautiful,
profile-aware result cards (mock indexer for now):
  - Movie: per-source ⌕ / Search all → movie scope.
  - Episode: expand an episode → per-source ⌕ → episode scope (its own results panel).
  - Season: the season ⌕ → SEASON-PACK scope, results under the season header.
  - Whole show: top-bar 'Search whole show' → complete-SERIES-pack scope.
Each card shows the full release name (mono), colour-coded quality badges
(resolution/source/codec/HDR/audio/repack), size, seeders, and a verdict — green
'✓ Meets profile' or a dimmed '✕ <reason>' (e.g. not in your enabled tiers, wrong
season, over size cap). Accepted hits sort first and carry a ⤓ Grab button (stub).
Spinner while searching; staggered card entrance; reduced-motion honoured.
Replaced the faux-scan stubs. JS/CSS balance clean.
2026-06-19 13:44:50 -07:00
BoulderBadgeDad
8803c1bd0c video download: dedicated TV show view — season/episode picker
Shows now get their own download experience instead of the movie layout. Clicking
Download on a show WIDENS the modal (~680→940px, animated) and renders a tree:

  - Quality target chips + a show summary (N seasons · M episodes · X in library ·
    Y missing) + a master select-all + a primary 'Search N selected'.
  - Season cards (collapsible) with a tri-state season checkbox, 'N eps · M missing',
    and a per-season search button (auto-opens the season to reveal the scan).
  - Episode rows: checkbox, E# · title, In library/Missing/Upcoming badge. Default
    selection = everything you're MISSING (owned shown but unticked, upcoming locked).
  - Each episode expands inline to its branded per-source search strip (Soulseek/
    Torrent/Usenet), so you can grab one episode à la carte; or batch via the season/
    bulk buttons. Searches are the same faux-scan stub — no backend yet.

Library shows use shipped episodes; tmdb shows prefetch seasons via the existing
/tmdb/show/<id>/season/<n> endpoint. get-modal stashes the detail + tvId and routes
shows to VideoDownload.render({kind:'show',…}); movie/youtube paths unchanged.
Vibey: staggered entrance, branded scan bars, smooth collapse. Reduced-motion honored.
JS + CSS balance clean.
2026-06-19 12:06:23 -07:00
BoulderBadgeDad
ecd9c25e3c video download view: drop the footnote + make Sources section pop
- Removed the 'Automatic searching arrives…' footnote.
- Sources rows are no longer tame: each carries its own brand colour (Soulseek
  blue / Torrent amber / Usenet purple / YouTube red) driving a tinted gradient
  row, a left accent rail, a glowing brand icon TILE, and an in-brand hover lift
  with a coloured shadow. A live pulsing status DOT sits by the label, the Search
  button is brand-tinted, and the scan bar/'Searching…' state now glow in the
  source's own colour. Reduced-motion still honoured.

CSS + JS balance clean.
2026-06-19 11:42:43 -07:00
BoulderBadgeDad
ac7b47e4db video download view: take the vibe up a level (staggered + animated)
Pure motion polish on the in-place download view:

  - Staggered entrance — the view's blocks rise in sequence; target chips pop in
    with a spring + a one-shot sheen sweep; source rows slide in cascaded.
  - Quality chips glow on hover in the modal's vibe hue; source rows lift/shift
    with a playful emoji wiggle.
  - Owned verdict badge pops in (spring); 'pending' breathes while checking; the
    'Eligible for upgrade' badge gets a soft amber glow pulse.
  - 'Search all' button pulses to draw the eye + a sheen sweep on hover.
  - Stub search is now a satisfying faux-SCAN: the row shows 'Searching…' with
    animated dots + a moving scan bar, then resolves to 'coming soon' (staggered
    across rows on 'search all', so it ripples). Still no backend.
  - Honors prefers-reduced-motion (keeps layout, drops the motion).

CSS + JS balance clean.
2026-06-19 11:39:19 -07:00
BoulderBadgeDad
2a61ac6a13 video download: render the download view IN-PLACE in the get-modal (no 2nd modal)
Per feedback — one modal with a view transition beats close+open. Clicking
'Download' now swaps the get-modal's detail body for the download view (quality
target + owned verdict + per-source search) with a '← Back to details' button;
the selection-only sections (episodes/next/follow) and the Full page/Download/Add
buttons collapse, the all-related movie bits stay.

  - video-download-modal.js → video-download-view.js: now a reusable RENDERER,
    VideoDownload.render(container, opts), not its own overlay. A future YouTube
    trigger can render the same view into its own container (still universal).
  - get-modal: enterDownload/exitDownload toggle the in-place view; stashes the
    owned file on modalState to feed the verdict. Removed the separate .vdl
    overlay/hero/close CSS; added .vgm-dl/.vgm-back + [hidden] guards; chips now
    pick up the modal's --vgm-h vibe hue.

Pure front-end; balance + div + css brace checks clean.
2026-06-19 11:27:59 -07:00
BoulderBadgeDad
1dc727f0bb video: universal Download modal (movie/show/youtube) — visual scaffolding
Adds a 'Download' action to the get-modal that closes it and opens a new universal
Download modal (VideoDownload.open) shared by movies, shows and YouTube:

  - Quality TARGET shown as chips, read live from the profile you configured
    (cutoff / codec / HDR / rejects / size for p2p; resolution/codec/container/
    60fps/HDR for YouTube).
  - Owned copies get a REAL verdict: 'In your library · 720p · BluRay · X265' +
    a 'Meets your target' / 'Eligible for upgrade' badge with reasons, via
    /downloads/evaluate (the quality_eval seam).
  - Per-source rows (soulseek/torrent/usenet from the download config, or yt-dlp
    for YouTube) each with a Search button + a 'Search all'.

Per the ask, the searches are STUBS — clicking flips the row to 'coming soon'; no
backend yet (that's the engine phase). Beautiful .vdl-* styling mirrors the get-modal
vibe (per-title hue glow). New JS file wired into index.html. Pure front-end.
2026-06-19 11:06:11 -07:00
BoulderBadgeDad
9f38c34900 video quality profile: loose resolution cutoff + movie/episode size split
Addressing UX feedback (Boulder): the cutoff and size guard were confusing for a
library that holds BOTH movies and TV.

  - 'Upgrade until' is now a LOOSE resolution target (4K / 1080p / 720p / SD /
    'best — never stop'), not a specific source×resolution tier. 4K is always in
    the list regardless of which tiers are toggled on (the old dropdown only
    listed enabled tiers, so 4K vanished when off). Stored as cutoff_resolution.
  - Size guard split into Max movie size + Max episode size (runtime-aware, like
    Radarr's MB/min but human-readable) — a flat GB cap was meaningless across a
    2-hour movie and a 25-min episode. Dropped the confusing min slider.

Pure-logic + API + UI in lockstep; removed the now-dead per-row cutoff marker.
12 tests green, ruff clean, JS/HTML balance clean.
2026-06-19 10:40:53 -07:00
BoulderBadgeDad
cd13b1185e video quality profile UI: rich-curated ladder + cutoff + rejects + soft prefs
Rebuilds the Video Quality Profile card to match the new Radarr-class model:

  - Quality ladder: one ranked, toggleable source×resolution list (Remux·4K …
    SDTV) reusing the .hybrid-source-item styling (parity with Download Source).
    The cutoff tier shows an accent rail + 'cutoff' tag.
  - Upgrade until: a styled dropdown of the enabled tiers (the cutoff).
  - Never grab: red toggle chips for the hard rejects (cam/screener/workprint/3d/x264).
  - Preferences: segmented controls for codec (Any/HEVC/AV1), HDR (Don't care/
    Prefer/Require), audio (Any/Surround/Lossless/Atmos) + a Prefer REPACK check —
    all soft tie-breakers.
  - Size guard: min + max GB sliders (0 = no limit).

Dropped the old resolutions/source_priority/single-codec/fallback widgets. JS
delegation rewritten for the new data-vq-* hooks; same-verdict-as-HEAD balance,
div balance clean. Pure UI on the isolated video Downloads tab.
2026-06-19 08:43:47 -07:00
BoulderBadgeDad
975f81f476 video downloads: quality profile resolution/source rows reuse the hybrid-source-item styling
Consistency — the Resolution order and Preferred-source order lists now use the exact
same .hybrid-source-item markup/CSS (card + arrows + priority number + pill toggle) as
the Download Source hybrid list, instead of the bespoke .vq-row styling. Resolution
keeps its enable toggle; source is order-only (no toggle), no brand icon (they're not
branded sources). Removed the now-dead .vq-row/.vq-arrow/.vq-toggle/.vq-rows CSS.

Pure UI; data-vq-* hooks preserved so the existing delegation/handlers are unchanged.
2026-06-19 08:13:24 -07:00
BoulderBadgeDad
16ba035330 video downloads: UX/visual polish on the Downloads tab (review feedback)
- Hidden leftover music sections: the broad hide rule now targets ANY
  [data-stg='downloads']:not([data-video-only]), not just .settings-group — so the
  music Quality-Profile tile (#quality-profile-tile) and the Retry-Logic collapsible
  (which are divs/section-headers, not .settings-group) no longer show as dead,
  un-openable shells on the video side.
- Reordered: Download + Transfer folders now come BEFORE the Download Source section.
- slskd connection section is now COLLAPSIBLE (collapsed by default) — it was a lot of
  fields; uses the same settings-section-header/body pattern as music's Retry Logic.
- Hybrid chain now reuses music's .hybrid-source-item markup/CSS for visual PARITY
  (icon + name + priority + pill toggle + arrows), minus the album/track badge.
- Quality profile copy clarified: 'Resolutions to accept' (order = preference) vs
  'Preferred source (tie-breaker)', clearer per-control hints.

Pure UI (markup/CSS/JS); backend + tests unchanged. JS verdict matches HEAD, HTML divs balanced.
2026-06-19 08:04:02 -07:00
BoulderBadgeDad
887ee01cb2 video downloads (phase 2): unified video quality profile
One profile across slskd/torrent/usenet — video quality is resolution + source +
codec (parsed from the release name/filename), not bitrate. Best-in-class settings UI
on the video Downloads tab:
- resolution tiers (4K/1080p/720p/480p) with per-tier toggle + arrow reordering
  (priority best-first), pill toggles
- source preference (BluRay > WEB-DL > WEBRip > HDTV), reorderable
- codec segmented control (Any / x265 / x264), Prefer-HDR toggle
- max-size-per-item slider (0 = no limit) + lower-quality fallback toggle

core/video/quality_profile.py: pure default/normalize/load/save (clamps size, rejects
bad codec, dedupes source list, recovers from corrupt JSON) — stored as JSON in
video.db's video_settings['quality_profile']. GET/POST /api/video/downloads/quality.
video-settings.js renders + saves it; isolated from music (imports only json/typing).

11 tests (9 pure + 2 API). Next: video source-mode + hybrid, then the shared slskd block.
2026-06-19 00:11:37 -07:00
BoulderBadgeDad
8392de9207 video downloads (phase 1): video-specific download + transfer folders
Foundation for the isolated video download settings. The Downloads tab is almost
entirely music-specific, so on the video side the music download sections are hidden
(video-side.css) and a data-video-only 'Video Download Folders' section takes their
place — an input (download) and output (transfer/library) folder, stored SEPARATELY
from the music soulseek.* paths in video.db's video_settings KV table.

- api/video/downloads.py: GET/POST /api/video/downloads/config (download_path,
  transfer_path), registered in the video blueprint. Imports nothing from music.
- video-settings.js: loadDownloads/saveDownloads wired into onPageShown + the
  video save-button chain.
- The shared 'Indexers & Downloaders' tab is left untouched (identical for both).

2 tests (round-trip + the isolation guard). Quality profile, video hybrid, and the
shared slskd block are the next phases.
2026-06-19 00:04:27 -07:00
BoulderBadgeDad
c33810f39f video enrichment: add DeArrow (crowd-sourced YouTube titles) — full parity
Fourth service. Rides the YouTube-video enrich path (like RYD/SponsorBlock), keyless,
on by default → toggle in the YouTube-Extras frame.
- DeArrowWorker: fetches the branding API and stores the first non-original crowd
  title for a cached YouTube video. apply_youtube_dearrow + youtube_video_dearrow_title
  DB methods; dearrow_status added to the youtube_enrich next/breakdown whitelists.
- Schema: dearrow_title/status/attempted on youtube_video_stats (video_schema.sql +
  _COLUMN_MIGRATIONS for existing DBs).
- config GET/POST dearrow_enabled; manager orb (blue 🏹, video-kind) + status poll;
  YT video-detail panel shows a 'DeArrow' alt-title (payload via youtube.py + CSS).
- 4 new tests + fixed-set/config assertions.

30 backfill tests green. (My change is ruff-clean; the 12 pre-existing S110s in
api/video/youtube.py are unrelated tech debt on this branch, left untouched.)
2026-06-18 23:09:02 -07:00
BoulderBadgeDad
6f8a2a3f7a video enrichment: add AniList (anime score) — keyless GraphQL worker, opt-in
Third service. Keyless GraphQL (new _http_post_json helper) → enable toggle in the
'Community Data (No Key)' frame, OFF by default (anime-niche + title-search match).
- AniListWorker: TV-only, searches AniList by title and stores the anime averageScore
  (0-100), with a conservative normalized-title guard so a fuzzy anime search can't
  attach a score to a non-anime show. anilist_enabled toggle (default off).
- DB: anilist_score/status/attempted on shows + _BACKFILL/_BACKFILL_COLS (keyed on
  title); show_detail returns anilist_score.
- config GET/POST anilist_enabled (default 0); manager orb (blue 🎌) + status poll;
  detail page 'AniList 85%' chip (+ CSS).
- 4 new tests (incl. the title-mismatch rejection) + fixed-set/config assertions.

27 backfill tests green, ruff clean. (Note: the youtube_status_route test still
flaps on the sandbox WSL WAL disk-I/O — environmental, unrelated.)
2026-06-18 23:01:23 -07:00
BoulderBadgeDad
5a95619e22 video enrichment: add TVmaze (TV community rating) — keyless worker, full parity
Second service. Keyless (no API key) → an enable toggle in a new 'Community Data
(No Key)' connections frame, mirroring the YouTube-Extras pattern.
- TVmazeWorker: TV-only (no movie DB), looks a show up by imdb/thetvdb id and
  gap-fills the TVmaze community rating. On by default; tvmaze_enabled toggle.
- DB: tvmaze_rating/status/attempted on shows + _BACKFILL/_BACKFILL_COLS (show only);
  show_detail returns tvmaze_rating.
- config GET/POST tvmaze_enabled; manager orb (teal 📺, show-kind) + status poll;
  detail page TVmaze rating chip (+ CSS).
- 4 new tests + the 3 fixed-set/config-exact assertions updated.

Note: one UNRELATED test (youtube_status_route) flaps on a WSL WAL 'disk I/O error'
in this sandbox (pass/fail/fail across reruns of identical code) — environmental, not
this change; the TVmaze + config tests pass consistently.
2026-06-18 22:56:02 -07:00
BoulderBadgeDad
314e587094 video enrichment: add Trakt (community audience rating) — full worker parity
First of the new enrichment services, wired to the SAME standard as the rest:
- TraktWorker backfill (core/video/enrichment/backfill.py): looks a title up by its
  IMDb id (?extended=full) and gap-fills the community rating + vote count. Registered
  in build_backfill_workers.
- DB: trakt_rating/trakt_votes/trakt_status/trakt_attempted columns + _BACKFILL /
  _BACKFILL_COLS registration; detail payloads return trakt_rating/votes.
- Connections tab: Trakt service frame (Client ID field + Test button), wired into
  video-settings.js load/save/bindings and the /enrichment/config GET+POST.
- Worker-manager modal + orb: WORKERS registry entry (red ★) + status-poll SERVICES
  list, so it gets the same orb animation + per-service matched/pending/error card.
- Detail page: a 'Trakt 8.2' rating chip alongside IMDb/RT/Metacritic (+ CSS).

5 new tests + 2 fixed-set assertions updated; 249 video tests green, ruff clean.
2026-06-18 22:33:36 -07:00
BoulderBadgeDad
c55a4fa10e video detail: surface subtitle availability (OpenSubtitles) + fix B023
The OpenSubtitles backfill worker already collects which subtitle languages exist
for each title (movies.subtitle_langs / shows.subtitle_langs), but nothing showed
it. Now the detail payload returns it (parsed to a list) and the hero renders a
'Subtitles: English · Spanish · …' CC-tinted chip row under the genres — so you can
tell subs exist before grabbing the file. Hidden entirely when there's no data.

(The clearlogo hero was already implemented, so this targets the one genuinely
invisible enrichment field.)

Also bound the per-iteration loop var in the ratings-breakdown counter (pre-existing
ruff B023). 154 video tests green, ruff clean.
2026-06-18 21:58:06 -07:00
BoulderBadgeDad
5506155abe YouTube videos: play in-app (stream via the embed overlay)
Clicking a video thumbnail now plays it inline — the thumbnails are play buttons
(data-vd-yt-play) that reuse the existing trailer overlay (youtube.com/embed/<id>
?autoplay=1). Applies to channel + playlist video cards and the playlist-section
mini-cards (which previously just linked out to YouTube). The rest of the row
still expands to details.
2026-06-17 21:29:04 -07:00
BoulderBadgeDad
abb9d0e89e + Wish: use the app-standard watchlist-button chrome (was bespoke)
The per-video wishlist toggle now reuses .library-artist-watchlist-btn (icon +
text, accent gradient) via a shared ytWishBtn() helper — compact in the dense
episode rows, icon-only in the tight playlist-section cards. 'Wishlist' ↔ 'In
Wishlist' with a green .watching state. Same behavior + data-vd-yt-wish hook;
just consistent chrome.
2026-06-17 21:02:08 -07:00
BoulderBadgeDad
62b815a3fe Channel page: Add-to-watchlist on each playlist section
Channel playlists (the collapsible sections on a channel page) now each carry the
standard watchlist button — Add to Watchlist / In Watchlist — so you can follow a
channel's playlist without pasting its link. The /youtube/playlists/<id> endpoint
flags each playlist with its follow state to hydrate the button; the button click
is handled before the row's expand toggle (stopPropagation), and adds via
followPlaylist — the same plumbing the search chip + watchlist use. Followed ones
then appear in the watchlist Channels tab alongside channels.
2026-06-17 20:48:59 -07:00
BoulderBadgeDad
ef3d4cfdb4 YouTube playlists on the watchlist — frontend (search, detail, watchlist)
- search: paste a playlist link → it resolves to a 'YouTube playlist' chip
  (cover, owner, count) with Add-to-watchlist; clicking the chip opens it.
  (isPlaylistRef + playlistCard + followPlaylist/unfollowPlaylist helpers.)
- routing: /video-detail/youtube/playlist/<PL…> deep-links like a channel
  (string id); the open-detail event handles kind='playlist'.
- detail view: a FLAT list in the curator's order (no year-seasons, no season
  nav / view toggle, no catalog streaming — it's a partial set). Billboard shows
  'Playlist · N videos · owner'. Reuses the show-detail shell + the new
  duration/views cards.
- watchlist: followed playlists sit beside channels in the Channels tab (rounded
  square + a list badge), open on click, ✕ to unfollow.

Live-validated (Lex Fridman playlist resolves + renders in order). The 2 shell
test 'failures' are the pre-existing window.-assertion (no new globals added).
2026-06-17 20:33:31 -07:00
BoulderBadgeDad
9c3d1a5709 Channel page: search + sort within the channel (restores the lost browse controls)
The standalone channel page had sort/filter/search; the merge into show-detail
dropped them. Now that the whole catalog is client-side, they're instant:

- A controls row above the year pills: a debounced title search + a sort select
  (Newest / Oldest / Most viewed / Longest).
- Newest/Oldest keep the year-season grouping (reordered); search + Most-viewed +
  Longest collapse into one flat sorted 'results' list. Empty search → a clear
  'No videos match …' state.
- Refactored ytToShow's grouping into reusable helpers (ytGroupByYear / ytEpisodeOf)
  + ytRegroup(), which the streaming loop now uses too — so newly-streamed videos
  fold into whatever view/sort is active, and the master list stays intact.

Search focus is preserved across the regroup (same hack the music manager uses).
2026-06-17 18:45:40 -07:00
BoulderBadgeDad
c7b5a93290 Channel videos: duration badge + view count on every card (TV-parity)
TV episode rows show runtime; channel video cards showed nothing per video. The
InnerTube lockup already carries both — now we capture duration ('12:34') + an
approximate view count (parsed from '2.6M views') in innertube_parse_video_items,
remember them on youtube_channel_videos (new duration/view_count cols + migration),
and render a duration badge bottom-right of the thumb + 'N views' in the card meta.
The background stream backfills them onto the recent (yt-dlp) videos too, so the
whole catalog gets them. Live-validated (MrBeast: 32:08 / 50M, 30/30). 65 tests green.
2026-06-17 18:36:47 -07:00
BoulderBadgeDad
4c16448ab3 Calendar 'next up' hero: smoother panel motion + collapsed panels rest at 25%
- The hover snap was jarring because collapsed panels floored at min-width:10%
  (so panels jumped ~10%↔80%) on a fast-start easeOutCirc curve. Raised the floor
  to 25% (3 panels now settle 50/25/25 and hovering just glides the 50% across)
  and switched to a gentler ease (0.66s cubic-bezier(0.45,0,0.2,1)).
- Lengthened the dim-overlay + sub/actions fades (0.4→0.55s, 0.3→0.45s) so they
  move with the expansion instead of finishing early. Mobile stack unaffected
  (it overrides min-width:0).
2026-06-17 16:32:31 -07:00
BoulderBadgeDad
cf03c28831 Discover: taller hero slider (420->520 desktop, 340->420 tablet) 2026-06-17 16:26:17 -07:00
BoulderBadgeDad
a3adf5e63c Video Manage-Workers modal: realign to the music modal's exact design
The video modal reused music's shell but had drifted into a parallel mini-design:
it emitted its own markup (.em-ph-main / .em-pause-btn / .em-item / .em-pg /
.vem-logo / .vem-icon) styled separately in video-side.css, instead of the
music classes that already exist in style.css. So the panel header, list rows,
rail icons and pager all looked 'redesigned'.

Now the modal emits music's real markup and inherits its global .em-* styling:
- Panel header -> .em-hero (glow + .em-icon--lg + .em-pill + .em-ph-sub)
- Unmatched list -> .em-row (status stripe, art+glyph, 'tried Nd ago', ghost Retry)
- Rail icon -> .em-icon chip (with --i stagger); pager -> .em-pager + .em-btn
- Pause -> .em-btn/.em-btn--go; coverage cards gain the First/Done/N-left badges
- Open/close entrance+exit animation (.em-in / .em-closing), dropping .em-in
  after it plays so the 3s rail re-render doesn't replay the stagger
- Panel sets --accent-rgb so music's accent-keyed rules recolor per worker

Removed the now-dead bespoke CSS. Episodes stay a coverage card (per the backend:
episode art cascades from a show match; /priority only accepts movie/show), so
the top bar is correctly Movies/Shows/Auto. Music side untouched (shared classes
are global; video overrides were all #vem-overlay-scoped or now deleted).
2026-06-17 14:18:43 -07:00
BoulderBadgeDad
581735ac7d YouTube enricher: coverage-aware retry + show in Manage Workers modal
- Fixes 'stuck idle': a channel marked enriched with FEW dates (proxies were
  down) was locked out for 24h, so the improved yt-dlp fallback never re-ran.
  channel_dates_enriched_recently now retries in 15 min when the last run got
  <15 dates, and skips 24h only after a good run. So the catalog actually fills
  in once a source works.
- The YouTube Dates worker now appears in the Manage Workers modal: added to
  WORKERS (red ▶), youtube-aware rail subtitle, and a dedicated simple panel
  (status + what-it-does + channels-enriched / dates-cached / queued counts) —
  no per-kind match queue. Pause/resume works; polls every 3s like the rail.
2026-06-17 11:37:26 -07:00
BoulderBadgeDad
ece4fbc792 Channel detail: fix episode-row layout + graceful seasons
- The YouTube episode rows reused .vd-ep's 5-column grid (index·thumb·info·
  badge·chev) but only have 4 children, so the thumb squished into the index
  slot and the Wish button stretched across the info column. Give .vd-ep--yt its
  own 4-column grid (thumb·info·wish·chev).
- Flat listing omits per-video dates, so all videos bucketed into a lone
  'Undated' season. Now a single dateless channel shows one 'All Videos' season
  (year-grouping still kicks in for any videos that DO have dates); the 'Missing
  only' toggle is hidden for channels (videos aren't owned/missing).
2026-06-17 10:07:51 -07:00
BoulderBadgeDad
0ff0d07c22 Search UX: no 'No results' flash before YouTube loads + avatar fallback
- The big one: an empty TMDB result no longer flashes 'No results' while the
  (slower) YouTube channel search is still in flight — a 'YouTube channels ·
  searching…' skeleton group (shimmer cards + spinner) shows instead, and the
  empty state only appears once BOTH sources resolve with nothing. Users no
  longer click away thinking nothing's happening.
- Channel avatars: fall back to the singular 'thumbnail' field when the flat
  results omit the thumbnails list, so fewer cards are photoless (the rest keep
  the clean initials placeholder). Reduced-motion respected.
2026-06-17 09:42:02 -07:00
BoulderBadgeDad
284acc8591 Merge YouTube channels into the TV-show detail page
Per request: a channel now opens the SAME detail page as a TV show instead of a
separate page. A channel renders through the show pipeline — currentKind='show'
(so the show container resolves) with d.kind='channel' + d.source='youtube'
driving content. Every change is an additive 'youtube' branch; the show/movie
path is byte-for-byte unchanged.

- Data transform (ytToShow): upload YEAR = season, video = episode, channel
  banner=backdrop, avatar=poster (proxied), tags=genres, subs/videos/views=meta.
- Source-aware seams: bbBackdrop/bbPoster/seasonArt, billboard meta + actions
  (Follow + Open-on-YouTube), episodeRow (ytEpisodeRow: Wish toggle), episode
  expand (loadEpisodeExtra → /youtube/video full description+stats).
- Channel-only: playlists as a section below episodes (collapsible, lazy-load
  their videos with wish toggles); per-video wish syncs across all cards; Follow.
- Routing: kind=channel → navigate('video-show-detail'); deep-link
  /video-detail/youtube/channel/<id> still parses (string id). All TMDB-only
  sections (cast/ratings/providers/etc.) auto-hide on empty channel data.

Retired the standalone video-channel.js + its subpage. JS/CSS balanced; music
untouched.
2026-06-17 09:17:41 -07:00
BoulderBadgeDad
9084f1b7bb Search: include YouTube channel results alongside TMDB
A text search now also surfaces matching YouTube channels (not just the paste-a-
link path). search_channels() extracts the channels-filtered YouTube results
page (flat); API GET /youtube/search hydrates a 'following' flag. The search
page fires it in parallel with the TMDB search and appends a 'YouTube channels'
group when it returns (best-effort; never blocks/breaks TMDB results). Cards open
the in-app channel page. 82 youtube+api tests green; balanced; music untouched.
2026-06-17 09:06:41 -07:00
BoulderBadgeDad
0456fa893f Channel page (UI): stats+tags, sort/filter/load-more, inline video detail, playlists
The channel detail page is now best-in-class, treated like a real show:
- Hero: stats ribbon (subs · videos · views) + the channel's topic tags as chips.
- Video grid: sort (newest/oldest/most-viewed), 'Wished only' filter, and a Load
  more that pages deeper than the first 60 uploads.
- Click any video → inline panel with its full description + likes/views (lazy
  /youtube/video fetch, cached); wish state syncs across every card with that id.
- Playlists rendered as collapsible 'seasons' that lazy-load their videos on
  expand (each with its own wish toggle).

.vc-* CSS; JS brace-balanced; music untouched.
2026-06-17 08:54:37 -07:00
BoulderBadgeDad
22adc3bc55 YouTube: pull FULL per-video metadata (lazy) for the wishlist info bar
Flat channel listing can't return descriptions, so selecting a video showed 'No
description'. Now we do a non-flat single-video extract on demand — the way the
TV nebula lazy-loads guest stars:
- core: video_detail(id) / shape_video() — description, views, likes, duration,
  tags, channel, webpage_url (full extract, yt-dlp injectable for tests).
- API: GET /youtube/video/<id> — returns it AND backfills the description onto
  the wishlist row (set_wishlist_video_overview), so re-opening is instant.
- Wishlist info bar (youtube): on select, lazy-fetch → real description +
  eyebrow (date · duration · views) + a 'Watch on YouTube' link; cached on the
  episode object. 'Loading details…' while in flight.

Mirrors the episode lazy-load + art-backfill patterns. 73 youtube+api tests
green; brace/CSS balanced; music untouched.
2026-06-17 08:22:45 -07:00