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.
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.
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.
- 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.
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.
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.
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.
- 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.
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.
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.
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.
The music wishlist has its artist 'nebula'; the TV side now has its own rich,
TV-native metaphor. Each show is a reel: poster + one horizontal film strip per
season (dark band with sprocket-hole borders) made of episode 'cells'. A cell
shows E#, glows in the show's hue, and widens on hover to reveal the title;
remove ×s sit on each cell, each season label, and the show header. Poster/title
open the show detail. Status tints the cells (wanted/downloading/done/failed).
Movies tab unchanged.
query_wishlist(show) now also returns library_id so reels open the owned detail
when applicable. Backend suites green.
A manual safety-net for the auto-promoter: queues episodes that have ALREADY
aired, are missing, and aren't yet on the wishlist. Upcoming episodes are left
alone (the calendar promotes them once they air), so it's a no-op on the current/
future weeks and useful when you page back to a past one.
- calendar_upcoming now returns the show's tmdb_id.
- /wishlist/check accepts {shows:[...]} -> by_show membership (db.wishlist_keys_
for_shows), so the button only counts/adds what's genuinely not yet queued.
- Calendar: computes aired-missing (air_date < today, !has_file), checks wishlist
membership, shows 'Add N missing to wishlist' when there's net-new; click groups
by show -> /wishlist/add, toasts, fires soulsync:video-wishlist-changed, recomputes.
Tests: +2 (wishlist_keys_for_shows, /wishlist/check by_show). Backend: 100 passed.
- api/video/wishlist.py: GET /wishlist (paged movie|show tab, or counts-only),
/wishlist/counts, POST /wishlist/add (movie OR show+episodes), /wishlist/remove
(scope movie|show|season|episode), /wishlist/check (hydration). Registered in
the blueprint.
- Dashboard 'wishlist' stat now reflects the real curated count (was a 0 stub).
Tests: +6 API (add movie/episodes, body validation, scoped removes, hydration,
routes registered). API suite 30 + DB suite 68 passing.
The curated 'get this' list. Atomic units are movies and episodes; adding a
whole show/season expands into episode rows (show/season are bulk ops). Upcoming
episodes stay out — the watchlist/calendar promote them once they air.
- video_wishlist table + two partial unique indexes (one per movie tmdb_id, one
per (show tmdb_id, season, episode)) so the shapes don't collide and re-adds
upsert. SCHEMA_VERSION 8 -> 9 (executescript creates it on existing DBs).
- DB: add_movie_to_wishlist, add_episodes_to_wishlist (bulk), remove_from_wishlist
(movie/show/season/episode scope), query_wishlist (movies | shows grouped
show->season->episode w/ wanted/done roll-ups, searched+paged), wishlist_counts,
wishlist_state (hydration).
Tests: +7 (idempotent upserts, show-tree grouping, scoped removes, movie/episode
same-tmdb don't collide, hydration, search+paging). DB suite: 68 passed.
Performance:
- Batched ownership: new db.library_ids_for_tmdb() resolves a whole rail in one
query per kind. _stamp_owned (now also used by search + trending) groups by
kind, so a full Discover page drops from ~500 connections to a couple per rail.
Function/data:
- 'See all' on every rail opens it as a paged grid (Load more); the filter bar's
Browse routes through the same generic category grid with a back button + title.
- Personalized 'Because you like <Genre>' rails seeded from your most-owned
genres (new db.top_owned_genres + /discover/taste endpoint).
- 'Hide owned' toggle drops in-library titles from every rail/grid (CSS class,
instant).
Visual vibes:
- Ambient page-top color bleed that follows the current hero slide's hue.
- Rail edge-fade mask, gentle fade-in on load, per-title hue glow on card hover.
Tests: +4 (batched id map, server scoping, one-query-per-kind stamp, top genres).
Full video enrichment + database suites: 145 passed.
- Backend: effective shows now carry status + owned/total episode counts (joined
off the shows table); query_watchlist gains a sort (default | title | added).
- Cards: a status pill (Airing / Upcoming / Ended) top-left + '12/20 eps' meta
under the title for shows.
- Toolbar: a sort select (Following / A-Z / Recently added) next to search.
82 video tests green.
The terminal-content counterpart to the watchlist eye. On library cards:
- airing show -> watchlist eye (monitor for new episodes)
- movie / ended show -> a 'get' download symbol that opens a detail modal
- video-get-modal.js: VideoGet.btn() + VideoGet.isAiring() (the shared status
test), and the modal — hero backdrop, eyebrow, title, meta (runtime/rating/
tagline), genres, overview, pulled from the existing detail endpoint. Action
buttons are VISUAL STUBS for now: 'Open full page' navigates; 'Add to
Wishlist' just toasts 'coming soon' (real population is a later phase).
- query_library now selects s.status so cards can pick eye vs get.
- CSS for .vget-btn (hover-reveal, accent on hover) + the .vgm-* modal, styled
to match the calendar episode modal.
82 video tests green (status is an additive column).
The page rendered every follow + airing-default show at once (DOM + all posters)
— slow once the watchlist grows. Now it pages like the library:
- /api/video/watchlist?kind=&search=&page=&limit= returns {items, pagination,
counts}; query_watchlist() filters by title + slices (effective list is
bounded, so compute-then-slice, not heavier UNION SQL).
- Page reworked to a single grid: Shows/People tabs each load their own page;
debounced search box; Prev/Next pager; tab badges show totals from counts.
- Only a page of cards (and lazy posters) render at a time.
4 tests added (DB paginate/search + endpoint). 82 video tests green.
The dashboard still read the old monitored-based views: watchlist from
v_watchlist (every monitored show) and wishlist from v_wishlist (every missing
movie/episode, since monitored defaults to 1). Repoint both:
- watchlist -> the curated watchlist_counts() total (follows + airing default).
- wishlist -> 0 for now. The auto-everything v_wishlist isn't the intended
curated wishlist; zero it (no live-DB mutation) until 'add to wishlist'
population lands, then repoint at the real source.
Test updated to the new semantics (airing show counts; wishlist cleared).
Owning a still-running show means you want its new episodes, so it's on the
watchlist without a click. Implemented as a computed default + explicit-override
so it stays correct:
- video_watchlist gains a 'state' column: 'follow' (explicit) | 'mute' (a
tombstone — user un-followed an airing show that's on by default, so the
default must not silently re-add it).
- Effective watchlist (list/state/counts) = explicit follows ∪ library shows
whose status isn't ended/canceled, minus mutes. Computed at READ time, so it
always tracks the library + a show's status — no scanner hook, no re-seeding.
- remove() now writes a mute tombstone (idempotent) instead of deleting; add()
sets state='follow' and clears any mute. Scoped to the active video server.
The existing library-card eye now paints 'watched' on airing shows by default;
clicking mutes, clicking again re-follows.
4 tests updated/added incl. the airing-default + mute + re-follow flow. 80 video
tests green.
A curated follow-list for the video side, mirroring the music watchlist. v1 is
membership only — the monitoring/discovery engine is a later phase.
Backend:
- video_watchlist table (kind 'show'|'person', keyed on tmdb_id — the stable
cross-context id both carry; library_id kept when owned). NOT the existing
shows.monitored flag (that defaults to 1 / is library-only / has no people).
- VideoDatabase: add/remove/list/state/counts (upsert COALESCEs library_id +
poster so a TMDB re-add can't wipe known data).
- /api/video/watchlist {GET, /add, /remove, /check, /counts}.
- query_library now selects s.tmdb_id so show cards can carry the key.
Frontend:
- video-watchlist-btn.js: shared eye button (the music ya-watchlist-btn mirror)
— build/toggle/hydrate, one delegated capture-phase click handler, broadcasts
soulsync:video-watchlist-changed so pages can react.
- Watchlist page (new subpage + video-watchlist.js): Shows / People tab switcher,
poster grid to detail-page quality, reloads each visit, drops cards on unfollow.
- Wired the eye onto library TV-show cards (movies excluded — wishlist, not
watch) + hydrate on render.
Tests: 6 new (DB upsert/COALESCE/state/counts + endpoint roundtrip/validation).
76 video tests green. Other card surfaces (cast, search, similar, filmography)
are the same VideoWatchlist.btn(...) one-liner — wired next.
Storage was already per-server (movies/shows UNIQUE(server_source, server_id),
episodes via per-server show_id, prune_missing scoped) — but reads returned
every server's rows, so a Jellyfin scan would show up alongside Plex.
Mirror the music standard: scope reads to the active video server
(resolve_video_server). query_library, calendar_upcoming, dashboard_stats and
library_id_for_tmdb take a server_source; the dashboard/library/calendar
endpoints pass it. server_source=None keeps "all servers" (enrichment processes
every server; tests unchanged). No schema change, no data migration — existing
Plex data is untouched and simply hidden while Jellyfin is the active server.
Regression tests: same title on both servers stays two rows; scoped reads only
return the active server's data; deep-scan prune never touches the other server.
A new isolated Calendar page (/api/video/calendar) — every upcoming episode for
your owned shows across a real 7-day week (today first), as art cards sorted by
air time with a per-cell breathing colour glow.
- Air times: enrich shows with TVDB airsTime (new shows.airs_time column +
migration); cells show + sort by time, streaming (untimed/00:00) = "Anytime".
One-time background backfill re-queues already-matched shows for the time.
- Click an episode → styled modal (show backdrop hero, episode still/synopsis,
air date+time, owned badge, genres, "about the show"), with an explicit
"Open full show page" action instead of navigating on click.
- Isolated: reads only video_library.db, writes nothing to the music side.
We already scanned codec/audio/source/size but only showed resolution. The movie
detail Details block now surfaces Quality / Video (HEVC, H.264…) / Audio / Source
(Blu-ray, WEB-DL…) / Size, and lists every version/edition you own when there are
multiple files. movie_detail now returns all media_files (not just the largest).
Search any movie / show / person (TMDB multi-search) entirely in-app. Results
that you already own link straight to the library detail; the rest open a
TMDB-backed 'preview' detail that reuses the exact same Netflix billboard UI
(direct image URLs, nothing owned/enriched). Everything resolves back into
SoulSync — no external links on un-owned titles.
- Search page (video-search.js): debounced /api/video/search, grouped
movies/shows/people cards (reuses .library-artist-card) with owned/preview
ribbons. People open the person page.
- Source-agnostic detail (video-detail.js): loads from /api/video/detail
(library) or /api/video/tmdb (preview); art helpers pick proxy vs direct URLs;
tmdb shows lazy-load episodes per season; owned-via-tmdb-url auto-redirects to
the library detail.
- 'More Like This' now drills in-app (tmdb detail, redirects if owned); cast/crew
link to a new in-app person page (bio + filmography, each credit owned/preview).
Library credits now carry tmdb_id so owned-item cast is clickable too.
- Backend: TMDBClient.search/full_detail/person (+ shared _parse_extras);
engine.search/tmdb_detail/tmdb_season/person_detail; db.library_id_for_tmdb;
routes /search, /tmdb/<kind>/<id>, /tmdb/show/<id>/season/<n>, /person/<id>.
Isolated (one-way): video-only files, no music imports, music shell untouched.
Seam tests: search/full_detail parsing, tmdb_detail assemble+redirect, search +
person library annotation, library_id_for_tmdb, route registration, shell/JS
isolation. 234 video-suite tests pass.
OMDb now has the same setup as TMDB/TVDB: a yellow dashboard orb (★ glyph) that
spins/idles in the worker-orb animation, an entry in Manage Workers (Ratings
coverage cards, pause/resume, retry, search), and a BACKGROUND ratings pass.
- Worker 'ratings mode' (is_ratings): instead of a match queue it pulls
ratings_next() (library items with an imdb_id and ratings_synced=0), fetches
IMDb/RT/Metacritic, applies + marks synced. So the whole library gets ratings,
not just titles you open (schema v7: ratings_synced).
- enrichment_breakdown/unmatched/retry get an 'omdb' branch (coverage =
ratings-filled, not matched). build_clients includes omdb; the lazy on-view
backfill uses the omdb worker's client.
- Dashboard orb + Manage Workers entry (★ glyph fallback where there's no logo),
yellow accent.
Seam tests: omdb worker rates the queue (ratings mode), ratings breakdown.
Next-level: real critic/audience scores beyond the TMDB star. OMDb (free key,
keyed by the imdb_id we already capture) returns IMDb / RT / Metacritic.
- OMDBClient (ratings + test); built as a non-worker 'ratings_client' on the
engine. _backfill_ratings runs in both lazy detail refreshes (overwrites, since
ratings are dynamic). schema v6: imdb_rating / rt_rating / metacritic on
movies + shows; show/movie payloads return them.
- Billboard renders branded rating badges (IMDb yellow, RT tomato/splat by
fresh/rotten, Metacritic green/yellow/red by score). Lazy refresh also triggers
when an imdb_id exists but ratings are missing.
- OMDb API-key frame in Settings (parity with TMDB/TVDB) + config GET/POST +
/enrichment/omdb/test.
Seam tests: OMDb parse, engine ratings backfill, apply_ratings + payload, config
includes omdb.
So library cards show real owned/total (e.g. 8/10) WITHOUT opening each show. When
the TMDB worker's match queue is clear, it pulls the full season/episode list for
one already-matched-but-unsynced show per loop (episode_sync_next), inserting
missing episodes + marking it synced. Over time every library show gets its full
list; the on-view lazy refresh still makes the one you open instant. TMDB-only;
counts toward the worker's pending so it shows busy (not 'Complete') while syncing,
and never loops on a single failing show.
Seam tests: episode_sync_next selection + pending count, worker syncs a pre-feature
matched show to the full list.
Reload bug: music's router boots first, rewrites an unknown /video-detail/... URL
to /dashboard, and my init read the already-changed URL (no restore) AND dispatched
open-detail before video-detail.js was listening (empty page + stray back button).
Fix: capture the path at SCRIPT-EVAL time (before music boots) and DEFER the
restore to a macrotask so every DOMContentLoaded handler is registered and music's
initial routing has run — then re-assert the real URL. Reload/deep-link now restore
the exact item.
Missing-episodes bug: the full-episode-list cascade only ran via the lazy refresh,
which was gated on ART being missing — so a show that already had posters/logo
never pulled its episode list (stayed owned-only). Added shows.episodes_synced
(schema v5): the worker sets it after a full cascade; show_detail returns it; the
lazy refresh now triggers when NOT synced, so owned + missing episodes populate.
Previously the episodes table held only what the server has (all 'Owned'), so the
detail page never showed what you're missing. Now the metadata provider defines
the full series structure and the server marks ownership:
- TMDB returns the full season list (poster optional) + full episode fields
(title/air date/runtime/still/rating) per season.
- backfill_episodes UPSERTs: owned episodes keep has_file=1; episodes the server
lacks are inserted as MISSING (has_file=0); fully-missing seasons get created.
The cascade now iterates every TMDB season, not just the ones on the server.
- The scan prune only removes SERVER-originated rows (server_id set) that vanished,
so enrichment-added missing episodes/seasons are never pruned on re-scan.
Season coverage (X / Y) is now meaningful, and the episode list shows Owned +
Missing together. Seam tests: missing-episode insert, fully-missing season,
prune preserves missing.
Phase 3: the stylized transparent title logo replaces the text title in the
billboard (the big Plex/Netflix 'premium' jump). Sourced from TMDB images
(append_to_response=images, include_image_language=en,null) in the same detail
call — no Fanart key needed.
- schema v4: movies.logo_url / shows.logo_url (idempotent migration).
- TMDB client picks an English logo (then language-neutral, then any); enrichment
backfills logo_url gap-only; show/movie payloads return 'logo'.
- Billboard shows the logo img (with graceful fallback to the text title on error
/ when absent; title kept visually-hidden for a11y). Lazy on-view refresh now
also triggers when the logo is missing, so existing libraries fill it in.
Seam tests: English-logo pick, backfill + payload, schema.
- Movie cards in the library now drill into a movie-detail page (both kinds use
the same open-detail event / video-side navigation).
- New video-movie-detail subpage reuses the .vd-* hooks; video-detail.js is now
kind-aware (root() targets the active page by kind, billboard/links/actions
branch on movie vs show). Flat layout: billboard + a details strip (released /
runtime / studio / status / critic score / quality) + the shared Cast & Crew row.
- Lazy on-view backfill for movies too: engine.refresh_movie_art re-fetches TMDB
(cast/genres/backdrop/ratings) when missing, regardless of match status, via
POST /detail/movie/<id>/refresh-art. movie_match_info added.
Seam tests: movie refresh backfills cast/genres, movie_match_info, route
registered, movie subpage markup, cards clickable for both kinds.
Last capture piece, schema v3 (new people + credits tables; CREATE IF NOT EXISTS
migrates existing DBs on restart, no wipe):
- people deduped by tmdb_id; credits link to exactly one movie OR show (separate
nullable FKs + CHECK, no polymorphic id) with department/job/character/order.
- TMDB client appends credits to the detail call (free) and parses cast (name,
character, photo, billing order) + headline crew (directors/writers/creators).
- enrichment_apply backfills cast/crew gap-only (never clobbers); show/movie
detail return cast + crew. Populates on view via the existing lazy refresh-art.
- Cast & Crew section on the detail page: grouped crew line + a horizontal
cast row with circular TMDB headshots, names, characters (accent hover).
Seam tests: TMDB credit parse (+ job filtering, created_by), backfill + people
dedup across titles, gap-only no-clobber, payload shape.
show_detail's season payload omitted the season id, so the frontend built
/api/video/poster/season/undefined and 404'd — season posters never showed even
once cached. Add the id; harden seasonArt to fall back to the show poster if id
is ever missing. Test pins that seasons carry an int id.
Root cause: season posters / episode art backfill happen during a show's TMDB
*match*, but already-matched shows never re-run ('Retry all failed' only resets
not_found/error), so existing libraries never got the art.
Fix (Boulder's idea): fetch-on-view + cache. When a show detail opens and any
season lacks a poster, the page calls POST /detail/show/<id>/refresh-art →
engine.refresh_show_art re-fetches /tv/<id> via the TMDB client and backfills
season posters + episode art gap-only, regardless of match status. Cached, so
it's a one-time cost per show; runs once per view; re-renders when done.
Seam tests: refresh_show_art backfills a MATCHED show's seasons, needs TMDB
configured, show_match_info, route registered.
Brings the video modal to parity with music's:
- 'Process first everywhere' control (Movies/Shows/Auto) in the topbar — a global
setting that pins which kind every worker processes first. enrichment_next takes
a priority kind; the worker reads enrichment_priority each loop; GET/POST
/api/video/enrichment/priority persists it. Reuses music's .em-global styling.
- Needs-matching bar now has a live count, status filter (All unmatched / Not
found / Pending) and a debounced search (reusing .em-select / .em-search),
matching the music modal. Episode view stays read-only.
- Live glow (scoped to #vem-overlay): pulsing running dot, accent glow on the
selected worker row + active process-first/kind, and a pulsing 'now processing'
chip in the worker accent. Music's shared .em-* styles untouched.
Seam tests: priority pins kind in enrichment_next + worker honors the setting,
priority endpoint GET/POST + validation, modal feature markup pinned.
Episodes ride along with their show instead of being a separate (tens-of-thousands)
queue: when the TMDB worker matches a show, it now backfills every season's
episodes — still / overview / rating — via /tv/<id>/season/<n> (one call per
season, gap-only so server data is never clobbered). Also backfills season
overviews.
The worker manager 'knows about it': the TMDB breakdown gains an Episodes
coverage entry (matched = has art, rest = pending), shown as its own card; the
Episodes view lists episodes still missing art. It's coverage-only, kept out of
the worker's idle/pending calc so it never blocks 'Complete'.
Seam tests: client season parse, worker cascade fills episodes, gap-only backfill
+ season overview, breakdown coverage (tmdb only), missing-art list, idle calc
ignores episode coverage.
The media server rarely has distinct per-season art, so season cards fell back to
a gradient. TMDB's show detail carries a poster_path per season — the show worker
now returns those, and enrichment_apply backfills seasons.poster_url for seasons
the server left without art (gap-only, never clobbers server art). The image
proxy streams a stored full URL (TMDB) directly vs. proxying a server path.
Seam tests: TMDB returns season posters, backfill fills only missing seasons.
Existing DBs created movies.tmdb_id / shows.tvdb_id as inline UNIQUE (can't be
dropped via migration). The new model allows the same title in >1 library, so a
second movie/show with the same id raised IntegrityError and the scanner SKIPPED
it — dropping the title (observed: 'UNIQUE constraint failed: movies.tmdb_id',
movie 548522 skipped).
upsert_movie/upsert_show_tree now use a shared _resilient_upsert: on
IntegrityError, retry WITHOUT the id columns so the row is stored (just without
the colliding id) — same pattern enrichment_apply already used. Regression tests
for both movies and shows under a simulated legacy unique index.
Enrichment now harvests the full detail payload (same call, no extra requests):
- TMDB: tagline, genres, rating (vote_average), runtime, status, first/last air
date (shows), release date + runtime (movies) — on top of overview/backdrop/ids.
- TVDB: switches to /series/<id>/extended for overview + genres.
enrichment_apply now uses BACKFILL semantics: metadata columns are written via
COALESCE(NULLIF(col,''), ?) so enrichment only fills fields the media server
left empty — it never clobbers server-provided data. Genres backfill to the
normalised link tables only when the item has none yet. Whitelist expanded for
the new columns.
Seam tests: backfill-only (server overview/genres kept, gaps filled), genre
backfill when empty, TMDB full-metadata extraction.
Captures the richer metadata the media server already exposes (schema v2;
idempotent migrations + CREATE IF NOT EXISTS, so an existing DB upgrades on
restart with no wipe):
- movies: tagline, rating (audience), rating_critic; shows: tagline, rating,
first/last air date; episodes: still_url + rating.
- Genres as a normalised many-to-many (genres + movie_genres/show_genres link
tables — no comma-blob), deduped, replace-on-upsert.
- Plex (.genres/.tagline/.audienceRating/.rating/.thumb) + Jellyfin (Genres/
Taglines/CommunityRating/CriticRating/Premiere+EndDate/episode Primary) both
extract them; episode stills served via /api/video/poster/episode/<id>.
- Detail payloads return genres/tagline/rating/air-dates + per-episode has_still;
the billboard shows a tagline, ★ score, genre chips, and episode rows render
REAL stills (no more orange placeholder once scanned).
Seam tests for genre dedup/replace, show+episode capture, episode still ref.
Cast/crew (people + credits) is the next phase.
Season selection is now switchable via a view toggle (persisted): poster RAIL
(scrollable season cards w/ coverage), TIMELINE band (segments sized by episode
count, filled by owned), TABS (pills), and the LIST dropdown. All drive the same
selection; episodes fade in on change.
- Watchlist button is now REAL: toggles shows.monitored via POST /api/video/monitor
(set_monitored), reflects 'In Watchlist' state. show_detail returns monitored.
- 'Get Missing' + a 'Missing only' toolbar toggle filter the episode list to
unowned episodes (actual downloading is the future acquisition subsystem).
Seam tests for the monitor endpoint + bad-input guards; shell hooks updated.
Addresses the 'feels basic' feedback:
- Hero is now a contained glass card with the backdrop blurred INSIDE it +
gradient overlay (same treatment as the music artist hero) — no more bare
gaps around the top/sides. Bigger poster, accent external-link chips
(IMDb/TMDB/TVDB), refined badges + stat tiles.
- Seasons are a poster-art card grid (season = album) with coverage rings/bars
and hover-lift, selecting one renders its episodes below (episode = track) —
episode overviews now shown. Mirrors the artist album-grid -> tracklist.
- Scan now captures real per-season posters (Plex sh.seasons() thumbs / Jellyfin
/Seasons Primary), served via get_art_ref('season') + /api/video/poster/season.
Falls back to the show poster until a re-scan populates them.
Seam tests for the season art ref; shell markup tests still green.