Channels enriched before InnerTube existed (partial coverage, e.g. CaseyNeistat
36, Good Mythical MORE 25) were locked behind the 24h coverage gate and would
never reach the full ~240-date catalog on their own. Tag each enrichment row
with the source ('innertube'|'fallback'); legacy rows (method NULL after the
additive migration) bypass the gate ONCE so they re-enrich and upgrade, then
settle under the normal window. Non-destructive: existing follows, wished videos
(24), and cached dates (1176) are untouched — only coverage refreshes on next
open/sweep. Migration is additive (method TEXT); regression test added.
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.
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.
- 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.
Bug from the logs: opening MrBeast showed only '2026: 3, Earlier: 87' because
the InnerTube date enrichment only fired for FOLLOWED channels — MrBeast was
opened, not followed, so it never ran and the page had only RSS dates (and RSS
includes Shorts that don't match the Videos tab → ~3 real matches). Now opening
any channel page enqueues enrichment, so its full catalog gets dated in the
background and the years populate via the existing re-poll. (24h gate still
prevents re-sweeping; the enqueue still no-ops under tests.)
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.
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.
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.
- 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.)
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.
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.
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.
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.
- 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.
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.
- 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.
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.
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.
- 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).
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.
- 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
- 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.
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.
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.
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.
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.
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.
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.
- 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).
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.