Commit graph

808 commits

Author SHA1 Message Date
BoulderBadgeDad
7ab62674ed video side: reuse music styling for Tools + dashboard scan controls
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.
2026-06-13 23:50:41 -07:00
BoulderBadgeDad
71f126f9d2 video side: Tools page + scan controls on dashboard (3 modes)
- 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.
2026-06-13 23:34:28 -07:00
BoulderBadgeDad
04fb19c80c video scan: three modes (full refresh / incremental / deep)
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.
2026-06-13 23:28:57 -07:00
BoulderBadgeDad
d7ab68c067 video side: Library page (lists movies/shows, scan trigger)
- 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.
2026-06-13 23:17:48 -07:00
BoulderBadgeDad
6665ecaa12 video side: library scanner (server = source of truth) + scan API
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.
2026-06-13 23:13:50 -07:00
BoulderBadgeDad
462fa50423 video DB: server-sourced scan upserts (movies/shows/seasons/episodes)
Server (Plex/Jellyfin) is the source of truth, so every scanned row carries
(server_source, server_id) for upsert + stale-removal — mirroring how music
keys on server_source + ratingKey.

- schema: server_source/server_id columns on movies/shows/episodes (+ server_id
  on seasons); unique (server_source,server_id) on movies/shows (multiple NULLs
  allowed so wishlist rows never block).
- VideoDatabase.upsert_movie / upsert_show_tree: take normalized, server-
  agnostic dicts (a Plex/Jellyfin adapter produces them — DB never touches a
  media SDK), set has_file + media_files, and prune episodes/seasons the server
  no longer reports.
- prune_missing(): removes top-level movies/shows the scan didn't see (cascades
  clean children).
6 new tests (insert/update/file-replace, season/episode build+prune, top-level
prune); 18 video-DB tests green.
2026-06-13 23:02:03 -07:00
BoulderBadgeDad
401a9be0ec video side: live dashboard via isolated /api/video blueprint
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.
2026-06-13 22:40:48 -07:00
BoulderBadgeDad
402a1fec50 video side: video.db schema + isolated VideoDatabase
Separate SQLite file (database/video_library.db, env VIDEO_DATABASE_PATH),
fully disconnected from music — never imported by music, imports nothing from
music, no shared write lock.

Schema (database/video_schema.sql), designed to dodge the music DB's known
pain points:
- movies; shows->seasons->episodes; channels->channel_videos (YouTube as a
  first-class peer); media_files (the library); downloads (queue+history);
  activity feed; root_folders / quality_profiles / video_settings config.
- No polymorphic ids: media_files/downloads use separate nullable FKs + a CHECK
  that exactly one owner is set; real cascades.
- Explicit external-id columns (tmdb/tvdb/imdb/youtube), no source-id blob.
- Watchlist/Wishlist/Calendar are DERIVED VIEWS over monitored + file state
  (single source of truth, can't drift like music's wishlist table did).

VideoDatabase mirrors music's conventions (WAL, foreign_keys ON, 30s busy
timeout, Row factory, once-per-process init, user_version backstop) but is an
independent implementation. 13 seam tests: schema builds, CHECK constraints
reject bad rows, cascades fire, views return correct membership, KV roundtrips,
and a guard that the module imports nothing from music.
2026-06-13 22:30:55 -07:00
BoulderBadgeDad
dbe35fc023 video dashboard: drop the TMDB/TVDB/Trakt/OMDb placeholder row
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.
2026-06-13 20:06:26 -07:00
BoulderBadgeDad
a57951c038 video dashboard: header matches music (sweep hidden, video meta + quick-nav)
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.
2026-06-13 19:57:22 -07:00
BoulderBadgeDad
c84231dd4f video side: build the Dashboard page (mirrors music, isolated data)
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.
2026-06-13 19:49:09 -07:00
BoulderBadgeDad
9330d66fcd video side: add Wishlist nav page
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.
2026-06-13 19:41:46 -07:00
BoulderBadgeDad
e3d3f453da video side: add Watchlist + Downloads nav pages
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.
2026-06-13 19:36:13 -07:00
BoulderBadgeDad
3202197740 video side: Music <-> Video sidebar toggle + video nav shell (isolated)
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.
2026-06-13 19:13:54 -07:00
BoulderBadgeDad
09b97c5f63 #870: Deezer ARL 'resets itself' — test the SAVED token, not the redaction mask
The Deezer ARL field round-trips a redaction sentinel for a saved-but-untouched
secret (shown as dots). The save path already guards against the sentinel
overwriting the real token (ConfigManager.set), so the ARL was never actually
lost — but the connection TEST read the field value and sent the sentinel as the
token, so Deezer returned USER_ID=0 ('Invalid ARL token') after navigating away
and back. That false failure made it look like the ARL kept resetting.

Fix:
- ConfigManager.resolve_secret(key, posted): empty/sentinel posted value -> the
  stored value; a real string -> a genuine new secret. Reusable for any secret
  connection-test (single source of truth).
- /api/deezer-download/test now resolves the effective ARL via resolve_secret, so
  an untouched field tests the stored token.
- testDeezerDownloadConnection() strips the sentinel before sending (untouched ->
  empty -> backend uses the saved token).

Seam/regression tests for resolve_secret (sentinel/empty/none -> stored, real ->
passthrough, nothing stored -> empty). JS integrity 64 green.
2026-06-13 15:37:29 -07:00
BoulderBadgeDad
177a4d8d05 #868: disambiguate same-name artists by owned-catalog overlap during enrichment
Enrichment matched artists by NAME ONLY (0.85 gate), so for a common name
('Rone' has ~5 artists) it stored whichever the source ranked first — often the
wrong one, which then drove a wrong/sparse library 'Standard' discography while
'Enhanced' (the real owned albums) showed the full set.

Fix — use the decisive signal the library already has (the albums you OWN):
- worker_utils: pick_artist_by_catalog() + catalog_overlap_score() +
  owned_album_titles()/release_titles(). When 2+ candidates clear the name gate,
  fetch each one's catalog and choose the one overlapping the owned albums; falls
  back to the current best-by-name pick when there's nothing to disambiguate or
  no overlap (so the common single-candidate path makes no extra API calls).
- Wired into Spotify (covers Spotify-Free, same client), iTunes, Deezer (now
  multi-candidate search_artists + get_artist_info store), and MusicBrainz
  (match_artist gains owned_titles; release-groups as the catalog).

Re-match path (#868):
- build_reset_query now also clears the stored source-ID column for artist/album
  item resets — previously a 're-match' only nulled match_status, so the worker's
  existing-id short-circuit re-confirmed the WRONG id and never re-resolved. Tracks
  excluded (ids live in tags, not a column).
- MusicBrainz also self-corrects its 90-day name->mbid cache: match_artist bypasses
  a cached mbid whose catalog has ZERO overlap with the owned albums, so a re-match
  isn't blocked by a stale wrong cache entry.

Tests: shared selector (9), per-worker disambiguation for all 4 sources + MB
backward-compat + MB cache-revalidation (8), reset-clears-id (2). 99 worker/
enrichment tests green.
2026-06-13 14:57:17 -07:00
BoulderBadgeDad
030d9bf9ff Quality Upgrade: best-in-class matching (direct track-ID tier, dedup-skip, duration guard)
Four refinements on top of the tiered matcher:

1. Direct source track-ID tier (new top tier): enrichment writes each source's own
   track ID into the file tags (spotify_track_id/deezer_track_id/itunes_track_id/...).
   If we have the active source's track ID, fetch that exact track by ID via
   get_track_details — zero search. Tiers are now: track-ID -> ISRC -> album->track
   -> artist+title. _read_file_ids reads ISRC + all per-source IDs in one tag read.

2. Skip already-proposed tracks: a re-run loads existing finding entity_ids for the
   job and skips those tracks before any API call (pending stays deduped, dismissed
   stays dismissed) — re-runs are cheap.

3. Wrong-version guard: the fuzzy tiers (album-search + track search) reject a
   candidate whose length differs from ours by >5s (live/edit/remix with same title).
   _load_tracks now selects t.duration; exact tiers (track-ID/ISRC/stored-album-ID)
   skip the guard.

4. Tighter album matching: same-title cuts in an album are disambiguated by closest
   duration when track_number doesn't decide it.

Findings record matched_via = track_id | isrc | album | search. 30 repair tests pass
(added track-ID tier, duration guard, dedup-skip, and unit coverage).
2026-06-13 13:34:48 -07:00
BoulderBadgeDad
777781db6a Quality Upgrade: tiered structured matching (ISRC -> album->track -> artist+title)
Replaces the blind fuzzy search with a smart hierarchy that uses the data we
already have, best identity first:

1. ISRC embedded in the file tags (enriched track) -> exact track.
2. Album -> track: use the album's stored source ID (albums.spotify_album_id /
   itunes_album_id / deezer_id / musicbrainz_release_id / audiodb_id) when the
   ALBUM is enriched (even if the track isn't); else find the album by searching
   'artist album', then locate our track in that album's tracklist by normalized
   title (track_number breaks ties). Pins the exact album context. (artist->album->track)
3. Plain artist+title search with similarity scoring. (artist->track) — loosest.

_load_tracks now returns dict rows (adds track_number + the album source-id
columns). Findings record matched_via = isrc | album | search. All clients
(spotify/deezer/itunes/discogs) expose search_albums + get_album_tracks with a
uniform {'items': [...]} shape, so the album tier is source-agnostic.

26 repair tests pass (added album-tier + _find_track_in_album coverage).
2026-06-13 13:00:16 -07:00
BoulderBadgeDad
3ea5b5181f Quality Upgrade: ISRC-first exact matching using the IDs enrichment already embedded
The job was doing a blind fuzzy search for every low-quality track, ignoring that
enrichment writes each track's ISRC + per-source IDs into the file tags. Now it
reads the file's embedded ISRC and resolves the EXACT track via each source's
'isrc:' search (universal cross-source key), guarded by an ISRC-equality check so
a source that ignores the syntax can't produce a false match — exact track, exact
album context, one call. Falls back to the name/artist fuzzy search only for
un-enriched tracks with no usable ISRC. Findings record matched_via=isrc|search.

4 new seam tests (guard accept/reject, ISRC-preferred-over-fuzzy, fuzzy fallback).
2026-06-13 12:43:46 -07:00
BoulderBadgeDad
b393866782 Remove old auto-acting Quality Scanner tool (replaced by Quality Upgrade Finder job)
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).
2026-06-13 12:14:45 -07:00
BoulderBadgeDad
69dd4e1792 Quality Upgrade Finder: new findings-based repair job (replaces auto-acting Quality Scanner)
The old Quality Scanner tool judged quality by file EXTENSION only (a 128k and a
320k MP3 looked identical), ignored the bitrate-based quality profile, used min()
of enabled tiers so the default profile flagged the ENTIRE non-lossless library,
and auto-dumped every match into the wishlist with no review.

This new repair job does it properly:
- meets_preferred_quality(): pure, bitrate-AWARE decision honoring every enabled
  quality bucket (320 MP3 passes a FLAC+320+256 profile; 128 MP3 doesn't). Floor
  is the worst enabled bucket, not the best.
- scans watchlist artists or whole library, finds below-quality tracks, matches a
  better version at scan time (reusing the existing tested match helpers), emits a
  FINDING showing the match + confidence. Off by default; nothing auto-queued.
- _fix_quality_upgrade apply handler adds the matched track WITH album context to
  the wishlist — the user-approved version of what the old tool did silently.
- Transcode/fake-lossless detection intentionally left to the existing Fake
  Lossless Detector job.

12 seam tests incl. a regression pinning the default-profile flooding bug. The old
tool is still in place; removing it + rewiring its automation action is the next step.
2026-06-13 11:51:43 -07:00
BoulderBadgeDad
846a9c75a0 #867: Tidal playlist discovery shows all tracks (was capped to ~21)
Two issues in the same path:
1. The shared discovery modal pre-renders one row per track from a
   separately-fetched frontend track list, then the poll dropped any backend
   result without a pre-rendered row (if (!row) return). When the frontend's
   track fetch came back rate-limited/partial (~21) while discovery's own fetch
   got all 59, the surplus results vanished. Now the modal CREATES a row for any
   result lacking one, so authoritative backend results drive the list (fixes
   all sources sharing the modal).
2. get_playlist hydrated a whole relationships page in one _get_tracks_batch
   call, but Tidal caps filter[id] at 20/request, silently truncating larger
   pages. Chunk to the cap like get_album_tracks already does.

Seam + regression tests (tests/test_tidal_playlist_batch_chunking.py).
2026-06-13 10:39:30 -07:00
BoulderBadgeDad
119c6e3196 Spotify (no-auth): report connected + 'Spotify (no-auth)' test result instead of a Deezer fallback
Status checks asked is_spotify_authenticated() (official OAuth only) instead of
is_spotify_metadata_available(), so a Spotify-Free primary read as disconnected.
get_primary_source_status had spotify_free awareness but it was dead code:
get_client_for_source('spotify') returns None unless officially authed, so the
free-availability probe never had a client. Fetch the client directly for that
check; add the missing free branch to the dashboard test message. Seam + regression tests.
2026-06-13 10:16:23 -07:00
BoulderBadgeDad
41f73f0c38 HiFi: auto-push genuinely-new default instances to existing installs once (so a newly-added working instance reaches everyone, not just Restore-Defaults clickers; removed defaults stay removed) 2026-06-13 09:31:15 -07:00
BoulderBadgeDad
6e7fd3ff5c M3U export: resolve paths via one bulk read instead of a per-artist search loop (fixes 'Export M3U hangs forever' under active enrichment/scan DB writes) 2026-06-13 08:55:46 -07:00
BoulderBadgeDad
651b904e92 Watchlist: per-artist 'auto-download' toggle (follow-only) — off = discover/surface releases but skip the wishlist add; default on 2026-06-13 08:07:20 -07:00
BoulderBadgeDad
2428df1144 #857: custom in-container completed-downloads path for Torrent/Usenet sources (settings + UI; resolver already consumed the keys) 2026-06-13 07:15:02 -07:00
BoulderBadgeDad
c62074d54a #865: resolve pasted SoundCloud links (incl. unlisted/private share URLs) via direct yt-dlp resolve; manual-search forces the SoundCloud source 2026-06-13 00:41:47 -07:00
BoulderBadgeDad
ba5d62946a Mirrored playlists: custom name alias (overrides display + sync name, survives upstream refresh) — card rename button like the source-ref editor 2026-06-13 00:23:56 -07:00
BoulderBadgeDad
4f24c2af6d #863: derive YouTube track artist from music fields / -Topic channel / 'Artist - Title' instead of the playlist owner 2026-06-12 20:14:55 -07:00
BoulderBadgeDad
cb2d920a9e #862: Library Reorganize falls back to tag-mode when an album has no source ID (media-server libs now actually reorganize) 2026-06-12 20:05:50 -07:00
BoulderBadgeDad
f5787764d4 #859: DB-update stall watchdog + UI self-heal (no more wedged 'Starting...' / frozen bar) 2026-06-12 19:38:30 -07:00
BoulderBadgeDad
47889387ad Playlists: resolve synthetic mirrored batch refs (youtube_mirrored_<pk>/auto_mirror_<pk>) to PK 2026-06-12 17:27:35 -07:00
BoulderBadgeDad
c6e077fefe Playlists: batch toggle force-rebuilds its own folder (row flag = provenance only) + resolve diagnostics 2026-06-12 17:19:45 -07:00
BoulderBadgeDad
4d7267e906 Playlists: reconcile rebuilds touched playlists from the LIBRARY, not task fields
The reconcile read each completed task's final_file_path to find paths — but not
every import path sets it (the verification worker marks the task completed
without it), so tracks that imported via that path were silently dropped (user
saw 3 of 5 symlinks). Root cause: leaning on a fragile per-task field.

Now reconcile_batch_playlists identifies the organize playlists the batch touched
(its own + any reached via a completed track's source_info provenance) and
rebuilds each from CURRENT library ownership via _rebuild_one_from_db
(check_track_exists over membership). It just asks the library what's owned, so
it's robust to HOW a track imported (modal worker / slskd monitor / verification
worker) and still prunes tracks that left. Takes a db handle; all three callers
pass MusicDatabase().

Reconcile tests rewritten for the DB-rebuild form (organize batch, wishlist
provenance, non-organize skip, plain no-op). 973 downloads/imports/playlist
tests pass.
2026-06-12 17:08:27 -07:00
BoulderBadgeDad
d160c486ec Playlists: mirror-update trigger prunes removed tracks (the other half)
Symmetric to the post-download reconcile (which handles ADDITIONS): when a
playlist's membership is re-synced (the mirror step — scheduled refresh or the
manual mirror endpoint), rebuild its folder from current membership WITH prune
IF it's organize-by-playlist. So a track that just LEFT the playlist has its
symlink cleaned up the instant membership changes, not only on the next download.

Factored a shared _rebuild_one_from_db (used by the manual 'Rebuild' button and
the mirror hook) + rebuild_mirrored_playlist_if_organized. Gated to organized
playlists, non-fatal at both mirror call sites.

Now the invariant 'folder = the playlist's current owned members' holds on every
change: additions caught at download, removals caught at mirror. 2 new tests
(removed track pruned; non-organized skipped). 985 + 277 tests pass.
2026-06-12 15:49:13 -07:00
BoulderBadgeDad
997e76b6b4 Playlists: one path-independent reconcile after every batch (closes wishlist gap)
Replaces the two organize-only triggers with a single reconcile_batch_playlists
called at both batch-completion points. It groups the batch's newly-resolved
tracks by their per-track playlist provenance:
  - the batch's OWN organize playlist → full (re)build with prune, and
  - a track that completed for a DIFFERENT playlist (e.g. a WISHLIST fulfilling a
    track that belongs to an organize playlist) → ADDED to that folder, no prune.

So a late wishlist arrival now lands in its playlist folder immediately, instead
of only on the next sync/manual rebuild — the folder = the playlist's owned
members, kept true on every ownership change regardless of download path. Uses
the paths the batch already captured (no DB re-match, no waiting on the server
scan/sync). Non-fatal.

3 new reconcile tests (organize full-rebuild, wishlist add-without-prune, plain
batch no-op). 983 downloads/imports/playlist tests pass.
2026-06-12 15:30:47 -07:00
BoulderBadgeDad
87621b7191 Playlists: Settings UI (path + symlink/copy + rebuild button) + rebuild endpoint
- 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.
2026-06-12 14:04:34 -07:00
BoulderBadgeDad
aa5d747327 Playlists: wire materialize triggers + retire per-track routing flag
- Routing (step 5): organize-by-playlist tracks no longer set the per-track
  _playlist_folder_mode flag, so they import NORMALLY into Artist/Album — exactly
  what a normal download does. _playlist_name provenance is kept (origin.py).
- Triggers (step 4): build the playlist folder from the batch's own payload at
  both end-of-flow points — the all-owned path in master.py (no downloads, so the
  lifecycle never runs) and the batch-complete hook in lifecycle.py (after
  downloads). Both gated on playlist_folder_mode, both non-fatal.

Works for the all-owned case (the smack test that did nothing before) and for
mixed owned/downloaded, with no source-ID or mirrored-playlist dependency. The
materialized folder uses the default ./Playlists root + symlink mode until the
Settings UI is added.

Updated the master test to assert the new contract (provenance kept, routing
flag gone). 979 tests pass.
2026-06-12 13:37:59 -07:00
BoulderBadgeDad
bef73d855d Playlists: stitch a batch's owned+downloaded paths into the materializer
materialize_playlist_from_batch(batch, download_tasks, config) collects the real
on-disk path of every resolved track from the batch's OWN payload — owned via
analysis_results.matched_file_path, downloaded via tasks.final_file_path — runs
each through the playback path resolver (Docker-correct), de-dupes, and hands the
list to rebuild_playlist_folder. Gated on playlist_folder_mode.

No re-matching, no source IDs, no mirrored-playlist lookup — works for any
organize-by-playlist download including the all-owned case. 5 tests. Still
isolated; the triggers wire it in next.
2026-06-12 13:32:53 -07:00
BoulderBadgeDad
3a6cb8cda5 Playlists: config (separate root + symlink/copy) + pure materializer seam
- settings: playlists.materialize_path (separate root, mapped apart from the
  music library so the media server never double-scans it) + materialize_mode
  (symlink|copy).
- core/playlists/materialize.py: pure filesystem engine that (re)builds a
  playlist folder of relative symlinks (or copies) into the real library —
  idempotent, prunes stale entries, disambiguates filename collisions, never
  escapes the root, and auto-falls-back to copy when the FS can't symlink.
  No DB, no app state; ops injectable. 13 unit tests.

Isolated + additive — nothing live calls this yet (stitcher/trigger/routing
come next).
2026-06-12 13:30:48 -07:00
BoulderBadgeDad
94a0070fa8 Orphan detector: hard-bail on a mass-orphan flood instead of warn-only
A DB<->filesystem path mismatch (Docker volume change, remount, Music
Paths unset for the container) makes EVERY library file fail to resolve
to a DB track, so the orphan detector flags the whole library as
orphaned. The mass-orphan check only logged a warning and then created
the findings anyway — so a user batch-applying 'move to staging' or
'delete' would relocate or wipe their entire library.

Make it a hard skip (create zero findings) like the dead-file cleaner
and stale-removal paths already do (#828). Centralise the predicate as
is_implausible_orphan_flood() alongside is_implausible_stale_removal()
so the rule lives in one tested place. Small genuine orphan sets still
surface unchanged — only an implausibly large flood (>50% and >20) is
suppressed.

Tests: seam cases for the new predicate + scan-level regressions (mass
mismatch -> 0 findings; small genuine set -> still reported).
2026-06-12 08:15:23 -07:00
BoulderBadgeDad
cbab4234ef Export: combine watchlist + library into one button with a scope selector
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.
2026-06-11 23:10:54 -07:00
BoulderBadgeDad
a789fb71c0 Library export: export the whole library roster too (corruption's request)
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.
2026-06-11 22:59:21 -07:00
BoulderBadgeDad
f8652c106b Watchlist: export the roster to JSON / CSV / text (corruption's request)
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.
2026-06-11 22:48:58 -07:00
BoulderBadgeDad
4d2772765c Add Aria2 to the torrent client list (Shdjfgatdif's request)
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.
2026-06-11 22:29:40 -07:00
BoulderBadgeDad
8118a2c6bd Add Empty Folder Cleaner library-maintenance job (corruption's request)
A maintenance job to keep the music library tidy — finds empty folders left behind
by imports/relocations/deletions (empty artist/album dirs, or dirs holding only OS
junk like .DS_Store/Thumbs.db) and removes them.

Safety is the focus (deleting directories is destructive):
- only TRULY empty folders are flagged — a folder with a cover image or any audio
  is never touched; only OS-junk files count as "no real content" (a setting),
- the library root + symlinked dirs are never removed,
- walks bottom-up so a parent left empty by its removable children cascades,
- the apply handler RE-CHECKS emptiness at delete time, so a folder that gained a
  file between scan and apply is left alone.

dir_is_removable + remove_empty_folder are pure/injectable seams. Wired through the
job registry, repair_worker apply handler (_fix_empty_folder), fixable-types, and
the findings UI. Opt-in (default off), weekly interval.

Tests (10): removable decision (empty / real-file / surviving-subdir / junk-only /
strict mode) + apply re-check (removes empty + junk, refuses content/root/symlink).
Repair + integrity suites green; ruff clean.
2026-06-11 22:11:26 -07:00
BoulderBadgeDad
4dd09ff48a Navidrome: self-heal the connection instead of latching disconnected (jimmydotcom)
A transient ping failure (network blip, Navidrome busy mid-scan) makes
_setup_client null out the configured creds, and _connection_attempted then
latches the client "disconnected" — so is_connected() returned False forever until
the user hit the manual Test button to re-read config. That's the reported
"disconnects every 5-10 min, reconnects instantly on Test."

Fix: ensure_connection no longer latches on a failed attempt — once a short
throttle (_RECONNECT_THROTTLE_S = 20s) elapses it re-attempts, and is_connected()
triggers that retry whenever it's currently disconnected. So a blip recovers on its
own within the next status check, no manual reconnect. The throttle prevents ping
storms when Navidrome is genuinely down.

Tests: transient failure self-heals after the throttle (and doesn't re-ping within
it); a connected client never re-pings; first connect attempts once. 115 navidrome/
media-server tests green.
2026-06-11 21:34:48 -07:00
BoulderBadgeDad
5b52d579c5 Login mode: enforce "every profile has a password" at every write-point (no gaps)
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.
2026-06-11 19:48:50 -07:00
BoulderBadgeDad
e046a2add4 Login mode: let the admin set a member's login password (Manage Profiles)
Closes the gap where "Require login" was effectively admin-only: a member with no
password can't sign in and can't bootstrap one themselves (can't log in to reach
the setting). The set-password endpoint already allowed admin→anyone — this adds
the missing UI.

Each non-admin row in Manage Profiles gets a lock-icon button that opens an inline
form to set / change / remove that member's LOGIN password (separate from the
quick-switch PIN), with a confirm field + a hint explaining when it's used. Admin
rows don't get it (admin manages their own in Settings → Security, which keeps its
anti-lockout). textContent-only rendering, so a profile name can't inject markup.

Test: admin sets a member's password → the member can then authenticate
(verify_profile_password) and a wrong password fails; admin can clear it back to
no-login. 64 script-split integrity tests green.
2026-06-11 19:24:31 -07:00