Commit graph

4281 commits

Author SHA1 Message Date
BoulderBadgeDad
59dafff96a YouTube dates: custom InnerTube parser as the primary bulk source (validated live)
A Beatport-style custom parser, but pointed at YouTube's own InnerTube browse API
— no key, no Java, no third-party proxies. Reads the channel 'Videos' tab's
lockupViewModel items (contentId + relative 'N ago' text → approximate date,
which is fine for year-seasons), paginating via continuation tokens.

Validated LIVE before wiring in: GMM/MrBeast/Veritasium each return 240 dated
videos in ~7s with correct year spreads (e.g. Veritasium 2017→2026), ~8 requests
per channel (light on rate limits). End-to-end enricher run cached 240 dates.

Enricher order is now: InnerTube (primary) → configured proxy (opt-in, only if
empty) → yt-dlp per-video (the fallback / basic method, for undated gaps + exact
dates). Pure parsing is fully unit-tested (relative-date conversion, lockup
filtering, continuation-token selection, pagination, guards). 182 video tests
green; additive — if InnerTube ever breaks, it returns {} and falls back, so
nothing breaks.
2026-06-17 12:52:20 -07:00
BoulderBadgeDad
a83df05ce2 YouTube dates: proxy is opt-in (public instances are dead); yt-dlp is the default
Live-tested the proxy against real channels — it does not work: Invidious public
APIs are 401/403 (auth-walled/disabled), and the one reachable Piped instance
returns EMPTY video lists for every channel (MrBeast/LTT/Veritasium all 0). Also
fixed a real bug: _harvest bailed on an empty first page instead of following the
nextpage token.

So the proxy is now OPT-IN via a youtube_proxy_instances setting (empty by
default → skipped entirely, no wasted dead calls). The default path is RSS +
the parallelized yt-dlp fallback, which actually works (your log: 9/36/25 dates
cached). Power users can point the setting at a live instance if they find one.
2026-06-17 12:30:21 -07:00
BoulderBadgeDad
964b9bfdd7 YouTube enricher: align INFO output fully with the other video workers
Verified the three engine workers (tmdb/tvdb/omdb) log per-item INFO under
video_enrichment.worker. Matched the youtube enricher to that exactly:
- INFO is now purely per-item ('Dated <ch> '<video>' -> <date>' / 'No date
  for …') + one terse per-channel summary ('Dated N/M videos for <ch>'),
  mirroring 'Matched … -> TMDB ID' / 'Synced full episode list…'.
- The 'YouTube dates:'-prefixed phase/diagnostic lines (enriching / proxy
  returned / failures) demoted to DEBUG.
2026-06-17 12:23:13 -07:00
BoulderBadgeDad
220f2ad4de YouTube enricher: consistent log output with the other video workers
- Logger renamed video.youtube_enrichment -> video_enrichment.youtube (same
  namespace as video_enrichment.worker).
- Per-item lines as it dates each video — 'Dated <channel> '<video>' -> <date>'
  / 'No date for …', mirroring the workers' 'Matched <kind> '<title>' -> TMDB
  ID: …'. (Phase lines kept for context: enriching / proxy returned / done.)
2026-06-17 12:19:55 -07:00
BoulderBadgeDad
722b80001e YouTube enricher: parallelize per-video date fallback (~3x faster)
Your log showed 'proxy returned 0' (public proxies dead) → the slow per-video
path. Date those recent uploads in a 3-worker thread pool instead of serially,
so a channel finishes in ~30s rather than 1-2 min; cached as each completes.
2026-06-17 12:18:29 -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
9c3facfe7e Enricher: don't spawn the background daemon under pytest (test isolation)
The enrichment daemon was starting during API tests (it uses the default DB +
network), touching a stray real DB and causing sqlite disk-IO flakiness. enqueue()
now no-ops when PYTEST_CURRENT_TEST is set; _enrich() is still tested directly
with a tmp DB. 176 video tests green.
2026-06-17 11:44:21 -07:00
BoulderBadgeDad
217486b3c7 Fix test_channel_enrichment_tracking for coverage-aware gate (thin run retries in 15m; good run honours window) 2026-06-17 11:38:52 -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
9524a40615 YouTube enricher: reliable full coverage without a proxy + sweep existing follows
Two gaps from real use:
- When all proxy instances are down (common), the fallback only dated WISHED
  videos, leaving most as 'Earlier videos'. Now on proxy failure it flat-resolves
  the channel's recent uploads and dates THOSE via yt-dlp (throttled, cached) —
  so years populate fully even with no working proxy.
- A channel followed before the enricher existed was never queued ('nothing to
  process'). /youtube/channels now sweeps: any followed channel not enriched
  recently gets enqueued — so viewing the Channels tab (or the wishlist badge
  refresh) picks up existing follows.

The detail page still reads cache+RSS (fast); the enricher fills the cache. 51
tests green.
2026-06-17 11:23:34 -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
30dc587ebf Background date enricher for followed YouTube channels (no key)
Follow a channel → a background job fetches its FULL upload-date catalog so the
channel page's year-seasons populate fully, cached permanently (one-time per
channel, instant after).

- core/video/youtube.py: proxy_channel_dates() — no-key BULK dates via Piped/
  Invidious instances (tries several, paginates); parse_proxy_dates handles both
  shapes. The clean path the user wanted.
- core/video/youtube_enrichment.py: YoutubeDateEnricher daemon — enqueue(channel)
  → proxy bulk → cache; per-video yt-dlp only as a throttled fallback (cap 60,
  0.4s) for wished videos when every proxy is down. Skips channels enriched in
  the last 24h.
- db: youtube_channel_enrichment table + mark/check + wishlisted_video_ids_for_
  channel. Enqueued from /youtube/follow and on opening a followed channel.

Scoped to FOLLOWED channels only (per request). 174 tests green; enricher imports
nothing from music.
2026-06-17 10:52:24 -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
87750e2464 Channel page (backend): channel tags/views + playlists + playlist videos
- shape_channel now carries tags (≤12) + channel view_count for the stats ribbon.
- Refactored entry-shaping into _shape_entries (shared by uploads + playlists).
- channel_playlists(id) → playlists (as 'seasons'); playlist_videos(id) → its
  videos. API: GET /youtube/playlists/<channel_id>, GET /youtube/playlist/<id>
  (with per-video wished hydration). 79 youtube+api tests green.
2026-06-17 08:51:53 -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
8941da8b60 YouTube next-level (backend): year=season nebula shape + channel detail API
- Resolver: capture banner_url + separate avatar (by thumbnail id), keeping
  per-video duration/views for the detail hero.
- query_youtube_wishlist reshaped to the EXACT TV-nebula shape: channel = show,
  upload YEAR = season (newest first), video = episode (newest=ep1 within a
  year), carrying source='youtube' + youtube_id + per-video source_id. Surrogate
  int returned as tmdb_id so the nebula's int keying just works.
- New API: GET /youtube/channel/<id> (full channel detail — meta + deeper
  uploads + following + per-video wished flags) and POST /youtube/wishlist/add
  (per-video wish). DB: youtube_video_wish_state, remove_one_video_from_wishlist.
- Tests updated for the nebula shape + banner + detail endpoints. 82 DB + 69
  api/youtube tests green.
2026-06-17 01:10:53 -07:00
BoulderBadgeDad
f4f40c161b Fix: video DB fails to initialize on upgrade (no such column: source_id)
The two new partial indexes (idx_video_wishlist_video/_channel on source_id /
parent_source_id) were in video_schema.sql, which runs via executescript()
BEFORE the column ALTERs. On a fresh DB the CREATE TABLE includes the columns so
it's fine, but on an existing (pre-bridge) DB the table already exists without
them, so 'CREATE INDEX ... ON video_wishlist(source_id)' threw 'no such column'
and the whole init rolled back — the video side wouldn't load at all.

Move those two indexes out of the schema into VideoDatabase._ensure_indexes(),
run AFTER _ensure_columns(). Regression test simulates a pre-source DB and
asserts the in-place upgrade + the youtube path both work. 81 DB tests green.
2026-06-17 00:56:06 -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
d30149db14 YouTube channels (3/4): API endpoints
api/video/youtube.py wired into the blueprint:
- GET  /youtube/resolve   — preview a pasted channel (meta + recent uploads) +
                            a 'following' hydration flag; no writes.
- POST /youtube/follow    — {url} or pre-resolved {channel} → follow + wish its
                            recent uploads (capped 30). Returns channel + counts.
- POST /youtube/unfollow  — {youtube_id} → un-follow (keeps wished videos).
- GET  /youtube/channels  — followed channels for the watchlist page.
- GET  /youtube/wishlist  — wished videos grouped by channel.
- POST /youtube/wishlist/remove — scope channel|video.

yt-dlp call lives behind resolve (injectable/mockable). 38 API tests green.
2026-06-17 00:38:11 -07:00
BoulderBadgeDad
e952ea5f66 YouTube channels (2/4): schema bridge + DB methods
Bridges YouTube onto the existing watchlist/wishlist tables (the chosen
approach) via a generic source/source_id (+ parent_source_id on wishlist) and a
stable surrogate for the NOT NULL tmdb_id, so existing dedup/group-by machinery
is untouched. SCHEMA_VERSION 13, additive column migrations.

- Channel follow  = video_watchlist kind='channel' (source='youtube').
- Wished video    = video_wishlist  kind='video' grouped by parent channel.
- youtube_surrogate_id(), add/remove/list/hydrate channels, add_videos_to_wishlist,
  query_youtube_wishlist (channel=group, videos=newest-first feed),
  youtube_wishlist_counts, scoped removal.
- Kept wishlist_counts/watchlist_counts byte-identical (exact-equality tests);
  YouTube counts live on their own method. 80 DB tests green.
2026-06-17 00:34:28 -07:00
BoulderBadgeDad
68383a16b3 YouTube channels (1/4): channel resolver in core/video/youtube.py
First slice of 'follow a YouTube channel as a show'. Pure, testable resolver:
- parse_channel_url() normalizes any channel reference (@handle, /channel/UC..,
  /c/.., /user/.., bare handle, with/without scheme or tab suffix) to a
  canonical /videos uploads URL, and rejects non-channels (watch/playlist/
  shorts/home/non-youtube).
- shape_channel() maps yt-dlp's flat-extract dict to our shape (channel meta +
  recent uploads), picking best-res thumbs, parsing sparse publish dates
  (timestamp or upload_date → ISO, None when flat mode omits them), filtering
  null/id-less entries.
- resolve_channel() ties them together via an injectable YoutubeDL factory.

yt-dlp only (already a pinned dep), extract_flat for cheap listing. 28 seam
tests, no network. Schema bridge + API + UI are the next slices.
2026-06-17 00:25:34 -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