Commit graph

2141 commits

Author SHA1 Message Date
BoulderBadgeDad
8fe73ac924 video: longer adaptive slskd wait + stop the automations page blink
two issues:
1. slskd search gave up at 22s (then stopped the search), but slskd gathers peers
   over its full ~60s window — so most movie searches were killed before results
   arrived. now waits up to 55s but returns early once results SETTLE (12+ hits, or
   no new hits for ~12s) so fast searches don't burn the whole window. matches the
   music side's longer wait.
2. the automations page re-rendered the WHOLE system section every 8s poll
   (replaceChild) → blink + wiped the socket's live progress. now it skips the
   rebuild unless something structural changed (added/removed/toggled/ran); live
   progress comes via socket, the countdown ticks locally.
2026-06-26 09:54:18 -07:00
BoulderBadgeDad
b55c245fe6 video downloads: re-download + clear history actions
after you delete a file, the scans dedup against download history so it won't
re-grab — this gives you the escape hatch:
- per-row 'Re-download' button in the History modal: forgets that grab (DELETE
  /downloads/history/<id>) so the next scan re-adds + re-downloads it.
- 'Clear' button: wipes the whole history (guarded confirm).
db.delete_download_history / clear_download_history(kind?) + the two endpoints.
tested (db methods + endpoints).
2026-06-26 09:44:56 -07:00
BoulderBadgeDad
f0e1fda0fc video wishlist badge: poll so it reflects server-side removals (downloads)
the remaining badge bug: when a download finishes it removes its item from the
wishlist SERVER-SIDE, which fires no frontend 'changed' event — so the badge went
stale and only 'jumped' (e.g. down to the tv-only count) on the next navigation.

add a lightweight badge poll: refreshes the authoritative /wishlist/counts every
8s while downloads are active (uses the downloads page's _vdpgAnyActive), every
30s idle, paused when the tab is hidden. now the count tracks down live as wishlist
items get downloaded. frontend add/remove events still fire instantly as before.
2026-06-26 00:19:54 -07:00
BoulderBadgeDad
1722618c6d video wishlist badge: stop the second writer clobbering it with the TV-only count
the other half of the bug: the endpoint total was fixed, but setCounts() (movie/
episode load) AND setYtCounts() (youtube load) ALSO wrote the nav badge from their
OWN partial state. these load separately, and on the dashboard only one runs — so
whichever fired last overwrote the badge with a partial count (correct number,
then 'switches' to TV-only).

fix: the /wishlist/counts endpoint is the single source of truth for the grand
total; both setters now just call refreshBadge() to (re)sync from it instead of
computing a partial sum. regression test pins it.
2026-06-25 23:21:59 -07:00
BoulderBadgeDad
23c64e000a youtube playlists: same settings cog/modal as channels
playlist cards on the watchlist tab now get the same hover cog → the per-channel
settings modal (custom show-name + quality override). the modal is kind-aware so
it reads 'Playlist settings' + 'the playlist's real name'.

no backend change needed: playlist videos are wishlisted with channel_id =
playlist_id, so the existing get/set_channel_settings + enqueue override lookup
already key off the playlist id. UI-only: cog on playlistCard + data-kind passed
to openChannelSettings(id, title, kind). test covers both cogs.
2026-06-25 23:07:15 -07:00
BoulderBadgeDad
f734905ba1 youtube channels: per-channel settings modal UI (cog on the Channels tab)
hover a channel on the watchlist Channels tab -> a settings cog appears (mirror
of the unfollow x) -> opens a self-contained modal:
- Show name (folder): overrides the $channel folder/show-name token; blank uses
  the real channel name.
- 'Force a specific quality' toggle -> resolution/codec/container/60fps/HDR
  override; off = use the global youtube quality from Settings.

VideoYoutube.openChannelSettings(id, title) builds the modal, loads via GET, saves
via POST /youtube/channel/<id>/settings. .vyt-cset-* CSS. string-contract test.

(note: 10 pre-existing soundcloud music-orchestrator test failures are unrelated
to this work — they fail on a clean HEAD too.)
2026-06-25 23:01:35 -07:00
BoulderBadgeDad
846d217dca video automations: order the System list as a logical pipeline
the API returns system automations newest-created-first, so a re-seeded row
(e.g. the just-migrated movie/episode processors) jumps to the top and jumbles
the grouping. the video page now re-sorts its System list by an explicit order:
scans (fill) → processors (drain) → library scan/sync → maintenance. scoped to
the video page only (music ordering untouched); unknown/future actions fall to
the end keeping API order. string-contract test pins the scan<proc<maint order.
2026-06-25 22:38:26 -07:00
BoulderBadgeDad
7d7d242e5d video automations: rename Download->Process + group into a clear pipeline
- 'Download X Wishlist' -> 'Process X Wishlist' everywhere (label + action_type):
  matches the music side's 'process_wishlist', the already-process youtube action,
  and reads right (movie/episode do search+pick+download, not just download).
  action_types video_download_movie/episode_wishlist -> video_process_*.
- group the new automations as a two-stage pipeline in the seed list + builder
  palette: Stage 1 SCANS (people/channels/playlists) fill the wishlist, Stage 2
  PROCESSORS (movie/episode/youtube) drain it. icons/labels/drift test updated.

brand-new this session so no migration needed. 337 automation tests green.
2026-06-25 22:26:31 -07:00
BoulderBadgeDad
b404420c76 video automations: auto-download movie + episode wishlist (soulseek)
the soulseek counterpart of the youtube drain — finally makes the people/airing
scans pay off. two automations (Auto-Download Movie / Episode Wishlist, hourly):
for each wished+released item, do a bounded blocking slskd search, pick the top
ACCEPTED release per the quality profile, and enqueue it exactly like a manual
grab (same add_video_download shape → the monitor finishes + organises it).

same standard as youtube: processes the WHOLE eligible wishlist (no total cap)
but searches a few at a time (max_concurrent, default 3, via a thread pool). a
busy guard skips the next hourly tick while a drain is still working. movies gate
on status='wanted' (skips monitored); episodes are all-wished. items already
downloading are skipped. quiet skip if the library folder isn't set.

reuses the real infra (build_query/slskd_search/_evaluate_hits/start_download/
download_monitor) — pure pick/select/record seam-tested; db queries added. 377
automation+video tests green.
2026-06-25 22:08:02 -07:00
BoulderBadgeDad
fb9459fb5c video automations: scan watchlist YouTube playlists (mirror-all)
playlists are followable but had no scan. new 'Scan Watchlist Playlists'
automation, sibling of the channel scan but a different rule: a playlist is a
curated finite set, so MIRROR it — wishlist every long-form video you don't have
plus any later additions (no forward-looking baseline, no last-N net).

playlist-as-show: videos wishlisted under the playlist's title, so the worker
files them as 'Playlist Name / Season YEAR / ... - date - title' (the ytdl-sub
tv_show_name-on-a-playlist convention). reuses all the channel plumbing — same
wishlist rows, same Download YouTube Wishlist drain, quality + org template.

seeded every 6h (Auto-Scan Watchlist Playlists); block + registration + icon +
drift test. dedup reuses wishlisted_video_ids_for_channel (parent_source_id=PL).
seam-tested; 944-test sweep green.
2026-06-25 21:18:53 -07:00
BoulderBadgeDad
3254e8a6c4 settings: youtube path template field on the Library tab
surface the youtube_template (built into organization.py) on Settings -> Library
Organization, next to the movie/episode templates: input + variable hints
($channel/$year/$date/$month/$day/$title/$videoid) + live preview, wired into
load/collect/save/reset. backend already round-trips it (load/save normalize).
2026-06-25 19:40:56 -07:00
BoulderBadgeDad
c47dccc7d7 youtube fulfillment: drain wishlist -> download queue
new 'Download YouTube Wishlist' automation: pushes a polite batch of wished
youtube videos into the shared video_downloads queue + spawns the yt-dlp worker
per video. skips in-flight ones (no double-grab); big backlogs drain over
several scheduled runs (batch_size, default 3). needs the youtube library folder
set.

- download_monitor: SKIP source='youtube' rows (owned by their worker thread, no
  slskd transfer to match) — surgical, slskd path untouched.
- db.youtube_wishlist_to_download(): flat newest-first list of wished videos with
  channel/title/date/thumb for organising.

block + registration + icon/label + drift test. all seam-tested.
2026-06-25 19:30:02 -07:00
BoulderBadgeDad
31d284ab0d video automations: watchlist scans for people + channels
two new video-side automation blocks that keep the wishlist fed:

- scan watchlist people: for each followed person, wishlist every un-owned
  movie they acted in or directed (back catalog + upcoming). released ->
  wanted, upcoming -> monitored (engine skips it til it's out, promotes on
  release). grabs rich detail at add time (backdrop/cast/overview/etc +
  provenance) into a new video_wishlist.detail_json col. fast re-runs skip
  already-wishlisted + only promote.

- scan watchlist channels: for each followed youtube channel, wishlist new
  long-form uploads (shorts excluded). forward-looking from follow time +
  a last-N safety net (default 10). diffs against wishlisted/downloaded/
  dismissed so it never dupes. pair with a 6h schedule trigger. scan-only;
  fulfillment engine comes later.

both are pure handlers with injected seams + full seam tests. add_movie_to_
wishlist gains status + detail_json (promote-only upsert). no schema break,
music side untouched.
2026-06-25 19:07:06 -07:00
BoulderBadgeDad
877a86c9b0 Video Discover: split Top 10 today into Movies + TV Shows (Netflix-style)
The mixed /trending/all chart is movie-heavy — TV was nearly absent (only
1 of the top 10 was a show). Split it like Netflix: 'Top 10 today' now holds
two ranked rails, Movies and TV Shows, from the dedicated /trending/movie/day
and /trending/tv/day charts (full 10 of each).

- client.trending(window, kind): single-type charts force the kind into
  _disc_map (those endpoints omit media_type).
- engine.trending(window, kind): kind in the cache key.
- /discover/list: key=trending_movies_today / trending_tv_today, both treated
  as single un-paged charts; inherit the same hide-owned + ranked rendering.
- frontend: one 'Top 10 today' group, two ranked shelves titled Movies / TV
  Shows (group header carries the 'Top 10' framing).
2026-06-24 08:00:13 -07:00
BoulderBadgeDad
e7552d3c62 Video Discover: fix orphaned Top 10 numerals when hiding owned + lift 'New' up the page
Bug: with 'Hide owned' on, an owned title in the Top 10 (e.g. House of the
Dragon) had its card hidden by the global .vdsc-hide-owned rule while its big
rank numeral stayed — a blank gap with just a number. The ranked rail fetched
the true chart (ignoring hide_owned) precisely to keep the chart intact, which
collided with that CSS. Fix: ranked rail now honours hide_owned at fetch time
(owned dropped server-side, ranks stay contiguous 1-N), plus a CSS safety net
exempting ranked cards from the hide rule so a numeral can never be stranded.
When hide-owned is OFF the true chart still shows owned with the 'In Library'
ribbon.

Ordering: moved 'New & noteworthy' above the async collection/taste groups so
the two always-visible discovery rows (Top 10 + New) anchor the top of the
page, matching how streaming services surface new releases.
2026-06-24 07:33:58 -07:00
BoulderBadgeDad
24b79dc29e Video Discover: sticky 'jump to section' nav for the deep rail stack
The page is now a long stack of rail groups (For you / Top 10 / New /
Trending / Mood / Studios / Genres / More). A sticky chip bar lets you
jump straight to any section — Netflix/Disney-style category nav. Chips
stay in lockstep with the groups: async-only groups (foryou/collection/
taste) reveal their chip when filled; pruned groups hide theirs. Lives
inside the shelves host so it auto-hides in the Browse-all grid view.
2026-06-23 23:58:53 -07:00
BoulderBadgeDad
a275cbfb7a Video Discover: Top 10 ranked rail + New/Mood/Studios sections + NEW badge
Best-in-class discovery pass (Netflix/Disney-class):
- iconic ranked 'Top 10 today' rail (daily trending, big outlined rank
  numerals, true global chart via lang=any, single un-paged fetch)
- 'New & noteworthy' (date-windowed new releases + theatrical pipeline)
- 'By mood' (genre AND-combos w/ vote floors: feel-good, mind-bending…)
- 'From the studios' (Pixar/Ghibli/A24/Marvel/DreamWorks via company ids)
- 'Something different' (quick watches / epics / family) merged with gems
- 'NEW' flag on un-owned current-year cards
Preserves async-loader group ids (foryou/collection/taste). Empty rails
self-prune; wrong studio ids just drop their rail.
2026-06-23 23:55:30 -07:00
BoulderBadgeDad
82cbc22577 video discover: organise rails into fixed, labelled sections (stable order)
The three async personalized loaders each did insertAdjacentHTML('afterbegin'), so 'Recommended
for you' / 'More like X' / gap rails landed in whatever order their fetch finished — the top of
the page reshuffled every load, and taste-based rails were scattered through generic ones.

Now Discover renders 6 authored groups in a fixed order, each with a header:
  For you · Finish your collection · More of what you like · Trending & popular ·
  Browse by genre · Hidden gems & more
Each async loader fills its own group's body (deterministic position) instead of racing to the
top; async-only groups (gaps) stay hidden until filled, and a group whose rails all drop out is
pruned. Extracted lazyShelfHtml/filledShelfHtml/shelfNav helpers; lazy-load, stagger, gen-guard,
see-all and scroll arrows all unchanged.
2026-06-23 10:39:41 -07:00
BoulderBadgeDad
4f24ac2733 video discover: stop duplicate 'Recommended for you' rows stacking on rail rebuild
Toggling service/language chips calls reloadRails(), which clears the shelves synchronously
but the personalized loaders (foryou/gaps/morelike) fetch async and insertAdjacentHTML afterbegin.
Rapid re-toggles let superseded in-flight fetches prepend anyway -> N 'Recommended for you' rows
piled up. Each rebuild now bumps state.railGen and every loader bails if its captured gen is
stale before prepending; chip-driven rebuilds are also debounced (350ms) so multi-select
coalesces into one rebuild. The 'On your streaming services' rail itself was building fine -
it was just buried under the duplicates.
2026-06-23 10:00:52 -07:00
BoulderBadgeDad
cd29239b2f video discover: move 'My services' out of the page-wide bar (it's a rail builder, not a filter)
'My services' only builds the optional 'On your streaming services' rail, but sitting under
the 'Across Discover' header it read as a page-wide filter (user confusion: 'i thought it
affected the whole page because that's what it says'). Moved it to its own self-describing row
('pick what you subscribe to — adds a rail to your feed') below the bar. 'Across Discover' now
holds only the genuinely page-wide controls: Hide owned + Languages. Markup/CSS only — the
data-vdsc-myprov hook is unchanged.
2026-06-23 09:53:36 -07:00
BoulderBadgeDad
1feb34621e video discover: cascade rail cards in instead of one block flash
Each rail showed a shimmer skeleton then revealed the whole row in a single fade — on a
big library with hide-owned on (which pages deep server-side) that reads as a long blank then
a pop. Cards now stagger in left-to-right via a per-card --i index (capped) + a 'backwards'
fill-mode keyframe (so the entrance animation releases and :hover transforms still work).
Applies to lazy rails (fillShelf) and the prepended personalized rows (staggerWithin).
2026-06-23 09:32:18 -07:00
BoulderBadgeDad
73395b9668 video discover: give the Browse panel its own language filter (self-contained search)
The Browse-all grid silently inherited the global rail language preference, so users
couldn't ad-hoc browse foreign cinema without changing their homepage prefs. Added a
language chipset to the Browse panel (auto-wired via the generic chip handler -> state.sel.lang)
and the grid now always sends lang= : a real code filters, 'any' opts OUT of the rail
preference entirely. Route treats lang=any as 'no language filter'. Added a 'Browse the full
catalog' eyebrow so the panel reads as a self-contained search, parallel to 'Across Discover'.

Hide-owned and the saved 'My services' pref stay single/global by design (they apply page-wide);
the Browse panel's provider chips remain its own grid filter.
2026-06-23 09:26:22 -07:00
BoulderBadgeDad
dffc5c2e5d video discover: lift page-wide controls into a distinct 'Across Discover' bar
Hide-owned + language + my-services were inside the Browse-all filter panel, making them read
as grid-only when they actually affect every rail. Moved them into a labelled 'Across Discover'
strip above the Browse panel (JS finds them by data-attribute, so no logic change). Browse panel
now holds only its grid filters (kind/sort/genre/source/decade).
2026-06-23 09:19:19 -07:00
BoulderBadgeDad
5906576a13 video discover phase 3: 'On your streaming services' rail
A saved streaming-services preference drives a personalized 'On your streaming services' rail:
- GET/POST /discover/providers-pref (TMDB provider ids); /discover/list OR-joins multiple
  providers (comma->pipe) into with_watch_providers.
- A 📺 multi-select in the toolbar (Netflix/Prime/Disney+/Max/Apple TV+/Hulu/Paramount+/Peacock);
  selecting services saves the preference and rebuilds the rails.
- The rail (high priority, after taste) appears once you've picked services, showing what's
  streaming on yours.
2026-06-23 00:33:35 -07:00
BoulderBadgeDad
6734b6ab22 video discover phase 4 (UI): 'Not interested' card button + ignore-list modal
- A ✕ 'Not interested' button on every un-owned Discover card (hover) — adds to the ignore list
  and fades the card out instantly.
- A '🚫 Ignore List' button top-right of the hero opens a vibey glassmorphic modal: a header
  explaining what it is, a search box to hide any movie/show directly (TMDB search), and a poster
  grid of everything hidden with one-click 'Un-hide'. Empty state guides the user.
- Card button + modal both POST /discover/ignore; ignored titles vanish from every rail (via
  _stamp_owned). Video-only, additive.
2026-06-23 00:29:46 -07:00
BoulderBadgeDad
d019e3aca1 video discover: hidden-gems rails (highly-rated, non-blockbuster)
Add 'Hidden Gems' (movies) + 'Critically Acclaimed Shows' rails — vote_average.desc with the
backend's vote_count floor filtering out single-vote noise, and the language preference applied.
Slot them above the decade/foreign rails.
2026-06-23 00:13:19 -07:00
BoulderBadgeDad
f1b6fd5e31 video discover: language preference UI (multi-select chips)
A 🌐 multi-select chip row in the Discover toolbar (EN/KO/JA/ES/FR/HI/DE/IT) to pick which
original languages appear in the general/curated rails. Loads the current preference from
/discover/languages, toggling a chip POSTs the new set and rebuilds the rails (never empty —
at least one stays on). Extracted reloadRails() (now shared by the hide-owned toggle + language
chips). Default EN, so the rails are English unless you opt more in.
2026-06-23 00:11:56 -07:00
BoulderBadgeDad
28fe3d2b0b video discover: preferred-languages filter (keep Bollywood etc. out of general feeds)
The general/curated rails (Popular/Trending/Top Rated + genre/decade) pull TMDB's GLOBAL lists,
flooding feeds with foreign-language titles (Bollywood). Add a multi-language preference:
- _disc_map now carries original_language (+ popularity) on each item.
- discover_languages setting (default 'en'); /discover/list post-filters general/curated rails
  to it (dropping known non-preferred-language titles) and pages deeper to keep rails full.
  Rails with an explicit lang (the dedicated foreign rails) bypass the filter.
- GET/POST /discover/languages to read/set the preference.
- Removed the hardcoded lang=en on general rails (the setting drives it now).
Default 'en' immediately fixes the Bollywood flood; UI to pick languages next.
2026-06-23 00:09:51 -07:00
BoulderBadgeDad
e7b1a239b4 video discover phase 2: blended 'Recommended for you' wall
A single personalized wall aggregating TMDB recommendations across many of your owned titles
(random_owned_titles seeds), ranked by consensus — a title recommended by more of your library
ranks higher (ties by rating then popularity), owned + seed titles excluded.
- core/video/discovery_recs.py: pure blend_recommendations (dedup/consensus/exclude), 7 tests.
- /api/video/discover/foryou aggregates ~12 seeds' recommendations.
- loadForYou() prepends the 'Recommended for you' rail on top of the stack; re-runs on the
  hide-owned toggle.
2026-06-23 00:05:26 -07:00
BoulderBadgeDad
d984895cba video discover: keep foreign titles out of general rails + fix hide-owned sparse rows
Two Discover UX issues:
- Foreign-language titles leaked into the general genre/decade rails. Added an
  original-language filter (with_original_language) through client.discover -> discover_filter
  -> /discover/list (?lang=); the genre/decade/'because you like' rails now pin lang=en, and a
  handful of dedicated foreign rails (Korean/Japanese/Spanish/French/Hindi) house non-English.
- 'Hide owned' + a huge library = nearly-empty rails (a 2-page batch was mostly owned, then
  CSS-hidden to almost nothing). /discover/list now takes hide_owned=1: it drops owned
  server-side and pages DEEPER (up to 8) until a rail has ~24 un-owned. fillShelf passes
  hide_owned when the toggle's on; toggling re-renders the rails (+ personalized rows) instead
  of just CSS-hiding cards.
2026-06-22 23:59:21 -07:00
BoulderBadgeDad
a8ce359cb7 video discover phase 1e: 'what am I missing' gap rails on the discover page
loadGaps() fetches /discover/gaps and prepends the gap rails ('Complete the <franchise>',
'More from <director/creator>') above the rail stack, mirroring loadMoreLike. Cards are the
standard un-owned TMDB cards — already actionable (VideoGet add-to-watchlist/get), so a
missing franchise entry or director film is one click from your queue. Best-effort/additive.
2026-06-22 23:30:05 -07:00
BoulderBadgeDad
d162966cee Video tools: add a Server Scan tool (trigger the media server to index new downloads)
This is the tool originally asked for — DISTINCT from the Library Scan (where
SoulSync reads the server into video.db). Server Scan tells Plex/Jellyfin to
rescan its OWN folders so newly-downloaded files get indexed, then a Library Scan
pulls them in. It's the manual twin of the post-download 'Scan Video Server'
automation, and targets Movies / TV / both like the Library Scan.

- POST /api/video/scan/server {media_type} -> refresh_video_server_sections (trigger)
- GET /api/video/scan/server/status?media_type -> {scanning:true|false|null} (live poll)
- new Server Scan card on the video Tools page + video-server-scan.js controller,
  mirroring the music live-status UX (phase + working bar); resumes if the page
  opens mid-scan. Server scans have no % (Plex doesn't report one) so the bar is a
  working indicator. Both backend functions already existed + are media-type aware.

Seam tests: trigger threads media_type (movie / default all), status reports the
scanning flag (True / null passthrough), and the blueprint exposes both routes.
2026-06-22 08:26:24 -07:00
BoulderBadgeDad
70ad5af378 Video tools: fix Library Scan card layout — wrap the 3 controls
Adding the Movies/TV target gave the scan card three controls (target + mode +
button), one more than music's two, overflowing the shared no-wrap flex row and
clipping the Scan Library button past the card edge. Scoped CSS on the video Tools
page lets the row wrap: two selects share the top row, the button takes its own
full-width row. Music's .tool-card-controls is untouched.
2026-06-22 08:20:47 -07:00
BoulderBadgeDad
b26f664326 Video tools: scan a Movies OR TV library (not just both)
The video Library Scan tool only scanned 'all' — but movies and TV are
independent libraries (unlike music's single library). The scanner backend
already supported media_type='movie'|'show'|'all'; this just wires it up:
- /api/video/scan/request now reads media_type and threads it to request_scan
- the Tools card gains a target selector (All / Movies Only / TV Shows Only)
  alongside the existing mode dropdown, matching the music scan's UX
- the live status detail reflects the target (no confusing '0 shows' on a
  movies-only scan)

Seam test: the endpoint passes both mode and media_type through (default all/full,
explicit movie/deep, TV-only). Existing scanner media-type/scope tests unchanged.
2026-06-21 23:27:35 -07:00
BoulderBadgeDad
7fac921afc Video show detail (TMDB source): show the Watchlist + Trailer actions
The TMDB-source show detail page rendered an empty action bar — renderActions
early-returned for source='tmdb' (a stale "previews have no actions" assumption that
predates the curated, tmdb_id-keyed watchlist), so the Watchlist button never showed
and the rest was skipped. Now an AIRING show gets the Watchlist button whether it's
owned or a TMDB preview (ended/cancelled stay terminal → no button); Trailer renders
from the payload; Get Missing stays library-only. Also fixed toggleWatchlist sending
a bogus library_id + 404 poster proxy for tmdb previews (data.id is the tmdb id there)
— it now omits library_id and uses the proxied TMDB poster, mirroring the card-hover add.
2026-06-21 14:57:32 -07:00
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
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
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
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
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
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
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
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