Commit graph

2013 commits

Author SHA1 Message Date
BoulderBadgeDad
67197ce956 Playlists: page the FULL list when YouTube cookies are set (like ytdl-sub)
Correcting my earlier 'YouTube caps at ~200' claim — that's the ANONYMOUS ceiling.
YouTube throttles unauthenticated playlist extraction to ~100 (yt-dlp) / ~200
(InnerTube); ytdl-sub gets everything because it runs with browser cookies. Our
code already wires cookiesfrombrowser via youtube.cookies_browser (same as the
music client) — it's just unset. The detail endpoint now resolves with a high
limit so a cookie-authenticated yt-dlp pages the WHOLE playlist, and uses whichever
source (yt-dlp vs InnerTube) returned more, with the true header count. The partial
note now tells you to add cookies in Settings to load the full list.
2026-06-17 22:07:50 -07:00
BoulderBadgeDad
8e56273c9d Playlist count: show the TRUE total + load ~200 (was capped at ~100 via yt-dlp)
A big playlist read '97' because yt-dlp flat caps playlist extraction at ~100 and
its playlist_count is often unset, so we showed the FETCHED count. Now the detail
endpoint overlays the InnerTube playlist browse (browseId VL<id>): curator-ordered
videos up to ~200 (vs yt-dlp's ~100) AND the real count from the header's
numVideosText (e.g. '512 videos'). The billboard shows the true total; when the
list is partial it says 'Showing the first N of TOTAL videos (YouTube limits
playlist browsing).' Live-validated (Veritasium uploads: total 512, 200 loaded).
103 tests green.

Honest cap: YouTube's browse only exposes ~200 of a playlist (page 2 returns no
continuation), so very large playlists show the right COUNT but list ~200.
2026-06-17 21:40:56 -07:00
BoulderBadgeDad
5506155abe YouTube videos: play in-app (stream via the embed overlay)
Clicking a video thumbnail now plays it inline — the thumbnails are play buttons
(data-vd-yt-play) that reuse the existing trailer overlay (youtube.com/embed/<id>
?autoplay=1). Applies to channel + playlist video cards and the playlist-section
mini-cards (which previously just linked out to YouTube). The rest of the row
still expands to details.
2026-06-17 21:29:04 -07:00
BoulderBadgeDad
4788b54c91 Playlist detail: fix 'Open on YouTube' + hero Watchlist button (were channel-only)
On a playlist page the hero actions were hardcoded for channels: 'Open on YouTube'
linked to /channel/<PL id> (opened as a channel, failed), and the Watchlist button
called the channel-follow handler. renderActions is now kind-aware — playlists link
to /playlist?list=<id> and the button follows/unfollows the PLAYLIST (new
toggleYtPlaylistFollowHero via a yt-pl-follow action).
2026-06-17 21:24:49 -07:00
BoulderBadgeDad
ba7562c5f2 Watchlist cards: show the channel/playlist video count, not '0 videos'
The channel card's 'N videos' was actually the count of WISHED videos (0 until you
wish some) — so every channel read '0 videos'. It now shows the REMEMBERED catalog
size (from youtube_channel_videos, which fills in as a channel is enriched/opened),
falling back to 'Channel' when nothing's cached yet. Wished count is kept as a
separate wished_count field. Playlists likewise show their video count (cached when
the playlist is followed-with-videos or opened) instead of a static 'Playlist'.
list_watchlist_channels/playlists return the remembered count; the playlist detail +
follow endpoints cache the list so the count is known. 138 tests green.
2026-06-17 21:13:33 -07:00
BoulderBadgeDad
abb9d0e89e + Wish: use the app-standard watchlist-button chrome (was bespoke)
The per-video wishlist toggle now reuses .library-artist-watchlist-btn (icon +
text, accent gradient) via a shared ytWishBtn() helper — compact in the dense
episode rows, icon-only in the tight playlist-section cards. 'Wishlist' ↔ 'In
Wishlist' with a green .watching state. Same behavior + data-vd-yt-wish hook;
just consistent chrome.
2026-06-17 21:02:08 -07:00
BoulderBadgeDad
113a5ded38 Fix: + Wish silently failed on playlist detail pages (no channel context -> 400)
The per-video + Wish derived its channel from data._channel, which only exists on
CHANNEL pages — on a playlist detail page it's data._playlist, so the add posted
no youtube_id and the endpoint 400'd (button reverted, nothing happened). Now it
falls back to the playlist's owner channel (channel_id/title, else the playlist
itself) so wishing a video works on playlist pages too.
2026-06-17 20:56:45 -07:00
BoulderBadgeDad
62b815a3fe Channel page: Add-to-watchlist on each playlist section
Channel playlists (the collapsible sections on a channel page) now each carry the
standard watchlist button — Add to Watchlist / In Watchlist — so you can follow a
channel's playlist without pasting its link. The /youtube/playlists/<id> endpoint
flags each playlist with its follow state to hydrate the button; the button click
is handled before the row's expand toggle (stopPropagation), and adds via
followPlaylist — the same plumbing the search chip + watchlist use. Followed ones
then appear in the watchlist Channels tab alongside channels.
2026-06-17 20:48:59 -07:00
BoulderBadgeDad
e141e3dcbd Playlist search result: standard 'Add to Watchlist' button (not 'Follow')
The pasted-playlist chip used the YouTube 'Follow' toggle; swapped it for the
app-standard watchlist button (library-artist-watchlist-btn — same look/wording
used across video + music): + Add to Watchlist ↔ ✓ In Watchlist. The search
toggle handler updates the icon/text spans + 'watching' class accordingly. No
global handler keys off that class, so reusing it is purely cosmetic.
2026-06-17 20:42:30 -07:00
BoulderBadgeDad
ef3d4cfdb4 YouTube playlists on the watchlist — frontend (search, detail, watchlist)
- search: paste a playlist link → it resolves to a 'YouTube playlist' chip
  (cover, owner, count) with Add-to-watchlist; clicking the chip opens it.
  (isPlaylistRef + playlistCard + followPlaylist/unfollowPlaylist helpers.)
- routing: /video-detail/youtube/playlist/<PL…> deep-links like a channel
  (string id); the open-detail event handles kind='playlist'.
- detail view: a FLAT list in the curator's order (no year-seasons, no season
  nav / view toggle, no catalog streaming — it's a partial set). Billboard shows
  'Playlist · N videos · owner'. Reuses the show-detail shell + the new
  duration/views cards.
- watchlist: followed playlists sit beside channels in the Channels tab (rounded
  square + a list badge), open on click, ✕ to unfollow.

Live-validated (Lex Fridman playlist resolves + renders in order). The 2 shell
test 'failures' are the pre-existing window.-assertion (no new globals added).
2026-06-17 20:33:31 -07:00
BoulderBadgeDad
eb8f803c36 Channel rail: sharp season posters (maxres thumbnail, was a cropped hqdefault)
The rail 'season poster' is a video thumbnail shown in a 2:3 portrait slot
(object-fit: cover), but InnerTube hands us hqdefault (480x360, often sqp-shrunk)
— so it got crop-zoomed and looked soft. Now the poster pulls maxresdefault
(1280x720; ~300KB vs ~23KB) via the image proxy, with a fallback chain
(maxres → original thumbnail → hide) so videos without a maxres thumb still
render. Only the big rail poster is upgraded; the small episode stills already
downscale crisply.
2026-06-17 19:17:29 -07:00
BoulderBadgeDad
cb5ae93f76 Channel page: restore the season view toggle (rail posters etc.) broken by search/sort
The search/sort change hardcoded pillsHTML() for the youtube branch, so the
view toggle did nothing and the rail view's season posters vanished. The youtube
branch now honours seasonView (rail / timeline / tabs / list) for the year nav,
with the new search+sort controls stacked above it; flat mode (search / most-
viewed / longest) still hides the per-year nav. Default view (rail) shows the
year posters again.
2026-06-17 19:11:30 -07:00
BoulderBadgeDad
9c3d1a5709 Channel page: search + sort within the channel (restores the lost browse controls)
The standalone channel page had sort/filter/search; the merge into show-detail
dropped them. Now that the whole catalog is client-side, they're instant:

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

Search focus is preserved across the regroup (same hack the music manager uses).
2026-06-17 18:45:40 -07:00
BoulderBadgeDad
c7b5a93290 Channel videos: duration badge + view count on every card (TV-parity)
TV episode rows show runtime; channel video cards showed nothing per video. The
InnerTube lockup already carries both — now we capture duration ('12:34') + an
approximate view count (parsed from '2.6M views') in innertube_parse_video_items,
remember them on youtube_channel_videos (new duration/view_count cols + migration),
and render a duration badge bottom-right of the thumb + 'N views' in the card meta.
The background stream backfills them onto the recent (yt-dlp) videos too, so the
whole catalog gets them. Live-validated (MrBeast: 32:08 / 50M, 30/30). 65 tests green.
2026-06-17 18:36:47 -07:00
BoulderBadgeDad
66f3f97460 Channel videos: POST + one page per request (kill the giant slow-request log spam)
Each /videos batch was 3 InnerTube pages + 0.4s sleeps (>1s → tripped the
slow-request WARNING) and carried the ~2KB continuation token in the URL, so
every batch dumped a giant warning line — a dozen per channel open. Now it's a
POST (token in the body, never the URL) fetching ONE page per call with no
server sleep, so each request is fast (<1s, no warning) and the frontend's 120ms
pacing keeps it polite. 17 youtube API tests green.
2026-06-17 18:19:32 -07:00
BoulderBadgeDad
267f11c848 Remember each channel: cache the video catalog + metadata (instant re-open)
Channel pages re-fetched everything every open (re-stream + ~3s yt-dlp metadata).
Now we remember what we learn about a channel and serve it cache-first:

- schema: youtube_channel_videos (list) + youtube_channel_meta (avatar/subs/tags/
  banner); dates stay in youtube_video_dates, merged on read. DB: cache_/get_
  channel_videos + cache_/get_channel_meta (upserts COALESCE so refreshes never
  drop fields).
- enricher: _enrich now REMEMBERS a channel — caches the full InnerTube catalog
  (list, via new innertube_channel_catalog) + metadata + dates, not just dates.
  Since it already sweeps followed channels, watchlisted channels get pre-warmed
  in the background → opening them is instant.
- channel endpoint: cache-first. A remembered channel renders from cache with NO
  network (returns from_cache:true); a miss resolves live (yt-dlp) and remembers
  it. The /videos batch endpoint caches every page it streams.
- frontend: cache hit renders the full catalog instantly, then re-streams to
  refresh QUIETLY (new uploads/date fixes); only the first (miss) load shows the
  'loading full history' banner.

Live-validated (MrBeast catalog: 90/3 pages, all titled+dated). 190 tests green.
2026-06-17 18:08:04 -07:00
BoulderBadgeDad
d91693ba53 Channel page: stream the FULL video catalog in batches (no more 90 cap)
The channel page was capped at the recent 90 (yt-dlp flat, hard min(90,limit)),
so prolific channels (Ninja Kidz etc.) showed only ~90 of hundreds. Now it loads
everything via YouTube's InnerTube API, paginated by continuation token — each
page fetched once (O(n), light on rate limits), unlike yt-dlp offset batches
which re-scan from the start every time.

- core: innertube_parse_video_items (keeps title+thumbnail, not just dates) +
  innertube_channel_videos_page(channel_id, continuation) → {videos, continuation}.
- api: GET /youtube/channel/<id>/videos?continuation=<tok> returns ~a batch of
  videos (approx dates, refined from the date cache) + the next token.
- frontend: ytLoadAllVideos streams batches after the fast initial 90 render,
  folding each into the year-seasons live — backfills dates on the videos already
  shown AND appends older ones, re-rendering per batch (episode grid only when the
  viewed season changed). Replaces the old date-only re-poll (this dates AND
  expands). Safety ceiling 2000; a 'Loading full history… N so far' indicator.

Validated live (MrBeast: 30/page, clean continuation, all dated). 101 tests green.
2026-06-17 17:42:21 -07:00
BoulderBadgeDad
5012f44cfa Video: deep-link every sidebar page (URL parity with the music side)
Only the detail page produced a real URL before; top-level video pages (search,
library, discover, calendar, watchlist, wishlist, …) pushed nothing and even
cleared the URL to '/'. Now each page deep-links to '/' + pageId (e.g.
/video-search), mirroring music's '/<page>' and the existing /video-detail/ scheme:

- navigate() pushes/replaces { videoPage } history state; parsePagePath/buildPagePath
  added alongside parseDetailPath/buildDetailPath.
- popstate restores a page URL (and hands the side back to music when Back crosses
  out of /video-*); boot restores a page deep link, re-asserting the URL against
  music's boot clobber (same tactic the detail boot already uses).
- applySide() is now chrome-only; switchSide()/boot drive navigation explicitly.
- Nav anchors carry the real href=/video-<page> (was '#') and the click handler
  lets ⌘/Ctrl/middle-click open a new tab — full parity with the music nav + cards.

Server already serves /video-* via the SPA catch-all; music side untouched
(isolated IIFE). Reload / Back / Forward / new-tab now work for every page.
2026-06-17 16:52:58 -07:00
BoulderBadgeDad
4c16448ab3 Calendar 'next up' hero: smoother panel motion + collapsed panels rest at 25%
- The hover snap was jarring because collapsed panels floored at min-width:10%
  (so panels jumped ~10%↔80%) on a fast-start easeOutCirc curve. Raised the floor
  to 25% (3 panels now settle 50/25/25 and hovering just glides the 50% across)
  and switched to a gentler ease (0.66s cubic-bezier(0.45,0,0.2,1)).
- Lengthened the dim-overlay + sub/actions fades (0.4→0.55s, 0.3→0.45s) so they
  move with the expansion instead of finishing early. Mobile stack unaffected
  (it overrides min-width:0).
2026-06-17 16:32:31 -07:00
BoulderBadgeDad
cf03c28831 Discover: taller hero slider (420->520 desktop, 340->420 tablet) 2026-06-17 16:26:17 -07:00
BoulderBadgeDad
a3adf5e63c Video Manage-Workers modal: realign to the music modal's exact design
The video modal reused music's shell but had drifted into a parallel mini-design:
it emitted its own markup (.em-ph-main / .em-pause-btn / .em-item / .em-pg /
.vem-logo / .vem-icon) styled separately in video-side.css, instead of the
music classes that already exist in style.css. So the panel header, list rows,
rail icons and pager all looked 'redesigned'.

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

Removed the now-dead bespoke CSS. Episodes stay a coverage card (per the backend:
episode art cascades from a show match; /priority only accepts movie/show), so
the top bar is correctly Movies/Shows/Auto. Music side untouched (shared classes
are global; video overrides were all #vem-overlay-scoped or now deleted).
2026-06-17 14:18:43 -07:00
BoulderBadgeDad
91bd895e52 YouTube channels: follow = watchlist only (clean toast) + drop the misleading '90 videos'
Two reports:
- 'Following · 0 videos added' toast: following from the channel page sent a
  stub with no videos, so 0 were wished. Watchlisting a channel shouldn't bulk-
  wish its whole catalog anyway (that conflates watchlist/track with wishlist/
  acquire, unlike TV shows). follow() now sends only the channel fields and the
  toast reads 'Added to watchlist'. Videos are wished explicitly via + Wish.
- '90 videos' in every channel header: that was just our fetch cap (limit=90),
  not the real total — YouTube no longer exposes a trustworthy channel video
  count (videoCountText reports a shelf count: 142 for Veritasium, 71 for MrBeast).
  Dropped it; the accurate subscriber count already conveys the channel's scale.
2026-06-17 13:37:15 -07:00
BoulderBadgeDad
c00df179ba Channel page: fix re-poll never refreshing when recent videos collapse to one year
The date re-poll only re-rendered if the season COUNT increased — but for a
daily channel the 90 recent videos all land in the current year, so it went from
[2026(3), Earlier(87)] (2 seasons) to [2026(90)] (1 season): count dropped, so
the years (which WERE cached, per the logs) never showed and the spinner hung.
Now it re-renders whenever the 'Earlier videos' bucket shrinks too. Also extended
the poll window (20/45/80/120/160s) for channels queued behind others.
2026-06-17 13:22:23 -07:00
BoulderBadgeDad
b627f9f411 Channel page: 'fetching dates' indicator during the wait + watchlist toggle no longer reloads
- The InnerTube enrichment takes ~30-45s; while videos are still undated the page
  now shows a spinner 'Fetching upload dates from YouTube… your year-seasons will
  fill in shortly' (reuses the episode-syncing indicator), which clears once the
  years populate via the re-poll (or after the poll window).
- The Watchlist button on a channel was calling loadChannel() on follow — a full
  re-fetch + scroll-to-top that read as a page refresh. Now it just flips the
  button in place (data.following + renderActions), like unfollow already did.
2026-06-17 13:11:17 -07:00
BoulderBadgeDad
3235b196b7 YouTube enricher: observable + fast-fail proxy + live channel re-poll
Addresses 'says running but I see nothing':
- Real logs: 'enriching <ch>…', 'proxy returned N', 'done — N proxy + N
  per-video (X/Y dated)' so you can see exactly what it's doing.
- Proxy timeout 8s→4s so dead instances (most public ones) fail fast instead of
  hanging ~40s before the yt-dlp fallback.
- Channel page now re-fetches a few times (25/60/110s) after load while videos
  are still undated, re-rendering only when NEW year-seasons appear — so dates
  the background enricher fills in pop in without a manual reload.
2026-06-17 12:06:54 -07:00
BoulderBadgeDad
3a24fc24b1 YouTube worker: push status over the socket like the others (stop polling)
The date enricher was the odd one out — the dashboard polled
/api/video/enrichment/youtube/status every 3s (flooding the access log) instead
of using the shared WebSocket the other three workers push on. Now the existing
_emit_video_enrichment_status_loop also emits 'enrichment:youtube' (same stats
shape), and video-enrichment.js binds it on the socket alongside tmdb/tvdb/omdb.
Removed the bespoke polling loop. One-time prime fetch on load stays (instant
initial state); everything after is socket-driven. Consistent with the others.
2026-06-17 11:59:39 -07:00
BoulderBadgeDad
581735ac7d YouTube enricher: coverage-aware retry + show in Manage Workers modal
- Fixes 'stuck idle': a channel marked enriched with FEW dates (proxies were
  down) was locked out for 24h, so the improved yt-dlp fallback never re-ran.
  channel_dates_enriched_recently now retries in 15 min when the last run got
  <15 dates, and skips 24h only after a good run. So the catalog actually fills
  in once a source works.
- The YouTube Dates worker now appears in the Manage Workers modal: added to
  WORKERS (red ▶), youtube-aware rail subtitle, and a dedicated simple panel
  (status + what-it-does + channels-enriched / dates-cached / queued counts) —
  no per-kind match queue. Pause/resume works; polls every 3s like the rail.
2026-06-17 11:37:26 -07:00
BoulderBadgeDad
b6d8f07516 Dashboard: YouTube date enricher as a 4th worker orb + Follow→Watchlist consistency
- The standalone enricher now reports orb telemetry: stats()/pause()/resume();
  /enrichment/youtube/status (+pause/resume) special-cased in the route. Added
  the 4th header worker button (red ▶), WORKER_DEFS entry, and SERVICES wiring —
  it's not on the socket so video-enrichment.js polls it every 3s. It animates
  (idle → active orb + inbound pulses) while a followed channel's dates are being
  fetched, like the TMDB/TVDB/OMDb orbs.
- Channel detail now uses the SAME standard watchlist button as shows/movies
  (library-artist-watchlist-btn, ✓/+ icon, 'In Watchlist'/'Watchlist') instead
  of a bespoke 'Follow' — consistency. Still wired to the channel follow action.

176 backend tests green; JS balanced; music untouched.
2026-06-17 11:05:59 -07:00
BoulderBadgeDad
ec5af17de7 Channel year-seasons: RSS dates + persistent date cache (real years that fill in)
Flat listing has no upload dates, so channels showed one 'All Videos' season.
Now real year-seasons, filled from cheap sources and cached so they grow:
- core: parse_rss_dates / channel_recent_dates — one public RSS GET dates the
  ~15 most-recent uploads (no yt-dlp, no bot risk).
- db: youtube_video_dates cache table + cache_video_dates / get_video_dates.
- /youtube/channel merges cached + RSS dates onto the flat videos (year-seasons)
  and caches what it learns; /youtube/video caches each fetched date too — so
  expanding/wishing videos progressively fills the catalog's years, instant on
  repeat. Dateless tail groups as 'Earlier videos'. Missing-only toggle hidden
  for channels. 84 db + 84 youtube/api tests green.

Full day-one coverage still needs the YouTube Data API's publishedAt (optional,
yt-dlp can't match it cheaply) — RSS + cache is the no-key best.
2026-06-17 10:30:54 -07:00
BoulderBadgeDad
ece4fbc792 Channel detail: fix episode-row layout + graceful seasons
- The YouTube episode rows reused .vd-ep's 5-column grid (index·thumb·info·
  badge·chev) but only have 4 children, so the thumb squished into the index
  slot and the Wish button stretched across the info column. Give .vd-ep--yt its
  own 4-column grid (thumb·info·wish·chev).
- Flat listing omits per-video dates, so all videos bucketed into a lone
  'Undated' season. Now a single dateless channel shows one 'All Videos' season
  (year-grouping still kicks in for any videos that DO have dates); the 'Missing
  only' toggle is hidden for channels (videos aren't owned/missing).
2026-06-17 10:07:51 -07:00
BoulderBadgeDad
3d67f51c29 Search avatars: proxy with a UA + graceful initials fallback
Screenshot showed channel results with empty circles, not the initials chip —
i.e. an avatar URL was present but the image was failing to load. Two causes
addressed:
- The image proxy fetched googleusercontent/yt3 with NO User-Agent, which the
  CDN 403s for some avatars → blank. Now sends a browser UA + Accept header.
- A failed avatar now swaps to the initials chip (onerror → outerHTML) instead
  of hiding the img and leaving an empty circle. Also proxy protocol-relative +
  deeper-subdomain YouTube image hosts. img-proxy tests green.
2026-06-17 09:57:42 -07:00
BoulderBadgeDad
0ff0d07c22 Search UX: no 'No results' flash before YouTube loads + avatar fallback
- The big one: an empty TMDB result no longer flashes 'No results' while the
  (slower) YouTube channel search is still in flight — a 'YouTube channels ·
  searching…' skeleton group (shimmer cards + spinner) shows instead, and the
  empty state only appears once BOTH sources resolve with nothing. Users no
  longer click away thinking nothing's happening.
- Channel avatars: fall back to the singular 'thumbnail' field when the flat
  results omit the thumbnails list, so fewer cards are photoless (the rest keep
  the clean initials placeholder). Reduced-motion respected.
2026-06-17 09:42:02 -07:00
BoulderBadgeDad
284acc8591 Merge YouTube channels into the TV-show detail page
Per request: a channel now opens the SAME detail page as a TV show instead of a
separate page. A channel renders through the show pipeline — currentKind='show'
(so the show container resolves) with d.kind='channel' + d.source='youtube'
driving content. Every change is an additive 'youtube' branch; the show/movie
path is byte-for-byte unchanged.

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

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

.vc-* CSS; JS brace-balanced; music untouched.
2026-06-17 08:54:37 -07:00
BoulderBadgeDad
bbba2c8225 Fix: clicking a watchlist channel opened the dashboard, not its detail page
video-channel-detail was in DETAIL_PAGES but missing from VIDEO_PAGES, so
pageMeta() fell back to VIDEO_PAGES[0] (dashboard). Register it so navigate()
resolves the channel detail subpage.
2026-06-17 08:43:43 -07:00
BoulderBadgeDad
9348166bb5 YouTube: proxy channel/video images so avatars + thumbnails always load
The channel avatar (and video thumbnails) are yt3.googleusercontent.com /
i.ytimg.com URLs; hotlink/CORS policy could blank them (failed <img> hides on
the watchlist, falls to initials on the wishlist orb) — which is why the poster
'vanished' on both pages.

Extend the /api/video/img proxy allowlist to YouTube CDN hosts (ytimg.com /
ggpht.com / googleusercontent.com, https-only, still SSRF-safe) and route all
YouTube art through it: VideoYoutube.img() helper used by the watchlist channel
cards, search chip, the wishlist nebula orb/season/still art, and the channel
detail avatar/banner/thumbnails. 2 img-proxy tests green; JS balanced.
2026-06-17 08:40:33 -07:00
BoulderBadgeDad
b3fd6037bb YouTube: fix missing channel poster on the wishlist orb
Flat channel listing doesn't always surface the avatar, so it could be stored
null → the orb fell back to plain initials (looked like a missing poster). Two
fixes:
- Orb falls back to the channel's newest video thumbnail when the avatar is
  absent, so it's never blank.
- Opening the channel page (which resolves the real avatar) now backfills it
  onto every wished row via set_wishlist_channel_poster — so the actual channel
  avatar appears on the wishlist orb thereafter. 6 tests green.
2026-06-17 08:32:47 -07:00
BoulderBadgeDad
591138a8a1 Wishlist: clear the info bar when an orb collapses
The synopsis + cast live in the orb's always-present header (.vwsh-xhead), not
inside the collapsing seasons block, so closing an expanded show/channel left
the selected episode/video's synopsis + cast photos lingering until you
re-opened and re-closed. Collapsing an orb now wipes both side columns and drops
any episode selection, so the :empty columns hide cleanly.
2026-06-17 08:24:32 -07:00
BoulderBadgeDad
22adc3bc55 YouTube: pull FULL per-video metadata (lazy) for the wishlist info bar
Flat channel listing can't return descriptions, so selecting a video showed 'No
description'. Now we do a non-flat single-video extract on demand — the way the
TV nebula lazy-loads guest stars:
- core: video_detail(id) / shape_video() — description, views, likes, duration,
  tags, channel, webpage_url (full extract, yt-dlp injectable for tests).
- API: GET /youtube/video/<id> — returns it AND backfills the description onto
  the wishlist row (set_wishlist_video_overview), so re-opening is instant.
- Wishlist info bar (youtube): on select, lazy-fetch → real description +
  eyebrow (date · duration · views) + a 'Watch on YouTube' link; cached on the
  episode object. 'Loading details…' while in flight.

Mirrors the episode lazy-load + art-backfill patterns. 73 youtube+api tests
green; brace/CSS balanced; music untouched.
2026-06-17 08:22:45 -07:00
BoulderBadgeDad
5ab3ec90a8 YouTube next-level (UI): channel detail page + wishlist YouTube tab = TV nebula
Two big pieces:

1) Wishlist YouTube tab now renders through the EXACT TV nebula (channel = show
   orb, upload YEAR = season, video = episode) instead of a flat list. Made the
   nebula source-aware: a youtube orb/season/label opens the in-app channel page
   (not tmdb); season name shows the year; episode meta shows the upload date;
   removes route through the youtube source_id endpoints (video / year / whole
   channel); the info bar shows a selected video's description (no cast/no tmdb
   fetch). Identical look — music wl-* + TMDB path untouched.

2) New in-app YouTube channel detail page (video-channel.js, sibling of
   video-person.js) — opens via open-detail {kind:'channel'} from the watchlist
   card, the wishlist orb/season, and deep links (/video-detail/youtube/channel/
   <id>; router now accepts string ids + the new page). Banner hero, avatar,
   subs/handle/video stats, description, Follow toggle, and a video grid where
   each upload can be wished individually (duration + views + watch-on-YouTube).
   Watchlist channel cards now open this page instead of bouncing to YouTube.

video-side.css .vc-*; JS brace-balanced; backend tests green.
2026-06-17 01:20:13 -07:00
BoulderBadgeDad
b440f2bcdc YouTube channels (4/4): UI — paste-to-follow, channel feeds, watchlist cards
Visual-first slice ties it together (window.VideoYoutube shared helper, all
.vyt-* CSS — music wl-* and the TMDB vwsh-* nebula untouched):
- Search: paste a channel link (or @handle) and instead of a title search you
  get a YouTube Follow chip — avatar, title/handle, a strip of recent stills,
  and a Follow button that follows + wishes recent uploads in one click.
- Wishlist: new YouTube tab. Channel = collapsible header (avatar, count,
  open-on-YouTube, Unfollow), videos = a flat newest-first thumbnail feed with
  per-video remove. Tab badge + sub-count wired.
- Watchlist: new Channels tab — followed channels as avatar cards with a wished-
  video count and an unfollow control; count badge kept fresh across follows.

JS brace-balanced, CSS balanced, 66 youtube+API tests green.
2026-06-17 00:47:24 -07:00
BoulderBadgeDad
b7cafb8b72 Calendar: lock multi-hero height so hover doesn't change the DOM height
The 'Next up' multi-panel used min-height, so when hovering re-wrapped a
narrowed panel's title onto more lines the whole hero grew taller and nudged the
page. Pin it to a fixed height and clamp panel titles to 2 lines; mobile stacked
layout falls back to height:auto.
2026-06-17 00:06:32 -07:00
BoulderBadgeDad
6d63016566 Calendar: keep hero side panels >=10% wide + make Compact the default view
- The 2-3 'Next up' panels now floor at min-width 10%, so when one is expanded
  the others stay visibly selectable instead of collapsing to slivers (reset to
  0 in the mobile stacked layout).
- Compact is now the default calendar view (still overridable + remembered via
  localStorage).
2026-06-17 00:03:49 -07:00
BoulderBadgeDad
987cee7cb3 Calendar: view switcher with a Compact (art-hidden) view
Adds a Cards/Compact toggle next to the week filter. Compact drops the 16:9
episode art (.vcal-art) — the big space eater — and tightens the cells so a lot
more episodes fit on screen at once; ownership (which the art's ✓ badge used to
show) becomes a green left accent stripe on owned cells. Just toggles a class on
the stable grid wrapper (no refetch), and the choice persists in localStorage.

Calendar-only; music side untouched.
2026-06-17 00:00:28 -07:00
BoulderBadgeDad
7c1644384d Calendar: time-aware 'Next up' (up to 3, diagonal panels) + auto-scroll to now
The hero used to lock onto the first episode of the week and never move. Now:
- 'Next up' is time-of-day aware on the current week: it shows the soonest
  episodes that haven't finished airing yet (90-min grace so a show that's on
  right now stays up; streaming/undated shows count as 'anytime today' so they
  don't expire at midnight), advancing as the day goes. Once the week has fully
  aired it falls back to the most recent.
- When 2-3 are upcoming, the hero splits into diagonal clip-path panels — the
  soonest leads, hovering any panel expands it full-width and collapses the
  others (secondary panels tease the title; sub + actions fade in on expand).
  Single upcoming = the original billboard. Stacks vertically on mobile.
- Opening the calendar (or hitting Today) now auto-scrolls to the band the
  wall-clock is in, so you land on 'now' instead of pre-dawn.

Calendar-only; music side untouched.
2026-06-16 23:57:14 -07:00
BoulderBadgeDad
f383c82beb Wishlist TV: season as a left poster panel, episodes flow to its right
The season header was a full-width bar holding a tiny 34px thumb + 'Season N'
with the episode grid stacked below — most of the row was dead space. Now each
season is a real poster panel on the LEFT (94px poster + name + count + 'View
show ->' on hover, the whole panel → show page; ✕ to remove) with the episode
grid filling the space to its RIGHT (denser 232px cards). Reads tighter and the
season poster is finally a meaningful element instead of a thumbnail.

Scoped under .vwsh-nebula; music wishlist untouched.
2026-06-16 23:46:03 -07:00
BoulderBadgeDad
ce84e715c3 Wishlist TV: flank the poster (synopsis | poster | cast) + graceful empty states
Fixes the expanded-show design from the screenshot:
- The header is now a 3-column row that flanks the poster: synopsis left, poster
  middle, cast right — using the wasted horizontal space instead of stacking
  everything vertically and pushing episodes down. Each side column hides (:empty)
  when there's nothing, so a collapsed bubble or a cast-less reality show just
  shows the centered poster (nebula grid unchanged). Stacks on mobile.
- Quiet episode-still placeholder (faint icon) instead of loud diagonal stripes
  for episodes TMDB has no still for.

All scoped under .vwsh-nebula; music wishlist untouched.
2026-06-16 23:40:27 -07:00
BoulderBadgeDad
817de567ed Wishlist TV: episode cast = guest stars + show regulars (no longer empty)
Most episodes have no TMDB guest stars, so 'episode cast' showed nothing. A
selected episode now lists its guest stars first, then the show's regular cast
(deduped), so actors always appear; the guests just lead.
2026-06-16 19:16:51 -07:00
BoulderBadgeDad
efcaea314c Wishlist TV: fix episode cast 404 (season number was undefined in URL)
query_wishlist episodes carry episode_number but not season_number (it lives on
the season), so the guest-cast fetch hit /episode/<tmdb>/undefined/<ep> -> 404 and
the S·E eyebrow read 'S undefined'. findEpisode now stamps season_number onto the
selected episode.
2026-06-16 19:11:40 -07:00
BoulderBadgeDad
7ef794fe74 Wishlist TV: episodes grouped under clickable season headers; show/season -> show page
Per feedback:
- Removed the 'View show' button. The info bar is now strictly synopsis (left) +
  cast (right).
- Episodes are shown grouped under a season header that links to the show page;
  clicking the show title or a season header navigates to the show detail. Clicking
  an episode SELECTS it (drives the info bar: episode synopsis + guest cast),
  click again to go back to show-level.
- Dropped the season-tile reveal step (episodes always visible when expanded).

Scoped under .vwsh-nebula; music wishlist untouched.
2026-06-16 19:07:46 -07:00
BoulderBadgeDad
11cc9aa789 Wishlist TV: episode-select drives a contextual info bar + explicit View-show button
Reworked per feedback:
- Clicking an episode SELECTS it (highlights), no longer navigates.
- A dedicated 'View show ->' button in the info bar is what opens the show.
- Info bar is contextual: nothing selected -> show synopsis + show cast; episode
  selected -> that episode's synopsis (from the wishlist row) + its guest cast
  (lazy /episode/<tmdb>/<s>/<e>). Click the episode again to go back to show-level.
- Changing/closing a season resets the bar to show-level.

Cast bubbles + View-show navigate; everything scoped under .vwsh-nebula (music
wishlist untouched).
2026-06-16 18:57:37 -07:00
BoulderBadgeDad
853dbcf40f Wishlist TV: show synopsis + clickable cast bubbles in the expanded panel
Your idea — use the empty space when a show is expanded. Expanding an orb now
lazily loads the show detail and lays out an info bar: the show SYNOPSIS on the
left, a row of CAST bubbles on the right (circular photos / initials fallback,
clickable -> opens the person page). This surfaces reliable show-level context
even for reality shows whose episodes have no TMDB overview (e.g. Love Island).

Reuses the existing detail endpoints (owned -> /detail/show, else /tmdb/show);
one fetch per show, cached on the group. All scoped under .vwsh-nebula — music
wishlist untouched.
2026-06-16 18:36:05 -07:00
BoulderBadgeDad
e8a0e1256b Wishlist: episode synopsis + clickable cards + FIFO/newest sort
More data in the roomier episode cards (asked for): episodes now carry a synopsis
(new video_wishlist.episode_overview, SCHEMA_VERSION 12 + migration; captured at
add-time, and the art-backfill fills it for old rows from the same tmdb_season
call). The card shows a 2-line synopsis under the meta line and is now clickable
-> opens the show detail (episodes have no page of their own).

Organize the wishlist two ways: added 'Oldest first' (FIFO) alongside 'Recently
added' (newest) — query_wishlist gains the 'oldest' sort for both movies + shows.

Tests: FIFO/newest ordering (with pinned add-times) + overview roundtrip/backfill.
105 passed. Music wishlist untouched.
2026-06-16 18:21:26 -07:00
BoulderBadgeDad
4d62df5c33 Wishlist TV: roomier episode cards + full-width episode area + polish
From the expanded-show screenshot: episodes were crammed into a 320px tile and
titles truncated. Reworked (all still scoped under .vwsh-nebula):

- Season tiles are now SELECTORS. Picking one drops its episodes full-width below
  the fan as 2-line cards: a bigger 16:9 still, the title wrapping to two lines,
  and a meta line (status dot · S/E · air date). Single-select; click again to
  close. Responsive grid, scrolls past ~360px.
- Softer count badge ('59 ep' muted pill instead of the loud number).
- Selected tile glows in the show hue; square bubbles bumped up so posters breathe.

Music wishlist untouched (verified: no bare .wl-* rules in video CSS).
2026-06-16 18:09:05 -07:00
BoulderBadgeDad
e5cd5cf08c Wishlist: real season posters (not the show poster) + extend backfill
Season tiles showed the show poster because the wishlist had no season art. Now
stored + used: new video_wishlist.season_poster_url (SCHEMA_VERSION 11 + migration);
the get-modal captures each season's poster at add-time (owned -> /poster/season
proxy, tmdb -> direct); query_wishlist exposes season.poster_url; tiles render the
real season poster (falling back to show poster, then placeholder).

Backfill generalized: /wishlist/backfill-stills -> /wishlist/backfill-art fills
BOTH stills and season posters from the same cached tmdb_season call (it already
returns the season poster). The page fires it once when either is missing.

Tests updated/added: art backfill targets + season-poster set + endpoint. 104 passed.
2026-06-16 17:42:12 -07:00
BoulderBadgeDad
2985514627 Wishlist: backfill episode stills for rows added before still-capture
The 'no episode images' was data, not a bug: existing episode rows predate
still-capture (81 rows, 0 stills). New /wishlist/backfill-stills fills them
cheaply — one cached tmdb_season call per (show, season), updating only rows
that lack a still. The show tab fires it once automatically when it sees missing
stills, then reloads so the images pop in.

DB: wishlist_still_backfill_targets + set_wishlist_still (won't clobber existing).
Tests: +2. Suites green.
2026-06-16 17:33:59 -07:00
BoulderBadgeDad
f7bd15d019 Wishlist TV: #4 progress bar + episode stills + square video bubbles
#4 acquisition progress: a thin done÷wanted bar across each bubble's bottom
(forward-prep — fills once the download engine lands).

Expanded-view richness: episodes now carry a still thumbnail. New video_wishlist
.still_url column (SCHEMA_VERSION 10 + migration); the get-modal captures the
still per episode (owned -> /poster/episode proxy, tmdb -> direct still_url) and
sends it through add; query_wishlist returns it; the episode row renders a 16:9
thumb (film-frame placeholder when absent) in a roomier expanded tile.

Subtle video identity: the bubbles are now rounded-SQUARES (the music orbs stay
circles). Every rule stays scoped under .vwsh-nebula — verified no bare .wl-*
rules in the video CSS, so the music wishlist is untouched.

Tests: +1 (still roundtrip). Backend 102 passed.
2026-06-16 17:27:16 -07:00
BoulderBadgeDad
599f36fdf3 Wishlist TV nebula: cinematic expand + season tags + rich tracks + sort
Next-level pass for the video nebula, ALL scoped under a video-only .vwsh-nebula
class so the music wishlist's global wl-* styling is untouched (verified: no bare
.wl-* rules in the video CSS).

1. Cinematic expand — an open orb bleeds the show's poster as a blurred, hue-
   tinted backdrop behind the season fan + glows the panel in the show's hue.
2. Season tags — each season tile stamps a bold 'S2' over its art so seasons
   read distinctly instead of identical posters.
3. Richer episode tracks — every episode line gets a colored status dot
   (wanted/searching/downloading/done/failed) + its air date.
4. Sort + count — a Recently added / Most wanted / A–Z sort (query_wishlist gains
   a sort param) and a live 'N shows · M episodes' subheader.

Tests: +1 (sort ordering). Backend 101 passed. Movies tab + music side untouched.
2026-06-16 17:16:59 -07:00
BoulderBadgeDad
d90ae493dd Wishlist TV: adopt the music 'Nebula' (show=orb, season=album, episode=track)
Ditched the filmstrip. The TV tab now renders the exact music wishlist nebula by
reusing its global wl-* classes: shows are glowing orbs sized by wanted-episode
count, click to expand into a season 'album fan' (tiles), click a season tile to
reveal its episodes; removes at episode/season + a show-remove × on the orb.
Everything's visible at once, no horizontal scrolling. Movies tab unchanged.

Only video-specific CSS is the show-remove button; behavior (orb/tile expand,
opens, removes) is wired via delegation in video-wishlist.js. Easy to tweak from
this shared base.
2026-06-16 17:05:51 -07:00
BoulderBadgeDad
1b456d548a Wishlist TV: filmstrip reels (replace the lame collapsible tree)
The music wishlist has its artist 'nebula'; the TV side now has its own rich,
TV-native metaphor. Each show is a reel: poster + one horizontal film strip per
season (dark band with sprocket-hole borders) made of episode 'cells'. A cell
shows E#, glows in the show's hue, and widens on hover to reveal the title;
remove ×s sit on each cell, each season label, and the show header. Poster/title
open the show detail. Status tints the cells (wanted/downloading/done/failed).
Movies tab unchanged.

query_wishlist(show) now also returns library_id so reels open the owned detail
when applicable. Backend suites green.
2026-06-16 16:54:19 -07:00
BoulderBadgeDad
bae8060c09 Calendar: 'Add missing to wishlist' catch-up button
A manual safety-net for the auto-promoter: queues episodes that have ALREADY
aired, are missing, and aren't yet on the wishlist. Upcoming episodes are left
alone (the calendar promotes them once they air), so it's a no-op on the current/
future weeks and useful when you page back to a past one.

- calendar_upcoming now returns the show's tmdb_id.
- /wishlist/check accepts {shows:[...]} -> by_show membership (db.wishlist_keys_
  for_shows), so the button only counts/adds what's genuinely not yet queued.
- Calendar: computes aired-missing (air_date < today, !has_file), checks wishlist
  membership, shows 'Add N missing to wishlist' when there's net-new; click groups
  by show -> /wishlist/add, toasts, fires soulsync:video-wishlist-changed, recomputes.

Tests: +2 (wishlist_keys_for_shows, /wishlist/check by_show). Backend: 100 passed.
2026-06-16 16:33:48 -07:00
BoulderBadgeDad
d3c875919f Wishlist page: header identical to the watchlist (kill the harsh glow)
My head glow was unblurred + mis-centered, so it read as a harsh purple band
with a gap above. Mirror the watchlist's exact head/glow/tabs/toolbar: blurred
accent radial clipped by the header, 36px title, pill tabs with count badges,
matching gutters + 900px breakpoint. Same chrome, different content.
2026-06-16 16:23:21 -07:00
BoulderBadgeDad
772703464b Wishlist page: match the watchlist container (full-width + 40px gutters)
It used max-width:1180 centered with no inner padding, so wide screens got big
empty side margins and the content looked squooshed. Mirror .vwlp-* instead:
full-width .vwsh-page, head/toolbar/body padded 40px (18px on small screens),
same head glow.
2026-06-16 16:18:17 -07:00
BoulderBadgeDad
bbd081c44b Video get-modal: wire 'Add to Wishlist' to real writes
No longer a stub. The modal now captures the title's tmdb id + per-episode meta
(title/air_date, including lazily-loaded tmdb seasons), and on submit:
- movie  -> POST /wishlist/add {movie} (owned -> 'queued for re-download')
- show   -> POST /wishlist/add {show, episodes:[selected S/E]}; if the 'Add to
  watchlist' tick is on, also POST /watchlist/add for the show.
Fires soulsync:video-wishlist-changed (+ watchlist-changed) so badges/pages
refresh, toasts the result, and closes. Every card surface (discover, search,
library, watchlist, detail) funnels through here, so they're all live now.
2026-06-16 16:14:19 -07:00
BoulderBadgeDad
261416fede Video wishlist: the page (Movies grid + grouped TV tree)
Tabbed Movies / TV page (mirrors the watchlist chrome). Movies render as a poster
grid with status pill + hover remove. TV groups into collapsible show -> season ->
episode rows with wanted/done roll-ups and a remove (x) at every level (episode /
season / whole show). Server-paged + searchable; updates the nav + hero badges
and listens for soulsync:video-wishlist-changed. Movie cards open detail.

Wires the pre-existing Wishlist nav button (added its badge) + subpage container +
.vwsh-* styles.
2026-06-16 16:11:45 -07:00
BoulderBadgeDad
246c650db4 Discover: fix grid pagination + session cache + responsive pass
Pagination (regression): the IntersectionObserver sentinel only fires on
intersection *changes*, so a short first page kept it on-screen and it never
re-fired — stuck at 20 — and I'd hidden the Load more button whenever IO exists,
leaving no fallback. Now:
- Load more button is always shown while there's more (the reliable control).
- Auto-load is self-correcting: track sentinel visibility and, after each load,
  pull again via rAF if it's still on-screen (rAF lets the observer update first
  so a cached category doesn't load every page at once).
- page increment moved inside loadGrid so the button + sentinel can't double-bump.

Caching: cachedFetch() memoizes /discover/list responses per URL for the session
(rails + grid pages) — revisits, paging and reopening a category are instant and
don't re-hit TMDB.

Responsive: small-screen pass for the Browse panel, hero (height/title/actions
full-width), grid header wrap, and the trailer close button (kept on-screen).
Also fixed the ambient layer's 130% width that could cause horizontal scroll.
2026-06-16 15:44:36 -07:00
BoulderBadgeDad
80d504f94d Discover: meaningful filter colors + drop the asymmetric edge-fade
Colors now carry meaning instead of cycling by position:
- segments (Movies/TV, sort) → the app accent (they're modes, not categories)
- genre chips → a thematic colour per genre (Horror red, Comedy gold, Sci-Fi
  cyan, Romance pink, …) via a name→colour map; unmapped fall back to neutral
- provider chips → each service's brand colour (Netflix red, Disney+ blue, Max
  purple, Hulu green, …)
- era chips → a single warm amber; 'All/Any …' reset chips → neutral grey

Edge-fade: removed the mask-image from .vdsc-rail and .vdsc-chips. On short
filter rows only the left fade landed (dimming the first chip) while the right
fell on empty space — reading as a one-sided fade over everything.
2026-06-16 15:26:12 -07:00
BoulderBadgeDad
b316d283f7 Discover: fix filter buttons rendering as bare text
The --c palette triples were space-separated (29 185 84), so rgba(var(--c), a)
expanded to the invalid rgba(29 185 84, a) and every fill/border/color was
dropped — leaving plain text. Match the codebase convention (--accent-rgb is
comma-separated) by making --c comma-separated, plus the rgba fallbacks.
2026-06-16 15:16:48 -07:00
BoulderBadgeDad
a1b9f8a827 Discover: restyle filter controls to the music album-detail button vibe
The fully-round accent pills + sliding-thumb segments didn't fit. Now every
filter control (kind/sort segments + genre/provider/era chips) shares the
album-detail action-button look: rounded-rect (9px), each tinted from a vibey
8-colour palette that cycles across the row (green/purple/blue/amber/pink/cyan/
coral/violet) — soft fill + border + coloured text, brightening on hover, the
selected one filling with its own colour. Dropped the sliding-thumb segmented
control + its now-dead JS (moveSeg/positionSegs).
2026-06-16 15:11:20 -07:00
BoulderBadgeDad
2b3ca12310 Discover: streaming-provider filter + infinite scroll + a11y polish
Provider filter (#4):
- client.discover() + engine.discover_filter() take a TMDB provider id and pass
  with_watch_providers + watch_region (engine._region) + flatrate. Browse gets a
  streaming-service chip row (Netflix/Prime/Disney+/Max/Apple TV+/Hulu/Paramount+/
  Peacock); the grid title reflects 'on <service>'.

Infinite scroll (#6):
- Grid paginates via an IntersectionObserver sentinel (600px lookahead) with a
  bottom spinner; the Load more button stays only as a no-IO fallback.

Polish (#7):
- Hero keyboard nav (←/→ when Discover is the visible view, ignoring inputs and
  while the trailer is open); focus-visible rings on chips/segments/cards/arrows.

Note: 'complete-the-franchise' rail (#5) needs a collection_id per movie, which
the schema doesn't store yet — deferred (would need an enrichment pass).

Tests: +1 (provider watch_region params); updated the discover_filter fake for
the new kwargs. Enrichment + API suites: 115 passed.
2026-06-16 14:55:13 -07:00
BoulderBadgeDad
a9ec025706 Discover: 'More like…' rails + in-app hero trailer
More-like rails (real personalization beyond genre):
- db.random_owned_titles() seeds from a few random owned titles (with tmdb_id);
  client.recommendations() + engine.recommendations() (cached + owned-annotated)
  fetch TMDB recs. New /discover/morelike interleaves movie/show seeds (max 3
  rails) and the page prepends them, pre-filled, above the rail stack with a
  gradient 'for you' title.

Hero trailer (cheap big visual win):
- client.video_trailer() (light /videos call, Trailer over Teaser) + engine
  .trailer() (day-cached) + /discover/trailer. Hero gets a 'Trailer' button that
  opens an in-app YouTube lightbox (autoplay, Esc/backdrop close, pauses the
  slideshow) — nothing leaves SoulSync.

Tests: +11 (recs parse/annotate/cache, trailer pick+fallback+cache, random owned
seeds owned-with-tmdb only, /discover/trailer + /discover/morelike endpoints).
Enrichment + API suites: 114 passed.
2026-06-16 14:44:44 -07:00
BoulderBadgeDad
5aa792d399 Discover: sliding segmented pills + chip polish + no-TMDB state
- Segmented controls (Kind/Sort) now have a highlight 'thumb' that springs
  between options (JS measures the active button → CSS var slide); repositions
  on click, page-show, and resize.
- Chips: brighter active gradient with a glow ring + subtle lift, smoother hover.
- No-TMDB empty state: genres are a static TMDB endpoint, so when they come back
  empty the page shows a 'Discover needs TMDB' card instead of a bare shell.
- Persist 'Hide owned' across sessions (localStorage); async image decoding on
  cards.
2026-06-16 14:21:59 -07:00
BoulderBadgeDad
8043db0ad4 Discover: redesigned Browse panel + more data per rail
Browse panel (was boring native dropdowns):
- Kind + Sort are now segmented pill controls; Genre + Era are horizontally
  scrollable, edge-faded chip rows with an accent-glow active state; primary
  'Browse all →' CTA. Genre chips rebuild when kind flips. Selection lives in
  state.sel (no <select> reads).

More data per rail (so 'Hide owned' doesn't gut a shelf):
- /discover/list gains a 'pages' param (1–3): fetches that many consecutive
  TMDB pages and concatenates them deduped in one response. Rails request
  pages=2 (~40 items); trending is a fixed list so extra pages are skipped.

Cleanup: removed dead .vdsc-filterbar/.vdsc-select CSS.

Tests: +3 (discover routes registered; multi-page concat+dedup; trending
fetched once despite pages). API suite: 22 passed.
2026-06-16 14:13:27 -07:00
BoulderBadgeDad
5c9b43a175 Discover v2: per-category pagination, personalization, hide-owned + perf
Performance:
- Batched ownership: new db.library_ids_for_tmdb() resolves a whole rail in one
  query per kind. _stamp_owned (now also used by search + trending) groups by
  kind, so a full Discover page drops from ~500 connections to a couple per rail.

Function/data:
- 'See all' on every rail opens it as a paged grid (Load more); the filter bar's
  Browse routes through the same generic category grid with a back button + title.
- Personalized 'Because you like <Genre>' rails seeded from your most-owned
  genres (new db.top_owned_genres + /discover/taste endpoint).
- 'Hide owned' toggle drops in-library titles from every rail/grid (CSS class,
  instant).

Visual vibes:
- Ambient page-top color bleed that follows the current hero slide's hue.
- Rail edge-fade mask, gentle fade-in on load, per-title hue glow on card hover.

Tests: +4 (batched id map, server scoping, one-query-per-kind stamp, top genres).
Full video enrichment + database suites: 145 passed.
2026-06-16 14:02:44 -07:00
BoulderBadgeDad
97a2023139 Video: prefetch first season in get-modal + seam tests for discover
- Get-modal: prefetch the first real (un-owned) season on open so its first
  expand is instant and its missing episodes pre-count in the footer (prefers
  Season 1 over Specials).
- Tests: 10 new seam tests for the Discover data layer — TMDBClient curated/
  discover/genres parsing (forced kind, decade date-range, tv first_air_date_year,
  backdrop+overview), and engine discover_curated/discover_filter/genre_list
  (owned annotation, caching, kind normalization, disabled-worker + error
  swallowing). Full video enrichment suite: 79 passed.
2026-06-16 13:44:44 -07:00
BoulderBadgeDad
a8ebc9b8f7 Video Discover page: trending hero + lazy genre/decade rails + filter grid
A browse-everything page for TMDB titles you don't own yet.

Backend:
- TMDBClient: discover() (genre/year/decade), curated() (popular / top_rated /
  now_playing / upcoming / on_the_air / airing_today), genres(); shared
  _disc_map() that carries backdrop + overview. trending() now routes through it.
- Engine: discover_curated / discover_filter / genre_list (cached + owned-
  annotated via library_id_for_tmdb) + _stamp_owned helper.
- api/video/discover.py: /discover/hero, /discover/genres, /discover/list
  (curated key | filtered kind+genre+year+decade+sort, paged).

Frontend (video-discover.js + .vdsc-* CSS + subpage markup):
- Cross-fading trending hero slideshow (per-title hue, dots, hover-pause,
  reduced-motion safe, click -> detail).
- Deep stack of Netflix-style rails (curated + every movie genre + decades),
  each lazy-loaded on scroll via IntersectionObserver, arrow scrollers,
  reusing the search card (owned 'In Library' / 'Preview' ribbon + get button).
- Filter bar (kind / genre / decade / sort) flips into a paged 'Load more' grid.
- Cards open detail through the shared soulsync:video-open-detail event.
2026-06-16 13:35:31 -07:00
BoulderBadgeDad
4b7a915d39 Video get-modal: lazy-load episodes for un-owned (tmdb) shows
A show you don't own ships its seasons with no episodes (loaded per-season
on expand, like the full detail page), so the modal mis-read '0 episodes'
as 'all owned' and seasons expanded to nothing.

- Render tmdb seasons as collapsible with an 'expand to load' placeholder;
  fetch /api/video/tmdb/show/<id>/season/<n> on first expand, render rows,
  pre-select the missing-aired episodes, enable the season select-all.
- Skip the 'you have every episode' hint when any season is lazy/un-owned;
  lazy seasons aren't tagged owned, so they stay visible under 'Missing only'.
- Next-episode line falls back to the payload's next_episode stub when no
  episodes are loaded (the only next-up source for un-owned shows).
2026-06-16 12:57:42 -07:00
BoulderBadgeDad
0071358125 Video get-modal: poster + ratings bar + owned-movie handling + next-up
- Owned movie shows 'In your library' (with best version quality) and the
  footer flips to 'Re-download' instead of '+ Add to Wishlist'.
- Poster thumbnail floats over the hero (hue halo + own rise animation),
  works for both library and TMDB sources.
- Ratings strip: branded IMDb / Rotten Tomatoes / Metacritic chips from the
  existing payload fields; renders only what's present.
- Airing shows show the soonest upcoming episode above the selector.
2026-06-16 12:46:07 -07:00
BoulderBadgeDad
d722224b26 Video get-modal: use the standard SoulSync buttons (discog-submit/cancel)
The footer's 'Add to Wishlist' + 'Full page' now use the same buttons as the
artist-detail / Download-Discography modal — .discog-submit-btn (primary, ⬇ +
label) and .discog-cancel-btn (secondary) — instead of the custom vgm buttons,
for consistency across the app. updateFooter sets the label span (keeps the
icon). Per-title hue glows stay on the hero/title/ambient.
2026-06-16 12:19:21 -07:00
BoulderBadgeDad
02e3b9d2e4 Video get-modal: premium vibe pass — per-title hue glows + motion
Each modal now glows in its own colour (stable hue hashed from the title, set as
--vgm-h):
- ambient hue halo around the modal + a hue-tinted hero scrim
- slow Ken Burns drift on the backdrop + a drifting light sweep across the hero
- staggered content rise on open; spring entrance for the modal
- title glow; hue-aware gradient CTA with lift + glow on hover
- episode rows grow an accent edge on hover; close button spins
- the 'Add to watchlist' row breathes a soft accent glow to invite the tick
All guarded by prefers-reduced-motion.
2026-06-16 12:15:18 -07:00
BoulderBadgeDad
13d44f3ba0 Remove download button and logic from video modal
Remove the 'Download' button and all related handling from the video-get modal. The diff deletes the data-vgm-download button markup, removes download branching in the click handler and the isDl flag, and stops updating download text/disabled state in the UI refresh. The wishlist path is now the single visual flow (toast always reports Wishlist + optional Watchlist); actual write and download-from-wishlist behavior will be implemented later.
2026-06-16 12:11:05 -07:00
BoulderBadgeDad
9a5d187c0d Video get-modal: fix owned-season dead-end under 'Missing only'
Fully-owned seasons used to render with a disabled checkbox and expand to
nothing (every episode hidden by the filter) — looked broken. Now:
- Fully-owned seasons are HIDDEN while 'Missing only' is on (the filter doing
  its job); turn it off to see + re-download them (owned episodes are
  selectable). Season meta reads 'owned · N eps' when shown.
- If you own EVERY episode, a hint appears: 'You have every episode. Turn off
  Missing only to re-download.' (instead of a blank list).
Partial seasons (with gaps) show + expand as before.
2026-06-16 11:55:59 -07:00
BoulderBadgeDad
419b939999 Video get-modal: owned episodes re-downloadable + 'Add to watchlist' offer
- Owned episodes are now SELECTABLE (checkbox, not pre-checked) so you can
  re-download a season/episode you already have; flip 'Missing only' off to see
  them. Every season (incl. fully-owned) gets a select-all. Select-all + the
  season checkbox state respect the 'Missing only' filter (no silently grabbing
  hidden owned episodes).
- Airing show you don't follow yet → an accent 'Add to watchlist' tickbox
  (default on) so grabbing episodes also starts you watching for new ones.
  Hidden for ended shows + shows you already follow. The Add-to-Wishlist stub
  toast notes '+ Watchlist' when it's ticked.
2026-06-16 11:42:57 -07:00
BoulderBadgeDad
e24e9bfe1e Video get-modal: TV episode selector (evolved Download Discography) + Download btn
Shows aren't wishlist items — episodes are. So the show modal now grows an
inline season/episode selector below the overview:
- Collapsible season cards (season-level select-all + 'N missing · M eps').
- Per-episode rows with three states: owned (locked ✓, 'In library'),
  upcoming (locked ◷, air date — these belong to the watchlist), and
  missing-aired (accent checkbox, PRE-SELECTED).
- 'Missing only' toggle (default on) hides owned to cut noise; live selected
  count; season checkboxes go indeterminate as you pick episodes.

Footer is now [count] · [Full page] · [Download N] · [Add N to Wishlist].
Movies keep a simple [Download] · [Add to Wishlist]. Add/Download are visual
stubs (toast the count) — the curated video_wishlist + download path come later.

Data is the existing show_detail seasons/episodes (owned + air_date).
2026-06-16 11:30:58 -07:00
BoulderBadgeDad
35957b426d Video: airing shows get BOTH the watchlist eye AND the get/download button
An airing show is ongoing (follow for new episodes) AND has a back-catalog you
may be missing (acquire) — so it needs both controls, not one or the other.
VideoGet.cardButton() now returns a control GROUP:
  person -> eye | movie -> get | airing show -> eye + get | ended show -> get
Library + watchlist-page cards route through it (the other surfaces already did).
New .vcard-ctrls wrapper lays the buttons out top-right; hover-reveal unchanged.

(Before, only ended shows showed the download button — which is why airing
shows looked like they had no way to acquire missing episodes.)
2026-06-16 11:07:53 -07:00
BoulderBadgeDad
21163239da Video detail page: follow control uses the new curated watchlist
The detail actions row still toggled the old shows.monitored flag via
/api/video/monitor. Now it's consistent with the cards:
- The 'Watchlist' button appears only for AIRING shows (movies + ended shows are
  terminal — they keep acquisition, not a watch-follow).
- It reads/writes the new /api/video/watchlist add/remove, resolves the real
  watched state on load (airing library shows are on by default), confirms on
  remove (the standard dialog), toasts, and broadcasts the change so the nav
  badge + watchlist page stay in sync.
2026-06-16 10:44:59 -07:00
BoulderBadgeDad
240e386871 Video: wire the eye/get button onto every card surface
A shared VideoGet.cardButton({kind,tmdbId,libraryId,title,poster,status,source})
picks the right control (person->eye, movie->get, show->eye when airing/unknown
else get) so every surface stays consistent. Injected into person filmography
(known-for + credits), search results (titles + people), and detail-page cast +
similar/collection rails. Each renderer hydrates watched state after render;
card roots get position:relative so the button anchors; reveal on hover.
2026-06-16 10:41:07 -07:00
BoulderBadgeDad
964675443b Video watchlist: nav badge with the live count
Adds a .dl-nav-badge to the Watchlist nav entry, updated from
/api/video/watchlist/counts on boot, on every watchlist change, and whenever
the page loads. Hidden at 0.
2026-06-16 10:20:33 -07:00
BoulderBadgeDad
58ceca86b1 Video watchlist: richer show cards (status pill + ep count) + sort dropdown
- Backend: effective shows now carry status + owned/total episode counts (joined
  off the shows table); query_watchlist gains a sort (default | title | added).
- Cards: a status pill (Airing / Upcoming / Ended) top-left + '12/20 eps' meta
  under the title for shows.
- Toolbar: a sort select (Following / A-Z / Recently added) next to search.

82 video tests green.
2026-06-16 10:19:13 -07:00
BoulderBadgeDad
1b23b7da78 Video: contextual get-symbol button + detail/download modal (visual only)
The terminal-content counterpart to the watchlist eye. On library cards:
- airing show  -> watchlist eye (monitor for new episodes)
- movie / ended show -> a 'get' download symbol that opens a detail modal

- video-get-modal.js: VideoGet.btn() + VideoGet.isAiring() (the shared status
  test), and the modal — hero backdrop, eyebrow, title, meta (runtime/rating/
  tagline), genres, overview, pulled from the existing detail endpoint. Action
  buttons are VISUAL STUBS for now: 'Open full page' navigates; 'Add to
  Wishlist' just toasts 'coming soon' (real population is a later phase).
- query_library now selects s.status so cards can pick eye vs get.
- CSS for .vget-btn (hover-reveal, accent on hover) + the .vgm-* modal, styled
  to match the calendar episode modal.

82 video tests green (status is an additive column).
2026-06-16 10:11:16 -07:00
BoulderBadgeDad
4287385af6 Video watchlist page: server-paged + search bar (like the library)
The page rendered every follow + airing-default show at once (DOM + all posters)
— slow once the watchlist grows. Now it pages like the library:

- /api/video/watchlist?kind=&search=&page=&limit= returns {items, pagination,
  counts}; query_watchlist() filters by title + slices (effective list is
  bounded, so compute-then-slice, not heavier UNION SQL).
- Page reworked to a single grid: Shows/People tabs each load their own page;
  debounced search box; Prev/Next pager; tab badges show totals from counts.
- Only a page of cards (and lazy posters) render at a time.

4 tests added (DB paginate/search + endpoint). 82 video tests green.
2026-06-16 09:40:41 -07:00
BoulderBadgeDad
b344303f75 Watchlist eye: confirm before removing (the standard yes/no dialog)
The eye is small/easy to mis-click and the card vanishes on the watchlist page,
so removal now goes through the shared showConfirmDialog (core.js) — 'Remove
"<title>" from your watchlist?' — before un-following. Adding stays one-click
instant. Cancelling re-enables the button with no change.
2026-06-16 09:29:31 -07:00
BoulderBadgeDad
d49eb967a4 Fix: watchlist card click did a full page reload (~15s freeze)
The watchlist cards were bare <a href='/video-detail/...'> with no click
interceptor, so clicking one triggered a REAL browser navigation to the
client-routed URL — the server returns the app shell and the entire app
re-boots (every static asset re-fetched = the ~15s 'restart' freeze).

Mirror video-library.js: intercept plain left-clicks on the cards and dispatch
soulsync:video-open-detail {kind, id, source} for in-app SPA navigation instead.
Encodes the open target on each card (library shows by library id, people +
un-owned shows by tmdb id). Modified clicks (new tab) still use the real href;
the eye button's capture-phase handler already stops its own clicks.
2026-06-16 09:16:18 -07:00
BoulderBadgeDad
e5a4dda117 Video watchlist (shows + people): DB + endpoints + button + page (v1, no scan yet)
A curated follow-list for the video side, mirroring the music watchlist. v1 is
membership only — the monitoring/discovery engine is a later phase.

Backend:
- video_watchlist table (kind 'show'|'person', keyed on tmdb_id — the stable
  cross-context id both carry; library_id kept when owned). NOT the existing
  shows.monitored flag (that defaults to 1 / is library-only / has no people).
- VideoDatabase: add/remove/list/state/counts (upsert COALESCEs library_id +
  poster so a TMDB re-add can't wipe known data).
- /api/video/watchlist {GET, /add, /remove, /check, /counts}.
- query_library now selects s.tmdb_id so show cards can carry the key.

Frontend:
- video-watchlist-btn.js: shared eye button (the music ya-watchlist-btn mirror)
  — build/toggle/hydrate, one delegated capture-phase click handler, broadcasts
  soulsync:video-watchlist-changed so pages can react.
- Watchlist page (new subpage + video-watchlist.js): Shows / People tab switcher,
  poster grid to detail-page quality, reloads each visit, drops cards on unfollow.
- Wired the eye onto library TV-show cards (movies excluded — wishlist, not
  watch) + hydrate on render.

Tests: 6 new (DB upsert/COALESCE/state/counts + endpoint roundtrip/validation).
76 video tests green. Other card surfaces (cast, search, similar, filmography)
are the same VideoWatchlist.btn(...) one-liner — wired next.
2026-06-16 01:06:24 -07:00
BoulderBadgeDad
6d72b7ca11 Video Calendar: EPG-style guide with a shared time axis + premium polish
The 7 independent day-stacks had no shared time axis — you couldn't scan
'what's on in prime time' across the week. Restructured into a real guide:

- Rows = time-of-day bands (Morning / Afternoon / Prime Time / Late Night /
  Anytime) down a frozen left time-rail; 7 day columns; frozen header row +
  sticky corner. Untimed streaming drops land in Anytime; each card keeps its
  exact time. Empty bands (none all week) are hidden; empty cells show a dot.
- Live 'now' cue lights the today-column × current-time-band intersection.
- Prime Time rail glints gold.
- Keyboard nav (←/→ weeks, T = today), scoped to the visible page + no modal.
- Week-change crossfade; staggered card entrance runs only on load/week-change
  (not on filter re-renders); keyboard focus rings on cards.
- Subheader now shows owned/missing split.

Kept all existing interactions: tilt cards, breathing glows, skeleton shimmer,
parallax billboard, rich episode modal. Vanilla static files, no build step.
2026-06-16 00:35:35 -07:00
BoulderBadgeDad
57c5148207 Video Calendar: week navigation + owned/missing filter
Finish the TV calendar as a planning tool.

- Week nav: ‹ prev / Today / next › steps the 7-day window (endpoint takes
  ?start=; each window starts on today's weekday so "current day first" holds).
  Title reflects it (This Week / Next Week / In N weeks); Today highlight only
  shows when the real today is in view.
- Filter: All / In library / Missing, applied client-side (no refetch) — the
  hero + grid + count all respect it.
- Hero eyebrow is context-aware (NEXT UP this week, FEATURED on other weeks).
2026-06-15 19:32:57 -07:00
BoulderBadgeDad
5856eefd7c Video Calendar: featured billboard, tactile cards, faster art
Take the calendar visuals up a level + fix slow image loads.

- Featured "Next up" billboard: the soonest upcoming episode as a cinematic hero
  (backdrop, pulsing accent, show title, S·E, air time, "View details" → modal).
- Cards: cursor-following 3D tilt + hover lift/scale/glow bloom; ambient glow
  dialed way down so it's calm at rest and only blooms on interaction.
- Faster art: poster proxy takes ?w= and asks the source for a thumbnail (Plex
  transcoder w/ original fallback, Jellyfin maxWidth, TMDB size bucket) — calendar
  requests ~500px instead of full backdrops. Skeleton shimmer → fade-in on load.
2026-06-15 19:25:45 -07:00
BoulderBadgeDad
8e00670491 Video Calendar: 7-day week grid with air times + episode modal
A new isolated Calendar page (/api/video/calendar) — every upcoming episode for
your owned shows across a real 7-day week (today first), as art cards sorted by
air time with a per-cell breathing colour glow.

- Air times: enrich shows with TVDB airsTime (new shows.airs_time column +
  migration); cells show + sort by time, streaming (untimed/00:00) = "Anytime".
  One-time background backfill re-queues already-matched shows for the time.
- Click an episode → styled modal (show backdrop hero, episode still/synopsis,
  air date+time, owned badge, genres, "about the show"), with an explicit
  "Open full show page" action instead of navigating on click.
- Isolated: reads only video_library.db, writes nothing to the music side.
2026-06-15 18:28:02 -07:00
BoulderBadgeDad
5b6ef295f8 Video detail: trailer billboard reveals with a left→right wipe + fade
Replace the flat opacity crossfade with a soft, feathered left→right wipe of the
trailer in (mask via @property, falls back to crossfade) — "a rollover and a
fade". The wipe now fires ONLY when YouTube reports PLAYING (handshake + state
events), not on a blind timer, with a 4.5s safety net. When the trailer ENDS the
original backdrop fades back in and the iframe is torn down.
2026-06-15 16:55:00 -07:00
BoulderBadgeDad
80dd2ff21c Video side: own server connection (Plex/Jellyfin), music-style picker, isolated
Give the video side its OWN server connection — pre-filled from music but stored
separately in video.db, fully isolated (video never writes music config/state).

- Effective config helpers (video_plex_config / video_jellyfin_config): video's
  own creds when set, else inherited read-only from music. resolve_video_server +
  _build_source + watch-link/poster/dashboard all use these (own db threaded in).
- Server Connection UI mirrors music's server picker (toggle = select + configure),
  scoped to Plex/Jellyfin, at the bottom of the Connections tab.
- Jellyfin: independent client built from video's creds; explicit USER picker like
  music (list users → that user's libraries); honors the pick, admin fallback.
- Honest connection diagnostics (reachable vs 401 vs no-users) instead of a vague
  "auth failed".
- Auto-save on change with toasts; the shared Save button is intercepted on the
  video side so it saves video settings (and can't fire a music save).
- Enrichment status now PUSHES over the socket like music (no browser polling /
  access-log flood); config save only rebuilds workers when an API key changed.
- Seam tests for effective-config inheritance/override + isolation guard.
2026-06-15 16:42:22 -07:00
BoulderBadgeDad
8f6f992670 video: self-contained video Source section; hide music's server settings on video
Per the desired model: video Settings has its OWN server settings, separate from
music. The 'Video Source' group now holds the Plex/Jellyfin pick AND the Movies/TV
library mapping (moved out of music's Plex panel so it's not coupled to the music
active server). The whole MUSIC 'Server Connections' group is hidden on the video
side. Video reuses the shared Plex/Jellyfin credentials: the picker shows both
servers, the configured ones selectable, the rest 'not connected — set up in Music
settings'. Music settings keep their own server settings, untouched.
2026-06-15 15:10:53 -07:00
BoulderBadgeDad
f93c211de3 fix: video side can configure server connections again, without changing music
The previous hard guard blocked saveSettings entirely on the video side — which
also blocked entering/saving Plex/Jellyfin connection creds (shared, legit). Now
the save runs, but on the video side it PRESERVES the persisted active_media_
server (window._persistedActiveServer) instead of reading the toggle — so video
can set up a connection without ever switching the music server. Auto-save stays
suppressed on the video side (no heavy music re-init from video field edits).
2026-06-15 14:59:36 -07:00
BoulderBadgeDad
5f1ad517c2 video: fully decouple video server from music (no fallback + hard save guard)
Two real coupling bugs, fixed:
- resolve_video_server still fell back to the music active server when no explicit
  video pick was set, so changing the music server changed video. Removed: video
  now uses ONLY an explicit video pick or the configured server(s) (Plex default
  when both). Changing the music server never changes video.
- The shared settings page could persist active_media_server from the video side.
  Guarded saveSettings itself (not just the debounced auto-save) so it NEVER runs
  while data-side=video — video saves only via /api/video/*.

Test: video does not follow the music active server. One-way isolation, both ways.
2026-06-15 14:52:37 -07:00
BoulderBadgeDad
a4d6e78bce fix: video side can no longer change the music server via the shared settings page
The settings page is shared; on the video side its auto-save (debouncedAutoSave-
Settings) still fired when editing VIDEO fields (TMDB key, watch region, autoplay),
and saveSettings reads the server toggle from the DOM + persists active_media_
server — so clicking the toggle to Jellyfin on the video side and then editing any
field would write active_media_server=jellyfin to the shared config (and trigger a
full music save). Guard: never auto-save music settings while data-side=video.
Video settings persist via /api/video/*; the toggle re-syncs to the real active
server on every Settings load. One-way isolation restored.
2026-06-15 14:42:36 -07:00
BoulderBadgeDad
b7ae32220b video: coverage-card click re-queues failed matches (match music manager)
The video worker manager's coverage cards only switched the view — they never
re-queued failed items, so not_found/error items sat in the retry cooldown and
the worker reported 'all matched' / idle while failures piled up. Clicking a
Movies/Shows coverage card now pins that group AND resets its failed matches
(not_found/error -> pending) so the worker sweeps them, mirroring the music
worker manager. (Episodes are a sync cascade, not a match queue, so unchanged.)
2026-06-15 14:27:25 -07:00
BoulderBadgeDad
c9900d91b0 video: hide Navidrome + Standalone server toggles on the video side
They're music-only, so a CSS rule scoped to body[data-side=video] hides those two
toggle buttons in the shared Server Connections section. Plex/Jellyfin stay
(for connecting a video server). Music side untouched.
2026-06-15 14:12:04 -07:00
BoulderBadgeDad
b2dbd6dec5 video: Video Source settings panel + library gating; move Detail prefs to own group
Settings → Video Source shows which server video uses (✓ Plex/Jellyfin), a
Plex/Jellyfin picker when both are connected, or a clear 'connect Plex or
Jellyfin' message when neither (Navidrome/Standalone are music-only and not
offered). The Library shows a non-breaking 'no video server' banner + disables
Scan until one is connected. Detail Pages prefs moved into their own 'Video
Preferences' group. /api/video/server GET+POST drives it.
2026-06-15 13:55:42 -07:00
BoulderBadgeDad
b2adc63a6a video: where-to-watch region (no longer hardcoded US)
A saved 'Where-to-watch region' picker in Settings → Detail Pages (19 common
regions, default US). The engine reads it for the providers in extras +
tmdb_detail (region in the cache key), and the detail page labels the section
'Where to Watch · <region>' so you know which market you're seeing.
2026-06-15 13:28:17 -07:00
BoulderBadgeDad
b8de46d2ad video: episode detail expand (guest stars + bigger still)
Click any episode (owned or missing) to expand it: a larger still, full overview,
and the episode's guest stars (clickable to the person page). Lazy-loaded per
episode from TMDB by the show's tmdb_id and cached. New client.episode_detail +
engine.episode_extra + /api/video/episode/<show_tmdb>/<season>/<episode>.
2026-06-15 13:20:15 -07:00
BoulderBadgeDad
3359e3c111 video: owned-media tech specs on movie detail (Plex-grade)
We already scanned codec/audio/source/size but only showed resolution. The movie
detail Details block now surfaces Quality / Video (HEVC, H.264…) / Audio / Source
(Blu-ray, WEB-DL…) / Size, and lists every version/edition you own when there are
multiple files. movie_detail now returns all media_files (not just the largest).
2026-06-15 13:13:26 -07:00
BoulderBadgeDad
e5724c6faa video: sticky back button on detail/person pages
The detail pages are long now (cast, videos, photos, reviews…), so the absolute
back button scrolled out of reach. It's now position:sticky (pinned top-left as
you scroll), with a negative bottom margin so the billboard still sits full-bleed
under it.
2026-06-15 12:40:48 -07:00
BoulderBadgeDad
1423e50ccd video: fix Play/Trailer buttons vanishing on owned movies
maybeRefreshMovie re-fetched the library payload (no trailer/server — those come
from the extras call) and replaced data, so renderActions re-rendered without the
buttons right after they appeared. It now carries over the live extras fields
(server/trailer/next_episode), mirroring the show reloadDetail fix.
2026-06-15 12:28:32 -07:00
BoulderBadgeDad
3f439e4277 video: fix back-button loop when a 'More Like This' item is owned
Clicking a recommendation/cast/similar link to a title you OWN opens it via a
tmdb URL that redirects to the library detail — but the redirect PUSHED a new
history entry, so Back landed on the tmdb URL which redirected forward again =>
stuck loop + a self-referencing 'Back to <same movie>' label.

The redirect now REPLACES the redirecting entry (keeping its layer + origin)
instead of pushing, so Back unwinds cleanly to where you actually came from.
2026-06-15 12:18:05 -07:00
BoulderBadgeDad
2f3e4b128b video: autoplay billboard trailer (opt-in setting)
After a couple seconds on a detail page, a muted trailer plays behind the hero
(Netflix/Disney+ style) with mute/unmute + stop controls; the backdrop fades back
when stopped. Stops on navigate-away/modal-open (no orphaned audio).

Gated by a 'Autoplay trailers in the billboard' toggle in video Settings →
Detail Pages (default on). Backed by billboard_autoplay in video_settings, read
via a lightweight /api/video/prefs. Tests updated for the new config field.
2026-06-15 11:57:29 -07:00
BoulderBadgeDad
be54fccc63 video: featured TMDB review on movie/TV detail pages
extras() now returns a featured review (author, rating, snippet, date); the detail
page shows it in a card with a clamped body + Read more/less. In-app (no external
link).
2026-06-15 11:52:07 -07:00
BoulderBadgeDad
df8f6a2acd video: person page — photos gallery + lightbox + 'also known as'
person() now returns profile images (thumb+full) and also_known_as. The person
page shows an 'Also known as' line and a Photos rail that opens the shared
fullscreen lightbox (arrows/Esc/counter).
2026-06-15 11:49:43 -07:00
BoulderBadgeDad
8bbdd712f9 video: per-title accent on preview + person pages (same-origin image proxy)
Owned detail pages sample the poster for the per-title glow, but preview/person
pages fell back to the theme accent because their TMDB images are cross-origin
(canvas taint). Added /api/video/img — a same-origin proxy restricted to
image.tmdb.org (SSRF-safe) — so:
- preview (tmdb) detail samples its poster via the proxy → real accent;
- the person page samples the portrait → per-person accent on the ring/glow/role.
Tests: route registered + proxy rejects non-tmdb URLs.
2026-06-15 11:46:49 -07:00
BoulderBadgeDad
c5e30530c7 video: card hover shows an info badge, not a misleading play button
Search / trending / filmography poster cards open the DETAIL page on click, so
the center ▶ read as 'play' wrongly. Swapped it for an italic 'i' info badge
('view details').
2026-06-15 11:42:32 -07:00
BoulderBadgeDad
9c29414e6d video: detail pages — Photos gallery+lightbox, all Videos, Details/keywords, full cast
Frontend for the new data, on both movie + TV detail pages:
- Photos: a backdrops rail → fullscreen lightbox (‹ › nav, keyboard arrows, Esc,
  counter).
- Videos: a rail of every trailer/teaser/clip/featurette (YouTube thumbs) → opens
  in the existing player modal.
- Details: budget / box office / language / country + keyword tag chips.
- Cast & Crew gets a 'View all N' → full-cast modal (clickable to person; TV shows
  per-actor episode counts).
All cached server-side (instant re-open) and lazy-loaded images. Isolated; shell
tests cover the new sections + modals.
2026-06-15 11:23:31 -07:00
BoulderBadgeDad
595ea2bfbd video: Where to Watch — provider logos are badges + one real link
TMDB only exposes a single aggregate 'where to watch' link (no per-provider deep
links), so N identical links read as broken. Streaming providers are now
non-clickable availability badges, followed by ONE accent 'Where to watch ↗'
link to the JustWatch/TMDB page. The Plex/Jellyfin tile keeps its real per-item
server deep link.
2026-06-15 11:00:15 -07:00
BoulderBadgeDad
e0b9245ba3 video: Play button reads 'Play on <logo>' (drop redundant text, better contrast)
The button showed the Plex logo AND the word 'Plex' ('[logo] Play on Plex'), all
white on a bright green bg — cluttered + hard to read. Now it's '▶ Play on
<server logo>' (the logo is the brand name) on a deeper green gradient so the
light Plex/Jellyfin wordmark reads clearly.
2026-06-15 10:54:49 -07:00
BoulderBadgeDad
b937343768 video: fix stuck ep-sync banner + vanishing Play/Trailer buttons on shows
Two bugs when a show's episode list backfills on view:
- The 'Fetching full episode list…' banner never hid: .vd-ep-syncing (and
  .vd-next-ep) set display:flex, which overrode the [hidden] attribute's
  display:none, so el.hidden=true did nothing. Added a guard so [hidden] always
  wins on the detail/search/person pages.
- Play & Trailer buttons vanished after the sync: reloadDetail replaced data with
  a fresh show_detail payload (no server/trailer — those come from extras), so
  renderActions re-rendered without them. reloadDetail now carries over the live
  extras fields (server/trailer/next_episode).
2026-06-15 10:32:04 -07:00
BoulderBadgeDad
81546cff69 video: detail-page feedback fixes (Play button style, crew links, ep-sync UX)
- Play button now matches the Trailer/Watchlist buttons exactly (same size/shape),
  just green — consistent hero buttons.
- Where to Watch: drop the duplicate streaming provider that matches your server
  (no more two 'Plex' entries). Providers still share TMDB's single JustWatch
  'where to watch' link (that's all TMDB gives).
- Director/Creator names (hero line + Cast & Crew section) are clickable → the
  in-app person page.
- Opening a show whose full episode list isn't cached yet now shows a 'Fetching
  the full episode list…' banner with a spinner, instead of a silent ~20s gap
  before missing episodes pop in.
2026-06-15 10:21:04 -07:00
BoulderBadgeDad
e94ea39187 video: skeleton loader for detail pages (shimmer billboard, not 'Loading…')
Replaces the plain loading text with a shimmering billboard placeholder (logo /
meta / overview / button bars) over the accent wash, so opening a title (esp. a
TMDB preview) feels instant and premium. Honors prefers-reduced-motion.
2026-06-15 09:59:39 -07:00
BoulderBadgeDad
8441ece6f0 video: person page — sort, department filter, age (best-in-class filmography)
- Sort dropdown: Newest / Oldest / Most popular.
- Department filter (Acting / Directing / Writing / …) for multi-hyphenates —
  only appears when a person has 2+ departments. Composes with the existing
  kind + ownership filters; every chip shows a CONTEXTUAL count (what you'd get
  if you clicked it, given the other active filters).
- Age in the hero meta ('47 years old', or lifespan + 'aged N' for the deceased).
- Backend: each person credit now carries its department (cast=Acting,
  crew=its TMDB department). Seam test added. 249 video-suite tests pass.
2026-06-15 09:57:39 -07:00
BoulderBadgeDad
6eed3d7775 video: best-in-class detail billboards (Play CTA, collections, recs, next-ep)
Movie + TV detail pages get the things a premium app surfaces:
- Primary 'Play on Plex/Jellyfin' button (white Netflix-style CTA with the server
  logo) in the billboard for owned items — deep-links straight to the item.
- 'Directed by' (movies) / 'Created by' (shows) line in the hero.
- Movies: a Collection/franchise row (the other films in the set), release-ordered.
- 'More Like This' now uses TMDB recommendations (better curated), similar as
  fallback.
- TV: a 'Next Episode' banner (S/E + name + air date) for continuing shows, and
  the selected season's overview under the season nav.
All in-app (cards drill into library/preview detail). Shell tests updated.
2026-06-15 09:50:07 -07:00
BoulderBadgeDad
df889c0c6e video: Where-to-Watch server tile uses the real Plex/Jellyfin logo
The 'Play on your server' tile now shows the actual server logo (same Plex/
Jellyfin art as the header server toggle) on a dark tile with the green owned
glow, instead of a generic play glyph. Falls back to the play glyph if the logo
fails to load.
2026-06-15 09:11:19 -07:00
BoulderBadgeDad
0246842000 video: Where to Watch — play-on-your-server tile + clickable providers
The 'Where to Watch' section is now actionable:
- For an OWNED title it leads with a 'Play on Plex/Jellyfin' tile (green, play
  glyph) that deep-links straight to the item on your server — Plex via the
  app.plex.tv web app (machineIdentifier fetched once + cached), Jellyfin via its
  web detail page. Built in engine.item_extras from the row's server_source +
  server_id and the shared media-server config (same source poster.py uses).
- Streaming providers (TMDB/JustWatch) are now clickable → the where-to-watch
  page, with a hover lift.

Owned-only: preview (tmdb) items have no library row so they get no server tile.
Seam tests cover the Jellyfin + Plex link building and the unowned no-link case.
240 video-suite tests pass.
2026-06-15 08:49:35 -07:00
BoulderBadgeDad
7081dad94e video: person page — filter filmography + Known For by owned vs missing
Adds an ownership filter (All / In Library / Missing) next to the kind tabs on
the person page. Both the Known For rail and the full filmography respect it, so
you can see 'what of this person's work do I actually have' (or what's missing).

- Each credit already carries library_id (owned), so filtering is client-side.
- Contextual counts: the kind tabs count within the current ownership filter and
  vice-versa, so the numbers always match what you'll see.
- 'In Library' filter glows green (matches the owned ribbon); empty states for
  'you have everything' / 'nothing owned yet'.
2026-06-15 08:42:26 -07:00
BoulderBadgeDad
769dc62b87 video: cooler person hero + perf pass on search/person pages
Person hero glow-up:
- Cinematic ambient — blurred portrait + an accent colour mesh + vignette, masked
  to fade into the page (was a flat wash).
- Portrait gets a slowly-rotating accent gradient ring (masked donut, GPU
  transform) and a gentle float; an accent ring + glow frame.
- An accent role tagline ('ACTOR' / 'DIRECTOR' …) above a gradient-filled name,
  plus a credits-count chip. Honors prefers-reduced-motion.

Performance:
- Long filmography grids use content-visibility:auto + contain-intrinsic-size so
  the browser skips off-screen cards (cheap scroll), and skip replaying the
  entrance animation as cards recycle.
- Hero layers are static (painted once); only two tiny composited transforms
  animate. Posters/photos stay on small TMDB sizes + lazy-load; trending cached;
  search debounced + request-sequenced.

Pure visual/perf layer — same data + isolation. Shell/JS tests pass.
2026-06-15 08:37:32 -07:00
BoulderBadgeDad
d6d0dc537c video: smart multi-layer back button + next-level search/person
Smart back (mirrors music's artist-detail): the top-left back button now
remembers where you actually came from, many layers deep. It keeps an origin
stack ({page} or {detail title}) and stamps each history entry with its layer
depth, so:
- the label is dynamic — '← Back to Search' / '← Back to The Bear' / '← Back to
  <person>' — instead of a hardcoded 'Library'/'Back';
- backing out of the first layer returns to the page you started from (Search,
  Watchlist, wherever), not always the Library;
- browser Back and our button both unwind the chain one layer at a time, in sync.
Fixes: search → person → back → movie used to mislabel as 'Library' and dump you
in the library.

Next level:
- Search isn't a blank box when idle — a 'Trending this week' rail (TMDB
  trending, owned/preview annotated). Returns when you clear the query.
- Person page gets a 'Known For' hero rail (top titles by popularity) above a
  full filmography now sorted chronologically (newest first).

Backend: TMDBClient.trending + engine.trending (+library annotation), route
/api/video/trending. Isolated; 237 video-suite tests pass.
2026-06-15 08:30:17 -07:00
BoulderBadgeDad
87f414c8c7 video: cinematic Netflix-grade redesign of the Search + Person pages
Matches the show-detail page's vibe (--vd-accent-rgb glows, full-bleed, rise/
fade entrances) instead of the plain library shell.

Search:
- Cinematic hero — big title, ambient accent glow, a large glowing search bar
  that lights up on focus.
- Results are premium 2:3 poster cards: hover lift + accent glow, poster zoom,
  gradient overlay, a play affordance, owned/preview ribbon + rating chip.
  People render as circular portrait cards. Grouped rows with accent-pill counts.
- Polished empty/hint states with a floating icon.

Person:
- Full-bleed cinematic hero with an ambient blurred-portrait backdrop (per-person
  color), a glowing circular portrait, oversized name, meta as glass chips.
- Bio with Read more/less; filmography as the same premium poster cards with
  premium pill tabs (All / Movies / TV).

Pure visual layer — same data hooks, routing and isolation. Shell/JS isolation
tests still pass.
2026-06-15 08:12:53 -07:00
BoulderBadgeDad
288d44155d video: in-app Search + TMDB-backed (preview) detail + person pages
Search any movie / show / person (TMDB multi-search) entirely in-app. Results
that you already own link straight to the library detail; the rest open a
TMDB-backed 'preview' detail that reuses the exact same Netflix billboard UI
(direct image URLs, nothing owned/enriched). Everything resolves back into
SoulSync — no external links on un-owned titles.

- Search page (video-search.js): debounced /api/video/search, grouped
  movies/shows/people cards (reuses .library-artist-card) with owned/preview
  ribbons. People open the person page.
- Source-agnostic detail (video-detail.js): loads from /api/video/detail
  (library) or /api/video/tmdb (preview); art helpers pick proxy vs direct URLs;
  tmdb shows lazy-load episodes per season; owned-via-tmdb-url auto-redirects to
  the library detail.
- 'More Like This' now drills in-app (tmdb detail, redirects if owned); cast/crew
  link to a new in-app person page (bio + filmography, each credit owned/preview).
  Library credits now carry tmdb_id so owned-item cast is clickable too.
- Backend: TMDBClient.search/full_detail/person (+ shared _parse_extras);
  engine.search/tmdb_detail/tmdb_season/person_detail; db.library_id_for_tmdb;
  routes /search, /tmdb/<kind>/<id>, /tmdb/show/<id>/season/<n>, /person/<id>.

Isolated (one-way): video-only files, no music imports, music shell untouched.
Seam tests: search/full_detail parsing, tmdb_detail assemble+redirect, search +
person library annotation, library_id_for_tmdb, route registration, shell/JS
isolation. 234 video-suite tests pass.
2026-06-14 23:31:35 -07:00
BoulderBadgeDad
b50f7c12f4 video: promote OMDb to a full 3rd enrichment worker (parity with TMDB/TVDB)
OMDb now has the same setup as TMDB/TVDB: a yellow dashboard orb (★ glyph) that
spins/idles in the worker-orb animation, an entry in Manage Workers (Ratings
coverage cards, pause/resume, retry, search), and a BACKGROUND ratings pass.

- Worker 'ratings mode' (is_ratings): instead of a match queue it pulls
  ratings_next() (library items with an imdb_id and ratings_synced=0), fetches
  IMDb/RT/Metacritic, applies + marks synced. So the whole library gets ratings,
  not just titles you open (schema v7: ratings_synced).
- enrichment_breakdown/unmatched/retry get an 'omdb' branch (coverage =
  ratings-filled, not matched). build_clients includes omdb; the lazy on-view
  backfill uses the omdb worker's client.
- Dashboard orb + Manage Workers entry (★ glyph fallback where there's no logo),
  yellow accent.

Seam tests: omdb worker rates the queue (ratings mode), ratings breakdown.
2026-06-14 23:07:35 -07:00
BoulderBadgeDad
f06728b0a7 video detail: IMDb / Rotten Tomatoes / Metacritic ratings (OMDb)
Next-level: real critic/audience scores beyond the TMDB star. OMDb (free key,
keyed by the imdb_id we already capture) returns IMDb / RT / Metacritic.

- OMDBClient (ratings + test); built as a non-worker 'ratings_client' on the
  engine. _backfill_ratings runs in both lazy detail refreshes (overwrites, since
  ratings are dynamic). schema v6: imdb_rating / rt_rating / metacritic on
  movies + shows; show/movie payloads return them.
- Billboard renders branded rating badges (IMDb yellow, RT tomato/splat by
  fresh/rotten, Metacritic green/yellow/red by score). Lazy refresh also triggers
  when an imdb_id exists but ratings are missing.
- OMDb API-key frame in Settings (parity with TMDB/TVDB) + config GET/POST +
  /enrichment/omdb/test.

Seam tests: OMDb parse, engine ratings backfill, apply_ratings + payload, config
includes omdb.
2026-06-14 22:54:53 -07:00
BoulderBadgeDad
e2a8d3339a video routing: re-assert the detail URL against late/async music redirects
The reload restore could still be clobbered if music's router redirects on a later
tick. Re-assert the /video-detail URL at 120/350/700ms after restore, but ONLY
while the detail subpage is still showing, so it survives an async redirect without
ever fighting genuine navigation away.
2026-06-14 22:27:42 -07:00
BoulderBadgeDad
53391372c3 video: fix detail reload (music router clobber) + reliably show missing episodes
Reload bug: music's router boots first, rewrites an unknown /video-detail/... URL
to /dashboard, and my init read the already-changed URL (no restore) AND dispatched
open-detail before video-detail.js was listening (empty page + stray back button).
Fix: capture the path at SCRIPT-EVAL time (before music boots) and DEFER the
restore to a macrotask so every DOMContentLoaded handler is registered and music's
initial routing has run — then re-assert the real URL. Reload/deep-link now restore
the exact item.

Missing-episodes bug: the full-episode-list cascade only ran via the lazy refresh,
which was gated on ART being missing — so a show that already had posters/logo
never pulled its episode list (stayed owned-only). Added shows.episodes_synced
(schema v5): the worker sets it after a full cascade; show_detail returns it; the
lazy refresh now triggers when NOT synced, so owned + missing episodes populate.
2026-06-14 22:26:37 -07:00
BoulderBadgeDad
0598f5fbda video: real-link routing for detail pages (parity with music artist-detail)
Mirrors the music side instead of a custom scheme: library cards are genuine
<a href='/video-detail/<source>/<kind>/<id>'> links, so reload / new-tab / Back /
Forward all work. Left-clicks are intercepted into SPA nav + history.pushState;
modifier-clicks fall through to the real URL.

- popstate restores the detail from the URL; the '← Library' back button is real
  history.back(); deep-link / reload to a /video-detail/... URL is restored on load
  (path captured before applySide can clear it). Server already serves the SPA for
  these paths (permissive catch-all) — no backend change.
- The path carries a SOURCE segment ('library' = a video.db id today; 'tmdb' /
  search results not yet in the library come later) — your library-vs-search split.
- Coexists with music's pathname router (only touches /video-detail/* and its own
  popstate); music's link/popstate handlers ignore these paths.

Tests: real-link cards, pushState/popstate routing, source segment.
2026-06-14 22:13:51 -07:00
BoulderBadgeDad
6e526d7745 video detail: deeper TMDB extras — trailer, where-to-watch, more like this
Phase 4: dynamic extras fetched LIVE per view (providers change, so not cached)
via GET /detail/<kind>/<id>/extras → engine.item_extras → TMDB
(videos + watch/providers + similar in one call).
- Trailer: a '▶ Trailer' action that opens an in-app YouTube modal embed (Esc /
  click-away to close).
- Where to Watch: provider logos for the region (JustWatch via TMDB).
- More Like This: a poster row of similar titles linking out to TMDB.
Both movie + show pages; all keyless (same TMDB key).

Seam tests: extras parse (trailer priority, provider/similar shape), item_extras
gating on tmdb_id, route registered, markup hooks. (RT/Metacritic via OMDb needs
its own key — offered separately.)
2026-06-14 21:42:41 -07:00
BoulderBadgeDad
efa0632883 video detail: clearlogo hero (TMDB images — no new key)
Phase 3: the stylized transparent title logo replaces the text title in the
billboard (the big Plex/Netflix 'premium' jump). Sourced from TMDB images
(append_to_response=images, include_image_language=en,null) in the same detail
call — no Fanart key needed.

- schema v4: movies.logo_url / shows.logo_url (idempotent migration).
- TMDB client picks an English logo (then language-neutral, then any); enrichment
  backfills logo_url gap-only; show/movie payloads return 'logo'.
- Billboard shows the logo img (with graceful fallback to the text title on error
  / when absent; title kept visually-hidden for a11y). Lazy on-view refresh now
  also triggers when the logo is missing, so existing libraries fill it in.

Seam tests: English-logo pick, backfill + payload, schema.
2026-06-14 21:36:14 -07:00
BoulderBadgeDad
59c88fa0db video: Movie detail page (Netflix flat layout), movies now clickable
- Movie cards in the library now drill into a movie-detail page (both kinds use
  the same open-detail event / video-side navigation).
- New video-movie-detail subpage reuses the .vd-* hooks; video-detail.js is now
  kind-aware (root() targets the active page by kind, billboard/links/actions
  branch on movie vs show). Flat layout: billboard + a details strip (released /
  runtime / studio / status / critic score / quality) + the shared Cast & Crew row.
- Lazy on-view backfill for movies too: engine.refresh_movie_art re-fetches TMDB
  (cast/genres/backdrop/ratings) when missing, regardless of match status, via
  POST /detail/movie/<id>/refresh-art. movie_match_info added.

Seam tests: movie refresh backfills cast/genres, movie_match_info, route
registered, movie subpage markup, cards clickable for both kinds.
2026-06-14 21:31:51 -07:00
BoulderBadgeDad
b9b3b9eed3 video 'capture everything': cast & crew (people + credits) + cast row UI
Last capture piece, schema v3 (new people + credits tables; CREATE IF NOT EXISTS
migrates existing DBs on restart, no wipe):
- people deduped by tmdb_id; credits link to exactly one movie OR show (separate
  nullable FKs + CHECK, no polymorphic id) with department/job/character/order.
- TMDB client appends credits to the detail call (free) and parses cast (name,
  character, photo, billing order) + headline crew (directors/writers/creators).
- enrichment_apply backfills cast/crew gap-only (never clobbers); show/movie
  detail return cast + crew. Populates on view via the existing lazy refresh-art.
- Cast & Crew section on the detail page: grouped crew line + a horizontal
  cast row with circular TMDB headshots, names, characters (accent hover).

Seam tests: TMDB credit parse (+ job filtering, created_by), backfill + people
dedup across titles, gap-only no-clobber, payload shape.
2026-06-14 21:20:08 -07:00
BoulderBadgeDad
8002f93220 video: include season id in show_detail (fix /poster/season/undefined 404)
show_detail's season payload omitted the season id, so the frontend built
/api/video/poster/season/undefined and 404'd — season posters never showed even
once cached. Add the id; harden seasonArt to fall back to the show poster if id
is ever missing. Test pins that seasons carry an int id.
2026-06-14 21:02:35 -07:00
BoulderBadgeDad
986059626f video: lazy season-art backfill on detail view (fixes matched-show art gap)
Root cause: season posters / episode art backfill happen during a show's TMDB
*match*, but already-matched shows never re-run ('Retry all failed' only resets
not_found/error), so existing libraries never got the art.

Fix (Boulder's idea): fetch-on-view + cache. When a show detail opens and any
season lacks a poster, the page calls POST /detail/show/<id>/refresh-art →
engine.refresh_show_art re-fetches /tv/<id> via the TMDB client and backfills
season posters + episode art gap-only, regardless of match status. Cached, so
it's a one-time cost per show; runs once per view; re-renders when done.

Seam tests: refresh_show_art backfills a MATCHED show's seasons, needs TMDB
configured, show_match_info, route registered.
2026-06-14 20:56:57 -07:00
BoulderBadgeDad
fcd4af0efd video manage-workers modal: Process First Everywhere, search/filter, live glow
Brings the video modal to parity with music's:
- 'Process first everywhere' control (Movies/Shows/Auto) in the topbar — a global
  setting that pins which kind every worker processes first. enrichment_next takes
  a priority kind; the worker reads enrichment_priority each loop; GET/POST
  /api/video/enrichment/priority persists it. Reuses music's .em-global styling.
- Needs-matching bar now has a live count, status filter (All unmatched / Not
  found / Pending) and a debounced search (reusing .em-select / .em-search),
  matching the music modal. Episode view stays read-only.
- Live glow (scoped to #vem-overlay): pulsing running dot, accent glow on the
  selected worker row + active process-first/kind, and a pulsing 'now processing'
  chip in the worker accent. Music's shared .em-* styles untouched.

Seam tests: priority pins kind in enrichment_next + worker honors the setting,
priority endpoint GET/POST + validation, modal feature markup pinned.
2026-06-14 18:28:22 -07:00
BoulderBadgeDad
80f1051e8a video enrichment: cascade episode backfill from the TMDB show worker
Episodes ride along with their show instead of being a separate (tens-of-thousands)
queue: when the TMDB worker matches a show, it now backfills every season's
episodes — still / overview / rating — via /tv/<id>/season/<n> (one call per
season, gap-only so server data is never clobbered). Also backfills season
overviews.

The worker manager 'knows about it': the TMDB breakdown gains an Episodes
coverage entry (matched = has art, rest = pending), shown as its own card; the
Episodes view lists episodes still missing art. It's coverage-only, kept out of
the worker's idle/pending calc so it never blocks 'Complete'.

Seam tests: client season parse, worker cascade fills episodes, gap-only backfill
+ season overview, breakdown coverage (tmdb only), missing-art list, idle calc
ignores episode coverage.
2026-06-14 18:09:24 -07:00
BoulderBadgeDad
e1e0e29432 video 'capture everything' (phase 1): stills, genres, ratings, tagline
Captures the richer metadata the media server already exposes (schema v2;
idempotent migrations + CREATE IF NOT EXISTS, so an existing DB upgrades on
restart with no wipe):
- movies: tagline, rating (audience), rating_critic; shows: tagline, rating,
  first/last air date; episodes: still_url + rating.
- Genres as a normalised many-to-many (genres + movie_genres/show_genres link
  tables — no comma-blob), deduped, replace-on-upsert.
- Plex (.genres/.tagline/.audienceRating/.rating/.thumb) + Jellyfin (Genres/
  Taglines/CommunityRating/CriticRating/Premiere+EndDate/episode Primary) both
  extract them; episode stills served via /api/video/poster/episode/<id>.
- Detail payloads return genres/tagline/rating/air-dates + per-episode has_still;
  the billboard shows a tagline, ★ score, genre chips, and episode rows render
  REAL stills (no more orange placeholder once scanned).

Seam tests for genre dedup/replace, show+episode capture, episode still ref.
Cast/crew (people + credits) is the next phase.
2026-06-14 17:17:20 -07:00
BoulderBadgeDad
c450fa1f9a video detail: 4 season-nav views + view toggle, real Watchlist, Missing filter
Season selection is now switchable via a view toggle (persisted): poster RAIL
(scrollable season cards w/ coverage), TIMELINE band (segments sized by episode
count, filled by owned), TABS (pills), and the LIST dropdown. All drive the same
selection; episodes fade in on change.

- Watchlist button is now REAL: toggles shows.monitored via POST /api/video/monitor
  (set_monitored), reflects 'In Watchlist' state. show_detail returns monitored.
- 'Get Missing' + a 'Missing only' toolbar toggle filter the episode list to
  unowned episodes (actual downloading is the future acquisition subsystem).

Seam tests for the monitor endpoint + bad-input guards; shell hooks updated.
2026-06-14 17:05:51 -07:00
BoulderBadgeDad
20f1214b68 video detail: reuse the artist-hero button + badge styles
- Action buttons now reuse the exact artist-detail classes
  (.library-artist-watchlist-btn + .discog-download-btn/.discog-btn-compact with
  shimmer) instead of bespoke vd-btn styling — identical to the music hero. Wired
  by data-attr (music binds those by id, so no hijack).
- IMDb/TMDB/TVDB source links now render as .artist-hero-badge chips (logo + short
  text fallback, TVDB inverted), matching the artist hero's #artist-hero-badges
  treatment instead of the off-standard text tags.
2026-06-14 16:47:18 -07:00