The detail actions row still toggled the old shows.monitored flag via
/api/video/monitor. Now it's consistent with the cards:
- The 'Watchlist' button appears only for AIRING shows (movies + ended shows are
terminal — they keep acquisition, not a watch-follow).
- It reads/writes the new /api/video/watchlist add/remove, resolves the real
watched state on load (airing library shows are on by default), confirms on
remove (the standard dialog), toasts, and broadcasts the change so the nav
badge + watchlist page stay in sync.
A shared VideoGet.cardButton({kind,tmdbId,libraryId,title,poster,status,source})
picks the right control (person->eye, movie->get, show->eye when airing/unknown
else get) so every surface stays consistent. Injected into person filmography
(known-for + credits), search results (titles + people), and detail-page cast +
similar/collection rails. Each renderer hydrates watched state after render;
card roots get position:relative so the button anchors; reveal on hover.
Adds a .dl-nav-badge to the Watchlist nav entry, updated from
/api/video/watchlist/counts on boot, on every watchlist change, and whenever
the page loads. Hidden at 0.
- 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 eye is small/easy to mis-click and the card vanishes on the watchlist page,
so removal now goes through the shared showConfirmDialog (core.js) — 'Remove
"<title>" from your watchlist?' — before un-following. Adding stays one-click
instant. Cancelling re-enables the button with no change.
The watchlist cards were bare <a href='/video-detail/...'> with no click
interceptor, so clicking one triggered a REAL browser navigation to the
client-routed URL — the server returns the app shell and the entire app
re-boots (every static asset re-fetched = the ~15s 'restart' freeze).
Mirror video-library.js: intercept plain left-clicks on the cards and dispatch
soulsync:video-open-detail {kind, id, source} for in-app SPA navigation instead.
Encodes the open target on each card (library shows by library id, people +
un-owned shows by tmdb id). Modified clicks (new tab) still use the real href;
the eye button's capture-phase handler already stops its own clicks.
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.
The 7 independent day-stacks had no shared time axis — you couldn't scan
'what's on in prime time' across the week. Restructured into a real guide:
- Rows = time-of-day bands (Morning / Afternoon / Prime Time / Late Night /
Anytime) down a frozen left time-rail; 7 day columns; frozen header row +
sticky corner. Untimed streaming drops land in Anytime; each card keeps its
exact time. Empty bands (none all week) are hidden; empty cells show a dot.
- Live 'now' cue lights the today-column × current-time-band intersection.
- Prime Time rail glints gold.
- Keyboard nav (←/→ weeks, T = today), scoped to the visible page + no modal.
- Week-change crossfade; staggered card entrance runs only on load/week-change
(not on filter re-renders); keyboard focus rings on cards.
- Subheader now shows owned/missing split.
Kept all existing interactions: tilt cards, breathing glows, skeleton shimmer,
parallax billboard, rich episode modal. Vanilla static files, no build step.
Finish the TV calendar as a planning tool.
- Week nav: ‹ prev / Today / next › steps the 7-day window (endpoint takes
?start=; each window starts on today's weekday so "current day first" holds).
Title reflects it (This Week / Next Week / In N weeks); Today highlight only
shows when the real today is in view.
- Filter: All / In library / Missing, applied client-side (no refetch) — the
hero + grid + count all respect it.
- Hero eyebrow is context-aware (NEXT UP this week, FEATURED on other weeks).
Take the calendar visuals up a level + fix slow image loads.
- Featured "Next up" billboard: the soonest upcoming episode as a cinematic hero
(backdrop, pulsing accent, show title, S·E, air time, "View details" → modal).
- Cards: cursor-following 3D tilt + hover lift/scale/glow bloom; ambient glow
dialed way down so it's calm at rest and only blooms on interaction.
- Faster art: poster proxy takes ?w= and asks the source for a thumbnail (Plex
transcoder w/ original fallback, Jellyfin maxWidth, TMDB size bucket) — calendar
requests ~500px instead of full backdrops. Skeleton shimmer → fade-in on load.
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.
Replace the flat opacity crossfade with a soft, feathered left→right wipe of the
trailer in (mask via @property, falls back to crossfade) — "a rollover and a
fade". The wipe now fires ONLY when YouTube reports PLAYING (handshake + state
events), not on a blind timer, with a 4.5s safety net. When the trailer ENDS the
original backdrop fades back in and the iframe is torn down.
Give the video side its OWN server connection — pre-filled from music but stored
separately in video.db, fully isolated (video never writes music config/state).
- Effective config helpers (video_plex_config / video_jellyfin_config): video's
own creds when set, else inherited read-only from music. resolve_video_server +
_build_source + watch-link/poster/dashboard all use these (own db threaded in).
- Server Connection UI mirrors music's server picker (toggle = select + configure),
scoped to Plex/Jellyfin, at the bottom of the Connections tab.
- Jellyfin: independent client built from video's creds; explicit USER picker like
music (list users → that user's libraries); honors the pick, admin fallback.
- Honest connection diagnostics (reachable vs 401 vs no-users) instead of a vague
"auth failed".
- Auto-save on change with toasts; the shared Save button is intercepted on the
video side so it saves video settings (and can't fire a music save).
- Enrichment status now PUSHES over the socket like music (no browser polling /
access-log flood); config save only rebuilds workers when an API key changed.
- Seam tests for effective-config inheritance/override + isolation guard.
Per the desired model: video Settings has its OWN server settings, separate from
music. The 'Video Source' group now holds the Plex/Jellyfin pick AND the Movies/TV
library mapping (moved out of music's Plex panel so it's not coupled to the music
active server). The whole MUSIC 'Server Connections' group is hidden on the video
side. Video reuses the shared Plex/Jellyfin credentials: the picker shows both
servers, the configured ones selectable, the rest 'not connected — set up in Music
settings'. Music settings keep their own server settings, untouched.
The previous hard guard blocked saveSettings entirely on the video side — which
also blocked entering/saving Plex/Jellyfin connection creds (shared, legit). Now
the save runs, but on the video side it PRESERVES the persisted active_media_
server (window._persistedActiveServer) instead of reading the toggle — so video
can set up a connection without ever switching the music server. Auto-save stays
suppressed on the video side (no heavy music re-init from video field edits).
Two real coupling bugs, fixed:
- resolve_video_server still fell back to the music active server when no explicit
video pick was set, so changing the music server changed video. Removed: video
now uses ONLY an explicit video pick or the configured server(s) (Plex default
when both). Changing the music server never changes video.
- The shared settings page could persist active_media_server from the video side.
Guarded saveSettings itself (not just the debounced auto-save) so it NEVER runs
while data-side=video — video saves only via /api/video/*.
Test: video does not follow the music active server. One-way isolation, both ways.
The settings page is shared; on the video side its auto-save (debouncedAutoSave-
Settings) still fired when editing VIDEO fields (TMDB key, watch region, autoplay),
and saveSettings reads the server toggle from the DOM + persists active_media_
server — so clicking the toggle to Jellyfin on the video side and then editing any
field would write active_media_server=jellyfin to the shared config (and trigger a
full music save). Guard: never auto-save music settings while data-side=video.
Video settings persist via /api/video/*; the toggle re-syncs to the real active
server on every Settings load. One-way isolation restored.
The video worker manager's coverage cards only switched the view — they never
re-queued failed items, so not_found/error items sat in the retry cooldown and
the worker reported 'all matched' / idle while failures piled up. Clicking a
Movies/Shows coverage card now pins that group AND resets its failed matches
(not_found/error -> pending) so the worker sweeps them, mirroring the music
worker manager. (Episodes are a sync cascade, not a match queue, so unchanged.)
Video Preferences group now carries data-stg='library' so it lives under the
Library settings tab on the video side (the infra already supported video-only
library settings). The Video Source panel moves to data-stg='connections' so it
stops showing on every tab.
They're music-only, so a CSS rule scoped to body[data-side=video] hides those two
toggle buttons in the shared Server Connections section. Plex/Jellyfin stay
(for connecting a video server). Music side untouched.
Settings → Video Source shows which server video uses (✓ Plex/Jellyfin), a
Plex/Jellyfin picker when both are connected, or a clear 'connect Plex or
Jellyfin' message when neither (Navidrome/Standalone are music-only and not
offered). The Library shows a non-breaking 'no video server' banner + disables
Scan until one is connected. Detail Pages prefs moved into their own 'Video
Preferences' group. /api/video/server GET+POST drives it.
The video side now uses a configured Plex/Jellyfin on its own (resolve_video_
server), not config_manager.get_active_media_server(). So a music-only server
(Navidrome/SoulSync) never applies to video, and 'Navidrome for music + Plex for
video' works. Order: explicit video pick (video_server setting) → music-active if
video-capable → the single configured one → Plex if both → None. Seam tests cover
each case incl. the mixed setup.
A saved 'Where-to-watch region' picker in Settings → Detail Pages (19 common
regions, default US). The engine reads it for the providers in extras +
tmdb_detail (region in the cache key), and the detail page labels the section
'Where to Watch · <region>' so you know which market you're seeing.
Click any episode (owned or missing) to expand it: a larger still, full overview,
and the episode's guest stars (clickable to the person page). Lazy-loaded per
episode from TMDB by the show's tmdb_id and cached. New client.episode_detail +
engine.episode_extra + /api/video/episode/<show_tmdb>/<season>/<episode>.
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).
The inline dict cache was a latent bug: no lock (the engine singleton is hit by
concurrent Flask + worker threads) and a wholesale-clear cliff at 256 (nuked hot
entries). Extracted a thread-safe TTL+LRU TTLCache into an importable core/ module
with seam tests (expiry via injected clock, LRU-not-wholesale eviction, a
concurrency stress test). Engine now uses it; search is cached too (60s, ownership
re-stamped fresh). Deliberately NOT persisted to disk — durable data already lives
in video.db; that tier would be over-engineering for a self-hosted app.
The owned-item extras and preview detail payloads were already cached (30-min
TTL); person_detail, tmdb_season, and trending were not — so person pages and
lazy preview seasons re-hit TMDB each view. Now cached too (trending 1h). Library
ownership is re-annotated fresh on each call so 'In Library' badges stay current
while the expensive TMDB fetch is reused. Tests for person + season caching.
The detail pages are long now (cast, videos, photos, reviews…), so the absolute
back button scrolled out of reach. It's now position:sticky (pinned top-left as
you scroll), with a negative bottom margin so the billboard still sits full-bleed
under it.
maybeRefreshMovie re-fetched the library payload (no trailer/server — those come
from the extras call) and replaced data, so renderActions re-rendered without the
buttons right after they appeared. It now carries over the live extras fields
(server/trailer/next_episode), mirroring the show reloadDetail fix.
Clicking a recommendation/cast/similar link to a title you OWN opens it via a
tmdb URL that redirects to the library detail — but the redirect PUSHED a new
history entry, so Back landed on the tmdb URL which redirected forward again =>
stuck loop + a self-referencing 'Back to <same movie>' label.
The redirect now REPLACES the redirecting entry (keeping its layer + origin)
instead of pushing, so Back unwinds cleanly to where you actually came from.
After a couple seconds on a detail page, a muted trailer plays behind the hero
(Netflix/Disney+ style) with mute/unmute + stop controls; the backdrop fades back
when stopped. Stops on navigate-away/modal-open (no orphaned audio).
Gated by a 'Autoplay trailers in the billboard' toggle in video Settings →
Detail Pages (default on). Backed by billboard_autoplay in video_settings, read
via a lightweight /api/video/prefs. Tests updated for the new config field.
extras() now returns a featured review (author, rating, snippet, date); the detail
page shows it in a card with a clamped body + Read more/less. In-app (no external
link).
person() now returns profile images (thumb+full) and also_known_as. The person
page shows an 'Also known as' line and a Photos rail that opens the shared
fullscreen lightbox (arrows/Esc/counter).
Owned detail pages sample the poster for the per-title glow, but preview/person
pages fell back to the theme accent because their TMDB images are cross-origin
(canvas taint). Added /api/video/img — a same-origin proxy restricted to
image.tmdb.org (SSRF-safe) — so:
- preview (tmdb) detail samples its poster via the proxy → real accent;
- the person page samples the portrait → per-person accent on the ring/glow/role.
Tests: route registered + proxy rejects non-tmdb URLs.
Search / trending / filmography poster cards open the DETAIL page on click, so
the center ▶ read as 'play' wrongly. Swapped it for an italic 'i' info badge
('view details').
Frontend for the new data, on both movie + TV detail pages:
- Photos: a backdrops rail → fullscreen lightbox (‹ › nav, keyboard arrows, Esc,
counter).
- Videos: a rail of every trailer/teaser/clip/featurette (YouTube thumbs) → opens
in the existing player modal.
- Details: budget / box office / language / country + keyword tag chips.
- Cast & Crew gets a 'View all N' → full-cast modal (clickable to person; TV shows
per-actor episode counts).
All cached server-side (instant re-open) and lazy-loaded images. Isolated; shell
tests cover the new sections + modals.
One TMDB call (append_to_response) now also returns: image gallery (backdrops +
posters, thumb+full), all YouTube videos (ordered trailer→teaser→clip→…), keyword
tags, facts (budget/revenue/language/country), and the FULL cast (tv via
aggregate_credits with per-actor episode counts). Shared by item_extras (owned)
and full_detail/tmdb_detail (preview).
Caching: the engine memoizes the live TMDB extras + preview payloads (30-min TTL)
so re-opening a title is instant instead of re-hitting TMDB. Tests added.
TMDB only exposes a single aggregate 'where to watch' link (no per-provider deep
links), so N identical links read as broken. Streaming providers are now
non-clickable availability badges, followed by ONE accent 'Where to watch ↗'
link to the JustWatch/TMDB page. The Plex/Jellyfin tile keeps its real per-item
server deep link.
The button showed the Plex logo AND the word 'Plex' ('[logo] Play on Plex'), all
white on a bright green bg — cluttered + hard to read. Now it's '▶ Play on
<server logo>' (the logo is the brand name) on a deeper green gradient so the
light Plex/Jellyfin wordmark reads clearly.
Two bugs when a show's episode list backfills on view:
- The 'Fetching full episode list…' banner never hid: .vd-ep-syncing (and
.vd-next-ep) set display:flex, which overrode the [hidden] attribute's
display:none, so el.hidden=true did nothing. Added a guard so [hidden] always
wins on the detail/search/person pages.
- Play & Trailer buttons vanished after the sync: reloadDetail replaced data with
a fresh show_detail payload (no server/trailer — those come from extras), so
renderActions re-rendered without them. reloadDetail now carries over the live
extras fields (server/trailer/next_episode).
- Play button now matches the Trailer/Watchlist buttons exactly (same size/shape),
just green — consistent hero buttons.
- Where to Watch: drop the duplicate streaming provider that matches your server
(no more two 'Plex' entries). Providers still share TMDB's single JustWatch
'where to watch' link (that's all TMDB gives).
- Director/Creator names (hero line + Cast & Crew section) are clickable → the
in-app person page.
- Opening a show whose full episode list isn't cached yet now shows a 'Fetching
the full episode list…' banner with a spinner, instead of a silent ~20s gap
before missing episodes pop in.
Replaces the plain loading text with a shimmering billboard placeholder (logo /
meta / overview / button bars) over the accent wash, so opening a title (esp. a
TMDB preview) feels instant and premium. Honors prefers-reduced-motion.
- Sort dropdown: Newest / Oldest / Most popular.
- Department filter (Acting / Directing / Writing / …) for multi-hyphenates —
only appears when a person has 2+ departments. Composes with the existing
kind + ownership filters; every chip shows a CONTEXTUAL count (what you'd get
if you clicked it, given the other active filters).
- Age in the hero meta ('47 years old', or lifespan + 'aged N' for the deceased).
- Backend: each person credit now carries its department (cast=Acting,
crew=its TMDB department). Seam test added. 249 video-suite tests pass.
Movie + TV detail pages get the things a premium app surfaces:
- Primary 'Play on Plex/Jellyfin' button (white Netflix-style CTA with the server
logo) in the billboard for owned items — deep-links straight to the item.
- 'Directed by' (movies) / 'Created by' (shows) line in the hero.
- Movies: a Collection/franchise row (the other films in the set), release-ordered.
- 'More Like This' now uses TMDB recommendations (better curated), similar as
fallback.
- TV: a 'Next Episode' banner (S/E + name + air date) for continuing shows, and
the selected season's overview under the season nav.
All in-app (cards drill into library/preview detail). Shell tests updated.