Commit graph

841 commits

Author SHA1 Message Date
BoulderBadgeDad
ab8f82af2e #903: re-export updates the same ListenBrainz playlist in place (no duplicates)
Re-running an export created a new LB playlist every time (LB keys on MBID, not name, and
create always mints a new one). Now remember which LB playlist a mirror was pushed to and
update it in place:

- listenbrainz_client: refactor batched-add into _add_tracks_in_batches; add
  get_playlist_track_count, delete_playlist, update_playlist (verify exists -> clear items via
  item/delete -> re-add -> edit title; reports gone=True if deleted on LB), and
  create_or_update_playlist (update when we have a prior MBID, else create; falls back to
  create if the remembered one was deleted). Stable URL/MBID across re-syncs.
- playlist_export_targets table + get/set_playlist_export_target: remember (mirror, target) -> LB MBID.
- export job consults/stores the target so push updates in place.

+6 mocked tests (clear+re-add same mbid, gone-fallback, create-or-update branches, delete). API
endpoints (item/delete, playlist/edit, playlist/delete, GET count) confirmed against LB docs;
live round-trip pending explicit auth.
2026-06-22 22:36:29 -07:00
BoulderBadgeDad
d430dd8b71 #903: real source wiring for the export MBID waterfall
Phase 4. core/exports/export_sources.py supplies the real I/O behind each waterfall source
and assembles resolve_fn: cache -> DB (tracks.musicbrainz_recording_id via text match) ->
file tag (UFID/musicbrainz_trackid) -> live MusicBrainz match_recording. Every source is
fail-safe (any error -> None -> fall through, export never breaks). A fresh non-cache hit is
written back to the persistent cache so the same song is free next export. Sources are
injectable; build_resolve_fn wiring (cache short-circuit + write-back) is unit-tested. 4 tests.
2026-06-22 20:35:20 -07:00
BoulderBadgeDad
42ff13d517 #903: persistent recording-MBID cache + export orchestrator
Phase 3. Additive backbone for the export job:
- mb_recording_cache table (IF NOT EXISTS) + core/exports/recording_mbid_cache.py: persistent
  (artist,title)->recording_mbid cache, mirrors album_mbid_cache (lazy DB, error-degrades to
  miss). The MusicBrainz tail is ~1 req/s, so a resolved MBID is remembered once and reused
  across every export/playlist.
- core/exports/playlist_export.py: resolve_playlist_tracks(tracks, resolve_fn) — walks tracks,
  dedups repeated songs within a run (resolve once), builds the ordered pseudo-playlist, tallies
  live stats (resolved/unmatched/deduped/by_source). Pure (I/O injected via resolve_fn + progress
  callback), so dedup + accounting are unit-tested with no DB/network. 5 tests.

No wiring into runtime yet; nothing existing touched except the additive table.
2026-06-22 20:31:35 -07:00
BoulderBadgeDad
c6b5cd9763 #903: ListenBrainz client create_playlist (create + batched item/add)
Phase 2. Add create_playlist(title, tracks, public) to the LB client: POST /playlist/create
for the MBID, then add tracks in batches of 100 (MAX_RECORDINGS_PER_ADD) via the item/add
endpoint so 1k-track playlists don't hit a single-request cap. Returns a result dict
{success, playlist_mbid, playlist_url, added, requested, error} and never raises — partial
add failures are reported honestly (playlist created, added count accurate). Extends the
existing token-auth client; additive. 4 mocked-network tests (batching, auth, failure).
2026-06-22 20:28:31 -07:00
BoulderBadgeDad
fb9d88ea6a #903: playlist export to ListenBrainz — pure cores (JSPF builder + MBID resolver)
Phase 1 of exporting mirrored playlists to ListenBrainz. Two pure, fully-tested seams,
zero runtime wiring yet (additive, no regression):

- core/exports/jspf_export.py: build_jspf(title, tracks) -> ({"playlist": {...}}, summary).
  LB's POST /1/playlist/create requires every track to carry a string identifier
  'https://musicbrainz.org/recording/<mbid>' (text-only tracks are rejected), so tracks
  without a valid recording-MBID UUID are dropped and counted in the coverage summary.
- core/exports/mbid_resolver.py: resolve_recording_mbid(artist, title, sources) — the
  cheapest-first waterfall (cache -> DB -> file tag -> MusicBrainz) as a pure function over
  injected (label, fn) sources. Short-circuits expensive lookups, treats a raising source
  as a miss (one flaky MB call can't fail the export), reports the resolving source label.

API spec confirmed against LB docs: POST /1/playlist/create, 'Authorization: Token <t>',
{"playlist": {"title", "track": [{"identifier": "<mb recording url>", title, creator, album}]}}.

13 tests.
2026-06-22 20:25:44 -07:00
BoulderBadgeDad
01c51a3c0e #904: guard standalone Deep Scan against relocating a desynced library
Standalone _run_soulsync_deep_scan did a path-only diff (untracked = transfer files
not in the soulsync DB) and shutil.move'd EVERY untracked file to Staging — no guard.
When the DB is empty/out of sync with disk (volume swap, DB reset, external Picard
tag edits) but Transfer holds the real library, that flags the whole library as
untracked and relocates all of it; Phase 5 then deletes the rows, and with Staging
cleanup on the files are gone for good. Reporter lost ~1,500 tracks into Staging.

The stale_guard the orphan detector + media-server deep scan already use (#828, #908)
was never wired into this path. Fix:

- core/library/standalone_scan.py (pure, tested): plan_standalone_deep_scan() diffs
  untracked (separator-normalized) and decides whether the move is safe. Blocks when
  the untracked share is implausibly large (>20 files AND >50% of Transfer — the
  desync signature, via is_implausible_orphan_flood) or when the user marked Transfer
  permanent. A normal batch of new arrivals still moves.
- web_server: consult the planner before Phase 4; on block, move NOTHING, leave files
  in place, and surface a loud warning + activity item. Guard Phase 5 deletes too
  (skip on desync-block or implausible stale share).
- 'Transfer is my permanent library — never move files out' toggle
  (import.transfer_is_permanent) in Settings.
- tests/library/test_standalone_scan.py: seam coverage + the #904 regression
  (empty DB + 1,500 files -> blocked, nothing moved).

No behavior change for in-sync libraries; the guard only trips on the desync pattern.
2026-06-22 17:53:49 -07:00
BoulderBadgeDad
49592f898c #902: YouTube Liked Music sync — paste a cookies.txt (server/Docker auth)
Private YT Music playlists (a user's Liked Music, list=LM) need auth, but the
only cookie option was cookiesfrombrowser — a browser on the same machine as
SoulSync, useless on a headless/Docker box (and locked to whatever account that
browser happens to be signed into). Add a 'Paste cookies.txt' mode so users can
supply the exact session they want from any machine.

- core/youtube_cookies.py: pure seam — build_youtube_cookie_opts (cookiefile vs
  cookiesfrombrowser precedence, mutually exclusive, fail-safe on a missing file),
  looks_like_cookiefile (needs a real cookie row; rejects junk/header-only),
  write_pasted_cookiefile (validate + 0600 write; blank/junk never clobbers a saved file).
- _youtube_cookie_opts() delegates to the seam, so every yt-dlp call site gets it.
- /api/settings pops cookies_paste before the generic persist, validates (400 on
  junk), writes config/youtube_cookies.txt, stores only the path (blob never hits config.json).
- Settings dropdown gains 'Paste cookies.txt'; selecting it reveals a textarea.
- tests/test_youtube_cookies.py: precedence, validation, fail-safe write (11 tests).
2026-06-22 14:41:04 -07:00
BoulderBadgeDad
b95a8f539e Auto-download: resolve an album track's real position from its album (not 1/1)
Tracks auto-downloaded from the playlist pipeline / wishlist / watchlist landed as
01/1 even though they belong to multi-track albums (wolf/Sokhi; verified live —
Deezer says 'Obelisk' is track 9 of The Grand Mirage, Olives is 3/4, etc.).

Root cause, located in code: discovery doesn't carry a per-track position for
sources whose search/track endpoint omits it (Deezer search, MusicBrainz
recordings — only their ALBUM endpoint has it). detect_album_info_web then set
'track_number': track_info.get('track_number') (= None) and never looked it up
from the album it HAD identified (context.py); the pipeline floored it to 1. The
one helper that does an album lookup only ran for the no-album-context branch and
is gated off by default. Not isolated to Deezer — the gap is source-agnostic.

Fix: when the album is known (album_id present) but the position is missing,
resolve the REAL (track_number, disc_number) from the album's own track list via
the source-agnostic get_album_tracks_for_source — using the album id discovery
already picked (no re-search, no edition guessing). Matches by ISRC -> source
track id -> title. Fail-safe: any miss/error leaves the number untouched, so it
still falls through to the filename exactly as before — never worse than today.

kettui: pure seam core/imports/album_position.resolve_track_position_in_album
(I/O-free, ISRC>id>title priority, skips position-less entries) + a fail-safe
integration wrapper, both covered — 11 tests incl. the 'Obelisk = 9/12' case,
priority resolution, and never-raises-on-fetch-error. 788 import/context/pipeline
tests green, ruff clean.
2026-06-22 13:32:24 -07:00
BoulderBadgeDad
203142c4a9 Multi-disc: file the track in the disc folder that matches its tag (Sokhi)
Confirmed from Sokhi's FLAC tags + screenshot: disc-2/3 tracks land in the 'Disc 1'
folder, collapsing every disc's track 3/4/5/6 into one folder. Root cause: the
import pipeline syncs the resolved TRACK number into album_info (so the folder
matches the tag — pipeline.py '[FIX] Updated album_info track_number') but never
did the same for DISC. So the 'Disc N' folder (built from album_info.disc_number,
often 1) used a different disc than the embedded tag (resolved per-track in
source.py — e.g. 2/3 from a MusicBrainz multi-medium release).

Fix: one SHARED resolver, resolve_disc_for_track(original_search, album_info),
used by BOTH source.py (the tag) and the pipeline (which now writes it back into
album_info before building the path). Same function + same inputs (the pipeline
pulls the identical get_import_original_search(context)), so folder and tag can
never disagree. Returns the first valid positive disc (per-track, then album),
else 1 — a falsy/unknown per-track disc falls through to the album instead of
flooring early.

Tests: resolver preference/fallback/floor + an explicit folder==tag lockstep check
incl. Sokhi's per-track-2/album-1 case. 2122 import/pipeline/metadata tests green.
2026-06-22 10:56:53 -07:00
BoulderBadgeDad
c11a742e58 Multi-disc albums: never write a disc-less track (floor disc to >=1)
Sokhi: some tracks in a multi-disc album showed up with a null disc in Jellyfin
and floated ungrouped above the disc sections (tracks 3/9/15). Mechanism: the tag
writer only wrote the disc tag when disc_number was truthy, and enrichment CLEARS
all tags before rewriting — so a track whose disc came back 0 / None / '' lost its
disc entirely. Those falsy values slipped through because source.py defaulted with
'is not None' (a literal 0 passed) and context.py's or-chain can yield None; this
happens especially when a track resolves to a different edition than its siblings.

Fix: normalize_disc_number() floors any value to >=1, and enrichment now writes the
disc tag UNCONDITIONALLY (like the track number) so a track is never disc-less.
source.py uses the same floor so the metadata dict (and the 'Disc N' folder org)
stays consistent. Valid multi-disc values are preserved untouched.

Tests: normalize floors 0/None/''/negatives/non-numeric -> 1, preserves 1..4 and
tolerates '2.0'. 1406 enrich/metadata/track-number tests green, ruff clean.

NOTE: this fixes the SYMPTOM (never ungrouped). The deeper cause — a track matching
a DIFFERENT edition/release than its album siblings (the Persona-box-set mismatch in
the sample file; the canonical-version problem) — is separate and still open.
2026-06-22 09:30:04 -07:00
BoulderBadgeDad
1ad80d77a6 #901: one-time backfill — stable ids for EXISTING file-import mirrored tracks
The mirror_playlist fix only assigns stable ids to newly-imported playlists, so a
user with an existing file-import playlist would still have empty-id rows (and dead
Find & Add matches) until a manual re-import. Add an idempotent startup backfill that
assigns the SAME stable id a fresh import would to any mirrored track missing one —
so existing matches start sticking with no re-import. Runs once per db/process (the
init is guarded), only touches empty-id rows (no-op afterward), native ids untouched.

Tests: backfill fills empty ids with the exact fresh-import id, is idempotent (2nd
run = 0), and leaves native ids alone.
2026-06-22 09:06:34 -07:00
BoulderBadgeDad
6e622d30f1 #901: give file-import playlist tracks a stable id so manual matches stick
A Find & Add on a file-import (CSV/M3U/TXT) playlist track was silently dropped and
the track re-appeared as 'extra' (radoslav-orlov). Root cause: unlike Spotify/YouTube
(native ids), file-import + iTunes-only tracks arrive with an EMPTY source_track_id —
and the whole manual-match system keys on it. _persist_find_and_add_match is a no-op
on an empty id, and find_manual_library_match_by_source_track_id returns None for one,
so the match can be neither recorded nor looked up. That's the youtube-vs-file
difference the reporter noticed.

Fix: stable_source_track_id() derives a DETERMINISTIC 'file:<hash>' id from the track
identity (artist|title|album, normalized) when there's no native id; mirror_playlist
assigns it so the SAME song gets the SAME id across re-imports/discovery — exactly
what the match lookup needs. Native ids are used verbatim; bonus: discovery extra_data
now survives a re-import for these tracks too.

Tests: helper (native passthrough, deterministic + case/field-insensitive, distinct
per song, empty-on-no-title, file: prefix); mirror_playlist integration (file tracks
get stable distinct ids, stable across re-import, native ids untouched). 319 playlist/
sync/discovery/mirrored tests green.
2026-06-22 08:53:24 -07:00
BoulderBadgeDad
0df1d55ed6 Sync: re-resolve a manual match against live Plex when its stored key went stale
wolf39us: a Find & Add manual match got dropped on auto-sync (60->57) with a 404
fetching the stored Plex ratingKey. Root cause: Plex re-keys tracks on a metadata
refresh, and the SoulSync DB id IS that ratingKey — so until a SoulSync rescan BOTH
the durable match's library_track_id AND the file-path self-heal (which reads the
same DB) land on the dead key, fetchItem 404s, the track falls through to fuzzy
(also the dead key) and is dropped.

Fix: when both DB-side lookups miss on Plex, re-resolve the manual match against
LIVE Plex by the matched track's metadata, disambiguated by the stored file path
so the user's EXACT chosen track wins among multiple versions; return the current
live track and heal the stored id + sync cache. A manual match is now never dropped
or silently re-matched — it 'always overrides' as asked. Scoped to Plex (DB-backed
servers materialize off the DB and don't have this re-key problem).

Seam tests: file-path picks the right version over a live alternate, basename
fallback for server-vs-local paths, no-file-match falls back to top hit (never
drop), no results/empty title -> None, and a broken client never raises.
2026-06-22 08:47:24 -07:00
BoulderBadgeDad
458658de86 Special-edition cover art: prefer the pinned release own cover over the release-group representative
A MusicBrainz album resolves its art at RELEASE-GROUP scope even for a concrete
release (musicbrainz_search _release_to_album -> _cached_art prefers the rg mbid).
On the Cover Art Archive a release-group front is a single REPRESENTATIVE cover
(CAA picks one release to stand for the group, ~always the standard edition), so
a special edition like "Clair Obscur: Expedition 33 (Gustave Edition)" got the
standard art baked into cover.jpg + embedded tags at download time.

Add core/metadata/caa_art.fetch_release_preferred_art: try the specific release
own /release/<mbid>/front first, fall back to the existing release-group/provider
URL only when the release has no art of its own (404 -> None). Wire it into both
download_cover_art (cover.jpg) and embed_album_art_metadata (tags) so they stay
in sync. min_bytes defaults to 0 so the fallback keeps its prior behavior — this
can only ever ADD a better edition match, never strip a cover that showed before.

Caveat (documented, not fixed here): this only helps when the resolved release IS
the right edition. If the upstream match picked the standard release/group, that
is the separate canonical-version matching problem.

Tests: pure helper (prefers release art, falls back on 404/tiny/exception, no-mbid
passes through, nothing-available -> None). 667 metadata/artwork/deezer/hifi tests pass.
2026-06-21 22:47:09 -07:00
BoulderBadgeDad
9aaafaf341 Organize-by-playlist: optional custom file naming for the playlist folder
Files inside an Organize-by-Playlist folder were stuck with the library filename
(materializer hardcoded os.path.basename) — users wanted control over the naming,
e.g. a playlist-order prefix so a DAP plays them in order.

Add an opt-in FILENAME template "Playlist File Naming" (file_organization.templates
.playlist_item), tokens $position/$artist/$album/$track/$title. It is a filename,
not a path: validated to forbid "/" or "\" and to require $title, both in the
Settings UI (blocks save with a reason) and in core/playlists/item_naming.py, which
also fails safe at apply time — a bad/empty template falls back to the library
filename, so it can never produce a broken name. Default empty = current behavior.

Works for symlink AND copy modes (a symlink name is independent of its target).
Applied in _rebuild_one_from_db (the live reconcile/rebuild path), which has the
per-track metadata + playlist order; the pure FS materializer just gained an
optional dest_names override and is otherwise untouched. $position is zero-padded
to playlist width for correct sorting.

Tests: pure validate/render (slash + missing-title rejected, fallback, sanitize,
no-separator guarantee), FS-layer dest_names + collision disambiguation + back-
compat, and end-to-end through the DB rebuild (07->01 rename + empty-template
keeps library filename).
2026-06-21 21:57:20 -07:00
BoulderBadgeDad
15ea87a154 #897: surface the ignore-list on the wishlist page + stop blocking manual re-adds
Two issues behind #897:

1) Discoverability — the "Ignored" management modal (view/un-ignore/clear-all,
   shipped with #874) was only reachable from the wishlist *overview modal*
   footer, which most users never open. Add the same button to the wishlist
   page toolbar next to Cleanup / Clear All, wired to openWishlistIgnoreModal().

2) Manual re-add silently blocked (carlosjfcasero) — the album-modal "add to
   wishlist" endpoint passes source_type=album, but the ignore gate only
   bypasses+clears for source_type=manual, so re-adding a previously-cancelled
   track failed. We cannot just send manual: source_type drives Albums/Singles
   categorisation and repair_worker legitimately uses album too. Thread an
   explicit user_initiated flag (db.add_to_wishlist -> service -> album route)
   that bypasses+clears the ignore while preserving the real source_type.

Regression test pins both: an automatic source_type=album add stays blocked,
the user_initiated add goes through, clears the ignore, and keeps source_type=album.
2026-06-21 20:21:44 -07:00
BoulderBadgeDad
3496bb1800 Dedup: match artists across a leading "The" so "The X" and "X" don't download twice
_get_artist_variations only widened the candidate fetch by diacritics, so a
request for "The Black Eyed Peas" never pulled a library track filed under
"Black Eyed Peas" (or vice-versa) — it "failed to match" and re-downloaded a
duplicate. Toggle the leading "The" in both directions when widening the fetch;
the confidence scorer (50/50 title/artist, 0.882 across the "The" gap) still has
the final say, so this can only widen what gets fetched, never merge genuinely
different artists. Mid-word "The" (e.g. "Theory of a Deadman") is untouched.
2026-06-21 19:21:59 -07:00
BoulderBadgeDad
1782e36c78 Test the REAL Plex/Jellyfin sync matcher's durable-match fallback (#895)
Closes the gap from cf2b4d7d: that commit unit-tested the DB-only matcher twin but
not services.sync_service._find_track_in_media_server — the actual path a connected
Plex/Jellyfin user hits. Drive it directly (Jellyfin server-type, so no Plex fetchItem
mocking): durable manual match is honored when the volatile cache is wiped, and a
stale library id self-heals via the stored file path.
2026-06-21 18:18:58 -07:00
BoulderBadgeDad
cf2b4d7d38 Playlist sync: honor the DURABLE manual match, not just the volatile cache
Find & Add was being forgotten on the next auto-sync. It persists two ways — a fast
sync_match_cache override AND a durable manual_library_match (#787) that survives a
rescan — but BOTH sync matchers (services.sync_service._find_track_in_media_server and
the DB-only fallback) only consulted the volatile cache. A library rescan wipes that
cache, so the next 'replace' auto-sync re-matched the track from scratch and the user
had to Find & Add it again.

Both matchers now fall back to the durable manual match when the cache misses
(self-healing a stale library id via the stored file path), exactly like the compare
view already does via resolve_durable_match_server_id. So a Find & Add pairing sticks
across rescans + auto-syncs. Seam tests: cache-wiped→durable hit, stale-id self-heal,
no-match→fuzzy fall-through.
2026-06-21 18:15:20 -07:00
BoulderBadgeDad
b6cb244cbd HiFi preview: abort the source, don't cascade to a lower-tier preview — #895
The log from a fresh run showed detection working but a second bug: rejecting the
lossless preview ('decoded 30s of 180s — rejecting') just dropped to the SAME track's
'high' (lossy m4a) tier — the same 30s clip — which the bitrate check can't see (m4a,
not FLAC), so it was accepted. A preview at any tier means the SOURCE only has a
preview of the track; lower tiers are the same clip. So on preview detection (manifest
OR post-download) we now return None to FAIL HiFi entirely, letting the orchestrator's
hybrid fallback try the next SOURCE (soulseek/youtube) instead of landing a lower-tier
preview. (A genuinely-missing tier still falls through to the next tier as before.)

Integration test drives the post-download abort end-to-end and pins 'no tier cascade'.
2026-06-21 17:09:43 -07:00
BoulderBadgeDad
4bce1932ba HiFi: catch faked-header previews (full length claimed, ~30s real audio) — #895
The first fix (#895) only caught manifests that HONESTLY declared a short length. The
real-world fakes are nastier: every length header is faked to FULL — HLS #EXTINF, the
m4a moov, AND the demuxed FLAC's STREAMINFO total_samples — while the file holds only
~30s of real audio. So the manifest-sum and mutagen-length checks both read 'full' and
passed it through. Measured 23 issue files: every one decoded to ~30s with a claimed
full length; size/claimed gave an impossible 55-362 kbps 'lossless', size/30s gave a
plausible ~600-1100 kbps.

Detect it two independent ways (post-download), so it fires even when every header lies:
- decoded real length via ffmpeg (-f null) vs the largest claimed length — the ground
  truth when a decoder is present (production demuxes with ffmpeg, so it is);
- for lossless, an impossibly-low implied bitrate (< 30% of raw PCM) — needs no decoder,
  catches all 23 on its own.

Pure is_fake_lossless_bitrate / is_preview_download / parse_ffmpeg_time helpers with seam
tests pinned to the real #895 file numbers. Reference is max(expected, container-claimed)
so the file's own faked claim becomes the bar its real audio must clear.
2026-06-21 16:56:46 -07:00
BoulderBadgeDad
1249160ff5 HiFi: reject preview manifests/files instead of landing a 30s fake
Fixes #895https://github.com/Nezreka/SoulSync/issues/895

HiFi (Tidal via hifi-api) sometimes serves a PREVIEW HLS manifest — only ~30s of
segments — for a full-length track. SoulSync downloaded exactly what the manifest
contained, and the only validation was a 100KB size floor (a 30s lossless preview is
~3-4MB, so it passed as 'complete') → a junk file that shows full length but plays ~30s.

Add two duration guards, both keyed off the track's real length (from get_track_info):
- Pre-download: sum the manifest's #EXTINF segment durations; if far short of the
  expected track length, it's a preview → skip that tier, fall through to the next
  source without wasting the download. (The parser discarded #EXTINF before.)
- Post-download: probe the finished file's real length (mutagen) and reject if short —
  backs up legacy/direct (no-EXTINF) downloads and catches any truncation.

Conservative: unknown/zero durations never reject. Pure sum_hls_segment_seconds /
is_short_audio helpers with seam tests.
2026-06-21 16:33:32 -07:00
BoulderBadgeDad
7bbd069147 Deezer playlists: tag the REAL album track number, not the playlist index
A downloaded track was getting the wrong track number (e.g. 'Apologize' from Shock
Value tagged track 1 instead of 16). Root cause: Deezer PLAYLIST track objects don't
carry `track_position` (only `/track/<id>` and `/album/<id>/tracks` do — even the
album object's embedded tracks omit it, verified against the live API). Both Deezer
playlist builders numbered tracks by their enumerate INDEX, which poisoned the real
album track number — and that value rides into the wishlist and onto the file tag.

Resolve the authoritative position from each album's `/album/<id>/tracks` (cache-first,
handles multi-disc) via a shared resolve_album_track_positions() helper, used by both
deezer_client.get_playlist and deezer_download_client.get_playlist_tracks. Falls back
to the index only if the album lookup fails (no regression). Seam tests for the helper.
2026-06-21 15:47:13 -07:00
BoulderBadgeDad
298d825757 #891: clear dead folders left with only cover images / .lrc sidecars
Reorganize leaves a cover.jpg (and other leftovers) behind when it moves an album,
and the Empty Folder Cleaner is too conservative to sweep them. Two complementary
fixes over one shared 'residual file' predicate (core/library/residual_files.py:
junk + cover/scan images + lyric/metadata sidecars), so both features agree on what
a dead folder is.

1. Reorganize (proactive): _delete_album_sidecars now sweeps ALL residual files when
   a source dir has no audio left — previously only a fixed list of cover NAMES, so
   back.jpg / disc.png / .webp survived and kept the folder un-prunable.
2. Empty Folder Cleaner (the request): new opt-in 'Remove Residual Files' setting
   (default off) treats a folder holding only images/sidecars/junk as removable —
   cleans the existing backlog + arbitrary names. Auto-renders as a UI toggle.

Safe by construction: whitelist-only (a booklet.pdf / video / .txt is real content
and kept), reorganize sweep gated on no-audio, cleaner re-checks at apply time.
20 new tests; 272 reorganize/repair/empty-folder green.
2026-06-18 20:07:41 -07:00
BoulderBadgeDad
213592821b #890: strip leading track-number prefix from filename-derived titles
Files named '01 - Sun It Rises.flac' with no embedded title tag leaked the stem,
number and all, into tracks.title as '01 - Sun It Rises' — which never matches the
canonical 'Sun It Rises', so the real track reads as a false 'missing' and albums
sort wrong.

New conservative strip_leading_track_number (paths.py): removes a clear track-number
prefix (zero-padded number, OR a number followed by a real separator+space) while
leaving titles that merely start with a number untouched — '7 Rings', '99 Luftballons',
'50 Ways to Leave Your Lover', '1-800-273-8255', '1979' all preserved. Never reduces
to empty/bare-number/punctuation.

Applied at:
- get_import_clean_title (context.py) — the universal resolver every import path funnels
  through, so the DB title AND the re-written embedded tag come out clean.
- album_matching scorer — so '01 - Sun It Rises' scores against 'Sun It Rises' and the
  file matches its real track (inheriting the clean canonical name).

27 targeted tests + 772 imports/matching green.
2026-06-18 18:35:04 -07:00
BoulderBadgeDad
8e218303be #889: FIX data loss — re-identify to the same release no longer deletes the file
Picking the release a track is ALREADY in deleted the file: the re-import lands at
the same path, record_soulsync_library_entry skips the insert (row exists), so no
new row is created — then delete_replaced_track removed that very row AND unlinked
the file (the freshly-imported one). This was the 'assumption' I documented but
never enforced.

Bulletproof guard: the pipeline writes its landing path into context['_final_processed_path'];
_process_matches captures it onto the candidate, and _finalize_rematch_hint passes
those new_paths to delete_replaced_track. If the old file canonically equals where
the import landed, it's a NO-OP — the row and file are kept (that file IS the
re-imported track). Canonical compare folds symlinks/case/sep.

So same-release re-identify is now a harmless re-tag-in-place; only a genuine re-home
(different path) deletes the old. 114 auto-import + rematch tests green.
2026-06-18 17:13:46 -07:00
BoulderBadgeDad
1367108e02 #889: fix replace-delete (resolve path) + re-identify now inherits album year/track#
Two real bugs surfaced in testing:

1. Original file not deleted on replace: delete_replaced_track checked os.path.exists
   on the RAW stored DB path (a Docker/media-server view), so it removed the row but
   orphaned the file. Now takes a resolve_fn (wired to resolve_library_file_path in
   the worker) and unlinks the RESOLVED real path.

2. No year / wrong album context: build_identification_from_hint set is_single=True,
   routing re-identify through _match_tracks' singles fast-path — which never fetches
   the chosen album, so the re-import got a bare stub (no release_date, total_tracks=1).
   Added force_album_match so the matcher FETCHES the chosen release even for a lone
   staged file → the track inherits the real album's year, in-album track number, and
   art. Holds for single-type releases too (they have a year as well).

Normal single-import behavior unchanged (force_album_match absent → same path).
112 auto-import + rematch tests green.
2026-06-18 16:46:07 -07:00
BoulderBadgeDad
f4c16ecc22 #889 Phase 4: the Re-identify modal + apply backend
The showpiece: a focused 'which release does this track belong to?' chooser.
Source tabs (default active), pre-seeded search, the same song surfaced across
single/EP/album with color-coded type badges, ISRC-ranked, replace-original
toggle (on by default). Glassy panel, blurred hero art, shimmer/spinner states,
hover-lift result cards — matched to the app's modal language.

Backend:
- core/imports/rematch_apply.py: pure staged_destination + build_reidentify_hint,
  injectable stage_file_for_reidentify (COPIES the file, never moves — original
  safe until re-import succeeds). 6 tests.
- POST /api/reidentify/apply (admin-only): resolve_hint_fields → stage file →
  create_hint → nudge the worker. Replace deletes the old row only on success.

Frontend: modal markup (index.html), full stylesheet (style.css), and the
openReidentifyModal/search/select/confirm flow (library.js). Not yet reachable
from a button — Phase 5 wires it.
2026-06-18 15:37:56 -07:00
BoulderBadgeDad
3e554f8274 #889 Phase 3: re-identify search — multi-source track→release lookup + API
Search any configured source (tabs, default active) and surface the SAME song
across its collections (single/EP/album) so the user can pick which release a
track should be filed under.

- core/imports/rematch_search.py: pure normalize + injected client factory.
  search_release_candidates() → lightweight display rows from typed search_tracks
  (title/artist/release/type badge/year/count/art/isrc/track_id); resolve_hint_fields()
  runs ONCE on the picked row via get_track_details to pull the album_id (+ isrc/
  track#/disc) the hint needs. infer_release_type() handles Spotify's missing 'EP'
  (multi-track 'single' → EP badge); filing is driven by real album_id, not the label.
- GET /api/reidentify/sources (tabs) + GET /api/reidentify/search (rows). Graceful
  empty on dead source / blank query / client error — never raises.

14 tests. Inert until the modal (Phase 4) calls it.
2026-06-18 15:31:33 -07:00
BoulderBadgeDad
08fb21fb13 #889 Phase 2: import seam — hint short-circuits identification, replaces on success
When a staged single-file candidate carries a re-identify hint, the worker builds
the identification straight from the user-chosen release (album_id/source) and
skips the guessing tiers — so the ambiguity that mis-filed the track is gone. No
hint → byte-identical to before (the lookup returns (None, None), fail-safe on any
DB error). A hinted import auto-processes (explicit user choice), still gated on the
global auto_process pref.

After the re-import lands, _finalize_rematch_hint consumes the hint and (if replace
was chosen) deletes the old row + file via delete_replaced_track — deferred to
success so a failed import never loses the original. Safe by construction: unlink
only when no surviving row references the file, and the modal never offers the
track's current release so old path != new path.

All hint logic lives in auto_import_worker.py + the pure rematch_hints helpers —
pipeline.py / side_effects.py untouched. 18 tests; full auto-import suite green.
2026-06-18 15:25:37 -07:00
BoulderBadgeDad
dbd8278a14 #889 Phase 1: re-identify hint store (DB table + pure create/find/consume seam)
A single-use, user-designated 'which release does this track belong to' answer.
Written when the user picks a release in the Re-identify modal and the file is
staged; the import flow will read it at the top of matching and consume it.

- rematch_hints table (additive, IF NOT EXISTS + indexes) keyed on staged_path
  with content_hash as a rename-proof fallback.
- core/imports/rematch_hints.py: pure DB seam over an injected cursor
  (create/find/consume/list) + a cheap size+head+tail file fingerprint.
- exempt_dedup baked into the hint (a re-identify must bypass dedup-skip);
  replace_track_id carried for deferred post-success cleanup.

Inert until wired (Phase 5) — nothing calls it yet. 9 seam tests.
2026-06-18 15:15:41 -07:00
BoulderBadgeDad
b04010a037 #885: repair-job scheduling is timezone-independent (Australia/Sydney loop)
paksenkin: TZ=Australia/Sydney made the Cache Maintenance job (and any repair
job) run every ~5s. Root cause: finished_at is written by SQLite CURRENT_TIMESTAMP
(always UTC) but the scheduler compared it against datetime.now() (naive LOCAL),
so the local↔UTC offset leaked into the elapsed time. Sydney (+11) made every job
look ~11h stale -> always due -> fired every poll; the Americas (behind UTC)
deflated it and masked the bug (why New_York 'worked').

Fix: compare in UTC. now = datetime.now(timezone.utc), and a new _hours_since()
helper parses the naive CURRENT_TIMESTAMP string AS UTC before subtracting — so
the machine timezone never affects scheduling. 5 tests incl. the literal repro
(a just-run job must not be due under Australia/Sydney) and a due-detection
sanity check; 41 repair-worker tests pass, ruff clean.
2026-06-18 14:02:09 -07:00
BoulderBadgeDad
400b35d655 #886: AAC as an opt-in Soulseek quality tier (purely additive, off by default)
radoslav-orlov: add AAC as a download quality option. AAC is more efficient than
MP3, so it's useful for Soulseek/torrents (streaming sources pick their own
codec; Amazon — the AAC-heavy one — is down).

Additive by construction: every quality tier already defaults enabled=false and
the waterfall is built only from enabled tiers, so AAC ships OFF and the bucketer
routes a not-enabled AAC file to the 'other' bucket EXACTLY as today (where it was
silently dropped). Only a user who turns AAC on makes it a first-class tier,
ranked above MP3 / below FLAC (priority 1.5, min-kbps gate so junk AAC can't beat
a good MP3).

- music_database: aac tier (disabled) in the default profile + all 3 presets.
- soulseek_client: map .m4a -> 'aac' in both result parsers (was 'unknown' ->
  dropped); add the 'aac' bucket + a gated branch + a fallback size limit.
- settings UI: an 'AAC' tier toggle (unchecked) between FLAC and MP3; save
  defaults its priority to 1.5 so upgraded profiles rank it right on first save.

7 seam tests pinning the additive guarantee (aac absent/disabled -> dropped as
before; FLAC/MP3 selection unchanged; aac on -> selectable, below FLAC, above
MP3); 81 quality/soulseek tests pass, ruff clean. quality_upgrade left untouched
(its AAC handling is unchanged).
2026-06-18 13:45:52 -07:00
BoulderBadgeDad
56da2e105c #887: Spotify enrichment shows 'Running (Spotify Free)' for no-auth users, not 'Not Authenticated'
radoslav-orlov: with no Spotify auth, enrichment runs on the no-creds Spotify Free
source (prefer-free is on by default) and IS working — pending drains, the modal
shows RUNNING — but the dashboard header tooltip said 'Not Authenticated /
Connect Spotify in Settings'. Two causes:

- get_stats() only set using_free for the rate-limit / spent-budget bridges, not
  the plain no-auth-default-free case. The loop already computes the right signal
  (free_serving = _free_active(), True here) but it was a local var. Cache it on
  self each iteration and report it as using_free (no auth API call in the 2s
  status loop).
- The dashboard's Spotify updater checked notAuthenticated BEFORE bridgingFree, so
  even with using_free it showed Not Authenticated. notAuthenticated now excludes
  the bridging-free case; the LastFM/Genius/Tidal/Qobuz updaters (no free path)
  are unchanged.

5 seam tests for get_stats free/auth reporting; 67 enrichment/free tests pass.
2026-06-18 13:00:33 -07:00
BoulderBadgeDad
a5267ee8cf NZBGet: import from the final location, not the incomplete '….#NZBID' dir
Swigs: 'No audio files found in /data/usenet/incomplete/….#2141' — SoulSync
imported a usenet album from NZBGet's intermediate working dir, which is emptied
after the move (files were in /data/soulseek/…). Two causes, both fixed to match
the already-correct SAB adapter:

- _parse_history mapped save_path=DestDir, but the authoritative final location
  after a post-processing move is FinalDir. Prefer FinalDir, fall back to DestDir,
  empty/whitespace -> None.
- _parse_group exposed the queue group's DestDir (the in-progress '….#NZBID' dir)
  as save_path, so a PP_FINISHED group (which maps to 'completed') could finalize
  on the incomplete folder before the move. A queue group now reports no save_path
  -> finalisation always comes from the history entry (real FinalDir/DestDir),
  bridged by the existing 120s completed-no-path window.

6 regression tests (FinalDir preferred, DestDir fallback, empty->None, queue/
PP_FINISHED never offer the incomplete path).
2026-06-18 12:47:19 -07:00
BoulderBadgeDad
58363ae510 Library: wire single->album resolution into import detection (gated, fail-safe)
detect_album_info_web gains a last-resort step: when a track matched a SINGLE
with no usable album context, look up the parent ALBUM that contains it (via
get_artist_albums_for_source + get_artist_album_tracks) and promote to it, so it
groups with its album-mates and gets the album's cover instead of the single's.

GATED behind metadata_enhancement.single_to_album (default OFF) — it's a
per-import metadata lookup, so it's opt-in, matching the canonical-version
pattern. Fully fail-safe: flag off, no source, or any client error/miss -> None,
so the track stays exactly as matched (never worse than today). The promoted
album name is forced past get_import_clean_album (which otherwise pins the
single's name) so grouping + tags use the album. 4 glue seam tests added
(promote-when-enabled, disabled-by-default, no-match, client-raises); 462
import-suite tests pass.
2026-06-18 09:30:11 -07:00
BoulderBadgeDad
00b26fc5f1 Library: single->parent-album resolution core (pure selector + injected-I/O resolver)
When a track matches a SINGLE release it carries the single's name/id and the
canonical grouping files it apart from its album-mates -> mixed cover art
(Sokhi). This re-homes it onto the album that actually contains it.

The selection is a pure, CONSERVATIVE function and the lookup loop takes injected
fetchers, so both are unit-testable without a live client. It only re-homes a
track when a real 'album'-type release's tracklist contains that EXACT track
(qualifier-tolerant) — never promotes a genuine standalone single, never guesses
(a wrong promotion would mis-home a real single, the inverse bug). Fail-safe: any
miss/error -> None (track stays as matched). 13 seam tests. Wiring next.
2026-06-18 09:24:06 -07:00
BoulderBadgeDad
b216233658 Library: group imported albums by canonical release id, not just the name string
Sokhi: songs in one album get mismatched cover art. Root cause is upstream of
the repair jobs (which correctly apply one cover per album_id): the standalone
import grouped albums by the album NAME hash (artist::album_name), so the SAME
release split into multiple album rows whenever the name string drifted, and the
cover-art/re-tag jobs then dressed each split row in its own art.

Foundation (new imports only; existing rows untouched): a pure, seam-testable
helper find_existing_soulsync_album_id() resolves the album row by precedence
name-hash id -> source RELEASE id -> (title, artist). When an import carries a
metadata-source album id, a differently-named import of the SAME release now
unifies into one row instead of splitting. Source-column lookup is allow-listed
(it's spliced into SQL) and guarded so a source without a dedicated album column
(Deezer) falls through to the name match instead of breaking the import.

Deliberate scope: this does NOT merge a track that genuinely matched a SINGLE
(a different release id) into its parent album — that needs single->album
resolution upstream and is the next step; this is the grouping substrate it will
feed. 10 seam tests (canonical unify, single-vs-album stays separate, precedence,
allowlist, server-source scope, missing-column fallthrough).
2026-06-18 09:16:00 -07:00
BoulderBadgeDad
3b0394dbc6 Metadata: a mid-enrichment crash on an art-less file no longer leaves it UNTAGGED
Sokhi: tracks occasionally land in Rockbox's 'untagged' bucket after a
'processing failed'. enhance_file_metadata saves the file with tags CLEARED up
front (so stale tags never linger), then runs the failure-prone external steps
(source-id embed, cover-art fetch). The core tags (album/artist/title/track from
the matched context) are written to the in-memory object BEFORE those steps, but
the on-disk file is still the cleared one until the final save.

The #764 fix made the error handler restore ART — but gated the re-save on there
being original art to restore. So a file with NO embedded art that hit a
mid-enrichment crash threw away its in-memory core tags and was left on disk as
the up-front clear saved it: untagged. Now the handler always persists the
in-memory tags (restoring art when present), so a crash leaves a correctly-tagged
file (album tag intact -> right bucket) instead of an empty one. Regression test
drives the real enhance_file_metadata against an art-less FLAC.
2026-06-18 08:42:04 -07:00
BoulderBadgeDad
7e175fec02 Cover art: a sequel digit glued to a CJK title ('…サウンドトラック2') now blocks the wrong-album match
Sokhi (again): downloading the base 'Mushoku Tensei S2 Original Soundtrack' embedded
the cour-2 '…サウンドトラック2' cover. numeric_tokens_differ stripped titles to
[a-z0-9], turning CJK into spaces — so the trailing '2' collapsed to a bare '2'
that '第2期' (season 2) already supplied on BOTH sides, leaving the digit sets equal
and the guard blind. Tokenise on \W (Unicode word-aware) instead, so a digit stays
attached to its word ('サウンドトラック2' is its own digit-bearing token). Latin
behaviour is byte-identical (Vol.4 vs Vol.4.5 etc.). Shared guard, so the art picker
AND the MusicBrainz->CAA path are both fixed. Regression tests added.
2026-06-18 08:24:54 -07:00
BoulderBadgeDad
d15b3a185d Track "01" bug: recover real track position instead of fabricating 1
Single tracks (esp. Deezer-sourced) imported as "01 - Title" regardless
of their real album position — e.g. Fly Away (track 2 of Greatest Hits)
landed as 01, littering album folders with duplicate "01" files.

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

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

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

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

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

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

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

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

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

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

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

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

Tests: pure helper (3) + real-DB integration reproducing the Calma case, the
parenthesized-remix variant, plain-title-unaffected, and no-flood (4). 64
search/match tests green.
2026-06-13 16:55:33 -07:00
BoulderBadgeDad
09b97c5f63 #870: Deezer ARL 'resets itself' — test the SAVED token, not the redaction mask
The Deezer ARL field round-trips a redaction sentinel for a saved-but-untouched
secret (shown as dots). The save path already guards against the sentinel
overwriting the real token (ConfigManager.set), so the ARL was never actually
lost — but the connection TEST read the field value and sent the sentinel as the
token, so Deezer returned USER_ID=0 ('Invalid ARL token') after navigating away
and back. That false failure made it look like the ARL kept resetting.

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

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

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

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

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

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

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

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

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

Findings record matched_via = track_id | isrc | album | search. 30 repair tests pass
(added track-ID tier, duration guard, dedup-skip, and unit coverage).
2026-06-13 13:34:48 -07:00