Commit graph

4380 commits

Author SHA1 Message Date
BoulderBadgeDad
8349fa78dd video enrichment: fix DeArrow retry (was a silent no-op)
enrichment_retry handled ryd/sponsorblock + the keyed backfills, but DeArrow (also a
youtube_video_stats backfill, keyed on dearrow_status) fell through to nothing — so the
modal's Retry button did nothing for DeArrow. Fold dearrow into the youtube_video_stats
retry branch. Regression test re-queues failed dearrow rows, leaves matched ones.
2026-06-20 12:52:06 -07:00
BoulderBadgeDad
0eac0ea46e video Manage Workers modal: decouple coverage click from global priority; Retry-all covers all kinds
Two UX fixes from feedback:
- Clicking a coverage card on a worker was also setting the GLOBAL 'process first'
  priority (and silently re-queuing that kind's failed items) — so picking a worker's
  coverage reached across and re-prioritised every worker. Now a coverage card just
  switches the view; priority changes only via the top Movies/Shows/Auto tabs, and
  re-queuing is the explicit 'Retry all failed' button. (Removed the now-dead
  requeueFailed helper.)
- 'Retry all failed' now re-queues EVERY coverage kind the worker handles (movie+show,
  etc.), not just the tab you're viewing — matching the 'all' in the label.

3 wiring tests; node --check clean.
2026-06-20 12:50:18 -07:00
BoulderBadgeDad
d6776f9f57 video enrichment: detail backfill is TMDB-only (fix TVDB 404 spam + double-processing)
The details backfill queue is keyed on tmdb_id, but the gate (hasattr match) let the
TVDB worker run it too — feeding TMDB ids to TVDB's /series/{id}/extended (→ 404 on
every show) and double-processing each show (TMDB backfilled it, TVDB then 404'd but
still logged 'Backfilled'). Gate on self.service=='tmdb' so only the TMDB worker runs
it. Regression test: the TVDB worker no-ops (never calls its client, leaves the item
pending). 95 enrichment tests pass.
2026-06-20 12:38:03 -07:00
BoulderBadgeDad
57f254acaf video enrichment: background TMDB details backfill (fills 'status' on pre-matched items)
The media server pre-matches shows/movies (tmdb_id set), so the enrichment matcher
skips them and never fetches TMDB details — leaving details-only fields like `status`
(airing vs ended) blank on almost the whole library. That's why the watchlist's
airing-shows default only ever saw the handful of shows whose detail page had been
opened (the one path that force-fetches details). Library here: 3,371 matched shows,
only 18 with status.

Fix: a one-time-per-item details backfill that runs in the enrichment worker's idle
loop (after the episode-sync pass). New `details_synced` marker column on shows+movies;
detail_backfill_next/mark_details_synced/pending_count; worker._detail_backfill_one()
re-fetches an already-matched item's TMDB details and gap-fills (never clobbers server
data), then marks it done so it's attempted once. No re-scan needed — it heals the
existing library in place, and once status is populated the airing-watchlist reflects
real TV.

It's a background gap-fill on already-matched items (like episode coverage), so it
doesn't block the worker's 'Complete' status. kettui: DB seam tests + worker tests
(fills status / enrich-by-id / marks-done-when-absent). ruff + isolation guards green;
94 enrichment tests pass.
2026-06-20 12:33:45 -07:00
BoulderBadgeDad
9a3ca6ba4e video downloads: header 'Auto' picks ONE best across all sources; fix YT playlist leak
Auto: replaced 'Manual all' + 'Auto all' (which fired auto on every source = up to
one download PER source = duplicate copies) with a single header 'Auto' button. It
searches every source, waits for them all to settle, compares the accepted+grabbable
hits across ALL sources by quality-profile score (tie-break on availability), and
grabs exactly ONE winner — the chosen row gets the auto ring + live tracker. Per-source
Manual/Auto buttons are unchanged.

Bug fix: viewing a YouTube channel populated the playlist section in the SHARED
show-detail DOM; opening a real movie/show afterward still showed those playlists,
because ytResetPlaylists() used the kind-scoped q() (pointing at the wrong root on a
movie load) and wasn't called on normal loads. Now it targets the show subpage
directly and runs from resetExtras() (every detail load), so stale playlists are
always cleared.

node --check clean; 20 wiring/regression tests green; all video-only.
2026-06-20 10:48:55 -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
1fa3e1b599 video automations: working builder + New Automation button (own builder, not music's)
The video automations page had no way to CREATE automations and its card cog did
nothing — it called the music global showAutomationBuilder(), which swaps views
inside the (hidden-on-video) music page, so the builder 'opened' on the music tab
instead. Now the video side has its own builder.

- index.html: the video automations subpage gets its own list-view + builder-view
  (vauto- prefixed ids) and a '+ New Automation' button, swapping exactly like the
  music page. Save/Cancel/Back reuse the shared builder functions.
- stats-automations.js: the builder is now context-aware. A builder context holds
  the element ids + blocks endpoint + owned_by + reload callback. Music context is
  the default and byte-identical (all 17 id lookups go through _bEl() resolving the
  music ids). showVideoAutomationBuilder() sets a video context (vauto- ids,
  /api/video/automations/blocks, owned_by='video'); editAutomation() routes the
  card cog to the right builder by active side. Opening clears BOTH builders'
  canvases so cfg-* ids can't collide. Save tags owned_by from context and calls
  the context's reload. A generic config_fields renderer/reader (video-gated so
  music keeps its bespoke renderers) drives video block config like the mode select.
- video-automations.js: exposes window._reloadVideoAutomations so a save refreshes
  the video list.

11 wiring tests (test_video_automations_builder.py); node --check clean; music
builder path unchanged.
2026-06-19 23:02:52 -07:00
BoulderBadgeDad
5d488bceb4 tests: isolate the VIDEO database too (never open the real video_library.db)
conftest already redirected the MUSIC DB to a tmp path after test writes once
corrupted a user's music library (WSL/NTFS + WAL). The VIDEO side had the SAME
hazard but no guard: VideoDatabase()/get_video_db() with no path resolve from
VIDEO_DATABASE_PATH, defaulting to the real database/video_library.db, and its
enrichment threads WRITE — a default-path open during tests corrupted the real
video library (one table's btree pages; recovered via row-by-row salvage).

Fix: set VIDEO_DATABASE_PATH to the same throwaway tmp dir at conftest import,
before anything loads. Adds guard tests proving VideoDatabase()/get_video_db()
never resolve to database/video_library.db (mirrors the music guards).
2026-06-19 23:02:38 -07:00
BoulderBadgeDad
40149d09f7 video automations: 'Scan Video Library' — the first video twin (shared engine)
The video side gets its OWN automations at music-side parity, kept separate so
nothing on the music side breaks. First twin: Scan Video Library — tells the media
server to rescan the user's SELECTED video sections (movies/TV, never music), then
reads the result into video.db so freshly-downloaded media shows as owned.

Architecture (scope tags + video twins on the shared engine):
- Handler core/automation/handlers/video_scan_library.py — pure function with
  injected I/O (server_refresh / run_video_scan); production lazily binds
  refresh_video_server_sections() + the video scanner. Owns its own progress.
  Lives on the SHARED automation side so it may import core.video (isolation only
  forbids core/video & api/video from importing music, not the reverse).
- blocks.py gains a 'scope' tag ('both' generic / 'video' video-only / absent=music)
  + blocks_for_scope(). The music /api/automations/blocks now filters out video
  blocks; new isolated /api/video/automations/blocks serves the video palette.
- automation_engine seeds 'Scan Video Library' (owned_by='video', schedule 6h) so
  it appears ONLY on the video Automations page; ensure_system_automations now
  honours owned_by + action_config. Music page excludes owned_by='video' rows.

kettui: seam-level tests for every handler path (happy/no-server/scan-error/never-
raises/mode), scope filtering (music excludes video, video gets generics, music
parity preserved), seeding (owned_by + mode), registration drift guard. 39 new
tests; full automation suite (288) + isolation guards green.
2026-06-19 19:38:18 -07:00
BoulderBadgeDad
13b30a5997 video sources: add refresh_sections() — tell the server to rescan VIDEO sections
The foundation for the 'Scan Video Library' automation (the video twin of music's
Scan Library). PlexVideoSource.refresh_sections() triggers a Plex scan on the selected
movie/TV sections (section.update()); JellyfinVideoSource.refresh_sections() POSTs
/Items/{id}/Refresh per selected video view (the GET-only _make_request can't POST).
Module helper refresh_video_server_sections() gets the active source and refreshes —
scoped to the user's chosen video libraries (Settings), so it scans the CORRECT media,
not music. Video-only, additive; isolation guard + (my code) ruff clean.
2026-06-19 19:03:51 -07:00
BoulderBadgeDad
3698ed1ec0 video Automations: hide the music system automations (show only owned_by='video')
Per Boulder — the music system automations target music resources and don't belong on
the video side. The page now filters to owned_by='video' (a tag the upcoming video
automations will carry); none exist yet, so the System section is hidden and the empty
state reads 'Video automations coming soon — separate from the music ones'. The hub +
layout stay (video hub content + video automations come next). Balance clean.
2026-06-19 18:29:02 -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
71d48f704d video Automations: reuse the music page's own builders for an exact match
Stopped hand-rolling the cards/section and now call the music page's GLOBAL builders
(_buildAutomationSection / renderAutomationCard / _buildAutomationHub from
stats-automations.js) — so the System section, every automation card, AND the
Automation Hub (Pipelines/Singles/Quick Start/Tips/Reference tabs) render byte-for-byte
identical to the music page. The System section uses a unique id (no clash) and is the
only thing re-rendered on refresh; the hub is built once (static). Run/toggle go through
the reused music card handlers; we re-sync the section after. Balance clean.
2026-06-19 18:13:53 -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
937f3094c6 video Downloads page: stop polling when off-page (incl. music side); 2s cadence
The active-downloads poll kept running in the background — the page-change event that
stops it only fires for video-side navigations, not when switching to the music side,
so /api/video/downloads/active was being hit forever. poll() now bails (and clears the
timer) whenever the Downloads page isn't on screen (data-side!=video or the subpage is
hidden). Also matched the music page's 2s active cadence (6s idle). Still HTTP polling,
same as the music downloads page (which polls /api/downloads/all every 2s — the app's
SocketIO is for other realtime, not the downloads list).
2026-06-19 17:28:45 -07:00
BoulderBadgeDad
3af3a1cd24 video downloads Phase C: auto-retry + alternate-query requery
When a grabbed release fails (transfer error / peer-cancel / never lands), the engine
now retries instead of giving up — the music-style depth:
- Grab stores the OTHER accepted results as a retry pool + the search context (schema
  v16: candidates / search_ctx / tried_queries / tried_files / attempts).
- core/video/retry.py (pure, tested): plan_retry() → try the next-best candidate; when
  the pool is dry, next_query() generates an ALTERNATE query (movie: drop the year; TV:
  numbering variants) to re-search; budget MAX_ATTEMPTS=6. merge_candidates dedupes
  against already-tried releases.
- Monitor: on failure, _fail_or_retry hops to the next candidate inline; if none, flips
  the row to a new 'searching' state and a background requery thread re-searches the
  alternate query, evaluates against the profile, and starts the best fresh hit — or
  marks failed once truly exhausted. 'searching' rows are owned by their thread.
- Page: 'Searching' status (Trying another release…) + a 'Nx' attempt badge.
16 tests (retry engine + candidate-retry transition); ruff clean on touched files.
2026-06-19 17:12:44 -07:00
BoulderBadgeDad
5661ccbfd2 video search: poll the FULL slskd timeout (~60s) + explain audio-only results
The poll capped at 32s but slskd results trickle in over ~50s (the music side waits
the whole search_timeout), so slow searches like 'Project Hail Mary' returned 'none'
before results landed. Now:
- /search/start returns poll_ms (slskd search_timeout + 8s); the UI polls that long
  (capped 80s), streaming results as they arrive, stopping early only once results
  clearly plateau (≥20s + stable) or hit 25.
- /search/poll returns total_files; when 0 video releases but slskd DID return files,
  the panel says 'returned N files, but none are video — likely audio/other for this
  title' instead of a blank 'none' (Soulseek is audio-heavy; many movie titles are
  audiobooks there). slskd_search.poll_search() returns {hits, total_files}.
Tests green, ruff + balance clean.
2026-06-19 16:55:53 -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
0bb77bf782 video downloads Phase A: cancelled state, cancel/retry, monitor robustness
Fixes the 'cancelled but still shows running' stuck bug and adds real depth:
- classify_state now distinguishes 'cancelled' from 'failed'.
- Monitor is robust to slskd forgetting a transfer: if it's gone, it first tries to
  complete from the FILE on disk (survives the music 'Clean Completed Downloads'
  auto-clear), else counts misses and fails the row after ~8 polls instead of hanging
  on 'downloading' forever. Cancelled transfers → cancelled status.
- POST /downloads/cancel (slskd DELETE transfer + mark cancelled) and /downloads/retry
  (re-grab the same release). get_video_download(id) added; clear includes cancelled.
process_download stays pure (fs/slskd injected); 15 tests, ruff + guard clean.
Phase B (page: tabs/queue/history/cancel+retry buttons) next.
2026-06-19 15:31:51 -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
cb56ee2bee video backfill: log the title (was 'None') — read item['title'] not ['name']
backfill_next returns rows keyed by title/kind/id/imdb_id (no 'name'), so the
'Enriched movie None via Trakt' spam was just the log line reading the wrong key.
Now logs the real title in the 'enriched' line, the fetch-failed line, and the
current-item status. Cosmetic only — the Trakt sweep itself was working correctly
(one-time backfill of the movie catalogue; each row marked once, never loops).
2026-06-19 15:12:02 -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
ceccb4ee65 video downloads: monitor thread + grab/active/clear endpoints
Phase 2 — the engine:
- core/video/download_monitor.py: a daemon thread polls slskd for active video
  downloads, updates progress, and on completion MOVES the file from the shared
  download folder into the per-type library folder + marks it completed. The
  per-download decision (process_download) is pure (fs + slskd injected) — 6 tests.
- POST /downloads/grab: validates (Soulseek-only v1), resolves the target library by
  kind, starts the slskd download, records the row, lazily starts the monitor.
- GET /downloads/active (list + ensure monitor running) · POST /downloads/clear
  (drop finished).
14 tests, isolation guard + ruff clean. Grab button + Downloads page next.
2026-06-19 14:53:18 -07:00
BoulderBadgeDad
9f687c061a video downloads pipeline: data + pure logic foundation
Phase 1 of the real grab→transfer pipeline:
- video.db video_downloads table (kind/title/release/source/username/filename/size/
  target_dir/dest_path/status/progress/error/timestamps) + CRUD (add/list/get_active/
  update/clear_finished). Schema v14.
- core/video/slskd_download.py: start_download (POST /transfers/downloads/{user}),
  list_downloads (GET → flattened), + pure classify_state (completed/failed/active),
  progress_pct, find_transfer.
- core/video/download_pipeline.py: pure find_completed_file (locate by basename in the
  download dir), dest_path_for, target_dir_for (kind → movies/tv/youtube library).

All filesystem/HTTP is injected or thin glue; 7 tests, isolation guard + ruff clean.
Monitor + grab endpoint + Downloads page next.
2026-06-19 14:50:27 -07:00
BoulderBadgeDad
84fac1f80a video downloads: share the INPUT folder with music; add libraries to compose
- Input/download folder is now the SAME shared config key the music side uses
  (soulseek.download_path via config_manager) — change it on either side and both
  follow, one physical download dir, simpler Docker mounts. Output libraries stay
  video-specific in video.db. ZERO music code touched (read/write the shared key only).
- docker-compose: documented the shared ./downloads input mount, and added the three
  video library OUTPUT mounts (./Movies→/media/movies, ./TV→/media/tv,
  ./YouTube→/media/youtube) matching the in-app placeholders.
- Test monkeypatches config_manager: asserts the input folder writes the shared
  soulseek.download_path (music sees it) and is NOT in video.db; libraries persist to
  video.db; legacy migration intact. ruff + guard + balance clean.
2026-06-19 14:40:22 -07:00
BoulderBadgeDad
89bedaf140 video downloads: split transfer folder into Movies / TV / YouTube libraries
One shared download (input) folder feeds three separate library (output) folders —
the engine routes a finished download to the one matching its type, so YouTube never
lands in your real TV library (own Plex/Jellyfin library + agent). Replaces the single
transfer_path with movies_path / tv_path / youtube_path (legacy transfer_path migrates
into Movies on first read). Settings UI: shared Download folder + 🎬 Movies / 📺 TV /
▶️ YouTube library inputs. Tests updated incl. the migration. ruff + balance clean.
2026-06-19 14:32:54 -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
305020f61e video search: REAL slskd search for the Soulseek source (was mocked)
The Soulseek ⌕ now actually queries your slskd instance instead of fabricating
results. core/video/slskd_search.py (isolated): build_query(scope,…) → POST
/api/v0/searches (shared soulseek.* URL+key) → poll /responses (bounded ~8s,
early-exit at 25) → keep video files → group_video_files() folds them into one hit
per release folder with a peer count + the fastest available source. Same
{title,size_bytes,…} shape, so parse→evaluate→rank/cards are unchanged.

Endpoint routes source=='soulseek' to slskd (torrent/usenet stay mocked), surfaces
'slskd not configured' / network errors, carries username/peers/slots/filename
through for the cards + a future real Grab, and caps to 40. Pure helpers tested (4);
isolation guard + ruff clean.
2026-06-19 14:16:44 -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
3490092e3d video search: each source returns its own results (was identical)
mock_search now takes the source and gives Soulseek / Torrent / Usenet distinct
hits — different release-group pools, seeder magnitudes, and which qualities show
up (Soulseek drops the top remux, Usenet drops the cam, etc.), the way real
indexers differ. Endpoint forwards body.source. Fixes 'click Torrent shows the
same results as Soulseek'. +1 test.
2026-06-19 13:56:04 -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
44b074772b video search: evaluate/rank pipeline + mock indexer + /downloads/search
Phase 2 of live search (indexer mocked, pipeline real):
- quality_eval.evaluate_release(parsed, profile, scope, want_season/episode, size_gb)
  → {accepted, score, rejected, tier, quality_label}. Sonarr-style: rejects junk
  sources/codecs/3D, requires an ENABLED ladder tier, honours HDR-require + size caps,
  and VALIDATES scope (episode wants SxxExx, season wants the whole-season PACK, show
  wants a complete-series pack, with season/episode-number checks). Scores keepers by
  resolution/source/codec-pref/HDR/audio/repack for ranking.
- mock_search.py: deterministic stand-in indexer returning scope-shaped raw hits
  (the single swap-point for real slskd/Prowlarr).
- POST /downloads/search: mock → parse → evaluate → rank (accepted, score, seeders).

16 tests, isolation guard + ruff clean. UI wiring next.
2026-06-19 13:39:09 -07:00
BoulderBadgeDad
958a5cd0a4 fix: video sidebar highlight needed two clicks (music chrome wiped it)
setActivePageChrome cleared .active from EVERY .nav-button — including the video
sidebar's — but only re-highlighted music buttons ([data-page]). On the first video
nav it ran (via navigateToPage) and wiped the video selection right after the video
side set it; the second click hit navigateToPage's same-page early-return so it
didn't run, which is why the highlight only 'stuck' on the second click.

Scope the clear to .nav-button[data-page] (music nav only). The video side owns its
own .nav-button[data-video-page] highlight via setActiveNav. Zero music-side impact
(all music nav buttons carry data-page).
2026-06-19 13:36:00 -07:00
BoulderBadgeDad
9b1d76b4ff video search: scene/p2p release-title parser (Sonarr-style)
core/video/release_parse.py (pure, isolated): parse_release(title) → resolution,
source (remux/bluray/web-dl/webrip/hdtv/dvd/cam/screener/workprint), codec, HDR,
audio, group, repack/proper/3d, and crucially the SCOPE — single episode (SxxExx)
vs season pack (Sxx / Season N) vs complete-series pack (S01-S05 / COMPLETE). The
search layer uses this to validate that a hit matches what was searched. 9 tests,
isolation guard + ruff clean. First piece of the live search pipeline.
2026-06-19 13:28:37 -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
e880be9910 video downloads: quality evaluation seam (owned-copy-vs-profile verdict)
The shared judge both the Download modal and the later-phase engine will use:
core/video/quality_eval.py (pure, isolated) — resolution_rank/resolution_label,
meets_cutoff (loose resolution target), and evaluate_owned(file, profile) →
{meets, resolution_label, reasons[]}. A copy is 'below target' when its resolution
is under the loose cutoff, or its codec is on the reject list.

Exposed as POST /api/video/downloads/evaluate (loads the stored profile, judges the
posted file). 9 tests green, isolation guard green, ruff clean.
2026-06-19 11:01:03 -07:00
BoulderBadgeDad
ddde6cba68 video downloads: separate YouTube quality profile (yt-dlp)
Boulder: 'youtube should have its own quality profile — there are not many options.'
Right — YouTube is grabbed with yt-dlp, not scene/p2p releases, so the Radarr-style
ladder/cutoff/rejects/HDR-audio tiers are meaningless. Added a small, separate profile:

  - core/video/youtube_quality.py (pure, isolated): max_resolution ceiling
    (best…360p), video_codec (any/av1/vp9/h264, soft), container (mp4/mkv/webm),
    prefer_60fps, allow_hdr. normalize/load/save → video.db youtube_quality_profile.
  - api/video/downloads.py: GET/POST /downloads/youtube-quality.
  - UI: a 'YouTube Quality' card on the Downloads tab (data-video-only) — resolution
    dropdown + codec/container segmented + 60fps/HDR checks, reusing the vq-* styles.
    Wired into onPageShown + the save-all chain.

The (later-phase) yt-dlp downloader maps these to a format/format_sort selection.
9 new tests green, isolation guard green, ruff clean, JS/HTML balance clean.
2026-06-19 10:45:38 -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