Commit graph

797 commits

Author SHA1 Message Date
BoulderBadgeDad
d1dd6ed714 Profiles Phase 1: admin endpoints to manage service-credential sets
Admin-only CRUD over the named credential sets from Phase 0:
- GET  /api/credentials            list all sets grouped by service (NO secrets)
- POST /api/credentials            create {service,label,payload}, validated
- PUT  /api/credentials/<id>       update label and/or payload (partial)
- DELETE /api/credentials/<id>     delete (clears any profile selections → fallback)

All four are @admin_only (non-admin → 403), payloads validated via
core.credentials.store, secrets never returned to the browser. Additive — no
existing endpoint or behaviour changes.

Tests: real web_server app + Flask test client (8) — create/list/update/delete
roundtrip, payload never leaks in list, missing-field/unsupported-service/blank-
label/duplicate(409)/404 validation, and the non-admin 403 gate on every write.
Verified the web_server import coexists with the rest of the suite (175 mixed
tests pass).
2026-06-10 00:33:13 -07:00
BoulderBadgeDad
daee96f814 Profiles Phase 0: service-credential-sets foundation (data + resolver, dormant)
Groundwork for admin-created, per-profile-switchable credential sets ("pills")
across auth services (Spotify/Tidal/Deezer/Qobuz/Plex/Jellyfin/Navidrome).
Strictly additive and dormant — nothing reads it at runtime yet, so zero
behaviour change for existing installs.

- core/credentials/store.py: pure service registry + payload validation +
  stale-safe active-set selection (pick_active_credential falls back to None
  when a selected set was deleted, so a profile never breaks).
- migration service_credentials_v1: two new tables — service_credentials
  (admin-created named sets; payload Fernet-encrypted at rest) and
  profile_service_credentials (each profile's selected set per service).
- MusicDatabase CRUD: create/update/delete/list/get_service_credential
  (list never returns the payload; get decrypts for the resolver), plus
  set/get_profile_service_credential and resolve_profile_service_credential
  (returns the profile's active payload or None → caller uses global default).

Tests: 12 — pure validation + stale-safe selection, and real-temp-DB storage
proving encryption round-trips, payload never lists, dup(service,label)
rejected, per-profile/per-service resolution, and delete clearing dangling
selections to a clean fallback. 95 migration/DB tests still pass.
2026-06-10 00:27:48 -07:00
BoulderBadgeDad
e16216fc2d Tests: register watchlist-history.js in script-split integrity (#831 follow-up)
The #831 watchlist work added the standalone webui/static/watchlist-history.js
(loaded in index.html) but didn't add it to NON_SPLIT_JS, so the onclick-
coverage test couldn't see its functions and failed on openWatchlistHistoryModal
referenced in the History button. Register it alongside its sibling
origin-history.js — same fix the registry comment was added for.
2026-06-09 23:17:19 -07:00
BoulderBadgeDad
8983299130 Security: stop GET /api/settings from shipping decrypted secrets to the browser
Found during the #832 audit: GET /api/settings returned dict(config_data) — and
config_data is DECRYPTED in memory — so every API key, OAuth secret, Plex/
Jellyfin token, and service password went to the browser in cleartext. Fernet
"encrypted at rest" protects a leaked DB file; it does nothing once the API
hands the plaintext to the client (devtools, HAR captures, an XSS, a screen
share, or a non-PIN'd LAN viewer).

Fix (centralized in ConfigManager):
- redacted_config() deep-copies config and replaces every _SENSITIVE_PATHS value
  that's actually set with REDACTED_SENTINEL; unset secrets stay empty so the UI
  still shows "not configured". Dict-valued secrets (tidal/qobuz OAuth sessions)
  collapse to the sentinel too. GET /api/settings now serves this copy.
- set() ignores a write of REDACTED_SENTINEL to a sensitive path, so the masked
  placeholder round-tripped by an unchanged settings form can never overwrite
  the real secret. A real value still saves; an empty value still clears.

Frontend: secret inputs are type=password, so the sentinel renders as dots
(looks like a saved secret). _wireRedactedSecrets() clears the mask on focus so
editing types fresh rather than onto the sentinel, and re-masks on blur if left
untouched — so an unchanged secret round-trips the sentinel (kept), an edited
one saves the new value, and a deliberately emptied one clears.

Tests: every sensitive path masks; unset stays empty; dict secrets mask; live
config not mutated; sentinel round-trip keeps the real secret; real value
overwrites; empty clears; sentinel on a non-secret path writes normally.
9 new tests; 518 config-touching tests pass (1 pre-existing soundcloud mock
failure, unrelated — fails identically on a clean tree).
2026-06-09 22:50:18 -07:00
BoulderBadgeDad
0c1dd6c2a9 Delete: resolve the real on-disk file when DB metadata uses curly quotes (#833)
the-hang-man: tracks with an apostrophe (e.g. "I'm Upset") deleted the DB row
but left the file. The library DB stored the title with U+2019 (the curly form
Spotify/Apple metadata uses) while the file was written to disk with U+0027
(ASCII). _resolve_library_file_path compared the curly path byte-for-byte via
os.path.exists, missed every time, and reported "could not be deleted".

Fix: resolve confusable-tolerantly. New core/library/path_resolve.find_on_disk
descends the path component by component, taking an exact match when present and
otherwise folding a small set of typographic look-alikes (curly vs straight
quotes, en/em dash, ellipsis, nbsp) for the comparison ONLY — it never renames,
just finds the file that's actually there. Exact matches always win per
component, so paths that already resolved are byte-for-byte unaffected. This
also fixes existing mismatched files (no re-import) and every caller of
_resolve_library_file_path (sidecar cleanup, dead-file checks, streaming), not
just delete.

Case is deliberately NOT folded: a case-sensitive dataset (ext4/ZFS) can hold
names differing only by case, and folding could resolve the wrong file. The
reported failure is purely typographic.

Tests: real temp-file fixtures exercising the actual byte mismatch — curly-DB →
ascii-disk resolves, exact still works, confusable in a folder component, exact
wins when both encodings present, genuinely-different name does NOT collide,
missing file → None. 10 new tests; 949 resolver-adjacent tests pass.
2026-06-09 22:28:53 -07:00
BoulderBadgeDad
66724186b1 Security: enforce the launch PIN server-side, not just a client overlay (#832)
Beckid: the admin launch PIN was a CLIENT-SIDE overlay only. `launch_pin_required`
just told the frontend to draw a fixed div over the app — removing it (Safari
"Hide Distracting Items", devtools, or any non-browser client like curl) gave
full unauthenticated access to every /api/* endpoint, because the server never
checked it. Anyone who reverse-proxies SoulSync publicly was wide open.

Fix: a before_request gate (_enforce_launch_pin) that rejects every request from
an unverified session while security.require_pin_on_launch is on. The decision
is a pure, unit-tested helper (core/security/launch_lock.request_is_locked) so
the allow/deny matrix can't silently regress. Allowed while locked: the page
shell + static assets, the unlock flow (current/list/select/verify/reset/logout),
and the public REST API /api/v1/ (its own @require_api_key governs it) — EXCEPT
/api/v1/api-keys-internal*, the "no auth required" key-management endpoints,
which stay locked so an attacker can't mint an API key and walk in the side door.
Everything else (data, settings, profile create/edit/delete/set-pin, socket.io)
is blocked.

A blocked top-level browser navigation (deep link / refresh on a sub-page like
/dashboard) is redirected to the root lock screen instead of dumping raw JSON —
detected via Sec-Fetch-Mode: navigate / Accept: text/html (is_html_navigation).
Programmatic fetch/XHR still get the JSON 401 so the frontend can react.

Also fixed the verified flag: get_current_profile POPPED launch_pin_verified
(one page load), but an enforced gate needs it to persist — now READ, so
verification lasts the session (until logout/expiry). No-ops entirely when
require_pin_on_launch is off (default).

Tests: full allow/deny matrix + navigation detection. 20 gate tests + 232
profile/security tests pass.
2026-06-09 22:19:14 -07:00
BoulderBadgeDad
9f12bdfef6 Watchlist: bespoke live scan deck + persistent per-run Scan History (#831 round 2)
Boulder: the live display was a cramped ~600px box showing a fraction of the
data the scan already tracks, with no animation and no history.

Live scan deck (replaces the three-column box, full width):
- Header: pulsing live dot, "x / y artists" progress text, and two live
  counter chips (found / added) that pop when they change.
- Animated progress bar (artist index / total) with a shimmer sweep.
- Stage: artist avatar with accent glow + name + readable phase line
  ("Checking album 2 of 5"), album art + album + current track.
- "Added to wishlist this run" feed: taller, bigger art, slide-in animation
  that plays once per new track (feed re-renders only when it changes).
- All data was already in scan_state (current_artist_index, total_artists,
  tracks_found/added_this_scan, current_phase) — just never displayed. The
  legacy fullscreen-modal markup shares element ids and lacks the new ones,
  so it keeps working untouched.

Scan History (persistent):
- New watchlist_scan_runs table — one row per run (status, timestamps,
  artists/found/added counts) + the full track ledger JSON. Saved at scan
  completion AND cancellation; idempotent on run_id; pruned to the last 100
  runs. Wishlist rows erode as tracks download, so this is the durable record.
- GET /api/watchlist/scan/history (runs) + /history/<run_id>/tracks (ledger).
- New History button on the Watchlist page → modal in the origins/blocklist
  house style: run cards (date, cancelled chip, artists/found/added stats)
  expanding into the Added / Skipped track lists with art and badges.

Tests: save+fetch with ledger, idempotent re-save, prune keeps newest,
unknown-run empty, cancelled runs recorded. 398 watchlist/wishlist/history
tests pass; JS syntax-checked; all rendered strings escaped.
2026-06-09 20:35:16 -07:00
BoulderBadgeDad
e8cde40d22 Watchlist: show WHICH tracks a scan found/added + group Download Origins (#831)
Tacobell444 (#707 follow-up): the scan summary said "New tracks: 19 • Added to
wishlist: 10" with no way to see which tracks those were — you had to scan your
wishlist and guess what was new.

Scan ledger: the scanner now records a per-run scan_track_events list (track,
artist, album, thumb, status added|skipped — skipped = found-new but declined
by add_to_wishlist: already queued or blocklisted; capped at 500). The status
endpoint already serializes scan_state, so the payload flows free. The
completed (and cancelled) scan summary on the Watchlist page gets a
"Show tracks" toggle expanding a styled list — Added section + Skipped section
with badges, reusing the live-feed row styling.

Download Origins grouping: the modal now groups entries by what triggered them
(watchlist artist / playlist name) with collapsible headers + counts instead of
a flat list with a per-row badge. Entries arrive newest-first so groups order
themselves by their newest download. Same row markup, checkboxes/delete intact.

Provenance: watchlist adds now stamp scan_run_id into wishlist source_info, so
per-run grouping is queryable later (future "what did run X add" views).

Tests: per-run ledger seam test (added + skipped statuses, album/artist fields,
FIFO unchanged). 316 watchlist/wishlist tests pass; JS syntax-checked.
2026-06-09 20:14:02 -07:00
BoulderBadgeDad
bcd69c8baa Multi-artist tags: Search → Download Now finally knows its metadata source (Netti93)
Third round of the multi-artist report. The earlier fixes (Deezer contributors
upgrade, _artists_list, feat_in_title/artist_separator) were all in place and
correct — but gated on source == 'deezer', and on the real Search → Download
Now path NOTHING carried the source: core/search/sources.py serialized tracks
with no source field, search.js's enrichedTrack didn't add one, so
get_import_source() resolved '' and the whole Deezer-specific block silently
skipped. Files were tagged with only the primary artist until a Retag (which
rebuilds context with the source set — exactly why retagging always fixed it).
The earlier tests passed because they set context['source'] directly — the one
field the real flow never had (same mock-drift as the #823 append tests).

Reproduced with Netti93's exact track (deezer 3966840171) through the real
extract_source_metadata: before — source '', artists ['August Burns Red'];
after — source 'deezer', contributors fetched, artists ['August Burns Red',
'Polaris'], title 'Sonic Salvation (feat. Polaris)' per feat_in_title.

Fix, three layers:
- core/search/sources.py: serialized tracks/albums/artists carry "source"
  (the canonical name the orchestrator already passes; '' when unnamed).
- core/imports/context.py get_import_source: also reads '_source' from the
  nested dicts (track_info/original_search/album/artist) — additionally fixes
  the discography/wishlist flows, which always passed '_source' that nothing
  read.
- search.js: enrichedTrack + the album-download path carry source through to
  the download task.

Tests: real-payload staging-shaped contexts (source in track_info, '_source'
shape, and the pre-fix sourceless shape staying safe — mocked Deezer client),
serializer source-field tests, resolver fallback tests; exact-shape serializer
tests updated for the new key. 1977 import/metadata/search tests pass (the
only 2 failures are the known soundcloud ones).
2026-06-09 17:20:16 -07:00
BoulderBadgeDad
e32e2e5e14 Sync: append mode actually dedupes — stop re-adding the whole playlist (#823)
carlosjfcasero round 2: after 6fa956d6 append stopped recreating the playlist,
but every sync re-appended ALL matched tracks — every track N times. His log
shows it plainly: "added 22 new tracks to 'Disney' (skipped 0 already present)"
on a playlist that already had those 22.

Root cause: the dedupe read `{t.id for t in get_playlist_tracks(...)}` — but
JellyfinTrack only defines `ratingKey`, never `id`, so the existing-ids set was
ALWAYS empty and everything looked new. NavidromeTrack is also ratingKey-only,
so the Navidrome append had the identical bug. Plex (plexapi ratingKey) was
fine. The existing tests were green because they mocked existing tracks as
SimpleNamespace(id=...) — encoding the same wrong assumption as the code.

Fix:
- New pure planner plan_playlist_append(current, desired) in
  core/sync/playlist_edit.py (next to the reconcile planner): order-preserving,
  drops already-present ids, dedupes within desired, stringifies (Emby numeric
  vs string safe).
- Jellyfin/Emby: existing ids fetched from the canonical
  /Playlists/{id}/Items endpoint (same as reconcile — works for Jellyfin GUIDs
  and Emby numeric ids), ratingKey fallback if that request fails.
- Navidrome: dedupe on ratingKey (the attribute that actually exists).

Tests: planner (skip-present incl. the reporter's unchanged-playlist case,
desired-order, dupes-within-desired, int/str ids, empties) + the append-mode
suite rewritten to pin the REAL shapes (raw Items dicts for Jellyfin,
ratingKey objects for Navidrome) + a new fallback-path test. 524
playlist/sync/jellyfin/navidrome tests pass.
2026-06-09 16:31:05 -07:00
BoulderBadgeDad
0939585620 Matcher: bracketed subtitles no longer read as different songs (#825)
carlosjfcasero round 2 (manual-add fix didn't help — different path). His log
pinned it: the mirrored sync auto-added 'Llamando a la tierra (Serenade From
the Stars)' by M-Clan every run even though his library has the song (stored
bare). Reproduced exactly: the subtitle restates no album context, so the #808
context strip keeps it, and the length-ratio penalty in
_calculate_track_confidence crushes the pair to 0.142 (needs 0.7). Sync →
"missing" → wishlist, forever; and the cleanup uses the SAME matcher, so it
deterministically never removed it. Self-reinforcing.

Fix at the matcher seam (benefits sync, cleanup, downloads, discography alike):
core/text/title_match.strip_subtitle_qualifiers(title, other) strips a
bracketed qualifier only when it (a) isn't restated in the other title, (b)
contains no version-marker token (EN + ES: live/remix/acoustic/version/
dueto/directo/vivo/...), and (c) introduces no new digit token ('(Pt. 2)',
'(2007)' stay different releases). Wired as a third comparison variant in
_calculate_track_confidence with its own length guard. Verified against his
log's other unmatched tracks: '(Live)' 0.15, '(Dueto 2007)' 0.179,
'(Versión 1988)' 0.167 all still correctly blocked — version qualifiers keep
their meaning; the M-Clan case goes 0.142 → 1.0 in both directions.

Also: sync's check_track_exists call now passes album= (cleanup already did),
enabling the album-aware fallback for multi-artist albums during sync.

Tests: tests/test_subtitle_qualifier_match.py — the reported case verbatim
(end-to-end through check_track_exists, both directions, batched candidate
path included), EN+ES version qualifiers still blocked, numeric guard,
'#769 Dani California' and '#808 OurVinyl' guards still hold. 1396
matcher/wishlist/sync tests pass.
2026-06-09 16:03:11 -07:00
BoulderBadgeDad
db65e783c7 Discography: keep collab tracks credited as one combined string (#830)
Vicky-2418: Download Discography skipped some albums/singles as "No New Track"
even on a brand-new artist, while manual one-by-one worked. The log showed the
real reason wasn't ownership at all — it was "N skipped (artist mismatch)".

Root cause (confirmed against the iTunes API, not guessed): iTunes returns a
collab as ONE combined string. Narvent's "Miss You (Ambient Remix)" is credited
'TRVNSPORTER, Narvent & SKVLENT'. track_artist_matches did an exact full-string
compare ('trvnsporter, narvent & skvlent' == 'narvent' → False), so every
collaborator's discography entry was dropped — despite the #559 comment claiming
it "keeps features" (it didn't, because features arrive combined, not as a list).

Fix: match the requested artist as a COMPONENT of the credit — split on the
common separators (, & ; / feat ft featuring vs x), while still including the
full string so band names with internal separators ("Florence + the Machine")
match exactly. Component matching stays exact per name, so true contamination
(the #559 case — artist not credited at all) is still dropped, and substrings
("Drakeo the Ruler" ≠ "Drake") still don't match.

Also corrects an over-conservative #559 test that asserted "Drake & Future"
shouldn't match "Drake" — it should; Drake is a credited collaborator. The guard
drops uncredited artists, not legit collabs packed into one string.

Tests: the exact real case (Narvent in the combined credit) + each collaborator,
contamination still dropped, feat/ft/featuring/x forms, no substring false
positive. 36 filter tests + 747 discography/metadata tests pass.
2026-06-09 14:35:54 -07:00
BoulderBadgeDad
1d16ac7978 Downloads: reuse an album's existing folder so batches don't split it (#829)
Tacobell444: when tracks land in an album across multiple batches (a wishlist
run, the Album Completeness job, a missed track re-downloaded later), the folder
is rebuilt from API metadata each time — so when $albumtype or $year come back
blank/different on a later batch, the folder NAME changes and the album splits,
forcing a Reorganize.

Fix: build_final_path_for_track now checks whether the album already lives in a
single folder on disk and, if so, drops the new track there instead of a freshly
templated folder. Match (chosen): exact stored Spotify album id first, then a
STRICT >=0.85 name+artist match (vs the 0.7 used elsewhere) — a wrong match here
misplaces a file. New core/library/existing_album_folder.resolve_existing_album_folder
holds the logic; always-on with template fallback.

Safety rails: only returns a folder UNDER the transfer dir (never a read-only
library/NAS mount), only when the album lives in EXACTLY ONE folder (multiple =
disc subfolders, which DatabaseTrack can't disambiguate — those defer to the
template), and any failure falls through to the template path. Added
MusicDatabase.get_album_by_spotify_album_id for the id-first lookup.

Tests: single-folder reuse, no-match, below-threshold, multi-folder defer,
outside-transfer reject, id-first, missing transfer dir, no-files-on-disk.
8 tests; 1556 path/import/download tests pass (only the known soundcloud
failures remain).
2026-06-09 13:47:25 -07:00
BoulderBadgeDad
26368a80ab Dead File Cleaner: don't flag a whole library when paths just aren't reachable (#828)
macstainless: a Plex-on-macOS user running SoulSync in Docker had all 5,250
tracks flagged "dead" even though the files exist and play in Plex. Root cause:
the DB stores paths as Plex reported them (/Volumes/Core/Music/...), which don't
exist inside SoulSync's container. resolve_library_file_path() returns None for
"couldn't find it at any known base dir" — and for a mis-mounted library that's
EVERY track, not a deletion. The job treated None as "file deleted" and created
a finding per track.

Fix mirrors the existing transfer-folder abort: collect unresolvable tracks, and
if at least max_unresolved_fraction (default 0.5) of the library is unresolvable
once it's past min_tracks_for_guard (default 25), treat it as a path-mapping/
mount problem — abort with an actionable message (Docker mount / Settings →
Library → Music Paths) and create ZERO findings. A small fraction unresolvable
is still reported as genuine dead files, and tiny libraries (< min) report as
before. Both thresholds are configurable per the job's settings.

Tests: mass-unresolvable aborts with no findings; a lone dead file among real
ones is still reported; a small all-dead library still reports; thresholds
configurable. 54 repair tests pass.
2026-06-09 13:12:50 -07:00
BoulderBadgeDad
90174de4b2 Spotify: rename "Spotify Free" → "Spotify (no auth)", default enrichment to it
Per Boulder's calls on the new enrichment toggle:

- Naming: "Spotify Free" was misleading (it's a hybrid — pick it, connect an
  account, and sync still uses your official playlists). Relabel the user-facing
  strings to "Spotify (no auth)" — the real distinction is needs-credentials vs
  not. Internal value/key (spotify_free, _free_*) unchanged, so no migration.

- Default ON: metadata.spotify_free_enrichment now defaults True (worker + UI
  load both treat unset as on). So bulk enrichment runs on the no-auth path by
  default and the official account is reserved for interactive search/sync; turn
  the toggle off to enrich through the connected account. The toggle overrides
  auth for the worker (authed users still enrich via no-auth) — matching the
  intended model.

- Worker runs on the toggle alone: is_spotify_metadata_available() now honors
  _prefer_free (+ package installed), so the worker enriches via no-auth even
  with no account connected and no 'no-auth' source selected. Only fires on a
  client carrying the flag (the worker's own), so interactive/watchlist
  availability is unchanged.

- UI: moved the toggle from "Metadata Source" to the Spotify section next to the
  auth fields, always visible, on by default. Help notes the genre trade-off.

Tests: prefer_free makes metadata available without auth/source (and is inert
without the package); interactive availability unaffected. 218 Spotify tests pass.
2026-06-09 12:55:19 -07:00
BoulderBadgeDad
38461295c2 Spotify: enrichment can prefer Free, and the budget→Free bridge actually diverts
Two related fixes at one root cause. Every catalog method gated the official API
on `use_spotify = is_spotify_authenticated()` and only fell to Free *after*
official failed. So when the client was authed but should defer to Free —
specifically when the worker's daily real-API budget was spent — it kept hitting
the official API anyway (just stopped *counting* it). The budget "bridge" never
actually diverted; it only stopped pausing.

Root-cause fix: the official gate is now `is_spotify_authenticated() and not
self._free_active()` across all 8 catalog methods (search_artists/albums/tracks,
get_artist/album/album_tracks/track_details/artist_albums). No-auth and
rate-limited are unchanged (auth is already False there); the change only affects
the cases where auth is True but we deliberately defer to Free. The user-account
methods and the metadata-availability helper are untouched.

New opt-in: metadata.spotify_free_enrichment. When set, the worker puts
`_prefer_free` on its OWN client and _free_active() honors it (needs only the
package installed — the flag is the opt-in — not the 'Spotify Free' source
choice). So a connected user can run bulk enrichment on the no-creds source to
spare their official quota, while interactive search/resolve stay official-first
(they use a different client that never sets the flag). Default off.

Tests: _free_active honors prefer_free (and is inert without the package);
search_albums defers to Free — official .sp raises if touched — both under
prefer_free AND under budget-exhaustion (the divert that previously never
happened). 215 Spotify tests pass.
2026-06-09 12:24:57 -07:00
BoulderBadgeDad
4a6d6fc17b Spotify Free: close the album-search gap via the artist's discography
SpotipyFree has no album-name search (search_albums returned []), and
SpotifyClient.search_albums had no Free branch at all — so when Spotify Free was
the active source (no-auth / rate-limit ban / spent budget), album matching
couldn't use Spotify and dropped straight to the iTunes/Deezer fallback. The
enrichment worker's per-album matching was effectively blind on Free.

Fix — work the gap using ONLY the free source:
- spotify_free_metadata: new search_albums_via_artist(artist, album) resolves the
  best-matching artist, scans their discography (get_artist_albums_list — which
  Free DOES support), and ranks by album-name similarity. Pure rank_albums_by_name
  helper is unit-testable; the network method composes it.
- SpotifyClient.search_albums: add a Free branch mirroring search_artists/tracks
  (official → Free-if-active → iTunes/Deezer), plus optional artist/album params
  so the Free path has the names it needs. Bare-query callers skip it unchanged.
- spotify_worker: _process_album_individual passes artist+album so the bridge can
  match albums on Free.

Tests: rank ordering + limit, via-artist picks the right artist and ranks,
empty on missing artist/album/no-match, and SpotifyClient.search_albums returns
Free albums (not the iTunes fallback) when free is active. 210 Spotify tests pass.
2026-06-09 12:05:40 -07:00
BoulderBadgeDad
8633386f00 Wishlist: manual "add to wishlist" now skips already-owned tracks (#825)
The manual album "Add to Wishlist" modal had NO ownership check at any layer —
the album view opened the modal without ownership info, the modal added every
track, and the backend (add_album_track_to_wishlist) added each unconditionally.
So adding an album you (partially) own dumped the owned tracks straight into the
wishlist (carlosjfcasero #825) — and the auto-cleanup doesn't reliably remove
them. The bulk discography path already dedups (full missing-track analysis);
this path didn't.

Backend (the reliable seam): add_album_track_to_wishlist now skips a track that
already exists in the library, gated on the same wishlist.allow_duplicate_tracks
toggle the watchlist scan + cleanup use — OFF → skip owned (returns
{success, skipped:true}), ON → add anyway. Default is ON, so default users are
unaffected; the quality re-download flow uses a different endpoint, so it's
untouched.

Frontend: handleAddToWishlist + addModalTracksToWishlist count skipped tracks
separately so the toast is honest ("Added 3 (5 already owned)" / "All N already
in your library") instead of falsely claiming owned tracks were added.

Tests: skips owned when duplicates off, adds missing when off, adds owned when
on (and doesn't even run the check then). 205 wishlist tests pass.
2026-06-09 10:55:45 -07:00
BoulderBadgeDad
8d133ecd60 Wishlist: serialize album-bundle downloads so they stop flooding the search pool (Sokhi #740)
Sokhi: "downloads searching for way too many tracks at once" — a wishlist run
that fanned out into ~one batch per album. Verified the actual search/download
concurrency IS capped at 3 (single shared missing_download_executor), so it
wasn't really hammering slskd — but the display showed ~20 "searching" and the
batch list was a mess.

Root cause: run_full_missing_tracks_process was supposed to "block its album-pool
worker for the whole search+download" (that's what the dedicated album_bundle_
executor is for), but it RETURNED the instant it had STARTED the downloads. So
the album pool only throttled the fast analysis phase — every album batch blew
through analysis and immediately dumped its tracks into the shared download pool,
all pre-marked 'searching'. The intended serialization never happened.

Fix: add serialize= to run_full_missing_tracks_process. Album-bundle batches
(dispatched on album_bundle_executor) pass serialize=True and now hold their pool
slot via _wait_for_batch_drain() until every task in the batch reaches a terminal
state — so only ~N albums are in flight at once. The wait is passive (downloads
are driven by the monitor + completion callbacks on other threads, so no
deadlock) and bails on shutdown, a removed batch, or a safety cap. The residual /
playlist / manual paths run on the SHARED pool and pass serialize=False (blocking
there would steal a real download worker), so they're unchanged.

Tests: _wait_for_batch_drain returns immediately when all-terminal, waits until
tasks finish, bails on shutdown, respects the cap, handles a missing batch. 975
download/wishlist tests pass (only the pre-existing soundcloud /app failures).
2026-06-09 10:06:53 -07:00
BoulderBadgeDad
6fa956d63a Sync: automated syncs honor the configured playlist sync mode instead of hardcoding 'replace' (#823)
carlosjfcasero: "append" sync mode still recreated the playlist (wiping image +
description) on both the sync-page auto-sync and the Playlist Pipeline. Root
cause: _run_sync_task defaulted sync_mode='replace', and every AUTOMATED caller
omits the mode — auto_sync_playlist (mirrored auto-sync + pipeline), the
iTunes-link sync, and Wing It. So those paths always replaced, ignoring the
user's chosen mode entirely. (Manual sync + the per-source discovery path already
passed a mode, which is why it only bit automated runs.)

Fix: when no mode is passed, _run_sync_task resolves the user's configured global
"Playlist sync mode" (normalize_sync_mode(None, playlist_sync.mode)) — the same
thing _submit_sync_task already does — instead of hardcoding 'replace'. The
global default is still 'replace', so users who never changed it are unaffected;
only those who set Append/Reconcile get the corrected behavior.

Tests: normalize_sync_mode(None,'append')→'append' (and 'replace' unchanged);
auto_sync_playlist must not force a mode (no sync_mode kwarg / no 7th positional)
so the resolution can happen. 896 sync/automation/discovery/playlist tests pass.
2026-06-09 08:49:46 -07:00
BoulderBadgeDad
a79816ad69 Full release dates: store + write yyyy-mm-dd end to end (#824 part 2)
Part 1 stopped existing full dates being destroyed; this adds first-class support
for full release dates so they can be set + persisted instead of truncated to a
year at the DB layer.

- Schema: new nullable `release_date TEXT` on the albums table (idempotent
  ALTER-ADD-COLUMN repair on startup + the live CREATE). NULL = year-only, every
  reader falls back to albums.year, so it ships safe/dormant.
- Tag writer: write_tags_to_file + build_tag_diff prefer db_data['release_date']
  (the full date) over the year int; _date_to_write writes the full date. When
  there's no release_date it's exactly Part-1 behavior (year, preserving an
  equally-specific existing file date).
- Retag read path: SELECT al.release_date in the tag-preview/write queries and
  thread it into _build_library_tag_db_data.
- Manual edit: release_date added to ALBUM_EDITABLE_FIELDS + a "Release Date"
  field (YYYY-MM-DD, validated client-side) in the album editor; the artist-album
  query returns it so existing values show. User-set dates are authoritative.
- Enrichment: Spotify + iTunes workers store the source's full release_date
  (YYYY-MM / YYYY-MM-DD) when present, only when empty — never clobbering a
  manual value.

Tests: writer uses release_date over year + overrides an existing file date;
falls back to year when absent; diff compares the full date. Migration verified
idempotent + enrichment no-clobber. 1435 tag/retag/db/library tests pass.
2026-06-08 23:32:42 -07:00
BoulderBadgeDad
319e90dead Tag writer: stop downgrading full release dates to just the year (#824)
Files tagged with a full date (e.g. 2023-11-03) were overwritten with only the
year on every enrichment/retag: the tag reader pulls the full TDRC/date string,
but the library DB stores `year` as an INTEGER, and the writer wrote str(year)
back unconditionally — truncating 2023-11-03 → 2023 (Tacobell444 #824).

Fix: new _date_to_write(existing, year) keeps the file's existing, more-specific
date when its year already matches what we'd write (the full date is correct and
consistent); it only writes the bare year when the file has no date or the year
genuinely differs (a real correction). Wired into the ID3 (TDRC), Vorbis (date)
and MP4 (©day) writers. build_tag_diff no longer flags the year as "changed"
when the years match, so the preview stops showing a phantom 2023-11-03 → 2023.

Tests: diff doesn't flag same-year full dates (but does flag a different year);
end-to-end write preserves 2023-11-03 against DB year 2023, and still corrects a
wrong year. 339 tag/retag tests pass.

NOTE (not fixed here): manually entering a NEW full date still can't persist —
the library albums/tracks tables store `year` as INT, so a yyyy-mm-dd entry is
truncated at the DB layer. Supporting manual full dates + enrichment fetching
full dates needs a release_date column (follow-up).
2026-06-08 23:15:31 -07:00
BoulderBadgeDad
e6bf7c26de Watchlist: stop treating different decimal-volume albums as duplicates (Sokhi — the real bug)
Sokhi's log showed the actual cause: with the wishlist "allow duplicates" toggle
on, is_track_missing_from_library skips a track when _albums_likely_match() says
the wanted album and a library album are the same — and it was matching albums
that differ ONLY by a decimal volume number:
  [AllowDup] Album match — skipping (wanted: '...Vol.5', library: '...Vol.5.5')
  [AllowDup] Album match — skipping (wanted: '...Vol.5.5', library: '...Vol.4.5')
His character-song CDs share track titles across volumes, so tracks from albums
he DOESN'T have got skipped because a same-titled track sits in a similarly-named
volume he DOES have — the partially-filled discography never completed.

Root cause: _normalize_album_for_match strips the dot ("Vol.5.5" -> "vol 5 5"),
and _VOLUME_MARKER_RE captured only a single trailing digit, so Vol.5, Vol.5.5
and Vol.4.5 all reduced to marker "5" and the volume-disagreement guard never
fired. Fix: capture the full multi-part number ((\d+(?:\s+\d+)*)) so "5" / "5 5"
/ "4 5" are distinct and the guard correctly rejects the match.

(Not lookback — the log confirms lookback=all was already honored.)

Tests: decimal/multi-part volumes (incl. the real CJK names) now block the
match; identical decimal volumes + naming-drift cases still match. 111 watchlist
tests pass.
2026-06-08 20:46:40 -07:00
BoulderBadgeDad
13dca2dea6 Cover Art Filler: write cover.jpg to the RESOLVED folder, not the raw DB path (Sokhi — the actual bug)
THE bug behind "embeds art but never writes cover.jpgs": _fix_missing_cover_art
passed `details['album_folder']` (= dirname of the raw DB path, e.g. Jellyfin's
/data/music/...) as the target folder. That path doesn't exist inside the
SoulSync container — only the resolved /app/... path does. So apply_art_to_album_
files' `os.path.isdir(target_dir)` was False and the ENTIRE cover.jpg block was
silently skipped: embedding still worked (it uses the resolved paths) and the DB
thumb updated, but the sidecar was never written. Exactly Sokhi's symptom + the
"Cover art already present — database thumbnail updated" toast (cover_written
stayed False).

Fix:
- _fix_missing_cover_art: derive the folder from the RESOLVED file
  (os.path.dirname(resolved[0])), never the raw album_folder.
- apply_art_to_album_files: bulletproof it — if the passed folder doesn't exist,
  fall back to the real directory of the files instead of silently skipping.

Tests: a non-existent folder still writes cover.jpg to the real file dir. 1348
cover/art/repair tests pass.
2026-06-08 18:25:50 -07:00
BoulderBadgeDad
2f3ade8acb Cover Art Filler: scan falls back to the raw file path when mapping fails (Sokhi #fix)
Root cause of Sokhi's endless 0-findings: the SCAN resolved each track's path
through the path-mapping layer with NO fallback, while the APPLY
(_fix_missing_cover_art) uses `_resolve_file_path(...) or p` — i.e. it falls
back to the raw DB path when mapping returns nothing. On his Docker setup the
mapping returns None, so the scan set has_local=False and skipped every album
(never looking at the folder), even though the apply WOULD have written the
cover.jpg from the raw path.

Fix: make the scan match the apply — if mapping returns nothing but the raw DB
path is a real file (container path == stored path), use it as-is. Now the scan
actually inspects the folder, sees the missing cover.jpg, and flags it; the
apply then writes it from the embedded art.

Tests: unresolved-path-but-real-file → flagged (sidecar_from_embedded); the
fallback does NOT fire for a non-existent path (media-server-only stays skipped).
Kept the [cover-diag] logging from the prior commit to confirm on Sokhi's run.
2026-06-08 17:32:38 -07:00
BoulderBadgeDad
a4a2135c19
Merge pull request #820 from failshell/fix/tidal-version-matching-and-rate-limit
fix(tidal): honour version field in matching and back off on rate limits
2026-06-08 16:25:51 -07:00
BoulderBadgeDad
5352db0a22 Cover Art Filler: write cover.jpg sidecars, even when files already have embedded art (Sokhi #813)
Sokhi: after the earlier flag fix, scans returned 0 matches — albums with
embedded art but no cover.jpg were treated as fully arted and skipped, so their
cover.jpg never got written.

Root cause: the scan used album_has_art_on_disk (True if EITHER embedded art OR
a cover.jpg exists), conflating the two. Now it checks them separately:
- a local album is flagged if it lacks embedded art, OR it lacks a cover.jpg
  sidecar AND cover.jpg writing is enabled (metadata_enhancement.cover_art_
  download — Boulder: "only scan for cover.jpgs when enabled").
- an album that has embedded art but no sidecar is fixable even when the API
  finds no art: the apply writes cover.jpg from the EXISTING embedded art.

apply_art_to_album_files now writes the cover.jpg sidecar by extracting the
album's own embedded art (new extract_embedded_art) — consistent with the
files, no API call — and only falls back to download_cover_art when there's
nothing embedded to extract. _fix_missing_cover_art no longer bails on a
missing artwork_url when sidecar_from_embedded is set.

Tests: scan flags embedded-but-no-cover.jpg (incl. when API finds nothing),
still skips albums with both, still flags artless albums; apply writes cover.jpg
from embedded art (no download), falls back to download when none, skips when a
sidecar already exists; extract_embedded_art unit tests. 1344 cover/art/repair
tests pass.
2026-06-08 15:35:55 -07:00
BoulderBadgeDad
cea0e9d63c Manual download search: paste a Tidal/Qobuz track link to grab the exact version (#813)
When a track shows "Not found", the manual search now accepts a pasted Tidal or
Qobuz track link, not just a typed query (CubeComming: the fuzzy search misses
versions; he can find the track on Tidal but can't get it to appear).

How it works (robust, reuses the proven path): parse the link → (source,
track_id) → fetch the track via the source client's get_track → build a clean
"artist title (version)" query → run THAT source's normal search → bubble the
result whose id matches the link to the top. So the candidate is a normal,
already-downloadable streaming result — no hand-built download encoding — and
it downloads through the existing verified flow.

Degrades gracefully: if the source isn't connected or the link can't be
resolved, it falls back to a normal text search of the raw input — the user is
never worse off than typing it themselves. Scoped to Tidal + Qobuz (the
streaming sources that download by track id, with public track URLs); Soulseek
can't take a link (P2P, no ids), YouTube/SoundCloud are URL-native via a
different path (future).

- core/downloads/track_link.py: pure parse_download_track_link (tidal/qobuz
  /track/<id>, slug/region suffixes, scheme-less) + query_from_track_payload
  (per-source title/artist, Tidal version-append).
- manual-search endpoint: link detection → resolve → restrict to that source →
  id-match bubble.
- placeholder hint mentions pasting a link; maxlength 200→300 for long URLs.

Tests: 14 (parser shapes + payload extraction incl. remix version-append +
qobuz performer/album-artist fallback). JS valid.
2026-06-08 15:03:54 -07:00
BoulderBadgeDad
b2de64e87d Search: extend pasted-link resolution to Discogs (#813)
#775 already resolves pasted Spotify / Apple / MusicBrainz / Deezer links to an
exact artist/album/track on the Search page. Added Discogs to that set (the one
source in the request not already covered; Amazon left out per request).

- by_id resolver: discogs in SUPPORTED_SOURCES + _KNOWN_HOSTS; parse
  /artist/<id>-slug, /release/<id>-slug, /master/<id>-slug (master→album), and
  strip the slug to the leading numeric id. Discogs has no standalone track
  URLs (tracks live inside a release), so artist + album resolve.
- Fetch dispatch: discogs albums use get_album(include_tracks=False) like
  itunes/musicbrainz; artists use get_artist; both already return the common
  normalized card shape. Updated the not-a-link hint to mention Discogs.

Frontend needs no change — it adopts whatever source the resolver returns and
Discogs is already a search source. Tests: parse release/master/artist
(slug-stripped, scheme-less) + resolve release→get_album(numeric id) and
artist→get_artist. 43 by_id tests pass.
2026-06-08 14:37:48 -07:00
BoulderBadgeDad
df898b5212 Import: atomic tag saves so an interrupted/OOM save can't destroy the file (#819)
CubeComming: manual imports of large hi-res Qobuz FLACs came out as empty
shells — no audio, no tags, no length/bitrate. Root cause: mutagen's in-place
save() rewrites the file, and it's NOT atomic; if the process is interrupted or
OOM-killed mid-write (he also reported the memory-growth issue #802), the file
is left truncated — audio and tags gone.

save_audio_file (the chokepoint both the enrichment tag-write AND wipe_source_
tags route through) now saves atomically: copy the original to a temp in the
same dir, write the modified tags into the copy, verify it's still valid audio
(duration > 0), then os.replace() it in. The original is untouched until that
atomic swap, so a crash can only orphan the temp — never destroy the user's
file. Falls back to the plain in-place save (byte-identical to before) when the
atomic path can't run, so nothing is ever left worse off. tag_writer's
write_tags_to_file routes through the same helper.

Verified the atomic path works with REAL mutagen on a real FLAC (audio length
preserved 1.0s→1.0s, tag written, temp cleaned). Tests: replace-on-success,
original-survives-save-failure, corrupt-temp-rejected, no-filename-plain-save,
+ a real-FLAC round-trip (skips without ffmpeg). 2443 import/metadata/tag tests
pass.
2026-06-08 13:46:28 -07:00
BoulderBadgeDad
cdee7b3550 Cover Art Filler: always write the cover.jpg sidecar (Sokhi)
Sokhi: filler works but doesn't write cover.jpg. Cause: the sidecar write
(download_cover_art) respects the import-time "Download cover.jpg to album
folder" toggle, while embedding ignores it — so with that toggle off, art
embeds but no sidecar is written.

A job literally called Cover Art Filler should produce the complete art when
you explicitly run it. download_cover_art gains force=True (bypasses the toggle)
and the filler's apply path passes it. The import pipeline still calls without
force, so it keeps honoring the user's setting.

Tests: filler passes force=True; existing cover-write mocks updated for the new
kwarg. 1732 art/cover/repair/import tests pass.
2026-06-08 11:07:00 -07:00
Jeff Theroux
3c06bd03c0
fix(tidal): honour version field in matching and back off on rate limits
Three related fixes to Tidal track matching and downloading:

1. version-field handling — Tidal stores remix/live/edit qualifiers in a
   dedicated `track.version` attribute (e.g. name="Emerge",
   version="Junkie XL Remix"), not in the track name. The qualifier
   filter and the matcher only looked at name/album, so the exact
   recording was discarded. Fold `version` into both the qualifier
   haystack and the candidate title passed to MusicMatchingEngine.

2. divergent-version penalty — once versions are visible, OTHER cuts of
   the same base become candidates ("(Shazam Remix)" vs "(southstar
   Remix)"). Neither title is a prefix of the other, so the prefix-based
   version check missed them and the raw ratio stayed high off the
   shared base. Apply a heavy penalty when both titles carry different
   version descriptors so the wrong cut can't outscore the threshold.

3. rate-limit backoff — the trackManifests endpoint is aggressively
   rate-limited; a bare request failed 429 instantly, burned the quality
   tier, re-queued the track and hammered again (a self-amplifying
   storm). Honour Retry-After / exponential backoff with a bounded retry
   count and shutdown-aware sleep.

Adds unit + end-to-end tests for all three.
2026-06-08 13:50:19 -04:00
BoulderBadgeDad
7d481ae02f Cover Art Filler: a local album with file art isn't "missing" just because the DB thumb is empty (Boulder)
Boulder (Plex): "flags every album, but everything has art." His albums show
art in the library (served from the embedded file art), but the DB thumb_url
cache column is empty — and the scan flagged on db_missing (empty thumb_url),
so every local album tripped it despite having perfectly good art in the files.

Now: a LOCAL album is flagged only when its files actually lack art
(disk_missing). An empty thumb_url is just a stale cache when the files have
art — not "missing cover art". db_missing still flags media-server-only albums
(no local files), where the DB thumb is the only art there is.

Tests: local+file-art+empty-thumb → NOT flagged (the bug); local+no-file-art →
still flagged; media-server-only+empty-thumb → still flagged.
2026-06-08 10:03:43 -07:00
BoulderBadgeDad
8277385051 Cover Art Filler: resolve the rep path before the disk-art check (flags-every-album)
The scan checked album_has_art_on_disk() on the RAW DB track path, while the
apply (_fix_missing_cover_art) resolves the path first. On any path-mapped
setup (docker mounts, a Plex/SoulSync path mismatch) the raw path isn't found,
disk art reads as "missing", and EVERY album gets flagged — then the apply
resolves the path, finds the art already there, and reports "already present".
Scan and apply disagreed purely because only the apply resolved paths.

Now the scan resolves the representative path the same way (resolve_library_
file_path, same transfer/download/config inputs the retag job uses) before
checking disk art. Unresolvable → treated as no-local-file (not claimed
disk-missing) so we never false-flag a file we simply can't reach.

Tests: disk check runs on the resolved path (thumb+art → not flagged);
unresolvable path → not flagged + art never checked on None.
2026-06-08 09:52:17 -07:00
BoulderBadgeDad
442ced3dbf Jellyfin/Emby: populate album thumb_url during the library scan (root cause of "flags every album")
JellyfinArtist sets self.thumb (→ artists.thumb_url on scan); JellyfinAlbum
never did. So for Jellyfin/Emby the library scan read getattr(album, 'thumb',
None) == None and albums.thumb_url stayed empty for the WHOLE library. Two
downstream effects: blank album art in the web UI, and the Cover Art Filler
flagging every album as "missing cover art" (db_missing = empty thumb_url) —
making the filler the only thing that ever populated the column, which is
backwards. It should come from the scan, like artist thumbs do.

Fix: JellyfinAlbum.thumb = /Items/<id>/Images/Primary (same shape as the artist
thumb, which already normalizes + displays via fix_artist_image_url). Now the
scan stores album thumbs, the UI shows art, and the filler only flags albums
that are genuinely missing art. Navidrome + Plex already set album thumbs.

Tests: album exposes the Primary-image thumb, None without an id, same shape as
the artist thumb.
2026-06-08 09:40:27 -07:00
BoulderBadgeDad
c83d4862e8 Cover art: stop crying "(read-only?)" when files are simply already arted (Sokhi/Boulder)
The real reason Sokhi's cover-art fix "didn't work" and Boulder saw the same
message on a WRITABLE Windows box: _fix_missing_cover_art reported
"Updated database thumbnail, but could not write art to files (read-only?)"
whenever embedded==0 AND cover_written==False — but the overwhelmingly common
cause of that is every file ALREADY having embedded art (skipped, not failed).
The message blamed read-only on a perfectly writable library, which sent us
chasing a read-only ghost across three commits. Sokhi's /fix returns 200
(success), so he was never hitting the genuine EROFS path at all.

Now the message is derived from the art_result breakdown:
- embedded/cover written → "Applied cover art: …"
- failed>0 (non-EROFS) → "… check file/folder permissions"
- skipped>0 (already arted) → "Cover art already present on all N file(s)"
- otherwise → "no file artwork was applied"
Genuine read-only (EROFS) still hard-fails with the clear mount message.

Tests: already-arted→present (not read-only), failed→permissions, EROFS→hard
fail, embedded→success. 453 cover/repair/art tests pass.
2026-06-08 09:06:37 -07:00
BoulderBadgeDad
1ca14d1c19 Cover art: surface read-only on the cover.jpg sidecar write too (Sokhi, #804 follow-up)
Sokhi still hit the read-only error after the statvfs fix (6c3e285a). Root
cause was a gap that fix didn't cover: read_only_fs was only set from the
per-file EMBED write, but download_cover_art SWALLOWS its own cover.jpg EROFS
(logs "Error downloading cover.jpg" and returns). So when an album's tracks
already have embedded art, the embed loop is skipped, only the cover.jpg
sidecar write runs, its EROFS is swallowed, and the filler reported success
while spamming the log — exactly Sokhi's case.

Fix (no blast radius — download_cover_art still never re-raises, since its
import-pipeline callers aren't wrapped): on EROFS it now records
'_cover_read_only' on the passed context instead of just logging; apply_art_to_
album_files passes a capture dict and promotes that to read_only_fs. So a
cover-only read-only album now correctly surfaces the read-only message instead
of a silent success.

Tests: +1 — embed skipped (track already arted) + cover.jpg read-only →
read_only_fs True. 472 cover/art/repair tests pass.
2026-06-08 08:34:36 -07:00
BoulderBadgeDad
40e3dac881 Sync: append mode preserves the playlist image like reconcile (#811)
#811 (reopen of #792, carlosjfcasero, Emby/Jellyfin): "append" mode clobbered
the playlist's custom image. The post-sync image push only excluded
'reconcile' — so append (which edits the playlist in place via
append_to_playlist) still re-pushed the source image over the user's poster
every sync. Now both in-place modes (reconcile + append) skip the image push;
only the destructive 'replace' (recreate-from-scratch) pushes it.

append_to_playlist + set_playlist_image were verified to NOT touch tracks or
description (image push only POSTs /Images/Primary), so this is the identity-
clobber fix for append.

Tests: append + reconcile preserve the image, replace still pushes it.
2026-06-07 23:25:42 -07:00
BoulderBadgeDad
20ca4bb981 Import: don't duration-quarantine manual imports against a re-resolved release (#804)
CubeComming #804: importing Coldplay "Yellow" (the 269s Parachutes album track,
correctly tagged) was quarantined — "Duration mismatch: file is 269.2s, expected
266.0s (drift 3.2s > tolerance 3.0s)". The expected 266s came from a re-resolved
*single* edition, not the file's actual album. The duration-agreement integrity
check exists to catch truncated/wrong slskd TRANSFERS — but a manual import is
the user's own already-tagged file being sorted, so checking it against a
re-resolved release just manufactures false quarantines.

Fix: both manual-import paths (singles + album) now mark the context
is_local_import; the integrity check skips the duration-agreement leg for local
imports via expected_duration_for_check() (new pure helper). The size +
mutagen-parse legs still run, so genuinely broken files are still caught — only
the release-vs-file duration comparison is skipped, and only for manual imports.
slskd downloads are completely unaffected.

This does NOT change the deeper matching (file still groups under Singles vs the
Parachutes album — the #767 canonical-version family); it stops the false
quarantine so the file imports.

Tests: 4 on the helper (local skips, download keeps, zero/None/garbage, string
coercion) + updated the routes context assertion. 557 import/integrity tests pass.
2026-06-07 23:02:34 -07:00
BoulderBadgeDad
d9dcf57f43 Import: never wipe a clean/matched import's tags when enhancement fails (#804)
CubeComming #804: since 2.6.7, importing already-tagged files (Bruno Mars,
Coldplay) blanked EVERY tag and filed them under "Unknown Artist". Root cause:
both metadata-enhancement blocks in post_process_matched_download did
`except Exception: wipe_source_tags(file_path)` — a full audio.tags.clear() +
strip + clear_pictures. But enhancement throwing means NO new tags were
written, so wiping just destroys the originals. A transient enhancement error
on a well-tagged file = total metadata loss. (The reported "bitrate change" is
a red herring: mutagen padding on re-save, not a re-encode — ReplayGain only
reads via ffmpeg and tags via mutagen.)

Fix: gate the failure-path wipe on should_wipe_tags_on_enhancement_failure()
(new pure, tested policy) — only wipe UNMATCHED downloads (likely junk source
tags); NEVER wipe a clean/matched import, preserve its existing tags + log.
Unmatched-download behavior is unchanged, so the only thing that changes is the
broken case.

Tests: 3 pin the policy (clean→preserve, unmatched→strip, falsey→strip).
1211 import/pipeline/metadata tests pass.
2026-06-07 22:44:43 -07:00
BoulderBadgeDad
46730d1661 Expired Cleaner: rename the safety toggle to dry_run (default ON), matching Re-tag
The destructive job's findings-vs-auto toggle was 'auto_delete: False'. Renamed
to 'dry_run: True' to match the Re-tag job's convention and make the safe
default unmistakable: dry run ON (default) = findings only, deletes nothing;
dry run OFF = hands-off auto-delete. Behaviour-identical to the previous
default — just clearer + consistent. Help text + tests updated.
2026-06-07 22:13:30 -07:00
BoulderBadgeDad
696119d5ac Expired Download Cleaner: retention-based cleanup of watchlist/playlist downloads (Boulder)
A Library Maintenance job that cleans up downloads tracked by Download Origins
once they pass a per-origin retention window — findings by default, opt-in
auto-delete.

A download is only ever proposed for deletion when ALL hold: older than its
origin's retention, NOT still in an actively-mirrored playlist / watched
artist, and played fewer than the keep-threshold (default 2 → "played more
than once is kept"). Only touches downloads recorded from the Download Origins
feature forward — never pre-existing or manual library.

- core/library/expired_cleanup.py: pure decision core (retention_cutoff,
  is_expired, select_expired) — no DB/clock, fully tested. play_count is the
  reliable listen signal (last_played is often unpopulated, so recency isn't
  used).
- ExpiredDownloadCleanerJob: gathers facts (play_count via a new
  get_origin_cleanup_candidates join; active-mirror via get_mirrored_playlists;
  watch via get_watchlist_artists) and either creates 'expired_download'
  findings or, with auto_delete on, deletes in-scan. Default OFF, both
  retentions default 'off'. Settings auto-render in the Library Maintenance
  panel (same as Cover Art / Lyrics / Re-tag).
- delete_origin_download(): shared delete (resolve path → remove file → drop
  track row → drop history row); a file that won't delete keeps its row +
  reports. Used by auto mode AND the _fix_expired_download apply handler.
- Frontend: type/action ('Delete')/result labels + finding detail render.

Tests: 9 on the pure brain (windows, off, per-origin, protected, play-count
threshold, bad age) + 7 on the job (no-op when off, findings, mirror/watch
protection, auto-delete, delete helper missing/real file). 185 repair/origin
tests pass.
2026-06-07 22:06:56 -07:00
BoulderBadgeDad
6c3e285a49 Cover art: detect read-only from the actual write, not statvfs (Sokhi false-positive)
Sokhi got a read-only error from the cover-art filler with NO ':ro' in his
compose. Root cause: my earlier Tim fix added a statvfs pre-flight that bailed
when f_flag & ST_RDONLY — but union/FUSE/network filesystems (mergerfs,
rclone, NFS), ubiquitous in self-hosted setups, misreport those mount flags.
A perfectly writable library could be flagged read-only and blocked. statvfs
is a guess; the only honest test is whether an actual write raises EROFS.

- Removed the statvfs pre-flight entirely. Read-only is now detected solely
  from a real EROFS on the embed write, which also fast-fails the remaining
  files (so no statvfs needed for the fast-fail Tim wanted either).
- Broadened the user message: a genuine read-only mount isn't always ':ro' —
  could be a read-only host/NFS/SMB mount or a mergerfs read-only branch.

Tests: writable FS succeeds even when statvfs would claim read-only (the
regression), real-EROFS-on-write still flagged + bails the rest, EACCES still
not conflated with EROFS. Dropped the now-moot Windows-statvfs test (statvfs
is no longer referenced). 445 art/cover/repair tests pass.
2026-06-07 19:57:34 -07:00
BoulderBadgeDad
ed38d60b18 Lyrics Filler: convert track duration ms→s for LRClib (exact-match was silently defeated)
Second lock-in catch: tracks.duration is stored in MILLISECONDS (schema), but
the scan passed it to LRClib as SECONDS. LRClib's exact-match-by-duration
strategy would never hit (215000s vs the real 215s), silently falling back to
the fuzzier title/artist search and storing the wrong duration in the finding.
Now divides by 1000 (guards against 0/garbage). Lyrics were still being found
via the fallback, so no track was missed — just less precise matching and a
wrong stored value. Test pins 215000ms → 215s.
2026-06-07 19:45:10 -07:00
BoulderBadgeDad
e93357a385 Lyrics retag: fetch query from a read-only lyrics_meta, never db_data (no tag pollution)
Lock-in pass caught a real bug in 1051ef24: the retag lyrics path stuffed the
library title/artist into a plan's db_data to feed the lyrics query — but
db_data is exactly what write_tags_to_file writes ("only writes fields that
have DB values"). So an UNMATCHED track (one with no source match, meant to
get art/lyrics only) would have had its title/artist tags overwritten from
the library values — an unintended tag write on a track we never verified.

Fix: each plan now carries a separate READ-ONLY lyrics_meta
({title, artist, album}) sourced from the library track + album scope, kept
entirely out of db_data. apply_track_plans reads lyrics_meta for the query
(db_data fallback for older plans); unmatched plans keep db_data={} so no tags
are written. _fix_library_retag threads lyrics_meta through the manual-apply
path too.

Tests: +1 regression pinning that an unmatched lyrics plan calls
write_tags_to_file with EMPTY db_data (no title/artist leak) while still
fetching lyrics. 70 lyrics/retag/repair tests pass.
2026-06-07 19:42:30 -07:00
BoulderBadgeDad
1051ef2402 Lyrics: add a "Lyrics Filler" maintenance job + lyrics option in the Re-tag tool (Sokhi)
The lyrics sibling of the Cover Art Filler, plus retag integration — reusing
the existing LyricsClient (LRClib) the import pipeline already uses.

- lyrics_client: extracted the LRClib fetch (exact-match-with-duration →
  search fallback) into a shared _fetch_remote_lyrics, used by both
  create_lrc_file (unchanged behavior) and a new check-only has_remote_lyrics.
- MissingLyricsJob (core/repair_jobs/missing_lyrics.py): scans tracks with no
  .lrc sidecar and — Option A — only flags ones LRClib actually has lyrics
  for, so instrumentals/interludes are never surfaced or re-flagged. Registered
  in the job list; default OFF; respects the lrclib_enabled toggle.
- _fix_missing_lyrics (repair_worker): applies a finding by fetching + writing
  the .lrc and embedding lyrics via create_lrc_file.
- Re-tag tool: new 'lyrics' setting ('fetch'|'skip', default skip). When
  'fetch', apply_track_plans now also fetches/refreshes the .lrc per track
  (fetch-if-missing, re-embed-if-exists) — threaded through scan gates, finding
  details, the auto-apply path, and the manual fix handler. Settings UI
  auto-renders the dropdown from setting_options; no markup needed.
- Frontend: type/action/result labels for missing_lyrics + a finding detail
  render case.

Tests: 12 — has_remote_lyrics truth table, sidecar detection, scan (only-
fixable / skip-existing / lrclib-disabled), the apply handler, and retag
lyrics_action on/off. 694 repair/lyrics/cover/retag tests pass.
2026-06-07 17:27:52 -07:00
BoulderBadgeDad
8ee59c7453 Blocklist Phase 2b: gate manual downloads with a "download anyway?" confirm
Closes the last acquisition gap — user-initiated downloads. A blocklist isn't
a censor, so search + discography stay fully visible; instead the download
ACTION is gated, visibly and overridably:

- Download modal (start-missing-process): an up-front check — if the WHOLE
  album or artist being downloaded is blocklisted, return 409 {blocked:true}
  with the entity, before starting a batch. The modal shows "X is blocklisted
  — download anyway?" and re-POSTs with ignore_blocklist:true on confirm
  (threaded onto the batch so the Phase 2a per-track filter skips it).
  Scattered single-track bans still fall through to the 2a filter quietly.
- Manual /api/download (search-result download): source-file-centric, so it
  matches the blocked ARTIST by name; same 409 + confirm + override. search.js
  now sends artist/title so the guard has something to match.
- Precedence confirmed: force-download overrides "already owned", NOT a ban
  (the 2a filter runs on the force-expanded missing list).

Frontend: shared confirmBlockedDownload() helper; modal + search callers
handle the blocked response and retry with the override.

Tests: manual download blocked-by-name / unrelated-allowed / override-passes,
and the modal up-front 409 for a blocked album. 8 blocklist API tests pass.
2026-06-07 16:15:23 -07:00
BoulderBadgeDad
45badf588c Blocklist Phase 2a: gate the download queue (playlist sync / album / discography)
Phase 1 guarded the wishlist; Phase 2a closes the other auto-acquisition path.
Playlist sync, album download, and discography backfill all flow through
run_full_missing_tracks_process, which queues missing tracks at one point —
right where the explicit-content filter already drops tracks. The blocklist
filter slots in beside it: each missing track is checked and a banned
artist/album/track is dropped before queueing (logged with a count), so a
blocked item can't slip in via these flows.

Same brain as Phase 1: the wishlist guard's matcher is generalized to
db.blocklist_reason_for_track(profile_id, track_data, source=None) — the new
`source` param lets the queue path supply the batch source, since an analysis
track dict may not carry a 'provider' field (artists still match by name
fallback regardless). One method, two callers (wishlist + queue), one cascade.

Manual single-track downloads (/api/download, candidate picker, redownload)
are deliberately NOT gated here — that's Phase 2b, pending a block-vs-warn-vs-
override policy decision.

Tests: source-fallback isolation (album id-only proves source drives the ID
match; artist name still matches sourceless), and a queue-filter simulation
mirroring master.py. 35 blocklist tests pass (the only failures in the
download family are the pre-existing soundcloud /app ones).
2026-06-07 15:49:59 -07:00
BoulderBadgeDad
b6d78d015d Blocklist Phase 1 (backfill + API + modal): the Blocklist button on the watchlist page
Completes Phase 1 on top of the backend (43c798a7):

- Cross-source backfill: core/blocklist/backfill.py is a pure injected-resolver
  core (resolve only missing sources, never raises); core/blocklist/runtime.py
  wires the real metadata clients with a confident name-match (exact
  significant-token equality; album/track also require the parent artist when
  both expose one — no wrong IDs hung on an entry). Resolution runs
  synchronously at add time, so a ban is cross-source from the first scan;
  the artist name-fallback in matching covers any gap.
- API: GET/POST/DELETE /api/blocklist (profile-scoped) + /api/blocklist/search
  (thin wrapper over the manual-match service search on the active source, so
  the modal needn't know the source). Add resolves the other sources before
  storing.
- Modal (webui/static/blocklist.js): tabbed Artists/Albums/Tracks in the
  revamp design language (accent light-edge, pill tabs, debounced search with
  spinner + out-of-order guard, per-result Block, "currently blocked" list
  with a match-status star and per-row remove). Opened by a new "Blocklist"
  button on the watchlist page, next to Download Origins.

Tests: 5 backfill (fill-missing-only, None/exception handling, arg shape) + 4
API (search proxy, add→backfill→list→delete round trip, validation). Modal
registered in the script-split onclick-coverage test; JS syntax-checked.
2026-06-07 15:25:52 -07:00
BoulderBadgeDad
43c798a76e Blocklist Phase 1 (backend): artist/album/track bans enforced at the wishlist chokepoint
A proper artist/album/track blacklist (distinct from download_blacklist, which
stays untouched). ID-keyed across metadata sources so a ban survives a source
switch; profile-scoped; cascade artist→album→track.

- core/blocklist/matching.py — pure decision core (no I/O): build an index from
  rows, candidate_block_reason() walks track→album→artist. Same-source ID match
  is primary; artist NAME is a fallback (covers the ID-backfill window);
  albums/tracks are ID-only (common titles like "Greatest Hits" must not
  false-positive across artists). Source-isolated so a numeric Deezer id can't
  collide with a numeric iTunes id of a different entity.
- DB: new `blocklist` table (profile_id, entity_type, name, 4 source-id cols,
  match_status) + CRUD, match-row fetch, backfill-pending query, id-backfill
  update (COALESCE — fills NULLs only).
- Guard: _wishlist_blocklist_reason at the top of add_to_wishlist — every
  auto-acquisition path funnels through it, so one check covers watchlist,
  discography backfill, repair, manual add. Fails OPEN (a guard error never
  blocks a legitimate add).
- Discovery unified IN: legacy discovery_artist_blacklist is migrated into the
  blocklist on upgrade (replicated to every profile so no global ban silently
  stops working; idempotent; legacy table kept for rollback). Discovery reads
  (hero + personalized-playlist SQL) now union the blocklist, so a new-modal
  ban filters discovery too.

Tests: 13 on the pure matcher (cascade, id-vs-name rules, source isolation,
precedence) + 10 on the DB/guard (CRUD, profile isolation, dedup, backfill,
end-to-end wishlist refusal + cascade + the discovery migration upgrade path).
50 blocklist/personalized tests pass.
2026-06-07 15:18:25 -07:00
BoulderBadgeDad
df929dc022 #809 Navidrome playback: stream via the server's API when the library isn't mounted on disk
mlody95pl: Navidrome sync works, playback fails ("Failed to resume playback").
Root cause: SoulSync plays library tracks by reading the file off its OWN
disk (/api/library/play → resolve path → serve bytes). "Report Real Path"
gives the correct path STRING, but that's Navidrome's container path — the
files still have to be mounted into the SoulSync container to open them, and
the user's compose has /music commented out. So disk resolution 404s.

Navidrome is a streaming server, so requiring a disk mirror to play from it is
the real limitation. Now, when a library file isn't on SoulSync's disk and the
active server is Navidrome, playback streams through the server's own Subsonic
/rest/stream API — no mount needed:

- NavidromeClient.build_stream_url(song_id, max_bitrate) — token-authed
  /rest/stream URL (mirrors build_cover_art_url; password never exposed).
- /api/library/play: on disk-miss, _build_library_stream_url (Navidrome-only;
  uses the song id sent by the player, or a DB lookup by file_path) sets a
  session stream_url instead of failing.
- /stream/audio: proxies that stream_url with Range passthrough so HTML5
  seeking works, streaming upstream bytes through in 64KB chunks (no full-file
  buffering).
- session state gains stream_url; the two library-play callers now send the
  track's server id.

Disk playback is unchanged (file_path path still wins when the file resolves),
so Plex/Jellyfin and mounted-Navidrome setups behave exactly as before.

Tests: 7 on the URL builder (auth shape, no-transcode default, maxBitRate,
guards) + 4 on the play-fallback routing (navidrome-only, passed-id vs
DB-lookup, none). 200 navidrome/stream/media-server tests pass.
2026-06-07 13:52:14 -07:00
BoulderBadgeDad
7207ec61fb Cover Art Filler: fix album art or artist art independently (Pache711)
Pache711: a cover-art finding showed the (correct) found album art next to a
(wrong) artist image with one "Apply Art" button — no way to take one and
skip the other. Turned out "Apply Art" only ever applied ALBUM art anyway;
the artist image was display-only context, so the bundling was an illusion
the UI created.

Now the finding is genuinely multi-target:
- scan (missing_cover_art.py): also searches for an artist image (always, so
  a WRONG existing one can be replaced — Boulder's call), name-matched
  exactly. Stored as found_artist_url only when it differs from the current
  artist thumb, so nothing is offered when there's nothing to change.
- apply (_fix_missing_cover_art): honors a target via _fix_action —
  'album' (default, unchanged "Apply Art" behavior: DB thumb + embed +
  cover.jpg), 'artist' (the artist's DB image), or 'both'. New _fix_artist_art
  sets artists.thumb_url for the album's artist.
- UI: each found image gets its own apply button — "Use for album" /
  "Use for artist". Applying either resolves the finding, so taking the
  correct one and ignoring the wrong one IS "fix one, dismiss the other".
  Current artist art shows as "(current)" context with no button.

Default stays album-only, so the plain Apply Art button and every existing
caller behave exactly as before. Tests: 5 on the apply targets (artist-only /
album-only / default / both / missing-url) against a real SQLite DB, plus the
existing cover-art suite updated for the new artist search. 107 repair/
cover-art/UI-integrity tests pass.
2026-06-07 13:24:46 -07:00
BoulderBadgeDad
8b7609cdb2 Manual match: paste a MusicBrainz ID/URL to match directly (Ashh)
Ashh: the manual-match modal fuzzy-searches a service and shows the top 8.
When the right release isn't in those 8 (common title — their example was
"Idols", which returns 8 unrelated releases and not Yungblud's), there was no
way through. But the user usually already knows the exact MBID.

Now the modal's search box doubles as a direct-ID box. Paste a MusicBrainz
MBID (bare UUID or a musicbrainz.org URL) and SoulSync looks that exact
entity up and shows it as the single result to confirm + Match — no fighting
the search ranking.

- core/library/direct_id.py: pure detector, returns the canonical ID only
  when the text unambiguously IS one (whole-query UUID, or a UUID inside a
  musicbrainz.org URL). "Idols", "Yungblud Idols", a UUID buried in free
  text → None, so normal search is never hijacked.
- _search_service: direct-ID fast path before the fuzzy search —
  get_release (→ get_release_group fallback for albums) / get_artist /
  get_recording. A pasted-but-unresolvable ID falls THROUGH to fuzzy search,
  so a typo can't dead-end the modal.
- UI: MusicBrainz placeholder now says "…or paste a MusicBrainz ID/URL".

Detector is service-keyed so Spotify/iTunes/etc. direct IDs can be added
later; today only MusicBrainz has a confirmable direct lookup, matching the
reporter's ask + screenshot. 9 tests: detector truth table (bare/URL/plain/
buried/other-service) + dispatch (confirmed release, release-group fallback,
unresolvable→fuzzy, plain query skips direct lookup).
2026-06-07 13:04:17 -07:00
BoulderBadgeDad
5187fe5f66 Torrents: stalled-torrent handling — abandon a dead magnet instead of holding a worker 6h (noldevin)
noldevin's first torrent was stuck "downloading metadata" — a dead magnet
with no peers. The poll loop would ride the full album deadline (6h default)
on it, holding the worker the whole time, with no built-in escape.

New stall handling, off the existing poll loop:
- core/download_plugins/torrent_stall.py — pure StallTracker (clock injected,
  no I/O): forward byte progress resets a stall clock; once a torrent spends
  the stall timeout in a working state (queued/downloading/stalled/error)
  with zero progress, it's stalled. seeding/completed/paused never count.
  Covers the metadata-stuck case (0 bytes, 0 progress) and a dead mid-download
  swarm with one rule.
- _handle_stalled: 'abandon' (default) removes the torrent + its partial data
  (a metadata stub is junk) and fails the download so the next source can try;
  'pause' parks it in the client for the user. Adapter errors are swallowed —
  the download still fails cleanly.
- two settings (download_source.torrent_stall_timeout_seconds = 600,
  torrent_stall_action = 'abandon'); timeout 0 disables, restoring the old
  ride-the-deadline behavior. Config-key driven, matching the existing
  album_bundle_* tuning knobs (no UI form, same as those).

Tests: 18 on the tracker + settings (timeout trip, progress reset, idle-state
exemption, pause→resume clock restart, disable, parse tolerance) + 3 on the
plugin action path (abandon removes w/ delete_files, pause pauses, adapter
error survived). 158 torrent-family tests pass.
2026-06-07 12:38:51 -07:00
BoulderBadgeDad
603b7a2ab8 Spotify tokens move into the database — daily Docker deauth fixed (wolf39us)
wolf39us: "It keeps unauthenticating... daily" — re-auth fixes it until the
next day. Mechanism: spotipy's token cache was a loose FILE at
config/.spotify_cache. /app/config is a declared VOLUME, but a compose file
that doesn't map it explicitly gets an ANONYMOUS volume — recreated empty on
every container pull. So a nightly Watchtower update kept all his settings
(config lives in the database now) while silently dropping the OAuth tokens.
His redirect-URI change won't help: callback URLs only matter during the
initial handshake, never for refresh.

New DatabaseTokenCache (spotipy CacheHandler) stores the token payload in
the same database-backed config store as every other setting — tokens now
survive exactly as long as the rest of the configuration does. The legacy
file is imported once on upgrade (no forced re-auth) and removed on logout;
a failed cache write logs and never raises (spotipy calls it mid-request).

Tests: roundtrip, JSON-string tolerance, one-time legacy import (store wins
after the file vanishes), garbage file ignored, logout clears both stores,
write failure never raises. 204 spotify tests pass.
2026-06-07 12:01:33 -07:00
BoulderBadgeDad
f1f9d803a5 Multi-artist: pin the Deezer-search -> Tidal-download flow end to end (Netti93 follow-up)
Netti93's follow-up report (single artist at download time, correct only
after retag) reproduces as FIXED on current dev — verified live against
Deezer's API with his literal track ('VERLIEBT IN MICH', FAYAN feat.
Dalton) and his exact config, through the real tag writer onto a real MP3:
TPE1=FAYAN, TIT2 gains '(feat. Dalton)', TXXX:Artists=[FAYAN, Dalton].
His last test (2.5.6 / May-19 dev) predates the fixes that closed it
(d5de724f contributors upgrade hardening, 0769fcd5 collab-tag loss).

These tests pin the full direct-download shape so it can't quietly
regress: Deezer /search payload (one artist) + provider on the candidate
(not the context) -> contributors upgrade fires -> feat_in_title and
artist_separator both honored. Network-free (client mocked with the live
API's verified response shape).
2026-06-07 11:46:04 -07:00
BoulderBadgeDad
58df4632c4 Watchlist: repair iTunes ids that are actually Deezer ids (the 37725457 corruption, proven live)
37725457 fixed _match_to_itunes to use the real iTunes client and flagged
the cross-source corruption as a possibility. Boulder's live DB proves it
happened: 6 of his 9 watchlist "iTunes" ids EQUAL the artist's Deezer id
(Taylor Swift's "iTunes" id was her Deezer id 12246; the real one is
159260351) — written back when the misnamed MetadataService.itunes slot
held a DeezerClient. The June-4 batch (Green Day, SOAD, Vulfpeck, ...) got
NULL instead because the slot now holds the Spotify primary.

The fix alone can't heal those rows: the backfill only fills EMPTY ids, so
a wrong non-empty id is permanent. New migration clears itunes_artist_id
where it equals deezer_artist_id (the corruption signature — distinct id
spaces, so a legitimate equal pair is effectively impossible, and the worst
case is a NULL that re-matches correctly on the next scan). Idempotent by
construction; similar_artists checked clean (its backfill always used the
registry correctly).

Tests: corrupted row cleared / legit + no-deezer rows kept / idempotent —
via a real re-init with the per-process init memo cleared (an app restart).
2026-06-07 11:27:28 -07:00
BoulderBadgeDad
377254572b Watchlist: iTunes ID backfill never worked in the normal wiring — use the registry client
Boulder noticed his recently added watchlist artists (June 4 batch) have no
iTunes match while Spotify/Deezer/MusicBrainz matched fine. The rotated log
has the receipt: "Cannot match to iTunes - MetadataService not available" ×8
→ "Backfilled 0/8 artists with itunes IDs", every scan.

_match_to_itunes was the only matcher with no fallback: it read the PRIVATE
_metadata_service attr, which is None whenever the scanner is constructed
from a spotify_client — the normal web_server wiring — and gave up, while a
lazy-loading metadata_service property sat right next to it and the deezer/
discogs/musicbrainz matchers all fall back to their registry clients. Bonus
landmine: even when set, metadata_service.itunes is the FALLBACK-client slot
and may actually be a DeezerClient (per _match_to_deezer's own comment), so
"iTunes" matching could have stored a Deezer artist ID as itunes_artist_id.

Now mirrors the other matchers: canonical registry get_itunes_client().
Self-healing — missing IDs are re-attempted every scan, so existing
watchlists backfill on the next run with no migration needed.

Tests: match works with _metadata_service=None (the exact production
condition), unconfident result returns None, missing client degrades
gracefully. 103 watchlist tests pass.
2026-06-07 11:22:03 -07:00
BoulderBadgeDad
4a1b3d0627 PR #801 follow-up: default-config template contradicted the retry engine's documented default
CI failed all 7 requeue tests that passed locally. Root cause is a real
shipping bug, not test flake: config/settings.py's default template set
retry_next_candidate_on_mismatch: False ("Default off — opt-in") while the
monitor reads it with inline default True and the PR documents it as ON.
Outcome split the userbase: a FRESH install (or CI's clean runner) gets the
template key = retry engine silently OFF; an existing config.json lacks the
key = inline True wins = engine ON. Same code, opposite behavior, decided by
install age.

- template aligned to True (the documented + approved default; existing
  installs already behave this way via the inline default)
- the requeue tests now pin the toggle ON via the wiring helper instead of
  reading the runner's ambient config — CI's fresh defaults vs a dev's
  lived-in config.json must never decide whether they pass. _patch_config
  composes (it wraps the pinned get and falls through).

64 retry-engine tests pass; fresh-default simulation confirms the toggle
resolves True.
2026-06-07 11:04:28 -07:00
BoulderBadgeDad
ece74250fb art_apply: statvfs pre-flight is POSIX-only — Windows has no os.statvfs
Boulder's lock-in question caught it: the read-only pre-flight called
os.statvfs unconditionally, which doesn't exist on Windows, and the
AttributeError wasn't covered by the except OSError — the whole cover-art
apply would have crashed for every Windows install (docker images are
Linux, so the reporter was fine; the maintainer wasn't). getattr-guarded
now: no statvfs -> skip the pre-flight, per-file EROFS detection (errno is
cross-platform) still active. Test pins the no-statvfs path.
2026-06-07 10:46:09 -07:00
BoulderBadgeDad
3c758635d5 Cover-art filler: name the real cure when the music mount is read-only
Tim (Discord): cover-art automation fails with '[Errno 30] Read-only file
system' on every file; he chmod 777'd and nothing changed — because EROFS is
the KERNEL refusing writes to a docker ':ro' volume mount, which no chmod
can fix. SoulSync's response was a wall of per-file warnings and a fix
result that still said success with a soft "(read-only?)" hint.

- apply_art_to_album_files now pre-flights the album folder with statvfs
  (asks the kernel, writes nothing): a read-only mount short-circuits the
  whole album instead of failing file by file. Belt: a per-file/cover EROFS
  (overlay quirks where statvfs lies) still sets the flag.
- the repair worker's apply now FAILS the finding with the actual cure:
  "remove ':ro' from the volume mapping and recreate the container — chmod
  cannot change this". EACCES (a real permissions problem chmod CAN fix)
  deliberately keeps the old soft path.

Tests: RO mount short-circuits before any file/cover write, save-time EROFS
still flagged, EACCES not conflated with EROFS. 29 art/repair tests pass.
2026-06-07 10:33:33 -07:00
BoulderBadgeDad
142a1aaf38 Cover art: a numeric difference is a different release — Vol.4 stops wearing Vol.4.5's cover
Sokhi (continued from #806): volume-numbered series ('B小町 …キャラクター
ソングCD Vol.2' / 'Vol.2.5' / 'Vol.4' / 'Vol.4.5') got each other's art from
both normal downloads and the retag tool. Two distinct holes, one principle:

1. The art picker's _album_matches validates by significant-token SUBSET —
   built to tolerate '(Deluxe)'/'- Remastered' suffixes. CJK strips out of
   the normalizer entirely, so Vol.4 → {b,tv,cd,vol,4}, a clean subset of
   Vol.4.5's {b,tv,cd,vol,4,5}: the wrong volume validated as "the same
   album with a suffix". Affected every fuzzy art source (iTunes, Deezer,
   AudioDB, Spotify) in downloads, retag, and the missing-art repair.

2. MusicBrainz match_release scores by string similarity — Vol.4 vs Vol.4.5
   is 0.973, so the wrong volume could win the match outright, and its MBID
   then feeds Cover Art Archive with NO downstream validation (CAA is
   MBID-keyed, trusted by design). With Sokhi's MB metadata source this is
   the likely path in his logs (his release-group 404s push re-matching).

The shared rule (core.text.title_match.numeric_tokens_differ): digit-bearing
tokens must be IDENTICAL between the two titles. A number on one side only —
volume, part, sequel, remaster year — is a different release, never a
suffix. '1989' vs '1989 (Deluxe)' still matches (digits shared); 'Album' vs
'Album 2' now rejects (sequels!). Art picker rejects outright (falls through
to next source / the download's own art — the designed cost of a false
reject); MB matcher halves the candidate's confidence, landing it below the
70 gate while the exact-volume result is untouched.

Tests: helper truth table, the exact reported pairs through _album_matches,
and match_release end-to-end (wrong volume alone → no match beats a wrong
MBID; exact volume beats near-identical wrong one despite lower MB score).
828 matching/metadata + 301 musicbrainz/retag/artwork tests pass.
2026-06-07 10:21:23 -07:00
BoulderBadgeDad
ef751ce4e4 Artist pages: stop watchlist probes from poisoning the album-list cache
Boulder: "Taylor Swift shows only 8 albums, nothing before 2022, no singles,
no EPs" — for every artist (actually: every WATCHLIST artist). Traced live:
get_artist_albums caches its result under an UNQUALIFIED key (no limit/page
info), and the watchlist's new-release probe (limit=5, max_pages=1 — the
April "reduce watchlist API calls ~90%" optimization) stored its truncated
single page in that same slot. The artist detail page reads the cache first,
so a watchlisted artist's page showed only the newest handful of releases —
newest-first, hence "nothing before 2022" — re-poisoned on every scan, with a
30-day TTL. When the source-priority fetch comes back tiny, the page's
fallback path quietly serves it, so the symptom looked like a discography
filter bug. Not related to the #808 matching change (that is a pure max(),
provably additive).

Three pieces:
- get_artist_albums tracks whether the fetch stopped while more pages
  existed (truncated) and only caches COMPLETE discographies. Individual
  albums keep their opportunistic caching — they're complete entities
  regardless of pagination. A small real discography that fits one page
  stays cacheable even under max_pages=1.
- MetadataCache.purge_artist_album_lists(): delete the already-poisoned
  album-list entries (TTL would have kept them for weeks); lists rebuild
  lazily on the next artist-page visit.
- one-time startup purge in web_server, config-guarded
  (maintenance.album_cache_purge_v1), mirroring the startup-repair pattern.

Tests: truncated probe never stores the list (but still returns its page),
complete multi-page fetch caches, and a genuinely-small one-page discography
under max_pages=1 still caches. 1087 spotify/cache/watchlist/artist tests
pass.
2026-06-07 09:49:30 -07:00
BoulderBadgeDad
f250eaa228 #808: album-context qualifiers stop blocking library-presence matching
carlosjfcasero: 'Champagne Supernova (OurVinyl Sessions)' is in the library
but the artist page shows it unowned and wishlist cleanup never removes it.
Measured with the real catalogs: Deezer/iTunes title the TRACK with the
qualifier while the library track is bare (the qualifier lives in the album
title) — and _calculate_track_confidence crushed that pair to ~0.17: the
"clean" titles keep parenthetical words, so the length-ratio penalty treats
'Champagne Supernova' vs 'Champagne Supernova (OurVinyl Sessions)' as
different songs. (Also confirmed: the OurVinyl release is absent from
Deezer's discography for the artist, so the standard page's 25-release list
not showing it is the source catalog, not a bug.)

Fix 1 — core.text.title_match.strip_redundant_context_qualifiers: a
parenthetical qualifier whose text appears (word-bounded) in the db track's
ALBUM title — or in the other title — restates release context and is
stripped for a comparison variant scored with its own length guard. Genuine
version markers keep their penalty: '(Live)' on a studio album appears in no
context and still blocks; '(Live)' on 'Live at Wembley' correctly matches —
owning the live album IS owning the live cut. Wired into
_calculate_track_confidence, so every check_track_exists consumer (wishlist
cleanup, discography dedup, repair jobs) benefits.

Fix 2 — the artist-page ownership endpoint's album gate: when album-aware
narrowing eliminates EVERY library candidate (the source's album naming just
doesn't resemble the library's — 'Jillette Johnson | OurVinyl Sessions' vs
'Champagne Supernova (OurVinyl Sessions)' ~0.5), fall back to artist-wide
title matching instead of declaring everything unowned off a failed
album-NAME comparison.

Tests: 8 — the exact reported pair end-to-end through check_track_exists,
word-boundary containment ('live' in 'alive' doesn't count), version-marker
safety both ways, and prefix songs still blocked. 1125 matching/wishlist/
library tests pass.
2026-06-07 09:24:03 -07:00
BoulderBadgeDad
157d19f3b9 Post-merge #801 follow-ups: un-silence the retry engine's logs + register origin-history.js
Review findings from PR #801, fixed as promised after merge:

- core/imports/version_mismatch_fallback.py and core/downloads/task_worker.py
  used bare getLogger(__name__) — outside the soulsync.* namespace where
  handlers attach, so the entire retry story (the [Modal Worker] search/retry
  walk and, critically, the "accepting best quarantined candidate as last
  resort" warning) never reached app.log. Same bug class as the prepare.py
  fix; both moved to get_logger. A repo sweep shows 61 more modules with the
  same pattern — noted as its own cleanup project.

- the full-suite run also caught a miss of MINE, not the PR's: the new
  origin-history.js wasn't registered in the script-split integrity test, so
  openDownloadOriginsModal failed onclick coverage. Registered — and the
  onclick scan now iterates the NON_SPLIT_JS registry instead of its own
  hardcoded copy, so the next standalone module can't silently skip coverage.

Merged dev verified: PR's 77 tests + 4233 full-suite tests pass (the only
exclusion is the eternal soundcloud /app file); integrity suite 64/64.
2026-06-07 00:58:09 -07:00
BoulderBadgeDad
79f020b3b4
Merge pull request #801 from nick2000713/feature/retry-next-candidate-on-mismatch
Downloads: complete retry overhaul,  exhaustive multi-source retry, MusicBrainz kanji fix, version-mismatch last-resort fallback
2026-06-07 00:45:21 -07:00
BoulderBadgeDad
e135873b4b #705: release-date gate — unreleased tracks stay out of the wishlist cycle and Fresh Tape
Watchlist scans add announced albums on purpose (so singles download the day
they drop), but the future-dated tracks leaked into two hot paths:

- Fresh Tape / Release Radar: future albums got NEGATIVE days_old, and the
  recency score (100 - days*7) has no upper clamp — prereleases weren't just
  slipping into the radar, they were mathematically FAVORED above every
  released track. That's the "50% prerelease" report. The builder now skips
  confidently-future albums (and clamps days_old to 0 as a belt).

- Wishlist processing: every auto cycle burned a full Soulseek search +
  timeout per unreleased track (~60 tracks/cycle for the reporter). Both the
  auto and manual flows now skip future-dated tracks with a counted log line.
  They STAY in the wishlist and join the cycle automatically the day their
  release date passes — no state, the date check is per-cycle. An explicit
  manual track selection overrides the gate (the user asked for those).

The gate (core/metadata/release_dates.py, pure + tested) is conservative by
design: Spotify dates come as YYYY / YYYY-MM / YYYY-MM-DD, and a track only
gates when its date is CONFIDENTLY future at its stated precision. Release
day counts as released; garbage or missing dates never block anything
(including out-of-range months/days, which fall back a precision level).

Tests: 6 covering all precisions, the release-day boundary, garbage
tolerance, dict shapes, and ordered partitioning. 403 wishlist/watchlist
tests pass.
2026-06-07 00:29:05 -07:00
BoulderBadgeDad
1f7834cc7b Download Origins: see (and delete) exactly what watchlist + playlist syncs downloaded
User ask: "a modal that lists the tracks downloaded via watchlist" — extended,
as discussed, to playlists too. One modal, two tabs, opened from the Watchlist
page (watchlist tab preselected) and the Sync page (playlists tab) — same
shared-modal-different-entry-points UX as the rest of the app.

The data: library_history recorded which SERVICE a file came from but never
what TRIGGERED it. New origin/origin_context columns (migration + index) are
written once at the import chokepoint via core/downloads/origin.py, a pure
tested deriver that reads, in priority: an explicit _dl_origin stamp (set at
batch-task creation for direct playlist batches, where the playlist context
otherwise only survived in folder mode), the wishlist provenance already
riding in track_info.source_info (watchlist_artist_name / playlist_name —
watchlist_scanner has stamped these for ages), and the folder-mode playlist
thread. Manual downloads stay unclassified by design. History starts from
now — provenance can't be conjured retroactively.

API: GET /api/download-origins?origin=watchlist|playlist (paged) and POST
/api/download-origins/delete — deletes the file on disk (resolved through the
shared container/host path resolver), the matching library track row, and the
history entries; a file that refuses deletion keeps its row and reports the
error instead of lying.

UI: webui/static/origin-history.js — tabbed modal in the revamp design
language (accent light-edge, pill tabs, entry rows reusing the
library-history-entry components), per-row delete + select-all bulk delete
with honest result toasts, empty/loading states, per-tab totals.

Tests: 8 — deriver priority/shapes (incl. the exact watchlist_scanner
source_info shape and JSON-string survival), origin filtering + counts,
row fetch/delete isolation between origins, delete-track-by-path.
2026-06-07 00:15:31 -07:00
BoulderBadgeDad
76c63b5bc4 #806 lock-in: archive.org outage cooldown for CAA originals
The lock-in pass caught the cost hole: art is fetched PER TRACK, and the old
code never touched archive.org at all — so an archive.org outage was free,
while the new native-first chain would pay a 10s timeout on every track
(a 12-track album = +2 minutes, exactly the import-slowness class we spent
today killing). One failed original now puts originals on a 10-minute
cooldown: subsequent fetches go straight to the 1200px CDN midpoint (the
pre-#806 behavior, full speed) and recover automatically when the cooldown
expires. Locked by a test: track 1 pays the failure once, track 2 never
touches the original. (Also: the missing time import the first run caught.)
2026-06-06 23:36:14 -07:00
BoulderBadgeDad
c6d1dede2b #806: MusicBrainz cover art at native resolution (Cover Art Archive /front)
The CAA branch of _upgrade_art_url capped art at the /front-1200 thumbnail —
a deliberate flakiness trade-off, but the policy had rotted into inconsistency:
iTunes art already shipped at 3000x3000, and bare /front URLs (release-group
lookups — exactly what the Re-tag flow produces) bypassed the cap entirely,
which is how Sokhi observed retag delivering full-res while downloads got 1200.

CAA URLs now upgrade to the bare /front ORIGINAL (native res, frequently
3000px+). The flakiness concern that motivated the old cap is handled where it
belongs, in the fetch: _fetch_art_bytes now walks an attempt chain — original
-> /front-1200 midpoint -> the original sized thumbnail — so a flaky
archive.org degrades to the old 1200px behavior, never below it.

Tests updated to the new contract (+3 chain tests: native-first, flaky
degrades to 1200 not 250, full chain ends at the thumbnail). 623 metadata +
1267 art-path tests pass.
2026-06-06 23:18:45 -07:00
BoulderBadgeDad
b7fc6c3361 Genius 429 backoff: fail-fast gate instead of napping the import pipeline
Caught live by the new lookup timing ("Genius track lookup took 242.4s"):
the 429 handler slept the backoff (30/60/120s) in the CALLING thread and then
re-raised anyway — the import pipeline waited 2x120s per track for lookups
that still failed. Worse, the pre-flight backoff wait also slept while
HOLDING the global Genius API lock, so every other Genius caller queued
serially behind the nap.

Now the backoff is a gate: a 429 opens a 30s->60s->120s window and re-raises
immediately; any call inside the window raises GeniusRateLimitedError on the
spot. The error subclasses requests.RequestException, so every existing
caller (the import's source lookups catch RequestException and skip; the
worker's per-item guards) already handles it as a one-line skip — lyrics and
Genius tags are garnish, nothing is allowed to WAIT for them.

Tests: backoff window fails fast (<0.5s vs the old full-window sleep), a 429
opens and escalates the gate without sleeping, the error is a
RequestException (the no-call-site-changes hinge), success decays the gate.
2026-06-06 20:51:33 -07:00
BoulderBadgeDad
88da265ef4 Import speed: downloads pause ALL enrichment workers, discovery pauses the contention five
Measured during a live album download: ~4m15s per track in post-processing
(normal is ~20s), with the time vanishing silently inside embed_source_ids —
up to 5 MusicBrainz calls per track crawling against a degraded musicbrainz.org
while the MB enrichment worker kept eating the same ~1 req/s per-IP budget.
Only Spotify/Last.fm/Genius were in the yield set; MusicBrainz, Deezer, iTunes,
Discogs etc. kept grinding through downloads.

Policy (new core/enrichment/yield_policy, tested):
- downloads active  -> ALL enrichment workers yield (post-processing touches
  every metadata source). listening-stats (local-only) and repair
  (user-scheduled) intentionally keep running.
- discovery active  -> the API-contention five yield (spotify/itunes/deezer/
  discogs/hydrabase) — discovery never paused anything before, despite the
  pause helper literally defaulting to label='discovery'.
- user overrides and user-paused bookkeeping keep their existing semantics;
  the dashboard yield_reason label now says WHICH foreground work caused it.

Observability (the 4-minute silence can never come back):
- every source lookup is timed; >2s logs a warning NAMING the source and
  duration (core/metadata/source.py _call_source_lookup)
- the pipeline always logs "Metadata enhancement took X.Xs" per track

7 policy tests (incl. the motivating case: MB yields to downloads, keeps
running during discovery); 277 pipeline/enrichment tests pass.
2026-06-06 19:05:56 -07:00
BoulderBadgeDad
ab33d8cf2e #802: on-demand memory-growth diagnostic (tracemalloc, browser-drivable)
A user reports ~0.7 MiB/s RSS growth; the one theory offered so far
(connection leak) was debunked, so instead of guessing: measure. New
core/diagnostics/memory_tracker wraps tracemalloc behind three GET endpoints
the user can drive from a browser:

  /api/debug/memory/start   begin tracing + baseline snapshot (idempotent)
  /api/debug/memory/report  top allocation sites by GROWTH since the baseline
                            (?top=N), with traced totals + process RSS so we
                            can see how much of the real growth tracing
                            accounts for; 15-frame tracebacks name the caller
  /api/debug/memory/stop    end tracing, free trace bookkeeping

Opt-in by design — tracemalloc shadows every allocation while active, so it
never runs by default. RSS via psutil with a /proc fallback.

Tests: report-without-tracking returns a hint (not an error); a real
start->hog->report->stop roundtrip attributes a genuine 5MB allocation to the
test file (fun fact encoded in the test: 'x'*1000 constant-folds into ONE
shared string and traces as ~40KB — the hog must allocate at runtime); the
stat formatter is duck-typed and unit-tested.
2026-06-06 18:31:14 -07:00
BoulderBadgeDad
00e50c2fcb Tests: lock the yt-dlp JS-runtime startup warning seam
Warns exactly once when deno is missing (naming the cryptic failure it
prevents users from having to debug), stays silent when deno is on PATH.
2026-06-06 16:52:52 -07:00
BoulderBadgeDad
fe1366d9e0 Mirrored "Liked Songs" stops 400ing on every auto-refresh (wolf39us)
"Liked Songs" isn't a real Spotify playlist — no playlist URI exists for it;
the web UI invents the virtual id 'spotify:liked-songs' and Spotify serves the
collection via the saved-tracks endpoint. The playlist DETAIL endpoint special-
cases that id, but the mirrored refresh path resolves stored ids through
get_playlist_by_id, which fed the virtual id straight into sp.playlist() ->
"http status: 400 ... Unsupported URL / URI" on every sync cycle, silently.

get_playlist_by_id now special-cases the virtual id at the client seam (every
by-id resolver benefits, not just the mirror adapter): it builds the Playlist
from the existing get_saved_tracks() pagination, with the real owner name and
track count. New LIKED_SONGS_PLAYLIST_ID constant owns the magic string.

Safety: get_saved_tracks swallows fetch errors into [] — indistinguishable
from "no likes" — and the virtual playlist is only ever offered when likes
exist. An empty result therefore resolves as a FAILED refresh (None) instead
of a valid-looking empty playlist a mirror sync might propagate by clearing
the server-side copy.

Tests: virtual id resolves from saved tracks and never touches the playlist
endpoint, real ids still do (regression), the mirrored adapter seam returns a
full PlaylistDetail, and empty saved-tracks -> None. 473 passed across the
playlist/mirror/spotify families.
2026-06-06 13:50:49 -07:00
BoulderBadgeDad
df19317dac Library Re-tag: cover-mode scans stop producing unappliable "(0 track(s))" findings
On path-mapped setups (Docker mounts etc.) the scan checked a bare
os.path.isfile() on the raw DB path — false for EVERY track — while the apply
handler resolves container/host mismatches. With a cover-art mode set, the
cover_action kept the album past the "anything to do?" gate, so every album
produced a finding with an empty tracks list whose apply could only ever fail
with "No tracks to re-tag in finding".

- the scan now resolves each track path with the same resolver the apply
  handler uses (resolve_library_file_path) before reachability checks and
  current-tag reads; plans carry the resolved path
- a finding can never be created with zero tracks — cover-action albums with
  no usable tracks are skipped, with a debug log of why (unreachable/unmatched
  counts) and the counts surfaced in the finding description
- unmatched-but-reachable tracks now get an art-only plan (empty db_data) so
  album cover art covers ALL the album's files, not just source-matched ones —
  apply_track_plans already treats empty db_data as a pure cover embed and
  counts a failed cover download as skipped, never failed (now locked by tests)
- cover-only findings are titled "(cover art, N track(s))" instead of the
  puzzling "(0 track(s))"

Tests: +5 (mapped paths resolve into plans, cover-with-nothing-reachable
creates no finding, unmatched -> art-only plan, art-only plan embeds cover,
failed cover download -> skipped). 87 passed across retag/repair/tag_writer.
2026-06-06 13:38:13 -07:00
BoulderBadgeDad
07d09d7d0e Stream button: the player never learns its stream is ready (2.6.5 regression)
Since the per-listener stream sessions refactor (Phase 3b), every browser gets
its own stream session — but the 1s 'tool:stream' socket broadcast still read
the legacy GLOBAL state (the DEFAULT session no real browser uses), so it told
every client "stopped" forever. The frontend skipped HTTP polling whenever the
WebSocket was up, so it only ever saw that wrong broadcast: the backend prep
downloaded the track, moved it into the session's stream folder and sat at
"ready" while the mini player showed nothing. Proxy users whose WebSockets
don't connect fell back to HTTP polling (session-correct) and streamed fine —
which is why this hid so well.

Fix: stream status is inherently per-listener, so stop pretending a global
broadcast can carry it —
- web_server.py: remove the 'tool:stream' emit from the tool-progress loop
  (the broadcast thread has no request context; it can only ever see DEFAULT)
- media-player.js: the status poller always polls /api/stream/status (resolves
  the caller's own session from the cookie); drop the dead broadcast handler
- core.js: unwire the 'tool:stream' socket listener

Observability fix that made this undebuggable: core/streaming/prepare.py used
getLogger(__name__) — outside the soulsync.* namespace where handlers attach —
so every prep log line (including failures) vanished from app.log. Moved to
get_logger("streaming.prepare") + a regression test locking the namespace.

34 streaming tests pass; ruff clean; web_server compiles; JS syntax-checked.
2026-06-06 12:43:20 -07:00
BoulderBadgeDad
69fc21d6b2 #767-2: reorganize finds the right album edition instead of mislabeling singles as deluxe
A single enriched against the deluxe gets every source ID pointing at the deluxe,
so the organizer filed it as e.g. track 2 of a 10-track album. Root cause: the
canonical resolver only ever scored the editions already linked — the correct
single was never even a candidate, and the misfit deluxe scored so low (0.1,
below the 0.5 floor) that nothing got pinned and the priority-walk grabbed the
deluxe anyway.

Fix, in three tested layers:
- resolve_canonical_for_album gains a fetch_alternates seam: when no linked
  edition clears the floor, it scores the source's OTHER editions of the same
  release and re-picks by best fit (dedup, injected, pure).
- default_fetch_alternates lists the artist's editions and keeps the same-release
  ones (edition-blind name match: Deluxe / - Single / [Remastered] all collapse),
  returning their tracklists. Favors recall; the scorer is the precision gate.
- _resolve_source does the misfit check inline: it fit-scores the walked edition
  and only on a clear misfit searches for a better edition, then persists the pin
  on apply (Track Number Repair + future runs agree). Cost-neutral and behavior-
  identical for well-fitting albums (no extra API calls); strict_source and the
  #758 manual lock are never overridden.

Tests: +4 resolver (expand/no-expand/dedupe/back-compat), +7 alternates (name
matcher + fetcher over fake APIs + cap), +3 organizer end-to-end (misfit->single
+pin, well-fit->no-expand, strict->no-expand). 300 passed across the reorganize
+ canonical family, lint clean.
2026-06-06 10:53:13 -07:00
BoulderBadgeDad
2921e80d58 Spotify: log WHY a request was skipped, not a catch-all "Not authenticated"
User report: "Not authenticated with Spotify" daily + workers paused. The
log was misleading — is_spotify_authenticated() returns False for five
distinct reasons (no creds / rate-limit ban / post-ban cooldown / no
cached token / probe failure), and all five API call sites logged the
same bare "Not authenticated with Spotify". So a routine rate-limit ban
read as a logout, and the real cause (logged only at DEBUG) was invisible.

New pure describe_spotify_unavailable() maps the real state to a clear
message (priority matches is_spotify_authenticated): not-configured →
rate-limited ("ban ~Nm left (not a logout)") → post-ban cooldown →
no-token ("not connected — re-authenticate") → "auth check failed (token
refresh may have failed)". A side-effect-free client method
(_auth_unavailable_reason) gathers the live state (reads the cached token,
no API probe) and the 5 sites log it.

Now a daily ban is identifiable as a ban, and a genuine logout is
identifiable as one — so reports like this are diagnosable from the log
alone. 7 tests pin the priority/messaging. Full suite clean.
2026-06-06 09:40:52 -07:00
BoulderBadgeDad
e5e56f3d06 Bridge the Spotify worker to Free when the daily budget is spent (don't pause)
#798 follow-up. The worker's 500/day budget is a REAL-API ban shield, but
when it was hit the worker paused outright — even for a Spotify-Free user
with the uncapped free source available. So "I'm on Spotify Free" still
got capped overnight. The intuition is right: if it's ever using Spotify
Free, the budget shouldn't apply.

Fix: spent budget now becomes a third "use free" trigger (alongside
no-auth and rate-limited). When the real-API budget is exhausted and the
free source is available, the worker switches to free (uncapped) for the
rest of the day instead of pausing, then reverts to real-first on the
daily reset.

- should_use_free_fallback gains a budget_exhausted arg (free activates on
  no-auth OR rate-limited OR spent-budget).
- the worker sets _budget_exhausted_use_free on ITS OWN client (a separate
  instance from the search client — verified, so user searches still use
  real auth), and clears it when the budget resets; _free_active() honors
  the flag.
- get_stats() using_free reports the budget-bridge too, and the dashboard
  bubble shows "Running (Spotify Free)" instead of "Daily Limit Reached"
  (budgetStuck = exhausted AND not bridging).

A no-free user still pauses on the budget (nothing to bridge to). A pure
free-only worker never increments the budget at all. New gate test pins
the budget_exhausted trigger. Full suite clean.
2026-06-06 08:51:30 -07:00
dev
37140dff34 Downloads: opt-in last-resort acceptance of repeated version mismatches
Some tracks don't exist on the sources in the wanted cut — every copy is, say,
the instrumental. The retry engine correctly rejects each (version mismatch) and
gives up, leaving the track missing. New opt-in fallback: once a track's AcoustID
retries are fully exhausted, if every quarantined candidate for it failed the
SAME version mismatch (same matched version, e.g. all instrumental) and there are
>= N of them, accept the best (first-tried = oldest = highest-confidence) one.

Safety rules (core/imports/version_mismatch_fallback.py):
- Version mismatches only. Audio/artist mismatches (different recording) and
  integrity/duration failures (truncated/wrong file) never participate.
- All qualifying entries must share the same matched version; a mix
  (instrumental + live) is ambiguous → no acceptance.
- Re-import bypasses ONLY the AcoustID gate; integrity/duration/bit-depth still
  run, so a truncated or genuinely wrong file is never let through here.
- Reuses the existing quarantine approve_quarantine_entry + re-verify dispatch.

Wired at the AcoustID give-up point in the verification wrapper. Two new
post_processing settings surfaced in the Retry Logic tile (default off):
accept_version_mismatch_fallback + version_mismatch_min_count.

Pure decision core + orchestration covered by tests (11). Acceptance logged at
WARNING with track + matched version.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:44:14 +02:00
dev
6cb5d455f9 MusicBrainz: alias trust-gate evaluates MB-score leader, not combined leader
The cross-script alias bridge (#442/#586) silently returned [] for some
artists ("Sawano Hiroyuki"). Root cause: the mb-only escape — built exactly
for the case where local string similarity is ~0 (romaji↔kanji) but MB's own
score is decisive — inspected scored[0], the COMBINED-score leader. When an
unrelated same-script decoy outranks the real artist on combined score (decoy:
sim 0.82 + mb_score 83 → combined 0.82, just under the 0.85 bar; real '澤野弘之':
sim 0 + mb_score 100 → combined 0.30, sorted last), the gate saw the decoy's
mb_score 83 (< 95), failed both paths, and cached an empty alias result.
Verification then scored the kanji artist 0% against the romaji expected name
and quarantined every correct file.

Evaluate the MB-SCORE leader independently of combined ranking for the mb-only
escape, and pull aliases from whichever entity actually passed (combined leader
for the combined path, MB-score leader for the mb-only path). The unambiguity
check now compares the top two raw MB scores. Same-script and single-result
paths are unchanged (regression-guarded by the existing #442/#586 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:44:14 +02:00
dev
70732ad80e Downloads: lazy multi-query quarantine retry — exhaust all queries per source
The cached-first retry (8d98b755) abandoned a source after a single query:
the first run returns as soon as ONE query starts a download, so
cached_candidates held only that query's results. On a quarantine retry the
whole source was then excluded from re-search (via searched_sources), so the
later queries (e.g. "artist + album") never hit that source again — it jumped
to the next source after one query instead of exhausting all queries per
source.

Track searched QUERIES (searched_queries) instead of whole sources. A
quarantine retry now skips only the already-run queries (their candidates are
walked via cached-first) and still searches the not-yet-run queries against the
same source. Budget-exhausted sources (exhaustive mode) stay excluded, so the
source switch still fires when a source is genuinely spent.

Removes the now-dead searched_sources state (written but no longer read).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:44:14 +02:00
dev
07ca7eacfa Downloads: cached-first quarantine retry — stop re-searching the same source
Each AcoustID/integrity quarantine retry re-ran the FULL search (all queries,
all sources) before picking the next-best candidate — so a track that failed
verification a dozen times re-queried Soulseek a dozen times (~3 min/cycle in
the field). The next-best pick was already sitting in cached_candidates.

Now the monitor flags the re-queue as a quarantine retry; the worker walks the
already-found candidates first (skipping used + budget-exhausted sources) and
hands them straight to the download path — no search. A source is searched
exactly once: once its candidates are cached, later quarantine retries exclude
it (searched_sources) so the hybrid chain falls through to a not-yet-searched
source instead of re-querying the spent one. Fresh downloads and the monitor's
dead-connection/stuck retries clear searched_sources and search fresh, so the
only re-search is for a genuinely new source or a dead peer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:44:14 +02:00
dev
5e0f86c5f5 Downloads: exhausted source switches to next source instead of failing
In exhaustive retry mode, a source that spent its whole per-source budget
(query_count × retries_per_query) gave up and failed the track outright —
never trying the other configured sources. For tracks where Soulseek has a
deep pool of wrong peers (e.g. an AcoustID title mismatch every copy shares),
the budget tripped long before HiFi/Tidal/… were ever reached.

Now, when a source's budget is spent, the monitor marks it exhausted on the
task and re-queues so the worker excludes it from the next hybrid search,
falling through to the next source in the chain. Each new source spends its
own fresh budget. The task only fails once no fallback source remains (or the
absolute total ceiling trips) — single-source mode still fails immediately,
since there's nothing to fall back to.

task_worker folds the exhausted-source set into both the orchestrator search
exclusion and the hybrid-fallback source list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:44:14 +02:00
dev
f3d43f385e Downloads: per-source exhaustive retry budget on mismatch (opt-in)
Adds an opt-in exhaustive mode to the quarantine-retry path. Default
behaviour is unchanged: a single global cap (MAX_QUARANTINE_RETRIES=5).

When post_processing.retry_exhaustive is on, each source gets its OWN
retry budget sized as query_count x retries_per_query. Soulseek peers
collapse to one 'soulseek' bucket; streaming plugins keep their name.
The worker now records query_count on the task; the budget scales with
the track's real query count. Loop protection is threefold: per-source
cap, used_sources exhaustion (the natural terminator), and an absolute
ceiling (MAX_TOTAL_QUARANTINE_RETRIES=100).

New settings (config + WebUI): retry_next_candidate_on_mismatch (master),
retry_exhaustive, retries_per_query (default 5).

Tests: 6 new cases covering per-source budgeting, source separation,
Soulseek-peer bucketing, query_count default, and the absolute ceiling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:44:14 +02:00
dev
e83cf19903 Downloads: retry next-best candidate on AcoustID/integrity quarantine
When a downloaded file is quarantined because AcoustID verification or the
integrity/duration check fails, the task no longer dead-ends as failed — it
re-runs the worker on the next-best candidate, skipping the quarantined source.

Reuses the monitor's existing transfer-error retry machinery (used_sources +
cached_candidates + worker re-dispatch), just triggered from the post-process
verification wrapper's two quarantine branches instead of only on transfer
errors. Universal across sources (Soulseek, HiFi, Tidal, etc.) since all
batch/sync downloads funnel through post_process_matched_download_with_verification.

- monitor.requeue_quarantined_task_for_retry(): marks bad source used, resets
  task to searching, resubmits worker. Guards: manual picks, cancelled tasks,
  missing source id, and a MAX_QUARANTINE_RETRIES=5 loop cap.
- Opt-out via post_processing.retry_next_candidate_on_mismatch (default on).
- Manual quarantine approve is unaffected (_skip_quarantine_check='all' bypasses
  the checks, so no quarantine flag, so no retry).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:44:14 +02:00
BoulderBadgeDad
2d2ee34df8 #758: a manual album match pins + locks the canonical version
Users manually match an album to the regular edition, but enrichment/
repair keeps treating it as the deluxe (missing songs, renumbered tracks).
Root cause: an album has TWO identities — the enrichment match
(spotify_album_id, which manual-match sets and the worker already honors)
and a SEPARATE canonical version pin (canonical_album_id, added by #777).
The canonical pin is what track-number repair / reorganize / missing-track
detection actually read, and library_manual_match never wrote it — so it
was resolved independently and landed on the deluxe edition.

(So #777 did NOT solve #758: it added canonical pinning, but manual
matches didn't write the pin.)

Fix: a manual ALBUM match on a canonical-recognised source now also pins
AND locks the canonical version to the chosen release:
- new canonical_locked column (same migration pattern as the other
  canonical cols).
- set_album_canonical(..., locked=False) gains an atomic WHERE-clause
  guard: an auto write can't overwrite a locked pin; a manual write
  (locked=True) always wins. get_album_canonical exposes `locked`.
- library_manual_match pins canonical for album matches via the pure
  should_pin_manual_canonical(entity_type, source).

The auto resolve job already skips already-pinned albums, so the lock is
protected on two fronts; the new guard also covers any future
re-resolution. A new manual match still overrides.

18 tests: the pure gate (+ a sync-invariant test vs _ALBUM_ID_COLUMNS)
and the DB lock seam (auto can't clobber a manual lock; manual overrides;
auto-over-auto still works). Additive — locked defaults False, so the
auto path is unchanged unless a manual lock exists. Full suite clean.
2026-06-05 23:28:19 -07:00
BoulderBadgeDad
12038bde08 Let the Spotify worker resume during a rate-limit ban when Free can bridge
#798 follow-up. The enrichment worker's own loop already bridges to the
no-creds Spotify Free source during a rate-limit ban (its guard checks
is_spotify_metadata_available()). But the resume button's pre-check
(_spotify_resume_pre_check) blocked resume on ANY rate-limit with no
awareness of Free — so a Free-opted-in user who got rate-limited was
locked out of restarting the worker, unable to fall through to the free
API.

Fix: the resume guard now mirrors the worker. Block only when
rate-limited AND nothing can serve (plain auth, no Free) — where resuming
would just sleep out the ban. When Free is available it serves during the
ban (is_spotify_authenticated() is False while banned, so
is_spotify_metadata_available() reports the free source), so resume is
allowed and the worker bridges via Free, then returns to real auth once
the ban lifts. Stays real-API-first; Free is only the bridge.

The rule is pinned in a pure helper should_block_rate_limited_resume()
next to the other gate functions, with 3 tests. Full suite clean (only
pre-existing soundcloud /app env failures remain).
2026-06-05 21:50:08 -07:00
BoulderBadgeDad
4039ec3573 #800: Write Tags must not overwrite a real file value with a placeholder
A mis-grouped library track sits under a 'Various Artists' / '[Unknown
Album]' record while the file itself is correctly tagged. Write Tags
reads the DB and stamped that junk over the file — destroying the
correct tags. (Rematching never helped: a match only stores a source-ID
pointer, it never changes the local name/title the writer reads.)

Guard: never replace a real file value with a placeholder. Added at both
seams so preview and write agree:
- build_tag_diff marks such a field protected/no-change (the preview no
  longer shows a wrong overwrite, and has_changes reflects reality),
- write_tags_to_file reads the file's current values and skips the
  placeholder-over-real fields, preserving the file.

Field-agnostic and direction-safe: the guard fires ONLY when the DB
value is a placeholder AND the file holds a real one. A legitimate value
still writes — including a genuine 'Various Artists' album artist on a
real compilation, where the file has no conflicting real value, so the
guard doesn't fire. Every write_tags_to_file caller writes DB->file as a
correction, so blocking placeholder-over-real is correct for all of them
(the download post-process uses a different path, embed_source_ids).

23 new tests (placeholder detection, the guard fn, build_tag_diff on the
screenshot-#2 scenario, end-to-end FLAC write preserving real values and
still overwriting with real ones). 113 existing repair/retag/tag tests
pass.
2026-06-05 20:07:03 -07:00
BoulderBadgeDad
79a30c055a Run auto-reconcile as a scan phase inside the running window
The post-scan reconcile previously ran AFTER the worker's 'finished'
signal, which flips db_update_state status to 'finished'. Automations
wait for a scan by polling that status, so they stopped waiting before
the reconcile ran — and the dashboard/Tools card showed "Completed" then
flipped to "Reading file tags…". For incremental scans this was
invisible (sub-second); for a full refresh it was a real gap (a chained
automation would fire minutes before the IDs were filled).

Fix: the worker now routes completion through _emit_finished(), which
runs self.post_scan_hook (the reconcile) FIRST, then emits 'finished'.
The hook is injected by the web layer (it owns path resolution). So:

- status stays 'running' through the reconcile,
- the reconcile pushes its phase ("Reading file tags for N new tracks…")
  and per-track progress through the SAME db_update_state callbacks the
  scan already uses — so automations, the dashboard card, and the Tools
  page all see it for free and wait for it,
- 'finished' is emitted exactly once, AFTER the reconcile — race-free, no
  status blip a poll could catch,
- best-effort: a hook exception never blocks 'finished', so a scan can't
  get stranded as perpetually 'running'.

Both scan entry points (_run_database_update_task, _run_deep_scan_task)
set the hook before run()/run_deep_scan(); the redundant post-run calls
are removed.

5 ordering tests pin the contract (hook-before-finished, finished still
fires without a hook, hook exception doesn't block finished, hook gets
the worker). Full suite clean (only pre-existing soundcloud /app env
failures remain).
2026-06-05 19:10:31 -07:00
BoulderBadgeDad
83c1cd92aa Auto-reconcile embedded IDs for new tracks on library scans
Extends the manual "Import IDs from File Tags" backfill so newly-scanned
files get their embedded provider IDs pulled into the DB automatically —
no button press needed to keep up with new music.

How it works:
- insert_or_update_media_track now returns 'inserted' / 'updated' / False
  (truthy-compatible; existing `if track_success` callers unaffected) so
  the scan worker can tell a genuinely new row from an update.
- DatabaseUpdateWorker collects the ids it newly INSERTED this run
  (self._new_track_ids) across all insert paths (Plex/Jellyfin/deep).
- After run()/run_deep_scan(), web_server calls _reconcile_after_scan(),
  which gap-fills embedded IDs for just those new tracks. Runs as a
  post-scan pass (the scan loop itself is untouched/fast — the media
  server API never exposes these custom IDs, so the file must be read
  once regardless; batching at the end keeps it out of the hot loop and
  best-effort so it can never abort a scan). A progress phase ("Reading
  file tags for N new tracks…") surfaces the full-refresh tail.

Shared engine:
- New reconcile_library() in core does the paging + lazy parent-map
  loading (only loads albums/artists actually referenced — cheap when
  scoped to a few new tracks) + per-page commits. BOTH the manual button
  and the scan hook call it, so there's one tested orchestration, no
  duplication. The backfill job was refactored onto it.

Same hardened safety: gap-fill only, atomically guarded against
overwrite, schema-introspected, idempotent. Scoped to new arrivals for
incremental/deep; full refresh re-inserts everything as new (recovering
the IDs a full-refresh wipe destroys).

+10 reconcile tests (reconcile_library scope/idempotency/progress/stop +
the engine). Full suite clean (only pre-existing soundcloud /app env
failures remain).
2026-06-05 18:31:11 -07:00
BoulderBadgeDad
e6d86dea26 Add "Import IDs from File Tags" backfill — gap-fill provider IDs from embedded tags
Files SoulSync (or MusicBrainz Picard) already tagged carry Spotify /
iTunes / MusicBrainz / Deezer / Tidal / AudioDB / Genius / Last.fm IDs in
their metadata. Enrichment workers gate their queues on
{provider}_match_status IS NULL, so reading those IDs back and gap-filling
the {provider}_id + match_status='matched' columns lets the workers skip
the API lookup entirely — big API savings on an already-tagged library.

New manual job in Tools -> Database & Scanning ("Import IDs from File
Tags"): scans every library file, reads embedded IDs, fills any that are
missing in the DB. Background job + progress card, mirroring the
write-tags-batch pattern.

core/library/embedded_id_reconcile.py (pure + tested):
- plan_reconcile(): gap-fill plan for a track + its album + artist. Only
  empty id columns are planned; a disagreeing embedded id is a conflict,
  never applied.
- apply_reconcile_plan(): one guarded UPDATE per id column —
  WHERE id=? AND (col IS NULL OR col=''). The guard makes the fill atomic:
  if an enrichment worker matched the same entity between our read and
  this write, the UPDATE affects 0 rows instead of clobbering it. Columns
  are introspected so a schema missing a provider's columns is skipped.
- reconcile_track_row(): per-track orchestration (id extraction, plan ->
  apply, keeping the in-memory parent maps fresh for sibling tracks).

Job hardening: paged track scan (bounded memory), per-page commits (don't
starve concurrent workers), per-file try/finally (one bad file can't abort
the run), counters from real rowcount.

Scope: 19 column-fills across 8 providers. MB *recording* (track) id is
left out (UFID frame the reader doesn't surface; Vorbis key ambiguous) —
MB album+artist are covered. Amazon/ASIN deliberately excluded (ASIN is a
different namespace than the worker's amazon_id). All target columns
verified against the live schema.

Purely additive: new module, two new endpoints, one new Tools card —
no existing behavior changed. 20 unit tests (incl. the concurrency guard).
Full suite clean (only pre-existing soundcloud /app env failures remain).
2026-06-05 17:52:29 -07:00
BoulderBadgeDad
2604704a27 #797: stop AcoustID quarantining correct non-English-artist downloads
AcoustID returns a recording's title/artist in their ORIGINAL script
(e.g. "久石譲" for Joe Hisaishi) while SoulSync's expected metadata is
romanized/English. A correct download then fails verification on two
walls: the title can never clear the 0.70 similarity bar cross-script,
and the only skip path that ignores the title required a near-perfect
0.95 fingerprint plus a resolved alias. Result: every non-English
artist trips it. Two complementary fixes, per the reporter's two ideas.

Graceful fix (automatic):
- New pure core/matching/script_compat.py detects when two strings are
  in genuinely different writing systems (CJK/Hangul/Cyrillic/Greek/
  Arabic/Hebrew/Thai vs Latin). Accented Latin (Beyoncé, Sigur Rós)
  stays Latin — no false trigger.
- acoustid_verification.py: when the EXPECTED artist and the matched
  artist span scripts AND the artist is confirmed via the existing
  MusicBrainz alias bridge, SKIP instead of quarantine, without the
  0.95 floor (the 0.80 trust floor already gates the fingerprint).
- Deliberately narrow: keyed on the ARTIST spanning scripts + being
  confirmed. A same-script artist with only a cross-script title keeps
  the stricter 0.95 floor, so the #607 wrong-file protection (Kendrick
  R.O.T.C, low-fingerprint Japanese-title) is untouched.

Per-request toggle (manual escape hatch):
- New "Skip AcoustID verification" checkbox in the download-missing
  modal beside "Force Download All".
- skip_acoustid threads request -> batch -> per-track track_info ->
  download context (same path as _playlist_folder_mode), landing on
  the existing _skip_quarantine_check='acoustid' bypass. No new
  mechanism; only the AcoustID gate is bypassed (integrity/bit-depth
  still run).

Tests:
- tests/matching/test_script_compat.py — script-boundary cases.
- test_acoustid_skip_logic.py — Joe Hisaishi SKIPs at 0.85; unconfirmed
  cross-script artist still FAILs; same-script low-fingerprint still
  FAILs.
- test_downloads_candidates.py — toggle injects the bypass; absent
  toggle keeps verification.

Full suite: 5169 passed; only pre-existing soundcloud /app env failures
remain. Zero regressions.
2026-06-05 16:02:01 -07:00
BoulderBadgeDad
4249856984 #798: make Spotify Free opt-in (not auto-bridge) + clearer help text
Per the cleaner model: the free source only runs for users who explicitly picked
'Spotify Free' — not for every connected user. _free_wanted() is now just
_free_selected() (dropped the has-credentials auto-trigger). So:
- Plain 'Spotify' user, rate-limited -> waits out the ban as before (no surprise
  background scraping, no ToS exposure for people who never chose free).
- 'Spotify Free' user, no auth -> free serves.
- 'Spotify Free' user who also connects an account -> official when healthy,
  free bridges only during a rate-limit, then switches back.

Rewrote the metadata-source help text as a plain per-source list with a clear
note on how Spotify Free + a connected account interact. Gate tests updated to
pin the opt-in behavior (plain-Spotify ratelimit = no bridge; Spotify-Free
ratelimit = bridge).
2026-06-05 15:10:18 -07:00
BoulderBadgeDad
217a5eda70 #798: Spotify Free as a real dropdown source + automatic rate-limit bridge
Consistency fix: Spotify Free is now its own entry in the metadata-source
dropdown (alongside Spotify / iTunes / Deezer / MusicBrainz) instead of a
side-toggle. Stored as fallback_source='spotify' + spotify_free=true so all
downstream 'spotify' routing and the spotify_* columns are unchanged.

Refined gate model (no toggle):
- Connected user (has credentials) -> official; bridges to free AUTOMATICALLY
  during a rate-limit ban (no opt-in needed).
- No-auth user -> must pick 'Spotify Free' in the dropdown; then free serves.
- Never opted into Spotify (no creds, didn't pick it) -> free never runs, so no
  surprise scraping. _free_wanted() = has_credentials OR picked-spotify-free is
  the guard.
- AUTHED + healthy -> official always; free never opens.

UI: dropdown gains 'Spotify Free (no credentials)' (selectable when the package
is installed — surfaced via status.free_installed, since selecting it is the
opt-in and can't depend on having selected it); load/save map the dropdown value
to the (fallback_source, spotify_free) pair; old checkbox removed.

Gate model pinned by 6 scenario tests (connected/healthy, connected/ratelimited
bridge, no-auth picked, no-auth not-opted-in, package-missing). 117 tests green.
2026-06-05 14:23:28 -07:00
BoulderBadgeDad
0eff9e3708 #798: Spotify Free metadata mode — backend (opt-in, shared spotify tables)
Adds an opt-in no-creds Spotify metadata path: SpotifyClient serves SpotipyFree
data (real Spotify IDs) when metadata.spotify_free is enabled AND SpotipyFree is
installed AND there's no real Spotify auth. The data lands in the SAME spotify_*
columns, so the enrichment worker, search, and #775 lookups work UNCHANGED —
they just receive free-sourced data. The worker's only change is its availability
gate.

- core/spotify_free_metadata.py: SpotifyFreeMetadataClient + normalize_artist +
  pure gate fns (should_use_free_fallback / should_offer_spotify_metadata /
  spotify_free_installed).
- SpotifyClient: _free_enabled (opt-in, default OFF) / _free_available /
  is_spotify_metadata_available / _free_active + in-client routing for
  search_artists/tracks + get_album/artist/track_details/album_tracks/artist_albums.
- 3 scoped availability gates use is_spotify_metadata_available(): enrichment
  worker loop, search resolve_client, watchlist. The ~40 discovery/user-library
  sites stay auth-only (no sprawl, no user-data risk).

Authed users are untouched (gate closed when auth healthy). UI surfacing comes
next. Pure gates + normalizer tested; orchestrator resolve test added.
2026-06-05 13:32:37 -07:00
BoulderBadgeDad
ad1bd97aac Fix MB-worker alias test fixtures missing self.db
The #799 uniqueness-guard added a source_id_conflict(self.db, ...) call to the
MusicBrainz worker's artist path. Two TestWorkerAliasEnrichment fixtures build
the worker via __new__ and set only .database, not .db, so the new call raised
AttributeError and the artist was marked 'error'. Mirror the third fixture
(which already sets worker.db). Production always sets self.db in __init__ —
test-only gap exposed by the new code path.
2026-06-05 13:24:21 -07:00
BoulderBadgeDad
0f3c374137 #799: add equivalence proof that should_rediscover == original pre-scan
Enumerates all 64 flag combinations and asserts should_rediscover matches the
verbatim pre-refactor inline logic for every case except the one intended fix
(manual_match beating a stale wing_it_fallback). Guarantees the auto Playlist
Pipeline behaves identically post-refactor — no regression.
2026-06-05 11:17:49 -07:00
BoulderBadgeDad
c20150e370 #799: stop mirrored discovery from reverting a manual fix to Wing It
A manually-fixed mirrored track silently reverted to 'Wing It' after re-running
discovery. Two compounding causes:

- extra_data is MERGED on save (update_mirrored_track_extra_data), and the
  manual-fix DB write (web_server.py) didn't clear the prior wing_it_fallback
  flag — so a track fixed after being a Wing It stub kept wing_it_fallback=True.
- the Playlist Pipeline pre-scan checked wing_it_fallback BEFORE manual_match
  (if/elif), so the stale flag won: the track was re-discovered and, on a miss,
  fell back to Wing It — discarding the user's pick.

Fix: extracted the pre-scan gate into core.discovery.manual_match.should_rediscover
(manual_match checked FIRST = authoritative, regardless of leftover flags), and
the manual-fix write now also clears wing_it_fallback/unmatched_by_user. Behavior
is identical for every other branch — only the manual-vs-wing-it ordering changes.

Tested at the seam incl. the exact regression (wing_it_fallback + manual_match
both set -> skip). 227 discovery tests green.
2026-06-05 11:07:32 -07:00
BoulderBadgeDad
55c9b52aee Auto-repair duplicated source ids on startup (one-time migration)
Ships the source-id cleanup to all users: a marker-gated one-time migration in
MusicDatabase init clears any source id (deezer/spotify/itunes/musicbrainz/
discogs/audiodb/qobuz/tidal) shared across differently-named artists — the
enrichment-corruption signature. Same-name cross-server duplicates are left
untouched (DISTINCT-name check). Cleared rows re-derive correct ids on the next
enrichment pass; the now name-guarded workers won't re-corrupt.

Runs once (CREATE TABLE _source_id_dedupe_v1 marker), idempotent, per-column
try/except so a missing column can't abort it. Test forces a re-run and asserts
corruption is cleared while a legit same-name dup survives.
2026-06-05 10:21:52 -07:00
BoulderBadgeDad
0af99881bf Tighten artist matching: 0.85 gate + shared uniqueness guard
Two complementary fixes to stop distinct artists ending up with the same source
id (the near-name collisions: ODESZA/odessa, Blance/Blanke, Lady A/Lady Gaga,
plus MusicBrainz's combined-score weak matches like Grant/Amy Grant):

- core/worker_utils.accept_artist_match() / source_id_conflict(): one shared,
  tested gate. Rejects artist matches below 0.85 (stricter than the 0.80 used
  for album/track titles, since short artist names false-positive easily) AND
  refuses to store a source id a DIFFERENTLY-named artist already holds. A
  same-named holder (one act across two media servers) is still allowed.

- Routed every artist-match worker through it: deezer, qobuz, tidal, discogs,
  itunes, spotify (its scorer now uses the 0.85 threshold), audiodb, and
  musicbrainz (conflict guard only — its matcher is combined-score, so the
  guard is the net that catches its weak-name matches).

Centralizing in worker_utils avoids the copy-paste that let the original
album/track overwrite bug live in four workers at once. 17 new gate tests.
2026-06-05 10:01:17 -07:00
BoulderBadgeDad
c30e8ce402 Add one-off repair for source ids shared across multiple artists
core/maintenance/dedupe_source_ids.py + scripts/dedupe_source_ids.py: find
source-id clusters held by differently-named artists (the enrichment-corruption
signature) and clear the id + match-status on those rows so the now-name-checked
workers re-derive each correctly on the next enrichment pass. Same-name
duplicates (one artist across two media servers) are left untouched.

Dry-run by default; --apply to write. 8 seam tests cover detection (corrupt vs
legit), dry-run safety, apply behaviour, and the no-op case.
2026-06-05 08:38:55 -07:00
BoulderBadgeDad
85549197e6 Apply artist-id name-guard to audiodb/qobuz/tidal workers too
The blind 'correct the parent artist's source id from an album/track match'
logic was copy-pasted into four enrichment workers; the Deezer fix only covered
one. AudioDB, Qobuz, and Tidal had the identical bug and would corrupt their own
id columns (and re-corrupt after any cleanup).

All three now gate the correction on a name match between the result's artist
and the parent artist (audiodb reads result['strArtist']; qobuz/tidal thread the
result artist name in from their callers, as Deezer does). Regression tests
cover mismatch-skips and match-corrects for each.
2026-06-05 07:47:59 -07:00
BoulderBadgeDad
8ce26d19fa Fix Deezer enrichment stamping one artist id onto multiple artists
Root cause of the duplicate deezer_id corruption: when enriching an album or
track, _verify_artist_id 'corrected' the parent artist's deezer_id to the
search result's primary-artist id whenever they differed — with NO name check.
For a collaboration/compilation track (e.g. one our library credits to Jorja
Smith that lives on Kendrick Lamar's curated 'Black Panther' album), the result
resolves to Kendrick's album, so Kendrick's id (525046) got written onto Jorja,
Vince Staples, SOB X RBE, etc. — many artists ending up with the same id.

Now the correction only fires when the result's primary-artist NAME matches the
parent artist (the album/track-artist path now mirrors _process_artist, which
already name-checks). Mismatches are logged and skipped as collab/compilation.

Note: this prevents new corruption; existing wrong ids in a library aren't
auto-repaired (per-artist enrichment preserves an existing deezer_id).
2026-06-05 07:32:51 -07:00
BoulderBadgeDad
d2d71d8f05 Fix artist-detail showing wrong artist when a source id is duplicated
A pasted Deezer artist link (or any Deezer-source artist click) opened the
wrong artist's header: deezer_id 525046 is stamped on 4 library rows (Kendrick
+ 3 others — an enrichment-corruption bug), and the library-upgrade lookup did
WHERE deezer_id=? LIMIT 1, grabbing an arbitrary row (Jorja Smith) while the
discography loaded fresh from Deezer (Kendrick) — a Frankenstein page.

find_library_artist_for_source now detects when a source id maps to >1 library
artist and refuses to guess: it skips the id-based upgrade (still allowing the
name fallback), so the caller renders the source artist directly — landing on
the correct artist. Unique ids are unaffected (no regression).

The underlying enrichment bug that writes one source id onto multiple artists
is separate and still worth a follow-up.
2026-06-05 07:24:51 -07:00
BoulderBadgeDad
06f01e29e8 #775: add artist links (paste an artist URL -> opens the artist)
Spotify/Apple/MusicBrainz/Deezer artist links now resolve via each source's
get-by-id (get_artist / Deezer get_artist_info), shaped to the artist card and
rendered as an artist result that opens the artist detail page through the
existing flow. Album/track link handling is unchanged; bare IDs still rejected.
2026-06-05 06:55:35 -07:00
BoulderBadgeDad
e05979ea07 #775: links only — reject bare IDs (ambiguous), add not-found hint
Follow-up to the bare-ID footgun: a bare number like 525046 carries no
source and no entity type, so it resolved to whatever album happened to own
that id (a user pasting Kendrick's Deezer artist id got an unrelated album).

Now the resolver accepts provider URLs (and the explicit spotify: URI) only;
a bare/unrecognized string is rejected and the dropdown surfaces a hint to
paste a full link. URL parsing + album/track resolution are unchanged.
2026-06-05 06:44:01 -07:00
BoulderBadgeDad
9772d5313c Add #775: resolve a pasted metadata link/ID instead of searching
New 'Link / ID' input on the Search page: paste a Spotify / Apple Music /
MusicBrainz / Deezer URL (or a bare ID) and it's looked up directly on the
owning source — no fuzzy search, no scoring.

- core/search/by_id.py: source-agnostic parser (URL domain/path or bare-ID
  format -> source,kind,id; numeric IDs fan out, first hit wins) + per-source
  get-by-id dispatch + adapters projecting each provider's dict onto the
  standard album/track card shape.
- /api/enhanced-search/by-id: thin additive route over resolve_identifier.
- Frontend: dedicated input that adopts the resolved source as active and
  renders through the existing dropdown + download/import flow.

Purely additive — existing files are insertion-only; the resolver runs only
behind the new route. 29 seam tests cover parsing, shaping, fan-out, and
not-found.
2026-06-05 06:32:45 -07:00
BoulderBadgeDad
1590330171 Fix #796: Soulseek album bundle left completed files in slskd download folder
The album-bundle path COPIES slskd's completed files into private staging (then
on to the library) but never removed slskd's originals, so they piled up in the
download folder. (copy, not move, is correct for the torrent/usenet bundle paths
— those clients keep seeding — so the shared copier can't just always delete.)

Add an opt-in remove_source to copy_audio_files_atomically that deletes each
source ONLY after it copies successfully (never on a failed stage), and set it
for the Soulseek path only. Torrent/usenet keep their originals.

Tests: keeps source by default / removes when requested / keeps on failed copy.
2026-06-04 21:56:07 -07:00
BoulderBadgeDad
6cb753e7a1 Fix #785: file/CSV playlists fail to match (raw 'Artist - Title' titles)
#768 added canonical_source_track to the live-sync matcher and the playlist
editor reconcile, but NOT to the two paths that actually run for file/CSV
mirrored playlists: the discovery worker (core/discovery/playlist.py) and the
DB-only matcher (core/discovery/sync.py). YouTube playlists are cleaned at
ingest, so they matched; file playlists fed the raw 'Arctic Monkeys - Do I
Wanna Know?' title into search+scoring and never matched the library's clean
'Do I Wanna Know?' → reported missing / shown as 'extra'.

Add a conservative canonical best-of to both: score with the raw title AND the
canonicalized one, keep the better. canonical_source_track only strips an
'<artist> - ' prefix when it equals the artist, so it can only add a candidate.

Tests: _canonical_best_score seam (file-style match / clean title scored once /
keeps original when better).
2026-06-04 21:13:23 -07:00
BoulderBadgeDad
65c9948c78 Fix #792: reconcile sync mode was clamped back to 'replace' in the backend
start_playlist_sync validated the resolved mode with 'if sync_mode not in
(replace, append): sync_mode = replace' — a pre-existing clamp two lines below
the config read I added, which silently downgraded a configured 'reconcile' to
'replace'. So config=reconcile resolved correctly then got clobbered, and every
sync ran replace regardless of the setting (and incognito didn't help — it's
backend, not cache).

Replace the hand-rolled clamp with a pure normalize_sync_mode(requested,
configured) helper (VALID_SYNC_MODES includes reconcile) so the resolution is
testable and can't silently drop a mode again. Regression tests cover
reconcile-from-config, request-overrides-config, and unknown->replace.
2026-06-04 15:57:40 -07:00
BoulderBadgeDad
939c660498 Fix #792: 'reconcile' playlist sync mode (edit in place, keep image/description)
Replace mode (default) deletes + recreates the server playlist every sync,
which wipes its custom image, description, and identity. Add an opt-in
'reconcile' sync mode that edits the existing playlist in place — adds the
tracks now in the source, removes the ones gone — without destroying the
object, so the user's custom art/description survive.

- Pure planner plan_playlist_reconcile(current, desired) -> {add, remove}.
- Per-client reconcile_playlist: Plex addItems/removeItems on the same object;
  Navidrome Subsonic updatePlaylist delta (songIdToAdd / descending
  songIndexToRemove); Jellyfin add + remove-by-PlaylistItemId on /Playlists/{id}/Items.
- sync_service: reconcile branch with a replace FALLBACK (if a server's in-place
  edit is unavailable/fails, sync still succeeds destructively — logged loudly).
- Default stays 'replace' (no behavior change). New Settings > Playlist sync mode
  picker (replace/reconcile/append) backed by playlist_sync.mode; per-request
  sync_mode still overrides.
- Reconcile skips the post-sync source-image push so a custom poster isn't
  re-clobbered (the bug).

Tests: planner (add/remove/dedupe/order/empty) + reconcile-or-replace dispatch
(success / false-fallback / exception-fallback / no-method). Per-server in-place
API calls need dev validation against real Plex/Jellyfin/Navidrome.

NOTE: opt-in only; default behavior unchanged.
2026-06-04 15:15:49 -07:00
BoulderBadgeDad
79c907cfcb Cover Art Filler: honor the configured cover-art sources too
Consistency follow-up: the Filler picked art via metadata source-priority +
its own prefer_source knob, ignoring metadata_enhancement.album_art_order —
the explicit cover-art source list that post-process embed and the Library
Re-tag job now use. So 'cover art sources' meant two different things.

Prefer select_preferred_art_url (the configured order) first; fall back to the
existing prefer_source / source-priority loop when no order is configured
(the default) — non-breaking, existing behavior unchanged for those users.
Help text updated. Test: configured order wins + skips the source loop.
2026-06-04 14:50:40 -07:00
BoulderBadgeDad
93d2399cf8 Library Re-tag: pull cover art from the configured cover-art sources
User feedback (Sokhi): after changing cover-art sources, re-tag should
re-download fresh covers from THEM. The job took cover_url only from the
matched metadata source's album image, ignoring the user's configured
cover-art order. Now prefer select_preferred_art_url (the same
metadata_enhancement.album_art_order the post-process embed honors), falling
back to the source image when no order is configured (non-breaking).

'replace' cover mode already force-refreshes art on every matched album, and
the embed replaces existing art (no duplicate pictures) — so 'replace' + a
configured art order = fresh covers from those sources. Help text updated.

Tests: prefers configured source URL / falls back to source image when unset.
2026-06-04 14:46:09 -07:00
BoulderBadgeDad
3b155411c2 Fix #787: Find & Add now records a durable manual match that survives a rescan
Find & Add on the playlist-sync page only wrote sync_match_cache, which is
DELETEd wholesale after every DB scan — so the source->library pairing (and
the user's manual matches) reverted to 'extra'/red-dot on the next shallow
scan. The three match stores (sync_match_cache, manual_library_track_matches,
discovery extra_data) were disconnected and all pointed at tracks.id, which a
rescan re-keys (esp. Jellyfin/Navidrome GUIDs).

Unify the match so it's one durable fact, recorded once, honored everywhere:
- Find & Add also writes a durable manual_library_track_matches row (one-way;
  the manual-match tool has no playlist to act on, so no reverse). Carries the
  library file path.
- New library_file_path column (idempotent migration) + find_track_id_by_file_path:
  re-resolve a stale library_track_id after a rescan re-keys the track, and
  self-heal the row.
- The sync compare display's override lookup now falls back to the durable
  manual match (resolve_durable_match_server_id) when sync_match_cache misses —
  so the pairing persists across a scan instead of reverting to a red dot.
  Purely additive: only adds matches when the cache returns nothing.

Tests: durable resolver (valid / stale-reresolve+self-heal / no-match / not-in-
playlist / missing-methods), file_path persistence + find_track_id_by_file_path.
2026-06-04 13:46:24 -07:00
BoulderBadgeDad
8ffdca3636 Fix CI: drop dead RetagDeps tests + pointless f-string
Follow-on to 07801aeb (the orphaned-file delete committed alone because the
git add aborted on the already-removed pathspec). Removes the two RetagDeps
tests in test_lyrics_reembed_from_sidecar (the dataclass was deleted with the
old Retag Tool) and a no-placeholder f-string in test_library_retag_job.
2026-06-04 12:53:05 -07:00
BoulderBadgeDad
07801aeb5b Fix CI: remove orphaned tests for the deleted retag worker
Removing the old Retag Tool (d91e6a38) deleted core/library/retag.py but left
its tests behind. tests/library/test_retag.py imported the gone module at
module top, so pytest aborted collection of the ENTIRE suite (CI red on every
run). tests/test_lyrics_reembed_from_sidecar.py had two RetagDeps tests for the
same removed dataclass (lazy imports — would fail at runtime once collection
proceeded).

Delete the orphaned test file + drop the two dead RetagDeps tests (the rest of
the lyrics-sidecar file is unrelated and stays). Also drop a pointless
f-string in test_library_retag_job. Suite now collects all 5008 tests.
2026-06-04 12:52:37 -07:00
BoulderBadgeDad
b5d22bede5 Fix #790: torrent client URL without http:// scheme fails to connect
A bare host like '192.168.1.5:8080' or 'qbittorrent.lan:8080' (no scheme)
is what users naturally type, but requests then raises 'No connection
adapters were found for ...' — it can't pick an http/https adapter, and a
bare host:port even gets misparsed as scheme=host. This surfaced as the
generic 'qbittorrent probe failed' with a 'login error: No connection
adapters were found' in the logs.

Add normalize_client_url() in torrent_clients/base: default a missing scheme
to http:// (+ trim trailing slash), and route all three adapters'
_load_config through it. Transmission normalizes the base before appending
/transmission/rpc.

Tests: normalizer unit cases + per-adapter regression (bare host -> http://).

Note: usenet adapters (sabnzbd/nzbget) share the same pattern and need the
same treatment in a follow-up.
2026-06-04 11:57:53 -07:00
BoulderBadgeDad
85b6ddb997 Navidrome: pin music-folder selection by id, not name (survives renames)
Follow-up hardening to #789. The selection was keyed purely by folder name,
so renaming a music folder in Navidrome silently reverted the scan to all
libraries. Now persist the folder id (stable across renames) as the primary
key alongside the name (kept for display + back-compat), and restore by id
first with a name fallback. Self-heals on reconnect: pre-id installs and
drifted/renamed names get the id + fresh name written back, so the settings
dropdown keeps highlighting the right folder.

Tests: restore-by-id-after-rename (+ name heal), name-fallback self-heals id,
no-drift writes nothing.
2026-06-04 11:47:31 -07:00
BoulderBadgeDad
cf655c5009 Fix #789: Navidrome library selection ignored (all libraries imported)
The saved music-folder selection was silently dropped on every reconnect.
_setup_client's restore step called the public get_music_folders(), which
starts with ensure_connection() — but we're already inside ensure_connection()
at that point (_is_connecting=True, _connection_attempted not yet set), so the
re-entrant call bailed and returned []. The restore matched nothing,
music_folder_id stayed None, and the per-call musicFolderId filters all
no-op'd → scans imported every library regardless of the user's choice.
Surfaces after any restart or settings save (reload_config resets the state).

Split get_music_folders() into the public method (does the connection check)
and a non-reentrant _fetch_music_folders() seam; the restore now calls the
seam directly (connection is already established + ping succeeded by then).

Regression + seam tests added.
2026-06-04 11:41:03 -07:00
BoulderBadgeDad
adbdda7b0e Library Re-tag: add light/full depth setting, default source to active, fix dropdown CSS
- depth setting (light = core tags + matched source ids; full = same
  multi-source enrichment cascade a fresh download gets, run additively
  via embed_source_ids). Threaded through scan/finding/auto-apply and the
  repair_worker fix handler.
- source now defaults to 'auto' (= your source priority / active source)
  instead of blank.
- give native <option> popups a solid dark background (were white-on-white).
- tests for full-depth full_meta payload + enrich invocation + light no-op.
2026-06-04 10:21:30 -07:00
BoulderBadgeDad
0a4c3d7dc8 Library re-tag: standard dry-run pattern (shows the Dry Run tag, opt-in auto-apply)
The job was the odd one out — auto_fix=False, no dry_run setting, so it never
showed the 'Dry Run' badge the other jobs do (the badge keys off
settings.dry_run === true). Aligned it to the standard pattern:
- auto_fix=True + dry_run setting defaulting True. Default behavior is unchanged
  (findings only, nothing written) AND it now shows the Dry Run badge.
- Turning dry_run off makes the scan auto-apply in place (result.auto_fixed),
  no finding — the opt-in 'just retag it' mode.
- Extracted a shared apply_track_plans() used by both the scan auto-apply and
  the repair_worker fix handler (handler now resolves Docker paths then
  delegates — one code path, no duplication).
Tests: dry_run=False auto-applies + writes + no finding; existing dry-run
finding/skip/apply tests still green. 410 passing.
2026-06-04 10:06:34 -07:00
BoulderBadgeDad
48debb7926 Library re-tag: seam tests for the job scan, apply handler, and source-id embed
Closes the kettui gap — the orchestration was unproven. Injected-fake seam
tests (temp sqlite + real empty track files, no metadata APIs / no real tag
writes):
- embed_known_source_ids: builds the right canonical id_tags from flat db keys,
  honors the musicbrainz embed gate, no-ops when there's nothing to write.
- library_retag scan: produces a detailed finding with the per-track old->new
  diff + stamped source ids, and skips an album that's already correct.
- _add_source_ids: per-source key mapping.
- _fix_library_retag apply: writes each track's payload, and reports failure
  when files are unreachable.
476 tests pass; ruff clean.
2026-06-04 09:46:35 -07:00
BoulderBadgeDad
d91e6a384d Remove the old Retag Tool (superseded by Library Re-tag job + Write Tags)
The old per-download Retag Tool was limited (only native-pipeline downloads,
100-group cap, manual per-group) and did the wrong thing — it moved/reorganized
files instead of just tagging. It's superseded by the new Library Re-tag job
(whole-library, in-place) + the enhanced-library 'Write Tags' button.

Removed: the post-download record_retag_download ingestion hook (stops writing
retag_groups on every download), core/library/retag.py, the web_server state +
deps + /api/retag/* endpoints + the tool:retag WebSocket emit, the dashboard
card + both modals (index.html), the core.js socket handler, and the tools-page
wiring + help entry (wishlist-tools.js). Updated the import-pipeline test.

Verified: web_server parses, app + core imports OK, 392 tests pass, no live
references to removed symbols.

Left as inert (harmless) for a careful follow-up sweep: the retag_groups/
retag_tracks tables + their DB CRUD methods (no longer written/read), and the
now-orphaned retag JS helper functions (no entry point/wiring/socket calls them;
interspersed with wishlist functions, so not blind-deleted).
2026-06-04 09:33:03 -07:00
BoulderBadgeDad
b0c78c8674 Library re-tag (1/3): pure planner — match source tracklist + per-field tag diff
The testable core for the new library-wide re-tag job. Given a source album's
metadata + tracklist and the library tracks' current file tags, it:
- matches source tracks to library tracks (disc+track number, then title sim),
- computes the per-field diff (old -> new) for the dry-run finding,
- builds the minimal write_tags_to_file payload — only fields that actually
  change under the chosen mode (overwrite vs fill-missing), so applying never
  touches unrelated/unchanged tags.
No IO/network/DB — 10 unit tests cover matching, both modes, blank-source
fields, and the album-artist/track-count payload mapping.
2026-06-04 08:50:40 -07:00
BoulderBadgeDad
405b0988d6 Cover Art Filler: skip files that already have art (keep apply purely additive)
Verification found a non-additive edge: embed_album_art_metadata uses FLAC
add_picture(), which APPENDS — so applying to an album where some tracks already
had art would have added a duplicate embedded picture. The apply now checks each
file and skips any that already carry art (shared _audio_has_art helper), so it
only ever ADDS art to files missing it. Test covers the skip (no re-embed).
2026-06-03 22:17:20 -07:00
BoulderBadgeDad
33965c7cbd Cover Art Filler: detect missing art ON DISK + actually write it to files
Previously the filler only flagged albums whose DB thumb_url was empty and, on
apply, only updated that DB thumb_url — so albums whose files had no embedded
art and no cover.jpg (but whose DB row had a URL) were never found, and even
'applying' art never touched the files. That's the reported 'doesn't scan all
albums' gap.

New core.metadata.art_apply (reuses the post-processing standard so the user's
album_art_order is honored):
- album_has_art_on_disk(): cheap-first check — folder cover.jpg/folder.jpg
  sidecar, then embedded art in a representative track (FLAC/ID3/MP4/Vorbis).
- apply_art_to_album_files(): embeds via embed_album_art_metadata + writes
  cover.jpg via download_cover_art; only ADDS art (never rewrites the user's
  tags); read-only/unwritable files are skipped + counted, never crash.

Scan now examines every titled album and flags it when art is missing in the DB
OR on disk. Apply embeds into the album's audio files + writes cover.jpg in
addition to the DB thumbnail (media-server-only albums fall back to DB-only).

Tests cover sidecar/embedded detection, the cheap-first short-circuit, and the
apply orchestration (embeds each file + cover.jpg; read-only failures counted).
2026-06-03 22:12:56 -07:00
BoulderBadgeDad
80828b86cf Cover Art Filler: validate search results to stop wrong cover art
The title/artist fallback search took results[0]'s artwork unconditionally, so
a loose full-text match returned the wrong album's cover (the 'new sources give
incorrect art' reports). Now it pulls a few results and only accepts one whose
title matches (subset, to allow Deluxe/Remaster) AND whose artist matches
exactly — the artist being the strong guard against wrong covers. Falls back to
an exact title match when a result carries no artist.

The album's own stored source-id path is unchanged (that id is authoritative).
Tests: wrong-artist rejected, skips wrong result for a matching one, + unit
coverage of the matcher (deluxe/feat/stopwords accepted, wrong artist/title
rejected).
2026-06-03 21:56:00 -07:00
BoulderBadgeDad
45f91fd318 Fix: qBittorrent 5.2.0+ login probe fails (HTTP 204 not handled)
qBittorrent 5.2.0 changed /api/v2/auth/login to return HTTP 204 (No Content)
on success instead of HTTP 200 with body 'Ok.'. The adapter required the body
to equal 'Ok.', so every login on 5.2.0+ failed with 'HTTP 204 body=' — the
connection probe and all torrent actions were broken.

Treat login as successful on the SID auth cookie and/or a success body: 'Ok.'
(<=5.1) or an empty HTTP 204 (>=5.2.0). Still reject bad creds, which
qBittorrent reports as HTTP 200 + 'Fails.' (not a 4xx).

Tests: 204-empty -> success, SID-cookie+empty-body -> success, 'Fails.' (even
with a stale cookie) -> failure.
2026-06-03 21:43:11 -07:00
BoulderBadgeDad
a977d28144 Fix #780: Deezer/non-Spotify organize-by-playlist resolved the wrong row
resolve_mirrored_playlist tried the mirrored-playlists primary key FIRST for
any all-digit ref. Deezer upstream ids are all-numeric, so a Deezer playlist id
was mistaken for the PK and the organize-by-playlist toggle resolved a wrong row
(or nothing) — the toggle silently wouldn't save / 'Open in Mirrored' missed.

Resolve by (source, source_playlist_id) first, fall back to PK only when the
source lookup misses. Thread the batch/wishlist source through the download-path
callers so numeric upstream ids resolve correctly there too. Spotify (base62
ids) is unaffected.

Seam tests: numeric Deezer id resolves by source (not PK), spotify alphanumeric
by source, PK fallback still works, profile-scoped, empty refs -> None.
2026-06-03 20:41:04 -07:00
BoulderBadgeDad
0353d365d6
Merge pull request #780 from kekkokk/feature/organize-by-playlist-library
Fix organize-by-playlist: library registration, wishlist after failed downloads, and stale playlist cache
2026-06-03 20:33:18 -07:00
BoulderBadgeDad
f333607d76 Recommendations: explain WHICH of your artists drive each suggestion
Adds get_recommendation_sources() — for each recommended similar artist it
resolves the polymorphic similar_artists.source_artist_id back to the display
names of the user's OWN artists (library + watchlist) that list it, by matching
against every provider-id column on both tables. The /api/discover/similar-artists
endpoint now attaches a 'because' array per recommendation so the UI can show
'because you have X, Y, Z' instead of just a count.

Seam tests cover: library + watchlist resolution across different provider-id
columns, dedup + name-sort, max_per cap, orphan source omission, profile scoping.
2026-06-03 18:23:51 -07:00
BoulderBadgeDad
f883e99feb Fix: MusicMap 404s miscounted as errors in similar-artists worker
The worker's WARNING observability proved the '38 errors' were almost all
MusicMap returning 404 (artist has no map page) — a genuine not-found, not a
fetch failure. But iter_musicmap_similar_artist_events flattened every
RequestException to status_code 502, and the worker maps 400/404 -> not_found
/ everything-else -> error, so these inflated the error count.

Surface the real HTTP status from the exception's response (404 stays 404),
falling back to 502 only when there's no response (timeout/connection drop,
which is correctly still an error eligible for retry).

Regression tests: 404 -> 404 (not_found), timeout -> 502 (error), 500 stays
error, plus an end-to-end worker check that a 404 result marks 'not_found'
and stores nothing.
2026-06-03 18:04:31 -07:00
BoulderBadgeDad
843de8a45e Similar Artists worker: guarantee every stored similar has a source id
Verified against live data: 1312/1313 stored similars carry a metadata source id,
but 1 slipped through name-only (a match on a source with no id column, e.g.
discogs). Enforce the standard: process_artist now SKIPS any similar whose match
doesn't map to a storable id column (spotify/itunes/deezer/musicbrainz) instead
of writing a useless name-only row. Regression test covers discogs-match + no-id
cases. Now 100% of newly-stored similars are actionable.
2026-06-03 16:26:29 -07:00
BoulderBadgeDad
9d308638f0 Similar Artists worker: surface WHY fetches error (observability before tuning)
The kettui move: 38/79 fetches errored on the first live run, but they were
logged at DEBUG only — invisible in app.log, so the cause (rate-limit vs
no-providers vs bug) is unprovable. process_artist now returns a (status, count,
detail) triple carrying the error reason (status code + message / exception),
and the worker logs the first 15 errors per session at WARNING (rest DEBUG) +
keeps _last_error. No blind pacing tweak — let it run, read the real reason, then
fix the proven cause. Seam tests updated + assert the reason is captured.
2026-06-03 16:22:51 -07:00
kekkokk
0b1fdba2a1 Fix standalone mirrored playlist sync and post-sync downloads.
SoulSync standalone matches library tracks without Plex fetchItem,
reports missing counts correctly, and skips server playlist writes.
Automation re-syncs when the mirror grows; after sync finishes, starts
organize download (organize-by-playlist) or wishlist processing.

UI: Spotify URL playlist-folder controls, organize toggle layout in the
discovery modal, reload organize preference when reopening Download Missing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 00:24:00 +02:00
BoulderBadgeDad
89e3486e84 Similar Artists enrichment worker (MusicMap → match → store) for library artists
Closes the gap where similar artists only existed for WATCHLIST artists: a new
background worker populates them for the whole LIBRARY, slotting into the
existing enrichment-worker pattern (bubble + Manage Enrichment Workers modal,
status/pause/resume, matched/not_found/pending/errors).

Per source-matched library artist → get_musicmap_similar_artists(name, 25)
(the same matcher the artist-detail page uses: fetches MusicMap names, matches
each to the user's source chain — primary + active fallbacks — returns only
matched artists) → store via add_or_update_similar_artist keyed by the artist's
metadata source id, the SAME key the watchlist scanner + artist map use, so the
two cooperate (idempotent upsert + retry_days window).

  - core/similar_artists_worker.py: pure seams (pick_source_artist_id,
    map_payload_to_store_kwargs, process_artist) + the threaded worker; skips
    artists not yet source-matched; classifies not_found vs transient error
    (retry after 30d).
  - DB migration: similar_artists_match_status / _last_attempted on artists
    (mirrors every other source worker's tracking columns).
  - Registered in EnrichmentService + instantiated in web_server, DEFAULT-PAUSED
    (opt-in) like Amazon — MusicMap is scraped/outage-prone + this is library-wide.
  - SERVICE_ENTITY_SUPPORT['similar_artists']=('artist',) so the modal breakdown
    ('artists with / without similars') + Retry work; manual-match (inapplicable
    to a relationship) is gated out via relationship:true.
  - 10 seam tests; existing 80 enrichment tests still pass.

Note: keys under profile 1 (single-profile setups); multi-profile is future work.
2026-06-03 15:07:49 -07:00
Francesco Durighetto
9ff2e7084a Fix organize-by-playlist downloads: library entries, wishlist, and stale Spotify cache
Persist organize_by_playlist on mirrored playlists and run playlist-folder
downloads from the auto-sync pipeline instead of the global wishlist phase.
Register SoulSync library rows after playlist-folder post-processing, route
failed organize batches to the wishlist correctly, and skip sync-time
unmatched wishlist only when organize download handles retries.

Invalidate stale playlist track caches on refresh (Spotify and Deezer ARL),
re-mirror on refetch, and improve standalone playlist modals (re-analysis,
Open in Mirrored). Add filesystem missing-track detection and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 10:26:32 +02:00
BoulderBadgeDad
77b8d7dd1f SpotipyFree integration confirmed working (236 tracks live); deps + meta tweak
- Verified end-to-end: fetch_public_playlist_full pulled all 236 tracks of the
  test playlist via SpotipyFree (the library handles the client-auth that 429'd
  the raw approach). Name + tracks correct.
- requirements.txt: declare spotipyFree>=1.1.2 as a normal pip dependency (like
  spotDL, also MIT — aggregation, not vendored) + websockets (a transitive dep
  SpotipyFree/spotapi needs that pip doesn't pull automatically). Code still
  soft-imports + falls back to embed, so it's never a hard runtime requirement.
- meta fetch uses limit=1 (name/owner only) so we don't pull the whole list
  twice. 9 tests green.
2026-06-02 22:50:04 -07:00
BoulderBadgeDad
06f11dc95a Full public playlists via optional SpotipyFree (no creds), MIT-clean
The in-house anonymous-token path is blocked by Spotify (429 without the web
player's rotating client-auth). Switch the full-fetch to SpotipyFree — the
maintained no-creds spotipy drop-in spotDL uses, which tracks that machinery.

- core/spotify_public_api.fetch_public_playlist_full now uses a SpotipyFree
  client (playlist + playlist_items + next), normalising the spotipy-shaped
  items to the embed scraper's shape. Injectable client_factory keeps it
  unit-testable without the library or network. Dropped the dead in-house
  token/pagination code.
- Licensing: SpotipyFree is GPL-3.0, so it is NOT bundled/required (SoulSync is
  MIT). Optional, user-installed: the import is soft, and on ImportError (or any
  failure) fetch_spotify_public falls back to the embed scraper (~100). So the
  shipped project stays cleanly MIT and the link path never regresses.
- requirements.txt: documents it as a commented optional extra
  (pip install SpotipyFree) with the GPL/MIT rationale.
- 9 tests: normalisation, pagination past 100, library-missing -> raises (->
  fallback), and the embed-fallback orchestration.

Needs a live click-through with SpotipyFree installed to confirm the exact
class/method names match (SpotipyFree.Spotify / playlist / playlist_items).
2026-06-02 22:43:34 -07:00
BoulderBadgeDad
8b060ee79a Fix: pull anonymous token from the EMBED page; drop meta call; graceful partial
Live debugging the 'shows 100' report:
- The full playlist page no longer embeds an accessToken, and get_access_token
  / server-time now 403/404. The EMBED page (open.spotify.com/embed/playlist/{id})
  still ships a usable anonymous token. Was fetching the wrong page -> no token
  -> raised -> embed fallback (100). Now reads the embed page for the token.
- Confirmed live: token extraction + embed parse work; the token is accepted by
  the Web API (429 rate-limit, not 401). Could not show >100 from here because
  the test IP got rate-limited from probing; needs a clean-IP click-through.

While in there, made it more robust against the rate-limiting that's clearly in
play:
- Refactored scrape_spotify_embed -> reusable parse_embed_html.
- fetch_public_playlist_full now does ONE embed fetch for token + name + first
  page (no separate metadata call = fewer requests = less 429 surface), then
  paginates the API. If the API is unavailable/rate-limited, it keeps the embed
  page's tracks (<=100) instead of raising — so the result is always >= today's
  behaviour, never worse.
- 12 tests incl. the new API-fails-but-embed-tracks-survive path.

Caveat unchanged: rides Spotify's undocumented embed-page token; degrades to the
embed fallback, never crashes.
2026-06-02 21:59:56 -07:00
BoulderBadgeDad
dd7f048386 Full public playlist fetch for the 'Spotify link' path (no creds), embed fallback
The no-auth 'add by link' path scrapes Spotify's embed widget, which only ever
contains ~100 tracks and can't paginate — so big public playlists got
truncated. This adds an in-house anonymous fetch that pulls the FULL list:

- core/spotify_public_api.py: reads the anonymous web-player accessToken Spotify
  already embeds in its own open.spotify.com page HTML (no app credentials, and
  no rotating TOTP secret for us to maintain), then paginates
  /v1/playlists/{id}/tracks 100 at a time until the whole playlist is pulled.
  Returns the embed scraper's exact shape. Pure helpers + injected http_get so
  it's unit-testable without the network.
- core/spotify_public_scraper.fetch_spotify_public(): tries the full fetch for
  playlists; on ANY failure (or for albums) falls back to scrape_spotify_embed.
  Worst case == today's behaviour, so the link path can't regress.
- web_server: the link-tab endpoint and the authed flow's last-resort scrape
  now both go through fetch_spotify_public.

Scoped entirely to the spotify_public_* (no-auth) path — the authenticated
playlist sync is untouched. 11 tests (token extraction, normalisation,
pagination past 100, and the embed-fallback orchestration).

Caveat: rides Spotify's undocumented page-embedded token — expected to break
when they change their page; it degrades to the embed fallback, never crashes.
Needs a live click-through to confirm the token path works end to end (can't
hit Spotify from the test env).
2026-06-02 21:27:06 -07:00
BoulderBadgeDad
e1fd1f2489 test: register enrichment-manager.js in script-split-integrity scan
The onclick-coverage guard only scans the split modules + a hardcoded extras
list, so it flagged openEnrichmentManager() (defined in the new, loaded
enrichment-manager.js) as undefined. Add enrichment-manager.js to the scanned
non-split files. The function genuinely exists and is loaded via its script tag.
2026-06-02 21:06:02 -07:00
BoulderBadgeDad
62ee1f8520 Enrichment manager: 6 UX improvements
- #1 Unconfigured-source banner: when a source has enabled=false, show a
  notice that browsing works but matches/retries won't run until it's set up.
- #2 Rate-limit detail: when rate_limited, surface 'resumes in ~Xm' (from the
  status payload) instead of just a pill.
- #3 Richer rows: unmatched items now show parent context — an album's artist,
  a track's album — via a parent expression in the query (+ test).
- #4 Bulk select: per-row checkboxes + a bulk bar to retry several at once
  (capped concurrency), reusing the /retry item endpoint.
- #5 Remember last worker: selection persists in localStorage and is restored
  on open; openEnrichmentManager(workerId) supports future deep-linking
  (bubbles left on their pause-on-click behaviour).
- #6 Keyboard nav: ArrowUp/Down moves focus between rows; actions are native
  buttons (Enter/Space) and Escape closes — list isn't poll-refreshed so focus
  is stable.

53 enrichment tests green; JS syntax clean.
2026-06-02 19:50:57 -07:00
BoulderBadgeDad
e53a157793 Enrichment manager: 'process this group first' + refined hero header
Per-worker processing-order override + UI polish.

Feature — pin an entity group to enrich first:
- Each worker normally runs artist -> album -> track. A user can pin one
  group (artist/album/track) to run first from the modal; the worker keeps
  that group first until it's exhausted, then resumes the normal chain.
- core/worker_utils.py: read_enrichment_priority() (reads
  <service>_enrichment_priority each loop, live) + priority_pending_item()
  (shared, whitelisted query returning the worker's expected item shape;
  Spotify/iTunes get album_individual/track_individual via a type map).
- A guarded ~6-line hook at the top of all 11 workers' _get_next_item.
  CRITICAL: when nothing is pinned (default) the hook returns immediately,
  so default enrichment order is byte-identical to before. Discogs (no track)
  and Genius (no album) only honor their supported entities.
- core/enrichment/api.py: GET/POST /api/enrichment/<id>/priority (+ config_get
  hook); POST validates the entity against what the source enriches.
- 14 new tests (helper shapes, exhaustion, route get/set/clear/validate).

UI:
- Refined hero header: identity + inline status left, single Pause right,
  'now enriching' quiet sub-line; overall coverage % moved into the stats
  section ('82% matched · 1,203 of 1,460'). Hero gently pulses while running.
- New processing-order strip: artist→album→track steps showing the live phase
  (pulsing 'now'), pinned group ('first' + 📌), and done/remaining; click a
  step to pin it, click again for auto.

py_compile clean across all 11 workers; 52 enrichment tests green.
2026-06-02 19:45:04 -07:00
BoulderBadgeDad
fc9a9f1c90 Enrichment manager v2: working retry + bulk retry-all-failed
Fixes a correctness bug and adds bulk re-queuing.

- Bug: per-row 'Retry' used clear-match, which sets an item to not_found
  with last_attempted=NULL. The worker only retries not_found items where
  last_attempted < (now - 30d), and 'NULL < cutoff' is false in SQLite, so
  those items were never re-queued. Fixed by resetting match_status to NULL
  (pending), which every worker's queue picks up on the next pass.
- New POST /api/enrichment/<id>/retry with scope 'item' | 'failed'
  (failed = re-queue every not_found item of an entity type), backed by a
  pure whitelisted build_reset_query + MusicDatabase.reset_enrichment().
- UI: per-row Retry now hits /retry; a 'Retry all failed' bulk button appears
  when the current entity has not-found items (confirm + count toast); a hint
  line explains retry/match/auto-retry behaviour.
- 11 new tests (38 enrichment tests total, all green).
2026-06-02 19:19:39 -07:00
BoulderBadgeDad
0b3c3f656d Add Manage Enrichment Workers modal (v1 + polish)
Dashboard 'enrichment bubbles' could pause/hover but offered no way to
*manage* a worker. This adds a full management modal opened from a new
header button, covering all 11 enrichment sources.

Backend (testable core helper + seam tests; no live-DB dependency):
- core/enrichment/unmatched.py: pure, whitelisted SQL builders for the
  unmatched browser. service/entity validated against a support map (never
  interpolated raw); search + pagination bound as params; tracks join albums
  for artwork; limit capped at 200.
- database/music_database.py: get_enrichment_unmatched() +
  get_enrichment_breakdown() (the breakdown splits matched/not_found/pending,
  which the existing get_stats().progress lumps together).
- core/enrichment/api.py: GET /api/enrichment/<id>/{unmatched,breakdown} on
  the existing blueprint + a db_getter hook.
- web_server.py: wire db_getter=get_database.
- tests/enrichment/test_unmatched.py: 19 tests across builders, DB methods,
  and Flask routes.

Frontend (vanilla, matches app conventions):
- webui/static/enrichment-manager.js: worker rail with live status + coverage
  micro-bars, accent-themed detail panel (hero header, segmented matched/
  not_found/pending stat cards, current item, pause/resume), and a searchable
  paginated unmatched browser with inline manual match (reusing
  search-service + manual-match) and retry (clear-match re-queues).
- Polish: entrance/exit motion, scroll-lock, Escape, refresh control,
  flicker-free polling (in-place updates), skeleton loaders, relative
  timestamps, per-worker accent theming, real dashboard logos reused at
  runtime (with the same invert/circle treatment), responsive rail.
- index.html: header button + script include. style.css: full styling.

Reuses existing pause/resume, status, and manual search+assign endpoints.
Backend tests green (19 new + 11 existing enrichment tests).
2026-06-02 19:06:44 -07:00
BoulderBadgeDad
dfa5204e0a Repair settings: dropdown for fixed-choice settings (canonical source_selection)
The canonical source_selection setting was rendering as a free-text box — easy
to typo an invalid mode. Added a generic choice mechanism so it's a dropdown:

- RepairJob.setting_options: {key: [allowed values]} (default {} — opt-in).
- CanonicalVersionResolveJob declares source_selection's three modes.
- repair_worker.get_all_job_info() includes setting_options in the job payload.
- enrichment.js renders a <select> (options prettified, current value selected)
  for any key listed in setting_options; everything else renders by value type
  as before. The save path already reads <select>.value as a string, so no
  change needed there.

Generic — any future job can get dropdowns the same way. Jobs that don't
declare setting_options are untouched (empty dict -> existing input rendering).

Tests: source_selection exposes the 3 options and its default is one of them.
23 repair-job/worker + canonical tests pass (other jobs unaffected).
2026-06-02 15:33:13 -07:00
BoulderBadgeDad
2fcdfd3145 Canonical findings: include as much (free) data as possible
Per request, pack each finding with everything available WITHOUT extra API
calls (kettui: reuse what's already fetched, read the album row we already
loaded, degrade per-field, keep it tested):

- Pinned release's track titles — already fetched during scoring, so free
  (capped at 60 to bound details_json).
- From the album row (free): year, DB track count, total duration, genres-free
  context, and the album's currently-linked source IDs.
- file_track_titles (your library's titles) for a side-by-side with the release.
- Artist + album thumbs (artist via the guarded lookup) and names.

_describe_pin now renders: "Artist — Album (year)", the fit breakdown, "Currently
linked: … → pinning X", "Beat: <alternatives>", and the release tracklist — so
the card is judge-able at a glance, and the structured fields are in details for
a richer UI.

NOT included (would cost an extra per-album API fetch, left as opt-in): the
*release's* own year/type/cover/URL from get_album_for_source, vs the library's.

Tests: _describe_pin rich-render (year/linked/tracklist), resolver release-titles,
orchestration free-context fields. 94 canonical + reorganize regression pass.
2026-06-02 14:10:02 -07:00