Commit graph

4412 commits

Author SHA1 Message Date
BoulderBadgeDad
ed2fbf2ae4 Video download history: API + beautiful timeline modal (phase 2)
GET /api/video/downloads/history (paged, ?kind/search/outcome) + /history/<id>, both
returning the live tab counts. New self-contained modal (video-download-history.js,
.vdh-* styles) opened from a History button on the Downloads page: day-grouped
timeline of every grab with poster, title, S/E, quality/resolution/codec/size and an
outcome badge; rows expand in place to reveal the full detail (release, source/uploader,
codecs, dest path, grabbed/finished times, error). Tabs (All/Movies/TV), search,
load-more, and a live count badge on the button.
2026-06-21 14:15:37 -07:00
BoulderBadgeDad
6fe82b2a78 Video download history: permanent archive snapshotted at terminal status (phase 1)
video_downloads is a transient queue (hard-deleted on cleanup), so there was no record
of what SoulSync actually grabbed. Add a permanent video_download_history table +
capture: the monitor snapshots every terminal download (completed/import_failed/
cancelled/failed) into it, with rich metadata (title, year, S/E from search_ctx,
release, source, size, quality + parsed resolution/codec, dest path, poster, outcome,
timestamps). Idempotent per (download_id, outcome, dest_path).

DB methods: record_download_history, query_download_history (paged/kind/search),
download_history_detail, download_history_counts, latest_completed_download(media_type)
— the last is the probe target for the upcoming smart post-download scan. Schema v17.
2026-06-21 14:10:22 -07:00
BoulderBadgeDad
a16afd1f9e Post-download scan: wait until Plex's scan queue is actually idle, not a fixed 2min
A fixed debounce can't fit a big library — 8500 movies + 4500 shows scan sequentially
through Plex's queue and can take 10-20 min, so the old 120s wait read the DB before
Plex finished and fresh downloads showed up late. Now Stage 1 (video_scan_server)
fires the rescan then POLLS the server until its scan queue goes idle, then emits the
done event.

- sources: PlexVideoSource.is_scanning (section.refreshing + activity feed, scoped by
  media_type) and JellyfinVideoSource.is_scanning (scheduled-task state), plus
  video_server_scan_in_progress() returning True/False/None.
- handler: pure wait_for_server_scan(scan_status, sleep, …) — grace, then poll every
  interval until idle or a generous cap; falls back to the fixed wait only when the
  server can't report status (None). debounce_seconds is now that fallback; new
  max_wait_minutes caps the poll.

Seam tests for the poll logic (idle/poll/fallback/cap/lost-status), the handler wiring,
and Plex scan-status detection.
2026-06-21 13:44:52 -07:00
BoulderBadgeDad
5a53ffc8c2 Video deep scan: pure read/reconcile, not a Plex disk-scan trigger
A deep scan is the equivalent of music's full refresh — it READS the server's
current state into video.db and prunes what's gone. It should NOT tell Plex to
rescan its disk. The deep-scan action types were wired to auto_video_scan_library
(nudge Plex + read); point them at the read-only auto_video_update_database in
'deep' mode instead. Update-db phase wording no longer says "new" for a full re-read;
deep-scan block descriptions clarify it's a read, not a disk-scan. Registration test
asserts the deep scans route to the read-only handler and never nudge the server.
2026-06-21 13:27:55 -07:00
BoulderBadgeDad
14a32f6006 Video scan family: make every action movie/TV-aware + deep scans are real actions
The deep-scan action types weren't selectable builder actions, and Scan Video Server
/ Update Video Database had no movie-vs-TV dimension — inconsistent with the rest.

- video_deep_scan_tv / video_deep_scan_movies are now proper builder blocks
  (Deep Scan TV/Movie Library), not just system-automation action types.
- video_scan_server + video_update_database gain a media_type ('all'|'movie'|'show')
  config + selector, threaded through. The post-download chain carries the scope on
  the scan-done event, so a TV-only rescan updates only TV (stage 2 inherits it).
- refresh_video_server_sections / Plex+Jellyfin refresh_sections scope the server
  nudge to the chosen library; auto_video_scan_library now nudges only its library.
- shared normalize_media_type() in sources; update_database skips cleanly when the
  singleton scanner is busy. Defaults stay 'all' so existing chains are unchanged.

Seam tests for refresh scoping, scan-server scope+event, update-db scope/inherit/skip.
2026-06-21 13:04:39 -07:00
BoulderBadgeDad
cf47032660 Video deep scans: fixed weekly times (TV Mon 2am, Movies Tue 2am)
Switch the two deep-scan system automations from a rolling 7-day interval to
weekly_time at 02:00 server-local — TV Mondays, Movies Tuesdays. Different days
means they never overlap, and a fixed wall-clock time doesn't drift with restarts.
Drop initial_delay (the seeder arms timed system triggers). _fix_deep_scan_schedules
migrates the original interval rows to the weekly schedule (the seeder only creates
rows, never updates a drifted trigger); it skips once trigger_type is weekly_time so
a hand-tuned day/time sticks. Idempotent.
2026-06-21 12:39:53 -07:00
BoulderBadgeDad
b6320d1a30 Video: two system deep-scan automations (Movie + TV), independently scoped
Video twin of music's 'Auto-Deep Scan Library', split in two because Movies and TV
are separate libraries — scanning the TV library must not pull in new movies and
vice-versa.

- scanner: add a media_type param ('all'|'movie'|'show', friendly aliases) that
  gates the movies vs shows passes (and their pruning), plus an in_progress busy
  guard so the singleton scanner can't be stomped by an overlapping run.
- video_scan_library handler: thread media_type through, skip cleanly when the
  scanner is busy, and name only the scanned library in the summary.
- two system automations (owned_by=video, weekly deep scan, staggered start delays):
  'Auto-Deep Scan Movie Library' + 'Auto-Deep Scan TV Library'. Distinct action
  types (video_deep_scan_movies / _tv) because the seeder keys on action_type; both
  reuse the one handler, scoped via action_config.
- builder block gains a Library selector (Movies+TV / Movies / TV) so custom scans
  can scope too; card label/icon maps cover the video action types.

Seam tests for scanner scope + busy guard, handler scope + skip, registration set.
2026-06-21 12:14:46 -07:00
BoulderBadgeDad
36f944be25 Video automations: make the builder sidebar scrollable like the music side
The Automations page reuses the music builder, whose .automations-builder-view is
height:100% so its trigger/action sidebar + canvas scroll independently. On music it
fills #automations-page (a .page at height:100%); on video it sat in a .video-subpage
with no height, so height:100% collapsed to content height and the sidebar grew
instead of scrolling (looked unformatted). #video-page-host is itself a .page, so
give just the automations subpage a definite height to resolve the chain. Scoped to
automations so every other video page keeps its natural document-flow scroll.
2026-06-21 11:55:42 -07:00
BoulderBadgeDad
0514931140 Auto-wishlist airing: run at a fixed daily 1am, not a rolling 24h interval
The job shipped as a 24h 'schedule' because the system-automation seeder only armed
next_run for interval specs — a 'daily_time' spec sat idle and never fired. The
interval fired reliably but drifted with every restart (5min after startup, then
+24h) instead of a fixed wall-clock time, which is worse for 'today's airings' (you
want it queued overnight).

Fix, the robust way:
- Seeder now arms timed system triggers (daily/weekly/monthly) via next_run_at, not
  just interval ones. Event-based triggers still return None and are left alone.
- Spec -> daily_time {time:'01:00'} for fresh installs.
- _fix_airing_automation_schedule migrates the existing 24h-interval row to daily
  01:00 (the seeder only creates rows, never updates a drifted trigger). Idempotent.

_finish_run already reschedules daily_time to the next 1am, so it stays pinned.
2026-06-21 11:41:34 -07:00
BoulderBadgeDad
8053bf339c Video wishlist + watchlist: restyle toolbar controls to the video-side standard
The Movies/TV/YouTube (and Shows/People/Channels) tabs, search bar, sort select and
clear-all read as generic dark glass. Align them to the video side's polished
language: selected tab now lights up with an accent outline + ring glow (the same
focus treatment as the search field) instead of a filled accent block; search is a
focus-ring shell with an accent icon; sort drops the native OS arrow for a custom
chevron; every control shares one 42px height + 12px radius + accent-ring focus.
Same treatment applied to the watchlist page so the two match.
2026-06-21 11:05:28 -07:00
BoulderBadgeDad
c35f56bffa Video auto-wishlist: store the show poster too (last field the manual add had)
Field-by-field against the working manual 'add to wishlist', the automation now
matches it on every column EXCEPT the show poster: the get-modal stores
poster_url = '/api/video/poster/show/<library_id>', the automation stored None — so
the wishlist orb fell back to the show's initials and read as 'not matched'. Carry
the same proxy path. With library_id (last commit) + poster_url (this) + the
tmdb_season stills/overviews, an auto-added row is now identical to a manual one.
2026-06-21 10:27:01 -07:00
BoulderBadgeDad
94e06f2d50 Video auto-wishlist: store the show library_id (fixes 'show not matched')
The real difference from a manual add: the wishlist resolves a show's synopsis +
cast from /api/video/detail/show/<library_id>, and falls back to the TMDB endpoint
only when library_id is absent (which redirects/lacks cast for owned shows). A
manual add sends show.library_id; the automation sent none — so auto-added shows
read as 'not matched' with no synopsis/actors. The handler now carries the show's
library id (the calendar's show_id) through to the wishlist.
2026-06-21 10:11:13 -07:00
BoulderBadgeDad
d925c34ce5 Video auto-wishlist: schedule the automation so it actually fires
The system automation used trigger_type 'daily_time' with no initial_delay, but
the seeder only arms a next_run when a spec has initial_delay (and
_calc_delay_seconds doesn't parse a daily_time clock anyway) — so it registered
as 'event-based' and never auto-ran; it only fired when triggered by hand.
Switched to the proven scheduled pattern (24h 'schedule' + initial_delay, like
Auto-Scan Watchlist) so it runs once a day on its own.
2026-06-21 09:53:28 -07:00
BoulderBadgeDad
ae68750d9e Video auto-wishlist: pull episode metadata from TMDB, like a manual add
Root cause of the metadata loss: a MANUAL 'add to wishlist' gets its episode
data from the TMDB season fetch (engine.tmdb_season — absolute still, overview,
season poster), while the automation read the local DB episodes table, where
stills are frequently empty/Plex-relative. So auto-added episodes came in blank
even after carrying the DB values.

The handler now fetches the SAME TMDB season metadata (cached per season,
injected for tests) and prefers it, falling back to the calendar/DB values if
TMDB is unavailable. Auto-added episodes now match manual ones.
2026-06-21 09:41:12 -07:00
BoulderBadgeDad
cc2fb2ff79 Video auto-wishlist: carry episode synopsis + still into the wishlist
Auto-added airing episodes came in metadata-empty (no synopsis, no still) — the
handler only passed season/episode/title/air_date, dropping the overview the
calendar already returns and never fetching the still URL (calendar_upcoming
only returned a has_still flag, not the URL). Now calendar_upcoming also returns
e.still_url, and the handler carries overview + still_url through. The wishlist
renders the (Plex-relative) still via the same pimg() proxy as the show poster,
so it resolves. Idempotent upsert backfills the already-added empty rows on the
next run.
2026-06-21 09:20:27 -07:00
BoulderBadgeDad
fd66390648 Video automations: reliably delete the obsolete 'Scan Video Library' seed
_fix_video_scan_default set its 'done' flag even on runs where it deleted
nothing, so once the flag latched True the standalone 'Scan Video Library'
system automation survived forever (the row the post-download chain replaced).
Drop the flag entirely — get_system_automation_by_action already matches only
the is_system-seeded row, so the cleanup is safe to run every startup and
no-ops once the row is gone.
2026-06-21 08:59:32 -07:00
BoulderBadgeDad
5711963a6f Video watchlist: strip dirty titles when sorting (leading-space fix)
'Holy Marvels with Dennis Quaid' is stored with a leading space in the shows
table, so the watchlist sort key fell to ' holy marvels…' — and a leading space
sorts before 'a', jumping it to the top. .strip() the sort key so dirty titles
sort by their real first letter.
2026-06-21 08:52:49 -07:00
BoulderBadgeDad
49714a59ea Video watchlist: sort alphabetically by name by default
The default sort put manual follows first (newest date_added), then airing
shows A–Z — so recently-followed shows like 'Welcome to Widows Bay' and 'From'
jumped to the top. A manual follow is no more special than an auto-added airing
show; default now sorts everything by name A–Z. 'added' (newest first) stays as
an opt-in sort.
2026-06-21 08:47:59 -07:00
BoulderBadgeDad
01c101d24a Video: live per-episode download tracking + auto-wishlist today's airings
Two Sonarr-parity features.

1) Per-episode live tracking. "Grab season" was headless (only a button label
   changed); episode rows had a status span that was never populated. Now every
   episode ROW shows its own live state — Searching → Downloading % → Downloaded
   / Failed — via epTrack() polling /downloads/status?id, matching the inline
   movie tracker. Grab season lights all target rows at once; manual + per-source
   auto grabs also light their row; reopening the modal resumes tracking in-flight
   episodes (resumeEpisodeTracking via /downloads/active + search_ctx match).
   Season batch grabs through the same payload as a manual grab (_pickAndGrab →
   sendGrab(buildGrabPayload)).

2) Auto-wishlist airing episodes. New daily automation (video_add_airing_episodes):
   reads the calendar for episodes airing TODAY for followed shows, skips owned
   ones, adds the rest to the wishlist (idempotent). Handler uses injected seams
   (calendar read + wishlist write) so it's unit-tested without a DB/server.
   Registered + action block + seeded as a daily system automation (01:00),
   owned_by=video.
2026-06-21 02:27:08 -07:00
BoulderBadgeDad
a52dda6a7f Video quality: recognize plain WEB + accept resolution-only releases
Two over-rejections that filtered out legit releases as "unknown quality":
- the source parser only matched WEB-DL/WEBDL, not plain "WEB" (very common)
- a release with a known resolution but no recognized source had no tier

Now plain WEB parses as web-dl, and a resolution-only release assumes web so
it lands on a tier instead of being rejected (ffprobe verifies the real quality
after download). Truly quality-less packs still reject. Tests added.
2026-06-21 02:11:40 -07:00
BoulderBadgeDad
efa64db04d Video downloads: best-in-class post-process pipeline
End-to-end import for video grabs, mirroring the music side's rigor and the
Radarr/Sonarr standard, fully isolated in core/video + api/video.

- Importer: parse release -> ffprobe-verify (true resolution, reject corrupt/
  samples) -> templated rename into Movie (Year)/ + Show/Season NN/ -> copy or
  move, carry subtitles, upgrade-replace a worse copy.
- Library Organization settings: editable $token path templates + toggles
  (transfer mode, verify, replace, carry subs, save artwork, write NFO,
  download subtitles + langs). Stored in video.db; matches the music File
  Organization section's look.
- Sidecar writer: movie.nfo / tvshow.nfo + full artwork set (poster, fanart,
  clearlogo, season posters) from on-demand TMDB detail, and external .srt from
  OpenSubtitles. Owned re-grabs resolve their library tmdb_id; tmdb_full_detail
  bypasses the owned->library redirect so they enrich too.
- Import page: surfaces import_failed downloads, resolve by hand (library-first
  -> TMDB picker -> force-place) or dismiss; fires a library refresh on place.
- "Grab whole season": episode-level batch (reuses searchInto + _autoPick).
- Brutalist redesign of the download modal sources + result cards.

All new logic has seam-level tests (pure parsers/planners + injected I/O);
sidecars/subtitles are best-effort and never break an import.
2026-06-21 01:44:35 -07:00
BoulderBadgeDad
366b5a266d video download modal: cinematic redesign of sources + search results
Reworked the whole sources/results area for a more premium, Netflix-y feel.

Results — flat divider list → real CARDS: a bold cinematic quality badge (resolution
over source), the release name as the hero, the meta as a row of crisp PILLS (codec /
audio / HDR / repack / group) plus a clean availability stat, then size · verdict · Get
grouped on the right. Cards have depth, a hover-lift, an accent edge on accepted
releases, and the auto/grabbed pick lights up with an accent ring + glow.

Sources — toned the full per-source colour wash down to a sophisticated dark-glass row
with the brand colour as an ACCENT only (left-edge light that extends on hover + the
glassy icon tile), so it reads calm and designed instead of busy.

All functional hooks preserved (data-vdl-card/grab, .vdl-res-main tracker dock, status
states, auto-pick). node --check + CSS balance clean; tracking/auto wiring tests green.
2026-06-20 16:57:09 -07:00
BoulderBadgeDad
9bb6c4ebd0 video wishlist: add a 'Clear all' button (movies / TV / YouTube)
The wishlist page had no way to empty a tab — only per-item remove. Added a red-tinted
'Clear all' button in the toolbar that empties the ACTIVE tab in one click (after a
confirm), shown only when that tab has items.

- db.clear_wishlist(kind) maps the tab to its rows (movie→'movie', show→'episode',
  youtube→'video') and deletes them; returns the count.
- POST /api/video/wishlist/clear {kind: movie|show|youtube}.
- Toolbar button + clearAll() (confirm → clear → reload) + updateClearBtn() visibility.

Tests: per-tab clear leaves the others intact, unknown-kind/empty no-ops, the endpoint,
and the frontend wiring. node --check clean.
2026-06-20 16:42:57 -07:00
BoulderBadgeDad
21e1944784 video scan: only FULL resets enrichment; incremental/deep preserve it
A scan wrote server-provided fields straight over the row, so an incremental/deep
re-read wiped the TMDB-backfilled 'status' (Plex returns it blank) — clearing the
airing watchlist. Now matches the intended model: incremental (add recent) and deep
(coverage + prune) PRESERVE enrichment-owned fields the server left blank; only a FULL
scan clobbers them (an explicit reset / fresh start).

- _resilient_upsert gains preserve_enrichment (default True): on a conflict UPDATE,
  enrichment-owned columns (per _ENRICH_META_COLS: status/network/ratings/air dates/…)
  take the server value only when non-blank, else keep what's stored. A real server
  value still wins.
- upsert_movie/upsert_show_tree thread the flag; scanner passes preserve=(mode!='full').

Tests: preserve-on-blank, server-value-wins, full-resets, and the scanner picking the
right mode. 88 DB + 18 scanner tests + isolation green.
2026-06-20 15:06:44 -07:00
BoulderBadgeDad
bdcd929558 video automations: remove the standalone 'Scan Video Library' system automation
Superseded by the post-download chain (Auto-Scan Video After Downloads → Auto-Update
Video Database After Scan), which keeps the library fresh without a separate scheduled
scan. Two small changes: drop it from SYSTEM_AUTOMATIONS (no longer seeded), and a
one-time flag-guarded cleanup (_fix_video_scan_default, v3) that DELETES the existing
system row — so it actually stops running, not just hidden. The video_scan_library
action/handler/block stay, so a custom scan automation can still be built later.
Engine seed test updated. 66 automation tests pass.
2026-06-20 14:55:34 -07:00
BoulderBadgeDad
91eae710b4 video automations: post-download scan chain (parity with music's batch→scan→update)
Mirrors the music flow: a finished download batch → refresh the media server → (after
it indexes) pull the new media into the DB — so a downloaded movie/episode shows as
owned without waiting for the 6h scheduled scan.

Two event-based system automations (owned_by='video'):
  - 'Auto-Scan Video After Downloads' (video_batch_complete → video_scan_server)
  - 'Auto-Update Video Database After Scan' (video_library_scan_completed → video_update_database)

Pieces:
- core/video/download_events.py: a callback registry (core/video can't import the
  engine — isolation). The monitor publishes batch-complete; web_server bridges it to
  automation_engine.emit('video_batch_complete', …), like music's web_scan_manager.
- download_monitor: fires the batch-complete event once, when the last in-flight
  download finishes (none queued/downloading/searching left).
- video_scan_server handler (stage 1): refresh server, wait a debounce for indexing,
  then emit 'video_library_scan_completed' (mirrors music's time-based completion).
- video_update_database handler (stage 2): incremental read (newest-first, stop after
  25 consecutive known — same as music).
- blocks: 2 video triggers + 2 video actions (scope='video'); registration; seeds.

kettui: seam tests for both handlers (refresh/wait/emit, incremental read, error paths),
the event registry (idempotent register, isolated failures), and the monitor (fires once
on last completion, never while work remains). 298 automation tests + isolation green.
2026-06-20 14:47:00 -07:00
BoulderBadgeDad
297709baa4 video scan automation: incremental + schedule-only (no startup-proximate full scan)
The scheduled 'Scan Video Library' defaulted to a FULL scan (re-reads everything +
prunes) that fired ~5 min after every app start. A recurring scan should be light:
switched the seed to INCREMENTAL (newest-only, no prune) and pushed the first run a
full interval out so it runs on its 6h cadence rather than right after startup.
One-time migration (_fix_video_scan_default, flag-guarded) corrects existing rows that
still carry the old full+startup default, without overriding a deliberate user choice.
2026-06-20 14:34:14 -07:00
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
49222dd0b8 video calendar: watchlist-driven by default + an 'All library' toggle
The calendar pulled every airing show in the library regardless of whether you
follow it. Now it's scoped to the EFFECTIVE watchlist by default — explicit show
follows ∪ airing library shows (not muted), same logic as the Shows watchlist tab —
so it tracks what you actually care about, and you can mute a show off it. A
'Watchlist / All library' toggle on the calendar lets you flip to everything you
own (remembered in localStorage).

- calendar_upcoming(watchlist_only=) adds the watchlist filter; /calendar takes
  ?scope=watchlist|all (default watchlist).
- Calendar page gets a scope toggle (defaults watchlist, persists, refetches on
  change).

Tests: DB scope (followed-only / airing-default / mute drops out / all-library sees
all) + frontend wiring. node --check clean.
2026-06-20 14:07:40 -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
b23a3e91d8 video scan: show a 'cleaning up' phase during the deep-scan prune
On a deep scan the progress bar hits 100% when the last item is read, but the prune
(delete orphaned rows + cascades) runs AFTER that and — on a big cleanup — takes a
few seconds, during which the scan still reads as running (can't start a new one,
workers still paused). Looked stuck at 100%. Now the scanner sets a 'cleaning up
removed movies/shows' phase around the prune so the UI shows it's finalizing, not
frozen. Test spies the phase at prune time. 18 scanner tests pass.
2026-06-20 13:44:35 -07:00
BoulderBadgeDad
7cd289e7b5 video scan: harden library scope + pause ALL enrichers during any scan
1) Scan only the MAPPED libraries — never fall back to 'all'. The scan path used
   _sections/_views with the selected name, but an empty name returned ALL sections
   of that type — so a missing/unreadable selection silently scanned every library
   (how the 4K movie + 'YouTube' TV libraries leaked in as movies/shows). New
   _scan_sections / _scan_views return [] when a kind isn't mapped; available_libraries
   still lists all (for the Settings dropdown). Now an unmapped kind scans NOTHING.

2) A library scan (full/incremental/deep) now pauses EVERY enricher, including the
   YouTube date enricher — a separate singleton outside engine.workers that kept
   running through scans. pause_for_scan/resume_after_scan pause+resume it too, only
   if it wasn't already manually paused (never override the user).

kettui: source-scope tests (mapped-only / unmapped-scans-nothing / listing still
shows all) + engine pause tests (pauses+resumes YT / preserves manual pause).
123 scanner/source/enrichment tests + isolation guard green.
2026-06-20 13:24:57 -07:00
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