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.
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.
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).
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.
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.
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.
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.
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.
- 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).
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).
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.
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.
- 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.
- 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.
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.
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.
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.
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).
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.
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.
#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.
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.
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.