Commit graph

801 commits

Author SHA1 Message Date
BoulderBadgeDad
d15b3a185d Track "01" bug: recover real track position instead of fabricating 1
Single tracks (esp. Deezer-sourced) imported as "01 - Title" regardless
of their real album position — e.g. Fly Away (track 2 of Greatest Hits)
landed as 01, littering album folders with duplicate "01" files.

Root cause: a Deezer single track is matched via /search/track, which
omits track_position, so the context never carried the real number; then
service.py + context.py fabricated a confident track_number=1 from that
gap. Because the resolver puts that first, the fake 1 beat the source.
It is source-agnostic (slskd-with-Deezer-metadata hits it too) — albums
work because /album/<id>/tracks DOES include positions.

Fix (at the shared import funnel, strictly additive):
- track_number.py: new read_embedded_track_number() (mutagen, local, no
  network) + an optional embedded_track_number arg on resolve_track_number.
  The downloaded file already carries the source-written position (deemix
  wrote it); consult it LAST — only when metadata AND the "NN - Title"
  filename both come up empty — so it can only fill the gap that would
  otherwise hit the default-1 floor. Never overrides a value the pre-fix
  resolver produced (no regression for correctly-named/mistagged files).
- pipeline.py: read the file tag at the resolve step and pass it in.
- De-poison: service.py:217 + context.py default to 0 (the existing
  "unknown" sentinel, like total_tracks), NOT 1 — so the fake 1 no longer
  blocks recovery. Frontend already treats falsy track_number as unknown
  (omits it), so this also drops the bogus "1." in the UI.

13 new resolver tests incl. the no-regression precedence guards; full
imports + wishlist suites green (583), no behavior change for albums.
2026-06-15 23:35:48 -07:00
BoulderBadgeDad
48e86a1a58 #874: wishlist ignore-list — stop auto-retrying removed/cancelled tracks
A user who removes a wishlist track, or cancels an in-flight wishlist
download, would have it re-added on the next auto cycle (watchlist scan,
failed-track capture, or the cancel handler's own re-add), so the same
release downloaded -> failed/cancelled -> re-queued forever.

Adds a TTL'd skip-gate (30 days), softer than the blocklist: it expires
so the track is reconsidered later, and never blocks a manual
force-download — only the automatic re-queue.

- core/wishlist/ignore.py: pure TTL/normalization/display logic + a
  best-effort orchestrator (no DB handle, caller passes now).
- database/music_database.py: migration-safe wishlist_ignore table +
  add/check/remove/list(+purge)/clear methods, and the gate in
  add_to_wishlist beside the blocklist guard. Fail-open throughout — an
  ignore error can never block a legitimate add; a manual add bypasses
  the gate AND clears the ignore.
- routes.py: user remove (single/album/batch) records an ignore. Hooked
  at the route layer, NOT the DB remove, so success-cleanup never
  ignores (regression-tested).
- web_server.py: cancel now ignores + removes from the wishlist instead
  of re-adding for endless retry; three /api/wishlist/ignore-list*
  endpoints.
- downloads.js: 'Ignored' modal (view / un-ignore / clear all).
- 13 tests: pure logic, DB seam, gate (block/bypass/fail-open),
  route wiring, and the success-cleanup-does-not-ignore regression.
2026-06-15 22:50:39 -07:00
BoulderBadgeDad
46be97b195 #876: group quarantine alternatives by target track-id + auto-clear siblings on approve
Multiple failed source attempts at one song each land in quarantine as
separate entries. Group them by the *intended* target (sidecar context
track_info isrc -> id -> uri, falling back to normalized artist|title for
legacy thin sidecars) — an exact relationship across siblings, since the
bad files' own tags differ but the target track is constant.

- core: quarantine_group_key() + find_quarantine_siblings() seams; list
  entries now carry group_key.
- approve endpoint: remove_siblings flag auto-deletes the other attempts
  once one is accepted (captured BEFORE approve restores the file out of
  quarantine, or the id lookup would resolve nothing). Scoped to the
  quarantine manager; download-modal chooser + version-mismatch fallback
  pass no flag and are unaffected.
- UI: multi-member groups render as a collapsible parent row (album art +
  'N alternatives'); singletons unchanged. Toast reports removed count.
- 11 tests incl. ordering regression for capture-before-approve.
2026-06-15 22:12:06 -07:00
BoulderBadgeDad
e7814e0acf #877: Download Discography filters mirror Artist Detail (fix dead EPs + add Live/Comp/Featured)
The Download Discography modal exposed only Albums/EPs/Singles, its EPs toggle did
nothing, and Live/Compilations/Featured were missing — so you couldn't fine-filter
a bulk download the way Artist Detail lets you browse.

Root cause: the modal's endpoint (/api/artist/<id>/discography) used the base
get_artist_discography, which lumps EPs into singles, and the modal only read
{albums, singles} — so the EPs bucket was always empty (dead toggle). It also had
no content-type (Live/Compilation/Featured) classification at all.

- Backend: the endpoint now uses get_artist_detail_discography — the SAME split
  Artist Detail uses — and returns a separate `eps` list.
- Frontend: read `eps`; tag each card with data-is-live/compilation/featured via a
  new shared _classifyReleaseContent() (also adopted by the Artist Detail cards so
  the two can't drift); add Live/Compilations/Featured filter buttons; combined
  category+content filtering. The download payload is built from VISIBLE checked
  cards, so every toggle now actually changes what downloads.
- Regression test: get_artist_detail_discography splits an EP into the eps bucket.
2026-06-15 21:30:55 -07:00
BoulderBadgeDad
02d6af29ed #879: a failed settings load must never overwrite the saved config
Reported by @Lysticity: opening Settings reset the whole config to defaults. The
chain: GET /api/settings 500s (their env: ConfigManager missing redacted_config)
-> loadSettingsData() called response.json() WITHOUT checking response.ok, so the
error body {"error": ...} was treated as settings -> every field populated as
`settings.x?.y || ''` blanked to defaults -> autosave then wrote those defaults
over the real config.

Fix (settings.js): bail BEFORE touching any field when the response isn't ok / is
an error body, set window._settingsLoadFailed, and guard BOTH save paths
(debouncedAutoSaveSettings + saveSettings) on it. The flag clears on the next
successful load. So any load failure (500, lock, network) now leaves the saved
config untouched instead of wiping it.

The redacted_config method exists in all 2.7.x source + on dev (their 500 looks
like a stale/mismatched build), but the UI must not destroy config on ANY failed
load. Regression test pins redacted_config stays a callable method on the class
(its removal is exactly what 500s the endpoint).
2026-06-15 20:09:53 -07:00
BoulderBadgeDad
2905fe0853 #880: retry 429 mid-walk when paginating Tidal Favorite Tracks (don't truncate)
The Favorites collection walker (_iter_collection_resource_ids) broke on ANY
non-200 — including a transient 429. So a rate-limit mid-pagination silently
truncated the collection: the log shows `status=429` then `Retrieved 98/100`,
and the mirror saved 98 of a 524-track favorites list. The auto-sync cycle only
"worked" because it dodged the 429. The regular-playlist paginator already
retries 429; the collection walker didn't.

Fix: retry the same cursor page with backoff (5/10/15/20s, 4 attempts) on 429,
mirroring the playlist paginator; 401/403 still bail (+ reconnect flag), other
non-200 still break. Regression tests: 429 mid-walk completes the full chain;
exhausted retries return partial without hanging; 429 doesn't set reconnect.
2026-06-15 19:56:53 -07:00
BoulderBadgeDad
afa07690f5 Find & Add: match a Spotify 'Title - Remix' query to the base-titled library track
wolf's report: Spotify shows 'Calma - Remix', Find & Add searches that literal
string, but the library stores the track as just 'Calma' (only the 3:58 duration
marks it the remix). The literal LIKE '%calma - remix%' misses, so it fell to the
OR-fuzzy fallback which floods on the common word 'remix' (20 unrelated '... remix'
hits). Dropping '- Remix' (searching 'Calma') finds it instantly.

Fix: search_tracks (and api_search_tracks) now retry on the BASE title — the part
before Spotify's ' - ' version separator — BEFORE the OR-fuzzy flood. So
'Calma - Remix' resolves to 'Calma' (or 'Calma (Remix)') and the noise fallback is
never reached when the base matches. New core.text.title_match.base_title_before_dash
(splits the first spaced ' - '; leaves bare hyphens like 'Up-Tight' alone).

Tests: pure helper (3) + real-DB integration reproducing the Calma case, the
parenthesized-remix variant, plain-title-unaffected, and no-flood (4). 64
search/match tests green.
2026-06-13 16:55:33 -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
BoulderBadgeDad
29c8f11403 #437: add ReplayGain Filler library-maintenance job
Post-processing applies ReplayGain only to slskd/WebUI downloads — content added
via Lidarr, the REST API, or by hand never got it, and there was no way to (re)apply
RG to existing tracks or fix ones where analysis failed (raised in #437 + comments).

New ReplayGain Filler repair job (sibling of Lyrics/Cover Art fillers): scans for
tracks with no ReplayGain track-gain tag and creates a finding per track; the scan
only READS tags (cheap) and no-ops when ffmpeg is absent. Applying a finding runs
the same ffmpeg ebur128 analysis the import pipeline uses (gain = ref - LUFS) and
writes the RG tags in place — no moves, no re-matching. Opt-in (default off),
schedulable like the other maintenance jobs.

Wired: job registry, repair_worker apply handler (_fix_missing_replaygain) +
fixable-types, and the findings UI (label / fix-button / detail rows).

Tests: pure needs_replaygain decision (missing/blank/present/+0.00-is-tagged) +
the apply handler's analyze→compute→write seam with the pipeline gain formula,
ffmpeg-absent + missing-file guards, and registration. 93 repair tests green.
2026-06-11 18:41:12 -07:00
BoulderBadgeDad
b5b9d6e5f4 #852 tests: cover the login-mode WS gate (the reported bypass was the login modal)
The integration test only exercised the launch-PIN path; the actual #852 report is
the "Sign in to SoulSync" username/password modal. Add login-mode cases: socketio
connect is rejected when require_login is on + unauthenticated, allowed once
login_authenticated. Confirms the WS gate covers both overlays.
2026-06-11 17:38:24 -07:00
BoulderBadgeDad
123eb6139f Artist detail: "DB Record" inspector — everything the DB knows about an artist
A small glowing button at the bottom-right of the artist hero (library artists
only) opens a programmer-style modal showing the COMPLETE artists DB row — every
source id + match status, cached bios / tags / similar / urls, soul_id, timestamps,
the lot (62 columns) — plus owned album/track counts.

- Backend: GET /api/artist/<id>/record returns the full row with JSON-text columns
  (genres, aliases, lastfm_tags/similar, discogs_urls, …) decoded into real
  arrays/objects, + album/track counts. 404 for non-library artists.
- Frontend: editor-themed modal (Tokyo-night tokens) with a Fields tab (copyable,
  filterable key/value rows) and a syntax-highlighted JSON tab. Copy-all-as-JSON,
  per-value copy (HTTP/Docker clipboard fallback), and Save .json. Esc / click-out
  to close. Helpers namespaced (_arecEsc) so they can't clobber the shared globals.

Tests: endpoint returns the full row with decoded JSON + counts; 404 for a missing
artist. 64 script-split integrity tests still green; ruff clean.
2026-06-11 16:57:48 -07:00
BoulderBadgeDad
826ac0b366 #853 follow-up: don't cache a partial Deezer discography on mid-pagination error
PR #853 added artist album-list caching to Deezer, but unlike the Spotify path it
had no equivalent of the truncated-fetch guard: Deezer paginates, and a transient/
malformed response on page 2+ (artist with >100 albums) broke the loop and cached
the PARTIAL list as the full discography — serving an incomplete album list from
cache until TTL.

Fix: track whether pagination finished cleanly. A malformed/empty-of-data response
mid-walk now clears a `complete` flag and the artist→album-LIST is cached only when
complete. Individual album entities still cache regardless (each is complete; we
just have fewer). A clean end (short page / empty page / reached limit) still caches
as before.

Tests: a page-1-ok / page-2-errors walk no longer caches (second call refetches
instead of serving a permanently-incomplete list); a clean two-page walk still
caches (happy path intact). 181 deezer/metadata tests green.
2026-06-11 15:35:36 -07:00
BoulderBadgeDad
814af2cdfb
Merge pull request #853 from ramonskie/artist-discography-cache
Cache artist album lists across metadata sources
2026-06-11 15:31:04 -07:00
BoulderBadgeDad
53c264ab50 Torrents: fix stall handling on "downloading metadata" + stop orphaning in qbit
noldevin: a magnet stuck "downloading metadata" ran 11h despite a 15-min stall
timeout, got cleared from SoulSync but left active in qbit, then re-grabbed as a
duplicate. Two bugs:

1. Stall never fired on metaDL. StallTracker reset its clock on any `downloaded`
   byte increase, but a metaDL torrent's byte counter still ticks up from DHT/peer
   protocol overhead while making no real progress — so the clock reset forever.
   Fix: the byte counter only counts once metadata is in (size>0). During the
   metadata phase (size==0) the only thing that counts as progress is *obtaining*
   metadata, so a magnet that can't even do that within the timeout is correctly
   flagged stalled. size=None preserves the old byte-only behavior (back-compat).

2. Orphaned in qbit. The monitor's stall exit removed the torrent, but the `error`
   exit and the 6h deadline exit only marked the download failed — leaving the
   torrent active in qbit, untracked here, so SoulSync re-grabbed the same dead
   torrent (qbit logs the duplicate-add). Fix: both terminal exits now run
   _cleanup_torrent (shared with the stall path), which removes+deletes (abandon)
   or pauses per the stall action — nothing is left orphaned.

Tests (10 new): metaDL byte-noise no longer resets the clock (stalls at timeout);
obtaining metadata resets it; real byte-progress still tracked after metadata;
_cleanup_torrent removes+delete_files on abandon / pauses on pause / no-ops on
empty hash or no adapter / swallows a client error. 151 torrent tests green.
2026-06-11 14:37:46 -07:00
ramonskie
76d3e25fd4 Cache artist album lists across metadata sources 2026-06-11 22:33:04 +02:00