Commit graph

4288 commits

Author SHA1 Message Date
BoulderBadgeDad
cf03c28831 Discover: taller hero slider (420->520 desktop, 340->420 tablet) 2026-06-17 16:26:17 -07:00
BoulderBadgeDad
a3adf5e63c Video Manage-Workers modal: realign to the music modal's exact design
The video modal reused music's shell but had drifted into a parallel mini-design:
it emitted its own markup (.em-ph-main / .em-pause-btn / .em-item / .em-pg /
.vem-logo / .vem-icon) styled separately in video-side.css, instead of the
music classes that already exist in style.css. So the panel header, list rows,
rail icons and pager all looked 'redesigned'.

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

Removed the now-dead bespoke CSS. Episodes stay a coverage card (per the backend:
episode art cascades from a show match; /priority only accepts movie/show), so
the top bar is correctly Movies/Shows/Auto. Music side untouched (shared classes
are global; video overrides were all #vem-overlay-scoped or now deleted).
2026-06-17 14:18:43 -07:00
BoulderBadgeDad
21ed97ff26 YouTube enrichment: legacy channels auto-upgrade to InnerTube (backwards compat)
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.
2026-06-17 13:51:10 -07:00
BoulderBadgeDad
91bd895e52 YouTube channels: follow = watchlist only (clean toast) + drop the misleading '90 videos'
Two reports:
- 'Following · 0 videos added' toast: following from the channel page sent a
  stub with no videos, so 0 were wished. Watchlisting a channel shouldn't bulk-
  wish its whole catalog anyway (that conflates watchlist/track with wishlist/
  acquire, unlike TV shows). follow() now sends only the channel fields and the
  toast reads 'Added to watchlist'. Videos are wished explicitly via + Wish.
- '90 videos' in every channel header: that was just our fetch cap (limit=90),
  not the real total — YouTube no longer exposes a trustworthy channel video
  count (videoCountText reports a shelf count: 142 for Veritasium, 71 for MrBeast).
  Dropped it; the accurate subscriber count already conveys the channel's scale.
2026-06-17 13:37:15 -07:00
BoulderBadgeDad
c00df179ba Channel page: fix re-poll never refreshing when recent videos collapse to one year
The date re-poll only re-rendered if the season COUNT increased — but for a
daily channel the 90 recent videos all land in the current year, so it went from
[2026(3), Earlier(87)] (2 seasons) to [2026(90)] (1 season): count dropped, so
the years (which WERE cached, per the logs) never showed and the spinner hung.
Now it re-renders whenever the 'Earlier videos' bucket shrinks too. Also extended
the poll window (20/45/80/120/160s) for channels queued behind others.
2026-06-17 13:22:23 -07:00
BoulderBadgeDad
b627f9f411 Channel page: 'fetching dates' indicator during the wait + watchlist toggle no longer reloads
- The InnerTube enrichment takes ~30-45s; while videos are still undated the page
  now shows a spinner 'Fetching upload dates from YouTube… your year-seasons will
  fill in shortly' (reuses the episode-syncing indicator), which clears once the
  years populate via the re-poll (or after the poll window).
- The Watchlist button on a channel was calling loadChannel() on follow — a full
  re-fetch + scroll-to-top that read as a page refresh. Now it just flips the
  button in place (data.following + renderActions), like unfollow already did.
2026-06-17 13:11:17 -07:00
BoulderBadgeDad
b5586d3f28 YouTube: enrich dates on ANY channel page open (not just followed)
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.)
2026-06-17 13:05:25 -07:00
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