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.)
- 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.
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.
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.
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.
Backend for the upcoming TV/movie detail pages, isolated to video.db:
- show_detail(id): show + seasons->episodes tree with owned/total roll-ups
(season 0 -> 'Specials', missing-season-row episodes still grouped).
- movie_detail(id): movie + owned flag + best media-file (resolution/quality).
- get_art_ref generalizes the poster ref to poster|backdrop; new
/api/video/backdrop/<kind>/<id> streams the hero art server-side (Jellyfin
Backdrop vs Primary handled).
- /api/video/detail/{show,movie}/<id> endpoints.
Seam tests for the tree roll-ups, owned/file, art ref, and both endpoints.
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.
- GET/POST /api/video/enrichment/config saves the keys into video_settings;
POST rebuilds the engine (stop old workers, rebuild clients with new keys) so
they pick up the change live.
- video-settings.js loads the saved keys into the TMDB/TVDB fields on the
Connections tab and saves them on change (workers enable once a key is set).
Backend is now end-to-end: key -> client.enabled -> worker matches the library
to TMDB/TVDB and fills ids + metadata. 91 tests green; real DB untouched.
Mirrors the music enrichment API so the shared Manage-Workers modal can drive
video workers by pointing at /api/video/...:
- GET services; GET <service>/status (worker.get_stats); POST pause/resume;
GET breakdown; GET unmatched (paged, kind/status/q); POST retry.
- Unknown service -> 404. Engine via the lazy singleton; DB queries via the
isolated video DB. 6 API tests (services/status/breakdown/unmatched/pause/
resume/retry/404) with an injected engine + fake clients.
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.
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.
The scan no longer blindly grabs every movie/show section — it reads the
libraries you map, like music's 'pick your Music library'.
- GET /api/video/libraries: discover the active server's Movies/TV libraries
(Plex sections by type / Jellyfin views by CollectionType) + current
selection. POST: save {movies, tv} per server into video_settings.
- sources.py: _build_source(movies_lib, tv_lib) filters to the mapped library;
get_active_video_source() (used by the scanner) loads the saved selection;
list_video_libraries() lists them unfiltered for the UI. Falls back to all
libraries when nothing is mapped yet.
- VideoDatabase.get/set_library_selection (per-server). 6 tests added; 33 green.
Mirrors the music model (full_refresh vs smart incremental, plus deep_scan):
- incremental: only recently-added items from the server (Plex addedAt:desc /
Jellyfin DateCreated, capped); upsert; no prune.
- full: every item; upsert all (refresh metadata + add new); no prune.
- deep: every item; upsert; prune what the server no longer has (empty-scan
safety preserved).
scanner.request_scan/scan_sync take mode; /api/video/scan/request reads
{mode} from the body (default full); adapters take incremental=. Tests cover
deep-prunes / full-doesn't / empty-deep-safety / incremental-requests-recent.
- 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.
Reads the active media server and mirrors it into video.db, adapting the music
scan pattern (ask the server, upsert, prune what's gone) — isolated from music.
- core/video/scanner.py: server-agnostic VideoLibraryScanner. Consumes a media
source (duck-typed) yielding normalized dicts; upserts movies + show trees,
prunes removed items, reports progress/state. Skips pruning when a scan
returns nothing (transient-failure safety). Background thread + scan_sync.
- core/video/sources.py: Plex + Jellyfin adapters that REUSE the shared
connected clients (MediaServerEngine) but own all video-section logic; produce
normalized dicts. (Validated against a live server by design; scanner itself
is fully unit-tested with a fake source.)
- api/video/scan.py: POST /api/video/scan/request, GET /api/video/scan/status.
- .gitignore: video_library.db + sidecars (mirrors music); tests inject a
tmp DB so none is ever created in the repo.
Tests: scan populate/prune/empty-safety/no-source-error, isolation guard
(core/video imports nothing from music), scan routes registered. 101 green.
First wire from video.db -> UI, kettui-style.
- api/video/ : isolated Flask blueprint (registered at /api/video with one
additive line in web_server.py). Reads only video.db; imports nothing from
the music API or DB.
- GET /api/video/dashboard -> VideoDatabase.dashboard_stats(): live library/
download/watchlist/wishlist counts (real 0s on an empty DB).
- video-dashboard.js now fetches it and fills the stat cards + Watchlist/
Wishlist header badges (formatted bytes/speed); falls back to zeros on error.
uptime/memory stay at markup defaults for now (not video-domain).
- Tests: dashboard_stats counts (empty + populated), endpoint returns zeroed
JSON via a Flask test client, blueprint exposes the route, and the video API
imports nothing from music. 93 video/integrity tests green.
Diagnostic-only change for issue Technodude reported: Tidal sync-playlist
downloads getting mass-cancelled mid-flight with no clear cause in the
logs. App.log shows ~91 second gaps between Tidal download start and
cancel — matches the monitor's 90s queue-timeout exactly — but none of
the monitor's WARNING log lines fire, so the trigger is ambiguous
between five `_should_retry_task` paths, three web_server cancel paths,
and the API endpoints.
Added a single `[CancelTrigger:<label>]` INFO log line immediately
before every `download_orchestrator.cancel_download(...)` call so the
next log dump pins down which path is firing.
Labels (grep-able, prefix tells the file, suffix tells the trigger):
monitor.not_in_live_transfers_90s
monitor.errored_state_retry
monitor.queued_state_timeout
monitor.stuck_at_0pct_timeout
monitor.unknown_state_no_progress_timeout
candidates.worker_cancelled_during_download
web.orphan_cleanup
web.cancel_download_task
web.atomic_cancel_v2
api.manual_cancel_single
api.public_cancel
The monitor's `deferred_ops` tuple grew from 3 elements to 4 (added
trigger label as last element). The dispatch loop unpacks both legacy
and new shapes so the change is backward-compatible for any in-flight
ops mid-deploy.
Zero behavior change. 367 download tests still green. WHATS_NEW left
untouched — diagnostic only, not user-facing.
After ship: ask Technodude to re-run the same sync playlist scenario,
attach the new app.log, grep `[CancelTrigger:` lines for the trigger
context, then write the actual fix.
Register MusicBrainz as a first-class metadata source alongside Deezer, iTunes, Spotify, Discogs, and Hydrabase. Expose the shared client through metadata services, add the settings option, and expand the MusicBrainz search adapter with source-compatible artist, album, track, and detail methods.
Carry MusicBrainz IDs through similar-artist discovery, recommended artists, artist map serialization, and personalized playlist selection. Update DB migrations and lookup filters so similar_artist_musicbrainz_id is preserved on older schemas and used for source requirements and library exclusion.
Normalize MusicBrainz album adapter output for import context and add regression coverage for registry mapping, typed album conversion, and similar-artist filtering. Verified by user with 120 focused tests passing.
These files had silent `except Exception: pass` blocks but no module
logger. Added `import logging` + `logger = logging.getLogger(__name__)`
at the top of each, then replaced the silent excepts with
`logger.debug(...)`.
- core/replaygain.py — 4 sites (id3 txxx + vorbis + mp4 atom reads)
- core/wishlist/presence.py — 3 sites (wishlist row parsing + queries)
- core/runtime_state.py — 1 site (activity toast emit)
- core/automation/signals.py — 1 site (collect known signals)
- core/download_engine/rate_limit.py — 1 site (plugin rate_limit_policy)
- api/system.py — 1 site (hydrabase status probe)
- api/search.py — 1 site (hydrabase search)
Refs #369
The global handle in web_server.py was named soulseek_client for
historical reasons but the type has long been DownloadOrchestrator,
not SoulseekClient. Renamed the global plus every parameter/attribute
that carried the legacy name.
- web_server.py: global var renamed; all 99 references updated.
- api/, core/downloads/*, core/search/*, core/streaming/*,
services/sync_service.py: parameter names, dataclass fields, and
init() arg names renamed.
- Test fixtures (CandidatesDeps, MasterDeps, SearchDeps, etc.) and
the _build_deps helpers updated accordingly.
The core.soulseek_client module path and SoulseekClient class name
(the actual soulseek-only client) are unchanged — only the orchestrator
handle renamed. Module imports of TrackResult/AlbumResult/DownloadStatus
from core.soulseek_client preserved.
- add neutral wishlist payload helpers while keeping legacy Spotify aliases
- route wishlist removal and classification through generic track data
- keep API and service compatibility for existing callers
- Move app-wide task and activity registries out of core/imports
- Share one runtime-state module across the web server, API, and import pipeline
- Keep import-specific helpers focused on context and post-processing
- Move import flow modules into a dedicated package
- Update app and test imports to the new namespace
- Group the import-focused tests under tests/imports
- Extract the import pipeline, album import, staging, path, file ops, guards, runtime state, side effects, and metadata enrichment out of .
- Canonicalize the refactored import path around and remove legacy , , , and request shapes from the import endpoints.
- Make album and track metadata lookups follow the configured provider priority instead of hard-coding Spotify, while still falling back when needed.
- Update the import routes and frontend payloads to use the new core helpers.
- Add coverage for the extracted helpers and the refactored import flows.
PS. apologies to anyone who might check this commit out - the intention was to start small, but things kinda snowballed out of control at some point since the logic just kept going on and on, and everything kinda had to be changed all at once for it all to make any sense
Inbound music requests are tracked in an in-memory _pending_requests
dict with a 1-hour TTL. Cleanup was only triggered inside
create_request(), so during quiet periods stale entries stayed in
memory until the next inbound request.
Add a background thread that wakes every 5 minutes and evicts any
entry older than _MAX_REQUEST_AGE. The thread is started once during
API blueprint registration (start_cleanup_thread is idempotent) and
is a daemon, so it exits automatically on process shutdown.
stop_cleanup_thread() is exposed for tests and future graceful-
shutdown hooks. It signals the stop event so the thread exits
without waiting for the next cleanup interval.
GET /api/v1/downloads previously serialized every entry in the
in-memory download_tasks dict on every call. With a long-running
server and many historical downloads this produces an unbounded
response payload.
The endpoint now accepts:
limit - max items to return (default 100, clamped to 1..500)
offset - skip first N items (default 0)
status - comma-separated statuses to include (e.g. downloading,queued)
The response now includes total (post-filter count), limit, and
offset so clients can paginate without loading everything first.
Tasks are sorted by status_change_time descending so the newest
activity is on page 1.
Backward compatibility: clients that ignore the new query params
get the same shape plus the extra top-level fields; the downloads
list itself is just capped at 100 instead of unbounded.
The /api/v1/library/tracks endpoint called search_tracks() to get
DatabaseTrack objects, then immediately called api_get_tracks_by_ids()
to re-hydrate full rows for serialization. Two round trips per search.
Added api_search_tracks() that returns dict rows with all track columns
plus artist_name, album_title, and album_thumb_url in a single query.
The basic and fuzzy search helpers were refactored to share raw-row
implementations, so the existing search_tracks() still returns
DatabaseTrack objects for the many internal callers that depend on
that shape (matching pipeline, repair worker, web UI search).
The wishlist list endpoint previously loaded and JSON-decoded the full
wishlist, filtered by category in Python, then sliced in memory. Cost
grew linearly with wishlist size on every page request.
get_wishlist_tracks now accepts offset and category parameters, both
applied in SQL via LIMIT/OFFSET and json_extract. get_wishlist_count
also accepts category so COUNT(*) matches the filtered page. The API
endpoint uses these to return only the requested page.
Backward compatible: other callers (core/wishlist_service) pass no
offset/category and still receive the full list.
Every authenticated API request previously called config_mgr.set(api_keys),
which rewrites the entire app config blob to SQLite. Under load this caused
significant write amplification and lock contention.
Persistence of last_used_at is now throttled per key hash to once every
15 minutes. The in-memory timestamp on the matched key is still updated
immediately, so reads within the same process see the live value; only
the on-disk persistence is throttled.
New POST /api/v1/request endpoint accepts a search query from external
sources (Discord bots, Home Assistant, curl) and triggers the
search-match-download pipeline asynchronously. Returns a request_id
for status polling via GET /api/v1/request/<id>. Optional notify_url
for callback on completion.
Also adds webhook_received trigger type and search_and_download action
type to the automation engine, so users can build custom flows like
"when webhook received → search & download → notify Discord".
Includes info panel in Settings showing endpoint URL and curl example.
Seasonal discovery had 3 use_spotify checks using is_authenticated()
(always True) instead of deriving from the configured source. Search API
(tracks, albums, artists) also defaulted to Spotify when authenticated.
All now check configured primary source first via get_primary_source().
Users can now choose between iTunes/Apple Music and Deezer as their free
metadata source in Settings. Spotify always takes priority when authenticated;
the fallback handles all lookups when it's not.
Core changes:
- DeezerClient: full metadata interface (search, albums, artists, tracks)
matching iTunesClient's API surface with identical dataclass return types
- SpotifyClient: configurable _fallback property switches between iTunes/Deezer
based on live config reads (no restart needed)
- MetadataService, web_server, watchlist_scanner, api/search, repair_worker,
seasonal_discovery, personalized_playlists: all direct iTunesClient imports
replaced with fallback-aware helpers
Database:
- deezer_artist_id on watchlist_artists and similar_artists tables
- deezer_track_id/album_id/artist_id on discovery_pool and discovery_cache
- Full CRUD for Deezer IDs: add, read, update, backfill, metadata enrichment
- Watchlist duplicate detection by artist name prevents re-adding across sources
- SimilarArtist dataclass and all query/insert methods handle Deezer columns
Bug fixes found during review:
- Similar artist backfill was writing Deezer IDs into iTunes columns
- Discover hero was storing resolved Deezer IDs in wrong column
- Status cache not invalidating on settings save (source name lag)
- Watchlist add allowing duplicates when switching metadata sources
Adds a full public REST API at /api/v1/ with 32 endpoints covering library, search, downloads, wishlist, watchlist, playlists, system status, and settings. Includes API key authentication (Bearer token), per-endpoint rate limiting, and consistent JSON response format. API keys can be generated and managed from the Settings page. No changes to existing functionality — the API delegates to the same backend services the web UI uses.