'Following' read like a filter (everything on the watchlist is followed); it's
just the default sort (explicit follows first, then airing-default shows A-Z).
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
- 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.
Adds an ownership filter (All / In Library / Missing) next to the kind tabs on
the person page. Both the Known For rail and the full filmography respect it, so
you can see 'what of this person's work do I actually have' (or what's missing).
- Each credit already carries library_id (owned), so filtering is client-side.
- Contextual counts: the kind tabs count within the current ownership filter and
vice-versa, so the numbers always match what you'll see.
- 'In Library' filter glows green (matches the owned ribbon); empty states for
'you have everything' / 'nothing owned yet'.
Person hero glow-up:
- Cinematic ambient — blurred portrait + an accent colour mesh + vignette, masked
to fade into the page (was a flat wash).
- Portrait gets a slowly-rotating accent gradient ring (masked donut, GPU
transform) and a gentle float; an accent ring + glow frame.
- An accent role tagline ('ACTOR' / 'DIRECTOR' …) above a gradient-filled name,
plus a credits-count chip. Honors prefers-reduced-motion.
Performance:
- Long filmography grids use content-visibility:auto + contain-intrinsic-size so
the browser skips off-screen cards (cheap scroll), and skip replaying the
entrance animation as cards recycle.
- Hero layers are static (painted once); only two tiny composited transforms
animate. Posters/photos stay on small TMDB sizes + lazy-load; trending cached;
search debounced + request-sequenced.
Pure visual/perf layer — same data + isolation. Shell/JS tests pass.
Smart back (mirrors music's artist-detail): the top-left back button now
remembers where you actually came from, many layers deep. It keeps an origin
stack ({page} or {detail title}) and stamps each history entry with its layer
depth, so:
- the label is dynamic — '← Back to Search' / '← Back to The Bear' / '← Back to
<person>' — instead of a hardcoded 'Library'/'Back';
- backing out of the first layer returns to the page you started from (Search,
Watchlist, wherever), not always the Library;
- browser Back and our button both unwind the chain one layer at a time, in sync.
Fixes: search → person → back → movie used to mislabel as 'Library' and dump you
in the library.
Next level:
- Search isn't a blank box when idle — a 'Trending this week' rail (TMDB
trending, owned/preview annotated). Returns when you clear the query.
- Person page gets a 'Known For' hero rail (top titles by popularity) above a
full filmography now sorted chronologically (newest first).
Backend: TMDBClient.trending + engine.trending (+library annotation), route
/api/video/trending. Isolated; 237 video-suite tests pass.
Matches the show-detail page's vibe (--vd-accent-rgb glows, full-bleed, rise/
fade entrances) instead of the plain library shell.
Search:
- Cinematic hero — big title, ambient accent glow, a large glowing search bar
that lights up on focus.
- Results are premium 2:3 poster cards: hover lift + accent glow, poster zoom,
gradient overlay, a play affordance, owned/preview ribbon + rating chip.
People render as circular portrait cards. Grouped rows with accent-pill counts.
- Polished empty/hint states with a floating icon.
Person:
- Full-bleed cinematic hero with an ambient blurred-portrait backdrop (per-person
color), a glowing circular portrait, oversized name, meta as glass chips.
- Bio with Read more/less; filmography as the same premium poster cards with
premium pill tabs (All / Movies / TV).
Pure visual layer — same data hooks, routing and isolation. Shell/JS isolation
tests still pass.
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.
Mirrors the music side instead of a custom scheme: library cards are genuine
<a href='/video-detail/<source>/<kind>/<id>'> links, so reload / new-tab / Back /
Forward all work. Left-clicks are intercepted into SPA nav + history.pushState;
modifier-clicks fall through to the real URL.
- popstate restores the detail from the URL; the '← Library' back button is real
history.back(); deep-link / reload to a /video-detail/... URL is restored on load
(path captured before applySide can clear it). Server already serves the SPA for
these paths (permissive catch-all) — no backend change.
- The path carries a SOURCE segment ('library' = a video.db id today; 'tmdb' /
search results not yet in the library come later) — your library-vs-search split.
- Coexists with music's pathname router (only touches /video-detail/* and its own
popstate); music's link/popstate handlers ignore these paths.
Tests: real-link cards, pushState/popstate routing, source segment.
Phase 4: dynamic extras fetched LIVE per view (providers change, so not cached)
via GET /detail/<kind>/<id>/extras → engine.item_extras → TMDB
(videos + watch/providers + similar in one call).
- Trailer: a '▶ Trailer' action that opens an in-app YouTube modal embed (Esc /
click-away to close).
- Where to Watch: provider logos for the region (JustWatch via TMDB).
- More Like This: a poster row of similar titles linking out to TMDB.
Both movie + show pages; all keyless (same TMDB key).
Seam tests: extras parse (trailer priority, provider/similar shape), item_extras
gating on tmdb_id, route registered, markup hooks. (RT/Metacritic via OMDb needs
its own key — offered separately.)
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.
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.
- Action buttons now reuse the exact artist-detail classes
(.library-artist-watchlist-btn + .discog-download-btn/.discog-btn-compact with
shimmer) instead of bespoke vd-btn styling — identical to the music hero. Wired
by data-attr (music binds those by id, so no hijack).
- IMDb/TMDB/TVDB source links now render as .artist-hero-badge chips (logo + short
text fallback, TVDB inverted), matching the artist hero's #artist-hero-badges
treatment instead of the off-standard text tags.
Per feedback, this drops the Spotify/artist-page parallel entirely and goes
Netflix:
- Full-bleed billboard (edge-to-edge — breaks out of the host's 40px padding),
big backdrop with Ken-Burns drift + layered scrims, oversized title, a Netflix
meta row (owned% · year · rating · seasons · runtime · status), 3-line synopsis,
and action buttons (Watchlist / Get Missing / external links).
- Per-show accent colour sampled from the poster (canvas) → drives the primary
button glow, status, episode hover — the SoulSync 'vibe', per title.
- Custom season dropdown + rich episode rows (index, 16:9 thumb w/ hover play,
title · runtime, 2-line synopsis, Owned/Missing) that fade/stagger in on season
change.
- Backdrop falls back to a cover-cropped poster; episode stills + genres + cast
arrive with the 'capture everything' phase. Watchlist/Get-Missing are visual
pending their endpoints. Shell tests 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.
Drill-in from a show card: full-bleed backdrop + poster + title/badges/overview +
stat tiles, then seasons->episodes as collapsible accordions with owned/missing
state and per-season coverage bars (season = album, episode = track — inspired by
the music artist page, premium vibe).
- video-detail.js (isolated IIFE) renders from /api/video/detail/show/<id>;
backdrop/poster via the proxy.
- Show cards dispatch soulsync:video-open-detail; video-side.js navigates to the
(non-nav) detail subpage; back button reuses data-video-goto.
- Movies stay non-clickable until the movie-detail page lands next.
- .vd-* CSS scoped to the detail page; music untouched. Shell + isolation tests.
- Paused enrich buttons now get music's exact amber/yellow treatment (gradient,
border, glow, hover) and an amber tooltip status — was just a flat opacity dim.
- Manage Workers button reuses music's .em-manage-btn* classes verbatim, so the
logo sits in the same gradient icon-circle with glow and the pill matches
pixel-for-pixel. Still wired by data-attribute (no inline handler), and music's
orbs/handler can't touch it (scoped to #dashboard-page). Dropped the old
bespoke .video-manage-workers-* CSS.
Port of webui/static/worker-orbs.js into video/video-worker-orbs.js — same
exact animation (physics/draw copied verbatim), but pointed at the video
dashboard header + the TMDB/TVDB enrich buttons + Manage Workers hub. Own
window.videoWorkerOrbs global, activated by the video side's page events;
music's orbs file is untouched and never learns about the video side.
video-enrichment.js feeds it real status as telemetry for the inbound pulses.
7s idle → floating orbs around the SoulSync logo, just like music.
Root cause both buttons looked black: --ve-accent was space-separated
(1 180 228) but used in rgba(var(--ve-accent), a) -> invalid 'rgba(1 180 228, a)'
so the color silently failed. Switched --ve-accent to comma-separated (matching
music's --accent-rgb) and fixed all fallbacks -> the accent + glow now render.
- TMDB: vibey light blue (56,189,248); TVDB: purple (168,85,247).
- TVDB brand mark inverted everywhere (dashboard button, modal rail, settings
frame) so the dark logo reads on the dark UI.
The buttons were flat and the reused .tooltip-content rendered in music's
default purple. Now everything is driven by --ve-accent (on the container so the
tooltip inherits it):
- buttons mirror music's .musicbrainz-button — always-on accent glow, accent
gradient/scale on hover, bigger glow when active; logo 24px @0.85 opacity with
drop-shadow; spinner uses the accent.
- tooltip-content border/glow/arrow/header + the status value are tinted with
the service accent (TMDB blue / TVDB green), overriding music's purple default
scoped to the video tooltip only. Music untouched.
- Logos (the URLs you gave) everywhere the services are listed: dashboard
buttons, Manage-Workers modal rail, and the settings API-config frames —
matching how music shows enrichment-service logos.
- Dashboard hover tooltips now reuse music's shared .tooltip-content/-header/
-body/-status/-current/-progress classes + the same positioning, so they look
identical to the music enrichment tooltips (Status / current item / Progress)
instead of my bespoke style. Music CSS untouched (shared classes, reused).
Each video connection item (TMDB/TVDB) now has a Test button that behaves like
music's: saves the key, hits POST /api/video/enrichment/<svc>/test, and toasts
the result via the shared showToast — isolated (own endpoint, own data-attr
handler, reuses the .test-button CSS).
- Client .test() pings TMDB /configuration and TVDB /login to verify the key.
- Endpoint returns {success,message,error}; unknown service -> 404.
94 tests green; music untouched.
The video API-key frames had the real data-service attribute, so music's
settings.js verify loop (#settings-page .stg-service[data-service]) picked them
up and errored 'Unknown service: tvdb' — and it ran on the music side too
(shared DOM), so it WAS impacting music. Renamed to data-video-service: same
identical .api-service-frame look, but music's [data-service] selector can't
match them. Music untouched again.
The dashboard 'Manage Workers' button now opens a video enrichment modal that
reuses music's global .em-* modal CSS (identical look) but is entirely its own,
isolated JS: own #vem-overlay, event-delegated (no inline handlers, no music
function calls), targets /api/video/enrichment, shows only TMDB/TVDB with
movie/show coverage.
- Rail of workers (status dot + coverage), panel with pause/resume, per-kind
coverage cards (matched/not-found/pending segmented bars), and a paged
unmatched browser with retry (item + retry-failed).
- Polls the selected worker every 3s. The few invented sub-classes are styled
scoped to #vem-overlay so music is never affected. 87 tests green.
Brings the worker buttons back onto the video dashboard header as real, live
controls — isolated (own CSS classes + own JS + /api/video/enrichment), music
untouched.
- TMDB/TVDB round buttons with per-service accent, a spinner that spins while
the worker runs, and a hover tooltip (status / current item / progress).
- video-enrichment.js polls /api/video/enrichment/<svc>/status (only on the
video side, so the video engine isn't spun up on the music side); click
toggles pause/resume. Manage Workers button fires soulsync:video-open-workers
for the modal (Phase 3).
86 tests green.
The Refresh/Deep Scan buttons already fired a scan, but the card gave no
feedback so it looked dead. Now it mirrors music's dashboard library card:
- a progress section (phase + bar + detail) appears during a scan, driven by
the shared scan events (real percent);
- buttons disable while scanning;
- the card hydrates on load/return — if a scan is already running, video-scan.js
re-emits progress and the card shows it;
- stats refresh when the scan finishes.
Reuses music's .library-status-progress classes. 84 tests green.
Handles big libraries (your ~8500 movies) like music does instead of rendering
everything at once.
- DB: sort_title populated article-aware on upsert ('The Matrix' files under M);
query_library(kind, search, letter, sort, status, page, limit) does all
filtering/sorting/paging in SQL and returns music's pagination shape
{page,total_pages,total_count,has_prev,has_next} + badge fields (resolution,
owned/episode counts).
- GET /api/video/library now takes those params (per kind) instead of dumping
everything.
- Library page: 75/page with ← Previous / Page X of Y / Next → (music's exact
controls/classes), Sort (Title/Year/Recently Added) + Owned/Wanted filter,
server-side search + A–Z. Cards gain a resolution chip (4K/1080p/…) and the
owned/wanted meta. Still not clickable.
124 tests green.
Dashboard endpoint now returns the active media server; the Tools card title
becomes '<Server> Library Scan' (e.g. 'Plex Library Scan'), matching how music
prefixes 'Plex Database Updater'.
The scan tool now behaves like music's, not just looks like it:
- Card matches: help '?' button, 'Last Scan' line, and the Movies/Shows/
Episodes/Size stats grid (populated from /api/video/dashboard on show + after
a scan). Same .tool-card-stats markup.
- Real progress bar: scanner fetches item totals up front (Plex section.
totalSize / Jellyfin TotalRecordCount) and reports a true percent as it
processes; the bar actually moves (movies → shows) instead of sitting at 100%.
- Cancel: the Scan button toggles to 'Cancel' mid-scan and POSTs
/api/video/scan/stop; the scanner checks a cancel flag between items and ends
in a 'cancelled' state. Mirrors music's stop affordance.
Tests: percent reported, cancel stops midway + saves only processed items, stop
route registered, tool-card structure. 117 video/integrity tests green.
Marked data-music-only (hidden on the video side): Music Videos Dir, Playlists
Folder, Playlist Folder Style, M3U Entry Base Path — all music-specific. Music
side unchanged.
Rebuilt the Library page to reuse the music library's exact look — no
reinvention, just new data:
- Same classes: .library-container, .library-artist-card grid, .alphabet-
selector, .library-search-input, loading/empty states. Movies/Shows tab pill
is the only video-specific bit.
- Real posters via a server-side proxy: GET /api/video/poster/<kind>/<id>
streams the Plex/Jellyfin artwork (token stays server-side); cards fall back
to an emoji on miss. list_movies/list_shows now expose has_poster (no raw
server paths leaked).
- Client-side search + A-Z letter filter (article-aware) over the loaded set;
cards are divs (not clickable yet, per request). Scan button in the header
reuses the shared scan controller and reloads on done.
110 tests green.
Standardized the TMDB/TVDB placeholders to the same .api-service-frame
.stg-service accordion markup as every music API service (header +
toggleStgService accordion + body with API Key field + callback-info), plus the
same 'Expand All' header. No bespoke structure. Reuses the existing accordion
handlers (already defined, integrity test green).
On the video side the API Configuration section (Spotify/Tidal/Deezer/etc.) is
all music — hidden now (group marked data-music-only). In its place, a video API
Configuration group (data-video-only) with disabled TMDB + TVDB placeholders for
the metadata sources we'll likely use. Music side unchanged.
The Movies/TV selectors now save the moment you pick one — same as the music
'Music Library' selector right above them — instead of a separate 'Save
Libraries' button. Removed the button and the copied 'doesn't affect config
file' caption; a small inline status shows 'Saved'.
Right next to music's 'Music Library' selector, the video side now shows
'Movies Library' + 'TV Shows Library' dropdowns (data-video-only, hidden on the
music side). video-settings.js populates them from /api/video/libraries when
Settings opens on the video side and saves the choice back; the scanner then
reads only those libraries. Isolated IIFE, data-attr wired. 83 tests green.
The visuals were off because I'd invented CSS/markup instead of reusing the
shared design system. Fixed to match music exactly:
- Dashboard Library card now uses music's full markup — header icon, Refresh/
Deep Scan buttons WITH their icons, and stat rows with icons (movies/shows/
episodes/disk). Same .library-status-* classes, no custom CSS.
- Tools 'Library Scan' card now mirrors the music Database Updater: a mode
dropdown (Incremental/Full Refresh/Deep Scan) + one Scan button inside
.tool-card-controls + the standard progress bar. Styling comes for free from
the generic music classes.
- Dropped bespoke .video-tool-btn/.video-scan-controls CSS and folded the
separate video-tools.js into the shared video-scan.js (one fewer file). JS
stays isolated only because it must hit /api/video + update video DOM.
110 tests green.
- New Tools page (video nav + subpage, mirrors music tools styling): a Library
Scan tool card with Incremental / Full Refresh / Deep Scan buttons + a live
status line. Room for more maintenance jobs later.
- Dashboard Library card now has Refresh (full) + Deep Scan buttons, like the
music dashboard.
- Shared video-scan.js controller: one place triggers + polls scans for all
surfaces (wires any [data-video-scan-mode]/[data-video-scan]); emits
soulsync:video-scan-progress/done. Library/Tools/Dashboard just listen — no
duplicated fetch/poll. video-library.js refactored onto it; dashboard reloads
stats on scan-done.
- All isolated IIFEs, data-attr wired (no inline onclick). video-tools added to
the nav (13 pages). 110 tests green.
- GET /api/video/library -> {movies, shows} from video.db (VideoDatabase.
list_movies/list_shows; shows carry episode_count + owned_count).
- Library page (video-library subpage, isolated video-library.js): tabbed
Movies/Shows grid of poster cards, count, empty-state. A 'Scan Library'
button POSTs /api/video/scan/request then polls /api/video/scan/status,
showing live phase/counts, and refreshes the grid when done.
- Reuses the music dashboard-header chrome (icon title, sweep hidden) + the
watchlist-button styling for the scan button; video-card grid styles added.
- All data-attr wired (no inline onclick); module is an isolated IIFE that
listens for soulsync:video-page-shown. 105 tests green.
Now: video.db -> scanner -> /api/video -> live dashboard + Library page, all
isolated from music. Scanner adapters await live Plex/Jellyfin validation.
Pointless until the real enrichment workers exist. Header keeps the icon
title, subtitle, Watchlist/Wishlist quick-nav and (hidden) sweep; the worker
button row will land later, matching music.
The video dashboard header now mirrors music's: icon + shimmer title,
subtitle, the Watchlist/Wishlist quick-nav (top-right), and the action-button
row. Differences, all isolated:
- Sweep band kept in markup but hidden on the video side (no animation for
now; meta-source-driven equivalent may return later).
- Quick-nav reuses .watchlist-button/.wishlist-button styling but carries NO
music IDs (no duplicate IDs, no music-JS binding) — navigates to the video
Watchlist/Wishlist pages via data-video-goto.
- header-actions holds disabled TMDB/TVDB/Trakt/OMDb placeholder chips
(.video-meta-button) standing in for music's enrichment buttons until the
video meta sources are wired.
No inline onclick; 75 tests green.
Real first video page, reusing music's .dash-grid/.dash-card CSS for an
identical look — but every value is driven by isolated video JS, no music
code referenced.
Sections mirror the music dashboard, adapted:
- Service Status: Media Server / Download Client / Metadata Source
- System Stats: swaps 'Active Syncs' -> 'Disk Usage'; keeps download/speed/
uptime/memory
- Library: Movies / Shows / Episodes / Disk Size
- Recent Syncs -> Recent Downloads (empty state for now)
- Quick Actions: Add Movie/Show, Watchlist, Downloads (navigate via
data-video-goto)
- Recent Activity
- No enrichment section, no header sweep animation (per plan)
Mechanics:
- #video-page-host now holds .video-subpage sections; controller toggles one
at a time and falls back to #video-placeholder-slot for unbuilt pages.
- video-side.js dispatches soulsync:video-page-shown; video-dashboard.js (new
isolated IIFE) listens and applies a zeroed STUB until video.db exists.
Single seam to swap for a real /api/video/dashboard fetch later.
- All wiring via data-attrs + addEventListener; no inline onclick (keeps the
script-split integrity contract intact). 73 tests green.
Completes the Watchlist+Wishlist pair (same as music). Watchlist monitors
shows/channels for new content; Wishlist is the wanted/missing queue
(movies, one-offs, failed grabs to retry). Placeholder for now.
Following (Watchlist) and the download queue (Downloads) are core to a
movies/TV/YouTube manager — same names as music so they read intuitively.
Both wired via data-video-page (no inline onclick); placeholder for now.
- Subtitle on the video side is now 'Video Manager' (was 'Video Sync & Manager').
Music keeps 'Music Sync & Manager' — sync fits music, not video.
- Toggle is now a proper animated pill: a gradient thumb slides under the active
side (CSS-driven off body[data-side], spring easing), each side has a small
icon (music note / film), inset track. Still data-attr wired, no inline onclick.
First slice of the video side, on the experimental branch. Purely additive and
fully isolated from music:
- A Music | Video toggle in the sidebar header; clicking flips body[data-side]
(remembered in localStorage). The shared shell (logo, user, Support, Version)
stays; only the nav set + subtitle swap.
- A second sidebar nav (.video-nav) with the video pages — Dashboard, Search,
Discover, Library, Calendar, Import, Settings, Issues, Help & Docs — shown via
CSS off body[data-side]. Service Status is hidden on the video side.
- A placeholder content host; real video pages land later.
Isolation contract held: index.html is +51/-0 (no music markup changed), music
JS/CSS untouched, nothing in music references the controller. The controller
(webui/static/video/video-side.js) is a self-contained IIFE wired purely via
addEventListener (no globals, no inline onclick) — so it can't affect music and
doesn't trip the script-split-integrity contract.
Tests: 6 video-shell structural/isolation tests + 64 script-integrity green.
Phase 2 of the redesign. The tool that judged quality by extension and auto-dumped
matches into the wishlist is gone; quality scanning is now the reviewed
quality_upgrade repair job.
Removed:
- Frontend: Tools-page Quality Scanner card, its JS handlers/poller/socket listener,
help tooltip + tour entry (webui index.html, core.js, helper.js, wishlist-tools.js).
- Backend: /api/quality-scanner/{start,status,stop} endpoints, the in-memory state +
executor + 1s socket broadcast, the QualityScannerDeps/run_quality_scanner shim.
- core/discovery/quality_scanner.py: the auto-acting worker + deps class (the shared
match/normalize helpers stay — the new job imports them).
Rewired:
- Automation 'start_quality_scan' action now triggers the quality_upgrade repair job
via repair_worker.run_job_now() (AutomationDeps gains run_repair_job_now, drops the
4 scanner fields). Action block's vestigial scope field removed (scope lives in the
job's settings now). NOTE: the 'quality_scan_completed' trigger no longer fires (the
repair job doesn't emit it).
- Updated all automation test _build_deps helpers + conftest tool-progress harness;
deleted the obsolete worker test. 528 affected tests pass; 6123 collect cleanly.
QUALITY_TIERS / _get_quality_tier_from_extension kept (used elsewhere).
- Settings: 'Playlists Folder' path field (Unlock pattern, separate-root help
text), a Symlinks/Copies selector, and a 'Rebuild playlist folders now' button
(standard test-button style). Wired through PATH_INPUT_IDS / load / save, plus
'playlists' added to the settings save allowlist so it persists.
- POST /api/playlists/materialize/rebuild → rebuild_organized_playlists_from_db:
rebuilds every organize-by-playlist folder from CURRENT ownership, re-matching
each track with check_track_exists (name, not IDs) so it self-heals after a
reorganize / membership change. +1 test.
70 materialize tests + JS integrity pass; settings round-trip wiring verified.
Per feedback — instead of two export buttons (one on the watchlist filter bar, one
in the library header), there's now a single "Export" button. The modal gains a
Watchlist | Library scope toggle at the top; switching scope re-fetches and shows/
hides the "library counts" option (library-only). One place, both rosters.
Also relaxed the two export endpoint wiring tests — they asserted an empty DB,
which is false in a shared test run (the artists table may already hold rows); now
they assert a valid JSON array + headers/columns instead. The endpoints are
unchanged and verified against real data.
Extends the watchlist export to the full library. The exporter is now general
(core/exports/artist_export.py, renamed from watchlist_export) — adds tidal/qobuz
links and an extra_fields passthrough, so the library export also carries
lastfm/genius URLs + soul_id, and an optional "library counts" toggle adds owned
album/track counts per artist.
- GET /api/library/artists/export?format=&links=&contents= — pulls every artists
row, normalizes onto the canonical *_artist_id keys, optionally GROUP-BY counts
for album/track totals.
- The export modal is now openArtistExportModal(scope): "Export Library" button in
the library header + the existing "Export" on the watchlist bar (a thin wrapper).
Library mode shows the extra "library counts" toggle.
Tests (11): builder across formats + the new tidal/qobuz links + extra_fields
columns; watchlist + library endpoint wiring. 64 integrity green; ruff clean.
An "Export" button on the watchlist filter bar opens a modal (same aesthetic as the
artist DB-record inspector) to export your whole watchlist roster — each artist's
name + source IDs (spotify / musicbrainz / deezer / discogs / itunes / amazon),
with an optional "external links" toggle that adds the discography URLs built from
those IDs. Live preview, copy, and download in the chosen format.
- core/exports/watchlist_export.py: pure builder (json/csv/txt + links, present-IDs
only, deterministic columns) — the single source of truth, fully unit-tested.
- GET /api/watchlist/export?format=&links= shapes the roster + returns it (with
X-Export-Count / X-Export-Ext headers for the modal).
- Frontend reuses the DB-record helpers (_jsonSyntaxHighlight / _arecCopy).
Tests (8): builder across json/csv/txt, links on/off, present-ids-only, empty +
bad-format fallback, mime/ext, and endpoint wiring. ruff clean; 64 integrity green.
Scoped to the watchlist for v1; library-wide export + a "library contents"
(owned albums/tracks) option are natural follow-ups.
New Aria2 JSON-RPC adapter, alongside qBittorrent / Transmission / Deluge. Aria2's
RPC (default :6800/jsonrpc) maps cleanly onto the uniform adapter contract:
- the --rpc-secret token leads every call as "token:<secret>" (no username — the
secret uses the existing password field),
- addUri returns a GID (our torrent id); tellStatus → TorrentStatus with state
mapping (active→downloading, or seeding once the payload is complete; waiting→
queued; etc.),
- remove picks forceRemove vs removeDownloadResult by status, and (since aria2
doesn't delete files on remove) unlinks the file paths itself for delete_files,
- bare-host URLs get /jsonrpc appended.
Wired into adapter_for_type + the Settings dropdown (with a help note: port 6800,
secret in the Password field). All adapter methods go through the same interface,
so the stall/orphan handling and downloads pipeline work unchanged.
Tests (9): registry wiring, state mapping (incl. active→seeding), token-prefixed
params, /jsonrpc fixup, status parse (+ name fallback, no div-by-zero). 126 torrent
tests green; ruff clean.
Invariant: while security.require_login is on, every profile must have a login
password or it's locked out. Previously only the admin's own anti-lockout existed,
so members could be stranded (created without a password, or login flipped on while
passwordless members existed). Closed all the write-points:
core/security/login_provisioning.py (pure policy, single source of truth):
- members_without_password(profiles) — non-admin profiles that can't sign in
- create_needs_password(require_login) / removing_password_strands(require_login)
Wired into web_server:
- create_profile: while login is on, a new member must be given a password (400
otherwise) and it's set on creation.
- enable-login (settings save): refuses to turn login on while any member lacks a
password — lists them — same shape as the existing admin anti-lockout.
- set-password: refuses to CLEAR a password while login is on (would strand them).
UI: Create Profile form gains a login-password field (alongside the optional PIN);
the Manage Profiles per-member password button (prior commit) covers existing
members + changes.
Tests: pure policy seam + endpoint enforcement (create blocked w/o password when
on, allowed w/ password, no friction when off, clear blocked when on). 442
profile/settings/auth tests green; ruff clean.
The artist-separator setting + backend join already supported any delimiter; just
add ' & ' to the dropdown. Safe by construction — artists are joined from the
source's artist LIST, never split, so a name containing '&' (Florence & The
Machine) is one entry and can't be mis-parsed. Closes the join half of #840.
After saving a password or recovery question, a refresh made the section look
unset (passwords are never echoed back to the browser), so it seemed like you had
to redo it. Now the saved state is reflected:
- "✓ A login password is set" appears when the admin has a password; the field
becomes "Enter a new password to change it".
- "✓ Recovery question saved: <question>" appears, the saved question is pre-
selected (preset or custom), and the answer field becomes "Enter a new answer to
change it".
- Shown both on load (applyLoginSavedState from /api/profiles, which now includes
recovery_question — not secret, already shown on the sign-in screen) and
immediately after saving.
64 integrity tests pass.
Mirror the PIN setup's confirm step so a typo can't silently set a password you
can't reproduce. Both the Step 1 admin password (Settings) and the forgot-password
reset (login screen) now require entering it twice and reject a mismatch before
saving. 64 integrity tests pass.
The security section had grown into a flat pile of toggles with hidden
dependencies. Regrouped into three labelled cards so it reads top-to-bottom:
- 🔑 Lock with a PIN — set PIN (Step 1) → Require PIN
- 👤 User accounts (login) — Step 1 admin password → Step 2 recovery question →
Step 3 Require login. The Step 3 toggle is now visually LOCKED (greyed +
disabled + "set the admin password first" hint) until an admin password exists,
so the anti-lockout rule is obvious instead of surfacing as a 400 on save. It
unlocks the moment the password is saved.
- 🌐 Reverse proxy & remote access — the proxy toggle, with the auth-proxy header
nested under it (indented), plus WebSocket origins.
- get_all_profiles/get_profile now expose has_password + has_recovery so the UI
can reflect setup state; updateRequireLoginGate() drives the lock.
- New .security-subgroup/.security-subhead/.security-nested/.security-locked CSS.
All IDs + handlers preserved. Inert unless used; default install unaffected.
64 script-split integrity tests pass.
- Settings → Security: a recovery-question picker (5 presets + Custom) + answer
field + Save, posting to /api/profiles/1/set-recovery. handleRecoveryQuestionChange
reveals the custom box.
- Login screen: a "Forgot password?" link opens a recovery view — enter username →
fetch your question → answer + new password → reset → reload signed in. Reuses the
launch-PIN overlay styling/structure (entry + recovery views).
All inert unless login mode is on, so a default/LAN install never sees any of it.
64 script-split integrity tests pass (every new handler resolves).
The UI that makes opt-in login usable. Off by default → your LAN setup is unchanged
(none of this appears unless security.require_login is on).
- Login screen overlay (reuses the launch-PIN styling): username + password →
/api/auth/login → reload into the app. Shown when /api/profiles/current reports
login_required (checked before profile selection).
- POST /api/profiles/<id>/set-password (admin, or self) to set/clear a login
password, distinct from the PIN.
- Settings → Security: "Login password (admin account)" field + a "Require login"
toggle (with the anti-lockout note). Wired into the existing settings load/save.
- Sign-out button in the profile bar, revealed only in login mode (login_mode flag
on /api/profiles/current); soulsyncLogout() → /api/auth/logout → reload.
Tests: set-password sets/clears + verifies; /api/profiles/current signals
login_required. 20 login/password tests pass; 64 script-split integrity pass.
Remaining (small follow-up): a password field in the Manage Profiles edit form so
admins can set OTHER profiles' passwords from the UI (the endpoint already exists).
Config is DB-backed (metadata.app_config) — there is no config.json — so the
reverse-proxy settings I added earlier had NO way to be set by a user and were
effectively dead. Added them to Settings → Security, next to the launch-PIN toggle:
- "Behind a reverse proxy" checkbox (security.trust_reverse_proxy) — help text notes
it's for nginx/Caddy/Traefik+TLS, to leave OFF for direct/LAN http://, and that it
needs a restart (applied at app init).
- "Auth proxy user header" field (security.auth_proxy_header) — e.g. Remote-User,
with the must-strip-client-headers warning; blank = off.
Wired into the existing settings load + save; the save loop already persists every
key in the security object via config_manager.set, so no backend change needed.
Fixed Support/REVERSE-PROXY.md to point at Settings → Security instead of a
nonexistent config.json. Off by default → zero impact for direct users.
64 script-split integrity tests pass.
- '⚠ Unverified' filter pill on the Downloads page lists completed downloads
whose verification status is unverified/force_imported (review queue)
- the quarantine-retry engine's attempt counter (already tracked internally)
is now surfaced: task.retry_info ('2/5') shows next to Searching/Downloading
in the modal and as 🔁 on the Downloads page rows, with the trigger in the
tooltip
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Functionally unchanged — just brought it up to the polish of the rest of the app
(My Accounts / Manage Workers style). Same markup hooks + JS bindings, so no
behaviour change.
- Glassy gradient panel with blur backdrop, rise+fade entrance, soft shadow.
- Sticky header with a gradient people-icon badge + subtitle; close button
rotates on hover.
- Profile rows are cards now: hover lift, and the profile you're signed in as is
highlighted (accent ring + a "You" pill).
- Role/status shown as pills (Admin / No Downloads / N pages) instead of a
dot-joined string.
- Edit/Delete are clean SVG icon buttons (was ✏️/🗑️ emoji) with accent/red hover.
- Inputs get a focus glow; colour swatches are larger with a check on the
selected one.
64 script-split integrity tests pass; all JS-referenced classNames verified present.
First service of the per-profile playlist-auth feature. Each profile connects
its OWN Spotify account through the shared (admin's) app, getting its own token;
used for that profile's playlist reads. Admin + unconnected profiles + all
background workers keep using the global/admin client — fully non-regressive.
- Shared-app OAuth: get_spotify_client_for_profile + the /auth/spotify init &
callback now use the GLOBAL app creds (falling back from any legacy per-profile
app creds) with the profile's own token cache, and show_dialog=true forces the
account chooser so a user can't silently inherit the admin's Spotify session.
The builder gates on the profile's own token cache existing — no cache → global.
- My Accounts modal (new, all-profile-accessible via the profile bar): one-click
Connect/Disconnect Spotify + connection status (account name). GET
/api/profiles/me/connections + POST .../spotify/disconnect; admin's Spotify is
read-only here (managed in Settings).
- Wired the request-scoped reads to the per-profile client: the playlist LIST,
the playlist TRACKS view, liked-songs count, and user info — so a connected
user sees and opens THEIR OWN (incl. private) playlists, not the admin's.
Tests: builder falls back to the global client for admin/None/unconnected (the
non-regression guarantee); connections status reports unconnected; admin
disconnect rejected. 124 profile/spotify/gate/integrity tests pass.
Still on the global account (next step): sync/download jobs run in background
workers with no profile context — stamping the requesting profile onto the job
is the remaining wiring. Other services (Tidal/Deezer/Qobuz/Last.fm/ListenBrainz)
follow this same pattern.
The model shifted from "admin creates shared credential sets, users pick" to
"each profile self-auths its own playlist accounts". Removed the admin-facing
Connected Accounts manager: the Settings section, credential-sets.js, its CSS,
the script tag + integrity-registry entry, and the loadSettingsData hook.
The credential-sets backend (service_credentials tables + /api/credentials and
/api/profiles/me/services endpoints) is left in place but dormant — additive,
tested, harmless — rather than churn migrations that already ran on installs.
Per-profile self-auth reuses the existing per-profile columns + the
get_*_for_profile client pattern instead. The Service Status modal (admin-only)
is unaffected.
The modal's server logos were the odd ones out — Jellyfin used the wide
jellyfin.org wordmark and Navidrome a stretched navidrome.org image. Switched
the modal's _SS_SERVER_INFO (drives both the rail and the option grid) to clean
square icons: Plex's plex-logo.svg, Navidrome's tweakers.net icon, and the
homarr-labs Jellyfin PNG. Also swapped the Settings page Jellyfin toggle from
the jellyfin.org wordmark to the same homarr-labs icon so they match.
Replaces the basic credential-pill quick-switch with a Manage-Workers-styled
modal (topbar + left rail + panel, entrance animation, brand-logo cards).
- Sidebar Service Status: whole panel opens the modal; clicking the Metadata /
Media Server / Download rows deep-links straight to that tab. Removed the
"switch ▸" hover text.
- Three tabs: Metadata (source logo cards, unavailable ones dimmed), Server
(Plex/Jellyfin/Navidrome/SoulSync logos), Download (Single⇄Hybrid segmented
toggle; Hybrid shows a draggable priority list). Logos reuse SOURCE_LABELS +
HYBRID_SOURCES; active card gets an accent ring + check.
- Admin writes the GLOBAL active source/server/download (reuses the same setters
+ client reloads as the Settings save, so changes take effect immediately).
Non-admins see it read-only (editable=false) — the per-profile override is the
next layer.
Backend: GET /api/profiles/me/active-sources (any profile; reports editable),
POST /api/profiles/active-sources (@admin_only; validates against the allowed
metadata/server/download lists, applies + reloads). New service-switch.js
(registered + in the integrity registry); old modal removed from
credential-sets.js (admin Connected Accounts manager stays).
Tests: 14 endpoint tests — read shape, admin sets metadata/hybrid+order
(reflected), bad-value 400s, non-admin read-only + 403 on write. 64 integrity
tests pass; real-app smoke confirms render + deep-links + the full set/reflect
cycle.