surface the youtube_template (built into organization.py) on Settings -> Library
Organization, next to the movie/episode templates: input + variable hints
($channel/$year/$date/$month/$day/$title/$videoid) + live preview, wired into
load/collect/save/reset. backend already round-trips it (load/save normalize).
End-to-end import for video grabs, mirroring the music side's rigor and the
Radarr/Sonarr standard, fully isolated in core/video + api/video.
- Importer: parse release -> ffprobe-verify (true resolution, reject corrupt/
samples) -> templated rename into Movie (Year)/ + Show/Season NN/ -> copy or
move, carry subtitles, upgrade-replace a worse copy.
- Library Organization settings: editable $token path templates + toggles
(transfer mode, verify, replace, carry subs, save artwork, write NFO,
download subtitles + langs). Stored in video.db; matches the music File
Organization section's look.
- Sidecar writer: movie.nfo / tvshow.nfo + full artwork set (poster, fanart,
clearlogo, season posters) from on-demand TMDB detail, and external .srt from
OpenSubtitles. Owned re-grabs resolve their library tmdb_id; tmdb_full_detail
bypasses the owned->library redirect so they enrich too.
- Import page: surfaces import_failed downloads, resolve by hand (library-first
-> TMDB picker -> force-place) or dismiss; fires a library refresh on place.
- "Grab whole season": episode-level batch (reuses searchInto + _autoPick).
- Brutalist redesign of the download modal sources + result cards.
All new logic has seam-level tests (pure parsers/planners + injected I/O);
sidecars/subtitles are best-effort and never break an import.
One shared download (input) folder feeds three separate library (output) folders —
the engine routes a finished download to the one matching its type, so YouTube never
lands in your real TV library (own Plex/Jellyfin library + agent). Replaces the single
transfer_path with movies_path / tv_path / youtube_path (legacy transfer_path migrates
into Movies on first read). Settings UI: shared Download folder + 🎬 Movies / 📺 TV /
▶️ YouTube library inputs. Tests updated incl. the migration. ruff + balance clean.
Boulder: 'youtube should have its own quality profile — there are not many options.'
Right — YouTube is grabbed with yt-dlp, not scene/p2p releases, so the Radarr-style
ladder/cutoff/rejects/HDR-audio tiers are meaningless. Added a small, separate profile:
- core/video/youtube_quality.py (pure, isolated): max_resolution ceiling
(best…360p), video_codec (any/av1/vp9/h264, soft), container (mp4/mkv/webm),
prefer_60fps, allow_hdr. normalize/load/save → video.db youtube_quality_profile.
- api/video/downloads.py: GET/POST /downloads/youtube-quality.
- UI: a 'YouTube Quality' card on the Downloads tab (data-video-only) — resolution
dropdown + codec/container segmented + 60fps/HDR checks, reusing the vq-* styles.
Wired into onPageShown + the save-all chain.
The (later-phase) yt-dlp downloader maps these to a format/format_sort selection.
9 new tests green, isolation guard green, ruff clean, JS/HTML balance clean.
Addressing UX feedback (Boulder): the cutoff and size guard were confusing for a
library that holds BOTH movies and TV.
- 'Upgrade until' is now a LOOSE resolution target (4K / 1080p / 720p / SD /
'best — never stop'), not a specific source×resolution tier. 4K is always in
the list regardless of which tiers are toggled on (the old dropdown only
listed enabled tiers, so 4K vanished when off). Stored as cutoff_resolution.
- Size guard split into Max movie size + Max episode size (runtime-aware, like
Radarr's MB/min but human-readable) — a flat GB cap was meaningless across a
2-hour movie and a 25-min episode. Dropped the confusing min slider.
Pure-logic + API + UI in lockstep; removed the now-dead per-row cutoff marker.
12 tests green, ruff clean, JS/HTML balance clean.
Rebuilds the Video Quality Profile card to match the new Radarr-class model:
- Quality ladder: one ranked, toggleable source×resolution list (Remux·4K …
SDTV) reusing the .hybrid-source-item styling (parity with Download Source).
The cutoff tier shows an accent rail + 'cutoff' tag.
- Upgrade until: a styled dropdown of the enabled tiers (the cutoff).
- Never grab: red toggle chips for the hard rejects (cam/screener/workprint/3d/x264).
- Preferences: segmented controls for codec (Any/HEVC/AV1), HDR (Don't care/
Prefer/Require), audio (Any/Surround/Lossless/Atmos) + a Prefer REPACK check —
all soft tie-breakers.
- Size guard: min + max GB sliders (0 = no limit).
Dropped the old resolutions/source_priority/single-codec/fallback widgets. JS
delegation rewritten for the new data-vq-* hooks; same-verdict-as-HEAD balance,
div balance clean. Pure UI on the isolated video Downloads tab.
Consistency — the Resolution order and Preferred-source order lists now use the exact
same .hybrid-source-item markup/CSS (card + arrows + priority number + pill toggle) as
the Download Source hybrid list, instead of the bespoke .vq-row styling. Resolution
keeps its enable toggle; source is order-only (no toggle), no brand icon (they're not
branded sources). Removed the now-dead .vq-row/.vq-arrow/.vq-toggle/.vq-rows CSS.
Pure UI; data-vq-* hooks preserved so the existing delegation/handlers are unchanged.
- Hidden leftover music sections: the broad hide rule now targets ANY
[data-stg='downloads']:not([data-video-only]), not just .settings-group — so the
music Quality-Profile tile (#quality-profile-tile) and the Retry-Logic collapsible
(which are divs/section-headers, not .settings-group) no longer show as dead,
un-openable shells on the video side.
- Reordered: Download + Transfer folders now come BEFORE the Download Source section.
- slskd connection section is now COLLAPSIBLE (collapsed by default) — it was a lot of
fields; uses the same settings-section-header/body pattern as music's Retry Logic.
- Hybrid chain now reuses music's .hybrid-source-item markup/CSS for visual PARITY
(icon + name + priority + pill toggle + arrows), minus the album/track badge.
- Quality profile copy clarified: 'Resolutions to accept' (order = preference) vs
'Preferred source (tie-breaker)', clearer per-control hints.
Pure UI (markup/CSS/JS); backend + tests unchanged. JS verdict matches HEAD, HTML divs balanced.
The slskd CONNECTION settings (URL, API key, search timeout + buffer, min delay, min
peer speed, max peer queue, download timeout, auto-clear) are genuinely shared — one
slskd instance serves both sides — so the video Downloads tab surfaces them and they
read/write the app-wide config_manager soulseek.* keys, NOT video.db. Changing them
on the video side changes them for Music too (labeled 'shared with Music').
Deliberately EXCLUDES the music download/transfer paths and source mode/quality — those
stay video-specific (video.db, phases 1-3). The block shows only when soulseek is the
active/primary source (or in the hybrid chain). The 'Indexers & Downloaders' tab is
already fully shared (no video override — only data-stg='downloads' is hidden).
- /api/video/downloads/slskd GET/POST over config_manager (config.settings — shared app
config, not music code; the isolation test still passes).
- video-settings.js loadSlskd/saveSlskd (minutes↔seconds for the timeout), gated by
soulseekActive(), wired into onPageShown + the save chain.
68 video API tests green (incl. the no-music-imports guard + a shared-slskd test that
asserts it writes soulseek.* but never the video paths). Completes the Downloads-tab
settings batch (folders, quality profile, source/hybrid, shared slskd).
Video-specific Download Source section: a mode dropdown limited to Soulseek / Torrent /
Usenet / Hybrid (no streaming sources — those are music-only). In hybrid mode, a chain
builder lists the three sources with arrow-reorder + enable toggles (best-first), and
NO album-level/track-level badges (a music-only concept, per spec).
- core/video/download_config.py: pure normalize for download_mode + hybrid_order
(validates to the 3 sources, dedupes, never empties); stored in video_settings.
- /api/video/downloads/config GET/POST now carries download_mode + hybrid_order
alongside the folders.
- video-settings.js: dropdown + hybrid rows render/reorder/toggle, show the hybrid
container only in hybrid mode, save on change. Reuses the .vq-row styling.
7 tests (6 pure + 1 API). Next: the shared slskd connection block (reads/writes the
music config_manager so both sides share one slskd) + confirming the shared Indexers tab.
Foundation for the isolated video download settings. The Downloads tab is almost
entirely music-specific, so on the video side the music download sections are hidden
(video-side.css) and a data-video-only 'Video Download Folders' section takes their
place — an input (download) and output (transfer/library) folder, stored SEPARATELY
from the music soulseek.* paths in video.db's video_settings KV table.
- api/video/downloads.py: GET/POST /api/video/downloads/config (download_path,
transfer_path), registered in the video blueprint. Imports nothing from music.
- video-settings.js: loadDownloads/saveDownloads wired into onPageShown + the
video save-button chain.
- The shared 'Indexers & Downloaders' tab is left untouched (identical for both).
2 tests (round-trip + the isolation guard). Quality profile, video hybrid, and the
shared slskd block are the next phases.
Fifth/final service. Keyless, movies + shows, on by default → toggle in the
'Community Data (No Key)' frame.
- WikidataWorker: two-step lookup — find the entity by IMDb id (haswbstatement P345)
then read its official website (P856); stores wikidata_url. Registered in
build_backfill_workers.
- DB: wikidata_url/status/attempted on movies + shows + _BACKFILL/_BACKFILL_COLS;
both detail payloads return wikidata_url.
- config GET/POST wikidata_enabled; manager orb (green 🔗) + status poll; detail page
'Official Site' link badge alongside IMDb/TMDB/TVDB.
- 3 new tests (incl. the two-step fetch) + fixed-set/config assertions.
34 backfill tests green, ruff clean. All five new services (Trakt, TVmaze, AniList,
DeArrow, Wikidata) now have the full worker/DB/connections/orb/manager/detail parity.
Fourth service. Rides the YouTube-video enrich path (like RYD/SponsorBlock), keyless,
on by default → toggle in the YouTube-Extras frame.
- DeArrowWorker: fetches the branding API and stores the first non-original crowd
title for a cached YouTube video. apply_youtube_dearrow + youtube_video_dearrow_title
DB methods; dearrow_status added to the youtube_enrich next/breakdown whitelists.
- Schema: dearrow_title/status/attempted on youtube_video_stats (video_schema.sql +
_COLUMN_MIGRATIONS for existing DBs).
- config GET/POST dearrow_enabled; manager orb (blue 🏹, video-kind) + status poll;
YT video-detail panel shows a 'DeArrow' alt-title (payload via youtube.py + CSS).
- 4 new tests + fixed-set/config assertions.
30 backfill tests green. (My change is ruff-clean; the 12 pre-existing S110s in
api/video/youtube.py are unrelated tech debt on this branch, left untouched.)
Third service. Keyless GraphQL (new _http_post_json helper) → enable toggle in the
'Community Data (No Key)' frame, OFF by default (anime-niche + title-search match).
- AniListWorker: TV-only, searches AniList by title and stores the anime averageScore
(0-100), with a conservative normalized-title guard so a fuzzy anime search can't
attach a score to a non-anime show. anilist_enabled toggle (default off).
- DB: anilist_score/status/attempted on shows + _BACKFILL/_BACKFILL_COLS (keyed on
title); show_detail returns anilist_score.
- config GET/POST anilist_enabled (default 0); manager orb (blue 🎌) + status poll;
detail page 'AniList 85%' chip (+ CSS).
- 4 new tests (incl. the title-mismatch rejection) + fixed-set/config assertions.
27 backfill tests green, ruff clean. (Note: the youtube_status_route test still
flaps on the sandbox WSL WAL disk-I/O — environmental, unrelated.)
Second service. Keyless (no API key) → an enable toggle in a new 'Community Data
(No Key)' connections frame, mirroring the YouTube-Extras pattern.
- TVmazeWorker: TV-only (no movie DB), looks a show up by imdb/thetvdb id and
gap-fills the TVmaze community rating. On by default; tvmaze_enabled toggle.
- DB: tvmaze_rating/status/attempted on shows + _BACKFILL/_BACKFILL_COLS (show only);
show_detail returns tvmaze_rating.
- config GET/POST tvmaze_enabled; manager orb (teal 📺, show-kind) + status poll;
detail page TVmaze rating chip (+ CSS).
- 4 new tests + the 3 fixed-set/config-exact assertions updated.
Note: one UNRELATED test (youtube_status_route) flaps on a WSL WAL 'disk I/O error'
in this sandbox (pass/fail/fail across reruns of identical code) — environmental, not
this change; the TVmaze + config tests pass consistently.
First of the new enrichment services, wired to the SAME standard as the rest:
- TraktWorker backfill (core/video/enrichment/backfill.py): looks a title up by its
IMDb id (?extended=full) and gap-fills the community rating + vote count. Registered
in build_backfill_workers.
- DB: trakt_rating/trakt_votes/trakt_status/trakt_attempted columns + _BACKFILL /
_BACKFILL_COLS registration; detail payloads return trakt_rating/votes.
- Connections tab: Trakt service frame (Client ID field + Test button), wired into
video-settings.js load/save/bindings and the /enrichment/config GET+POST.
- Worker-manager modal + orb: WORKERS registry entry (red ★) + status-poll SERVICES
list, so it gets the same orb animation + per-service matched/pending/error card.
- Detail page: a 'Trakt 8.2' rating chip alongside IMDb/RT/Metacritic (+ CSS).
5 new tests + 2 fixed-set assertions updated; 249 video tests green, ruff clean.
- Manage Workers modal: register fanart.tv / OpenSubtitles / YouTube Votes (RYD)
/ SponsorBlock in the worker rail (cards + animations + pause/resume come free
via the shared .em-* design); add the 'video' entity kind.
- Settings: fanart.tv + OpenSubtitles API-key fields (with Test buttons) and a
no-key 'YouTube Extras' toggle frame (RYD + SponsorBlock on/off).
- API /enrichment/config now reads/writes the two keys + the two toggles; a key
change rebuilds the engine so the worker turns on immediately.
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.
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.
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.
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.
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.