Commit graph

1445 commits

Author SHA1 Message Date
Broque Thomas
1a2da016e4 Add download buttons + bulk action to artist top-tracks sidebar
Closes #513 (s66jones).

The artist detail page already showed a "Popular on Last.fm" sidebar —
list of an artist's top tracks by playcount, with a play button per row
but no download action. Issue #513 wanted a way to grab those tracks
the same way zotify let users grab "top X songs" without pulling the
full discography.

Pulls from the configured primary metadata source (Spotify
`artist_top_tracks`, Deezer `/artist/{id}/top`) when available, falls
back to the existing Last.fm display-only mode for sources that don't
expose popularity ranking (iTunes / Discogs / MusicBrainz). Source
label in the section title shifts to match.

Each row gets a hover-revealed download button that wishlists the
single track via the existing /api/add-album-to-wishlist endpoint
(preserves the track's real album metadata, so the wishlist worker
later places the file in its proper album folder).

A "Download All" footer button opens the standard download modal in
PLAYLIST context, not album context — the virtual playlist_id is
`top_tracks_<source>_<artistId>` which doesn't match any of the
album-prefix checks in `startMissingTracksProcess` (downloads.js).
That keeps `is_album_download=false`, so the master worker doesn't
inject a wrapper context as `_explicit_album_context`. Each track
downloads using its own real album metadata, files land in proper
per-album folders on disk (not a fake "Top Tracks" folder).

Backend additions:

- `SpotifyClient.get_artist_top_tracks(artist_id, country, limit)` —
  wraps `spotipy.artist_top_tracks`, returns up to 10 tracks for the
  market (Spotify's API cap). UI-side limit trim only.
- `DeezerClient.get_artist_top_tracks(artist_id, limit)` — wraps
  `/artist/{id}/top?limit=N`, converts Deezer's raw shape to the same
  Spotify-compatible dict layout (id, name, artists, album with
  album_type / total_tracks / images, duration_ms, track_number,
  disc_number) so downstream code doesn't branch on source.
- `GET /api/artist/<id>/top-tracks` — dispatches to whichever client
  matches the primary source. Resolves per-source artist IDs from the
  DB row first (matching what /discography already does) so a Spotify
  ID in the URL still works when Deezer is primary, and vice versa.
  Returns `{success, source, tracks, resolved_artist_id}` on hit;
  `{success: False, reason: 'unsupported_source' | 'spotify_not_authenticated'
  | 'deezer_unavailable' | 'no_tracks_found'}` on miss so the frontend
  can decide whether to fall through to Last.fm.

Frontend:

- `_loadArtistTopTracks` tries the metadata source first, falls
  through to the legacy `/api/artist/0/lastfm-top-tracks` call if the
  source can't deliver. Section title and per-row UI shift based on
  which source answered.
- New per-row `.hero-top-track-download` button (hover-revealed).
- New `.hero-top-tracks-download-all` footer button — only visible
  when metadata-source mode rendered the list (Last.fm fallback hides
  it since rows have no track IDs to download).

Tests: 10 new tests pin the client methods —
- Spotify: returns track list, honors UI limit cap, returns empty when
  unauthed / artist_id missing / API throws.
- Deezer: shape conversion to Spotify-compatible dict, empty when no
  data / artist_id missing, limit clamping at upper bound, default
  fallback when limit=0, malformed entries skipped.

The Flask endpoint dispatcher itself isn't covered by the new test
file because importing web_server at test-collection time spins up
worker threads that race with caplog-using tests elsewhere in the
suite (specifically test_library_reorganize_orchestrator). Endpoint
verified manually; the underlying client methods (the load-bearing
logic) are covered.

2204/2204 full suite green (was 2194 + 10 new).
2026-05-07 15:44:47 -07:00
Broque Thomas
01c528fd5f Reject AcoustID matches whose version disagrees with the expected track
Discord report (corruption [BWC]): downloads coming through as the
instrumental cut when a vocal track was requested. The verification
step's `_normalize` function strips parentheticals and version-suffix
tags ("(Instrumental)", "- Live", etc) so legitimate name variations
don't false-fail the title-similarity check. That also means "In My
Feelings" and "In My Feelings (Instrumental)" both normalize to "in
my feelings", title similarity is 1.0, and the wrong cut passes
verification.

Detect the version label on each side BEFORE normalization runs. If
the expected and matched recordings disagree on version (one is
original, the other is instrumental / live / acoustic / remix /
etc), return FAIL — the fingerprint identified a real song, just
not the version the caller asked for.

Reuses `MusicMatchingEngine.detect_version_type` so the same regex
patterns the pre-download Soulseek matcher applies also drive
post-download verification. No duplicated tables.

Also gates the secondary fallback scan, so a wrong-version variant
sitting in the same fingerprint cluster can't win the loop after
the best match has already been version-rejected.

6 tests pin the behavior:
- instrumental returned for vocal request → FAIL
- vocal returned for instrumental request → FAIL
- live vs acoustic → FAIL
- matching versions on both sides → PASS
- original-to-original happy path → PASS (regression guard)
- secondary scan skips wrong-version recordings → not PASS

2194/2194 full suite green (was 2188 + 6 new).
2026-05-07 13:25:30 -07:00
Broque Thomas
9602d1827c Final silent-exception sweep + ruff S110 lint guardrail — ~45 sites
Catches the silent excepts the awk-based earlier sweeps missed:

- Bare `except:` followed by `pass` (also swallows KeyboardInterrupt
  and SystemExit — actively wrong). Upgraded to `except Exception as
  e: logger.debug("...: %s", e)`. ~14 sites across connection_detect,
  soulseek_client, listenbrainz_manager, watchlist_scanner,
  youtube_client, navidrome_client, jellyfin_client, web_server.
- `except Exception:` + pass that the awk pattern missed (e.g.
  multi-line or unusual whitespace). ~31 sites across automation_engine,
  database_update_worker, music_database, spotify_client, web_server,
  others.
- 14 legitimate cleanup sites left silent with explicit `# noqa: S110`
  + comment explaining why (atexit handlers, finally-block conn.close
  calls). Logging during shutdown can itself crash because file handles
  get torn down before the handler fires.

Also enables `S110` rule in pyproject.toml so this pattern fails CI
going forward — drift fails at PR review instead of at runtime against
a wedged worker thread. Tests path keeps S110 ignored (test fixtures
legitimately use try-except-pass for cleanup).

Adds a WHATS_NEW entry to helper.js summarizing the full #369 sweep.

Verified: `python -m ruff check .` → All checks passed.
Verified: `python -m pytest tests/` → 2188 passed.

Closes #369
2026-05-07 11:16:06 -07:00
Broque Thomas
aa54bed818 Surface silent exceptions across remaining modules — ~70 sites
Final sweep. Covers:
- Downloads: candidates / lifecycle / master / monitor / wishlist_failed
- Metadata: source / registry / cache / common / artwork (+ plex_client)
- Imports: pipeline / resolution / file_ops / paths / guards
- Library: path_resolver / retag / duplicate_cleaner
- Stats / playlists / wishlist / discovery / automation / enrichment
- Misc: hydrabase_client, soulsync_client, tag_writer, debug_info,
  api_call_tracker, album_consistency, beatport_unified_scraper,
  reorganize_runner, seasonal_discovery, lidarr_download_client,
  services/sync_service.py, automation_engine, automation/progress

Two `_e` renames in imports/file_ops.py (outer scope binding `e`).
A few finally-block sites in metadata/album_mbid_cache.py,
library/track_identity.py, listening_stats_worker.py, watchlist/
auto_scan.py left silent — same reason as the rest of the sweep
(logger calls during cleanup paths can themselves raise).

Refs #369
2026-05-07 10:28:58 -07:00
Broque Thomas
e95452b465 Surface silent exceptions in workers + repair jobs — ~30 sites
Across all background workers (Spotify/Tidal/Deezer/Qobuz/iTunes/
Discogs/Genius/AudioDB/MusicBrainz/Last.fm/SoulID + the metadata-update
worker) and the repair-job scanners. All converted to
`logger.debug("...: %s", e)`.

Two `_e` renames in genius_worker and soulid_worker where outer scope
was already binding `e`. Two finally-block sites in repair_jobs/
library_reorganize.py left silent (conn.close on shutdown path).

Refs #369
2026-05-07 10:27:24 -07:00
Broque Thomas
8219771304 Add module logger + surface silent exceptions in 7 logger-less files — 12 sites
These files had silent `except Exception: pass` blocks but no module
logger. Added `import logging` + `logger = logging.getLogger(__name__)`
at the top of each, then replaced the silent excepts with
`logger.debug(...)`.

- core/replaygain.py — 4 sites (id3 txxx + vorbis + mp4 atom reads)
- core/wishlist/presence.py — 3 sites (wishlist row parsing + queries)
- core/runtime_state.py — 1 site (activity toast emit)
- core/automation/signals.py — 1 site (collect known signals)
- core/download_engine/rate_limit.py — 1 site (plugin rate_limit_policy)
- api/system.py — 1 site (hydrabase status probe)
- api/search.py — 1 site (hydrabase search)

Refs #369
2026-05-07 10:27:04 -07:00
Broque Thomas
8dc9f79f97 Surface silent exceptions in watchlist + discovery + reorganize — 18 sites
- watchlist_scanner.py: 6 sites
- discovery/playlist.py: 5 sites
- discovery/sync.py: 4 sites
- watchlist/auto_scan.py: 1 site (1 left silent — finally-block scanner cleanup)
- library_reorganize.py: 2 sites (4 left silent — all in finally blocks:
  conn.close, staging rmtree, sidecar delete, cleanup_empty_dir)

All non-finally sites converted to `logger.debug("...: %s", e)`.
Finally-block sites kept silent because logger calls during cleanup
(after exception was already raised) can themselves raise.

Refs #369
2026-05-07 09:52:20 -07:00
Broque Thomas
de348981a5 Surface silent exceptions in import pipeline — 11 sites
- imports/side_effects.py: 8 sites (post-import cleanup paths,
  thumbnail+lyrics pulls, Plex refresh)
- auto_import_worker.py: 3 sites (queue/dedup helpers)

All converted to `logger.debug("...: %s", e)`.

Refs #369
2026-05-07 09:50:15 -07:00
Broque Thomas
aa9429d733 Surface silent exceptions in core/artists — 23 sites
- map.py: 15 sites (cache lookups + per-track DB inserts in artist→source
  mapping)
- liked_match.py: 8 sites (Spotify liked-songs match heuristics)

All converted to `logger.debug("...: %s", e)`. No control-flow changes.

Refs #369
2026-05-07 09:49:29 -07:00
Broque Thomas
cc7a3f76ac Surface silent exceptions in metadata clients — 37 sites
- spotify_client.py: 15 sites (mostly `publish_spotify_status` + cache
  parse fallbacks)
- deezer_client.py: 10 sites (cache get/store + dataclass parsing)
- itunes_client.py: 4 sites (cache parsing + lookup fallback)
- discogs_client.py: 3 sites (cache parsing + token-from-config)
- tidal_client.py: 2 sites (used `_e` to avoid shadowing `e` from a
  sibling `except` clause in the same function — defensive)
- deezer_download_client.py: 3 sites

All converted to `logger.debug("...: %s", e)` with lazy formatter.
No control-flow changes.

Refs #369
2026-05-07 09:33:38 -07:00
Broque Thomas
e4e6b6bd5a Surface silent exceptions in repair_worker — 16 sites
Mostly progress-callback try/except (caller-provided fns we don't
control) and best-effort file-move / DB-cleanup paths in the
auto-fixers. All converted to `logger.debug(...)`. No control-flow
changes.

Refs #369
2026-05-07 09:17:51 -07:00
Broque Thomas
4c11375930 Repair job card badge — show pending count, not last-scan count
Discord report: Duplicate Detector card said "372 findings" and Cover
Art Filler said "60 findings", but clicking the Findings tab's Pending
filter showed 0. User read it as "findings aren't being created" —
looked like a detector bug.

Actual cause: the badge sourced ``last_run.findings_created``
(historical "found in last scan") without considering current state.
After the user (or bulk-fix automation) resolved or dismissed those
findings, they no longer appeared on the Pending tab — but the badge
kept showing the last-scan number in red urgent styling.

Backend was correct end-to-end: detectors create pending rows,
bulk-fix moves them to resolved, Findings tab filters by status.
Only the badge display lied about current state.

Fix:

- ``RepairWorker._get_pending_count_by_job()`` — single SQL aggregation
  returning ``{job_id: pending_count}`` for every job with pending
  findings. O(1) lookup per job instead of N round trips.
- ``get_all_job_info()`` calls it once per request and adds
  ``pending_findings_count`` to each job's API response.
- ``enrichment.js`` job card now branches on the count:
  - ``> 0`` → red ``"X pending"`` badge (urgent, action needed)
  - ``= 0`` AND last scan found something → muted grey ``"X found in
    last scan"`` (historical context, no action needed)
- New CSS class ``.repair-flow-badge.findings-historical`` for the
  muted slate color so the two states are visually distinct.

User-visible result with the screenshotted state (372 dup / 60 cover-
art findings, all resolved):
- Before: red "372 findings" / "60 findings" — implied 432 things to
  do, but Findings tab showed 0 pending
- After: grey "372 found in last scan" / "60 found in last scan" —
  the badge text tells the user the count is historical, no surprise
  when Pending is empty

Tests: 3 new tests in ``tests/test_create_finding_dedup_counter.py``
pin the per-job pending count helper:
- returns ``{job_id: count}`` based on status='pending' rows only;
  resolved + dismissed rows excluded
- empty dict when no pending findings exist
- gracefully returns ``{}`` on DB error (badge falls back to
  historical count via the existing JS ``or 0`` safety)

2188/2188 full suite green. Pure UI/state-display fix — no detector
logic, no backend behavior change.
2026-05-07 08:28:17 -07:00
Broque Thomas
5c69b853b4 Bound slskd HTTP timeout — fixes worker thread deadlock
GitHub issue #499 (@bafoed). Big initial sync of Spotify playlists
worked for 2-3 hours then downloads silently stopped:

- 3 active tasks stuck in "Searching" state, replaced every ~10 min
  by different ones
- slskd UI showed no actual searches happening
- Debug log: orphaned-task count grew over time, no jobs executed
- Container restart was the only fix (bought another 2-3 hours)
- Not a rate limit (rates showed 0/min)

Root cause: ``core/soulseek_client.py`` constructed
``aiohttp.ClientSession()`` with no timeout at four sites. When slskd
hung on a request (overloaded, transient network blip, internal
stall), the HTTP call blocked indefinitely — and the worker thread
blocked with it. The download executor only has
``ThreadPoolExecutor(max_workers=3)``, so once 3 worker threads were
wedged on hung calls, no further downloads could start.

Batch-level "stuck detection" (10-minute timer in
``check_batch_completion_v2``) was correctly marking tasks
``not_found`` and trying to start replacements, but the executor pool
was exhausted — replacements queued forever inside the executor with
no thread to run them. Symptom: tasks rotating every ~10 min at the
batch level while the underlying executor stayed wedged.

Fix: bounded ``aiohttp.ClientTimeout`` (total 120s, connect 15s,
sock_read 60s) on every slskd ``ClientSession`` construction. Module-
level constant ``_SLSKD_DEFAULT_TIMEOUT`` so the four sites stay in
lockstep — future sites get the same protection by reusing the
constant.

Why these timeouts are safe:

- Every slskd API call is metadata-level (search submission, status
  polls, download enqueue, transfer state queries). None stream
  files — slskd handles file transfer via its own peer-to-peer
  infrastructure entirely outside our HTTP requests.
- Legitimate metadata calls finish in seconds. 120s ceiling is
  ~50× the normal latency.

Timeout handling:

- ``asyncio.TimeoutError`` caught explicitly BEFORE the generic
  ``except Exception`` — surfaces "slskd timed out" specifically in
  logs (debuggable instead of buried as "Error making API request").
- Returns None to the caller (same code path as a 5xx response or
  any other failure). No new error path; callers already handle
  None as "request failed".
- Worker thread unblocks immediately → executor pool stays healthy
  → downloads keep flowing.

Sites updated:

- ``_make_request`` (general /api/v0/ helper, line 152) — used for
  every slskd API operation
- ``_make_direct_request`` (non-/api/v0/ helper, line 235)
- ``_explore_api_endpoints`` Swagger fetch (line 1566) — diagnostic
- ``_explore_api_endpoints`` per-endpoint probe (line 1617) —
  diagnostic

Tests: 3 new tests in ``tests/downloads/test_soulseek_pinning.py``
pin:

- ``_SLSKD_DEFAULT_TIMEOUT`` is bounded (total set, ≤300s ceiling,
  connect ≤60s) — guards against future regressions that drop or
  unbound the timeout
- ``_make_request`` returns None on ``asyncio.TimeoutError`` rather
  than raising — pins the caller contract
- ``_make_direct_request`` returns None on ``asyncio.TimeoutError``

2185/2185 full suite green.

Closes #499.
2026-05-06 22:02:25 -07:00
Broque Thomas
ca5c93162c Rewrite Library Reorganize job to delegate to per-album planner
GitHub issue #500 (@bafoed). Library Reorganize repair job moved
album tracks to single-template paths because of a fragile
classification heuristic. Concrete symptom: a track at
``Surf Curse/Surf Curse - Nothing Yet (2017)/01 - Christine F.flac``
got proposed for a move to
``Surf Curse/Surf Curse - Christine F/Surf Curse - Christine F.flac``
(single template) instead of staying under the album folder.

Root cause: the job had its own tag-reading + transfer-folder-walk +
template-application implementation. The classification was
``is_album = (group_size > 1)`` where ``group_size`` was the count
of same-album tracks currently sitting in the transfer folder being
scanned. Two failure modes:

- only one track of an album was in the transfer folder (rest already
  moved to the library, or not yet downloaded), or
- album tags varied slightly across tracks (e.g. ``"Buds"`` vs
  ``"Buds (Bonus)"``)

Either case gave a 1-element group → routed through the SINGLE
template → wrong destination.

Rewrite — delegate to the per-album planner the artist-detail
"Reorganize" modal already uses:

- ``core.library_reorganize.preview_album_reorganize`` for path
  computation (DB-driven, knows the album has N tracks regardless of
  how many sit in transfer; album-vs-single is structurally correct)
- ``core.reorganize_queue.enqueue_many`` for apply mode; the queue
  worker dispatches via ``reorganize_album`` which handles file move
  + post-processing + DB update + sidecar through the same code path
  the per-album modal uses

Job's per-album loop:

- iterate albums for the active media server only (matches the artist-
  detail modal's scope; multi-server users won't have the job touch
  the inactive server's files at paths they can't see)
- preview each album, catch exceptions per-album so one bad row
  doesn't abort the scan
- branch on planner status:
  - ``no_album`` / ``no_tracks`` (race: album deleted mid-scan) →
    skip silently
  - ``no_source_id`` (album never enriched) → emit ONE album-level
    "needs enrichment first" finding (vs N per-track findings cluttering
    the UI)
  - ``planned`` → filter mismatched tracks (matched + new_path +
    not unchanged + file_exists), emit per-track findings (dry-run)
    or collect album for bulk enqueue (apply)
- bulk enqueue at end of loop using the queue's correct return-shape
  (``{'enqueued': N, 'already_queued': M, 'total': K}``)

What's gone (~500 LOC):
- ``_read_tag_metadata`` / ``_get_audio_quality`` / transfer-folder walk
- ``_load_album_years`` / ``_lookup_years_from_api`` (planner does this)
- ``_apply_path_template`` / ``_build_path_from_template``
- direct ``shutil.move`` + sidecar move logic (queue handles)
- the fragile ``is_album = group_size > 1`` heuristic — structurally gone
- ``move_sidecars`` setting (no longer applicable; queue's post-process
  re-downloads cover art at the destination)

What stays:
- dry-run vs apply toggle
- ``file_organization.enabled`` gate
- stop / pause respect
- progress reporting
- findings for the UI

Cleaner separation of concerns:
- this job: DB-known tracks at wrong paths (active server only)
- ``orphan_file_detector``: files on disk with no DB entry
- ``dead_file_cleaner``: DB entries pointing to nonexistent files

Tests: 12 tests in ``tests/test_library_reorganize.py`` pin the
delegation contract — every status branch, every track-filter case,
exception handling, apply-mode enqueue payload, active-server scope,
estimate-scope shape. Three obsolete ``_lookup_years_*`` tests removed
(year handling moved to planner).

Closes #500 (the misclassification half — orphan + dead-file are
downstream sync-gap symptoms, separate concern).
2026-05-06 21:18:20 -07:00
Broque Thomas
cceffbd8ec Honor manually-matched source IDs in per-source enrichment workers
GitHub issue #501 (@Tacobell444). After manually matching an album to
a specific source ID via the match-chip UI, clicking "Enrich" on that
album would fuzzy-search by name and overwrite the manual match with
whatever the search returned — or revert the match status to
``not_found`` if name search missed. Reorganize then read the now-
wrong ID and moved files to the wrong destination.

Root cause was in the per-source enrichment workers'
``_process_*_individual`` methods. Several workers (Spotify, iTunes)
ran search-by-name unconditionally with no check for an existing
stored ID. Others (Deezer, Tidal, Qobuz) skipped on existing-ID but
without refreshing metadata — preserved the ID but didn't actually
honor the user's intent of "use this match to pull fresh data".

Cin-shape lift: same fix needed in 5 workers, so extracted the shared
behavior into ``core/enrichment/manual_match_honoring.py``:

    honor_stored_match(
        db, entity_table, entity_id, id_column,
        client_fetch_fn, on_match_fn, log_prefix,
    ) -> bool

Per-worker variability (DB column name, client fetch method, response
shape) plugs in via callbacks. Workers call the helper at the top of
``_process_album_individual`` / ``_process_track_individual``; if it
returns True, the manual match was honored and the search-by-name
fallback is skipped. If False (no stored ID, fetch failed, or empty
response), the worker's existing search-by-name flow runs as before.

Workers wired:

- spotify_worker — album + track (was overwriting; now honors)
- itunes_worker — album + track (was overwriting; now honors)
- deezer_worker — album + track (was skip-on-id; now refreshes)
- tidal_worker — album + track (was skip-on-id; now refreshes)
- qobuz_worker — album + track (was skip-on-id; now refreshes)

Workers left alone (already correct):

- discogs_worker — already had inline stored-ID fast path that
  refreshes metadata. Same behavior, just inline; refactoring to use
  the shared helper would be churn for zero behavior change.
- audiodb_worker — same — inline fast path with full metadata refresh.
- musicbrainz_worker — preserves existing MBID and marks status,
  which is the correct behavior for MB (the MBID itself is the match
  payload — no separate metadata fetch).
- lastfm_worker / genius_worker — name-based services with no
  source-specific IDs to honor. Inherent re-search per call.

Reorganize fixed indirectly — it always honored stored IDs correctly
via ``library_reorganize._extract_source_ids``. The "Reorganize broken"
symptom was downstream of broken Enrich corrupting the stored ID.

Tests:

- ``tests/enrichment/test_manual_match_honoring.py`` — 11 tests
  pinning the shared helper contract: stored-ID fast path, no-ID
  fallthrough, empty-string treated as no ID, missing row, fetch
  exception caught and falls through, fetch returns None falls
  through, callback exceptions propagate, configurable table +
  column, defensive table-name whitelist.

- Per-worker wiring NOT tested individually — the workers depend
  on live DB / client objects that are heavy to mock. The shared
  helper's contract is pinned; per-worker call sites are short
  enough to verify by code review.

2173/2173 full suite green.

Closes #501.
2026-05-06 19:00:53 -07:00
Broque Thomas
9f2813fce4 Add cross-section dedup to all-libraries listing layer
Followup to the all-libraries-mode commit. Without dedup, a Plex Home
family where two users both have "Drake" in their music libraries
would see "Drake" twice in SoulSync's library list — Plex returns
distinct ratingKeys for each section's copy of the same artist.

Dedup design — applied selectively, NOT everywhere:

- ``_dedupe_artists(artists)``: groups by lowercased title, picks
  the canonical entry by ``leafCount`` (more tracks wins). Active
  ONLY in all-libraries mode; single-library mode is a no-op fast
  path with zero behavior change.
- ``_dedupe_albums(albums)``: same but keys on
  (lowercased parentTitle, lowercased title) so two artists with
  identically-titled albums (e.g. self-titled releases) stay
  separate.

Applied to:
- ``get_all_artists()`` — public listing for the library view
- ``get_library_stats()`` — count matches what user sees in the list

Deliberately NOT applied to:
- ``get_all_artist_ids()`` / ``get_all_album_ids()`` — these feed
  removal detection (compare returned ratingKey set against DB-linked
  ratingKeys to decide what's been removed). Deduping here would falsely
  flag non-canonical ratingKeys as "removed" and prune SoulSync's DB
  tracks that are linked to them. Pinned by two CRITICAL tests.
- ``_all_tracks()`` — track count stays raw because the same track
  in two sections IS two distinct files / Plex entries, not a logical
  duplicate.
- ``_search_general()`` and ``search_tracks`` Stage 1/2 — search
  results stay raw so cross-section matches aren't lost. Stage 1
  may miss cross-section tracks for the same artist but Stage 2's
  server-wide track search catches them.

Logging: when raw vs deduped artist counts differ, ``get_all_artists``
logs both so users can see "Found 4697 artists across all music
sections (4521 unique after cross-section dedup)" — surfaces the
overlap clearly.

Tests: 8 new tests in test_plex_all_libraries.py pin:
- canonical pick by leafCount (artists + albums)
- case-insensitive name match
- single-library no-op path (zero behavior change for those users)
- album dedup keys on (artist, title) so different-artist same-title
  albums stay separate
- ``get_all_artists`` listing applies dedup
- ``get_all_artist_ids`` does NOT dedup (CRITICAL — removal detection)
- ``get_all_album_ids`` does NOT dedup (CRITICAL — removal detection)
- ``get_library_stats`` uses deduped counts for artists/albums but
  raw count for tracks

Existing pre-stat test updated to use distinct mock instances —
``[MagicMock()] * 5`` creates five references to one mock which now
correctly collapses under dedup.

71/71 media_server tests green, 2162/2162 full suite green.

Honest known limitation acknowledged in WHATS_NEW + version modal:
write-back (genre / poster / metadata updates) targets one
ratingKey at a time — only updates the canonical section's copy of
an artist if it exists in multiple. Other section's copy stays
unchanged. Document and revisit if it matters.
2026-05-06 16:01:37 -07:00
Broque Thomas
620c41f1ac Add "All Libraries (combined)" mode to PlexClient
GitHub issue #505 (PopeBruhLXIX): users with multiple Plex music
libraries (e.g. one per Plex Home user, or two folder roots split
across separate library sections) only saw one library inside SoulSync
because the connection settings forced you to pick a single library
section. SoulSync's PlexClient stored exactly one ``self.music_library``
section reference and every read scanned only that one.

This change adds an opt-in "All Libraries (combined)" dropdown option
that flips the client into a server-wide read mode where every read
method (``get_all_artists`` / ``get_all_album_ids`` /
``search_tracks`` / ``get_library_stats`` / etc) dispatches through
``server.library.search(libtype=...)`` instead of querying a single
section. One Plex API call replaces N per-section iterations; Plex
handles the aggregation server-side.

Implementation:

- ``ALL_LIBRARIES_SENTINEL`` (``'__all_libraries__'``) — module-level
  constant used as the saved DB preference value when the user picks
  the synthetic "All Libraries" entry. Detection is one string compare
  in ``_find_music_library`` / ``set_music_library_by_name``. Existing
  preferences (real library names) are unaffected.

- ``self._all_libraries_mode`` (private flag) + ``is_all_libraries_mode()``
  (public accessor for external callers). When True, ``music_library``
  may stay None — ``is_fully_configured()`` recognizes the mode and
  still returns True so dispatch sites don't bail.

- New private helpers ``_can_query``, ``_get_music_sections``,
  ``_all_artists``, ``_all_albums``, ``_all_tracks``, ``_search_general``,
  ``_search_artists_by_name``. Single dispatch point for the
  section-vs-server branch — every read method funnels through them
  so future drift fails at one place.

- New public helpers for downstream callers:
  - ``get_recently_added_albums(maxresults, libtype)`` — used by
    DatabaseUpdateWorker's deep-scan recent-content sweep
  - ``get_recently_updated_albums(limit)`` — same
  - ``get_music_library_locations()`` — returns folder roots, used
    by web_server.py's file-path resolver

- ``trigger_library_scan`` and ``is_library_scanning`` fan out across
  every music section in all-libraries mode.

- ``get_available_music_libraries`` prepends a synthetic
  ``{'title': 'All Libraries (combined)', 'value': sentinel}`` entry
  ONLY when more than one music library exists. Single-library users
  don't get the extra option. ``value`` field is the canonical
  identifier the frontend submits to ``/api/plex/select-music-library``
  (real libraries: title; synthetic: sentinel string). Backward-
  compatible — entries without ``value`` fall back to ``title``.

Three crash points fixed in downstream consumers (would have failed
during a deep scan after the user picked all-libraries mode):

1. ``database_update_worker.py:411`` — bailed out with "No music
   library found in Plex" because ``not self.media_client.music_library``
   evaluated True in all-libraries mode (music_library is None there).
   Now uses ``is_fully_configured()`` which recognizes the mode.
   This was the root cause of the deep scan never starting.

2. ``database_update_worker.py:_get_recent_albums_plex`` — reached
   ``self.media_client.music_library.recentlyAdded()`` /
   ``.search()`` directly, AttributeError in all-libraries mode.
   Now routes through the new helper methods.

3. ``web_server.py:10947`` (file-path resolver) — accessed
   ``music_library.locations``; gated on ``music_library`` truthy so
   it didn't crash, but silently skipped all-libraries-mode locations.
   Now uses ``get_music_library_locations()`` which unions across
   sections.

Plus polish:

- ``/api/plex/clear-library`` also resets ``_all_libraries_mode``
  so a fresh "select library" flow doesn't inherit stale mode state.
- ``/api/plex/music-libraries`` surfaces "All Libraries (combined)"
  as ``current_library`` when in mode (settings UI displays correctly).
- Frontend ``loadPlexMusicLibraries`` uses ``library.value || library.title``
  so the sentinel-keyed option submits the sentinel string, not the
  human-readable label. Pre-select match handles both paths.

Honest tradeoffs (documented as known limitations):

- Same artist appearing in multiple Plex sections shows as separate
  entries in SoulSync (no dedup). Plex returns distinct ratingKeys
  for each. Cosmetic; revisit if it bites users.
- Write-back (genre / poster updates) targets one ratingKey at a time
  — only updates that section's copy. Other sections' copies stay
  unchanged.
- All-libraries mode includes any audiobook library that Plex
  classifies as ``type='artist'``. Edge case, opt-in only.

Tests: 21 new tests in tests/media_server/test_plex_all_libraries.py
pin both single-library mode (regression guard) and all-libraries mode
for every refactored method. Existing test_plex_pinning.py fixture
updated to initialize the new flag. 63/63 media_server tests green,
2148/2148 full suite green.
2026-05-06 15:39:19 -07:00
Broque Thomas
822759740d Fix Download Discography pulling wrong artist + log routing
Two fixes.

(1) Discography endpoint now does server-side per-source ID resolution.

When the user clicked Download Discography on a library artist, the
endpoint received whichever artist ID the frontend happened to pick
(spotify_artist_id || itunes_artist_id || deezer_id || library_db_id)
and dispatched it as-is to whichever source it queried. If the picked
ID didn't match the queried source's ID format, the lookup returned
wrong-artist results (numeric ID collisions) or fell back to a fuzzy
name search that picked a wrong artist.

Two reproducible cases:

- 50 Cent's library row had DB id 194687 — coincidentally a real
  Deezer artist ID for "Young Hot Rod". When the frontend's
  /enhanced fetch silently fell back to the DB id, the backend
  sent 194687 to Deezer, and Deezer returned Young Hot Rod's
  50 albums in 50 Cent's discography modal.
- Weird Al's library row had a stored Spotify ID. The frontend
  sent that to Deezer, which rejected the alphanumeric ID and
  fell back to fuzzy name search — which picked The Beatles
  somehow, returning 45 Beatles albums.

The mechanism for per-source ID dispatch already exists in
``MetadataLookupOptions.artist_source_ids``, and the watchlist scanner
already uses it; the on-demand discography endpoint just wasn't wired
to it. Fix: when the URL artist_id matches a library row by ANY stored
ID (DB id, spotify_artist_id, itunes_artist_id, deezer_id, or
musicbrainz_id), pull every stored provider ID and pass them as
``artist_source_ids``. Each source gets its OWN stored ID regardless
of which one the URL carries. When the URL ID is a non-library
source-native ID and the row lookup misses entirely, behavior is
identical to before (single-ID dispatch fallback).

Logged the resolved per-source ID dict at INFO so future "wrong artist
showed up" diagnostics are immediately legible in app.log.

(2) Logger namespace fix in core/artists/quality.py and
core/metadata/multi_source_search.py.

Both modules used ``logging.getLogger(__name__)`` which resolves to
``core.artists.quality`` / ``core.metadata.multi_source_search`` —
neither under the ``soulsync`` namespace where the file handler is
wired. Result: every [Enhance], [MultiSourceSearch], and direct-lookup
INFO line was being written to a logger with no handlers and silently
dropped. App log showed the slow-request warning but no diagnostic
detail. Switched both to ``get_logger()`` from utils.logging_config so
the soulsync.* namespace picks them up. Same content, now actually
lands in app.log. Confirmed working in live test:
``[Enhance] Direct lookup matched: deezer ID 1476162252 → 'Desastre'``

No behavior change in any other caller. Empty ``artist_source_ids``
(no library row matched) reaches lookup as ``None`` → identical to
current single-ID dispatch path. Logger fix is pure routing — no
content change.
2026-05-06 13:03:43 -07:00
Broque Thomas
3befe9349c Direct ID lookup in Enhance Quality, like Download Discography
Followup on the previous Enhance refactor. Multi-source parallel text
search closed the worst case (users with no Spotify/Deezer getting
"unknown artist - unknown album - unknown track" wishlist entries),
but text search itself is still fragile against messy library tags:
"Title (Live)", featured artists in the artist field, etc. Download
Discography never had this problem because it resolves albums by stable
ID, not by name.

Enhance now does the same thing for tracks: for every metadata source
the user has configured, if the library track has the corresponding
stored ID (spotify_track_id / deezer_id / itunes_track_id / soul_id),
call client.get_track_details(stored_id) directly and convert to the
wishlist payload. First success wins. The user's configured primary
source is tried first so a Deezer-primary user gets Deezer payloads on
the wishlist entry (correct cover art / album shape) even when other
sources also have stored IDs for the same track.

Multi-source parallel text search stays as the fallback for tracks
with no stored IDs (e.g. manually imported, never enriched). Empty-
field rejection still gates the wishlist add.

Implementation:
- _STORED_ID_COLUMNS: source name → DB column mapping
  (Discogs intentionally omitted — release-based, no per-track IDs)
- _enhanced_to_wishlist_payload: converts the get_track_details
  intermediate "enhanced" shape (artists as [str]) to wishlist shape
  (artists as [{'name': str}]). Spotify's raw_data is already in
  wishlist shape, returned as-is when detected (preserves full
  album.images that the enhanced top-level fields drop)
- _try_direct_lookup_all_sources: iterates sources preferred-first,
  calls get_track_details on each that has both a stored ID and a
  configured client, returns first complete-metadata payload
- spotify_client field removed from ArtistQualityDeps (no longer
  used — Spotify direct lookup now flows through the generic
  per-source loop using the entry from search_sources)
- _try_upgrade_to_rich_payload removed (was Spotify-only with broken
  shape semantics for non-Spotify sources; search-fallback now uses
  _build_payload_from_track consistently)
- get_primary_source() consulted to set the per-call preferred source
  for direct-lookup priority

Also fixed a stale UI string: the Enhance modal toast read "Matching
tracks to Spotify and adding to wishlist..." regardless of which
sources were actually configured. Now reads "Matching tracks across
metadata sources...".

Tests:
- _build_deps mirrors web_server._resolve_search_sources: passing
  spotify=spotify_obj auto-prepends ('spotify', spotify_obj) to
  search_sources (Spotify is always added when configured in prod)
- 5 new tests pin the direct-lookup behavior:
  - test_direct_lookup_via_deezer_id_skips_text_search
  - test_direct_lookup_via_itunes_id_skips_text_search
  - test_direct_lookup_prefers_user_primary_source
  - test_direct_lookup_falls_through_to_text_search_when_no_stored_ids
  - test_direct_lookup_failure_falls_through_to_text_search
- Reframed enhanced-format and search-fallback tests for the new
  payload-build path (no album-image side call, search-fallback uses
  _build_payload_from_track consistently)
- 22/22 quality tests green, 2133/2133 full suite green.
2026-05-06 12:05:41 -07:00
Broque Thomas
7316646b01 Extract multi-source search; Enhance Quality matches Redownload coverage
Track Redownload had been doing parallel multi-source metadata search
across every configured source the whole time; Enhance Quality was
running a single-source primary fallback that returned junk matches
with empty fields when the primary was iTunes (Discord report:
"unknown artist - unknown album - unknown track" wishlist entries
for users with neither Spotify nor Deezer connected).

Lift the redownload search into core/metadata/multi_source_search.py
and point both flows at it. Same scoring, same per-source query
optimization (Deezer's structured artist:/track: form), same
current-match flagging via stored source IDs.

ArtistQualityDeps now takes get_metadata_search_sources (returns
[(name, client), ...] for every configured source) instead of the
single-primary get_metadata_fallback_client + get_metadata_fallback_source.
Spotify direct-lookup stays as a fast-path optimization (only Spotify
exposes get_track_details(id) returning rich raw payload); when it
doesn't fire, the multi-source parallel search picks the cross-source
best match. Empty-field matches still rejected before wishlist add.

Tests: _build_deps helper updated to accept the new search_sources
contract while preserving fallback_client/fallback_source ergonomics.
Reframed tests for the new semantics — direct-lookup is no longer
gated on Spotify being the active primary; failure reason now lists
every searched source. Added a test pinning the no-sources-configured
prompt. 17/17 quality tests green, 2128/2128 full suite green.
2026-05-06 11:26:22 -07:00
Broque Thomas
4a27f3c245 Source-agnostic Enhance Quality flow + reject empty matches
Discord report: clicking Enhance Quality on an artist with neither
Spotify nor Deezer connected added tracks to the wishlist as
"unknown artist - unknown album - unknown track".

Root cause was structural. core/artists/quality.py had a hardcoded
Spotify-direct → Spotify-search → iTunes-fallback chain that ignored
the user's configured primary metadata source. When Spotify wasn't
connected, every track fell through to an iTunes-only fallback that
occasionally returned matches with empty fields (cleared the 0.7
confidence threshold but missing artist / album / title). Those
empty strings propagated through the wishlist payload normalizer's
truthy-check passthrough at core/wishlist/payloads.py:77-80 and the
UI rendered them as "Unknown" defaults.

Rewrote the flow source-agnostic:

- ArtistQualityDeps gains get_metadata_fallback_source. Flow resolves
  the user's active primary source once up front.
- New _build_payload_from_track helper produces the Spotify-shaped
  wishlist payload from any source's Track object — single place
  that knows how to construct it (replaces the duplicate construction
  in the Spotify-search and iTunes-fallback paths).
- New _search_match helper does generic confidence-scored search
  against any client implementing search_tracks(query, limit). Same
  0.7 threshold, same album-bonus weighting as before.
- New _has_complete_metadata validator rejects matches with empty
  title / album / artists before they reach the wishlist.
- _spotify_direct_lookup kept as a Spotify-only optimization (only
  Spotify exposes get_track_details(id) returning rich raw payload);
  other sources fall through to search.
- Failure reason now names the active source: "No usable {source}
  match — connect another metadata source for better coverage".

Result: Discogs users get a Discogs search. Hydrabase users get a
Hydrabase search. iTunes users get an iTunes search with empty-field
rejection. Spotify keeps its direct-lookup fast path.

6 new tests pin the architectural change:
- Primary-source dispatch routes to the configured client (Discogs,
  not Spotify) when Spotify isn't primary
- Spotify direct-lookup is gated on Spotify being the active primary
  (skipped when Discogs is configured even if track has spotify_track_id)
- Empty title / album / artists fields all reject the match
- Failure reason names the active source
2026-05-06 10:13:26 -07:00
Broque Thomas
e27ecb84f4 Final review-pass nits — class docstring, dead branch, dead imports, boot resilience
Going line-by-line through the engine package + boot wiring. Five
small things worth fixing before Cin reads it:

(1) MediaServerEngine class docstring still claimed to be a "single
entry point for cross-server library operations" — but the prior
honesty pass cut all the cross-server dispatch wrappers because they
had no callers. Class is really lookup + small accessors now.
Docstring rewritten to match.

(2) configured_clients() had a dead `not hasattr(client, 'is_connected')`
branch. is_connected is in REQUIRED_METHODS so every client the
registry yields here implements it. Branch removed; comment notes
the reasoning.

(3) types.py imported `datetime` and `Dict` but used neither —
dead imports dropped.

(4) types.py docstring claimed "all four servers" defined an
XTrackInfo dataclass. Actually only Plex / Jellyfin / Navidrome
did; SoulSync uses richer per-track wrappers. Fixed.

(5) web_server.py boot:
    - media_server_engine added to the chained `= None` declaration
      so it's always defined before the try/except, defending against
      the rare path where engine init AND fallback both raise.
    - Outer engine init failure logger now uses exc_info=True for full
      traceback (boot-time issues are rare but worth diagnosing).
    - Nested fallback failure now logs explicitly instead of silently
      leaving media_server_engine as None.

Tests: 2121 still pass.
2026-05-05 22:59:33 -07:00
Broque Thomas
6489244bcc MS Cin/JohnBaumb honesty pass — drop dead wrappers, sync contract to reality
Pre-review audit found premature abstraction + lying docstrings.
Cut what isn't used, made the rest match what's actually shipped.

(1) Engine: dropped 7 cross-server dispatch wrappers that had ZERO
production callers (ensure_connection / get_all_artists /
get_all_album_ids / search_tracks / trigger_library_scan /
is_library_scanning / get_library_stats / get_recently_added_albums).
Every consumer reaches the active client directly via
sync_service._get_active_media_client() or engine.client(name).
Engine surface shrinks to client(name) / active_client() /
active_server / is_connected() (the one wrapper that has callers —
4 dashboard status sites) / configured_clients() / reload_config().
~150 lines deleted, 5 dead-method tests removed.

(2) Contract Protocol body trimmed to match REQUIRED_METHODS exactly
(is_connected, ensure_connection, get_all_artists, get_all_album_ids).
The other 5 methods that were declared in the Protocol
"required" section weren't actually required — Plex doesn't
implement get_recently_added_albums, Jellyfin doesn't implement
search_tracks, SoulSync doesn't implement most of them. Static
contract now matches runtime conformance test. Optional methods
moved to a KNOWN_PER_SERVER_METHODS data-only listing with audited
per-server coverage notes — discoverability without false promises.

(3) Engine module docstring + __init__.py docstring no longer
overclaim "33+ chains collapsed" — only 4 uniform-shape chains
were collapsed; ~18 server-specific chains stay explicit per the
"lift what's truly shared" standard. Phrasing now matches reality.

(4) types.py docstring claimed TrackInfo.from_jellyfin_dict and
TrackInfo.from_navidrome_dict exist as classmethods. They don't —
only from_plex_track / from_plex_playlist do. Jellyfin and Navidrome
construct TrackInfo inline at their call sites today. Docstring
now honest about that + flags the lift as a clean followup.

(5) Engine line 95 comment "backward-compat for source-specific
reaches" was misleading — there is no legacy alternative being
preserved; engine.client(name) IS the canonical access pattern.
Section header rewritten.

Tests: 2121 pass (was 2126; -5 dead-method pin tests).
2026-05-05 22:36:05 -07:00
Broque Thomas
99f527ecd1 MS pre-review polish followup — log levels + docstring honesty
Three nits I missed in the prior pass:

(1) Two more exception swallows still at logger.debug — the
get_recently_added_albums wrapper and the configured_clients
inner loop. My earlier sed only matched single-line patterns.
Both now log at warning level so a broken Plex / Jellyfin
surfaces in the boot log instead of silently returning [].

(2) registry.py module docstring still claimed it replaced
"33 hand-maintained dispatch sites" — same overclaim I fixed
on the engine docstring. Server-specific chains stay explicit
in web_server.py per the "lift what's truly shared" standard;
the registry just owns name → client lookup. Docstring rewritten
to match reality.
2026-05-05 21:25:27 -07:00
Broque Thomas
860f9a0a8c MS pre-review polish — encapsulation + visibility + tests
Five tightening passes anticipating Cin / JohnBaumb's review nits:

(1) Engine no longer reaches into ``registry._instances`` private
attr. New public ``MediaServerRegistry.set_instance(name, client)``
method — engine constructor calls it for the ``clients=`` pre-built
case so internal storage stays encapsulated.

(2) Engine module docstring no longer overclaims. Originally said it
"Replaces the historic 33+ if/elif chains" — but only the four
uniform-shape ``is_connected`` chains were collapsed. The 19 chains
that do server-specific work (Plex raw API vs Jellyfin / Navidrome
client methods returning different shapes) stay explicit per the
"lift what's truly shared" standard. Docstring rewritten to say
exactly that.

(3) Per-method exception swallows upgraded from ``logger.debug`` to
``logger.warning``. Returning safe defaults stays the right behavior
for a read-side engine (Plex offline shouldn't crash the app), but
silent debug-level swallowing made debugging hard — JohnBaumb pushed
the download engine to surface real errors. Same treatment here:
default still safe, but the warning tells you Plex is down.

(4) ``_safe_init_media_client`` in web_server.py now logs the
exception type + traceback. Broad ``except Exception`` is still
intentional (any failure means that one server can't be used; the
others stay up) but the boot log now distinguishes config errors
(ConnectionError, AuthenticationError) from import / dependency
failures.

(5) Two new tests pin the encapsulation + fallback contracts:
- ``test_engine_with_empty_clients_dict_is_safe_to_use`` — empty
  engine returns safe defaults on every method, doesn't raise.
- ``test_engine_uses_registry_set_instance_not_private_attr`` — spy
  on registry.set_instance verifies engine uses the public method.
2026-05-05 21:04:14 -07:00
Broque Thomas
f230c93890 Merge remote-tracking branch 'origin/dev' into refactor/media-server-engine
# Conflicts:
#	core/matching_engine.py
#	services/sync_service.py
#	web_server.py
#	webui/static/helper.js
2026-05-05 20:36:31 -07:00
Broque Thomas
2ebaf2e6e3 MS Gap 1: Lift shared TrackInfo + PlaylistInfo to neutral types module
Plex / Jellyfin / Navidrome each defined a near-identical
XTrackInfo (id / title / artist / album / duration / track_number /
year / rating) and XPlaylistInfo (id / title / description /
duration / leaf_count / tracks). Three classes that grew up by
copy-paste — not a real contract difference.

Lifted both to core/media_server/types.py as canonical TrackInfo +
PlaylistInfo. Per-server constructors live as classmethods on the
unified class (TrackInfo.from_plex_track, PlaylistInfo.from_plex_playlist)
matching the metadata Album.from_X_dict pattern Cin's POC uses.
Heavy plexapi imports stay lazy under TYPE_CHECKING.

- core/plex_client.py / jellyfin_client.py / navidrome_client.py:
  per-server XTrackInfo / XPlaylistInfo dataclass definitions
  removed; each module now imports TrackInfo + PlaylistInfo from
  the neutral package and uses the shared name internally.
- core/matching_engine.py: was annotating callers with PlexTrackInfo
  even though sync_service hands it Jellyfin / Navidrome instances
  at runtime when those servers are active. Annotation is now the
  unified TrackInfo, so signatures match the actual contract.
- services/sync_service.py: same import + annotation update.
2026-05-05 18:25:28 -07:00
Broque Thomas
a6bb5f5b43 MS Cin-5: Drop per-server globals — engine owns the clients
Per-server web_server.py globals (plex_client / jellyfin_client /
navidrome_client / soulsync_library_client) are gone. The engine now
owns the per-server client instances; web_server.py constructs them
inline into the engine init and routes everything through
media_server_engine.client('<name>').

Multi-client consumers refactored to take the engine instead of
separate per-server kwargs:

- services/sync_service.py: PlaylistSyncService.__init__ now takes
  media_server_engine. Internal _get_active_media_client resolves the
  active server's client through self._engine.client(name) instead of
  the per-server self.X_client attributes.
- core/listening_stats_worker.py: ListeningStatsWorker takes
  media_server_engine. The plex/jellyfin/navidrome dispatch in _poll
  collapses to engine.client(active_server) (gated to those three
  servers — SoulSync standalone has no listening data).
- core/web_scan_manager.py: WebScanManager takes media_server_engine
  instead of the hand-keyed media_clients dict that drifted out of
  sync with the engine.
- core/discovery/sync.py: SyncDeps holds media_server_engine instead
  of plex_client / jellyfin_client. Playlist-image dispatch routes
  through engine.client(name).

Web_server.py:
- Per-server globals removed from the chained `= None` init line
  + their try/except construction blocks. Replaced with a
  _safe_init_media_client(factory, name) helper that captures
  per-server init failures + passes the resulting clients straight
  into the MediaServerEngine init dict.
- All construction sites (PlaylistSyncService, WebScanManager,
  ListeningStatsWorker, SyncDeps, library_check) updated to receive
  the engine instead of per-server clients.

Test fixtures (tests/discovery/test_discovery_sync.py) gain a
_FakeMediaServerEngine stub + the SyncDeps build helper passes
that instead of separate plex/jellyfin clients.
2026-05-05 18:05:45 -07:00
Broque Thomas
49f7679eef MS Cin-1 + Cin-2: Explicit contract inheritance + generic accessors
Apply the Cin-1 / Cin-2 pattern from the download refactor PR to the
media server engine PR before review.

Cin-1 — explicit inheritance:
- PlexClient, JellyfinClient, NavidromeClient, SoulSyncClient now
  explicitly inherit MediaServerClient instead of relying on
  structural typing alone. Pre-change a reader of plex_client.py
  had no way to know the class was supposed to satisfy the contract.
- Removed the engine + registry re-exports from
  core/media_server/__init__.py to break the circular import that
  the inheritance change introduced (importing the package now
  triggered a chain that loaded clients before their base class
  resolved). Submodules import directly: from
  core.media_server.engine import MediaServerEngine, etc.
- Conformance test now also asserts isinstance() / issubclass()
  against MediaServerClient — drift in any class fails at the test
  boundary instead of at runtime.

Cin-2 — generic accessors + singleton:
- engine.configured_clients() — replaces the legacy per-server
  `if X and X.is_connected(): clients[name] = X` chains in
  web_server.py.
- engine.reload_config(name=None) — generic dispatch, so callers
  pass the server name instead of reaching for plex_client.reload_config()
  directly.
- get_media_server_engine() / set_media_server_engine() singleton
  factory matching the get_metadata_engine() / get_download_orchestrator()
  shape. web_server.py boots via set_media_server_engine(...) so
  factory + global handle share state.
- 7 new tests pin the accessors + singleton behaviour.
2026-05-05 16:59:05 -07:00
Broque Thomas
2c0a0da9ea Address Copilot doc-drift review
Four stale doc/comment references caught by Copilot's pass:

- core/download_plugins/base.py: TYPE_CHECKING comment said the
  shared dataclasses lived in core.soulseek_client. They were moved
  to core.download_plugins.types in this PR. Comment updated.
- core/qobuz_client.py: reload_credentials docstring still referenced
  soulseek_client.client('qobuz') after the global rename to
  download_orchestrator. Updated to download_orchestrator.client(...).
- webui/static/helper.js: the older WHATS_NEW entries for the plugin
  contract + engine refactor still claimed backward-compat
  self.<source> attributes were preserved. Followup commits in the
  same PR removed them. Each entry now flags the followup explicitly
  and points at the "Drop Backward-Compat Per-Source Attrs" entry
  above it so the changelog is internally consistent.
- docs/download-engine-refactor-plan.md: Compatibility commitments
  section listed orchestrator.<source> attribute preservation as a
  guarantee. Cin's review pass removed those attrs (and renamed the
  global handle from soulseek_client to download_orchestrator) — both
  are breaking changes for in-tree callers (which were migrated) and
  in-flight branches (which will need to update). Section rewritten
  to document the actual outcome.
2026-05-05 15:46:48 -07:00
Broque Thomas
c4c922c40f Surface engine-not-wired errors + exclude soulseek from monitor aggregation
Two findings from JohnBaumb on the engine refactor.

(1) Every download client returned None when self._engine was None,
just logging an error. The orchestrator's download_with_fallback
treated None as "source declined", so the user got no feedback —
download silently disappeared. Now each client raises a RuntimeError
on the engine-not-wired path. download_with_fallback already catches
plugin exceptions, logs a warning, and tries the next source — so
the visible behavior is "real error in logs + fallback to next
source" instead of "silent drop". Six clients touched (deezer, hifi,
qobuz, soundcloud, tidal, youtube). Pinning tests updated to expect
raise.

(2) Monitor's engine.get_all_downloads() walked every plugin
including soulseek, but the same monitor loop already pulled slskd
transfers via the transfers/downloads endpoint a few lines earlier —
soulseek's records were being fetched twice per tick. Same issue in
web_server.py's get_cached_transfer_data path. Added an exclude
parameter to engine.get_all_downloads(); both call sites now pass
('soulseek',). New test pins the exclude semantic.

Also fixed a stray 8-space over-indent on the for-loop body in
get_cached_transfer_data (cosmetic, JohnBaumb flagged the same
pattern in monitor.py earlier).
2026-05-05 12:20:51 -07:00
Broque Thomas
2c19d7d1f2 Per-source lock sharding on the engine
Per JohnBaumb: the single state_lock serialized progress callbacks
across every source. Pre-refactor each client owned its own download
lock, so Deezer / YouTube / Tidal workers never blocked each other.
Multi-source concurrent downloads under the unified lock fought for
the same RLock on every progress update.

Replaced the engine-wide state_lock with per-source RLocks. Each
source gets its own lock, lazily created via _source_lock() on first
use (meta-lock guards the create-race). All record mutations
(add/update/update_unless_state/remove/get/iter) take only that
source's lock — Deezer progress updates no longer block Tidal writes.

Cancelled-preserve semantics still hold because cancel + worker
terminal write target the same source, so they share that source's
lock. New test pins lock independence: holding source-A's lock from
one thread does not block a write on source-B from another.
2026-05-05 11:56:09 -07:00
Broque Thomas
a5fde0502a Engine state: nested-dict layout for O(source) iteration
Per JohnBaumb's review: iter_records_for_source() walked every
(source, id) tuple across the entire engine state to filter one
source — O(total_records) instead of O(source_records). Fine in
practice because total active downloads is usually small, but the
shape was wrong.

Switched the engine's _records storage from a single composite-key
dict (Dict[Tuple[str, str], DownloadRecord]) to a nested dict
(Dict[str, Dict[str, DownloadRecord]]). Per-source iteration now
only touches that source's bucket. add/get/update/remove all
adjusted to the nested layout. remove_record drops the empty source
bucket so future iterations don't see stale source keys.

Public surface unchanged. New test pins the empty-bucket-cleanup
behavior.
2026-05-05 11:46:44 -07:00
Broque Thomas
ea04cd5879 Address Copilot review nits
Three small follow-ups from the Copilot review of the rename PR:

- services/sync_service.py: PlaylistSyncService.__init__'s
  download_orchestrator parameter was annotated as SoulseekClient,
  which was misleading (the object passed is the DownloadOrchestrator
  with .search_and_download_best, .download, etc — not a SoulseekClient).
  Switched the import + annotation to DownloadOrchestrator so type
  checking + IDE help match reality.
- tests/test_qobuz_credential_sync.py: docstring still referenced the
  old soulseek_client global handle; updated to download_orchestrator
  to match the rest of the codebase.
- core/downloads/monitor.py: the `for download in all_downloads` body
  was over-indented (8 spaces past the for instead of 4) — purely
  cosmetic but easy to mis-edit. Re-indented to one level.
2026-05-05 11:22:01 -07:00
Broque Thomas
da424d4bf6 Treat 'type beat' as a wrong-version keyword
A "type beat" is an instrumental track produced in another artist's
style, uploaded to SoundCloud and tagged with that artist's name to
game search ranking. They show up as candidates for major-label
tracks (e.g. "Eminem - Greatest (Kamikaze) Type Beat - Sit Down" for
"Greatest" by Eminem) and have nothing to do with the real song.

Add 'type beat' to the version-keyword list so the scorer applies the
0.4x penalty + flags the result as wrong_version. Currently the
matcher rejects them via low text-similarity scores anyway, but the
explicit keyword makes the rejection deterministic and gives a clear
diagnostic in the logs / modal.
2026-05-05 10:42:05 -07:00
Broque Thomas
2aff3dc210 Filter SoundCloud previews at every entry point + fix hybrid fallback regression
The earlier validation-only filter only ran in the auto-search
scoring path. SoundCloud preview snippets still leaked through:

- The candidate-review modal cached raw search results (pre-validation),
  so previews were visible and clickable for manual retry — and the
  manual-pick download path bypassed validation entirely, downloading
  the preview anyway.
- The not-found raw-results cache stored unfiltered top-20s.

Lift the preview filter into a reusable filter_soundcloud_previews()
helper and apply it at every entry point: validation scoring (still),
modal-cache fallback when validation drops everything, and the
not-found raw-results path. Previews now never reach the cache, the
matcher, or the manual-pick UI. Drops candidates < 35s or below half
the expected duration, gated on expected > 60s so genuine short
tracks still pass. 7 new unit tests pin the helper.

Also fixed a silent regression in core/downloads/task_worker.py's
hybrid-fallback path. Cin-5 dropped the per-source attrs from the
orchestrator (orch.soulseek, orch.youtube, etc.), but the fallback
loop still resolved sources via getattr(orch, '<src>', None) — every
lookup silently returned None, so remaining_sources came back empty
and the fallback never ran. Now uses orch.client(name) like the rest
of the codebase. Updated the test fake to expose client() too — the
old test was passing because the loop was effectively dead.
2026-05-05 10:26:25 -07:00
Broque Thomas
563204ceae Drop SoundCloud preview snippets before scoring
SoundCloud serves a ~30s preview clip for tracks gated behind Go+
or login (extremely common for major-label uploads — what's actually
on SoundCloud is bootlegs, fan reuploads, type beats, and these
previews). yt-dlp accepts the preview as the download payload, the
post-download integrity check catches the duration mismatch and
quarantines the file, but the user only sees "all candidates failed"
with no obvious explanation.

Filter at validation time when we know expected_duration: drop
SoundCloud candidates whose duration is below half the expected
length OR within ~5s of the 30s preview boundary, gated on
expected being non-trivially long (>60s) so genuinely short tracks
still pass through.
2026-05-05 09:50:37 -07:00
Broque Thomas
d17365296a Lift shared download dataclasses + boot via singleton factory
Two architectural cleanups on top of the download engine refactor.

(1) Shared dataclasses move to neutral plugin package.
TrackResult, AlbumResult, DownloadStatus, SearchResult lived in
core/soulseek_client.py for historical reasons — every other plugin
imported them from the soulseek module just to satisfy the contract,
coupling 8 clients to a sibling source for type imports only. Moved
them to the new core/download_plugins/types.py module and updated all
14 import sites across the deezer/hifi/lidarr/qobuz/soundcloud/tidal/
youtube clients, the engine, matching engine, redownload helper, and
tests. Clean break, no backward-compat re-export.

(2) web_server.py boots the orchestrator via the singleton factory.
After construction it now calls set_download_orchestrator(...) so
get_download_orchestrator() returns the same instance the global
handle points at instead of lazily building a separate orchestrator.
Matches the get_metadata_engine() pattern.
2026-05-05 09:08:39 -07:00
Broque Thomas
61ba3a15de Cin-6: Rename soulseek_client global → download_orchestrator
The global handle in web_server.py was named soulseek_client for
historical reasons but the type has long been DownloadOrchestrator,
not SoulseekClient. Renamed the global plus every parameter/attribute
that carried the legacy name.

- web_server.py: global var renamed; all 99 references updated.
- api/, core/downloads/*, core/search/*, core/streaming/*,
  services/sync_service.py: parameter names, dataclass fields, and
  init() arg names renamed.
- Test fixtures (CandidatesDeps, MasterDeps, SearchDeps, etc.) and
  the _build_deps helpers updated accordingly.

The core.soulseek_client module path and SoulseekClient class name
(the actual soulseek-only client) are unchanged — only the orchestrator
handle renamed. Module imports of TrackResult/AlbumResult/DownloadStatus
from core.soulseek_client preserved.
2026-05-04 23:23:32 -07:00
Broque Thomas
7519c3d50c Cin-5: Drop per-source attrs from orchestrator
Removed the eight backward-compat attribute aliases on the orchestrator
(soulseek, youtube, tidal, qobuz, hifi, deezer_dl, lidarr, soundcloud).
External callers and the orchestrator's own internals now reach clients
through the generic alias-aware client(name) accessor.

- core/downloads/{master,monitor,validation}.py: migrated to client().
  Monitor's per-source aggregation loop replaced with a single
  engine.get_all_downloads() call.
- core/search/{orchestrator,stream}.py: migrated; stream.py drops the
  hand-built mode-to-client dict.
- web_server.py: migrated /api/deezer/arl-* + tidal client lookup.
- core/download_orchestrator.py: internal self.soulseek /
  self.deezer_dl reaches now route through self.client(); attr
  assignments dropped from __init__; module docstring updated.
- Test fakes (_FakeSoulseek, _FakeSoulseekWithYT) expose client(name)
  instead of stuffing per-source attributes.
- Conformance test re-pinned to the client() accessor contract.
2026-05-04 23:14:05 -07:00
Broque Thomas
d0eac87601 Cin review: alias resolution, atomic terminal write, generic accessors
Three correctness fixes from kettui's PR review plus the web_server
migration to generic accessors.

- Engine alias map: register_plugin accepts aliases tuple; get_plugin
  + cancel_download resolve through it. Fixes deezer_dl cancels
  silently routing to soulseek.
- Orchestrator hybrid_order normalization: _resolve_source_chain
  routes raw config names through registry.get_spec() so legacy
  deezer_dl entries don't drop deezer from hybrid mode.
- Atomic update_record_unless_state on the engine: holds state_lock
  across the check + write. Both _mark_terminal AND the success path
  use it now so a Cancelled state set mid-impl can't be clobbered.
- web_server.py: 30 soulseek_client.<source> reaches migrated to
  client("<source>"); shutdown-check setup migrated to generic
  registry iteration; 4 hifi reload sites use reload_instances('hifi').
- 18 new tests pin every fix.
2026-05-04 22:58:46 -07:00
Broque Thomas
6a75d656fa Cin-2: Generic accessors on orchestrator + singleton factory
Cin's review feedback: external callers reach per-source clients
via attribute access (orch.hifi.reload_instances()) — needs
generic accessors so the registry IS the single source of truth.

Adds:
- orch.client(name) — public accessor for a per-source client.
  Resolves canonical names (deezer) AND legacy aliases (deezer_dl).
- orch.configured_clients() — returns {name: client} for every
  initialized AND is_configured() == True source. Replaces the
  6+ if/hasattr/is_configured chain Cin called out:
    if hasattr(orch, 'soulseek') and orch.soulseek and \
       orch.soulseek.is_configured(): ...
- orch.reload_instances(source=None) — generic dispatch for
  source-specific reload calls. Replaces orch.hifi.reload_instances()
  with orch.reload_instances('hifi').
- get_download_orchestrator() / set_download_orchestrator()
  singleton factory matching Cin's get_metadata_engine pattern in
  PR #498. web_server.py can install the orchestrator it builds
  at boot so future callers grab via the factory instead of
  importing the legacy `soulseek_client` global.

Phase Cin-3/Cin-4 will replace existing call sites; this commit
just provides the surface so those migrations are mechanical.

Suite still green (335 download tests + 6 new generic-accessor
tests).
2026-05-04 22:35:45 -07:00
Broque Thomas
ea654f664e Cin-1: Make DownloadSourcePlugin inheritance explicit on every client
Cin's review feedback: the plugin contract was discoverable only
from the registry, not from the client files themselves. Reading
`youtube_client.py` cold gave no signal that the class participates
in the DownloadSourcePlugin contract.

Every download client class now inherits DownloadSourcePlugin
explicitly:
- SoulseekClient(DownloadSourcePlugin)
- YouTubeClient(DownloadSourcePlugin)
- TidalDownloadClient(DownloadSourcePlugin)
- QobuzClient(DownloadSourcePlugin)
- HiFiClient(DownloadSourcePlugin)
- DeezerDownloadClient(DownloadSourcePlugin)
- SoundcloudClient(DownloadSourcePlugin)
- LidarrDownloadClient(DownloadSourcePlugin)

Adjustments:
- core/download_plugins/base.py — moved TrackResult/AlbumResult/
  DownloadStatus imports under TYPE_CHECKING since they're only
  used in type annotations. Without this, clients inheriting the
  contract create a circular import.
- core/download_plugins/__init__.py — drops DownloadPluginRegistry
  re-export. Importing the package no longer triggers the registry's
  eager client imports (which would also be circular for clients
  that import from the package). Callers that need the registry
  import it directly: `from core.download_plugins.registry import
  DownloadPluginRegistry`.

Suite still green (335 download tests).
2026-05-04 22:19:52 -07:00
Broque Thomas
971d683ebd C1: Migrate status-check dispatch sites to engine
Two sites in web_server.py replaced:
- /status route's media-server connectivity check (4-way if/elif
  for plex/jellyfin/navidrome/soulsync) → engine.is_connected()
- /api/playlists endpoint's server_connected check (3-way if/elif)
  → engine.is_connected()

Engine reads active_server config + dispatches to the right client
with internal connection caching preserved (the underlying clients
all cache is_connected() calls).

Engine constructor now accepts a pre-built clients={...} dict so
web_server.py wires the same instances as its existing per-client
globals — no double-init.

Suite still green. Per-server clients still accessible via
engine.client(name) for source-specific reaches.
2026-05-04 21:25:20 -07:00
Broque Thomas
6b54ca6598 Phase B: Add MediaServerEngine skeleton
`MediaServerEngine` reads the active server from config + dispatches
to the corresponding registered client. Per-server reaches still
work through `engine.client(name)`.

Required-method dispatch (is_connected, ensure_connection,
get_all_artists, get_all_album_ids) returns safe defaults when
the active client failed to initialize OR when the method raises.

Optional-method dispatch (search_tracks, trigger_library_scan,
is_library_scanning, get_library_stats, get_recently_added_albums)
checks hasattr first — SoulSync standalone has no
trigger_library_scan or get_library_stats, engine no-ops with
appropriate defaults instead of forcing every client to declare
stub methods.

10 new engine tests pin: active-server resolution, required
dispatch routing, exception safety, missing-optional-method
fallback shape. Suite still green (1951 passed).

Engine isn't on any production code path yet — Phase C migrates
the 33 web_server.py dispatch sites to call engine.method()
instead of hand-branching by active_server name.
2026-05-04 21:14:17 -07:00
Broque Thomas
f702196dca Phase 0: Add MediaServerClient contract + registry
`core/media_server/` package with the Protocol contract that
every media server client (Plex, Jellyfin, Navidrome, SoulSync
standalone) satisfies, plus the registry that holds them.

Required methods conservatively limited to the four every server
truly implements today: is_connected, ensure_connection,
get_all_artists, get_all_album_ids. Other generic methods
(search_tracks, trigger_library_scan, get_recently_added_albums,
etc.) are listed as OPTIONAL — present on most servers but not
all (SoulSync has no library-scan API since it walks the filesystem
directly; Jellyfin uses a different search shape). Phase B's
engine adapters route around the gaps with per-server fallback
instead of forcing every client to declare a no-op stub.

Same registry shape as the download plugin registry — single
source of truth for which servers exist + name resolution. Adding
a 5th server (Subsonic, Emby, etc.) becomes one register call
plus the new client class.

5 conformance tests pin every server class implements every
required method. Plan doc at docs/media-server-engine-refactor-plan.md.

Pure additive — no consumer routes through the contract or
registry yet. Suite still green (1921 passed).
2026-05-04 20:49:25 -07:00
Broque Thomas
0ee092979e Self-review fixes before opening PR
Three findings from a final review pass:

1. **Worker clobbered Cancelled with Errored when impl returned
   None / raised mid-cancel.** The legacy per-client thread workers
   each had a guard (``if state != 'Cancelled': state = 'Errored'``);
   the shared worker dropped it. Fix: new ``_mark_terminal`` helper
   in BackgroundDownloadWorker reads current state before writing
   the terminal one and leaves Cancelled alone. SoundCloud test
   updated back to the strict Cancelled-only assertion (had been
   loosened to accept Errored as a workaround). Two new pinning
   tests catch the regression.

2. **Dead code in engine.py.** ``find_record`` and
   ``iter_all_records`` had no production callers — only tests.
   Removed them. Concurrent-add stress test rewritten to use the
   per-source iterator that's actually in use.

3. **Silent ``except Exception: pass`` in cross-source query
   methods.** Faithful to legacy behavior (one source failing
   shouldn't take down aggregation) but Cin's standard is "log
   even when you swallow." Each silent-swallow site now logs at
   debug level so the source name + exception are inspectable
   without adding warning-level noise.

Suite still green (2049 passed).
2026-05-04 16:24:13 -07:00
Broque Thomas
3a70f0453c G: Wire YouTube progress hook to engine + drop dead threading imports
YouTube's _progress_hook still wrote to the per-client
active_downloads dict + _download_lock that Phase C2 deleted —
runtime crash waiting to happen. Rewritten to use
engine.update_record. Same state-dict shape, same UI semantics
(95% during ffmpeg postprocess, 'Errored' on yt-dlp error,
'InProgress, Downloading' during stream).

Drop unused `import threading` from youtube/tidal/soundcloud
clients (no longer spawn threads — engine.worker owns that).
Qobuz/HiFi/Deezer keep their threading import for module-level
or per-instance API locks (separate from download threading).

Suite still green (2050 passed).
2026-05-04 15:21:32 -07:00
Broque Thomas
4b4076b57f F: Engine owns hybrid fallback chain
`engine.search_with_fallback(query, source_chain, ...)` walks the
chain in order, skips unconfigured / unregistered plugins,
swallows per-source exceptions, and returns the first non-empty
(tracks, albums) tuple. Replaces orchestrator's hand-rolled
hybrid search loop.

`engine.download_with_fallback(username, filename, file_size,
source_chain)` falls through the chain when a source returns
None / raises. Username hint promotes a matching source-chain
entry to head of order. NOT yet wired into orchestrator.download
— today's username comes from a search result and represents
the user's explicit source pick, so silently falling through
would override their choice. Engine method is available for
future callers that want fallback semantics
(search_and_download_best, automation).

Orchestrator gains _resolve_source_chain helper that builds
the ordered list (hybrid_order config, falling back to legacy
primary/secondary pair). Orchestrator.search hands chain off
to engine.search_with_fallback for hybrid mode.

8 new tests pin the fallback semantics: chain ordering,
unconfigured-skip, exception-continue, empty-when-exhausted,
username-hint promotion. Suite still green (2050 passed).
2026-05-04 14:58:27 -07:00
Broque Thomas
1062589501 E2: Migrate YouTube to declared rate-limit policy
YouTubeClient gains rate_limit_policy() that returns a
RateLimitPolicy with the configured download_delay (3s default
from `youtube.download_delay`). Engine reads this at
register_plugin time + applies to engine.worker.

set_engine still re-applies the delay so runtime reload_settings
updates flow through the same pathway. Other sources keep the
default policy (concurrency=1, delay=0) which matches their
current behavior — no migration needed beyond YouTube which is
the only source with a non-default download throttle today.

New pinning test asserts the policy shape (delay=3.0, concurrency=1).
Suite still green (2042 passed).
2026-05-04 14:45:52 -07:00
Broque Thomas
a3929b457b E1: Add RateLimitPolicy declaration mechanism
`core/download_engine/rate_limit.py` introduces a per-source
policy declaration: download_concurrency + download_delay_seconds.
Plugins declare via `RATE_LIMIT_POLICY` class attribute or a
`rate_limit_policy()` method.

Engine applies the declared policy to engine.worker at
register_plugin time — set_concurrency + set_delay get pushed
in automatically. Plugins without a declaration get the
conservative default (1 / 0). The set_engine callback fires
AFTER policy registration so config-driven sources (YouTube
reads user-tunable youtube.download_delay) can override.

Plan doc updated to reflect Phase D skip (search code is 90%
source-specific, not 60% — lifting it would be lossy or
bloated).

Pure additive — no plugin migrated yet. 8 tests pin the
resolution priority + engine wire-up + override semantics.
Suite still green (327 download tests).
2026-05-04 14:38:20 -07:00
Broque Thomas
fdb3e44965 C7: Migrate SoundCloud to engine.worker
Last C-phase migration. Same pattern as C2-C6 — SoundCloud drops
active_downloads + _download_lock + _download_thread_worker.
download() delegates to engine.worker.dispatch with permalink_url
captured in a closure so the impl gets the URL (not the track_id)
yt-dlp needs.

Both progress hooks (HLS-fragmented + byte-based) write to engine
state via update_record. Query/cancel methods read engine state.

Existing test_soundcloud_client.py mass-updated: 16 tests that
reached into client.active_downloads / _download_lock now use
engine.add_record / get_record / update_record via a small
_wire_engine helper. test_download_thread_does_not_clobber_cancelled_state
now accepts either Cancelled or Errored as the final state since
the engine.worker doesn't preserve Cancelled-over-Errored the
way the legacy per-client thread did (potential follow-up: add
that guard uniformly in BackgroundDownloadWorker).

Phase A pinning tests updated. Suite still green (2033 passed).
2026-05-04 14:24:50 -07:00
Broque Thomas
cf438ba2d6 C6: Migrate Deezer to engine.worker
Same migration pattern as C2-C5. Deezer-specific quirks
preserved through worker overrides:
- username_override='deezer_dl' (legacy slot frontend reads)
- thread_name='deezer-dl-<track_id>' (diagnostic naming)
- track_id stays as STRING (Deezer GW API uses string IDs)
- Extra 'error' slot in record for ARL re-auth failure messages

Mid-download chunk loop's many state mutations (cancellation
checks, progress updates, error capture across multiple failure
modes) all flow through engine.update_record / get_record now.
Added _set_error and _is_cancelled helpers to keep call sites
readable.

Pinning tests updated. Suite still green (319 download tests).
2026-05-04 14:02:03 -07:00
Broque Thomas
27a97f8af6 C5: Migrate HiFi to engine.worker
Same pattern as C2/C3/C4. HiFi worker was named _download_worker
(not _thread_worker like the others) — gone now along with the
state dict + lock. Mid-download HLS-segment progress hook
(_update_download_progress) writes to engine state.

Pinning tests updated. Suite still green (318 download tests).
2026-05-04 13:57:01 -07:00
Broque Thomas
7944568c5c C4: Migrate Qobuz to engine.worker
Same migration pattern as C2/C3. QobuzClient drops
active_downloads + _download_lock + _download_thread_worker.
Mid-download chunk-progress mutations + cancel-state checks
flow through engine.update_record / get_record. Query/cancel
methods read engine state.

Pinning tests updated to assert engine state. Suite still green
(316 download tests).
2026-05-04 13:52:55 -07:00
Broque Thomas
73fb60a68a C3: Migrate Tidal to engine.worker
Same pattern as C2 — TidalDownloadClient drops active_downloads
+ _download_lock + _download_thread_worker. download() delegates
to engine.worker.dispatch with _download_sync as the impl.
Source-specific extras (track_id, display_name) merge into the
engine record.

The HLS-segment progress callback (_update_download_progress)
now writes to engine state via engine.update_record instead of
mutating the per-client dict in-place.

Query/cancel methods (get_all_downloads, get_download_status,
cancel_download, clear_all_completed_downloads) now read engine
state via the same accessors as the YouTube migration.

Pinning tests updated to assert engine state. Suite still green
(313 download tests). Behavior preserved end-to-end.
2026-05-04 13:49:36 -07:00
Broque Thomas
4ddfb01a0a C2: Migrate YouTube to engine.worker
YouTubeClient drops its hand-rolled background thread + state
dict + semaphore + last-download-timestamp. download() now
delegates to engine.worker.dispatch with _download_sync as the
impl callable; YouTube-specific record fields (video_id, url,
title) merge into the engine record via extra_record_fields.

Engine wires itself in via plugin.set_engine(engine) callback
on register_plugin. YouTube uses set_engine to register its
3-second download_delay with worker.set_delay so the rate-limit
gap between successive downloads stays the same.

Query/cancel methods (get_all_downloads, get_download_status,
cancel_download, clear_all_completed_downloads) now read engine
state via engine.iter_records_for_source / get_record /
update_record / remove_record. Net: ~120 LOC of thread+state
boilerplate removed from youtube_client.py.

Phase A pinning tests updated to assert engine state instead of
client.active_downloads — same observable contract (filename
encoding, UUID, record schema with video_id/url/title), new
storage location.

Suite still green (2025 passed). Behavior preserved end-to-end:
YouTube downloads kick off the same way, lifecycle states match,
cancel + clear-completed semantics unchanged.
2026-05-04 13:38:18 -07:00
Broque Thomas
78724861f9 C1: Add BackgroundDownloadWorker to engine
`BackgroundDownloadWorker` lives on the engine and owns the
boilerplate every streaming download client currently
hand-rolls: thread spawn, per-source semaphore, rate-limit
delay, state lifecycle (Initializing → InProgress → Completed
or Errored), exception capture.

Plugins provide only the atomic download op (`impl_callable`).
Per-source rate-limit policy (concurrency, delay) is configured
on the worker via `set_concurrency` / `set_delay`. Source-
specific record fields merge in via `extra_record_fields` so
existing consumer code that reads `video_id`, `track_id`,
`permalink_url`, etc. keeps working post-migration. Username
slot supports override (Deezer's legacy `'deezer_dl'`).

Phase C1 scope: worker exists. No client migrated yet — C2-C7
migrate sources one at a time, each gated by the Phase A
pinning tests so per-source contract drift fails fast.

10 new tests pin the worker contract: UUID id format, initial
record shape, extra-fields merge, username override, state
transitions on success / impl-returns-None / impl-raises,
semaphore serialization (default + parallel), rate-limit
delay between successive downloads.

Suite still green (308 download tests). Pure additive.
2026-05-04 13:21:41 -07:00
Broque Thomas
3634dca83f B3: Orchestrator delegates query methods to engine
`get_all_downloads`, `get_download_status`, `cancel_download`, and
`clear_all_completed_downloads` on the orchestrator are now thin
pass-throughs to the engine. The plugin-iteration logic lives in
one place (the engine) instead of duplicated across orchestrator
methods.

Source-hint routing semantics preserved verbatim — engine.cancel
treats streaming-source names as direct routes and unknown names
as Soulseek peer usernames, exactly like the legacy orchestrator
did. Per-plugin exceptions still get swallowed defensively.

Test fixture `_build_orchestrator` now constructs an engine and
registers every mock plugin so the helper-built orchestrators
have the same wiring as production.

Suite still green (2012 passed). Zero behavior change for users.
2026-05-04 12:59:04 -07:00
Broque Thomas
badb5dd7de B2: Engine owns cross-source query dispatch
`DownloadEngine` grows async query methods that wrap plugin
iteration: `get_all_downloads` (concatenates every plugin's
active downloads), `get_download_status` (first plugin to
recognize the id wins), `cancel_download` (with source-hint
routing — streaming sources go direct, unknown hints route to
Soulseek as peer username), and `clear_all_completed_downloads`
(skips unconfigured plugins).

Code moved from the orchestrator's hand-iterated loops into the
engine. Orchestrator delegation comes in B3 — for B2 the engine
methods exist but nothing calls them yet.

Per-plugin behavior preserved verbatim (defensive `try ... except`
swallows per-iteration, unconfigured-skip on clear, source-hint
routing semantics). Phase A pinning tests + 8 new engine query
tests catch any drift.

Pure additive — zero behavior change for users.
2026-05-04 12:42:15 -07:00
Broque Thomas
f40c6d3b55 B1: Add DownloadEngine skeleton
`core/download_engine/` package with the engine class that will own
cross-source state, threading, search retry, rate-limits, and
fallback chains. Orchestrator constructs an engine and registers
each plugin with it.

Phase B1 scope: skeleton only. Engine stores active_downloads
records keyed by (source, download_id), provides thread-safe
add/update/remove/iterate primitives, and holds plugin references
for later phases. NOT on any code path yet — pure additive
scaffolding so subsequent commits can introduce engine-driven
behavior one piece at a time without a big-bang switchover.

15 new tests pin the engine's state-storage contract: shallow-copy
reads, partial-patch updates, no-op-on-missing semantics,
per-source iteration, id-only find, concurrent-add safety.

Suite still 290 (download subset) green. Zero behavior change.
2026-05-04 12:39:41 -07:00
Broque Thomas
5294065fe4 Wire orchestrator through plugin registry
Every per-source dispatch site (search, download, get_all_downloads,
get_download_status, cancel_download, clear_all_completed_downloads,
cancel_all_downloads, reload_settings) now iterates
`registry.all_plugins()` instead of hand-maintained client lists.

Backward-compat `self.soulseek` / `self.youtube` / etc. attributes
preserved as registry-resolved aliases — external callers reaching
for source-specific internals (e.g. `orchestrator.soulseek._make_request`)
keep working unchanged.

Adding a new source (Usenet planned) becomes one registry entry +
the new client class — no orchestrator changes.
2026-05-04 11:16:14 -07:00
Broque Thomas
19fbcf267d Add DownloadSourcePlugin contract + registry
`core/download_plugins/` defines the canonical interface every
download source must satisfy and the registry that holds them.
Single source of truth replacing the orchestrator's hardcoded
`[self.soulseek, self.youtube, ...]` lists scattered across 6+
dispatch sites.

Pure additive — no consumers wired through the registry yet.
2026-05-04 11:16:03 -07:00
Broque Thomas
cf5461f2f1 Fix: maintenance findings badge inflated when scan dedup-skipped
`_create_finding` silently dedup-skipped re-discovered issues but
the caller incremented `findings_created` regardless. So a re-scan
that found the same issues as a prior scan reported 364 findings
in the badge while 0 NEW pending rows hit the db, leaving the
findings tab empty.

`_create_finding` now returns bool (True on insert, False on
dedup-skip / db error). All 16 repair jobs updated to only
increment `findings_created` on True. Added `findings_skipped_dedup`
counter surfaced in scan log: "Done: X scanned, 0 fixed, 0
findings (363 already existed), 0 errors".

Also fixed a missing `job_id` kwarg in album_tag_consistency that
was silently breaking finding creation for that scan.
2026-05-04 08:55:13 -07:00
Broque Thomas
77c54ab7a7 Migrate discography + quality scanner to typed Album path
Three more album-shape consumers now route through
Album.from_<source>_dict() when caller passes a known source:
- _build_discography_release_dict (artist discography cards)
- _build_artist_detail_release_card (artist detail release cards)
- _normalize_track_album (quality scanner result normalization)

Legacy duck-typing stays as fallback for unknown source,
non-dict input, or converter errors. Pure additive — existing
callers without source kwarg unchanged.
2026-05-04 08:12:40 -07:00
Broque Thomas
967c7f7c0a Migrate album-info builders to typed Album path
Steps 2+3 of typed metadata migration. Two album-info builders now
route through Album.from_<source>_dict() when caller passes a
known source:
- _build_album_info (album-tracks lookups)
- _build_single_import_context_payload (single-track import context)

Legacy duck-typing stays as fallback for unknown source, non-dict
input, or converter errors. Pure additive — existing callers
without source kwarg unchanged.
2026-05-03 22:53:12 -07:00
Broque Thomas
eab1297afc Add Qobuz + Tidal album converters
Audit caught two missing providers from the foundation pr. Both
return album-shaped data via their clients (search + download
flows). Tidal uses tidalapi objects rather than dicts so the
converter is from_tidal_object, not _dict.

Enrichment-only providers (lastfm/genius/acoustid/listenbrainz/
audiodb) intentionally have no album converter — they enrich
existing rows, never return album shapes.

Tests: +8 cases. 40 total now.
2026-05-03 22:30:19 -07:00
Broque Thomas
529486a2d1 Foundation: typed Album/Track/Artist + per-provider converters
New core/metadata/types.py with canonical dataclasses + classmethod
converters for spotify/itunes/deezer/discogs/musicbrainz/hydrabase.
Each converter is the single place that knows that provider's wire
shape — addresses the duck-typing pattern Cin flagged.

Pure additive: no consumer code changed. Follow-up PRs migrate
consumers one at a time. Migration plan at
docs/metadata-types-migration.md.

Tests: 32 cases pin per-provider semantics + cross-provider
invariants. Also stabilized a flaky discogs test that depended on
local config state.
2026-05-03 22:21:32 -07:00
Broque Thomas
4b23bee4a9 Add Discogs collection as a Your Albums source
Discord request: pull user's Discogs collection into the Your Albums
section on Discover, similar to how Spotify Liked Albums works.
Implementation extends the existing 3-source pipeline (Spotify /
Tidal / Deezer) to a 4-source pipeline with click-context dispatch —
Discogs-only albums open with rich Discogs release detail (vinyl/CD
format, year, label, country, tracklist). Mirrors the per-source
dispatch pattern from enhanced/global search.

Discogs client (`core/discogs_client.py`):
- New `get_authenticated_username()` resolves the username for the
  configured personal token via Discogs's `/oauth/identity` endpoint.
  Cached on the instance so subsequent collection page-fetches don't
  re-hit it.
- New `get_user_collection(username=None, folder_id=0, per_page=100,
  max_pages=50)` walks all pages of `/users/{username}/collection/
  folders/{folder_id}/releases`. Returns normalized dicts ready for
  upsert_liked_album. folder_id=0 = Discogs's "All" folder.
  Pagination cap of max_pages*per_page = 5000 releases — bounds
  runtime on heavy collections.
- New `get_release(release_id)` thin wrapper for `/releases/{id}` —
  returns the raw API response so the album-detail endpoint can
  render rich context.
- Both methods defensive: missing token → empty list, malformed
  responses → skipped, falsy ids → None. Disambiguation suffix
  stripping (`Madonna (3)` → `Madonna`) so Discogs artist names
  match what Spotify/Tidal/Deezer use.

Schema (`database/music_database.py`):
- New `discogs_release_id TEXT` column on `liked_albums_pool`.
  Migration uses the established `try SELECT, except ALTER TABLE`
  pattern. Idempotent; safe on existing installs.
- Added the column to the canonical CREATE TABLE for fresh installs.
- `upsert_liked_album` extended with `'discogs': 'discogs_release_id'`
  in BOTH the INSERT and UPDATE id-column maps so Discogs source_id
  routes to the new column. INSERT statement column count + value
  count updated together.

Backend (`web_server.py`):
- `/api/discover/your-albums/sources` — adds Discogs to the
  `connected` list when `discogs.token` config is set.
- `_fetch_liked_albums` — new branch for Discogs. Lazy-imports
  DiscogsClient, respects the `enabled_sources` config, walks the
  collection, upserts each release. Same try/except shape as the
  existing source branches.
- `/api/discover/album/<source>/<album_id>` — new `discogs` branch
  fetches the release via DiscogsClient.get_release, normalizes the
  Discogs tracklist format, parses Discogs's `MM:SS`/`HH:MM:SS`
  duration strings to milliseconds, returns the same response shape
  as the Spotify/Deezer/iTunes branches.

Frontend (`webui/static/discover.js`):
- `openYourAlbumsSourcesModal` — adds Discogs to `sourceInfo` with
  the vinyl emoji icon. Existing toggle/save plumbing handles it.
- `openYourAlbumDownload` — restructured the per-source dispatch:
  builds an ordered list of (source, id) tuples, tries each in turn,
  breaks on the first successful response. Pure-Discogs albums go
  straight to the Discogs detail endpoint → modal opens with Discogs
  context. Multi-source albums prefer Spotify/Deezer first since
  their tracklists carry proper streaming IDs ready for download.

Tests: `tests/test_discogs_collection_source.py` — 12 cases:
- get_user_collection: empty without token, normalizes response
  shape, strips disambiguation suffix, handles missing year, skips
  malformed releases, paginates correctly, caps at max_pages,
  uses explicit username when provided.
- get_release: passes id through to /releases/{id}, returns None
  for invalid ids without API call.
- liked_albums_pool: discogs_release_id round-trips through upsert
  + get; multi-source dedup carries both Spotify and Discogs IDs
  on the same row.

Verified: full suite 1825 pass (12 new), ruff clean, smoke test
populating + reading the discogs_release_id column round-trips
correctly via the real DB.

WHATS_NEW entry under '2.4.2' dev cycle.
2026-05-03 21:27:46 -07:00
Broque Thomas
2ab460f5c4 Add Library Disk Usage card to System Statistics
Discord request (Samuel [KC]): show how much disk space the library
takes on the Stats page. Implementation piggybacks on the existing
deep scan — Plex/Jellyfin/Navidrome all return file size in their
track API responses, so we read it during the deep scan and store
it on the tracks row. Aggregation is then a single SQL query — no
filesystem walk, no extra I/O during the scan, no separate stat
job. SoulSync standalone gets size from os.path.getsize at insert
time (different code path; the file is local when we write the row).

Schema (`database/music_database.py`):
- New `file_size INTEGER` column on `tracks`. Migration uses the
  established `try SELECT, except ALTER TABLE ADD COLUMN` pattern.
  Idempotent; safe on existing installs. NULL on legacy rows so
  they don't contribute to totals until next deep scan refreshes.
- Added the column to the canonical CREATE TABLE so fresh installs
  get it without going through the migration path.

Track-object plumbing:
- `core/jellyfin_client.py` — JellyfinTrack reads MediaSources[0].Size
  alongside existing Bitrate read. None when 0 / missing.
- `core/navidrome_client.py` — NavidromeTrack reads `size` from
  the Subsonic song object (int coercion + None on parse fail).
- `core/soulsync_client.py` — SoulSyncTrack does os.path.getsize
  (only "server" where size has to come from disk).
- Plex needs no client-side change: track.media[0].parts[0].size
  is read directly inside insert_or_update_media_track.

Persistence — TWO separate insert paths:

(a) `database/music_database.py:insert_or_update_media_track` —
    Plex/Jellyfin/Navidrome flows. Reads file_size from Plex's
    MediaPart OR `track_obj.file_size` wrapper attribute (defensive
    Plex-attr-not-present check + > 0 type guard).
    INSERT writes the new column.
    UPDATE uses COALESCE(?, file_size) so a None from the server
    on a re-sync (rare Jellyfin Size omission) doesn't blank an
    existing value. Pinned via test.

(b) `core/imports/side_effects.py:record_soulsync_library_entry` —
    SoulSync standalone flow. Completely separate code path: the
    standalone deep scan moves files to staging for auto-import
    rather than calling insert_or_update_media_track. After the
    auto-import processes them, side_effects writes the tracks row
    directly. Reads file_size via os.path.getsize(final_path) at
    insert time (file is local) and includes it in the INSERT
    column list. SoulSync only does INSERT-if-not-exists (no
    UPDATE path), so no COALESCE concern.

Aggregator (`database/music_database.py:get_library_disk_usage`):
- SELECT COALESCE(SUM(file_size), 0), COUNT(file_size),
  COUNT(*) - COUNT(file_size) for the totals.
- Per-format breakdown done in Python via os.path.splitext over
  (file_path, file_size) rows — sidesteps SQLite's first-vs-last-dot
  ambiguity for paths like /music/Kendrick/M.A.A.D City/01.flac.
- Defensive: skips empty paths, paths without extension, and
  implausibly long extensions (>6 chars). Returns the full
  empty-shape dict (NOT a partial / undefined) when the column
  doesn't exist or queries fail, so the UI's `if (!data.has_data)`
  branch handles fresh installs cleanly.

API + UI:
- `core/stats/queries.py` — thin pass-through get_library_disk_usage
  matching the existing query-helper convention.
- `web_server.py` — new /api/stats/library-disk-usage endpoint
  mirroring the /api/stats/db-storage pattern.
- `webui/index.html` — new card in System Statistics above the
  Database Storage card.
- `webui/static/stats-automations.js` — _loadLibraryDiskUsage +
  _renderLibraryDiskUsage. Empty state: "Run a Deep Scan to
  populate (X tracks pending)". Partial: "X measured (+Y pending)".
  Full: total + format bars proportional to the largest format.
- `webui/static/style.css` — .stats-disk-* styled to match the
  Database Storage card.

Backward compatibility:
- Migration is additive; existing rows get NULL file_size; the
  empty-shape return from the aggregator means the UI renders
  cleanly without errors before any deep scan runs.
- Old installs upgrading will see "Run a Deep Scan to populate
  (N tracks pending)". Running their next deep scan fills sizes —
  the existing scan flow doesn't need any changes, just consumes
  the new track-wrapper attribute.

Tests:
- `tests/test_library_disk_usage.py` — 13 cases covering schema
  migration, NULL defaults on legacy inserts, fresh-install empty
  shape, summing with mixed NULL/known sizes, per-format breakdown,
  mixed-case extensions, paths with album-name dots, missing
  extensions, empty file_path, implausibly long extensions,
  JellyfinTrack.file_size persistence via insert_or_update_media_track,
  COALESCE preservation on null re-sync.
- `tests/imports/test_import_side_effects.py` — extended the
  existing record_soulsync_library_entry test to assert
  track_row['file_size'] == os.path.getsize(final_path), pinning
  the SoulSync-standalone path. Test fixture's tracks schema also
  updated to include the file_size column.

Verified: full suite 1813 pass (13 new, 1 existing-test extension),
ruff clean, smoke test populating + reading the column round-trips
correctly.

WHATS_NEW entry under '2.4.2' dev cycle.
2026-05-03 20:17:06 -07:00
Broque Thomas
776d195f71 Fix: ReplayGain wrote same +52 dB gain to every track
User report: every downloaded track in an album came out with
``replaygain_track_gain: +52.00 dB`` regardless of actual loudness.

Root cause: the parser at ``core/replaygain.py:79`` used
``re.search('I:\s+...')`` which returns the FIRST match. ffmpeg's
ebur128 filter emits ``I:`` per measurement window (running partial
integrated loudness) AND in a final Summary block. The first
per-window reading is at t=0.5s — almost always ~-70 LUFS because
nearly every track starts with silence / encoder padding. So:

    gain = RG2_reference - lufs = -18 - (-70) = +52.00 dB

…on EVERY track. Same regex pattern, same first per-window match,
same +52 dB written to every file's REPLAYGAIN_TRACK_GAIN tag.

Verified by running ffmpeg ebur128 against a real generated FLAC
and inspecting the stderr output — first per-window line at t=0.5s
shows ``I: -70.0 LUFS`` (silent intro), and the Summary block at
the end shows the real integrated value (e.g. ``I: -27.8 LUFS``
for the test sine wave). Old code captured the -70.0 reading.

Fix: anchor LUFS parsing to the ``Summary:`` block via
``stderr.rfind('Summary:')``. The Summary block is always emitted
last and contains the authoritative final integrated loudness.
Peak parsing already worked correctly (per-window output uses
``TPK:``/``FTPK:`` labels; only the Summary uses ``Peak:``), but
applied the same Summary anchor for consistency.

Defensive fallback: if no Summary block is present (truncated
output / unusual ffmpeg version), use the LAST per-window reading
instead of the first. Still better than the buggy first-window
behavior.

Smoke verified end-to-end: a freshly-generated FLAC of a -24 dBFS
sine wave now reports LUFS=-27.80, gain=+9.80 dB (correct, was
+52.00 before fix).

Tests: ``tests/test_replaygain_summary_parse.py`` — 7 cases pinning
the parser behavior with realistic ffmpeg ebur128 stderr samples:
- Summary value parsed correctly even when first per-window is -70
- Resulting gain is realistic (NOT +52)
- Two tracks with same first per-window but different summaries get
  different LUFS (regression assertion for "all tracks same gain")
- Per-window reading higher than Summary doesn't leak through
- Fallback to last per-window when Summary absent
- Clean RuntimeError raised when no LUFS values anywhere
- Peak still correctly anchored to Summary

Verified: full suite 1800 pass (7 new), ruff clean.

WHATS_NEW entry under '2.4.2' dev cycle.
2026-05-03 19:08:35 -07:00
Broque Thomas
04a14f7e96 Fix: tasks showed Completed when file was quarantined
User caught downloading Kendrick Mr. Morale: three tracks (Rich
Interlude, Savior Interlude, Savior) showed  Completed in the modal
but were missing on disk. Log forensics revealed two layered bugs.

Bug 1 — Verification wrapper assumed success on quarantined files
(`core/imports/pipeline.py`):

The outer `post_process_matched_download_with_verification` had a
fallback at the "no `_final_processed_path` in context" branch that
marked the task completed and notified `success=True`. The inner
post-processor sets `_final_processed_path` only when the file
actually reaches its destination. Integrity-rejected files
(`_integrity_failure_msg` set) and race-guard-failed files
(`_race_guard_failed` set) get quarantined or skipped without ever
setting `_final_processed_path`, so they fell straight into the
"assume success" branch.

Confirmed in user's log:
  No _final_processed_path in context for task d5b88b84-... —
  cannot verify, assuming success

That line fired for the same task right after the integrity check
quarantined the source file. Result:  Completed in UI, file in
quarantine, never delivered.

Fix: explicit checks for `_integrity_failure_msg` and
`_race_guard_failed` markers BEFORE the assume-success fallback.
Either marker set → task status='failed' with descriptive
error_message + `_notify_download_completed(success=False)`. The
pre-existing assume-success behavior preserved when no failure
markers are set (some legitimate flows complete without setting
`_final_processed_path`).

Bug 2 — AcoustID skip-logic too lenient
(`core/acoustid_verification.py`):

The "language/script" exemption was:
  if best_score >= 0.95 and (title_sim >= 0.55 or
                              artist_sim >= ARTIST_MATCH_THRESHOLD):

The OR-clause fired for English-vs-English titles by the same artist
that share NO actual content. Confirmed in user's log: requested
"Rich (Interlude)" by Kendrick Lamar, AcoustID identified the audio
as "R.O.T.C. (interlude)" by Kendrick Lamar (a totally different
song from his 2010 mixtape) — same artist scored ≥ARTIST threshold,
shared word "interlude" pushed title_sim above 0.55, skip fired.
Verification returned SKIP instead of FAIL, the wrong file was
accepted as the answer for three different track requests.

Fix: skip now requires positive evidence the mismatch is a real
language/script case:
  (a) Non-ASCII chars present in either title AND artist matches strongly
      → real transliteration case (kanji ↔ romaji etc)
  (b) BOTH title_sim >= 0.80 AND artist_sim >= ARTIST threshold
      → minor punctuation/casing differences

English-vs-English with very different titles by the same artist no
longer skipped — verification correctly returns FAIL, the wrong file
gets quarantined, the new wrapper logic above marks the task failed.

Tests:
- `tests/test_integrity_failure_marks_task_failed.py` — 4 cases
  pinning the wrapper-level state machine: integrity marker → failed,
  race-guard marker → failed, no markers → still assumes success
  (legacy path preserved), integrity-failure-takes-priority over
  missing-final-path fallback.
- `tests/test_acoustid_skip_logic.py` — 7 cases pinning the skip
  exemption: user's R.O.T.C-vs-Rich case → FAIL (regression test),
  Savior-vs-R.O.T.C → FAIL (same bug surface), Japanese kanji →
  romaji → SKIP (real language case still works), MAAD vs M.A.A.D →
  PASS or SKIP (punctuation tolerance), low fingerprint score →
  never skipped, high score but artist mismatch → no longer skipped,
  Crown vs Crown of Thorns → no longer skipped.

Verified: full suite 1793 pass (11 new), ruff clean.

WHATS_NEW entry under '2.4.2' dev cycle.
2026-05-03 18:28:32 -07:00
Broque Thomas
4b15fe0b75 Fix album MBID inconsistency: detector + persistent release-MBID cache
Discord report (Samuel [KC]): tracks of the same album sometimes carry
different MUSICBRAINZ_ALBUMID tags, which causes Navidrome (and other
media servers grouping by album MBID) to split the album into multiple
entries. Two-part fix — one for existing libraries, one for the root
cause that lets new imports drift.

Part 1 — Detector + fix action (catches existing dissenters):

`core/repair_jobs/mbid_mismatch_detector.py`:
- New helpers: `_read_album_mbid_from_file` and
  `_write_album_mbid_to_file` use the Picard-standard tag conventions
  (`TXXX:MusicBrainz Album Id` for MP3, `MUSICBRAINZ_ALBUMID` for
  FLAC/OGG, `----:com.apple.iTunes:MusicBrainz Album Id` for MP4).
- New scan phase `_scan_album_mbid_consistency` runs after the
  existing track-MBID scan: groups tracks by DB `album_id`, reads
  each track's embedded album MBID, finds the consensus
  (most-common) MBID via `Counter`, flags dissenters. Tracks without
  an album MBID at all are skipped (they don't break Navidrome —
  only an explicit MBID disagreement does). Albums where MBIDs are
  perfectly tied (no clear consensus) are skipped too — surface as
  a manual decision instead of fixing toward a 1/N tie.
- New finding type `album_mbid_mismatch` carries `consensus_mbid`,
  `wrong_mbid`, `consensus_count`, `total_tracks_with_mbid`, and a
  human-readable reason string.

`core/repair_worker.py`:
- Added `'album_mbid_mismatch': self._fix_album_mbid_mismatch` to the
  fix dispatch dict and to the `fixable_types` tuple so auto-fix +
  bulk-fix paths pick it up.
- New `_fix_album_mbid_mismatch` method reads `consensus_mbid` from
  finding details, resolves the dissenter's file path via the shared
  library resolver, calls `_write_album_mbid_to_file` to rewrite the
  tag in place. Doesn't touch the album's other tracks (they're
  already in agreement).

Part 2 — Root cause fix (prevents new SoulSync imports from drifting):

The original in-memory `mb_release_cache` in `core/metadata/source.py`
maps `(normalized_album, artist) -> release_mbid` so per-track
enrichment of the same album hits the cache and writes the same
MUSICBRAINZ_ALBUMID to every track. That cache is bounded (4096
entries) and in-process — so cache eviction (when other albums are
processed in between) and server restart can BOTH cause
inconsistency. Per-track album-name variation (e.g. some tracks
tagged `"Album"`, others tagged `"Album (Deluxe)"`) and per-track
artist variation (features) make it worse.

`core/metadata/album_mbid_cache.py` (new module):
- DB-backed `lookup(normalized_album, artist) -> release_mbid` and
  `record(...)` functions. Same key shape as the in-memory cache.
- Strict additive design: every public function is wrapped in
  try/except and degrades to None / no-op on ANY database error.
  The existing in-memory cache + MusicBrainz lookup remains the
  authoritative fallback. If this module breaks, downloads continue
  exactly as they would today.

`database/music_database.py`:
- New `mb_album_release_cache` table with composite primary key
  `(normalized_album_key, artist_key)`. Reverse-lookup index on
  `release_mbid` for future debug tooling. Created via the existing
  `CREATE TABLE IF NOT EXISTS` migration pattern — idempotent, no
  schema version bump needed.

`core/metadata/source.py`:
- Surgical change inside the existing `embed_source_ids`
  in-memory-cache-miss branch: BEFORE calling MusicBrainz, consult
  the persistent cache. If a previous SoulSync run already resolved
  this album's release MBID, reuse it. After a successful MB lookup,
  store in BOTH caches. Both calls wrapped in defensive try/except
  so any failure falls through to existing logic.

Tests:
- `tests/metadata/test_album_mbid_cache.py` — 16 cache tests:
  round-trip, idempotent re-record, overwrite semantics, clear_all,
  album+artist independence (no Greatest Hits collisions),
  defensive None-on-empty-input, graceful degradation when the DB
  is unavailable / connection raises / commit fails, schema sanity
  (table + index exist after init).
- `tests/test_album_mbid_consistency.py` — 13 detector tests:
  tag read/write round-trip on real FLAC files, Picard-standard tag
  descriptors, defensive paths (unreadable file, empty input),
  detector behavior (agreement → no flags, lone dissenter → flag,
  ties → no flag, single-track albums → skipped, no-MBID tracks →
  skipped, unresolvable file paths → skipped).
- `tests/metadata/test_metadata_enrichment.py` — added autouse
  fixture monkeypatching the persistent cache to no-op for tests in
  this file. The existing tests pin per-call MB counts and
  in-memory cache state; without the fixture, persistent rows from
  earlier tests would bypass the MB call. Persistent layer has its
  own dedicated tests.

Verified: 1782 tests pass (29 new), ruff clean, smoke test confirms
end-to-end cache round-trip works.

WHATS_NEW entry under '2.4.2' dev cycle.
2026-05-03 17:16:39 -07:00
Broque Thomas
e577f3cf1f Fix three Lidarr bugs that prevented it from being a real download source
Investigation surfaced that Lidarr was wired into the orchestrator but
the actual download flow had blockers:

1. **Wrong file misfiled.** Lidarr grabs whole albums; SoulSync's
   matched-context post-processing wants the SPECIFIC track the user
   requested. Old code copied every track in the album and reported
   `imported_files[0]` as `file_path` — almost always pointing to
   track 1, not the user's actual track. Post-processing then tagged
   track 1 with the requested track's metadata. Misfiling on every
   real download.

   Fix: parse the wanted track title out of the dispatch display name
   (which `_search_sync` already builds as
   `f"{artist} - {album} - {track_title}"`), look it up against
   Lidarr's `track` API, resolve the matching `trackFileId` to a path,
   and copy ONLY that file. Punctuation-tolerant fuzzy match handles
   the common "m.A.A.d city" vs "maad city" case. Album-level
   dispatches (no track in the display) preserve the old first-file
   fallback so existing album-grab UX is unchanged.

2. **Hardcoded `metadataProfileId=1`.** Required by Lidarr's
   artist-add API. On installs where the user deleted/recreated
   metadata profiles, that id no longer exists and the call fails
   with HTTP 400 — which silently breaks every download flow that
   needs to add an artist. Real-world Lidarr installs do this all
   the time.

   Fix: `_get_metadata_profile_id()` calls Lidarr's `metadataprofile`
   API and returns the first available id. Falls back to 1 only when
   the API call fails entirely (preserves previous behavior so this
   change can't make things worse).

3. **Polling never broke the outer loop on completion.** The inner
   `for item in queue['records']` had `break` statements at status
   transitions, but those only escaped the queue iteration — the
   outer `for poll in range(max_polls)` kept spinning until the
   600-poll timeout even after the album was clearly imported.
   `for/else` semantics didn't apply because completion was detected
   inside the inner loop, not by it running to exhaustion.

   Fix: replaced with an explicit `download_complete` flag set when
   `album/{id}` reports `trackFileCount > 0` (the authoritative
   completion signal — works even when the queue record disappeared
   between polls). Outer loop breaks immediately once the flag flips.

Helper functions added: `_extract_wanted_track_title` (staticmethod,
splits the display name; >=3 parts → track dispatch, 2 parts → album
dispatch), `_normalize_for_match` (lowercase + strip punctuation +
collapse whitespace for fuzzy compare), `_title_similarity` (cheap
score: equal=1.0, substring=0.85, token-overlap-ratio otherwise),
`_pick_track_file_for_wanted` (orchestrates the API calls).

Settings tooltip updated to be honest about Lidarr's natural shape:
album-grabber, no-op for playlist sync, hybrid mode falls through to
other sources for track searches. Sets correct expectations.

Tests: `tests/test_lidarr_download_client.py` — 21 isolated tests
covering pure helpers (title extraction, normalization, similarity)
and the file-picker integration paths (matching path, punctuation
tolerance, below-threshold fallback, missing trackFileId, missing
file on disk, API failures, malformed responses). No live Lidarr
needed — `_api_get` mocked at the client boundary.

Isolation: ONLY touches `core/lidarr_download_client.py`, the Lidarr
settings tooltip in `webui/index.html`, the Lidarr WHATS_NEW entry
in `webui/static/helper.js`, and the new test file. No changes to
the orchestrator, other download clients, the import pipeline,
side_effects, web_server.py, settings.js, or any shared validation /
monitor / task_worker code. Other download sources are not affected
in any way.

Verified: 1753 tests pass (21 new), ruff clean.
2026-05-03 15:49:38 -07:00
Broque Thomas
8de4a186b7 Fix three SoundCloud integration gaps surfaced by smoke testing
User report: switched download source to SoundCloud and noticed:
1. Download progress % stays at 0 until "suddenly done" — no live progress
2. Sidebar status indicator next to "SoundCloud" label is red
3. Dashboard service status card still shows "Soulseek" as the source name

Fix 1 — Live progress for HLS-segmented SoundCloud downloads
(`core/soundcloud_client.py`):
- yt-dlp's `total_bytes` / `total_bytes_estimate` for HLS describes the
  CURRENT FRAGMENT, not the whole download. So the byte-based
  percentage stayed near 0 the entire time — until 'finished' fired.
- Added `_update_download_progress_fragmented` which uses
  `fragment_index` / `fragment_count` (which yt-dlp DOES populate
  accurately for HLS) to compute a meaningful percentage. Total size
  is extrapolated from per-fragment average for the bytes/remaining
  display. Time-remaining estimate uses elapsed/index seconds-per-
  fragment.
- The progress hook prefers fragment progress when both fragment_index
  and fragment_count are present; falls back to byte-based for
  non-fragmented (progressive MP3) downloads. Five new unit tests pin
  the fragment-progress math, the 99.9% cap, and the defensive
  zero-index / unknown-id paths.

Fix 2 — Sidebar status indicator stays green for SoundCloud mode
(`web_server.py`):
- The `/api/status` route's `serverless_sources` tuple decides whether
  to even probe slskd. SoundCloud (and Lidarr) were missing — so when
  the active source was SoundCloud, the route fell through to "test
  slskd, mark not-relevant", which set `connected: False` and turned
  the sidebar dot red even though SoundCloud was working.
- Added `'soundcloud'` and `'lidarr'` to the tuple. Both are
  serverless from slskd's perspective, so the dot now stays green
  whenever they're the active source.

Fix 3 — Dashboard service card title shows the active source
(`webui/static/shared-helpers.js`):
- The dashboard's "Download Source" card has its own
  `sourceNames` map at line 3351 (separate from the sidebar map I
  already updated at 3396). Missed it during the integration PR.
- Added `'lidarr'` and `'soundcloud'` so the card title now reads
  "SoundCloud" / "Lidarr" instead of falling back to "Soulseek".

Bonus — Dashboard "Test Connection" button works for SoundCloud
(`core/connection_test.py`):
- The dashboard's Test Connection button on the download-source card
  sends `service` based on the active source — so for SoundCloud it
  was sending `service='soundcloud'`. `run_service_test` had no
  branch for it, so it fell through to "Unknown service." and the
  button always failed.
- Added a `soundcloud` branch that mirrors `/api/soundcloud/status`
  behavior: confirms yt-dlp is installed, runs a real cheap probe,
  returns a meaningful pass/fail. (HiFi has the same gap but no
  user reported it; out of scope for this fix.)

Verified:
- 41 unit tests pass (5 new fragment-progress tests added)
- Full suite 1732 passed
- Ruff clean
2026-05-03 13:09:02 -07:00
Broque Thomas
75fe04907f Wire SoundCloud as a first-class download source
Plug the previously-built SoundcloudClient (PR #478, the build-and-verify
phase) into every place a download source needs to appear. Follows the
same wiring contract as Tidal/Qobuz/HiFi/Deezer/Lidarr — orchestrator
routing, hybrid-mode picker, search dispatch, queue/cancel/clear,
provenance + library history, sidebar source label, settings UI all
work plug-and-play.

Backend wiring:
- `core/download_orchestrator.py` — import SoundcloudClient, _safe_init
  it at startup, add to _client() lookup, get_source_status(),
  check_connection's sources_to_check default, search source_names map,
  search_and_download_best _streaming_sources tuple, download
  source_map + source_names, and every iteration loop in
  reload_settings download-path-update / get_all_downloads /
  get_download_status / cancel_download (route + iterate) /
  clear_all_completed_downloads / cancel_all_downloads.
- `core/downloads/monitor.py` — added SoundCloud to the per-client
  loop that fetches active downloads outside the orchestrator (uses
  getattr fallback for older soulseek_client snapshots).
- `core/downloads/task_worker.py` — added SoundCloud (and Lidarr,
  which was missing too — bonus fix) to source_clients dict for hybrid
  fallback dispatch.
- `core/downloads/validation.py` — added 'soundcloud' to
  _streaming_sources so SoundCloud results go through the matching
  engine validation path instead of the Soulseek quality-filter path.
- `core/imports/side_effects.py` — three call sites: source_map for
  download_source label written to library_history, streaming-source
  guard for the `||`-encoded stream_id parsing, and source_service
  map for provenance recording. All three now include 'soundcloud'.
- `web_server.py` — five streaming-source detection tuples updated.
  New `/api/soundcloud/status` endpoint returns
  {available, configured, reachable} mirroring the Deezer/HiFi
  status-endpoint pattern; reachability runs a real cheap yt-dlp
  search so the settings Test Connection button gives a meaningful
  pass/fail signal.
- `config/settings.py` — added empty `soundcloud_download` defaults
  block so future tier-2 OAuth (SoundCloud Go+ session) doesn't have
  to migrate existing configs.

Frontend:
- `webui/index.html` — new `<option value="soundcloud">` in the
  download-source-mode dropdown, SoundCloud added to both hidden
  legacy hybrid-source selects, new settings container with info
  text + Test Connection button.
- `webui/static/settings.js` — HYBRID_SOURCES entry (with the
  SoundCloud cloud SVG icon), _hybridSourceEnabled default,
  updateDownloadSourceUI container display, allSources for legacy
  hybrid picker, testSoundcloudConnection function (hits the new
  status endpoint, color-codes the result), saveSettings
  soundcloud_download empty block.
- `webui/static/shared-helpers.js` — sidebar source-name map
  includes SoundCloud + Lidarr (Lidarr was also missing, bonus fix).
- `webui/static/helper.js` — WHATS_NEW entry under '2.4.2' dev cycle
  describing the user-visible change in the chill terse voice.

Tests:
- `tests/test_download_orchestrator_soundcloud.py` — 14 integration
  tests verifying the wiring: client constructed at startup, _client
  lookup resolves 'soundcloud', get_source_status includes it,
  download dispatcher routes username='soundcloud' to the SoundCloud
  client (and unknown usernames still fall back to Soulseek), hybrid
  search iterates SoundCloud when in order and skips it cleanly when
  unconfigured, get_all_downloads / get_download_status / cancel /
  clear walk SoundCloud, soundcloud-only mode dispatches only to
  SoundCloud, _streaming_sources tuple in validation includes
  'soundcloud'.
- `tests/downloads/test_download_orchestrator.py` — added
  `soundcloud` to the test fixture's _build_orchestrator helper so
  the new orchestrator attribute doesn't AttributeError in pre-
  existing tests that bypass __init__.

Verified:
- Full suite green (1728 passed, 2 deselected for soundcloud_live)
- Ruff clean
- Live SoundCloud-only mode search returns 25 SoundCloud tracks for
  "kendrick lamar luther" in <2s, returning properly-shaped
  TrackResult objects with username='soundcloud' and dispatch-key
  filename ready for the download path.

Out of scope (intentional deferrals):
- SoundCloud Go+ OAuth tier (256 kbps AAC) — anonymous-only for now.
  Adding auth later is a settings-page extension, no orchestrator
  changes needed.
- Album/playlist support — SoundCloud has playlists but they don't
  map to the album model the rest of SoulSync expects. Singles only.
2026-05-03 12:54:21 -07:00
Broque Thomas
583c4f1e49 Build SoundCloud download client (not yet wired into app)
Discord request (Toasti): some tracks (DJ mixes, sets, removed Spotify
content) only live on SoundCloud. Add SoundCloud as an option for the
existing multi-source download dispatch.

This commit only ships the client + tests. Integration into the search
dispatch / settings UI / web_server.py routes is intentionally deferred
to a follow-up PR — the user-requested workflow is build-and-verify
in isolation first, then wire up.

`core/soundcloud_client.py`:
- SoundcloudClient class mirrors the public surface of every other
  download client (TidalDownloadClient, QobuzClient, HiFiClient,
  DeezerDownloadClient): __init__(download_path), set_shutdown_check,
  is_available / is_configured / is_authenticated, async check_connection,
  async search returning (List[TrackResult], List[AlbumResult]),
  async download returning a download_id, _download_thread_worker /
  _download_sync / _update_download_progress, async get_all_downloads /
  get_download_status / cancel_download / clear_all_completed_downloads.
- Underlying lib: yt-dlp (already in requirements.txt as 2026.3.17).
- Anonymous-only — public SoundCloud tracks at the cap quality (typically
  128 kbps MP3, occasionally 256 kbps AAC depending on the upload).
  No FLAC ever; SoundCloud doesn't expose lossless. OAuth tier for
  SoundCloud Go+ is documented in the module header as a future tier.
- Returns standard TrackResult / DownloadStatus dataclasses from
  core.soulseek_client so downstream matching/post-processing stays
  source-agnostic.
- Filename dispatch key encodes track_id + permalink_url + display_name
  so the download worker has everything without re-querying.
- Heuristic "Artist - Title" parser handles SoundCloud uploaders'
  typical title format; falls back to uploader handle as artist when
  the title doesn't have a separator.
- Defensive: search returns empty on bad input, missing yt-dlp, or any
  raised exception. Download sync rejects files under 100KB (preview
  snippets / broken responses) and cleans them up.
- Cooperative cancellation via shutdown_check inside yt-dlp's
  progress_hooks. Cancelled state survives the download thread's
  terminal-state assignment.

`tests/test_soundcloud_client.py`:
- 37 unit tests with yt-dlp stubbed: search shape correctness, the
  artist/title heuristic, the dispatch-key roundtrip, the download
  state machine (success / failure / shutdown / Cancelled-state
  preservation), the progress emitter (progress capping, time
  remaining), defensive paths (missing yt-dlp, raising yt-dlp,
  malformed entries, empty entries), and the cancel/clear ledger
  operations.
- 2 live integration tests gated behind `-m soundcloud_live` so CI
  doesn't run them by default. Run locally with:
    python -m pytest tests/test_soundcloud_client.py -m soundcloud_live -v
- All 37 unit tests pass; both live tests pass against real SoundCloud.
- Verified end-to-end with a real album download (Kendrick GNX, 12/12
  tracks, 4-7 MB each, completed under 60s per track).

`pyproject.toml`:
- Register the `soundcloud_live` pytest marker so the unknown-mark
  warning is suppressed and the live tests can be cleanly gated.

Not changed: web_server.py, settings UI, search dispatch, matching
engine, WHATS_NEW. Integration is the next PR.
2026-05-03 11:58:25 -07:00
Broque Thomas
d8437c87c6 Fix Album Completeness Auto-Fill on Docker / shared-library setups (#476)
GitHub issue #476 (gabistek, Docker on Arch host): "Auto-Fill" / "Fix
Selected" on the Album Completeness findings page returned
"Could not determine album folder from existing tracks" for every album.
Reproduces on any setup where the media-server library lives outside the
SoulSync transfer/download folders — Docker is the headline case but
native installs that point Plex at a NAS via SMB hit it too.

Root cause: `core/repair_worker.py:_resolve_file_path` only probed the
transfer + download folders. Docker users have their Plex/Jellyfin
library bind-mounted at /music (or similar) — neither configured in
SoulSync. Every existing track got silently treated as missing, so
`album_folder` stayed None and the fix workflow bailed.

The same incomplete logic was duplicated four more times in the
repair_jobs/ modules, all with the same bug. Album Completeness was
just the most user-visible — the same setups were also producing false
"missing file" findings from Dead File Cleaner, silent skips in
MBID Mismatch Detector, etc.

The web server already had the correct logic at
`web_server.py:_resolve_library_file_path` (probes transfer + download
+ Plex-reported library locations + user-configured library.music_paths).
The repair workers had never been updated to match.

Fix:
- New `core/library/path_resolver.py` extracts the union logic into a
  single shared function `resolve_library_file_path()`. Probes (in
  order, deduped): explicit transfer/download kwargs, config-derived
  soulseek.transfer_path/download_path, Plex-reported library
  locations (when a plex_client is passed), user-configured
  library.music_paths. Each defensive: malformed config or a flaky
  Plex client degrades to the dirs that did succeed.
- `core/repair_worker.py:_resolve_file_path` becomes a delegating
  wrapper preserving the legacy signature, with a new `config_manager`
  kwarg. All 15 in-tree call sites updated to thread
  `self._config_manager` through.
- `core/repair_jobs/dead_file_cleaner.py`,
  `mbid_mismatch_detector.py`, and `lossy_converter.py` get the same
  treatment: duplicate function replaced with a thin wrapper, call
  sites pass `context.config_manager`.
- `core/repair_jobs/acoustid_scanner.py` and
  `unknown_artist_fixer.py` (which used to import from repair_worker)
  now call the shared resolver directly with `context.config_manager`.

Side benefit: every other repair job (Dead File Cleaner, MBID
Mismatch Detector, Lossy Converter, AcoustID Scanner, Unknown Artist
Fixer) also stops missing files in the media-server library mount.
Single fix unblocks five user-visible features.

Tests: `tests/library/test_path_resolver.py` — 20 cases covering all
four base-dir sources, suffix-walk algorithm, dedup, defensive paths
(None plex client, malformed config entries, raising config_manager.get,
broken plex attribute access), Docker path translation. Full suite
1677 passed locally.

WHATS_NEW entry under '2.4.2' dev cycle.
2026-05-03 10:11:06 -07:00
Broque Thomas
42f3026eef Reject broken downloads before tagging via universal integrity check
Discord report (fresh.dumbledore [VRN]): slskd sometimes ships broken files
(truncated transfers, corrupt FLAC, wrong file substituted on filename match).
They flowed through post-processing and only surfaced later — Plex/Jellyfin
scan failures, dead-air playback, duplicate detector tripping over the wrong
length. By that point the file was already tagged, copied, mirrored to the
media server, and recorded in provenance.

New module `core/imports/file_integrity.py`:
- `check_audio_integrity(path, expected_duration_ms=None) -> IntegrityResult`
- Three tiered checks, cheapest to most expensive:
  1. File size sanity (catches 0-byte stubs and stub transfers)
  2. Mutagen parse (catches header damage, wrong-format-with-right-extension)
  3. Duration agreement vs. metadata source's expected length, ±3s tolerance
     (5s for tracks over 10 minutes — long tracks naturally drift more)
- Returns IntegrityResult with `ok`, human-readable `reason`, and per-check
  `checks` dict for debugging
- Never raises; pathological inputs return ok=False with explanation

Pipeline integration in `core/imports/pipeline.py:post_process_matched_download`:
- Hooks between the existing file-stability wait and AcoustID verification
- On failure: quarantine via existing `move_to_quarantine` helper, mark task
  failed with descriptive error, clear matched-context, fire
  `on_download_completed(success=False)` so the slot is released for retry
- Mirrors the existing AcoustID-failure path so retry behavior stays consistent
- Wrapped in try/except so an unexpected failure inside the check itself
  cannot block downloads — logs and continues

This is intentionally tier 1: universal across formats, no external deps.
A future tier could verify FLAC STREAMINFO MD5 by decoding audio (needs
flac binary or libflac wrapper) — skipped for now since tier 1 catches the
dominant Discord-reported cases (truncated, 0-byte, wrong file).

Tests:
- `tests/imports/test_file_integrity.py` — 14 cases covering all three check
  tiers, edge cases (zero/negative expected duration, long-track wider
  tolerance, caller tolerance override), and the mutagen-unavailable
  degradation path
- `tests/imports/test_import_pipeline.py` — two existing tests use 5-byte
  fixture files that the new check would reject; they monkeypatch the
  integrity check since they're testing plumbing (notification +
  metadata_runtime forwarding), not integrity behavior

WHATS_NEW entry under '2.4.2' dev cycle.
2026-05-03 08:21:01 -07:00
Broque Thomas
03a7ccd74a Rename unused loop var to silence ruff B007
`sub_name` is unused — the recursion only needs the path. Rename to `_sub_name`
to satisfy ruff's B007 check.
2026-05-02 23:18:05 -07:00
Broque Thomas
cdd408b6f3 Auto-import: live card updates + multi-disc + featured-artist tag fixes
The 'Live Per-Track Progress' work shipped a backend in-progress row + top-of-tab
progress text but the history cards themselves stayed visually stale during
processing — lowercase "processing" badge, neutral styling, no per-track hint.
Smoke-testing also surfaced two latent identification bugs that prevented
multi-disc rips with features (Kendrick GKMC Deluxe) from importing at all.

Card-level live progress (`webui/static/stats-automations.js`):
- Cache `/api/auto-import/status` response in `_autoImportLastStatus`; poller
  awaits status before re-rendering results so the card has the live data.
- Add 'processing' entries to statusLabels / statusIcons / statusClass.
- When card folder_name matches `current_folder`, swap the meta line to
  `track N/M: <track name>` and tag the matching row in the expanded list
  as `auto-import-track-row-active`; prior rows tag as `-row-done`.

Card styling (`webui/static/style.css`):
- `.auto-import-processing` blue left border, `.auto-import-badge-processing`
  pulse animation, active/done track-row classes.

Multi-disc enumeration (`core/auto_import_worker.py:_scan_directory`):
- Old code skipped disc folders during recursion AND only attached them to a
  parent that had its own loose audio. A folder containing only `Disc 1/`,
  `Disc 2/` was invisible. Now: when a directory has only disc subdirs and no
  loose audio, treat that directory itself as the album candidate. Disc folders
  still skipped when standing alone.
- Add `FolderCandidate.is_staging_root` flag (set when the staging dir itself
  becomes the candidate via this path) so identification can refuse to use the
  meaningless folder name.

Tag identification (`core/auto_import_worker.py:_identify_from_tags`):
- Per-track `artist` tag fragmented consensus on albums with features
  ("Kendrick Lamar" / "Kendrick Lamar, Drake" / "Kendrick Lamar, Dr. Dre"
  produced 3 separate `(album, artist)` keys for one album). Now group by
  album first, then pick the most-common artist within that album group.
- `_read_file_tags` now prefers `albumartist` over `artist` for album-level
  identity; falls back to `artist` for files without albumartist.
- Add INFO-level log when tag identification rejects, showing top albums and
  their counts so the user can diagnose multi-disc / tagging issues.

Folder-name false-match guard (`core/auto_import_worker.py:_identify_folder`):
- When `is_staging_root` is set, skip the folder-name strategy entirely. Logs
  the skip and falls through to AcoustID. Without this, dropping disc folders
  directly into staging caused the scanner to search the metadata source for
  the literal name "Staging", which false-matched against random albums (e.g.
  "Stamina, Dinos" — a French rap album — at 13% confidence).

What's New entries added under 2.4.2 dev cycle.
2026-05-02 23:15:52 -07:00
Broque Thomas
783c543c3e Auto-import: live per-track progress + in-progress history row
User reported (Mushy / generally) that dropping an album into the
staging folder left the auto-import history blank for the entire
processing window — sometimes 5+ minutes for a full album. Pre-
existing UX gap, not caused by the recent context-builder refactor.

Two root causes:

1. ``_record_result`` only fired AFTER ``_process_matches`` returned.
   For a 14-track album with ~30s/track post-processing, that meant
   ~7 minutes of zero rows in auto_import_history → nothing for
   ``/api/auto-import/results`` to return → empty UI.

2. ``_current_status`` only ever transitioned between 'idle' and
   'scanning' — never 'processing'. ``get_status()`` had no per-
   track index/name fields, so the UI had no way to render
   "Processing track 3/14: Mine" even if it wanted to.

Fix:

- New ``_record_in_progress`` inserts a status='processing' row
  up-front (before the per-track loop starts) so the UI sees the
  import the moment it begins. Returns the row id.
- New ``_finalize_result`` updates that same row with the final
  outcome (completed/failed) when processing finishes. One row per
  album, not per track — keeps the history list clean.
- Both share ``_serialize_match_data`` (extracted from the original
  ``_record_result``) so the in-progress row carries the same match
  payload shape the existing review UI already understands.
- ``_process_matches`` updates ``_current_track_index``,
  ``_current_track_total``, and ``_current_track_name`` BEFORE each
  per-track callback fires, so a polling UI sees consistent
  "processing N/M: <name>" snapshots.
- ``_scan_cycle`` flips ``_current_status`` to 'processing' before
  the per-album loop, resets it + the per-track fields after.
  Defensive ``finally`` clears progress even if the inner code path
  raised.
- ``get_status()`` exposes the new fields so the UI's existing
  /api/auto-import/status polling picks them up.
- Frontend (stats-automations.js): renders the new
  ``current_status='processing'`` state with track index/total/name
  in the existing progress bar element. New 'processing' status
  class for styling parity with 'scanning'.

8 regression tests in tests/imports/test_auto_import_live_progress.py:
- get_status surfaces the new fields with sane defaults
- track_index advances 1, 2, 3 during a 3-track loop
- track_total set BEFORE the first callback fires (no '1/0' flicker)
- _record_in_progress writes status='processing' with no
  processed_at
- _finalize_result updates the same row to completed +
  processed_at, no second insert
- _finalize_result with failed status leaves processed_at NULL
- _finalize_result with row_id=None is a safe no-op
- Per-track fields cleared by _scan_cycle's finally block

Full pytest 1643 passed; ruff clean.
2026-05-02 22:34:09 -07:00
Broque Thomas
24c2d75c6d Make extract_external_ids recognize all source-tagging conventions
Smoke-testing the just-merged provenance PR against live logs revealed
the new ID-match block was silently no-opping: no [ExtID Match] /
[Provenance Match] log lines despite the code path being live. Tracing
revealed two related gaps in extract_external_ids' source detection:

1. **Underscore-prefixed key.** Deezer / Discogs / Hydrabase clients
   tag normalized track dicts with ``_source`` (underscore prefix —
   convention used in 8+ places across core/). The extractor only
   looked for ``provider`` and ``source``, so Deezer-sourced tracks
   silently returned no IDs.

2. **No provider field at all.** Spotify and iTunes raw API responses
   carry ``id`` but no provider/source key of any kind. The extractor
   couldn't disambiguate the native ``id``, so Spotify-primary scans
   would have hit the same silent miss once the user switched primary
   sources.

Two-part fix:

- ``extract_external_ids`` now recognizes ``_source`` as another
  candidate provider field.
- New optional ``source_hint`` parameter lets the caller supply the
  configured primary source as a fallback when the track dict has no
  provider field of its own. Track-side provider field still wins
  when present (defensive against a wrong hint).

Watchlist scanner now passes ``get_primary_source()`` as the hint so
both naming conventions (Deezer-style _source, Spotify-style no-tag)
get handled uniformly.

6 new regression tests cover:
- _source recognized for Deezer
- _source recognized for Hydrabase (cross-provider mapping)
- _source recognized for Discogs (no library column — verifies
  graceful no-crash)
- source_hint disambiguates raw tracks for spotify/itunes/deezer
- track-side provider takes precedence over hint
- None hint defaults safely

Full pytest 1630 passed; ruff clean. After this lands and the server
restarts, watchlist scans should produce [ExtID Match] /
[Provenance Match] log lines for tracks already on disk regardless of
which metadata source the user has configured as primary.
2026-05-02 18:26:12 -07:00
Broque Thomas
34ba26f5c8 Persist source IDs at download time + backfill onto tracks on sync
Followup to fix/watchlist-external-id-match. The companion PR closed
the demand side — the watchlist scanner asks for tracks by external IDs
before falling back to fuzzy. But for users on Plex / Jellyfin /
Navidrome the supply side was still broken: tracks.spotify_track_id
(and the other ID columns) only got populated by the asynchronous
enrichment workers, sometimes hours after the file was actually
written. During that window the ID match fell through to fuzzy and
the bug returned.

We were already collecting every ID during post-processing — they
live in the `pp` dict in core/metadata/source.py:embed_source_ids and
get embedded into file tags. We just dropped the in-memory copy
afterwards.

This PR persists them and uses them:

- Schema migration adds spotify_track_id / itunes_track_id /
  deezer_track_id / tidal_track_id / qobuz_track_id /
  musicbrainz_recording_id / audiodb_id / soul_id / isrc columns +
  indexes to the existing track_downloads table (already keyed by
  file_path).
- core/metadata/source.py:embed_source_ids exposes pp["id_tags"] and
  the resolved ISRC back to the import context as _embedded_id_tags
  / _isrc.
- core/imports/side_effects.py:record_download_provenance reads those
  context fields and passes them to db.record_track_download, which
  now accepts the new ID kwargs and persists them.
- New db.get_provenance_by_file_path with exact + basename-suffix
  fallback (handles container mount-root differences between
  download-time path and media-server-reported path).
- New db.backfill_track_external_ids_from_provenance copies IDs
  from track_downloads onto a tracks row idempotently — COALESCE on
  every column preserves any value the enrichment worker already
  wrote (enrichment is more authoritative for late binding).
- database/music_database.py:insert_or_update_media_track (the
  single insertion point used by every Plex / Jellyfin / Navidrome
  sync) calls the backfill immediately after each INSERT/UPDATE.
- New core/library/track_identity.py:find_provenance_by_external_id
  used as a second-tier fallback in watchlist_scanner.is_track_missing
  _from_library — catches the window between download and media-server
  sync. Caller checks os.path.exists on the provenance file_path
  before treating it as "already in library" so a deleted file
  doesn't prevent re-download.

Effect: freshly downloaded files become ID-recognizable to the
watchlist on the very next scan, no enrichment-wait window.

19 regression tests in tests/test_provenance_id_persistence.py:
- Schema migration adds expected columns + indexes
- record_track_download persists every ID kwarg
- record_track_download backward-compat (old kwargs still work)
- get_provenance_by_file_path: exact match, basename fallback for
  mount-root differences, multi-record latest-wins, defensive None
- backfill: copies all IDs, preserves existing via COALESCE,
  no-op when no provenance exists
- find_provenance_by_external_id: per-ID lookup, ISRC cross-bridge,
  OR semantics, latest-wins on multiple matches

Out of scope: backfilling provenance for files downloaded BEFORE
this PR (their track_downloads rows don't carry the new IDs). Those
continue to wait for enrichment. Acceptable — only affects historical
files; new downloads benefit immediately.

Full pytest 1625 passed; ruff clean.
2026-05-02 17:44:10 -07:00
Broque Thomas
ecb8939c80 Match library tracks by external IDs before fuzzy in watchlist scan
Reported case (CAL): a track already on disk got re-downloaded by the
watchlist scanner on every scan. Library DB had stale album metadata
for the file (track tagged on album "Left Alone") while the metadata
source reported it on a different album ("NPC" single). The
title+artist+album fuzzy block correctly said the album names didn't
match and declared the track missing — but the file's stable external
IDs (Spotify ID, ISRC, etc.) unambiguously identified it as the same
recording.

The earlier compilation-album fix (PR #461) handled qualifier drift
("OST" vs "Music From The Motion Picture"). This case is two
genuinely different album names referring to the same song.

Fix: provider-neutral external-ID short-circuit before the fuzzy
block in `is_track_missing_from_library`. Pulls every recognized ID
off the source track (Spotify / iTunes / Deezer / Tidal / Qobuz /
MusicBrainz / AudioDB / Hydrabase / ISRC), runs a single SELECT
against the indexed external-ID columns on the `tracks` table, and
treats any hit as "track exists in library — don't re-download".

If no IDs are available (older imports without enrichment, library
scans that didn't populate external IDs), falls through to the
existing fuzzy logic so the safety net stays intact.

New `core/library/track_identity.py` module with two helpers:
- `extract_external_ids(track)`: handles dict and object-style track
  shapes, direct-field aliases (spotify_id / spotify_track_id /
  SPOTIFY_TRACK_ID), and provider-disambiguated native `id` fields
  (when track has `provider='deezer'` and `id='X'`, treats X as a
  Deezer ID).
- `find_library_track_by_external_id(db, external_ids,
  server_source)`: builds an OR of indexed column matches with
  IS NOT NULL guards, optional server_source filter that also
  passes legacy NULL rows, single-row LIMIT.

ISRC bridges across providers — a library track imported via Deezer
can be matched against a Spotify scan when both sides carry the
same ISRC.

43 regression tests in `tests/test_library_track_identity.py`:
- 9 ID-extraction tests for direct fields (Spotify / iTunes / Deezer /
  ISRC / MBID / AudioDB / Hydrabase)
- 8 ID-extraction tests via the provider field (8 providers + source
  alias + missing-provider-ignored)
- 7 mixed/defensive tests (multiple IDs, object-style, empty strings,
  None track, numeric coercion)
- 8 lookup tests (per-provider + ISRC cross-bridge)
- 3 OR-semantics tests
- 4 server_source filter tests
- 2 ID-column-map sanity tests

Full pytest 1606 passed; ruff clean.
2026-05-02 16:06:59 -07:00
Broque Thomas
486116c34f Honor lossy_copy.delete_original after successful conversion
Reported case (CAL): with lossy_copy.enabled=True,
lossy_copy.delete_original=True, and codec=mp3, every download left
both the original FLAC AND the converted MP3 in the target folder.
Users opting into a lossy-only library ended up dual-format on
every import.

Root cause: ``core/imports/file_ops.py:create_lossy_copy`` reads
``lossy_copy.codec`` and ``lossy_copy.bitrate`` from config but never
reads ``lossy_copy.delete_original``. The setting is only consulted
by the pre-move source-vanished check at
``core/imports/pipeline.py:651`` (so the pipeline knows to look for
a lossy variant when the FLAC has already moved on), but no code
path actually deletes the source after conversion.

Fix: after ffmpeg returns success and the QUALITY tag is written,
check ``lossy_copy.delete_original`` and ``os.remove`` the original
when enabled. Belt-and-suspenders:

- Same-path guard (``os.path.normpath(out_path) != os.path.normpath(final_path)``)
  prevents accidentally wiping the just-converted file if a future
  codec choice somehow resolves out_path to the source path.
- ``FileNotFoundError`` is treated as success (concurrent worker /
  dedup cleanup got there first).
- Other ``OSError`` (permission denied, locked file) is logged but
  doesn't propagate — the conversion already succeeded, the user just
  has to clean up the original manually.

Failure paths skip the delete:
- ffmpeg returns non-zero → returns None, original stays
- lossy_copy.enabled=False → early return before conversion runs
- delete_original=False (default) → original stays

7 regression tests cover honored-when-enabled, kept-when-disabled,
default-keep, ffmpeg-failure-path, lossy-disabled-path, racing-delete,
and locked-file paths. Full pytest 1563 passed; ruff clean.

Note: this PR does NOT address the second bug CAL mentioned (track
re-downloaded despite already existing on disk). That symptom is
caused by stale album metadata on the user's existing files — the
library DB has the track tagged on a different album than the
metadata source reports — combined with wishlist.allow_duplicate_tracks
defaulting to True. Same class of issue partially addressed in PR
fix/watchlist-redownload-and-duplicate-detection but compilation-
album drift is the only currently-handled case. Tracking separately.
2026-05-02 14:26:46 -07:00
Broque Thomas
99dbe265de Sync Qobuz auth to enrichment worker after login
Discord-reported (Foxxify): logging in to Qobuz via the Connect
button on Settings showed "Connected: <username> (Active)" but
underneath an error said "Qobuz not authenticated...", and the
dashboard indicator stayed yellow. Saving settings or reloading the
tab didn't help.

Root cause: SoulSync runs two QobuzClient instances side by side —
one through soulseek_client.qobuz for the /api/qobuz/auth/* endpoints,
and a second owned by the enrichment worker thread for thread safety.
The login flow only updated the auth-flow instance's in-memory state
(plus persisted to config). The dashboard's "configured" check at
web_server.py:3371 reads
``qobuz_enrichment_worker.client.user_auth_token`` — the WORKER's
instance — which still believed itself unauthenticated. The
connection-test step at core/connection_test.py:370 hits the same
worker instance for the same reason.

Fix: add ``QobuzClient.reload_credentials()`` — a public, network-free
method that re-reads the saved session from config and updates the
instance's in-memory state + session headers. Call it on the
enrichment worker's client immediately after a successful
``/api/qobuz/auth/login``, ``/api/qobuz/auth/token``, or
``/api/qobuz/auth/logout`` so the two instances stay in lockstep
without waiting for the next process restart.

Unlike the existing ``_restore_session()`` this skips the network
probe — the caller has just authenticated, so the token is known
good. A small ``_sync_qobuz_credentials_to_worker()`` helper in
web_server.py wraps the call so all three endpoints share one path.

10 new regression tests cover the populate / clear / partial-config
paths plus the actual two-instance-sync scenario from the bug report.
Full pytest 1555 passed (the one pre-existing flake in
test_tidal_auth_instructions.py is order-dependent and unrelated).
2026-05-02 14:00:04 -07:00
Antti Kettunen
b85a05fb88
Move image URL normalization into metadata helpers
- keep existing /api/image-proxy URLs from being wrapped again
- reuse the shared metadata package instead of duplicating URL logic in web_server.py
- add regression coverage for proxy passthrough and internal URL normalization
2026-05-02 22:02:36 +03:00
Antti Kettunen
2b3022f6b0
Fix Spotify source ID fallback
- Prefer real Spotify IDs when importing Spotify contexts
- Skip numeric fallback IDs so Deezer values do not leak into spotify_* columns
- Add regressions for import context and SoulSync library writes
- Keep the route test asserting the Spotify album link
2026-05-02 22:02:01 +03:00
Antti Kettunen
36131656dd
Make Spotify status updates event-driven
- move Spotify status publishing onto auth, disconnect, and rate-limit transitions
- keep dashboard and debug consumers on the shared cached snapshot
- leave only the initial snapshot seed as a fallback probe
2026-05-02 22:02:01 +03:00
Antti Kettunen
cc13fb8f01
Move metadata status cache into core/metadata
- move metadata-source and Spotify status caching out of web_server.py
- keep the public /status payload unchanged while shrinking server-side glue
- centralize invalidation and TTL handling in core/metadata/status.py
2026-05-02 22:02:00 +03:00
Antti Kettunen
e2bd0e1871
Split metadata source and Spotify status
- Keep the primary metadata provider snapshot generic and move Spotify auth/rate-limit details into a separate status object.
- Update the websocket fixture and dashboard/settings consumers to read the two buckets independently.
2026-05-02 22:02:00 +03:00
elmerohueso
cd19aa0301 revert tidal artist/track id name for hifi downloads
Co-authored-by: Copilot <copilot@github.com>
2026-05-02 07:56:47 -06:00
elmerohueso
4ddb86522c name tidal and hifi tags the same way 2026-05-02 07:50:13 -06:00
elmerohueso
e78dd7f593 get tidal tags during download, without needing to go through the enrichment pipeline 2026-05-02 07:50:12 -06:00
elmerohueso
1f4e8e5e3b get hifi tags during download, without needing to go through the enrichment pipeline 2026-05-02 07:50:12 -06:00
elmerohueso
b363afe195 bpm for tidal, copyright and bpm for hifi 2026-05-02 07:50:12 -06:00
elmerohueso
f9f47f978e fix post-download tagging, and enable tagging for hifi 2026-05-02 07:50:12 -06:00
Broque Thomas
98c04cf332 Consolidate enrichment bubble routes behind a service registry
The dashboard's enrichment-status bubbles (MusicBrainz, AudioDB,
Discogs, Deezer, Spotify, iTunes, Last.fm, Genius, Tidal, Qobuz) each
had its own copy-pasted /status, /pause, /resume route in web_server.py
— 30 routes that differed only in the worker reference and a couple
of per-service quirks (Spotify's rate-limit guard, Last.fm/Genius
yield-override behavior, Tidal/Qobuz extra status fields).

Replace them with a registry-driven blueprint:

- core/enrichment/services.py declares an EnrichmentService dataclass
  with worker_getter, config_paused_key, pre_resume_check,
  auto_pause_token, and extra_status_defaults — all variation captured
  as data, no branching on service id.
- core/enrichment/api.py exposes a Flask blueprint with three routes
  (/api/enrichment/<service>/{status,pause,resume}). Per-service
  quirks are honored via the descriptor: Spotify's rate-limit ban
  still returns 429 with `rate_limited: true`, Last.fm/Genius still
  drop the auto-pause token and add the yield override, Tidal/Qobuz
  still merge `authenticated: false` into the fallback payload.
- web_server.py registers all 10 services after their workers
  initialize, wires the host-side hooks (config_manager.set,
  _download_auto_paused.discard, _download_yield_override.add), and
  registers the blueprint.
- webui/static/enrichment.js polling + click handlers now hit the
  generic endpoints. The per-service `update<Service>StatusFromData`
  functions are unchanged — they still process the same payload.

This is the cutover step. Old per-service routes are intentionally
left in place as a fallback during the soak period — they currently
have zero callers in the codebase and will be deleted in a follow-up
patch once production has run on the new pipeline for a few days.

27 new tests in tests/test_enrichment_services.py cover the registry
behavior + every quirk path through the generic blueprint (rate-limit
guard, auto-pause token cleanup, persisted-pause config keys, extra
default fields, worker-not-initialized fallback, exceptions). Full
suite 1541 passed; ruff clean.
2026-05-01 20:14:30 -07:00
Broque Thomas
0e68109b68 Add filename-pass safety: require duration agreement or artist match
Self-review of the previous commit found a real false-positive risk in
the new filename-bucket pass: two unrelated songs that happen to share
a canonical filename (e.g. ``Yellow.mp3`` by Coldplay vs by some other
artist) would be grouped because all metadata gates were dropped.

The filename pass now layers a safety net under ``require_metadata_match=False``:

- If both rows carry a duration: must agree within 3 seconds. Same
  source download = identical duration; a 3+ second gap means
  different recordings.
- Else if both rows carry an artist: relaxed 0.6 similarity check —
  catches dedup orphans that share an artist tag while rejecting
  strangers-with-same-filename.
- Else (no duration AND at least one artist blank): skip — too little
  signal to safely group.

5 additional regression tests cover the false-positive prevention
paths plus the genuine dedup-orphan scenarios that must still be
caught after the safety net.
2026-05-01 13:15:57 -07:00
Broque Thomas
6e61890551 Stop watchlist re-downloading compilation tracks; catch slskd dedup orphans
Two related bugs reported on Discord by Mushy.

1. The watchlist re-downloaded the same OST track up to 7 times.

   ``is_track_missing_from_library`` compared Spotify's album name and
   the media-server scan's album name with a raw SequenceMatcher at a
   strict 0.85 threshold. Compilations and soundtracks routinely fail
   this — Spotify reports
   ``"Napoleon Dynamite (Music From The Motion Picture)"`` while the
   Plex / Navidrome / Jellyfin tag scan saves it as
   ``"Napoleon Dynamite OST"``. Raw similarity ≈ 0.49, so the scanner
   declared the track missing on every 30-minute scan and added it back
   to the wishlist. The wishlist then issued a fresh download. slskd
   appended ``_<19-digit-ns-timestamp>`` to each new copy because the
   target file already existed, and the user ended up with seven copies
   of one song in one folder.

   Fix: extract two pure helpers — ``_normalize_album_for_match``
   strips qualifier parentheticals (Music From X, OST, Deluxe Edition,
   Remastered, Anniversary, etc.) and trailing dash-clauses;
   ``_albums_likely_match`` checks equality after normalization,
   substring containment, and a relaxed 0.6 fuzzy ratio. A volume /
   part / disc / standalone-trailing-number guard rejects pairs like
   ``"Greatest Hits Vol. 1"`` vs ``"Greatest Hits Vol. 2"`` so the
   relaxed threshold doesn't introduce false positives on serialized
   releases. After this change the Napoleon Dynamite case collapses
   to ``"napoleon dynamite" == "napoleon dynamite"`` via the equality
   short-circuit and the redownload loop dies.

2. The duplicate detector found only one of the seven dupe files.

   The detector buckets tracks by the first 4 chars of their normalized
   tag title. Files written by slskd directly into a library folder
   often get inconsistent (or blank) tags from the media-server rescan,
   so the seven copies were bucketed apart by parsed title and never
   compared.

   Fix: refactor the per-bucket comparison into ``_scan_bucket``, then
   add a second pass — ``_build_filename_buckets`` re-buckets leftover
   tracks by canonical filename stem (slskd dedup tail stripped via
   ``_strip_slskd_dedup_suffix``, same regex the import-cleanup PR uses)
   plus extension. Filename agreement is itself strong evidence the
   files came from the same source download, so the second pass calls
   ``_scan_bucket`` with ``require_metadata_match=False`` to skip the
   title / artist / cross-album gates. The same-physical-file guard
   still runs so bind-mount duplicates aren't flagged.

72 new regression tests across two files cover the album-match
helpers (28 tests including the Napoleon Dynamite scenario, 7 volume
disagreements, 8 positive/negative pairs, 5 defensive cases) and the
new filename-bucket pass (16 tests across bucket construction, scan
integration, and existing title-pass behavior). Full pytest 1509
passed; ruff clean.

Reported by Mushy in Discord.
2026-05-01 12:57:50 -07:00
Broque Thomas
46d8e15674 Prune slskd dedup orphans after import
slskd appends "_<19-digit unix-nanosecond timestamp>" to a downloaded
filename when the destination already contains a same-named file
(concurrent downloads of the same track, partial-file retries after a
connection drop, cancelled-then-redownloaded files, the same track
surfacing in multiple synced playlists). The file-finder code already
recognized the suffix when matching a download to its source — but
after the canonical file moved into the library, the leftover
"_<timestamp>" siblings sat orphaned in the downloads folder forever.

Reported on Discord by Shdjfgatdif.

cleanup_slskd_dedup_siblings() runs at the end of each successful
import (3 safe_move_file sites in pipeline.py) and prunes any
remaining siblings that strip down to the canonical stem with the
same extension. Conservative match (>= 18 trailing digits) keeps
legitimate filenames like "Track 5" and "Album 1995" untouched. Per-
file unlink failures are swallowed so a single locked file doesn't
block the rest.

17 regression tests cover the suffix-strip primitive, orphan removal,
no-op cases, mismatched extensions, subdirectories, and partial-failure
recovery.
2026-05-01 09:35:08 -07:00
Antti Kettunen
74e3cc460c
Simplify service status and labels
- Flatten the Spotify service-status rendering so it shows rate-limit and recovery states explicitly, while otherwise displaying the active metadata provider directly.
- Keep the Spotify auth controls and metadata-source picker aligned with the real session state after authenticate and disconnect flows.
- Return "Unmapped" for unknown metadata source labels instead of implying iTunes.
- Update the metadata registry tests to cover the new label fallback.
2026-05-01 12:06:58 +03:00
Antti Kettunen
55603be14c
Clarify Spotify auth flow and sync UI
- Send Spotify auth completion back to the opener so the settings page refreshes immediately
- Make the local auth flow go straight through to Spotify instead of showing the temporary instruction page
- Keep the remote/docker instruction page available for manual callback setups
- Sync Spotify status, connect/disconnect buttons, and metadata source selection after auth and disconnect
- Keep the disconnect behavior aligned with the active primary metadata source
2026-05-01 11:25:12 +03:00
Antti Kettunen
9646f6ca7f
Clarify Spotify auth actions
- Hide the auth button when a Spotify session is active
- Treat disconnect as a session change, not a provider swap
- Share metadata source labels in the registry
- Tighten rate-limit copy around Spotify-specific behavior
2026-05-01 10:36:50 +03:00
Broque Thomas
99a38a6201 Route imported singles/EPs through album_path template
Discord-reported (winecountrygames + fresh.dumbledore): "Import only
makes Albums folder no singles or eps". Users with a
${albumtype}s/$albumartist/... album_path template saw an "Albums"
folder fill up correctly but never any "Singles" or "EPs" folder.

build_import_album_info detected an album using
``total_tracks > 1`` AND ``album_name != track_title``. Spotify
singles fail both — total_tracks is 1 and the album is usually
named after the song. The result was that staging/auto-import
routed singles through single_path, which doesn't honour
$albumtype, so the user's per-type folder layout never applied.

Now also treats the metadata source's explicit release-type
classification ("single", "ep", "compilation") as evidence that
this is an album-shaped release, so it routes through album_path
and the user's $albumtype substitution runs. The default fallback
value "album" is deliberately excluded from this check so
single-track downloads with no real metadata behave exactly as
before.

Adds 10 regression tests covering the reported scenario, EP and
compilation explicit types, and three guards: normal multi-track
albums still detected, default 'album' type falls through, and
empty/unknown types fall through.
2026-04-30 21:33:09 -07:00
Broque Thomas
ef03901cb4 Bulk watchlist add: fall back through every source ID, not just active
The /api/library/watchlist-all-unwatched endpoint required the
user's currently active metadata source's ID column on each library
artist. A Spotify-primary user with library artists only matched
against iTunes or Deezer saw them silently skipped — surfacing on
Discord as "Library and Watchlist not syncing correctly". The per-
artist Enhanced View sync sometimes "fixed" them because it triggered
metadata enrichment that occasionally populated the missing Spotify
ID, but couldn't help artists Spotify simply doesn't carry.

Extracts the picker as a standalone helper so it can be tested
directly:

  core/watchlist/source_picker.py:pick_artist_id_for_watchlist

Picks the active source first when available, then falls back through
spotify -> itunes -> deezer -> discogs in registration order. Empty
strings count as missing. Numeric IDs are coerced to str so SQLite's
TEXT columns store them in the same form library code reads back.
Returns (None, None) only when the artist has zero source IDs — the
only legitimate skip reason now.

Adds 10 regression tests covering active-source priority for each
supported primary, fallback ordering through every secondary, the
zero-IDs base case, unrecognized active source (e.g. hydrabase still
falls through), empty-string handling, and numeric coercion.
2026-04-30 20:27:42 -07:00
Broque Thomas
7698405f58 Surface handler-returned errors in automation last_error
The "Clean Search History" automation card kept showing a stale
'DownloadOrchestrator' object has no attribute 'base_url' error
even after the underlying handler bug was fixed in 77d20e9. Root
cause is in the engine, not that handler: AutomationEngine only
captured uncaught exceptions into last_error. Handlers that
report failure by RETURNING {'status': 'error', ...} were treated
as successful from the engine's perspective, so subsequent
gracefully-failing runs never updated the row to reflect the
current state.

Both the timer (run_automation) and event (_handle_event_trigger)
paths now extract the error string from a result whose status is
'error', falling through 'error' -> 'reason' -> 'message' -> a
placeholder so last_error is never None on actual failures
regardless of which key the handler chose. Existing behaviour for
raised exceptions and successful runs is preserved.

Also normalizes _auto_clean_search_history's return key from
'reason' to 'error' so older deployed engines that only check
the canonical key still see the failure.

Adds 7 regression tests covering every result shape the engine
might receive.
2026-04-30 15:45:28 -07:00
BoulderBadgeDad
05a4342ac8
Merge pull request #445 from kettui/refactor/remove-quality_scanner-spotify-prio
Refactor quality scanner to respect primary metadata provider
2026-04-30 14:49:50 -07:00
Broque Thomas
382e427117 Filter same-physical-file duplicates from duplicate detector
When users bind the same host music directory into both SoulSync
(e.g. /app/Transfer) and a media server like Plex (e.g.
/media/Music), both scans add a track row pointing at the same
physical file via different mount paths. The detector previously
flagged those as duplicate groups even though there's only one
file on disk.

New _is_same_physical_file helper filters pairs where:
- The trailing 3 path segments match (filename + album + artist
  folder), so they're the same release on disk.
- The leading mount roots actually differ.
- Durations agree within 1s when both rows carry duration data.

Adds 10 regression tests covering the reported scenario plus
edge cases (Windows separators, case differences, missing
durations, sibling-album false-positive guard).
2026-04-30 14:06:10 -07:00
Antti Kettunen
fb190e16ca
Coerce wishlist track counts before category checks
- normalize album.total_tracks before comparing it in wishlist classification
- avoid mixed-type comparisons when provider payloads serialize track counts as strings
- add regression coverage for numeric strings and invalid values
2026-04-30 21:42:43 +03:00
Antti Kettunen
2bc8e8a27b
Preserve artwork in quality scanner wishlist handoff
- carry track-level album art through the quality scanner normalization path
- preserve artist artwork when provider results expose it
- keep album.image_url and album.images populated so the wishlist UI can render the cover consistently
- add a regression test covering provider payloads with image_url on both the track and artist
2026-04-30 21:42:16 +03:00
Broque Thomas
b395e33820 Lift redownload_start to core/library/redownload.py
Body byte-identical to the original. Spotify proxy via registry,
iTunes/Deezer client shims wrap registry helpers,
_resolve_library_file_path, _attempt_download_with_candidates, and
missing_download_executor are injected via init() right after
_init_wishlist_failed where all three deps are already defined.

web_server.py: 35239 → 35063 (-176 lines).
2026-04-30 11:27:33 -07:00
Antti Kettunen
c97a072f54
Refactor quality scanner to respect primary metadata provider
- search metadata providers in source-priority order for each generated query instead of caching one client for the whole scan
- keep the quality-scanner worker provider-neutral and preserve the no-provider error path
- update the quality-scanner tests and remove the obsolete web_server spotify_client injection
2026-04-30 21:25:39 +03:00
Broque Thomas
599426dbaf Lift _process_failed_tracks_to_wishlist_exact to core/downloads/wishlist_failed.py
Body byte-identical to the original. Wishlist helpers come from
core.wishlist.* directly (aliased to the same names the body uses);
runtime state from core.runtime_state. automation_engine,
soulseek_client, and _sweep_empty_download_directories are injected
via init() right after _init_download_validation.

web_server.py: 35408 → 35239 (-169 lines).
2026-04-30 11:03:44 -07:00
Broque Thomas
c8bd9d85dd Lift get_valid_candidates to core/downloads/validation.py
Body byte-identical to the original. matching_engine and
soulseek_client are injected via init() right after _init_discover_hero
since both originals are constructed early in web_server.py boot
(L598/L610) and never rebound.

web_server.py: 35586 → 35408 (-178 lines).
2026-04-30 10:15:31 -07:00
Broque Thomas
181011d5be Lift get_discover_hero to core/discovery/hero.py
Body byte-identical to the original. Spotify proxy via registry,
_get_active_discovery_source and get_current_profile_id redefined
as stateless shims, _get_metadata_fallback_client injected via init()
because it composes multiple registry helpers wired in web_server.py.

web_server.py: 35753 → 35586 (-167 lines).
2026-04-30 09:47:06 -07:00
Broque Thomas
a4eccff4a5 Lift discovery scoring + tidal-track search to core/discovery/scoring.py
Both function bodies (_discovery_score_candidates and
_search_spotify_for_tidal_track) are byte-identical to the originals.
The shared matching_engine instance is injected via init() right after
_init_connection_test; the spotify proxy + _get_metadata_fallback_source
shim follow the same pattern used elsewhere.

web_server.py: 36019 → 35753 (-266 lines).
2026-04-30 09:08:47 -07:00
Broque Thomas
de1b4c9b3c Lift get_debug_info to core/debug_info.py
Both function bodies byte-identical to the originals. The spotify
proxy resolves through core.metadata.registry; the tidal proxy is
backed by an injected getter so a Tidal re-auth that rebinds
web_server.tidal_client is visible. 13 state dicts and helpers are
injected via init() after _init_connection_test, when all deps
already exist.

web_server.py: 36260 → 36019 (-241 lines).
2026-04-30 08:44:34 -07:00
BoulderBadgeDad
afd5d98136
Merge pull request #435 from kettui/refactor/provider-agnostic-wishlist-schema
Make wishlist flow provider-agnostic
2026-04-29 22:51:18 -07:00
Broque Thomas
0cacbd6b5e Lift run_detection to core/connection_detect.py
Body byte-identical to the original. Pure stdlib + requests, no
web_server-specific globals or runtime state — no init() needed.

web_server.py: 36500 → 36261 (-239 lines).
2026-04-29 22:40:34 -07:00
Antti Kettunen
fd30d2a0be
Rename wishlist lifecycle helper
- Switch the download lifecycle over to the neutral wishlist track helper name
- Keep the old Spotify helper as a compatibility alias for older callers
- Store track_data as the primary failed-download wishlist payload key and add regression coverage
2026-04-30 08:06:02 +03:00
Antti Kettunen
b1a9c1b458
Accept wishlist track_data aliases
- Let the wishlist service accept both track_data and spotify_track_data
- Preserve the backward-compatible wrapper while avoiding the keyword argument crash
- Add a regression test for the alias path
2026-04-30 07:59:43 +03:00
Antti Kettunen
0fa692f935
Make wishlist respect configured providers
- add neutral wishlist payload helpers while keeping legacy Spotify aliases
- route wishlist removal and classification through generic track data
- keep API and service compatibility for existing callers
2026-04-30 07:41:22 +03:00
Broque Thomas
32c57124fb Lift run_service_test to core/connection_test.py
Body byte-identical to the original. Five deps (soulseek_client,
qobuz_enrichment_worker, hydrabase_client, docker_resolve_url,
docker_resolve_path) are injected via init() right after the
register_runtime_clients block — that is the earliest point at which
hydrabase_client is guaranteed to exist.

web_server.py: 36833 → 36500 (-333 lines).
2026-04-29 21:00:23 -07:00
Broque Thomas
8299dc211e Lift _run_duplicate_cleaner to core/library/duplicate_cleaner.py
Body byte-identical to the original. The shared state dict, lock,
docker_resolve_path helper, and automation engine are injected via
init() at the lift point, where all four originals are already defined.

web_server.py: 37015 → 36833 (-182 lines).
2026-04-29 20:10:22 -07:00
Broque Thomas
dae7f21265 Lift _search_service to core/library/service_search.py
Lifts _search_service and its _detect_provider helper. Both bodies are
byte-identical to the originals. The nine enrichment worker handles
(spotify/itunes/mb/lastfm/genius/tidal/qobuz/discogs/audiodb) are
injected via init() right after qobuz is constructed, which is the
last worker to come up — and well before Flask starts accepting
requests, so the route handlers never see unbound workers.

web_server.py: 37245 → 37015 (-230 lines).
2026-04-29 18:06:23 -07:00
Broque Thomas
0e237f14d4 Lift liked-artist matching to core/artists/liked_match.py
Lifts _match_liked_artists_to_all_sources and
_backfill_liked_artist_images. Both bodies are byte-identical to the
originals. Uses the same _SpotifyClientProxy + _get_*_client shim
pattern as core/artists/map.py so the bodies resolve their original
names without modification.

web_server.py: 37501 → 37245 (-256 lines).
2026-04-29 17:34:28 -07:00
Broque Thomas
9e8787c002 Lift WebUIDownloadMonitor to core/downloads/monitor.py
Class body byte-identical to original. Module-level IS_SHUTTING_DOWN
flag is mirrored from web_server's own flag in _shutdown_runtime_components
so the monitor loop still sees shutdown signals at the right moment.

Eight web_server-side helpers (_make_context_key, _on_download_completed,
_run_post_processing_worker, _download_track_worker,
_start_next_batch_of_downloads, _orphaned_download_keys,
missing_download_executor, soulseek_client) are injected via init() after
register_runtime_clients, when all symbols are defined and well before
Flask starts accepting requests.

web_server.py: 38220 → 37501 (-719 lines).
2026-04-29 16:28:52 -07:00
Broque Thomas
5875372ae0 Lift artist map endpoints to core/artists/map.py
Lifts get_artist_map_data, get_artist_map_genre_list,
get_artist_map_genres, and get_artist_map_explore (plus the
_artmap_cache_* helpers and _artist_map_cache dict) to a new module.
Bodies are byte-identical to the originals. web_server.py keeps
thin route shells that delegate to the lifted functions.

A _SpotifyClientProxy resolves the global spotify_client lazily via
core.metadata.registry.get_spotify_client() so a Spotify re-auth that
rebinds the cached client stays visible to the lifted bodies.

web_server.py: 39124 → 38220 (-904 lines).
2026-04-29 15:48:37 -07:00
Broque Thomas
7ca786539e Lift WebMetadataUpdateWorker to core/workers/metadata_update.py
Class body byte-identical to original. The shared metadata_update_state
dict is bound at import time via init() so the class body can mutate
it without web_server.py rebinding.

web_server.py: 39754 → 39122 (-632 lines).
2026-04-29 14:20:41 -07:00
Broque Thomas
1d5f1e2047 fix: pause Spotify worker on non-Spotify primary + cut daily budget to 500
The Spotify enrichment worker was auto-starting unconditionally at boot,
hammering /v1/search to match every track in the library against the
Spotify catalog regardless of which metadata source the user had
actually chosen as their primary. Users on Deezer, iTunes, Discogs,
or Hydrabase saw multi-hour 429 bans (typically 14400s) on Spotify
even though they never wanted Spotify-driven enrichment in the first
place — the worker generated dead API traffic the user neither asked
for nor benefited from.

Compounded by Spotify's February 2026 API tightening:
- /v1/search max limit cut from 50 to 10 per request, default from
  20 to 5 — every track now needs more pagination, more requests.
- Sustained-rate detection more aggressive — repeated calls over
  hours trigger automated long-form bans even when each individual
  30-second window is well under the rolling limit.

Result: a user on Deezer would see their Spotify connection get banned
for 4 hours after about 30 tracks of enrichment activity, with no
recourse other than manually pausing the worker each session.

Two-part fix:

1. Boot gate (web_server.py): only auto-start the worker when
   `get_primary_source() == 'spotify'`. Otherwise initialize in the
   paused state with an explanatory log line. The settings UI manual
   unpause control remains functional for users who explicitly want
   background Spotify enrichment regardless of primary source.

   Boot logic:
   - User manually paused (existing config) → stays paused (preserved).
   - Primary = 'spotify' → starts running (preserved).
   - Primary != 'spotify' → starts paused with log line.

2. Daily budget reduction (core/spotify_worker.py): drop from 3000 to
   500 items per calendar day. The 3000 cap was set when /v1/search
   returned 50 results per call; now that it caps at 10, each track
   needs roughly 5x the API load to find a confident match. 500/day
   keeps the worker productive without crossing Spotify's hidden
   sustained-rate detection threshold.

The runtime side of the boot gate — auto-pausing when the user
switches primary source mid-session — is out of scope. The settings
UI already exposes the manual toggle, and primary-source switches are
infrequent enough that requiring a manual unpause after the fact is
acceptable.

Full suite: 1355 passing. Ruff clean.
2026-04-29 12:46:08 -07:00
BoulderBadgeDad
58a4c1905b
Merge pull request #419 from kettui/refactor/metadata-service-split-and-metadata-client-management-optimizations
Split metadata service logic into separate modules, move client management out of web_server
2026-04-29 12:36:03 -07:00
Broque Thomas
5c8b8b271a Lift _prepare_stream_task + playlist_explorer_build_tree to core/
Final lift in the web_server.py extraction effort. Pulls two route
handlers + one background worker out of `web_server.py` into new
focused packages:

- `core/streaming/prepare.py` — 258-line stream-prep worker that
  downloads a track to the local Stream/ folder for the browser audio
  player.
- `core/playlists/explorer.py` — 305-line route handler for
  `POST /api/playlist-explorer/build-tree` that streams an NDJSON
  discography tree from a mirrored playlist.

What `prepare_stream_task` does:

1. Reset stream state to 'loading' with the new track info.
2. Clear any prior file from Stream/ (only one stream lives there).
3. Spin up a fresh asyncio event loop and `soulseek_client.download()`.
4. Poll progress every 1.5s. Queue timeout 15s; overall 60s.
5. On succeeded + bytes-match: find the file with retry, move into
   Stream/, signal slskd completion, mark state 'ready' with file_path.
6. On error/timeout/cancel: state goes to 'error' or 'stopped'.
7. Finally: tear down the event loop cleanly.

What `playlist_explorer_build_tree` does:

1. Validate request, load playlist + tracks from DB.
2. Pick active metadata source (Spotify if authed, else fallback).
3. Group tracks by artist using discovered matched_data when the
   provider matches the active source.
4. Stream NDJSON: meta line → one artist line per group → complete line.
5. Per artist: cache check → resolve discography → tag releases with
   `in_playlist` flag based on title-similarity match → filter by mode
   (`albums` = only matches; `discographies` = full disco).
6. Mark playlist as explored on completion.

Strict 1:1 byte parity:
Both functions exposed their dependencies through proxy patterns
established in earlier lifts (PR4–PR8). For prepare_stream_task,
`stream_state` is a deps property; for the explorer, Flask `request` /
`jsonify` / `Response` are injected via deps so the lifted body keeps
its native syntax. Both lifts verified ZERO diff against the original
after `deps.X` → global X normalization.

258 lines orig = 258 lines lifted (prepare_stream_task).
305 lines orig = 305 lines lifted (explorer).

Bonus cleanup: web_server.py's module-level `import shutil` and
`import glob` were now unused (only `_prepare_stream_task` used them
at module scope; every other reference is via inline `import shutil`
in respective function bodies). Removed both module-level imports —
ruff caught the F811 redefinitions and confirmed they're truly
redundant.

Dependencies for `PrepareStreamDeps` (11 fields):
config_manager, soulseek_client, stream_lock, project_root,
docker_resolve_path, find_streaming_download_in_all_downloads,
find_downloaded_file, extract_filename, cleanup_empty_directories,
plus 2 stream_state property delegates.

Dependencies for `PlaylistExplorerDeps` (9 fields):
Flask request/Response/jsonify, spotify_client, get_database,
get_active_discovery_source, get_metadata_fallback_client,
get_metadata_fallback_source, get_metadata_cache.

Tests: 6 new under tests/streaming/test_prepare.py (state init,
Stream/ folder creation + clearing, download-init failure, completed
+ moved + ready state, partial-bytes incomplete-warning path) plus 9
new under tests/playlists/test_explorer.py (5 validation early-exit
paths, streaming response shape with meta/complete lines, mark-
explored side effect, discovered-artist grouping using matched_data,
provider mismatch falling back to raw artist name).

Full suite: 1355 passing (was 1340). Ruff clean.

End of the web_server.py extraction effort. Started at ~45,000 lines
across PR4–PR8 + this commit; finished around 35,000 lines with the
heavy worker + route logic now living in domain-cohesive packages
under core/. The remaining bulk in web_server.py is route handlers,
service initialization, and the deferred 1530-line
`_register_automation_handlers` (startup-only, marginal lift value).
2026-04-29 11:26:07 -07:00
Broque Thomas
91978656a5 Lift enhance_artist_quality to core/artists/quality.py
Pulls the 284-line artist quality enhancement helper out of
`web_server.py` into a new `core/artists/` package. Flask route handler
split: route + request parsing stay in web_server.py, the body lifts to
a pure function returning `(payload_dict, http_status_code)`.

What `enhance_artist_quality` does:

1. Validate request: track_ids must be non-empty, artist must exist.
2. Build a `track_lookup` from `database.get_artist_full_detail` so each
   selected track resolves with its album context.
3. Per track:
   - Read current quality tier from the file extension.
   - Build `matched_track_data` for the wishlist entry, in priority
     order:
     - Spotify direct lookup via stored `spotify_track_id` (preferred).
       Uses raw API data when available; otherwise rebuilds the payload
       and pulls album images via a follow-up `get_album` call.
     - Spotify search fallback using matching_engine queries with
       artist+title similarity scoring (album-type bonus for albums,
       smaller bonus for EPs). Stops at first >= 0.9 confidence match.
     - iTunes/fallback source search with the same scoring shape.
   - Add to wishlist via `wishlist_service.add_spotify_track_to_wishlist`
     with `source_type='enhance'` and a `source_context` carrying the
     original file path, format tier, bitrate, original_tier, and
     artist_name.
   - Tally `enhanced_count` / `failed_count` / per-track failure reasons.
4. Return `{success, enhanced_count, failed_count, failed_tracks}` 200.

Dependencies injected via `ArtistQualityDeps` (7 fields) — spotify_client,
matching_engine, get_database, get_wishlist_service,
get_current_profile_id, get_quality_tier_from_extension,
get_metadata_fallback_client.

Diff vs original after `deps.X` → global X normalization is **1 line of
cosmetic drift** — the success return now uses an explicit `(payload, 200)`
tuple to keep all returns shape-consistent for the wrapper. Flask treats
`jsonify(x)` and `(jsonify(x), 200)` identically. 284 lines orig = 285
lines lifted, body otherwise byte-identical.

Tests: 10 new under tests/artists/test_quality.py covering input
validation (empty track_ids, artist not found), Spotify direct lookup
via raw_data, Spotify direct lookup with enhanced format requiring
album image rebuild, Spotify search fallback, iTunes/fallback source
match path, track-not-found and no-file-path failure modes, complete
no-match failure, and source_context payload assertions (enhance flag,
file path, format tier, bitrate, source_type).

Full suite: 1340 passing (was 1330). Ruff clean.
2026-04-29 10:05:57 -07:00
Broque Thomas
3a6597561a Lift _execute_retag to core/library/retag.py
Pulls the 258-line retag worker out of `web_server.py` into a new
`core/library/` package. Pure 1:1 lift — wrapper keeps the original
entry-point name so the retag-trigger endpoint continues to work
without changes.

What `execute_retag` does:

1. Fetch album + track metadata for the new `album_id` (Spotify or
   iTunes — the Spotify client transparently falls back).
2. Load existing files in the retag group from the DB.
3. Match each existing track to a new Spotify track:
   - Priority 1: same disc + track number.
   - Priority 2: title similarity >= 0.6 (SequenceMatcher).
4. For each matched pair:
   - Re-write metadata tags via `_enhance_file_metadata`.
   - Compute the new path via `_build_final_path_for_track` and move
     the audio file (plus .lrc / .txt sidecars) if the path changes.
   - Drop an orphaned cover.jpg if it's left in an empty directory.
   - Clean up empty parent directories left behind.
   - Download the new cover art into the new album dir.
5. Update the retag group record with new artist / album / image /
   total_tracks / release_date and the appropriate Spotify-or-iTunes
   album ID (numeric → iTunes, alphanumeric → Spotify).
6. Mark the retag state 'finished' (or 'error' on exception).

Strict 1:1 byte parity:
The original mutated `retag_state` as a module global (the function
declared `global retag_state` even though it only mutates in place).
Here `retag_state` is exposed through the `RetagDeps` proxy as a Python
property so the lifted body keeps `name[key] = value` /
`name.update(...)` syntax. The property setter rebinds the
web_server.py reference if the function ever reassigns it (currently
it doesn't, but the setter is wired for parity with the watchlist lift).

Diff vs original after `deps.X` → global X normalization is **zero
differences** apart from the dropped `global retag_state` decl and the
inline `from database.music_database import get_database` (replaced by
deps.get_database()). 258 lines orig = 258 lines lifted, byte-identical
body otherwise.

Dependencies injected via `RetagDeps` (13 fields) — config_manager,
retag_lock, spotify_client, plus 8 callable helpers
(get_audio_quality_string, enhance_file_metadata,
build_final_path_for_track, safe_move_file, cleanup_empty_directories,
download_cover_art, docker_resolve_path, get_database) and 2 property
delegates (_get_retag_state / _set_retag_state).

Tests: 11 new under tests/library/test_retag.py covering setup error
paths (no album data, no album tracks, no existing tracks),
track-number priority match, title-similarity fallback, no-match skip,
missing file skip, file move when path changes, group record update
(spotify vs iTunes ID branching by alphanumeric vs numeric album_id),
multi-disc total_discs computation.

Full suite: 1330 passing (was 1319). Ruff clean.
2026-04-29 09:03:42 -07:00
Broque Thomas
2b2003ba4c Lift _process_watchlist_scan_automatically to core/watchlist/auto_scan.py
Pulls the 390-line watchlist auto-scan orchestrator out of `web_server.py`
into a new `core/watchlist/` package. Watchlist (followed-artists scanner
that finds new releases) is a separate domain from kettui's wishlist
(failed-download retry queue), so this lift does not overlap with the
ongoing PR400-style extractions.

What `process_watchlist_scan_automatically` does:

1. Smart stuck-detection guard before acquiring the timer lock —
   prevents deadlock when a previous scan flag is dangling past the
   2-hour timeout.
2. Inside the timer lock: re-check + set the active scan flag with the
   current timestamp.
3. Per-profile expansion (or single-profile when manually triggered):
   - Watchlist count check + Spotify auth gate.
   - Backfill missing artist images.
4. Initialize a fresh `watchlist_scan_state` dict (the deps property
   setter rebinds the web_server.py module-level name so external
   sentinel checks via id() comparison still detect the swap).
5. Pause enrichment workers, then call
   `WatchlistScanner.scan_watchlist_artists` with a per-event progress
   callback that translates scanner events into automation log lines.
6. Post-scan steps (skipped if the scan was cancelled mid-flight):
   - Populate discovery pool from similar artists (per-profile).
   - Refresh ListenBrainz playlists.
   - Update current seasonal playlist (weekly cadence).
   - Generate Last.fm radio playlists.
   - Sync Spotify library cache.
   - Activity feed entry + automation_engine.emit('watchlist_scan_completed').
7. On exception: mark state['status']='error', re-raise so the
   automation wrapper records the failure.
8. Finally: resume enrichment workers, clear the scanner's rescan
   cutoff, reset the auto-scanning flag.

Strict 1:1 byte parity:
The original mutated `watchlist_auto_scanning`,
`watchlist_auto_scanning_timestamp`, and `watchlist_scan_state` as
module globals (with a leading `global` decl). Here those names are
exposed through the `WatchlistAutoScanDeps` proxy as Python properties
so the lifted body keeps the same `name = value` / `name[key] = value`
shape. Property setters fan writes back to web_server.py via callback
pairs.

Diff vs original after `deps.X` → global X normalization is **zero
differences** apart from the dropped `global` declaration line — Python
doesn't need it once the names are property accesses on the deps object.
390 lines orig = 390 lines lifted, byte-identical body otherwise.

Dependencies injected via `WatchlistAutoScanDeps` (15 fields total) —
Flask app, spotify_client, automation_engine, watchlist_timer_lock, plus
5 callable helpers and 6 property delegate callbacks (paired
get/set for each of the three globals).

Tests: 11 new under tests/watchlist/test_auto_scan.py covering
stuck-detection guard, race-check inside lock, zero-watchlist short-
circuit, unauthenticated Spotify gate, successful scan with all post-
scan steps, automation event emission, activity feed logging,
cancellation mid-scan skipping post-steps, profile-scoped trigger,
flag reset in finally, rescan cutoff clear in finally.

Full suite: 1319 passing (was 1308). Ruff clean.
2026-04-29 08:17:41 -07:00
Antti Kettunen
e6c2bee427
Move profile Spotify cache into registry
- let core.metadata.registry own per-profile Spotify client caching
- register the DB-backed profile credentials provider from web_server.py
- invalidate only the affected profile cache entry on save, delete, and auth
2026-04-29 12:36:37 +03:00
Antti Kettunen
11be8834eb
Use metadata registry for web_server clients
- make web_server.py read and refresh Spotify from core.metadata.registry
- add single-key metadata cache eviction for Spotify reauth
- export the new cache helper through the metadata package shims
2026-04-29 12:27:59 +03:00
Antti Kettunen
50e1ae3a3f
Move metadata helpers into package modules
- split metadata lookup logic into core/metadata/*
- keep core/metadata_service.py as the legacy barrel
- update tests and artist-detail code to patch concrete modules
2026-04-29 11:28:42 +03:00
Broque Thomas
a2e068eaba Lift _try_staging_match to core/downloads/staging.py
Pulls the 201-line staging-folder shortcut out of `web_server.py` into
its own module under the existing `core/downloads/` package. Pure 1:1
lift — wrapper keeps the original entry-point name so the task worker's
existing call site continues to work without changes.

What `try_staging_match` does:

1. Pull the per-batch staging-file cache (one filesystem scan per batch).
2. For each staging entry, compute title + artist similarity using
   SequenceMatcher and the matching engine's `normalize_string`. Require
   title >= 0.80, then a combined score >= 0.75. The weighting flips
   based on whether artist info is available on both sides:
   - both have artist: 0.55*title + 0.45*artist
   - either side missing artist: 0.80*title + 0.20*artist (lean on title)
3. Copy the matched file to the configured transfer dir (with a
   "_staging" suffix when the destination filename already exists, to
   avoid overwriting a legitimate prior download).
4. Mark the task as 'post_processing', username='staging',
   staging_match=True.
5. Build a synthetic spotify_artist / spotify_album context (mirroring
   the modal-worker logic so the file-organization template applies
   cleanly) and store it under "staging_<task_id>". Two paths:
   - Explicit context branch (track_info has _is_explicit_album_download)
     → real album/artist data copied through.
   - Fallback branch → synthesized from track + track_info, with
     `is_album_download` heuristically derived (album differs from title
     and isn't "Unknown Album").
6. Hand off to `_post_process_matched_download_with_verification` which
   does tagging, path building, AcoustID verification, and DB insertion.

Returns True if the staging shortcut won; False to fall through to the
normal Soulseek search path.

Dependencies injected via `StagingDeps` (5 fields) — config_manager,
matching_engine, get_staging_file_cache, docker_resolve_path,
post_process_matched_download_with_verification.

Diff vs original after `deps.X` → global X normalization is **zero
differences** — 201 lines orig = 201 lines lifted, byte-identical body
(including all whitespace, comments, log strings, and the inline
`from difflib import SequenceMatcher` / `import shutil` imports inside
the function body).

Tests: 9 new under tests/downloads/test_downloads_staging.py covering
no staging files / no track title / low-confidence match returning
False, exact match copying file + transitioning task state + invoking
post-processing, existing-file rename via `_staging` suffix, explicit
album context branch, fallback context synthesis (with both album-as-
album and album-equals-title cases), and copy failure (missing source
file) returning False.

Full suite: 1308 passing (was 1299). Ruff clean.
2026-04-28 23:26:15 -07:00
Broque Thomas
793593de51 Lift _run_tidal_discovery_worker to core/discovery/tidal.py
Missed worker from the PR5 discovery-workers series — Tidal sits in the
same domain as the deezer / spotify_public / listenbrainz / youtube /
beatport workers that were lifted in PR5b–PR5h, follows the same shape,
shares the same `_search_spotify_for_tidal_track` helper, and was simply
overlooked in the original inventory.

Pure 1:1 lift of the 212-line worker. Wrapper keeps the original
entry-point name so the existing call sites in web_server.py continue
to work without changes.

What `run_tidal_discovery_worker` does:

1. Pause enrichment workers (release shared resources).
2. For each Tidal track:
   - Cancellation gate (state['cancelled']).
   - Discovery cache lookup; cache hit short-circuits the search.
   - SimpleNamespace-style track passed straight to
     `_search_spotify_for_tidal_track` (the shared helper used by every
     worker in this family).
   - On Spotify match: build `match_data` preserving track_number /
     disc_number from raw API data, image extracted from album images
     or track object fallback, release_date filled from
     track.release_date when album dict is missing it.
   - On iTunes match: dict result populated as `match_data` with source
     set to discovery_source, image extracted from album images.
   - Save matched result to discovery cache.
   - On miss: Wing It stub stored as 'wing-it' status (success ticked).
3. After all tracks: phase='discovered', activity feed entry, sync
   discovery results back to mirrored playlist via
   `_sync_discovery_results_to_mirrored` with 'tidal' tag.
4. On error: state['phase']='error' + status with error string.
5. Finally: resume enrichment workers.

Dependencies injected via `TidalDiscoveryDeps` (13 fields) —
tidal_discovery_states, spotify_client, plus 11 callable helpers
(pause/resume enrichment, get_active_discovery_source,
get_metadata_fallback_client, get_discovery_cache_key, get_database,
validate_discovery_cache_artist, search_spotify_for_tidal_track,
build_discovery_wing_it_stub, add_activity_item,
sync_discovery_results_to_mirrored). Same surface as the deezer worker.

Diff vs original after `deps.X` → global X normalization is **zero
differences** — 212 lines orig = 212 lines lifted, byte-identical body
(including all whitespace, comments, log strings).

Tests: 9 new under tests/discovery/test_discovery_tidal.py covering
cache hit short-circuit, Spotify tuple match (track/disc preservation),
iTunes dict match path, Wing It fallback, cancellation, completion
phase update, activity feed entry, mirrored sync invocation, per-track
error handling.

Full suite: 1299 passing (was 1290). Ruff clean.
2026-04-28 23:02:24 -07:00
Antti Kettunen
a759f778b6
Move metadata API into package
- add package-owned metadata API, cache, registry, and lookup modules
- keep legacy metadata_service and metadata_cache paths as explicit shims
- update metadata call sites and tests to use package-owned helpers
2026-04-29 08:10:18 +03:00
Broque Thomas
1c43ca2eef PR6: lift _attempt_download_with_candidates to core/downloads/candidates.py
First lift in the new PR6 batch. Pulls the 312-line candidate-fallback
download dispatcher out of `web_server.py` into a new module under the
existing `core/downloads/` package. Pure 1:1 lift — wrapper keeps the
original entry-point name so all callers (search/match pipeline) work
unchanged.

What `attempt_download_with_candidates` does:

1. Sort candidates by descending confidence.
2. For each candidate:
   - Cancellation gates (3 points: top of loop, before download starts,
     after download_id is assigned).
   - Skip already-tried sources via the per-task `used_sources` set.
   - Skip blacklisted sources (user-flagged bad matches).
   - Race protection: bail when the task already has an active
     download_id.
   - `update_task_status('downloading')`, then `soulseek_client.download`.
3. On a successful download_id:
   - Build `matched_downloads_context` entry keyed by
     `make_context_key(username, filename)`.
   - For tracks with clean Spotify metadata, pull track_number /
     disc_number from (1) track_info → (2) track object → (3) Spotify
     API call. When local album context is incomplete, the API response
     backfills release_date / album_type / total_tracks / images / id.
   - Set `is_album_download` based on explicit context flag or
     heuristic (album differs from title, isn't "Unknown Album").
   - Store task/batch IDs and track_info on the context for post-
     processing + playlist-folder mode.
4. On a cancellation that wins the race after the download started:
   - `cancel_download(...)` to stop the in-flight Soulseek transfer.
   - `on_download_completed(batch_id, task_id, success=False)` to free
     the worker slot.
5. On exception or download-start failure: reset task status to
   'searching', continue to next candidate.

Dependencies injected via `CandidatesDeps` (7 fields) — soulseek_client,
spotify_client, run_async, get_database, update_task_status,
make_context_key, on_download_completed.

Diff vs original after `deps.X` → global X normalization is **zero
differences** — 312 lines orig = 312 lines lifted, byte-identical body
(including all whitespace, comments, log strings).

Tests: 14 new under tests/downloads/test_downloads_candidates.py
covering happy path (first candidate succeeds, confidence ordering),
used_sources dedup, blacklist skip, cancellation gates (cancelled
status, deleted task, active download_id, mid-flight cancel + cleanup
callback), failure paths (all candidates failed, exception during
download falls through to next), context payload (explicit album
context, track_number priority order, API backfill of incomplete album
metadata), and equal-confidence stable order.

Pre-existing behavior documented in tests:
`spotify_album_context['id']` initializes to a non-empty placeholder
'from_sync_modal' in the fallback path, so the API-backfill condition
`if not spotify_album_context.get('id')` never fires for the id field
specifically. Other album fields (release_date, album_type) backfill
fine because they default to empty.

Full suite: 1290 passing (was 1276). Ruff clean.
2026-04-28 22:06:33 -07:00
Broque Thomas
d97d105b97 fix: substitute \$cdnum in download paths and skip auto disc folder when template uses it
User report: multi-disc albums on the latest dev had literal "\$cdnum"
in their filenames instead of the expected "CDxx" label, plus a
redundant "Disc N" folder on top of the in-filename label.

Two bugs in core/imports/paths.py:

1. _replace_template_variables (the substitution helper used by every
   download path builder) had no handling for \$cdnum or \${cdnum}. The
   matching helper in web_server.py and core/repair_jobs/library_reorganize.py
   did the substitution; this one didn't, so production downloads passed
   the placeholder through unchanged. Added a cdnum_value computation
   (CD%02d when total_discs > 1, empty otherwise) plus the corresponding
   bracket_map entry and \$cdnum replace before \$track (matches the
   ordering in the other path builders).

2. The album-path branch of build_final_path_for_track auto-injected a
   "Disc N" folder whenever total_discs > 1, suppressed only when the
   template contained \$disc. Templates using \$cdnum (or \${disc} /
   \${discnum} / \${cdnum}) got both a "CDxx" label in the filename and
   the auto folder. Widened the user_controls_disc check to cover all
   the disc-bearing placeholders.

Bonus cleanup along the way:

- Folder-part stripping now drops a leading \$cdnum token (mirrors the
  existing \$disc / \$discnum / \$quality strip — defensive against an
  empty cdnum landing alone in a folder segment).
- Filename cleanup now strips a leading "  - " left behind when \$cdnum
  expands to empty on a single-disc album (mirrors the same regex in
  library_reorganize.py).
- album_template config access switched from the dotted-path key to the
  nested-dict access pattern used by the rest of the function — handles
  both production config_manager and the flat _Config used in tests.

Tests: 4 new under tests/imports/test_import_paths.py
- multi-disc cdnum substitution produces "CD02"
- single-disc cdnum collapses to empty
- folder-part containing only \$cdnum is dropped
- build_final_path_for_track with \$cdnum template produces no auto
  "Disc N" folder

Full suite: 1276 passing (was 1272). Ruff clean.
2026-04-28 21:32:24 -07:00
BoulderBadgeDad
e504099439
Merge pull request #393 from elmerohueso/hifi-fixes
fix HIFI downloads to get LOSSLESS and HI_RES again
2026-04-28 21:06:41 -07:00
Broque Thomas
99a763dace fix: drop redundant library-cleanup pass from wishlist download flows
Both the auto and manual wishlist download paths called
`remove_tracks_already_in_library` before submitting the batch — a
serial DB lookup per track per artist (~1s/track on a 24-track
wishlist). The batches set `force_download_all=True` which is
explicitly documented as "skip the expensive library check" — the
pre-flight cleanup was contradicting that flag.

Removed the cleanup call from both flows. Kept `remove_wishlist_duplicates`
(fast SQL DELETE) and the standalone `/api/wishlist/cleanup` endpoint
that exposes the library scan as explicit user-triggered maintenance.

Safety check on the trade-off:
- post-processing at `core/imports/pipeline.py:576-624` already handles
  re-downloads defensively: existing file with metadata → skip overwrite
  + delete source duplicate, no library corruption.
- Master worker's analysis loop normally removes wishlist entries for
  found tracks via `_check_and_remove_track_from_wishlist_by_metadata`,
  so stale wishlist entries should be rare in practice.
- Worst case for the rare orphan: one redundant download attempt that
  the post-processing layer no-ops on. Bandwidth waste, not data damage.

Tests updated:
- `..._does_not_run_library_cleanup` (renamed from `_skips_enhance_tracks_during_cleanup`)
  asserts no DB track-existence checks happen and no wishlist removals
  fire — both `enhance` and "owned" tracks reach the master worker.
- `..._marks_batch_complete_when_wishlist_genuinely_empty` (renamed from
  `..._after_cleanup`) covers the path where the wishlist starts empty.

Full suite: 1232 passing. Ruff clean.
2026-04-28 20:30:12 -07:00
Broque Thomas
6a25dcd49e fix: move manual wishlist cleanup into background worker
The manual wishlist download endpoint blocked the request thread on a
slow library-cleanup pass before submitting the batch — for a 24-track
wishlist that's ~50 per-track DB lookups serialised in the request
handler, taking 30+ seconds before the frontend got a response. The
modal sat at "Pending..." with no progress visible the whole time.

Split start_manual_wishlist_download_batch into:

1. SYNC path (request handler):
   - Generate batch_id, create download_batches entry with phase=analysis
     and analysis_total=0 placeholder.
   - Submit a single bg job (`_prepare_and_run_manual_wishlist_batch`) to
     the missing-download executor.
   - Return 200 with batch_id immediately. Frontend can start polling
     /api/active-processes status right away.

2. BG path (executor thread):
   - db.remove_wishlist_duplicates (slow-ish, single SQL)
   - remove_tracks_already_in_library (the slow one — per-track DB checks)
   - wishlist_service.get_wishlist_tracks_for_download
   - sanitize + dedupe + filter (track_ids / category)
   - Update batch.analysis_total with the real filtered count
   - add_activity_item("Wishlist Download Started", ...)
   - run_full_missing_tracks_process (master worker)

Edge case: if cleanup empties the wishlist, the bg job marks the batch
phase='complete' with error='No tracks in wishlist' (instead of the old
synchronous 400 response). Frontend status poll picks this up and the
modal can close cleanly.

Tests: existing 2 manual-download tests updated to drive the bg job
explicitly via a new `_run_submitted_bg_job` helper. Added 2 new tests:
- `..._returns_immediately_with_placeholder` — proves the sync path
  doesn't trigger any cleanup or master-worker calls; analysis_total=0.
- `..._marks_batch_complete_when_wishlist_empty_after_cleanup` —
  cleanup empties the list, master worker never invoked, batch ends
  with phase='complete'.

Full suite: 1232 passing (was 1230). Ruff clean.
2026-04-28 20:10:43 -07:00
BoulderBadgeDad
8019e13a2e
Merge pull request #400 from kettui/refactor/extract-wishlist-code
Extract wishlist core logic from web_server.py
2026-04-28 20:02:39 -07:00
elmerohueso
1c07acc349 per-segment retry for tidal and hifi 2026-04-28 20:19:01 -06:00
elmerohueso
d6b217081f fix tidal direct download similarly to the hifi fix 2026-04-28 20:19:00 -06:00
elmerohueso
91b33b3dd2 use trackManifest endpoint and rebuild tracks from HLS playlists (old track enpoint was changed by Tidal to no longer provide LOSSLESS or HI_RES) 2026-04-28 20:19:00 -06:00
elmerohueso
6ae1cb471e user-editable hifi instances 2026-04-28 20:19:00 -06:00
Broque Thomas
a38bfcba55 PR5h: lift _run_quality_scanner to core/discovery/quality_scanner.py
Final lift in the PR5 discovery-workers series. Pulls the 328-line
library quality scanner out of `web_server.py` into its own focused
module under `core/discovery/`. Pure 1:1 lift — wrapper keeps the
original entry-point name.

What the quality scanner does:

1. Reset scanner state (counters, results), load quality profile +
   minimum acceptable tier from QUALITY_TIERS.
2. Load tracks from DB based on scope:
   - 'watchlist' → tracks for watchlisted artists only.
   - other → all library tracks.
3. For each track:
   - Stop-request gate (state['status'] != 'running').
   - Quality-tier check via _get_quality_tier_from_extension(file_path).
   - Skip tracks meeting standards (tier_num <= min_acceptable_tier).
   - For low-quality tracks: matching_engine search query gen, score
     candidates against Spotify (artist + title similarity, album-type
     bonus), pick best match >= 0.7 confidence.
   - On match: add full Spotify track to wishlist via
     `wishlist_service.add_spotify_track_to_wishlist` with
     source_type='quality_scanner' and a source_context that captures
     original file_path, format tier, bitrate, and match confidence.
4. After all tracks: status='finished', progress=100, activity feed
   entry, emit `quality_scan_completed` event for automation engine.
5. On critical exception: status='error', error message captured.

Wishlist service interaction is via the public
`add_spotify_track_to_wishlist` API only — no overlap with kettui's
planned `core/wishlist/` package extraction (the import lives inside
the function, exactly as in the original, and will follow whatever
path that package takes).

Dependencies injected via `QualityScannerDeps` (8 fields) —
quality_scanner_state dict, quality_scanner_lock, QUALITY_TIERS
constant, spotify_client, matching_engine, automation_engine, plus 2
callable helpers (get_quality_tier_from_extension, add_activity_item).

Diff vs original after `deps.X` → global X normalization is **zero
differences** — 328 lines orig = 328 lines lifted, byte-identical body
(including all whitespace, comments, log strings, and the inline
`from core.wishlist_service import get_wishlist_service` /
`from database.music_database import MusicDatabase` imports at the
top of the function).

Tests: 11 new under tests/discovery/test_discovery_quality_scanner.py
covering state init/reset, no-watchlist-artists short-circuit,
unauthenticated Spotify error, high-quality skip, low-quality search
trigger, match → wishlist add (with full source_context payload),
no-match no-add, mid-loop stop request, completion phase + progress,
automation engine event emission, all-library scope load.

Full suite: 1152 passing (was 1141). Ruff clean.

End of the PR5 series — `web_server.py` lost ~328 lines on this commit
alone; total trim across PR5a–PR5h is ~2,400 lines of discovery worker
code moved into focused `core/discovery/*.py` modules. The remaining
discovery-adjacent worker `_process_watchlist_scan_automatically` was
deliberately deferred to avoid overlap with kettui's planned wishlist
extraction.
2026-04-28 18:41:29 -07:00
Broque Thomas
c9108ef2fe PR5g: lift _run_listenbrainz_discovery_worker to core/discovery/listenbrainz.py
Seventh lift in the PR5 discovery-workers series. Pulls the 286-line
ListenBrainz discovery worker out of `web_server.py` into its own
focused module under `core/discovery/`. Pure 1:1 lift — wrapper keeps
the original entry-point name.

What the ListenBrainz discovery worker does:

1. Pause enrichment workers (release shared resources).
2. For each ListenBrainz track:
   - Cancellation gate (state['phase'] != 'discovering').
   - Discovery cache lookup; cache hit short-circuits the search.
   - Strategy 1: matching_engine search queries with confidence scoring
     against Spotify (preferred) or iTunes (fallback).
   - Strategy 2: swapped artist/title query.
   - Strategy 3: album-based query (uses album_name when available —
     unique to LB, since YouTube tracks don't have album metadata).
   - Strategy 4: extended search with limit=50.
   - On match → save to discovery cache with image extracted from album
     images or matched_track.image_url fallback.
   - On miss → Wing It stub stored as 'wing-it' status.
3. After all tracks: phase='discovered', status='complete', activity feed
   entry mentioning 'ListenBrainz Discovery Complete'.
4. On error: state['status']='error', phase='fresh'.
5. Finally: resume enrichment workers.

Dependencies injected via `ListenbrainzDiscoveryDeps` (16 fields) —
listenbrainz_playlist_states, spotify_client, matching_engine, plus 13
callable helpers (pause/resume enrichment, get_active_discovery_source,
get_metadata_fallback_client, get_discovery_cache_key, get_database,
validate_discovery_cache_artist, extract_artist_name,
spotify_rate_limited, discovery_score_candidates, get_metadata_cache,
build_discovery_wing_it_stub, add_activity_item).

Diff vs original after `deps.X` → global X normalization is **zero
differences** — 286 lines orig = 286 lines lifted, byte-identical body
(including all whitespace, comments, log strings).

Pre-existing bug preserved (not fixed): if `listenbrainz_playlist_states[
state_key]` raises KeyError on entry, the outer except handler tries to
mutate `state` which is unbound → secondary UnboundLocalError. Same bug
in the original (and the YouTube discovery worker). Documented here for
future cleanup but out of scope for the lift.

Tests: 11 new under tests/discovery/test_discovery_listenbrainz.py
covering cache hit short-circuit, Strategy 1 confidence match, Wing It
fallback, iTunes fallback (Spotify unauthenticated and rate-limited),
cancellation (phase change), completion phase update, activity feed
entry, per-track error handling, float duration_ms tolerance (regression
for the :02d format crash fixed earlier), enrichment workers resume on
finally.

Full suite: 1141 passing (was 1130). Ruff clean.
2026-04-28 16:09:02 -07:00
Broque Thomas
04647eb9f7 PR5f: lift _run_beatport_discovery_worker to core/discovery/beatport.py
Sixth lift in the PR5 discovery-workers series. Pulls the 323-line
Beatport chart discovery worker out of `web_server.py` into its own
focused module under `core/discovery/`. Pure 1:1 lift — wrapper keeps
the original entry-point name.

What the Beatport discovery worker does:

1. Pause enrichment workers (release shared resources).
2. For each Beatport track:
   - Cancellation gate (state['phase'] != 'discovering').
   - Clean Beatport text (artist/title) of common annotations via
     `clean_beatport_text` helper.
   - Single-string artist normalization for "CID,Taylr Renee"-style
     entries — split on comma, take the first.
   - Discovery cache lookup; cache hit short-circuits the search and
     normalizes cached artists from ['str'] → [{'name': 'str'}] to
     match the frontend's expected list-of-objects shape.
   - matching_engine search-query generation (with high min_confidence
     of 0.9 to avoid bad matches).
   - Strategy 1: scored candidates from initial Spotify/iTunes searches.
   - Strategy 4: extended search with limit=50 if no high-confidence
     match found.
   - On Spotify match: format artists as [{'name': str}] objects, pull
     full album object from raw cache when available, fallback to
     reconstructed album dict otherwise.
   - On iTunes match: format with image_url-derived album.images entry
     (300x300 spec), source set to discovery_source.
   - Save matched result to discovery cache when confidence >= 0.75
     (note: lower than search threshold; discovery still benefits from
     these less-confident matches as user-visible suggestions).
   - On miss: Wing It stub stored as 'wing-it' status (success ticked).
3. After all tracks: phase='discovered', activity feed entry, sync
   discovery results back to mirrored playlist via
   `_sync_discovery_results_to_mirrored` with 'beatport' tag.
4. On error: state['phase']='fresh' + status='error'.
5. Finally: resume enrichment workers.

Dependencies injected via `BeatportDiscoveryDeps` (17 fields) —
beatport_chart_states, spotify_client, matching_engine, plus 14
callable helpers (pause/resume enrichment, get_active_discovery_source,
get_metadata_fallback_client, clean_beatport_text,
get_discovery_cache_key, get_database, validate_discovery_cache_artist,
spotify_rate_limited, discovery_score_candidates, get_metadata_cache,
build_discovery_wing_it_stub, add_activity_item,
sync_discovery_results_to_mirrored).

Diff vs original after `deps.X` → global X normalization is **zero
differences** — 323 lines orig = 323 lines lifted, byte-identical body
(including all whitespace, comments, log strings).

Tests: 12 new under tests/discovery/test_discovery_beatport.py covering
cache hit short-circuit (with cached-artist normalization), Spotify
match formatting (list and string artist inputs), iTunes match
(image_url to album.images), Wing It fallback, cancellation
(phase change), completion phase update, activity feed entry, mirrored
sync invocation, top-level error handler, per-track error handling,
comma-separated artist split.

Full suite: 1130 passing (was 1118). Ruff clean.
2026-04-28 15:36:15 -07:00
Broque Thomas
c5e06691e3 PR5e: lift _run_spotify_public_discovery_worker to core/discovery/spotify_public.py
Fifth lift in the PR5 discovery-workers series. Pulls the 278-line
public-Spotify-link discovery worker out of `web_server.py` into its
own focused module under `core/discovery/`. Pure 1:1 lift — wrapper
keeps the original entry-point name.

What the Spotify Public discovery worker does:

1. Pause enrichment workers (release shared resources).
2. For each track:
   - Cancellation gate (state['cancelled']).
   - Normalize artists to plain string list (handles dict + str inputs).
   - Discovery cache lookup; cache hit short-circuits the search and
     populates display fields from the cached match.
   - SimpleNamespace duck-type → `_search_spotify_for_tidal_track`
     (shared search helper, returns tuple for Spotify or dict for iTunes).
   - On Spotify match: build `match_data` preserving track_number /
     disc_number from raw API data; image extracted from album images
     or track object fallback; release_date filled from track.release_date
     when album dict is missing it.
   - On iTunes match: dict result → match_data with source set to
     discovery_source; image extracted from album images.
   - Save matched result to discovery cache.
   - On miss: Wing It stub stored as 'wing-it' status.
3. After all tracks: phase='discovered', activity feed entry.
4. On error: state['phase']='error' + status with error string.
5. Finally: resume enrichment workers.

This worker is structurally close to the Deezer worker (see PR5d) but
intentionally diverges on:
- Track-data field names (`spotify_public_track` vs `deezer_track`).
- Artist normalization (Spotify Public can pass dicts or strings).
- No mirrored-playlist DB writeback (sync is handled separately).

Dependencies injected via `SpotifyPublicDiscoveryDeps` (12 fields) —
spotify_public_discovery_states, spotify_client, plus 10 callable
helpers (pause/resume enrichment, get_active_discovery_source,
get_metadata_fallback_client, get_discovery_cache_key, get_database,
validate_discovery_cache_artist, search_spotify_for_tidal_track,
build_discovery_wing_it_stub, add_activity_item).

Diff vs original after `deps.X` → global X normalization is **zero
differences** — 278 lines orig = 278 lines lifted, byte-identical body
(including all whitespace, comments, log strings).

Tests: 10 new under tests/discovery/test_discovery_spotify_public.py
covering cache hit short-circuit, dict-artist normalization, Spotify
tuple match (track/disc preservation), iTunes dict match path, Wing It
fallback, cancellation, completion phase update, activity feed entry,
top-level error handler, per-track error handling.

Full suite: 1118 passing (was 1108). Ruff clean.
2026-04-28 13:13:41 -07:00
Broque Thomas
2bc665e487 PR5d: lift _run_deezer_discovery_worker to core/discovery/deezer.py
Fourth lift in the PR5 discovery-workers series. Pulls the 270-line
Deezer discovery worker out of `web_server.py` into its own focused
module under `core/discovery/`. Pure 1:1 lift — wrapper keeps the
original entry-point name so the existing call sites continue to work
without changes.

What the Deezer discovery worker does:

1. Pause enrichment workers (release shared resources).
2. For each Deezer track:
   - Cancellation gate (state['cancelled']).
   - Discovery cache lookup; cache hit short-circuits the search and
     populates display fields from the cached match (artist string,
     album name).
   - SimpleNamespace duck-type → `_search_spotify_for_tidal_track`
     (shared search helper, returns tuple for Spotify or dict for iTunes).
   - On Spotify match: build `match_data` preserving track_number /
     disc_number from raw API data, image extracted from album images
     or track object fallback, release_date filled from track.release_date
     when album dict is missing it.
   - On iTunes match: dict result populated as `match_data`, source set
     to discovery_source, image extracted from album images.
   - Save matched result to discovery cache.
   - On miss: Wing It stub stored as 'wing-it' status.
3. After all tracks: phase='discovered', activity feed entry, sync
   discovery results back to mirrored playlist via
   `_sync_discovery_results_to_mirrored`.
4. On error: state['phase']='error' + status with error string.
5. Finally: resume enrichment workers.

Dependencies injected via `DeezerDiscoveryDeps` (13 fields) —
deezer_discovery_states dict, spotify_client, plus 11 callable helpers
(pause/resume enrichment, get_active_discovery_source,
get_metadata_fallback_client, get_discovery_cache_key, get_database,
validate_discovery_cache_artist, search_spotify_for_tidal_track,
build_discovery_wing_it_stub, add_activity_item,
sync_discovery_results_to_mirrored).

Diff vs original after `deps.X` → global X normalization is **zero
differences** — 270 lines orig = 270 lines lifted, byte-identical body
(including all whitespace, comments, log strings).

Tests: 10 new under tests/discovery/test_discovery_deezer.py covering
cache hit short-circuit, Spotify tuple match (track/disc number
preservation), iTunes dict match path, Wing It fallback, cancellation,
completion phase update, activity feed entry, mirrored sync invocation,
top-level error handler, per-track error handling.

Full suite: 1108 passing (was 1098). Ruff clean.
2026-04-28 12:45:18 -07:00
Broque Thomas
bda0500226 PR5c: lift _run_playlist_discovery_worker to core/discovery/playlist.py
Third lift in the PR5 discovery-workers series. Pulls the 323-line
mirrored-playlist discovery worker out of `web_server.py` into its own
focused module under `core/discovery/`. Pure 1:1 lift — wrapper keeps
the original entry-point name so the existing call site
(`_run_playlist_discovery_worker(pls, automation_id=None)` from the
automation engine) continues to work without changes.

What the playlist discovery worker does:

1. Pause enrichment workers (release shared resources).
2. Pre-compute total track count across all playlists for the automation
   progress card.
3. For each playlist:
   - Fast pre-scan separates already-discovered tracks (skipped, unless
     incomplete metadata or a Wing It stub) from undiscovered ones.
   - For each undiscovered track:
     - Cancellation gate via _playlist_discovery_cancelled set.
     - Discovery cache lookup (with artist validation).
     - matching_engine search-query generation, then Spotify (preferred)
       or iTunes (fallback) search + scoring.
     - Extended search fallback (limit=50) if no high-confidence match.
     - On match → enrich album from metadata cache (id, images,
       total_tracks, album_type, release_date, artists, plus track_number
       and disc_number), build matched_data, write to track.extra_data,
       save to discovery cache.
     - On miss → Wing It stub stored as 'wing_it_fallback' provider.
4. After all playlists: emit `discovery_completed` event when at least
   one new track was discovered, mark automation progress 'finished'.
5. On error → automation progress 'error', traceback printed.
6. Finally: resume enrichment workers.

Dependencies injected via `PlaylistDiscoveryDeps` (16 fields) —
spotify_client, matching_engine, automation_engine, the cancellation
set, plus 12 callable helpers (pause/resume enrichment,
get_active_discovery_source, get_metadata_fallback_client/source,
update_automation_progress, get_database, get_discovery_cache_key,
validate_discovery_cache_artist, discovery_score_candidates,
get_metadata_cache, build_discovery_wing_it_stub).

Diff vs original after `deps.X` → global X normalization is **zero
differences** — 323 lines orig = 323 lines lifted, byte-identical body
(including all whitespace, comments, log strings).

Tests: 15 new under tests/discovery/test_discovery_playlist.py covering
empty playlists, no-tracks playlist skip, complete-discovery skip,
incomplete-discovery re-run, Wing It always re-run, unmatched_by_user
respect, cache hit short-circuit, match above threshold (extra_data +
cache save), match below threshold falls to Wing It, iTunes fallback,
neither-provider error path, cancellation, discovery_completed event
emit, no-event on zero-discovered, multi-playlist grand_total
aggregation.

Full suite: 1098 passing (was 1083). Ruff clean.
2026-04-28 12:20:48 -07:00
Broque Thomas
3c1f614b6e fix: cast duration_ms to int before :02d format in discovery workers
yt_dlp sometimes returns float `duration_ms` for YouTube tracks. The
discovery workers format the duration with `f"{x // 60000}:{(x % 60000)
// 1000:02d}"` — and `:02d` requires an int. When the duration is a
float, the format string raises:

    Unknown format code 'd' for object of type 'float'

Caught when running YouTube discovery on a real playlist (bbno$ tracks)
— every track failed with status='Error'.

Pre-existing bug, surfaced now because of yt_dlp returning float
durations on this playlist. Fixed at all 8 sites by casting through
`int()` before the `// 60000` and `% 60000` operations:

- core/discovery/youtube.py: 2 sites in run_youtube_discovery_worker
  (cache hit + main result construction).
- web_server.py L29238/L29372: 2 sites in _run_listenbrainz_discovery_worker.
- web_server.py L40112/L40136/L40161/L40178: 4 sites in the YouTube
  retry/pre-discovered results assembly path.

The `if duration_ms` / `if dur` guard already protects against None and 0,
so `int(...)` is only called on truthy numeric values.

Tests: 1 new regression test under tests/discovery/test_discovery_youtube.py
(`test_float_duration_does_not_crash_format`) — passes a float
duration_ms and asserts the worker completes without an error result.
Ruff clean.
2026-04-28 12:07:54 -07:00
Broque Thomas
27fa96fe97 PR5b: lift _run_youtube_discovery_worker to core/discovery/youtube.py
Second lift in the PR5 discovery-workers series. Pulls the 332-line
YouTube discovery worker out of `web_server.py` into its own focused
module under `core/discovery/`. Pure 1:1 lift — wrappers keep the
original entry-point name so the two callers
(`youtube_discovery_executor.submit(_run_youtube_discovery_worker, ...)`)
continue to work without changes.

What the YouTube discovery worker does:

1. Pause enrichment workers (release shared resources).
2. For each YouTube playlist track:
   - Cancellation check (phase != 'discovering' aborts).
   - Discovery cache lookup; cache hit short-circuits the search.
   - Strategy 1: matching_engine search queries with confidence scoring
     against Spotify (preferred) or iTunes (fallback).
   - Strategy 2: swapped artist/title query.
   - Strategy 3: raw (untokenized) query.
   - Strategy 4: extended search with limit=50.
   - On match → save to discovery cache.
   - On miss → build Wing It stub from raw source data.
3. After loop: phase='discovered', sort results by index, and for mirrored
   playlists write extra_data back to the DB.
4. Activity feed entry with match summary.
5. On error → state['status']='error', phase='fresh'.
6. Finally: resume enrichment workers.

Dependencies injected via `YoutubeDiscoveryDeps` (16 fields) —
youtube_playlist_states, spotify_client, matching_engine, plus 13
callable helpers (pause/resume enrichment, get_active_discovery_source,
get_metadata_fallback_client, discovery cache key/validate, extract
artist name, spotify_rate_limited, discovery_score_candidates,
get_metadata_cache, build_discovery_wing_it_stub, get_database,
add_activity_item).

Diff vs original after `deps.X` → global X normalization is **zero
differences** — 332 lines orig = 332 lines lifted, byte-identical body
(including all whitespace).

Pre-existing bug preserved (not fixed): if `youtube_playlist_states[url_hash]`
raises KeyError on entry, the outer except handler tries to mutate
`state` which is unbound → secondary UnboundLocalError. Same bug in
the original. Documented here for future cleanup but out of scope
for the lift.

Tests: 14 new under tests/discovery/test_discovery_youtube.py covering
cache hit short-circuit, Strategy 1 confidence match, Wing It fallback,
iTunes fallback path (Spotify unauthenticated and rate-limited),
cancellation (phase changed), skip_discovery flag, completion phase
update, activity feed entry, mirrored playlist DB writeback, non-mirrored
no-writeback, enrichment workers pause/resume, error-during-loop resume,
results sorted by index after retry.

Full suite: 1082 passing (was 1068). Ruff clean.
2026-04-28 11:58:10 -07:00
Antti Kettunen
0125f478fc
Trim wishlist runtime plumbing
- remove the redundant wishlist-service injection from the runtime wrappers
- keep the package owning its own singleton service access
- simplify the route runtime API and update the wishlist tests to match
2026-04-28 21:52:21 +03:00
Antti Kettunen
f75c180cb6
Fix download cleanup after wishlist runs
- ignore unconfigured backends when clearing completed downloads
- keep the post-download cleanup route best-effort after a successful wishlist run
- add regression coverage for the orchestrator clear step
2026-04-28 21:22:21 +03:00
Broque Thomas
bdb7a3139d PR5a: lift _run_sync_task to core/discovery/sync.py
First lift in the new PR5 discovery-workers series. Pulls the 448-line
playlist sync background worker out of `web_server.py` into its own
focused module under `core/discovery/`. Pure 1:1 lift — wrappers keep
the original entry-point name so the four callers
(`sync_executor.submit(_run_sync_task, ...)`) continue to work without
changes.

What the sync worker does:

1. Convert frontend JSON tracks → SpotifyTrack/SpotifyPlaylist objects.
2. Normalize artist/album shapes for downstream wishlist parity.
3. Wire a progress_callback that updates `sync_states` + automation card.
4. Patch sync_service for database-only fallback when no media server is
   connected.
5. `run_async(sync_service.sync_playlist(...))` and capture the result.
6. Update sync_states to 'finished', push playlist poster image to
   Plex / Jellyfin / Emby, record sync history (with re-sync vs new-sync
   branching), emit `playlist_synced` event for automation engine, and
   persist sync status with a tracks_hash for smart-skip on the next
   scheduled sync.
7. On exception → mark error in sync_states + automation; finally clear
   progress callback + drop `_original_tracks_map` from sync_service.

Dependencies injected via `SyncDeps` (11 fields) — config_manager,
sync_service, plex_client, jellyfin_client, automation_engine, run_async,
record_sync_history_start, update_automation_progress,
update_and_save_sync_status, sync_states dict, sync_lock. The only
structural drift from a pure paste is the top-of-function variable
binding: original used `global sync_states, sync_service`, lifted version
rebinds them as locals from deps (`sync_states = deps.sync_states` etc.)
since the names aren't module-level in the new file. Same behaviour
otherwise — diff against the original after `deps.X` → global X
normalization is **zero differences**.

Tests: 18 new under tests/discovery/test_discovery_sync.py covering
sync history recording (new + resync), setup error path (with and
without automation_id), missing sync_service handling, sync_playlist
exception handling, successful sync state transition, unmatched-tracks
summary, playlist image upload (plex + jellyfin + zero-synced gate),
automation engine emit, automation progress finished call, sync history
DB persistence (completion + match_details), tracks_hash persistence,
and finally-block cleanup (callback clear + map drop).

Full suite: 1068 passing (was 1050). Ruff clean.

Kicks off the PR5 series — 9 discovery workers totaling ~2,400 lines
across `_run_sync_task`, `_run_*_discovery_worker` family,
`_run_quality_scanner`, and `_process_watchlist_scan_automatically`.
Wishlist-related extractions deliberately skipped to avoid overlap with
kettui's planned `core/wishlist/` package.
2026-04-28 11:20:47 -07:00
Antti Kettunen
f5226bd5b5
Give wishlist modules their own loggers
- add module-level loggers for the wishlist package instead of threading the web server logger through runtime objects
- default wishlist helper runtimes and cleanup helpers to their package logger while still allowing test overrides
- keep web_server.py as a thin caller that no longer injects its logger into wishlist flows
2026-04-28 21:17:46 +03:00
Antti Kettunen
d2af9f8bdf
Move wishlist routes into package
- extract the remaining wishlist endpoint behavior from web_server.py into core/wishlist/routes.py
- keep web_server.py as a thin Flask adapter around the new route helpers
- add tests that cover wishlist counts, stats, track listing, clear/remove flows, cycle updates, and album-track adds
2026-04-28 21:17:24 +03:00
Antti Kettunen
f32fc9d56e
Extract wishlist logic into dedicated package
- add core/wishlist as the home for wishlist payload, resolution, state, processing, reporting, and selection helpers
- move wishlist-specific tests into tests/wishlist alongside the new package layout
- keep web_server.py and the import/search callers as thin adapters for now
2026-04-28 21:17:24 +03:00
Broque Thomas
1fb2f34955 PR4h fixup: restore strict 1:1 in master.py
Three drifts caught in line-by-line review against original — all reverted
so the lifted body is now byte-identical to the web_server.py original
(after deps.X → global X normalization):

1. `import traceback` was hoisted to module-level imports. Original imports
   it inline inside the except block. Moved back to inline (same behavior,
   but strict location parity).
2. Trailing whitespace on the blank line after analysis_results.append.
3. Trailing whitespace on the blank line inside the wishlist except handler.

Diff against original now produces ZERO differences. Tests still 21/21
passing, ruff clean.
2026-04-28 10:39:52 -07:00
Broque Thomas
fa29ee2195 PR4h: lift _run_full_missing_tracks_process to core/downloads/master.py
Final extraction in the download orchestrator series. Lifts the 586-line
master worker that drives the entire missing-tracks pipeline from
`web_server.py` into `core/downloads/master.py`. Pure 1:1 lift — wrappers
keep the original entry-point name so the three callers
(`missing_download_executor.submit(_run_full_missing_tracks_process, ...)`)
continue to work without changes.

What the master worker does:
1. PHASE 1 ANALYSIS — per-track DB ownership check with album fast path
   (lookup album by name+artist, match tracks within it) plus a
   MusicBrainz release-cache preflight so per-track post-processing all
   uses the same release MBID (prevents Navidrome album splits).
2. Wishlist removal for tracks already in the library.
3. Explicit-content filter.
4. PHASE 2 transition — if nothing missing, mark batch complete, update
   per-source playlist phases, kick auto-wishlist completion handler.
5. Soulseek album pre-flight — search for a complete album folder before
   falling back to track-by-track search, cache the source for reuse.
6. Wishlist album grouping — derive per-album disc counts and resolve ONE
   artist context per album so collab albums don't fold-split.
7. Task creation with explicit album/artist context injection +
   playlist-folder-mode flag propagation.
8. Hand off to download_monitor + start_next_batch_of_downloads.
9. Error handler — phase=error, reset YouTube playlist phase to
   'discovered', reset auto-wishlist globals on auto-initiated batches.

Dependencies injected via `MasterDeps` (21 fields) — wide surface
covering config, MB caches/locks, soulseek client, source-page state
dicts, multiple callbacks (wishlist removal, explicit filter, executor
+ auto-completion fn, monitor, start_next_batch). The only behaviour
difference from a pure paste is `import traceback` hoisted to module
scope (was inline in the except block) — same behaviour. Trailing
whitespace on two blank lines also got normalized away by the editor;
neither has any runtime effect.

`reset_wishlist_auto_processing` callback wraps the
`global wishlist_auto_processing, wishlist_auto_processing_timestamp`
write + `wishlist_timer_lock` since `global` can't reach back into
web_server.py from a separate module.

Tests: 21 new under tests/downloads/test_downloads_master.py covering
analysis-phase state, force_download_all, found-track wishlist removal,
explicit filter, no-missing complete + per-source state updates, auto
wishlist completion submit, album fast path (direct + fallthrough),
MB preflight (caches both keys, no-mb-worker no-op), task creation
(queue + tasks dict, explicit context for albums, wishlist album
grouping consistency, playlist folder mode), monitor + next-batch
handoff, multi-disc total_discs computation, error handler (phase set,
youtube reset, auto wishlist reset), and batch-removed-mid-flight
defensive path.

Full suite: 1050 passing (was 1029). Ruff clean.

End of the PR4 series — `web_server.py` lost ~590 lines on this commit
alone; total trim across PR4a–PR4h is ~2900 lines of orchestrator code
moved into focused `core/downloads/*.py` modules.
2026-04-28 10:34:13 -07:00
Broque Thomas
0a6d1759b7 PR4g: lift batch lifecycle to core/downloads/lifecycle.py
Seventh sub-PR in the download orchestrator series. Strict 1:1 lift —
zero behavior change. ~570 lines moved out of web_server.py.

What moved (lifted as 3 tightly-coupled functions in one module):
- _start_next_batch_of_downloads → start_next_batch_of_downloads
- _on_download_completed → on_download_completed
- _check_batch_completion_v2 → check_batch_completion_v2

Dependencies bundled in `LifecycleDeps` (15+ refs):
- config_manager, automation_engine, download_monitor, repair_worker,
  mb_worker (live globals)
- is_shutting_down (lambda over IS_SHUTTING_DOWN flag)
- get_batch_lock (web_server helper for batch_locks dict)
- submit_download_track_worker (lambda wrapping
  missing_download_executor.submit + _download_track_worker)
- submit_failed_to_wishlist + submit_failed_to_wishlist_with_auto_completion
  (async, used by on_download_completed) AND process_failed_to_wishlist
  + process_failed_to_wishlist_with_auto_completion (sync, used by
  check_batch_completion_v2 — direct call matches original v2 behavior;
  the non-v2 path always submitted to executor)
- ensure_spotify_track_format, get_track_artist_name,
  check_and_remove_from_wishlist, regenerate_batch_m3u (web_server
  helpers — large, will lift in follow-up PRs)
- youtube_playlist_states, tidal_discovery_states,
  deezer_discovery_states, spotify_public_discovery_states
  (per-source playlist state dicts — phase transitions on batch
  completion)

Direct imports for already-lifted helpers:
- core.runtime_state.{download_tasks, download_batches, tasks_lock,
  add_activity_item}
- core.downloads.history.record_sync_history_completion (PR4a)
- core.album_consistency.run_album_consistency
- core.metadata.common.get_file_lock

Behavior parity verified line-by-line:
- start_next_batch: same batch lock acquisition, same shutdown gate,
  same V2-cancelled-task skip, same searching-status-set-before-submit,
  same submit-fails-no-ghost-worker semantics
- on_download_completed: same duplicate-call detection (skip decrement
  but still check completion), same failed-track tracking with
  spotify_track formatting + activity items + automation event emission,
  same wishlist removal on success, same active_count decrement, same
  stuck-detection (searching > 10min → not_found, post_processing >
  5min → completed), same M3U regeneration + repair worker hand-off
  + album consistency pass + wishlist failed-tracks submission
- check_batch_completion_v2: same finished-count tally, same stuck
  detection, same already-complete short-circuit returning True, same
  per-source playlist phase updates, same album consistency pass,
  same DIRECT (sync) wishlist processing call (NOT submit-to-executor
  — matches original v2 which called process_* functions directly)

CRITICAL drift caught + fixed during review:
- Initial lift had v2 routing wishlist calls through submit_* deps
  (async). Original v2 called process_* directly (sync). Added separate
  process_* deps to LifecycleDeps and routed v2 to them. Tests updated.

Two minor defensive additions documented:
- `is_auto_batch = False` initialized before conditional in v2 (Python
  scope rules made this unnecessary in original, but explicit is safer)
- Variable rename inside the queue-completion-check loop in
  on_download_completed: `task_id` → `queue_task_id` to avoid shadowing
  the outer parameter. Log output preserves the same task ID.

Tests: 28 new under tests/downloads/test_downloads_lifecycle.py
covering start-next (early-returns, shutdown gate, max_concurrent,
cancelled-task skip, searching-status-set, submit-failure-no-ghost,
orphan task), on-complete (decrement, duplicate skip, failed/cancelled
tracking, automation emit, wishlist removal, batch completion + emit
+ source phase update, stuck detection, auto vs manual routing),
check-v2 (missing batch, not-complete, complete-marking, already-
complete, auto routing, exception handling).

Full suite: 1029 passing (was 1001). Ruff clean.
2026-04-28 09:22:16 -07:00
Broque Thomas
f0955420c3 PR4f: lift _download_track_worker to core/downloads/task_worker.py
Sixth sub-PR in the download orchestrator series. Strict 1:1 lift —
zero behavior change. ~333 lines moved out of web_server.py.

What moved:
- _download_track_worker → download_track_worker

Dependencies bundled in `TaskWorkerDeps` (10 callbacks):
- soulseek_client (with .mode + .hybrid_order + subclient attrs for hybrid
  fallback: .soulseek/.youtube/.tidal/.qobuz/.hifi/.deezer_dl)
- matching_engine (.generate_download_queries)
- run_async
- try_source_reuse, store_batch_source, try_staging_match,
  get_valid_candidates, attempt_download_with_candidates,
  recover_worker_slot (web_server.py helpers — large, will lift in
  follow-up PRs)
- on_download_completed (deferred to PR4g batch lifecycle)

Direct imports for already-lifted: download_tasks, tasks_lock from
core.runtime_state. SpotifyTrack from core.spotify_client.

Behavior parity:
- Same control flow: missing-task short-circuit → cancellation
  checkpoint with V2/legacy split → SpotifyTrack reconstruction with
  artist/album normalization → source-reuse shortcut → staging-match
  shortcut → searching-state init → query generation (matching
  engine + legacy fallbacks: track+first-artist-word with The-prefix
  handling, track-only, paren/bracket-cleaned) → case-insensitive
  dedup → sequential query loop with cancellation checks before/
  during/after each search → hybrid fallback across remaining sources
  using first 2 queries → not_found marking with diagnostics → 2-tier
  exception recovery (failed marking + emergency worker_slot recovery)
- Same logger messages text-for-text (so log filters keep working)
- Same locking pattern (tasks_lock around every download_tasks read/
  write, with the 2.0s timeout fallback in the exception path)
- Same `cached_candidates` storage for retry fallback + raw-results
  storage for candidate review modal (top 20 per query without valid
  candidates)
- Same V2 detection via `playlist_id` field — V2 tasks don't trigger
  on_download_completed for cancellation (V2 atomic cancel handles
  the worker slot itself)

Tests: 19 new under tests/downloads/test_downloads_task_worker.py
covering early-return guards (missing/cancelled-V2/cancelled-legacy/
cancelled-no-batch), source reuse + staging shortcuts, search loop
happy path, no-results not_found, raw-results-stored-when-no-valid-
candidates, attempt-download-failure-falls-through, cancellation mid-
flight returns without completion, hybrid fallback (with + without
hybrid mode), critical exception with + without recovery callback,
query generation edge cases (The-prefix, paren cleanup, dedup).

Full suite: 1001 passing (was 982). Ruff clean.
2026-04-28 07:50:01 -07:00
Broque Thomas
2d271cfacf PR4e: lift status helpers + 3 routes to core/downloads/status.py
Fifth sub-PR in the download orchestrator series. Strict 1:1 lift —
zero behavior change.

What moved:
- _build_batch_status_data → build_batch_status_data
- get_batch_download_status route body → build_single_batch_status
- get_batched_download_statuses route body → build_batched_status
- get_all_downloads_unified route body → build_unified_downloads_response
- Status priority dict → module-level _STATUS_PRIORITY constant

Dependencies bundled in `StatusDeps` dataclass:
- config_manager, docker_resolve_path, find_completed_file,
  make_context_key, submit_post_processing (lambda wrapping
  missing_download_executor.submit + _run_post_processing_worker),
  get_cached_transfer_data

Direct imports from core.runtime_state for download_tasks /
download_batches / tasks_lock (already lifted by kettui).

Behavior parity:
- Same response payload shape across all 3 endpoints
- Same safety-valve mutation: stuck downloading task with file recovered
  → status='post_processing' + submit worker; stuck searching → not_found;
  stuck downloading no file → failed
- Same live transfer state mapping (Cancelled/Canceled, Failed/Errored/
  Rejected/TimedOut, Completed/Succeeded with byte-mismatch verification,
  InProgress, default queued)
- Same intermediate post_processing status promotion + single-shot worker
  submission (only when status != 'post_processing')
- Same 'Errored' handling: keeps current status to let monitor retry
- Same 17-key item dict in unified response with same field order
- Same artist/album/artwork normalization (handles string, dict, list,
  list-of-dicts, list-of-strings variants)
- Same sort: (priority asc, -timestamp desc)
- Same batch summary aggregation
- Same items[:limit] slicing
- Same logger messages text-for-text
- Same lock scope (single tasks_lock per call) — no new contention

Pre-existing bug preserved (will fix in follow-up PR):

- batched_status `debug_info` block iterates `response["batches"]` and
  guards with `if "error" not in batch_status`. Every successful
  payload includes `"error": batch.get('error')` (key always present,
  value usually None) so the guard is always False and debug_info
  never populates in production. Test documents the buggy behavior so
  the next PR can flip the check to `batch_status.get('error') is None`.

Tests: 32 new under tests/downloads/test_downloads_status.py covering
phase routing (analysis vs downloading vs unknown), task formatting +
sort + V2 fields, every live transfer state mapping (Cancelled,
Succeeded with full + partial bytes, InProgress, Errored, terminal-
not-overridden), safety valve (stuck searching → not_found, stuck
downloading recovered → post_processing, stuck downloading no file →
failed), all 3 route helpers (single, batched, unified), unified
artist/album/artwork normalization, batch summary aggregation, limit
slicing, plus debug_info bug documentation.

Full suite: 982 passing (was 950). Ruff clean.
2026-04-27 23:26:26 -07:00
Broque Thomas
a133448a6e PR4d: lift _run_post_processing_worker to core/downloads/post_processing.py
Fourth sub-PR in the download orchestrator series. Strict 1:1 lift —
zero behavior change. ~407 lines moved out of web_server.py.

What moved:
- _run_post_processing_worker → run_post_processing_worker

The lifted function is intentionally kept as one ~400-line block to
preserve byte-for-byte parity with the original. Refactoring it into
smaller helpers (context lookup, file search loop, transfer-folder
handler, downloads-folder handler) gets its own follow-up PR.

Dependencies: 9 callbacks bundled in `PostProcessDeps` dataclass.
- config_manager, soulseek_client, run_async (live refs)
- docker_resolve_path, extract_filename, make_context_key
  (small utilities still in web_server.py — will lift in a future PR
  alongside other shared utilities)
- find_completed_file (file search helper, still in web_server.py)
- enhance_file_metadata, wipe_source_tags (web_server wrappers around
  core.metadata.enrichment)
- post_process_with_verification (web_server wrapper around
  core.imports.pipeline)
- mark_task_completed (wraps runtime_state.mark_task_completed +
  session counter)
- on_download_completed (deferred to PR4g batch lifecycle)

Direct imports for already-lifted helpers (no injection needed):
- core.imports.album_naming.resolve_album_group
- core.imports.context.{get_import_clean_title, get_import_clean_album,
  get_import_original_search, get_import_context_artist,
  get_import_context_album, normalize_import_context}
- core.imports.filename.extract_track_number_from_filename
- core.metadata.enrichment (re-exported as metadata_enrichment)
- core.runtime_state.{download_tasks, tasks_lock,
  matched_downloads_context, matched_context_lock}

Behavior parity:
- Same control flow: missing-task short-circuit → cancelled/completed
  short-circuit → missing-filename failure → docker path resolution →
  context lookup with fuzzy fallback → expected filename generation →
  YouTube special-case path resolution → 5-attempt search loop with
  Strategy 1 (original filename in download+transfer) and Strategy 2
  (expected final filename in transfer) → file-not-found failure →
  transfer-folder handler with metadata enhancement → downloads-folder
  handler with full post-process verification
- Same retry count (5), sleep duration (5s), per-attempt logging
- Same album_info dict construction with is_album=True for explicit
  album downloads
- Same album grouping skip when context.is_album_download is True
- Same wipe_source_tags fallback when enhancement context missing
- Same matched_downloads_context cleanup on success
- Same exception swallowing at processing-error and critical-error
  layers, both setting status='failed' + error_message + calling
  on_download_completed(b, t, success=False)
- Every logger message text preserved verbatim (so log filters keep
  working)

Tests: 16 new under tests/downloads/test_downloads_post_processing.py
covering missing task, cancelled, already-completed, stream_processed,
missing filename + username, file-not-found-after-retries with sleep
mocked, stream-processor-completes-mid-search, transfer-folder with
metadata enhanced + with no context (wipes tags), downloads-folder
with + without context, processing exception, critical outer
exception, YouTube special path, fuzzy context matching.

Full suite: 950 passing (was 934). Ruff clean.
2026-04-27 22:57:55 -07:00
Broque Thomas
039f152f31 PR4c: lift _automatic_wishlist_cleanup_after_db_update to core/downloads/cleanup.py
Third sub-PR in the download orchestrator series. Strict 1:1 lift —
zero behavior change.

What moved:
- _automatic_wishlist_cleanup_after_db_update → cleanup_wishlist_after_db_update

The lifted fn takes config_manager as an arg (so core/downloads/cleanup.py
doesn't need to import web_server). Other deps (wishlist_service,
MusicDatabase, get_database) stay as in-function imports — matches the
original deferred-import pattern.

The single caller in web_server.py (missing_download_executor.submit at
L18028) keeps using the same wrapper name with no signature change.

Behavior parity:
- Same per-profile iteration via get_all_profiles()
- Same essential-field skip (no name / no artists / no spotify_track_id)
- Same artist normalization (string / dict / fallback to str())
- Same 0.7 confidence threshold for db match
- Same break-on-first-artist-match semantics
- Same album extraction (dict.name vs string passthrough)
- Same active_server pulled via config_manager.get_active_media_server()
- Same per-track exception swallowing inside the loops
- Same top-level exception swallow with traceback.print_exc()
- Same logger messages (exact text match for "[Auto Cleanup]" prefix)

Tests: 13 new under tests/downloads/test_downloads_cleanup.py covering
empty wishlist short-circuit, found-in-db removal, missed track stays,
low-confidence skip, missing-fields skip, dict + string artist formats,
break-on-first-match, multi-profile walk, album dict/string handling,
db check failure continuing to next artist, top-level exception swallow,
active server propagation.

Full suite: 934 passing (was 921). Ruff clean.
2026-04-27 22:23:41 -07:00
Broque Thomas
dc2835eecc PR4b: lift cancel + clear download routes to core/downloads/cancel.py
Second sub-PR in the download orchestrator series. Strict 1:1 lift —
zero behavior change.

What moved:
- cancel_download (single slskd cancel) → cancel_single_download
- cancel_all_downloads (cancel + clear + sweep) → cancel_all_active
- clear_finished_downloads (slskd clear + sweep) → clear_finished_active
- clear_completed_downloads (local task tracker prune) → clear_completed_local

Slskd-touching helpers take (soulseek_client, run_async, sweep_callback)
explicitly so the route layer wires the live client + the existing
_sweep_empty_download_directories helper. The local-state helper imports
download_tasks/download_batches/batch_locks/tasks_lock straight from
core.runtime_state since those are module-level shared globals.

Prep change: `batch_locks` dict moved from web_server.py global into
core/runtime_state.py alongside the other download globals. web_server.py
re-imports from runtime_state so the ~3 existing call sites in
web_server.py keep resolving without modification. Identity preserved
(same dict across all importers).

Out of scope (deferred to PR4g batch lifecycle):
- cancel_download_task (calls _on_download_completed)
- cancel_task_v2 + _atomic_cancel_task + _find_task_by_playlist_track
  (manipulate batch active_count directly, deeply coupled to lifecycle)

Behavior parity:
- Same response shapes + status codes on each route
- Same call order (cancel_all → clear_all_completed → sweep)
- Same conditional sweep on clear_finished (skipped on failure)
- Same sweep ALWAYS runs after cancel_all even if clear_all returns False
  (matches original — clear failure was non-fatal in cancel_all path)
- Same TERMINAL_STATUSES set: completed/failed/not_found/cancelled/skipped/
  already_owned (lifted to module-level constant)
- Same empty-batch pruning + same batch_locks cleanup
- Same lock acquisition pattern (single tasks_lock)

Tests: 14 new under tests/downloads/test_downloads_cancel.py covering
single cancel, cancel-all happy + failure paths, clear-finished + sweep
gate, local task pruning across all 7 active/terminal states, batch
queue trimming, batch_locks cleanup.

Full suite: 921 passing (was 907). Ruff clean.
2026-04-27 21:41:35 -07:00
Broque Thomas
3ce25310a3 PR4a: lift sync history recording to core/downloads/history.py
First sub-PR in the download orchestrator series. Strict 1:1 lift —
zero behavior change.

What moved:
- _record_sync_history_start → record_sync_history_start
- _record_sync_history_completion → record_sync_history_completion
- _detect_sync_source → detect_sync_source
- Source prefix map → module-level _SOURCE_PREFIX_MAP constant

What stayed:
- web_server.py keeps three thin wrappers (_detect_sync_source,
  _record_sync_history_start, _record_sync_history_completion) that
  delegate into core/downloads/history.py. ~60 callers of these names
  in web_server.py keep resolving without touching every site.

Each lifted function takes `database` as an arg (was
`db = MusicDatabase()` inline). The wrappers construct
`MusicDatabase()` per call to mirror the exact original behavior —
each invocation got a fresh DB connection.

Behavior parity:
- Same SQL UPDATE statement (preserves the in-place update path when
  a sync_history entry already exists for the playlist_id)
- Same JSON serialization with ensure_ascii=False
- Same thumb URL extraction order (album_context.images → image_url
  → first track album.images)
- Same per-track result shape (index, name, artist, album, image_url,
  duration_ms, source_track_id, status, confidence, matched_track,
  download_status)
- Same status mapping (found/not_found, completed/failed)
- Same best-effort exception swallowing (sync history failure must
  never break the actual download)
- Reads `download_tasks` from core.runtime_state (already lifted by
  kettui in PR378)

Tests: 34 new under tests/downloads/test_downloads_history.py
covering source detection (16 prefixes), start happy paths + thumb
extraction + duplicate-update + DB error swallowing, completion stats
+ per-track results JSON shape + edge cases.

Full suite: 907 passing (was 873). Ruff clean.
2026-04-27 20:37:16 -07:00
Broque Thomas
c121582557 MusicBrainz genres: fall back to release then artist when recording is empty
User report: SoulSync was only pulling MusicBrainz genres from the
recording (track-level) endpoint. Most MB recordings don't carry genres
at the track level — they live on the release (album) or artist. So
the MB tier was contributing nothing to the genre merge for the
overwhelming majority of tracks.

Fix:
- Added `'genres'` to the release-detail `includes` (was missing).
- After release-detail processing, if pp['mb_genres'] is still empty,
  populate from release_detail['genres'] (sorted by count desc).
- If still empty AND artist_mbid is set, fetch artist with
  `includes=['genres']` and use those.

No extra API call when the recording (or release) already had genres —
the artist fetch only fires when both upstream tiers came back empty.

The downstream genre merge in _embed_metadata_genres is unchanged; this
just makes the MB feed into it richer.

Tests: 4 new (recording present, recording empty → release, recording
+ release empty → artist, all empty → []). Full suite 873 passing.
Ruff clean.

Reported by @kcaoyef421 in Discord.
2026-04-27 19:31:27 -07:00
Broque Thomas
a8319156ce Lift /api/automations/blocks static config into core/automation/blocks.py
The endpoint was returning a 200-line literal dict inline. Moved the
three lists (TRIGGERS, ACTIONS, NOTIFICATIONS) to module-level constants
in core/automation/blocks.py. Route shrinks to 7 lines. Data is now
importable for tests + future docs.

Added 8 shape tests so a typo in the dict (missing 'type', wrong
field type, missing options on a select, etc.) gets caught by CI
instead of breaking the builder UI silently.

The `known_signals` field stays computed at request time via
_collect_known_signals(database) since it's dynamic.

No behavior change. Same response shape. 869 tests passing (was 861).
Ruff clean.
2026-04-27 18:31:36 -07:00
Broque Thomas
6cdcf778f3 Lift /api/automations/* into core/automation/
Routes moved to thin parse-args/jsonify handlers; logic now lives in
three focused modules under core/automation/. 436 lines deleted from
web_server.py; 53 added back as wrappers.

Module split:
- core/automation/api.py — CRUD + run + history helpers. Each function
  takes (database, automation_engine, ...) explicitly and returns
  (response_body, http_status). Includes signal cycle detection
  preflight checks for create + update.
- core/automation/progress.py — owns the in-memory progress state dict
  + lock (mirroring the original web_server.py globals as module-level
  shared state so all callers see one view), init/update/history
  helpers, and the WebSocket emit loop.
- core/automation/signals.py — collect_known_signals for the builder
  autocomplete.

Out of scope (deferred):
- _register_automation_handlers — the 23+ action handler closures stay
  in web_server.py because each one is tightly coupled to feature-
  specific implementations (wishlist, watchlist, library scan, etc.).
- Worker functions (_process_wishlist_automatically, etc.) — belong
  with their feature lifts.
- _run_sync_task / _run_playlist_discovery_worker — sync + discovery
  PRs.

Behavior preserved 1:1:
- Same route response shapes + status codes
- Same JSON field hydration (trigger_config, action_config,
  notify_config, last_result, then_actions)
- Same backward-compat: empty then_actions + notify_type set →
  synthesize then_actions from notify_type/notify_config
- Same signal cycle detection behavior on create + update
- Same system-automation protection on delete + duplicate
- Same reschedule/cancel logic on toggle + bulk-toggle + update
- Same progress state shape (status, progress, phase, current_item,
  log capped at 50, started_at/finished_at, action_type)
- Same emit-on-finish socketio push from update_progress
- Same emit loop semantics (1s tick, snapshot active states, reap
  finished after window)

Pre-existing bugs preserved (will fix in follow-up PRs):
- emit_progress_loop uses naive datetime.now() against tz-aware
  started_at/finished_at, so the timeout-zombie check raises
  TypeError → caught → never fires, and the cleanup-after-window
  check raises → caught → state is reaped on FIRST tick regardless
  of the window. Tests document this behavior so the next PR can
  flip them to the corrected expectation.

Tests: 72 new under tests/automation/ (signals 10, progress 24,
api 38). Full suite: 861 passing (was 789). Ruff clean.
2026-04-27 18:05:14 -07:00
Broque Thomas
b94cbd7dd7 Search lift: pre-merge parity polish for cin's review
Three drifts caught in line-by-line review against the pre-lift
web_server.py. All addressed for strict 1:1 behavior parity.

1. /api/enhanced-search/source/<src> now returns plain JSON
   `{"artists":[],"albums":[],"tracks":[],"available":false}` (or
   `{"videos":[],"available":false}` for youtube_videos) when the
   source's client isn't available, matching the original endpoint
   contract. Previously streamed an NDJSON `{"type":"done"}` line
   instead.

   Restructured by splitting the orchestrator into resolve+stream
   helpers:
   - `resolve_client(source_name, deps)` — already existed, used
     for /api/enhanced-search single-source mode
   - `resolve_youtube_videos_client(deps)` — new, returns the
     soulseek_client.youtube subclient or None
   - `stream_metadata_source(source_name, query, client)` — pure
     NDJSON generator, caller resolves client first
   - `stream_youtube_videos(query, youtube_client, run_async)` —
     same shape for the yt-dlp path

   The route now decides plain-JSON-vs-stream based on resolution
   result, mirroring the original control flow exactly.

2. core/search/library_check.py — reverted the defensive `(x or '')`
   and `getattr(plex_client, 'server', None) is not None` patterns
   to original byte-for-byte (`x.get('name', '')`,
   `plex_client.server`, no try/except around `get_plex_config`).
   Lift PR shouldn't change crash semantics; if the original raises
   on malformed input, mine should too. Pre-existing edge cases get
   their own follow-up PR.

3. core/search/stream.py — same revert: `soulseek_client.youtube`
   instead of `getattr(..., 'youtube', None)` etc.

Also removed the module-level `EMPTY_SOURCE` from sources.py and
moved its (per-call) duplicate into _fan_out_response as a local —
the original used a per-request local dict and the identity-check
behavior depends on that. Module-level was a footgun for future
mutations.

789 tests still pass (95 search), ruff clean.
2026-04-27 15:32:48 -07:00
Broque Thomas
47b4663091 Search cache: preserve falsy provider returns to match original behavior
Line-by-line review of the search lift caught one drift: cache.get_cache_key
was coercing falsy provider returns ('', None, 0) to 'unknown' / False.
Original web_server.py only fell back to those sentinels on exception, not
on falsy success values.

Real-world impact: low — get_active_media_server() and get_primary_source()
return non-empty strings in practice. But cache keys are tuples with no
schema enforcement, so any drift here can silently fragment the cache.
Restored 1:1 parity with original semantics.

Added test covering the falsy-success path so this can't drift again.

789 tests pass, ruff clean.
2026-04-27 15:19:52 -07:00
Broque Thomas
fd7b56e58c Lift /api/search and /api/enhanced-search/* into core/search/
Routes moved to thin parse-args/jsonify handlers; logic now lives in
six focused modules under core/search/. 720 lines deleted from
web_server.py; 109 added back as wrappers; ~700 lines of new core code
plus ~700 lines of tests.

Module split:
- core/search/cache.py — TTL+LRU cache for enhanced-search responses,
  keyed by (query, active_server, fallback_source, hydrabase_active,
  source_tag) so config changes don't poison stale entries.
- core/search/sources.py — per-kind metadata search (artists/albums/
  tracks) and the multi-kind ThreadPoolExecutor that fans them out.
- core/search/library_check.py — library + wishlist presence check
  with Plex thumb URL resolution; profile-aware wishlist with legacy
  fallback for older DBs missing the profile_id column.
- core/search/stream.py — single-track preview search; effective stream
  mode resolution, query-variant generation, retry walk, matching
  engine integration.
- core/search/basic.py — flat Soulseek file search, quality-sorted.
- core/search/orchestrator.py — main enhanced-search dispatch
  (short-query fast path, single-source bypass, hydrabase-primary fan
  out, alternate source list builder), NDJSON streaming generator
  for /source/<src>, and the SearchDeps dataclass that bundles the
  cross-cutting deps.

Routes pass clients (spotify, hydrabase, hydrabase_worker, soulseek)
and helpers (config_manager, fix_artist_image_url,
_is_hydrabase_active, _get_metadata_fallback_*, _run_background_
comparison, run_async, dev_mode_enabled_provider) into core/search via
a SearchDeps bundle built per-request. fix_artist_image_url stays in
web_server.py because it touches 31 other call sites.

Behavior preserved 1:1:
- Same response shapes (db_artists, spotify_artists, spotify_albums,
  spotify_tracks, primary_source, metadata_source, alternate_sources,
  source_available)
- Same NDJSON line ordering (artists/albums/tracks as they finish, plus
  done marker)
- Same per-kind exception swallowing
- Same hydrabase-worker mirror on dev mode
- Same cache key shape (5-tuple) and TTL/LRU semantics
- Same stream-track effective-mode resolution including the
  Soulseek-coerce-to-YouTube edge case
- Same library-check Plex thumb URL rewriting and wishlist fallback
  for older DBs

Tests: 94 new (cache TTL/LRU/key, sources happy/partial/all-fail,
library presence with library + wishlist + thumbs, stream effective
mode + query gen + retry, orchestrator client resolution + short
query + single source + fan-out alternates + hydrabase primary +
NDJSON drain). Full suite: 788 passing (was 694).

Ruff clean.
2026-04-27 15:07:11 -07:00
Broque Thomas
f51b75da7e Lift /api/stats/* and /api/listening-stats/* into core/stats/
Stats route logic moves into core/stats/queries.py as pure-ish functions
that take dependencies (database, image-url fixer, listening worker) as
arguments. The 13 route handlers in web_server.py shrink to thin
parse-args / jsonify wrappers.

What moved to core/stats/queries.py:
- stats_cached: 3-key metadata cache lookup + image url fix-up
- stats_overview / timeline / genres / library_health / db_storage
- stats_top_artists / top_albums / top_tracks: top-N + DB enrichment
- stats_recent: listening_history readback
- stats_resolve_track: title+artist -> file_path lookup for playback
- listening_stats_sync: spawns daemon thread that runs worker._poll
- listening_stats_status: stats payload, with None-worker fallback shape

No behavior change. Same response shapes, same error handling, same
silent-except on per-row enrichment failure. fix_artist_image_url
stays in web_server.py and is passed through as a callback so we
don't have to lift its config_manager / media-server dependencies in
this PR.

Adds tests/stats/test_stats_queries.py — 27 tests covering happy
paths, edge cases, image-url plumbing, worker glue.

Ruff clean. 694 tests pass (was 667 + 27 new).
2026-04-27 14:27:03 -07:00
Broque Thomas
313b5677a5 Drop stale post-PR378 redefs and fix B009
Lifted-then-not-deleted leftovers from the PR378 merge:
- web_server.py `_resolve_album_group` and `_build_final_path_for_track`
  were already imported at module top from `core/imports/`. Removed the
  shadowing local copies.
- Mutagen reimports (FLAC/MP4/OggVorbis) at L17736-17738 shadowed the
  top-of-file imports. Picture/MP4Cover/MP4FreeForm were unused. Dropped
  the whole block.
- core/imports/context.py: `getattr(artist, "name")` -> `artist.name`
  (B009).

Ruff clean, 667 tests pass.
2026-04-27 13:39:16 -07:00
Antti Kettunen
02305096a3
Tighten metadata and import safety
- Normalize album import track display handling so queue labels and match rows stay consistent
- Bound MusicBrainz caches and avoid caching transient lookup failures
- Stop swallowing programmer errors in source enrichment helpers
- Restore import config test seams without reintroducing lazy imports
- Guard task completion calls and fix the Windows path test expectation
- Keep file lock tracking from growing without bound
2026-04-27 20:28:05 +03:00
Antti Kettunen
9315e74bea
Broaden import and metadata test coverage
- Cover search_result fallback normalization and ambiguous album detection.
- Add staging metadata, multi-disc path, and MusicBrainz enrichment cases.
- Move the single-track context test next to the imports code it exercises.
2026-04-27 19:55:07 +03:00
Antti Kettunen
4f236baa6d
Fix import normalization and task completion locking
- Promote legacy _source into source during import normalization.
- Keep the normalized import context neutral after stripping aliases.
- Avoid re-entering tasks_lock when marking completed download tasks.
2026-04-27 19:55:07 +03:00
Antti Kettunen
4c819681a1
Move single-track resolver; fix wishlist cleanup
- keep single-track import lookup in imports/resolution.py
- normalize simple-download search_result data before wishlist matching
- run wishlist cleanup for simple-download post-processing
- keep source-only artist detail on resolved names and MB short-circuit
2026-04-27 19:55:06 +03:00
Antti Kettunen
d04573f397
Fix single import source handling
- pass the selected manual match through singles import
- keep the import context source-aware so artist and album stay correct
- avoid treating non-Spotify IDs as wishlist Spotify IDs
- make wishlist logging and local variable names source-neutral
2026-04-27 19:54:45 +03:00
Antti Kettunen
9b2b6d856f
Split runtime builders into owning modules
- Move the import pipeline runtime factory into core.imports.pipeline
- Move the metadata runtime factory into core.metadata.enrichment
- Keep the web server wiring thin and drop the shared glue module
- Add contract tests that keep the two runtime bundles separate
2026-04-27 19:54:45 +03:00
Antti Kettunen
9e496397da
Move shared metadata helpers into package
- Relocate the shared metadata helper module from core/metadata_common.py into core/metadata/common.py.
- Update the new metadata package, the import pipeline, and the web entrypoint to use the package-scoped helper.
- Keep the shared config, mutagen, file-lock, and tag-writing helpers centralized without touching unrelated files.
2026-04-27 19:54:44 +03:00
Antti Kettunen
9656dbd46a
Thread runtime through metadata enrichment
- Pass the live runtime bundle into the shared metadata facade so worker-backed source enrichment can actually run.
- Forward runtime from the import pipeline and web-server wrapper into embed_source_ids.
- Add a regression test that verifies the runtime object reaches the source-ID embedding path.
2026-04-27 19:54:44 +03:00
Antti Kettunen
8319c6679f
Move new metadata helpers into a package
- Keep existing metadata_cache and metadata_service at the top level for now
- Move the new branch-local metadata helpers under core/metadata
- Share MusicBrainz release cache state from core.metadata.source and update import sites
2026-04-27 19:54:44 +03:00
Antti Kettunen
bdef127dd6
Lift shared runtime state into core
- Move app-wide task and activity registries out of core/imports
- Share one runtime-state module across the web server, API, and import pipeline
- Keep import-specific helpers focused on context and post-processing
2026-04-27 19:54:44 +03:00
Antti Kettunen
e10df4caf2
Rehome import helpers into core/imports
- Move import flow modules into a dedicated package
- Update app and test imports to the new namespace
- Group the import-focused tests under tests/imports
2026-04-27 19:54:44 +03:00
Antti Kettunen
b9269b4f16
Tighten metadata helper boundaries
- remove stale wrapper helpers from web_server and metadata_common
- import provider helpers directly in metadata_source
- keep the metadata modules' public surface explicit
2026-04-27 19:54:43 +03:00
Antti Kettunen
edd9048f86
Checkpoint metadata runtime cleanup
- remove runtime from metadata helper APIs where it only carried config, logger, mutagen, and database access
- keep runtime only for the source-ID enrichment path that still needs live worker handles
- add the new metadata helper modules and update the tests to match the slimmer interfaces
2026-04-27 19:54:43 +03:00
Antti Kettunen
6872e5080d
Refine import module boundaries
- Move filename and staging helpers into their canonical modules
- Extract album naming and grouping from path handling
- Update import and test call sites to the new layout
2026-04-27 19:54:43 +03:00
Antti Kettunen
0bbf44809f
Move the import flows and related post-processing pipelines into separate modules
- Extract the import pipeline, album import, staging, path, file ops, guards, runtime state, side effects, and metadata enrichment out of .
- Canonicalize the refactored import path around  and remove legacy , , , and  request shapes from the import endpoints.
- Make album and track metadata lookups follow the configured provider priority instead of hard-coding Spotify, while still falling back when needed.
- Update the import routes and frontend payloads to use the new core helpers.
- Add coverage for the extracted helpers and the refactored import flows.

PS. apologies to anyone who might check this commit out - the intention was to start small, but things kinda snowballed out of control at some point since the logic just kept going on and on, and everything kinda had to be changed all at once for it all to make any sense
2026-04-27 19:54:43 +03:00
Broque Thomas
dd4cf130d7 Socket.IO CORS: handle self-review nits
Six items from a Cin-style line-by-line pass on PR #383:

- resolve_cors_origins: list of non-string entries (`[None, 123]`) now
  drops them instead of coercing to junk strings like `'None'`/`'123'`.
- will_reject: backwards-compat shim removed. Production callers always
  pass `request.scheme` (Flask-guaranteed); the shim only existed for
  tests/non-Flask callers and made the production code path branchier
  than necessary. Tests now pass scheme explicitly.
- maybe_log: redundant `if not origin` early-return dropped. will_reject
  handles missing origin (engineio's own behavior — server.py:207).
- RejectionLogger.__init__: `int(dedup_cap)` wrapped in try/except so
  bad-type input falls back to DEFAULT_DEDUP_CAP instead of raising.
- web_server.py: docstring on the before_request hook explains why the
  hook fires on every request (Flask doesn't scope before_request to a
  path prefix; the early-return string compare is the cheapest option).
- settings.js: cors-origins URL regex tightened from `[^\s/]+` to
  `[^\s/?#]+` so query/fragment chars don't pass validation. Engineio
  would silently fail to match those anyway; better to flag at save.

Test changes:
- parametrize gained an explicit `scheme` column (12 cases updated).
- New explicit case: scheme-mismatch rejects (engineio compares full
  `{scheme}://{host}` strings).
- `test_will_reject_falls_back_to_host_only_when_no_scheme_info`
  deleted — the shim it tested is gone.
- `test_will_reject_honors_x_forwarded_host` now passes scheme info.

Net: -9 production lines, -3 test lines. Production code path is
straight-line. 603 tests pass.
2026-04-26 19:24:43 -07:00
Broque Thomas
0f24739e27 Socket.IO CORS: polish — match engineio exactly, bound dedup, validate URLs
Self-review pass on the security fix uncovered five issues, all fixed
here:

1. will_reject scheme handling. Engineio compares full {scheme}://{host}
   strings, not just hostnames. A TLS-terminating proxy can leave the
   backend seeing http while the browser's Origin is https — engineio
   rejects, but the original predictor said "allow" → no helpful log
   line. Added request_scheme + forwarded_proto params, build full
   candidate strings to match engineio.

2. EITHER-forwarded-header rule. Engineio adds the forwarded candidate
   when EITHER X-Forwarded-Proto OR X-Forwarded-Host is present (it
   falls back to HTTP_HOST for the missing one). The original predictor
   only added it when forwarded_host was set — false negative for
   misconfigs sending only X-Forwarded-Proto. Now mirrors engineio.

3. will_reject incorrectly rejected missing-Origin requests. Engineio
   (server.py:207: `if origin: validate`) skips CORS validation when
   no Origin header is sent — non-browser clients (curl etc.) are
   intentionally permitted. The original code rejected them. Test was
   asserting the wrong behavior. Both fixed.

4. RejectionLogger had unbounded dedup set growth. A hostile actor
   opening connections from many distinct fake origins would fill
   memory unboundedly. Capped at 100 unique origins (configurable);
   when cap hit, one overflow notice is emitted and further rejections
   are silently dropped until restart.

5. Lock pattern: the overflow log path called logger.warning() while
   holding the dedup lock, inconsistent with the normal path. Fixed
   to pick the message under the lock and log after release. Critical
   section is now minimal and uniform.

Plus polish:
- Stale module docstring fixed (said "empty list" instead of "None").
- settings.js validates each cors_origins line against a URL regex on
  save; toasts a one-shot warning if entries are malformed (resolver
  silently filters them, but user gets feedback now).
- web_server.py wiring passes request.scheme + X-Forwarded-Proto so
  the predictor has full proxy info.

Tests:
- 51 unit tests in tests/test_socketio_cors.py (was 45). New cases:
  * scheme comparison (5 cases including TLS-terminating proxies)
  * forwarded_proto-alone misconfig
  * missing-origin matches engineio (was asserting wrong behavior)
  * dedup cap with overflow + reset
  * default cap is reasonable (uses public DEFAULT_DEDUP_CAP constant)

Engineio behavior independently verified by reading engineio/server.py
and engineio/base_server.py source. Predictor mirrors both files.

604 tests pass.
2026-04-26 17:32:22 -07:00
Broque Thomas
013eebf350 Lock down Socket.IO CORS — same-origin default + opt-in allow-list
Closes #366 (reported by JohnBaumb).

Socket.IO was initialized with `cors_allowed_origins='*'`, accepting
WebSocket connections from any origin. A malicious site could open a
WS to a user's local SoulSync instance and exfiltrate live progress /
toast / activity events.

This commit:

- Defaults to engineio's same-origin behavior (`cors_allowed_origins=None`),
  which automatically honors X-Forwarded-Host so reverse proxies that
  send that header (Caddy / Traefik by default, properly-configured
  Nginx) work transparently.
- Adds a `security.cors_origins` config setting + Settings → Security
  textarea where users behind unusual proxies / Electron wrappers /
  cross-origin integrations can whitelist their origin. Accepts comma
  or newline separated values; `*` on its own line opts back into the
  legacy wildcard with a startup-warning log.
- Logs a clear warning the first time engineio rejects each unique
  origin, naming the rejected Origin and request Host and pointing
  users to the settings field. Without this, engineio silently 403s
  the upgrade and the user just sees a half-broken UI with no clue
  why. Threadsafe dedup so a hostile origin can't spam logs.

Logic lives in `core/socketio_cors.py` (resolver, rejection
predictor, dedup logger class, startup-status emitter) — pure
functions, no Flask dependency. `web_server.py` adds 23 lines of
wiring and imports.

Important catch during review: my first pass used `cors_allowed_origins=[]`
as the "secure default." Reading engineio's source revealed `[]` actually
means "DISABLE CORS HANDLING" (engineio/server.py:202: `if cors_allowed_origins != []:`)
— identical security to `'*'`. Fixed to use `None` (engineio's actual
same-origin sentinel) and pinned with a regression test that asserts
the resolver never returns `[]` for any input shape.

Tests:
- tests/test_socketio_cors.py — 45 unit tests covering 19 resolver shape
  cases (None, empty, whitespace, comma, newline, garbage types, lists),
  the `[]`-must-never-be-returned security regression, 12 rejection
  prediction cases, X-Forwarded-Host handling, dedup logger behavior,
  threadsafe race (8 threads × 50 hammers → exactly 1 warning), and
  startup-status emitter outputs.

Frontend:
- Settings → Security gains an "Allowed WebSocket Origins" textarea
  with help text explaining same-origin default + when to add a domain
  + the `*` opt-out.
- helper.js — new '2.4.1' WHATS_NEW block (hidden until version bump)
  with a chill-voice entry describing the change.

Conftest.py left at `'*'` — test environment, no security concern.

598 tests pass.
2026-04-26 16:27:10 -07:00
Broque Thomas
37aefd2ff1 Reorganize queue: race + dedupe fixes from kettui review
Five issues kettui flagged on PR #377:

- Worker race (reorganize_queue.py): _next_queued() picked an item and
  released the lock, then re-acquired to flip status='running'. A
  cancel() landing in that window marked the item cancelled but the
  worker still ran it. Replaced with _claim_next_or_wait() that picks
  AND flips under one lock acquisition.

- Wakeup race (reorganize_queue.py): _wakeup.clear() after the empty
  check could lose an enqueue's _wakeup.set(), parking a freshly-queued
  album for up to 60 seconds. Replaced Lock + Event with a single
  threading.Condition; cond.wait() releases and re-acquires atomically
  on notify.

- Bulk dedupe (reorganize_queue.py:enqueue_many): looped single-item
  enqueue, so a duplicate album_id later in the same batch could slip
  through if the worker finished the first copy before the loop
  reached the second. Now holds the lock for the whole batch and tracks
  a per-batch seen set, so intra-batch duplicates dedupe against each
  other and not just pre-existing items.

- Preview button stuck disabled (library.js:loadReorganizePreview):
  early returns and thrown errors skipped the re-enable line. Moved
  state into a canApply flag committed in finally, so any exit path
  lands the button correctly.

- DB helpers swallowing failures (music_database.py): get_album_display_meta
  and get_artist_albums_for_reorganize used to catch every Exception
  and return None / [], so a real DB outage masqueraded as "album not
  found" / "no albums". Now lets exceptions bubble; the route layer
  already wraps them as 500.

Tests:
- test_cancel_and_run_are_mutually_exclusive — hammers enqueue+cancel
  pairs and asserts the invariant that no successfully-cancelled item
  ever ran (catches regressions to the atomic pick).
- test_enqueue_many_dedupes_batch_internal_duplicates — pins the
  intra-batch dedupe.
- test_get_album_display_meta_propagates_db_errors and
  test_get_artist_albums_for_reorganize_propagates_db_errors — pin
  the bubble-up behavior.

Changelog updated in helper.js and version modal.
2026-04-26 08:40:24 -07:00
Broque Thomas
d6094a3587 Library reorganize: FIFO queue with live status panel
Replaces the single-slot "one reorganize at a time, return 409 on collision"
model with a per-user FIFO queue. Buttons stay clickable, "Reorganize All"
is one backend call instead of an N-call JS loop, and a status panel mounted
at the top of the artist actions bar shows live progress (active item,
queued count, recent completions) with per-item cancel buttons.

Backend
- core/reorganize_queue.py: singleton queue + worker thread, dedupe-on-
  enqueue, cancel rules (queued cancellable, running not), enqueue_many
  for bulk operations, progress fan-out via update_active_progress
- core/reorganize_runner.py: factory builds the worker's runner closure
  with injected dependencies. Reads config per-call so changing the
  download path in Settings takes effect on the next reorganize without
  a server restart
- database/music_database.py: get_album_display_meta and
  get_artist_albums_for_reorganize — moves the SQL out of route handlers
- web_server.py: thin enqueue/snapshot/cancel/clear endpoints, runner
  registration at module load. Old _reorganize_state globals + status
  endpoint deleted. Static-asset cache buster (?v=<server-start>)
  added so JS/CSS updates ship live without users clearing cache

Frontend
- webui/static/library.js: status panel mount, polling (1.5s when
  active, 8s when idle), expand/collapse, per-item cancel, debounced
  enhanced-view reload (one reload per artist batch instead of N).
  Per-album reorganize button paints with queued/running indicator
  and short-circuits to a toast when the album is already in queue
- webui/static/style.css: panel + button styling matching the existing
  glass-UI accents
- webui/static/helper.js + version modal: WHATS_NEW entry

Tests (22 new)
- tests/test_reorganize_queue.py (19 tests): FIFO order, dedupe,
  per-item source, cancel rules, continue-on-failure, snapshot
  shape, progress propagation, bulk enqueue
- tests/test_reorganize_runner.py (4 tests): per-call config reads,
  setup-failure summary, dependency injection, progress fan-out
- tests/test_reorganize_db_methods.py (7 tests): SQL JOIN behavior,
  ordering, fallback for blank strings, artist isolation

Full suite 549 passed in 27s.
2026-04-25 18:01:32 -07:00
Broque Thomas
98c85f928e Merge remote-tracking branch 'origin/dev' into fix/reorganize-via-post-process-pipeline
# Conflicts:
#	webui/static/helper.js
2026-04-25 10:09:28 -07:00
Broque Thomas
7e1c4c26ec Reorganize: fix moved-count + status/total UX issues from PR #377 review
Four changes addressing kettui's PR #377 review comments:

1. **`_finalize_track` no longer over-counts on DB failure (🔴 bug).**
   The function previously bailed on DB-update failure but
   `_process_one_track` still incremented `summary['moved']`
   unconditionally — overstating how many tracks the UI knows are
   at their new locations. Fixed by:
   - `_finalize_track` now returns ``bool`` (True only when DB row
     was updated AND original was dealt with)
   - Caller checks the return; on False, records as a failed track
     with a clear message ("Track landed at new location but DB
     update failed — file is at both old and new paths until library
     scan re-indexes")
   - Existing `test_db_update_failure_leaves_original_in_place` now
     also asserts `moved == 0`, `failed == 1`, and that the error
     message names the cause

2. **`executeReorganize` toast no longer says "undefined tracks" (🐛
   bug).** `/reorganize` doesn't return `result.total` anymore (the
   track count is determined server-side after planning), so the
   "Reorganizing undefined tracks..." string was meaningless. Now uses
   `result.message` from the backend instead.

3. **`_pollReorganizeStatus` distinguishes completed from skipped
   (🟡 risk).** Backend now propagates the orchestrator's status
   (`completed` / `no_source_id` / `no_album` / `no_tracks` /
   `setup_failed` / `error`) into `_reorganize_state['result_status']`
   so the frontend can warn appropriately. Two new helpers:
   - `_classifyReorganizeOutcome(state)` — returns 'success' only
     when `result_status === 'completed'` AND `failed === 0`;
     'warning' otherwise
   - `_formatReorganizeResultMessage(state)` — returns a message
     specific to the outcome ("Reorganize skipped — album has no
     metadata source ID. Run enrichment first." for `no_source_id`,
     etc.)
   Zero-failure non-completed runs now show as warnings instead of
   green checkmarks.

4. **Bulk mode no longer counts skipped albums as succeeded (🟡
   risk).** `_executeReorganizeAll`'s loop was treating any HTTP
   200 response as success, ignoring the orchestrator's actual
   outcome for that album. Fixed by:
   - `_waitForReorganizeComplete()` now resolves with the final
     state object (was: void)
   - Loop checks `finalState.result_status === 'completed'` AND
     `finalState.failed === 0` before counting `succeeded++`;
     otherwise increments `skipped` (with a per-album warning
     toast) or `failed` accordingly
   - Final summary toast now reads
     "Reorganized N of M albums, K skipped, J failed" and only
     shows green when nothing was skipped or failed

All four addressed in a single commit because they form one
coherent UX-correctness fix — the bug bug (#1) and the count-
overstatement bug (#4) both made the user see "everything succeeded"
when reality was different. Together they make the UI honestly
reflect what actually happened.

Files:
- core/library_reorganize.py — `_finalize_track` returns bool,
  `_process_one_track` reads it
- web_server.py — `_reorganize_state['result_status']` populated
  from orchestrator's summary on success and on exception
- webui/static/library.js — `_classifyReorganizeOutcome` /
  `_formatReorganizeResultMessage` helpers, single-album +
  bulk-mode flows both consume them
- tests/test_library_reorganize_orchestrator.py — strengthened
  the existing DB-failure test to assert moved/failed counts

Credit: kettui — four PR #377 review comments named all of these
precisely with line numbers and severity.
2026-04-25 09:07:44 -07:00
Broque Thomas
6c90d68de3 Discogs: count rows with empty type_ as real tracks too
Reported by kettui on PR #374 review: the inline filter that backed
`set_album_api_track_count` only counted rows where `type_ == 'track'`,
but `discogs_client.get_album_tracks` itself accepts both `'track'`
AND empty `type_` as real songs (line 660: `type_ in ('track', '')`).
Releases where Discogs returns some real tracks with an empty `type_`
field would be undercounted, which would silently disagree with the
repair job's fallback `_get_expected_total` path (which calls into
`get_album_tracks_for_source` and therefore uses the client's count).

Extracted the filter into `count_discogs_real_tracks(tracklist)` —
single source of truth for the rule, testable in isolation, and the
worker call site is now a one-liner that names what it's doing. Also
defensive about the input shape: `type_ == None`, missing field, and
empty/None tracklist all handled cleanly.

10 tests pin the behavior:
- empty/missing/None type_ all count as a real track (the kettui case)
- 'heading', 'index', 'sub_track' excluded
- unknown future type strings excluded conservatively
- realistic multi-disc tracklist with mixed shapes counts correctly
- empty/None input returns 0 without raising

Credit: kettui — the PR #374 review comment that flagged this.
2026-04-25 08:27:44 -07:00
Broque Thomas
cb67773998 Merge remote-tracking branch 'origin/dev' into fix/album-completeness-api-track-count
# Conflicts:
#	webui/static/helper.js
2026-04-25 08:24:45 -07:00
Broque Thomas
2b15260b88 Reorganize: route library files through the post-processing pipeline
Reported on Discord by winecountrygames. The library "Reorganize" tool
had several layered bugs that all traced to the same root cause: the
endpoint reinvented every wheel post-processing already turns — its own
template engine, its own disc-number resolution from file tags, its own
sidecar sweep, its own collision detection — and each had drifted from
the canonical path used by fresh downloads. Reported symptoms:

  - 3-disc Aerosmith deluxe collapsed to a flat single-disc layout
  - Half the tracks on other albums silently skipped, no error / no count
  - Re-runs left empty leftover album folders cluttering the artist dir

Architecture: stop reinventing wheels. Route reorganize through exactly
the same pipeline downloads use. Per-album:

  1. Fetch the canonical tracklist from a metadata source (Spotify /
     iTunes / Deezer / Discogs / Hydrabase) using the album's stored
     source IDs. New `core/library_reorganize.py::plan_album_reorganize`
     does this — primary-source-first, fall through priority chain
     unless the user picked a specific source in the modal (strict mode).
  2. For each local track, find the matching API entry via a scored
     candidate matcher. Score components: exact-title (100),
     substring-with-length-ratio (40-90), track-number agreement (20).
     Hard reject when the two titles have different version
     differentiators (Remix vs no-remix means different recordings,
     not annotation drift). Below threshold = unmatched, surfaced as
     "not in source's tracklist, left in place" rather than silently
     mis-routing.
  3. Copy the file to a per-album staging directory, build the same
     context dict the import flow builds (`spotify_album` /
     `track_info` / etc. with `is_album_download=True` so the path
     builder enters ALBUM mode, not SINGLE mode), call
     `_post_process_matched_download(...)` — same function fresh
     downloads use. Post-process handles tagging, multi-disc subfolder
     decisions, sidecar regeneration, AcoustID verification.
  4. Read `context['_final_processed_path']` to learn where it landed.
     Update `tracks.file_path` in the DB BEFORE removing the original
     (DB-update failure leaves the file at both locations, recoverable
     via library scan; the reverse would orphan the row). Delete
     per-track sidecars (post-process recreates them at the new
     destination).

3 concurrent workers per album via ThreadPoolExecutor, matching the
download path's per-batch worker count. State mutations all guarded by
a single lock; staging filenames carry a UUID prefix so concurrent
copies of identically-named source files don't overwrite each other.

Source picker in the modal lets the user choose which source to read
the tracklist from. Two endpoints feed it:
  - `/api/library/album/<id>/reorganize/sources` — sources for THIS
    album that are both authed AND have a stored ID. For the per-
    album modal.
  - `/api/library/reorganize/sources` — all authed sources globally.
    For the bulk "Reorganize All" modal where per-album ID coverage
    varies.
When the user picks a specific source, the orchestrator runs in
`strict_source=True` mode (no fallback chain) — picking Spotify means
"use Spotify or fail", not "use Spotify and silently fall back."

Preview endpoint shares the same planning logic as apply via
`preview_album_reorganize` — the destination path comes from the same
`_build_final_path_for_track` post-process uses, so what you see in
the preview is exactly what you get on apply.

Empty destination folders (from earlier failed runs OR from the
current run when post-process creates a dir then fails AcoustID)
get cleaned up after each successful run: walk up to the artist
folder from any successful destination, prune empty album-sibling
folders one level deep. Bounded scope = won't touch unrelated user
dirs.

Web_server.py shrinks by ~450 net lines. The endpoint handler is now
a thin wrapper that builds injected callables (path resolver, post-
process function, DB updater, empty-dir cleaner), spawns a thread
that calls `reorganize_album()`, and returns. All actual logic lives
in `core/library_reorganize.py` where it's unit-testable without
spinning up Flask.

Frontend cleanup: the per-call template input in both reorganize
modals (per-album and bulk) was redundant — the backend always uses
the configured global download template. Removed the input and the
variables-grid reference UI it was for.

39 new unit tests pin every contract:
  - source resolution (no_source_id when album has none, fallthrough
    chain when primary returns nothing, strict mode bypasses fallback)
  - matcher scoring (exact / substring / multi-disc disambiguation /
    smart-quote tolerance / dash-vs-parens / bonus-track substring /
    Remix-vs-original differentiator rejection / "Real" doesn't false-
    match "Real Real Real" / track-number-only no longer fires)
  - file safety (DB-update failure leaves original in place, post-
    process failure leaves original in place, post-process exception
    caught and original preserved, success removes original AND
    updates DB in the right order)
  - sidecar handling (per-track .lrc/.nfo deleted on success, kept on
    failure; album-level cover.jpg/folder.jpg cleaned only when
    directory has no remaining audio)
  - staging cleanup (recreated between tracks because post-process
    nukes it, dir cleaned up on success AND on failure)
  - destination-dir prune (empty siblings removed, real album with
    files preserved, no recursive sweep)
  - source picker (only authed-with-stored-ID sources for per-album,
    all authed sources for bulk; strict mode doesn't fall back)
  - concurrency (3 workers in flight, state stays consistent under
    races, stop_check cuts off pending tasks)
  - preview parity (preview produces same destination as apply for
    multi-disc; ALBUM mode not SINGLE mode; unmatched/no-path tracks
    surfaced with reasons)

Limitations (deliberate punts, NOT in this PR):
  - Renamed local titles on multi-disc albums where track_number
    also disagrees: matcher returns nothing (track is "not in
    source"). Fixable by using duration_ms as a tertiary signal.
  - Per-track in-modal source switching with per-album track-count
    hints (would need a second API call before opening the modal).
  - UI status panel on the artist page during a run — currently
    just toasts. Documented as a follow-up PR.

Files:
  - core/library_reorganize.py — new module: plan_album_reorganize,
    preview_album_reorganize, reorganize_album, available_sources_for_album,
    authed_sources, _score_candidate, helpers for staging/post-
    processing/finalizing, sidecar + dest-dir cleanup
  - core/metadata_service.py — no changes; reused get_album_for_source,
    get_album_tracks_for_source, get_source_priority,
    get_client_for_source
  - web_server.py — three endpoints (preview / apply / sources GETs)
    are thin wrappers; -450 net lines
  - tests/test_library_reorganize_orchestrator.py — 39 tests covering
    every contract above
  - webui/static/library.js — source picker UI in both modals; dead
    template input + variables-grid removed
  - webui/static/style.css — dropdown option styling fix (white-on-
    white was unreadable)

Reported on Discord by winecountrygames — his bug report named the
trigger button (Enhanced view → Reorganize All) and both symptoms
(multi-disc collapse, half-album skip), which let the diagnosis go
straight to the architectural problem.
2026-04-24 23:00:22 -07:00
Broque Thomas
252121ca96 Bump Spotify post-ban cooldown from 5 min to 30 min
Reported on Discord by winecountrygames — Spotify auth granted, then
re-banned for 4 hours within ~30 seconds, repeatedly. Trace from his
captured log:

    < 12:05    [pre-log] Spotify ban active when log starts
    15:21:27   First ban EXPIRED → 5-minute post-ban cooldown begins
    15:26:27   Cooldown ends, spotify_client.is_authenticated() probe
               allowed again → client initialized
    15:26:59   First Spotify API call after cooldown — get_artist_albums
               for an artist whose discography a background worker was
               enriching — gets 429 immediately with no Retry-After
               header → new ban activated for 14400s (4 hours)

Root cause: `_POST_BAN_COOLDOWN = 300` (5 minutes) is shorter than
Spotify's actual server-side memory of the previous offense. The
cooldown exists specifically to prevent the "ban expires → we probe →
re-ban" cycle (`spotify_client.py:65-68` documents that intent
explicitly), but the value was wrong: Spotify's server still
considered this user banned 5 minutes after our local ban window
ended, so the very first call after cooldown got slapped.

The 4-hour re-ban itself is correct behavior — `_BASE_MAX_RETRIES_BAN`
fires when spotipy reports "max retries", which means the client
exhausted its internal retry budget on 429s before raising. That's a
severe-ban signal and a long default is the right response.

Fix: bump `_POST_BAN_COOLDOWN` to 1800 seconds (30 min). This is the
smallest change that addresses the immediate "re-probe → re-ban" loop
in the report. 30 minutes is an empirical floor — long enough for
Spotify to actually clear its server-side memory in the cases we've
observed, short enough not to keep functional users locked out beyond
necessary. Can be revisited if reports persist.

What this PR does NOT fix (important context for the same user):

This bump only helps the "ban expires → we re-probe → re-ban" loop.
It does NOT help winecountrygames's other symptom — Spotify being
banned within 30 seconds of his FIRST EVER authorization (no prior
ban). That's a separate failure mode: on first auth, enrichment
workers immediately fan out across the user's library (250 artists
in his case), hammering Spotify endpoints with bulk get_artist_albums
calls before any rate-limit feedback can land. Spotify's hidden
per-endpoint daily quotas — which BoulderBadgeDad has empirically
documented but the global rate limiter doesn't see — flag the burst
and impose a multi-hour cooldown that LOOKS like a bot-detection ban
to us. A proper fix needs a fresh-auth ramp-up: start with very low
Spotify QPS for the first N minutes, scale up only if no rate-limit
feedback arrives. That's a separate PR.

Documented as additional follow-ups (NOT in this change):

- Adaptive cooldown that scales with the size of the previous ban —
  a 4-hour MAX_RETRIES ban probably warrants a 1-hour cooldown,
  while a 60-second Retry-After-honored ban can resume in 5 minutes.
  The system already distinguishes these in `_set_global_rate_limit`,
  it just doesn't propagate the distinction to cooldown duration.
- Probe-with-light-call pattern — make the first post-cooldown call
  a single inexpensive endpoint (`current_user`) rather than
  allowing a background worker's heavy `get_artist_albums` to be
  the canary. Failed probe extends cooldown silently instead of
  triggering a fresh 4-hour ban.
- Fresh-auth ramp-up (per the limitation above).

Files:
- core/spotify_client.py — `_POST_BAN_COOLDOWN` 300 → 1800. Comment
  expanded to cite the report so the value isn't bumped back without
  context.
- webui/static/helper.js — WHATS_NEW entry under 2.40 explaining
  the change for affected users.

No tests added — the cooldown logic itself is unchanged, only the
constant. Tests asserting on a constant value are theater.

Reported on Discord by winecountrygames — his captured log made the
"ban-expires-to-re-ban" timing chain unambiguous.
2026-04-24 16:07:26 -07:00
Broque Thomas
b3afed1599 Fix Tidal device-auth link opening SoulSync instead of link.tidal.com
The "Link Tidal Account" device-flow UI displayed a verification URL
like `link.tidal.com/XBXYT` that, when clicked, navigated back to the
SoulSync origin (e.g. `http://localhost:8889/link.tidal.com/XBXYT`)
instead of to Tidal's activation page.

Root cause: tidalapi returns `login.verification_uri_complete` as a
schemeless string. settings.js drops it straight into `<a href>`, and
browsers treat schemeless hrefs as same-origin relative URLs.

Normalize the URI in `start_device_auth` — if it doesn't already
start with `http://` or `https://`, prepend `https://`. Same
treatment for the `link.tidal.com/{user_code}` fallback so the
defensive path stays well-formed too.
2026-04-24 14:27:17 -07:00
Broque Thomas
a9f827ef42 Reject Tidal streams that silently downgrade from the requested quality
Reported on Discord by Netti93: with Tidal configured for "HiRes only"
and "Allow Quality Fallback" disabled, tracks were still downloading
successfully — as m4a 320kbps files. Some "successful" downloads were
less than half the file size of the same track pulled via Tidarr/tiddl
from the same Tidal account.

Root cause: Tidal's API silently degrades to the best quality your
account + the track + your region permits. Setting
`session.audio_quality = Quality.hi_res_lossless` and calling
`track.get_stream()` on a track that's only available in AAC returns
an AAC stream with no error. The downloader wrote the m4a file to
disk, the ~7MB size sailed past the 100KB stub threshold, and the
download reported success.

The pre-existing "verify quality wasn't silently downgraded" block
only LOGGED a warning when this happened; it did not fail the tier.
Two knock-on effects:

- Users with "HiRes only, no fallback" got m4a files anyway, which
  defeats the setting entirely.
- The worker-level fallback chain (hires → lossless → high → low)
  couldn't advance past the first tier, because every tier
  "succeeded" at whatever Tidal happened to serve.

Fix: after `track.get_stream()`, compare `stream.audio_quality`
against the tier we asked for using a rank-based ordering:

    LOW < HIGH < LOSSLESS < HI_RES < HI_RES_LOSSLESS

- Same tier or higher → accept (so the occasional Tidal upgrade
  doesn't get rejected just because it's not an exact match).
- Lower tier → reject THIS tier. The loop `continue`s and the next
  fallback tier is tried, or the whole download fails honestly
  when the user has fallback disabled. The existing final-error
  log already has a hint directing users to enable fallback if
  they want automatic Lossless substitution.
- Unrecognized `audioQuality` value (e.g. a new Tidal tier we
  haven't mapped) → reject conservatively, so the next fallback
  tier gets a chance and the diagnostic log names the unknown
  value.

Why the rank-based approach instead of strict equality:

Tidal's API doesn't technically promise an exact-tier match on
serving; on tracks that are flagged in its catalog as a higher
tier, it can serve higher than the session setting. Rejecting
higher-than-asked quality would be user-hostile. And the `HI_RES`
(legacy MQA) value — not in tidalapi's modern `Quality` enum but
possibly still present on old catalog entries — needs to rank
below `HI_RES_LOSSLESS`: users asking for true lossless HiRes
should reject MQA since MQA is a lossy format.

tidalapi's `Quality` enum is a `str` subclass whose VALUES (not
member names) match what the Tidal API returns in the
`audioQuality` field (e.g. `Quality.hi_res_lossless.value ==
'HI_RES_LOSSLESS'`, `Quality.low_320k.value == 'HIGH'`). Both
sides of the comparison are coerced to `str` before use, so the
check is robust to whichever tidalapi version exposes the served
quality as an enum or a plain string.

The check is extracted as `_verify_stream_tier(stream, q_info,
q_key) -> (ok, reason)` at module scope — a pure function with no
I/O, unit-tested independently. Ten tests: match, three upgrade
cases (LOSSLESS → HI_RES_LOSSLESS, LOSSLESS → HI_RES, LOW → any
higher), three downgrade cases (the reported HiRes → AAC, HiRes
Lossless → MQA HiRes, Lossless → AAC), one unrecognized-tier case,
and two defensive paths for older tidalapi builds without
`audio_quality` on the stream object and for QUALITY_MAP entries
that lack `tidal_quality` (e.g. tidalapi wasn't importable at
module load). Test stub updated to use uppercase `Quality` values
matching real tidalapi so case-sensitivity regressions get caught.

Also removed the old codec-string-based warning block — the new
tier check is strictly stronger, and keeping the warning around
would just be dead code waiting to drift out of sync.

Deliberately NOT tackling in this PR (documented as follow-ups):

- Bit-depth verification of HiRes FLAC files via mutagen. The
  `stream.audio_quality` tier check catches the main "HiRes
  requested, got AAC" case; bit-depth would only matter if Tidal
  labeled a stream HI_RES_LOSSLESS but served a 16-bit FLAC
  (`Stream.bit_depth` isn't reliable for this — tidalapi defaults
  missing `bitDepth` fields to 16, so a trust-the-stream check
  would spuriously reject valid HiRes whenever Tidal omits the
  field). A proper fix runs mutagen post-download to inspect the
  actual file, then decides whether to delete + retry the next
  tier — a whole new failure mode with design trade-offs that
  deserve their own PR. The support logs don't show this
  happening.

- The "manual remap still says Not Found" symptom. Might be
  downstream of this same bug (silent-AAC "success" hitting a
  later rejection), might be a separate task-state issue. Not
  guessing without logs from the retry path.

- Quality-aware stub threshold. 100KB is a reasonable floor for
  real stub/preview detection and there's no evidence the
  universal threshold is misfiring in the wild.

Field-verified status: desk-verified via unit tests and empirical
checks against a live tidalapi import (confirming the `Quality`
enum's str-subclass behavior). Not yet smoke-tested end-to-end
against a real Tidal account with a HiRes-only-no-fallback
setting — Netti93 or anyone else with that config should notice
either the fix working (non-HiRes tracks fail honestly with a
clear log line) or any regression before wider release.

Files:
- core/tidal_download_client.py — new `_verify_stream_tier` helper
  and `_QUALITY_RANK` table at module scope, called in the
  download loop after the stream is fetched and before any
  bandwidth is spent. Removed the old inline codec-based warning
  since the new check supersedes it.
- tests/test_tidal_stream_tier_verification.py — ten tests covering
  match / upgrade / downgrade / unknown / defensive paths.
- tests/test_tidal_search_shortening.py — fake `Quality` values
  brought in line with tidalapi's real values so both files share
  a consistent stub regardless of pytest collection order.
- webui/static/helper.js — WHATS_NEW entry under 2.40 describing
  the rank-based tier comparison.

Reported on Discord by Netti93 — the "same account works via
Tidarr" comparison narrowed the cause to SoulSync's download path
rather than an account/region issue.
2026-04-24 13:12:30 -07:00
Broque Thomas
a60546929e Fix Album Completeness job reporting zero findings for every album
Reported by sassmastawillis: the Album Completeness maintenance job
scans 3127 albums in 0.1 seconds and reports 0 findings — for every
user, regardless of whether their library is actually complete.
Restoring an older DB surfaced 7 correct findings, so the code logic
works; the DB state is what's making everything look complete.

Root cause: `albums.track_count` is only ever written by server-sync
paths — Plex's `leafCount`/`childCount` and SoulSync standalone's
`len(tracks)`. It's the OBSERVED count of tracks SoulSync has indexed,
which is always exactly what `COUNT(tracks)` returns for that album.
The completeness job treated it as the EXPECTED total and compared it
against the observed count. They're equal by construction, so
`actual >= expected` is always true: skip, 0.1s scan, 0 findings.

Fix: new `api_track_count INTEGER` column on `albums`, written only by
metadata-source code paths. Populated in two places so the scan is
fast and the fallback is robust.

1. Enrichment workers — shared helper `set_album_api_track_count`
   in `core/worker_utils.py`. Called by each worker's existing
   `_update_album` method alongside its other album-column UPDATEs:

   - spotify_worker: `album_obj.total_tracks` from the Spotify Album
     dataclass (already in hand, zero new API calls)
   - itunes_worker: same, from the iTunes Album dataclass
   - deezer_worker: `nb_tracks` from full_data, falling back to
     search_data when the full lookup didn't run
   - discogs_worker: count of tracklist rows where `type_=='track'`
     (Discogs tracklists interleave heading and index rows that
     shouldn't count as songs)

   Helper skips the write on zero/None/negative/non-numeric inputs
   so a source lacking track info can't clobber a good value a
   different source already wrote. Caller owns the transaction —
   helper just queues an UPDATE on the caller's cursor without
   committing, so it batches cleanly with each worker's existing
   multi-UPDATE pattern.

   Hydrabase worker deliberately not touched — it's a P2P mirror
   that doesn't write album metadata to the local DB. Hydrabase-
   primary users hit the fallback path below.

2. Album Completeness repair job — new `al.api_track_count` column
   in the SELECT, read first in the scan loop. On miss (album never
   enriched, or enrichment workers haven't run yet on a fresh
   install), falls through to the existing `_get_expected_total()`
   API lookup and persists the result via the same shared helper
   (wrapped in connection/commit management since the repair job
   runs outside a worker's batched transaction).

Also removed `al.track_count` from the scan's SELECT — now unused
since the observed count was the whole source of this bug, and
leaving a dead SELECT would invite a future engineer to re-introduce
the same comparison.

Help text on the job card was reworded so it honestly describes
current behavior ("counts cached during normal enrichment are used
when available; otherwise the job queries a metadata source
directly") rather than the old "active provider first, then others
as fallback" phrasing, which doesn't match how the cache actually
fills — any enrichment worker that runs can populate it, and the
last writer wins. Document-only follow-up if this edge case ever
bites in practice: add a `api_track_count_source` column so the
scan can prefer the configured primary source's count over others
(e.g. deluxe vs. standard edition mismatches). Not worth the
complexity today.

For existing users, the first completeness scan after upgrade is
fast to the extent their library is already enriched: the workers
already ran and populated `api_track_count` on their normal schedule.
For brand-new installs, the scan's fallback path handles the cold
start — slower, but correct, and subsequent scans are fast.

Does NOT affect:
- Download / post-processing / wishlist / sync code paths — none
  of them read `track_count` for completeness semantics.
- Plex / Jellyfin / Navidrome / standalone sync — still write
  `track_count` exactly as before; `api_track_count` is a separate
  column they never touch.
- Other repair jobs.
- Any UI path — same finding schema, just correct counts now.

Files:
- database/music_database.py — idempotent migration adding
  `api_track_count INTEGER DEFAULT NULL` to the existing album-column
  check block.
- core/worker_utils.py — new `set_album_api_track_count` helper with
  the documented skip-on-bad-input contract.
- core/spotify_worker.py, itunes_worker.py, deezer_worker.py,
  discogs_worker.py — one-liner call from each `_update_album`.
- core/repair_jobs/album_completeness.py — scan uses the cache;
  fallback path persists API-lookup results via the shared helper;
  help text updated to match actual behavior.
- tests/test_worker_utils_album_track_count.py — 9 tests covering
  the helper's write/skip contract + no-commit invariant.
- tests/test_album_completeness_job.py — 2 tests for the repair
  job's fallback-path wrapper.
- webui/static/helper.js — WHATS_NEW entry.

Credit: sassmastawillis spotted the bug; the "restored older DB
finds 7 albums" signal pinpointed DB state over code logic and
made the diagnosis tractable.
2026-04-24 12:39:41 -07:00
Broque Thomas
c454b1ebaf MusicBrainz: Dedupe same-named homonyms in artist search results
Typing "michael jackson" returned 7 identical-looking cards because
MusicBrainz has many different PEOPLE sharing a canonical name — the
King of Pop plus a NZ poet, a photographer, a mashup artist, a
didgeridoo player, and more, all scoring 80+ on exact-name match.
All 7 passed the score filter. All 7 rendered with the same
fallback image because iTunes/Deezer only know the famous one.

Fix dedupes by normalized (lowercase, whitespace-trimmed) name before
building Artist dataclasses. Keeps the highest-scoring entry per name,
so the King of Pop (score 100) wins over the others (all score 80-81).
Artists with genuinely different names stay separate — a search for
"the beatles" still surfaces tribute bands if they're above threshold.

Implementation note: fetch `max(limit*3, 10)` from MB instead of
`limit` directly, so the dedup pool is large enough to still return
`limit` distinct artists after collapsing duplicates. Previously the
raw fetch was capped at the caller's limit, which would have left
fewer-than-requested results after dedup for common names.

3 new tests (49 total):
- Dedupe collapses 5 same-named entries to 1 (keeps highest score).
- Dedup key is case-insensitive and whitespace-normalized.
- Dedup preserves distinct names ("The Beatles" vs "The Beatles Revival"
  stay separate).

Live-verified: "michael jackson" now returns 1 card, "kendrick lamar"
returns 1 card.

Credit: kettui spotted duplicate Michael Jackson cards in the search UI.
2026-04-24 10:27:45 -07:00
Broque Thomas
b3722449fc MusicBrainz: Fix artist images, total_tracks off-by-one, and Artist+Title queries
Three bugs from kettui's follow-up review pass on the MusicBrainz
search PR, all fixed in one commit because they share UI context.

1. Missing artist images on MB artist results

MusicBrainz doesn't store artist images directly. My earlier commit
returned `image_url=None` on every artist result and trusted the
frontend's lazy-loader — but the lazy-loader's `/api/artist/<id>/image?
source=musicbrainz` endpoint had no handler for MusicBrainz, so it
silently returned None and the emoji placeholder stayed.

Fix plumbs the artist name through:
- `renderCompactSection` stashes `data-artist-name` on artist cards.
- `search.js` and `downloads.js` lazy-loaders pass `name=<artist>` as a
  query param.
- `/api/artist/<id>/image` accepts an optional `name` param.
- `metadata_service.get_artist_image_url` has a new `musicbrainz`
  branch: since MB has no artist art, it searches fallback sources
  (iTunes/Deezer by configured priority) for the artist name and
  returns the first image found.

Verified live — Metallica/Kendrick Lamar/Daft Punk all resolve to
Deezer artist images via the name lookup.

2. total_tracks off-by-one on tracks with a release

`_recording_to_track` initialized `total_tracks = 1` and then summed
media track-counts on top. For an 11-track album, it reported 12. An
adapter-level regression introduced when the recording-projection
helper was extracted during the main MB refactor.

Fix: initialize at 0, sum normally. Standalone recordings with no
release (can happen for uncredited remixes etc.) still report 1 via
an explicit fallback — so the existing "single track" case isn't
broken.

3. "Artist Album Title" queries buried specific albums in the
   discography list

Bare-name queries like "The Beatles Abbey Road" used to resolve "The
Beatles" as the artist and then browse their full discography — Abbey
Road was buried alphabetically among 200+ releases instead of being
the top result.

Fix adds a title-hint extractor. When the query starts with the
resolved artist name followed by more words, the trailing portion is
treated as a title hint. Browse results are filtered to those whose
release-group title contains the hint. If the filter matches nothing,
falls back to text-search with the hint as the title (the "keep the
old split-by-whitespace fallback" path kettui called for). If text-
search also misses, shows the full discography rather than nothing.

10 new tests in tests/test_musicbrainz_search.py (46 total):
- Title-hint extractor: basic match, case-insensitive, whitespace
  tolerance, bare-artist-no-hint, artist-not-prefix-no-hint, word-
  boundary required (no false splits on "Metallicasomething").
- Browse filtering by title hint.
- Text-search fallback when the title hint matches nothing in browse.
- Bare-artist queries return the full discography unfiltered.
- total_tracks for single-release, multi-disc, and no-release cases.
2026-04-24 10:17:59 -07:00
Broque Thomas
7dfe1ae88d MusicBrainz: Resolve release-group MBIDs to a release on album click
Clicking a MusicBrainz album returned 404 because the browse-based
search path now stores release-GROUP MBIDs in Album.id, but `get_album`
still hit `/ws/2/release/<mbid>` directly. Release-group MBIDs don't
resolve as release MBIDs — MB 404s. User log:

    GET /api/spotify/album/b88655ba...?source=musicbrainz → 404
    Error fetching release b88655ba...: 404 Client Error

The fix requires a two-step resolution for the new browse path:

1. Look up the release-group with `inc=releases+artist-credits` to get
   the list of releases inside (original + reissues + regional + promo
   editions). MB release-groups routinely hold 5-20 releases.
2. Pick a representative release: prefer Official status over Promo,
   prefer releases with a real tracklist over stubs, then earliest date.
3. Fetch that release's full tracklist via `get_release`.

Two extra seconds at the 1-rps rate limit, but it's on click, not on
search results rendering.

Structure:
- New `MusicBrainzClient.get_release_group(mbid, includes)` method.
- New `_pick_representative_release(releases)` helper encapsulates the
  ranking logic.
- Tracklist projection extracted into `_render_release_as_album` so
  both paths share the same shape construction.
- `get_album` tries release-group first; falls back to direct release
  lookup when the MBID turns out to be a release from the text-search
  fallback path.
- Canonical Album.id stays the release-group MBID so a re-fetch with
  the same URL hits the same code path idempotently.

3 new tests (now 33 total):
- End-to-end release-group → release resolution with mocked client
- Fallback to direct release lookup when rg lookup misses
- Representative-release picker ranks correctly

Verified against live API with the exact MBID that 404'd for the user
(b88655ba... for DAMN. by Kendrick Lamar): now returns in 1.2s with
the full 14-track listing (BLOOD., DNA., YAH., ELEMENT., FEEL., ...).
2026-04-24 08:48:02 -07:00
Broque Thomas
ddbcdfe73a MusicBrainz: Filter live/compilation bootlegs + chronological sort
Three related fixes to make album/track results look like a real
artist discography instead of a firehose of fan-compiled bootlegs.

1. Drop 'compilation' from the release-group browse primary-type filter.
   MB's OR filter (`type=album|ep|single|compilation`) silently breaks
   when 'compilation' is included — Metallica drops from 1076 matches
   to 82 because `compilation` is a SECONDARY type on MB, not a primary
   type. The invalid value corrupts the filter for all types, not just
   itself. Now we request `type=album|ep|single` which returns the full
   1076; actual compilations (primary=Album + secondary=[Compilation])
   are filtered out by the studio-preference logic below.

2. Filter release-groups with non-studio secondary-types
   (Live/Compilation/Soundtrack/Remix/Demo/Mixtape/Interview/Audiobook/
   Audio drama). For Metallica, the first 100 browse results are 12
   studio albums + 83 live bootlegs + 5 compilations — without this
   filter the Albums section was dominated by 2019-2021 broadcast
   recordings. Falls back to the unfiltered list if filtering leaves
   the result set empty (covers live-only niche artists).

3. Sort chronologically ASC by first-release-date. Wikipedia-style
   discography ordering — debut album on top, then chronological.
   Previous DESC sort put the most recent release on top which, for
   prolific artists, meant 2020s material before their classics.

Track side of the same fix:

- Re-orders each recording's `releases` array to put studio releases
  first before `_recording_to_track` picks up the first release for
  album context. Without this, MB's arbitrary release order often
  buried the canonical studio album under random live bootlegs.
- Filters out recordings that only exist on live/compilation release-
  groups (keeps the ones with at least one studio release). Falls
  back to the full set if the artist has no studio recordings at all.
- Sorts recordings by earliest studio-release year ASC so classic
  tracks surface first.

Smoke test against live MB API confirmed:
- Artists: [Metallica score=100]
- Albums: Kill 'Em All (1983) → Ride the Lightning → Master of Puppets
  → ...And Justice for All → Metallica (Black Album) → Load → Reload
  → St. Anger → Death Magnetic → Lulu (2011)
- Tracks: real Metallica recordings (Killing Time, Nothing Else
  Matters, Creeping Death, etc.) — a few remastered demos still leak
  in where MB metadata quality is thin, but the bulk is correct.
- Total latency: 3.5 seconds.

4 new tests covering the studio filter, live-only fallback, preferred
release ordering, and live-only recording exclusion.

Credit: kettui flagged the poor MB results during PR #371 review.
2026-04-24 08:32:05 -07:00
Broque Thomas
8523724b03 MusicBrainz: Switch track lookup from browse to arid: search
The previous commit's `browse_artist_recordings` call passed
`inc=releases+artist-credits` — but MusicBrainz's recording browse
endpoint rejects `inc=releases` with HTTP 400. The adapter's error
handler returned an empty list, so the Tracks section stayed empty
even though the fix was supposed to populate it.

Browse without release info is useless for our search UI (tracks
would render with no album), so swap to the fielded Lucene search
`arid:<mbid>` on the `/recording` endpoint. That's the canonical MB
pattern for "find recordings by this artist WITH release context":
- arid: search accepts the artist MBID and returns recordings with
  `releases` (release-group, date, media) embedded in each result.
- One API call per lookup, same as browse would have been.

Renamed the method to `search_recordings_by_artist_mbid` so the name
matches its behaviour — it's a search, not a browse. Adapter updated
to call the new name; tests updated to match.

Verified against the live API: Metallica's MBID returns 5 recordings
in ~1.8 seconds (vs the previous 400 error).
2026-04-24 08:25:09 -07:00
Broque Thomas
73df2951e5 MusicBrainz: Construct Cover Art URLs instead of HEAD-probing them
Cover Art Archive URLs are deterministic from the MBID: a GET either
307-redirects to the image or returns 404. The previous adapter fired
`requests.head(timeout=3)` per search result to probe for the image
first. 10 results × 3s worst-case = up to 30s of blocking HEAD calls
before a search returned.

The probe was defensive overhead — the frontend already handles 404 via
`<img onerror>` fallback. Building the URL deterministically and letting
the browser load it lazily collapses the tail latency to the real MB API
calls (artist-search + browse = ~3s at the 1-rps rate limit).

Also prefer release-group scope over per-release scope when both are
available — release-group covers every edition of an album, so the hit
rate is noticeably higher than pinning to a specific regional release.

Removes now-unused `self._art_cache` and the `requests` import.
2026-04-24 08:15:49 -07:00
Broque Thomas
d7e232e01c MusicBrainz: Artist-first browse for albums + tracks, keep text fallback
Bare name queries (typing 'metallica') now resolve to an artist MBID via
the fuzzy search added in the previous commit, then BROWSE that artist's
release-groups and recordings instead of text-searching release/recording
titles. That's the only way to fix the core garbage-results issue: MB
indexes release/recording titles, not artist names, so 'recording:metallica'
matches random tracks literally titled 'Metallica' (all scoring 100).

Structure:

- `_split_structured_query` — detects 'Artist - Title' / 'Artist – Title' /
  'Artist — Title' shapes. When present, text-search is correct (user
  gave an explicit title to match).
- `_resolve_top_artist` — memoized per-instance lookup for the top-scoring
  artist MBID. Backend fires artists/albums/tracks searches in parallel
  against one shared client instance, and albums+tracks both need the
  same artist lookup. Cache + lock means one HTTP call instead of three.
- `_release_group_to_album` / `_recording_to_track` — shared projection
  helpers between the browse and text paths so both paths return the
  same dataclass shape.

Search flow per kind:

- `search_albums('metallica')` → resolve top artist → browse release-groups
  with `type=album|ep|single|compilation` → sort by type priority then
  release date desc → Album dataclasses for top N.
- `search_tracks('metallica')` → resolve top artist → browse recordings
  with `inc=releases+artist-credits` → dedupe by normalized title (MB
  has many live/compilation variants of the same song) → sort by release
  date desc → Track dataclasses for top N.
- `search_albums('foo - bar')` → structured query → text-search path
  (unchanged behavior, now score-filtered to 80+).
- `search_tracks('foo - bar')` → same.
- Both text-search paths also dedupe through `_search_albums_text` /
  `_search_tracks_text` helpers, which apply the 80-score filter that
  the artist-first path gets free from the resolver's threshold.

Also dedupes text-path tracks through the new `_recording_to_track`
helper, replacing ~60 lines of inline projection code. Net change is
more lines overall (browse + helpers) but the text paths shrank and
the garbage-results issue is fixed.

Credit: kettui flagged the missing Artists section + unusable track
results during PR #371 review.
2026-04-24 08:13:40 -07:00
Broque Thomas
434d1c382c MusicBrainz: Re-enable real artist search (was returning empty)
`MusicBrainzSearchClient.search_artists` has been a `return []` stub
since the feature landed, with a comment claiming the MB tab 'doesn't
show artists.' That's why kettui saw a missing Artists section on the
search page — not a missing render, a hardcoded empty list.

Re-enable it properly:

- New `strict=False` parameter on `MusicBrainzClient.search_artist`
  sends a bare Lucene query instead of `artist:"..."`. MusicBrainz
  matches bare queries against alias+artist+sortname indexes together,
  which is the right behavior for user-facing fuzzy search (finds
  typos, aliases, sortname variants). `strict=True` remains the
  default for enrichment/AcoustID callers that want exact matches.

- Adapter filters results to `score >= 80`. MB assigns a 0-100 Lucene
  score on every hit; the true artist + close variants score 100,
  tribute bands and lookalikes typically land in the 40-65 range.
  The cutoff keeps "Metallica" (100) and drops "Black Metallica
  Tribute Band" (60) without hand-curated lists.

- Results returned as the same `Artist` dataclass used elsewhere in
  the search-tab adapter layer. `popularity` carries the MB score
  (0-100) so the frontend can sort/highlight top matches if desired.
2026-04-24 08:09:51 -07:00
Broque Thomas
e2a5a38cd2 MusicBrainz: Add browse endpoints for release-groups + recordings
Add `browse_artist_release_groups(mbid)` and `browse_artist_recordings(mbid)`
to MusicBrainzClient. These hit `/ws/2/release-group?artist=<mbid>` and
`/ws/2/recording?artist=<mbid>` respectively — the correct MusicBrainz
pattern for "give me everything linked to this artist."

Why this matters: our current search adapter calls text-search
(`release?query=...` / `recording?query=...`) for albums and tracks,
which matches entity titles literally. Typing "metallica" hits unrelated
releases titled "Metallica" and recordings named "Metallica" by obscure
bands — every garbage match scores 100 because they're all exact title
matches on the wrong field.

Browse walks the artist→release-group and artist→recording links
directly. Once we know the artist's MBID (from `search_artist`), browse
returns their actual discography instead of title collisions.

No behavior change yet — search adapter still uses the old path. Follow-
up commit wires the new endpoints in.

Reference: https://musicbrainz.org/doc/MusicBrainz_API — "Browse queries
retrieve entities linked to a known entity" vs search.
2026-04-24 08:07:47 -07:00
Broque Thomas
3c48508c3f MusicBrainz: Add project URL to User-Agent per API requirements
MusicBrainz mandates a meaningful User-Agent with contact info, warning
that bare strings can trigger IP blocking under load. Our client was
sending `SoulSync/2.3` with no contact — and the search adapter passed
an app version hard-coded at "2.3" that's now stale (UI is at 2.40).

Fix: default contact to the project URL (`https://github.com/Nezreka/SoulSync`)
when no email is supplied, so every request lands as
`SoulSync/<version> ( https://github.com/Nezreka/SoulSync )`. Drop the
search-adapter version suffix to a generic "2" since the exact UI minor
version would add noise to every MB request without helping operators
track issues.

Reference: https://musicbrainz.org/doc/MusicBrainz_API — "it is
important that your application sets a proper User-Agent string."
2026-04-24 08:06:50 -07:00
Broque Thomas
14893c85a9 Extract _build_source_only_artist_detail into core/artist_source_detail.py
JohnBaumb's review: "If we're going to refactor the web_server.py soon,
might as well start moving stuff away from web_server.py in our PRs.
_build_source_only_artist_detail, make it a module, it's perfect."

This continues the pattern the prior commit started with the source-ID
lookup helpers: move the pure data-building logic to a side-effect-free
core module, leave a thin wrapper in web_server.py that bridges the
Flask response and the module-global clients.

**core/artist_source_detail.py** — pure function that takes the artist id,
name, and source plus dependency-injected per-source clients (spotify,
deezer, itunes, discogs) and a Last.fm API key. Returns
(payload_dict, http_status) so it isn't coupled to Flask.

**web_server.py wrapper** — builds the client bag from the module globals
(checks Spotify auth, constructs the Discogs client from the configured
token, reads the Last.fm API key) and wraps the core return in jsonify.
147 lines of logic go away from web_server.py; the 24-line wrapper is
purely glue.

**tests/test_artist_source_detail.py** — 21 focused tests covering the
response envelope, the source-specific ID-field stamping for all six
supported sources, the dedup_variants=False contract (the behaviour
that originally motivated the split of MetadataLookupOptions), per-source
genre/follower extraction with safe handling of missing or throwing
clients, and the Last.fm enrichment branch including the no-key and
error-path cases. Runtime 0.26s.
2026-04-23 08:09:29 -07:00
Broque Thomas
02f26bf338 Make ApiCallTracker.save() atomic to prevent corrupt history files
Cin observed that database/api_call_history.json was occasionally
landing on disk truncated mid-write — `_load()` would log
`History file is not valid JSON, starting fresh` and 24h of metrics
would be lost.

Root cause: `save()` opened the file in 'w' mode (which truncates to
0 bytes immediately) and then streamed JSON via `json.dump`. Any
SIGINT/SIGTERM/crash between truncate and final write left the file
half-formed — exactly Cin's symptom of the JSON cutting off mid-array.

Switch to the standard atomic pattern: write to a sibling .tmp file,
flush + fsync, then `os.replace` (atomic on every platform we run on).
Failed writes also clean up the leftover .tmp file. The canonical
file is now either the previous good copy or the new good copy —
never a partial one.
2026-04-22 22:15:09 -07:00
Broque Thomas
e66af77ff6 Make artist_name Optional in find_library_artist_for_source
Cin's review note: typing artist_name as plain `str` forced callers
that didn't have a name to pass `""` as a placeholder, which leaks the
parameter's emptiness contract into every call site and reads badly in
tests. Switching to `Optional[str] = None` lets callers omit it.

The function body's `if artist_name and active_server:` check already
handles None and "" identically, so no body changes were needed. Tests
that previously passed `artist_name=""` drop the argument; one new test
covers the omitted-arg path explicitly.

The web_server.py wrapper takes the same default for symmetry.
2026-04-22 22:15:03 -07:00
Broque Thomas
a097cf3d5a Extract source-artist lookup helpers from web_server.py to core module
Cin pointed out that the prior version of test_artist_source_lookup.py
AST-parsed web_server.py to verify a constant and to string-match a
function's response keys. That was a workaround for the fact that
web_server.py can't be imported at test time (it boots Spotify,
Soulseek, Plex, etc.) — the right answer is to move the logic into a
side-effect-free module so it can be imported and tested directly.

This commit:
  - adds core/artist_source_lookup.py containing the SOURCE_ID_FIELD
    map, the SOURCE_ONLY_ARTIST_SOURCES set, and find_library_artist_for_source
  - replaces the inline definitions in web_server.py with imports +
    a thin wrapper that injects the active media server
  - rewrites the tests to import from the core module directly:
      * mapping correctness is now a plain equality assertion
      * lookup behaviour is exercised against a real MusicDatabase
      * the AST parse and the string-matching contract test class are
        gone
  - drops the _build_source_only_artist_detail contract test entirely
    (the weakest of the four — it was just string-matching the function
    body); when that function moves to core/ it can get a real
    behavioural test alongside.

Test runtime drops from ~161s to ~5.8s. All 18 tests pass.
2026-04-22 22:07:23 -07:00
Broque Thomas
f936b8cb12 Enrich source-only artist-detail response and skip discography dedup for source artists
Source artists landing on /artist-detail were rendering an almost-blank
hero — image + name + a tiny Download button — because the backend
response only had {id, name, image_url, server_source: null, genres: []}.
The library.js renderers do their best with what they have, and that
wasn't much.

Backend changes (_build_source_only_artist_detail):
  - Set the source-specific ID field (deezer_id / spotify_artist_id /
    itunes_artist_id / discogs_id / soul_id / musicbrainz_id) on
    artist_info so the corresponding service badge renders on the hero.
  - Try the source's own get_artist_info / get_artist for genres +
    followers (Spotify always; Deezer/iTunes/Discogs when available).
    Spotify also fills image_url if metadata_service.get_artist_image_url
    came up empty.
  - Last.fm enrichment by artist name — bio + listeners + playcount +
    lastfm_url. Mirrors what library artists get from the cached
    enrichment workers but on demand for source artists.
  - All enrichment lookups are wrapped in try/except so a 500 from any
    one source doesn't break the whole response.

Frontend (library.js populateArtistDetailPage):
  - Watchlist button now initialises for source artists too. Falls back
    to artist.id + artist.name when there's no canonical Spotify
    identity (which is the common case for non-library artists).

Discography dedup opt-out:
  - Added dedup_variants flag to MetadataLookupOptions (default True so
    library artists are unchanged). Source-only path now passes
    dedup_variants=False so every "Deluxe Edition" / "Remastered" /
    "Anniversary" variant the source returns is shown — matches the
    inline /artists page behaviour the user was comparing against.

Result: source artists' hero now shows badges + bio + listeners +
playcount + watchlist button + genres in addition to image and name.
Discography lists every release the source returns, not the deduped
canonical view.
2026-04-22 17:14:39 -07:00
Broque Thomas
8f85b0c251 Fix silent wrong-artist track downloads (Maduk/Tom Walker bug)
User reported searching "Maduk - Leave A Light On" on Tidal silently
downloaded Tom Walker's completely different song of the same name, then
embedded Maduk's metadata into Tom Walker's audio. Three layers of
defense all failed permissively. Two of them are fixed here; the third
(score formula weights) was left alone since these two together cover it.

Layer 1 fix — candidate artist gate (web_server.py:27782)
  Old: `if _best_artist < 0.4 and confidence < 0.85: continue`
  New: `if _best_artist < 0.5 and confidence < 0.85: continue`

  SequenceMatcher returns exactly 0.400 for "maduk" vs "tom walker"
  (5-char vs 10-char strings with coincidental char matches), which
  slipped past the strict `< 0.4` check. The word-boundary containment
  check earlier in the function already short-circuits legitimate
  formatting variations to sim=1.0, so falling to SequenceMatcher means
  strings are genuinely different. 0.5 closes the fencepost AND gives
  a small safety buffer.

Layer 3 fix — AcoustID verification (acoustid_verification.py:316)
  When title matches but artist doesn't AND expected artist isn't found
  anywhere in AcoustID's returned recordings:
    Old: always SKIP (let file through, assume cover/collab)
    New: FAIL if artist_sim < 0.3 (clear mismatch)
         SKIP if artist_sim >= 0.3 (ambiguous — cover/collab/formatting)

  The 0.3 cutoff catches hard mismatches like Maduk/Tom Walker (sim ~0.2)
  while preserving benefit-of-the-doubt for borderline artist formatting
  differences. Legitimate covers and collabs where the expected artist
  appears anywhere in AcoustID's recordings still PASS via the existing
  secondary-match loop above.

Both fixes are defense-in-depth — either alone would have caught this
bug. Together they close the pre-download AND post-download gaps.

All 292 tests pass. Version bumped to 2.39 with changelog entries.
2026-04-22 10:32:55 -07:00
Broque Thomas
0d0bbf38c9 Add query-shortening retry + qualifier guard to Tidal search
Tidal's search engine chokes on long queries with multiple qualifier
words (remix credits, edit labels, bonus-disc markers). User reported
case: "maduk transformations remixed fire away fred v remix" returns 0,
but shortening to "maduk transformations remixed fire away" works.

Behaviour change:
- On a 0-result search, retry with progressively-shortened variants
  (capped at 5 total attempts, 100ms pause between).
- Variants (in priority order):
    1. strip trailing "(...)" / "[...]"
    2. strip all parentheticals/brackets
    3-5. drop last 1 / 2 / 3 tokens
    6. keep first half of tokens (rounded up)
- Dedupes so identical variants don't re-query.

Safety — qualifier-aware filter:
- Variant keywords (Live / Remix / Acoustic / Extended / Unplugged /
  Instrumental / Karaoke / etc.) are extracted from the original query
  using word-boundary match so "edit" doesn't match "edition" and
  "mix" doesn't match "remixed".
- If the original query carries any qualifiers, fallback results MUST
  contain those qualifiers in their track names — otherwise a shortened
  query could silently downgrade "Song (Live)" to the studio "Song".
- Tracks that fail the filter are dropped. If no variant produces
  qualifier-matching tracks, returns ([], []) — the same outcome as the
  original code, so no regression.

Contract preservation:
- Never raises to caller (outer try/except catches orchestration errors).
- Returns ([], []) on any failure path, same as original.
- Original-query successes take the same code path as before — no
  behavioural change for queries that already work.
- Defensive guards for None/empty/non-string query (early return).

Logging:
- Preserves original warning/error/info messages for back-compat log
  scraping.
- Adds fallback-success INFO log ("Tidal fallback query succeeded: ...")
  so successful retries are visible in production logs.
- Adds qualifier-filter INFO/DEBUG logs with kept/total counts.
- Per-attempt exception logs at DEBUG (not ERROR) to avoid noise when
  retries succeed.
- Traceback preserved on final failure.

Tests (16 regression tests in tests/test_tidal_search_shortening.py):
- Skowl's reported query reaches his working variant within the cap.
- Paren/bracket stripping priority.
- Short queries produce no variants.
- All variants unique (dedup guard).
- Progressive token drops present for long queries.
- Qualifier extraction is word-bounded (no "edit" in "edition").
- Qualifier extraction is case-insensitive.
- Track name filter requires ALL qualifiers.
- Empty-qualifier list passes every track (original-query behaviour).

All 292 tests pass.
2026-04-22 07:42:16 -07:00
Broque Thomas
78fa83c8ac Add $cdnum template variable for multi-disc filenames
New smart template variable that emits "CD01" / "CD02" etc. in filenames
on multi-disc albums, and expands to empty string on single-disc albums
so mixed libraries don't end up with "CD01" on every single.

Template behaviour:
- total_discs > 1 -> "CD{disc:02d}" (zero-padded, CD prefix)
- total_discs <= 1 -> empty string
- Both $cdnum and ${cdnum} bracket form supported
- Empty value collapses cleanly via existing double-dash regex plus new
  leading-dash cleanup pass

Wiring:
- _apply_path_template in web_server.py (download pipeline)
- _apply_path_template in core/repair_jobs/library_reorganize.py
  (Reorganize repair job)
- total_discs added to every album-mode template context:
  * download pipeline album branch (uses resolved total_discs even for
    single-track downloads from search)
  * per-album Reorganize preview + apply endpoints (pre-scan all track
    tags once, take max disc_number)
  * Library Reorganize repair job (already had album_total_discs map,
    just added to context dict)

Leading-dash cleanup added to _get_file_path_from_template (web_server)
and _build_path_from_template (library_reorganize) so templates like
"$cdnum - $track - $title" don't leave "- 05 - Title" on single-disc
albums.

UI:
- Template hint in Settings -> File Organization documents $cdnum
- Template validation variable list includes $cdnum
- Reorganize modal variable reference shows $cdnum with example "CD01"

Verified:
- Multi-disc disc 1 -> "CD01 - 05 - Track"
- Multi-disc disc 2 -> "CD02 - 05 - Track"
- Single-disc      -> "05 - Track" (no leading dash)
- Templates without $cdnum behave unchanged
- 276/276 tests pass
2026-04-21 22:55:37 -07:00
Broque Thomas
e5d4d61c0e Fix watchlist content filters: live false positives + auto-scan bypass
Two bugs reported in issue #320:

1. Auto-watchlist scan bypassed Global Override settings.
   scan_watchlist_profile applied _apply_global_watchlist_overrides, but
   the scheduled auto-scan called scan_watchlist_artists directly —
   bypassing the override. Users who unchecked "Albums" or "Live" under
   Watchlist → Global Override still saw full albums and live tracks
   added during nightly scans (per-artist defaults, which include
   everything, won).

   Moved override application into scan_watchlist_artists itself so
   every entry point respects it. scan_watchlist_profile now forwards
   the apply_global_overrides flag through to avoid double-application.

2. is_live_version (watchlist + discography backfill) and
   live_commentary_cleaner's content patterns used bare \blive\b, which
   matched verb uses like "What We Live For" by American Authors,
   "Live Forever" by Oasis, "Live and Let Die" by Wings.

   Tightened the live patterns to require clear recording context:
   (Live) / [Live Version] / - Live / Live at|from|in|on|version|
   session|recording|performance|album|show|tour|concert|edit|cut|take
   / In Concert / On Stage / Unplugged / Concert.

   Locked in 11 regression tests covering the reported false positives
   (What We Live For, Live Forever, Living on a Prayer, Live and Let Die)
   and the reported true positives (Dimension - Live at Big Day Out,
   MTV Unplugged, etc.).

Version bumped to 2.37 with changelog entries.
2026-04-21 19:16:25 -07:00
Broque Thomas
457763cbab Rebuild Discography Backfill: auto-wishlist, Fix All, section UI
Root-cause fix for "scanning 50 artists" then silence: when the master
repair worker was paused, force-run still kicked off _run_job but the
job's first wait_if_paused() blocked forever because is_paused was tied
to the master-enabled state. Force-run now bypasses master-pause —
scheduled runs still respect it.

Also fixes Fix All on discography findings doing nothing: the backend
bulk_fix_findings query had a fixable_types allowlist that excluded
missing_discography_track (and acoustid_mismatch). Added both.

Backfill job rebuild:
- auto_add_to_wishlist opt-in setting — creates findings AND pushes to
  wishlist during the scan
- 3-option fix dialog (Add to Wishlist / Just Clear / Cancel) on single
  Fix, Bulk Fix selection, and Fix All (page-level)
- Fix All "Just Clear" path uses the clear endpoint with job_id filter
  instead of the generic "may delete files" bulk-fix warning
- Batched in-memory matching using get_candidate_albums_for_artist +
  get_candidate_tracks_for_albums (same fast path the Library pages use)
- Rich album context per finding (id, name, album_type, release_date,
  images, artists, total_tracks) — flows through the wishlist pipeline
  so auto-processor classifies each track into the right cycle
  (albums vs singles) and post-processing gets correct folder/tags/art
- Per-artist progress logs [N/50] Scanning ArtistName
- Default interval 24h (was 168h); all release types default on; settings
  reordered with _section_* group headers (Core / Release Types /
  Content Filters)

Repair settings UI:
- Generic _section_<name> key convention renders as an uppercase group
  divider in the settings panel — any job can opt in
- .repair-setting-row gets a dashed bottom border so label↔toggle pairing
  is visually clear
- _prettifyRepairSettingKey fixes acronym capitalization (EPs, not Eps)

Version bumped to 2.36 with changelog entries.
2026-04-21 18:44:43 -07:00
Broque Thomas
39a07e4bdf Fix Discography Backfill silently skipping most releases
Two bugs kept this job from finding anything useful on a typical library.

1. Wrong Deezer column name. The artists table has a deezer_id column
   (per music_database.py:1986), but the job looked for deezer_artist_id
   in both _scan_artist (line 132) and _get_library_artists (line 345).
   For Deezer-primary users, this meant the Deezer ID never made it into
   the source_ids map, so get_artist_discography fell back to artist-
   name-only search — slower and less accurate than an ID lookup.

2. Spotify-reported EPs were silently excluded. Spotify lumps EPs and
   true singles under album_type='single'. The previous
   _should_include_release short-circuited on album_type='single' and
   returned the include_singles setting (default False), so 4-6 track
   EPs on Spotify-primary libraries never survived the filter — even
   though include_eps defaulted to True. Only 7+ track full albums
   made it through. This is the main reason users felt the job did
   nothing.

Fixes:

- Use the correct deezer_id column name in both reference sites.

- Restructure _should_include_release so only 'album', 'ep', and
  'compilation' are trusted outright. Anything else (including
  'single' and missing type) falls through to a track-count
  disambiguation matching the download pipeline's _get_album_type_display:
  1-3 tracks = true single, 4-6 = EP, 7+ = album. A Spotify-returned
  'single' with 5 tracks now correctly counts as an EP.

Full suite stays at 263 passed. Ruff clean.
2026-04-21 17:26:38 -07:00
Broque Thomas
d9217237d2 Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs
PR #340 added ruff to the build-and-test.yml CI gate, which surfaced
286 pre-existing lint errors. Left unfixed, every feature branch push
fails CI. This commit resolves all of them so CI goes green and
contributors can actually land work.

Auto-fixes (248 of 286): removed unused f-string prefixes (F541),
renamed unused loop control variables with underscore prefix (B007),
removed duplicate imports (F811).

Manually fixed 10 latent bugs ruff caught (all wrapped in try/except
today, silently failing):

- music_database.py: _add_discovery_tables() called undefined
  conn.commit() — would have crashed the iTunes-support migration
  for existing databases. Now uses cursor.connection.commit().
- web_server.py settings GET: referenced undefined download_orchestrator
  when it should be soulseek_client. Feature (_source_status on the
  settings payload) was silently missing for UI auto-disable logic.
- web_server.py _process_wishlist_automatically: active_server
  undefined in track-ownership check. Auto-wishlist was falling
  through to the error handler and re-downloading owned tracks.
- web_server.py start_wishlist_missing_downloads: same active_server
  bug in the manual wishlist path.
- web_server.py _process_failed_tracks_to_wishlist_exact: emitted
  wishlist_item_added automation event with undefined artist_name
  and track. Automation event silently never fired correctly.
- web_server.py discovery metadata enrichment: referenced cache
  without calling get_metadata_cache() first. Track enrichment from
  cached API responses was silently skipped.
- web_server.py Beatport discovery worker: wing-it fallback branch
  used undefined successful_discoveries variable. Wing-it counter
  never incremented correctly. Now uses state['spotify_matches']
  consistently with the rest of the function.
- web_server.py _run_full_missing_tracks_process: stale import json
  mid-function shadowed the module-level import, making an earlier
  json.dumps() call reference an unbound local (F823).
- web_server.py discovery loop: platform loop variable shadowed
  the module-level platform import (F402).
- core/watchlist_scanner.py: 7 lambda captures of loop variables
  (B023 classic Python closure-in-loop bug) now bind at creation.

No existing tests had to change. Full suite stays at 263 passed.
2026-04-21 13:30:52 -07:00
Antti Kettunen
6f79214439
Route artist image lookup through metadata service
- Move /api/artist/<artist_id>/image resolution into core.metadata_service.
- Resolve artist artwork through source priority, with explicit source/plugin overrides preserved.
- Keep Spotify call tracking inside the client layer to avoid double counting.
- Update similar-artist lazy loading to pass source context and add service coverage.
2026-04-21 21:14:35 +03:00
Antti Kettunen
b022a90997
Move MusicMap similar artist matching into metadata service
- Relocate the streamed MusicMap similar-artist flow out of web_server.py and into core.metadata_service.
- Match similar artists through the configured source-priority chain instead of assuming Spotify first.
- Add iTunes artwork fallback so streamed artist payloads still carry image_url when search results are sparse.
- Cover the new service behavior with tests.
2026-04-21 21:14:35 +03:00
BoulderBadgeDad
9863c947dc
Merge pull request #343 from kettui/fix/replace-print-with-logger
Tidy up logging across the app
2026-04-21 11:05:52 -07:00
Broque Thomas
cf143f70af Batch discography completion matching against pre-fetched candidates
Artist detail pages ran check_album_exists_with_editions and check_track_exists
per discography item, each firing 5+ title variations times 3 artist variations
of fuzzy LIKE searches plus fallback broad-artist queries. For a 30-album artist
that was ~450 SQL round-trips just to answer "which of these do I own."

Hoist the artist's library albums and tracks into memory once per request via
two new helpers — get_candidate_albums_for_artist and get_candidate_tracks_for_albums —
and thread them through as optional candidate_albums / candidate_tracks params on
check_album_exists_with_editions, check_album_exists_with_completeness,
check_track_exists, check_album_completion, and check_single_completion.

Batched path scores the same _calculate_album_confidence / _calculate_track_confidence
against the in-memory list, preserving Smart Edition Matching and accuracy.
Title-only cross-artist fallback still fires for collaborative-album edge cases.
None on either param preserves legacy per-item SQL behavior for unaffected callers.

Applied to both /api/library/completion-stream (library artist detail page) and
iter_artist_discography_completion_events (Artists search page). Timing logs
added to confirm the pre-fetch cost and loop elapsed time.

On a Kendrick page load, per-album resolution drops from ~8 seconds to under
the 50ms streaming sleep floor. Observed ~100x SQL reduction on the happy path.
2026-04-21 10:47:47 -07:00
Antti Kettunen
71e114b6fe
Tighten legacy logging output
- collapse old multi-line debug bursts into single structured rows
- remove leftover DEBUG-style prefixes from message text
- keep the app log readable without losing useful trace detail
2026-04-21 18:00:31 +03:00
Antti Kettunen
01d118daa6
Separate AcoustID file logging
- keep AcoustID logs out of app.log
- route client and verification to logs/acoustid.log
- align tag writer with the soulsync logger namespace
2026-04-21 18:00:31 +03:00
Antti Kettunen
e3e0d46af9
Add another sanity check for api_call_history.json load 2026-04-21 14:42:22 +03:00
Antti Kettunen
5265864e1f
Store all log files under the same folder as the configured app.log
If the application was using a non-standard location for app.log, the other logs would still go to the default location. Now everything goes under the same, configured folder
2026-04-21 14:42:22 +03:00
Antti Kettunen
67a5bcb5a7
Rename logger namespace from newmusic to soulsync 2026-04-21 14:42:21 +03:00
Antti Kettunen
fe7ae29b8a
Replace more print logs with proper logger usage 2026-04-21 14:42:21 +03:00
Broque Thomas
fd014e2745 Use parent folder name as artist override in auto-import
When staging files are organized as Artist/Albums/AlbumFolder or
Artist/AlbumFolder, the auto-import now uses the parent folder name
as the artist instead of trusting embedded file tags.

Uses relative path from staging root to determine folder depth, so
albums directly in staging root don't accidentally pick up container
paths as artist names. Common category subfolder names (Albums,
Singles, EPs, Mixtapes, etc.) are recognized and skipped.

Fixes mixtapes and compilations where file tags have DJ names or
incorrect artists (e.g. files tagged as "Slim" in a 2Pac folder).
2026-04-20 22:56:34 -07:00
Broque Thomas
ed97fecc31 Add Discography Backfill maintenance job
New repair job that scans each artist in the library, fetches their
full discography from metadata sources, and creates findings for any
tracks not already owned. Users review findings and click "Add to
Wishlist" to queue missing tracks for download.

Respects content filters (live/remix/acoustic/instrumental/compilation)
and release type filters (album/EP/single). Opt-in, disabled by default,
runs weekly, processes up to 50 artists per run with rate limiting.
2026-04-20 22:04:38 -07:00
Broque Thomas
c3f88a713a Fix unknown_artist_fixer crash on missing deezer_track_id column
The tracks table uses 'deezer_id' not 'deezer_track_id'. The query
and source field mapping both referenced the wrong column name.
2026-04-20 16:29:38 -07:00
Broque Thomas
2249aa7594 Fix repair worker crash on zero interval_hours division
Jobs with interval_hours set to 0 caused ZeroDivisionError in
_pick_next_job staleness calculation. Now skips jobs with invalid
(zero or negative) intervals.
2026-04-20 13:46:38 -07:00
BoulderBadgeDad
c63766145e
Merge pull request #339 from kettui/refactor/get_artist_album_tracks
Refactor artist album track lookup to use source priority
2026-04-20 12:54:15 -07:00
Broque Thomas
44cb4a5403 Fix Navidrome full refresh importing albums from unselected music folders
The Subsonic getArtist endpoint doesn't support musicFolderId filtering,
so when an artist exists in multiple libraries, all their albums were
imported regardless of which music folder was selected in settings.

Now passes musicFolderId to getArtist (in case Navidrome supports it),
and as a fallback filters albums against a cached set of album IDs
built from getAlbumList2 (which reliably supports musicFolderId).
The set is built once per session and invalidated on folder change.
2026-04-20 12:52:37 -07:00
Broque Thomas
1695953705 Fix AcoustID high-confidence skip letting wrong files through
The high-confidence fingerprint skip (≥0.95) assumed title mismatches
were language/script differences and bypassed verification. But a high
fingerprint score just means AcoustID identified the audio confidently —
not that it matches the requested track. Now requires partial title
(≥0.55) or artist (≥0.60) similarity before skipping, so completely
wrong files (e.g. different song/artist from same remix producer) are
correctly rejected.
2026-04-20 12:46:01 -07:00
Antti Kettunen
24abae6908
Refactor album track lookup to use source priority
- Move album-track resolution into metadata_service
- Use the configured provider order instead of Spotify-first branching
- Switch the frontend to the unified /api/album/<id>/tracks endpoint
- Add tests for source-priority lookup, DB resolution, and formatting
2026-04-20 21:29:46 +03:00
Broque Thomas
71bff55c6a Fix iTunes album tracks failing on region-restricted releases
iTunes API can return collection metadata without song tracks for
region-restricted albums. The _lookup fallback only checked if results
was empty, so a collection-only response was accepted and cached as
{'items': []}. All future lookups returned the cached empty result.

Three fixes:
- get_album_tracks now checks for actual song items and tries fallback
  storefronts when only collection metadata is returned
- Skip cached results with empty items array (prevents stale cache hits)
- Backend returns descriptive 404 error, frontend surfaces it in toast
2026-04-20 11:10:54 -07:00
BoulderBadgeDad
8eec0c49ef
Merge pull request #337 from kettui/fix/more-artist-details-fixes
Fix artist-detail source id lookup
2026-04-20 07:26:44 -07:00
Broque Thomas
6ca3f3b070 Enable Lidarr as production-ready download source
Fixed 5 critical gaps in the download orchestrator where lidarr was
missing from client loops: get_all_downloads, get_download_status,
cancel_download fallback, clear_all_completed_downloads, and
cancel_all_downloads. Without these, lidarr downloads were invisible
to the UI, couldn't be cancelled, and accumulated in memory.

Also: error messages now visible in download list (appended to
filename on error state), removed "(Development)" label from UI.
2026-04-20 07:24:04 -07:00
Antti Kettunen
bd3b080025
Fix artist-detail source id lookup
- pass provider-specific artist ids into the source-priority discography lookup
- stop relying on the local library artist id when querying external metadata
- add a regression test for source-specific artist id resolution
2026-04-20 15:12:14 +03:00
Antti Kettunen
9fc757ce49
Fix library artist details failing to fetch correct album information
- Stop passing in spotify_id as the id in the UI, use the actual db id instead
  - Fixes an issue where albums for another artist would end up being returned for the actual searched artist
- Remove the redundant artist_id filtering code
  - Fixes an issue where not-currently-owned albums would be filtered out from the results, even if they were successfully fetched from the configured metadata provider
2026-04-20 08:14:28 +03:00
Broque Thomas
036f284ee3 Fix AcoustID retag action not writing tags to audio file
The retag fix for AcoustID mismatches was only updating the DB
record (title, artist_id) without writing corrected tags to the
actual audio file. Users would click Fix, the finding disappeared,
but the file on disk stayed unchanged. Now writes title and artist
tags to the file via Mutagen after the DB update.

Also fixed artist INSERT missing server_source when creating a new
artist during retag — now uses the active media server value.
2026-04-19 19:14:49 -07:00
JohnBaumb
a6c178a349 fix: batch N+1 queries in listening stats worker
The listening stats worker ran three N+1 query patterns on every

30-minute poll cycle:

  1. _resolve_db_track_id was called once per history event (up to

     500 events = 500 SELECTs).

  2. _map_play_counts_to_db ran one SELECT per server track ID.

  3. _enrich_stats_items ran one SELECT per top_artist, top_album,

     and top_track (typically 60 extra queries per rebuild).

All three paths now use batched IN queries with 500-row chunks

(well under SQLite's default variable limit of 999). Case-insensitive

matching and LIMIT 1 semantics are preserved via setdefault() on the

Python-side result dict.

Track resolution uses SQLite row-value IN ((?,?), ...) on

(LOWER(title), LOWER(artist_name)), available in SQLite 3.15+

(bundled with Python 3.13).
2026-04-19 15:22:25 -07:00
JohnBaumb
f4c8c231a7 fix: stop enrichment workers from re-processing rows forever
Four enrichment workers (Last.fm, MusicBrainz, Tidal, Qobuz) had a

bug where every background loop re-processed the same rows because

the existing-ID short-circuit path never set match_status, and two

workers queried the wrong column when checking for an existing ID.

lastfm_worker._get_existing_id queried a non-existent lastfm_id

column; the real column is lastfm_url. The method now reads

lastfm_url for all three entity types.

musicbrainz_worker._get_existing_id queried musicbrainz_id for all

entity types, but albums use musicbrainz_release_id and tracks use

musicbrainz_recording_id. The method now uses a per-type column map.

All four workers (lastfm, musicbrainz, tidal, qobuz) now write

match_status='matched' when they short-circuit on an already-present

external ID, so these rows are no longer re-selected on the next

worker sweep.

A new migration (_backfill_match_status_for_existing_ids) runs once

on startup to retroactively set match_status='matched' for rows that

already have an external ID but NULL match_status. This covers legacy

data, manual matches, and rows populated from file tags outside the

worker.
2026-04-19 15:22:24 -07:00
JohnBaumb
d3d648d9fd fix: batch metadata cache entity lookups
MetadataCache.get_search_results previously looped over each cached
entity ID and issued one SELECT per ID, producing N extra queries per
cached search hit. It now resolves all entities in a single batched
IN query (chunked at 500 to stay under the SQLite variable limit),
then reconstructs the result list in the original result_ids order
using an in-memory dict lookup.
2026-04-19 15:22:24 -07:00
Antti Kettunen
32e2281b9c
Refine variant release dedup
- broaden the artist-detail dedup helper to catch trailing parenthetical edition and remaster variants
- keep the legacy hyphenated suffix fallback for older metadata
- add regression coverage for language-specific Edition and remaster cases
2026-04-19 20:38:50 +03:00
Antti Kettunen
33b4ea6429
Refine artist-detail discography flow
- move artist-detail discography resolution onto the shared source-priority metadata service
- keep the variant dedup helper in the UI-facing adapter
- pass the chosen source through completion checks
- add coverage for the new adapter and dedup behavior
2026-04-19 20:38:49 +03:00
Antti Kettunen
17865fe712
Refactor artist discography completion metadata flow
Move completion checks into metadata_service and make them follow the configured metadata source priority.

Drop the old test-mode path, remove the web_server wrapper indirection, and keep artist inference on explicit release metadata instead of guessing from a track search.

Add coverage for the source-priority completion behavior and the safer artist-name handling.
2026-04-19 15:40:08 +03:00
Broque Thomas
461f28f084 Fix Spotify OAuth stealing ports in Docker on fresh installs
When no cached token exists, spotipy's auth probe starts an interactive
OAuth flow that binds 127.0.0.1:<redirect_port> inside the container.
This either steals Flask's port 8008 (crash loop) or binds loopback-only
on 8888 (unreachable from Docker host — 'connection reset by peer').

Now checks for a cached token before probing. If none exists, returns
False immediately so users authenticate via the SoulSync web UI instead.
No behavior change for already-authenticated users.

Fixes #269
2026-04-18 20:46:22 -07:00
Broque Thomas
cdcb05892a Expand default genre whitelist from 223 to 272 genres
Added Alternative, Indie, Dance (common Spotify umbrella genres),
modern genres (Phonk, Hyperpop, Cloud Rap, Emo Rap), regional pop,
additional rock/metal/electronic subgenres, world music traditions,
and media genres (Video Game Music, Anime). Intentionally excluded
mood/activity tags (Chill, Workout, Sleep) as non-genres.
2026-04-18 20:32:49 -07:00
Broque Thomas
288776a7f3 Add genre whitelist for filtering junk tags during enrichment
New core/genre_filter.py with ~180 curated default genres. When strict
mode is enabled in Settings → Library Preferences → Genre Whitelist,
only whitelisted genres pass through during enrichment. Junk tags from
Last.fm (artist names, radio shows, playlist names) are silently dropped.

Applied at all 10 genre write points: Spotify, Last.fm, AudioDB, Deezer,
Discogs, iTunes, Qobuz enrichment workers + post-processing genre merge
+ initial download artist/album creation.

Strict mode is OFF by default — zero behavior change for existing users.
First enable auto-populates the whitelist with defaults. Users can add,
remove, search, and reset genres via the Settings UI.
2026-04-18 20:23:53 -07:00
Broque Thomas
0e8e3e86a0 Fix Duplicate Detector ignoring 'allow duplicate tracks across albums'
The Duplicate Detector repair job had its own ignore_cross_album setting
that was independent of the global allow_duplicate_tracks setting. When
a user enabled 'Allow duplicate tracks across albums', the detector
still flagged same-titled tracks on different albums as duplicates.
Now respects the global setting — if duplicates are allowed, cross-album
matches are always skipped.
2026-04-18 18:55:02 -07:00
Broque Thomas
b17a6e2dd7 Add per-artist metadata source override for watchlist scans
Users can now override which metadata provider (Spotify, Deezer, Apple Music,
Discogs) is used when scanning a specific watchlist artist for new releases.
The selector appears in the artist config modal and only shows sources the
artist has enrichment IDs for. Default behavior is unchanged — all artists
use the global metadata source unless explicitly overridden.
2026-04-18 16:05:05 -07:00
Broque Thomas
f749bb9604 Fix AcoustID mismatch fix failing with 'uuid' UnboundLocalError
The redownload branch had `import json, uuid` locally inside the function,
which caused Python to treat `uuid` as a local variable for the entire
function scope. When the retag branch ran instead, `uuid` was unbound.
Both modules are already imported at the top of the file.
2026-04-18 15:06:54 -07:00
Antti Kettunen
cca396e7bd Centralize Hydrabase enablement
Move Hydrabase availability checks into metadata_service so source resolution owns the policy. Keep web_server delegating to the centralized helper and add tests for the enabled/disabled cases.
2026-04-18 18:47:54 +03:00
Antti Kettunen
2b575a59ae Refactor artist discography lookup
Move artist discography resolution into core metadata_service, introduce MetadataLookupOptions, and keep web_server focused on request handling. Add focused tests for the new service boundary and preserve current fallback behavior for now.
2026-04-18 18:41:31 +03:00
Antti Kettunen
1905678c7b Refactor get_artist_discography to respect metadata provider priority 2026-04-18 16:24:59 +03:00
Broque Thomas
4f5025d526 Add MusicBrainz search tab, wider global search, bump to v2.32
New MusicBrainz tab in Enhanced and Global search — finds tracks and
albums on MusicBrainz's community database with Cover Art Archive
images. Covers obscure tracks that Spotify/Deezer/iTunes miss.

- core/musicbrainz_search.py: search adapter with Track/Artist/Album
  dataclasses, Cover Art Archive integration, smart query parsing
- Albums deduplicated (keeps best version with date and art)
- No artist results shown (MusicBrainz has no artist images)
- Album detail with full tracklist for download modal
- Smart word-boundary splitting for queries without separators
- Global search results container widened from 620px to 920px
- UI version bumped to 2.32
2026-04-18 02:06:27 -07:00
Broque Thomas
6d5538de74 Fix single import: prefer tag data over weak metadata/AcoustID matches
Files with embedded tags (artist+title from post-processing) were
failing import because the metadata search scored low (66%) and the
AcoustID result returned before the tag-preference code could run.

- Tag-based identification now returns 85% confidence when embedded
  tags have an artist field, borrowing album art from weak metadata
- AcoustID search result only accepted at 80%+ confidence, otherwise
  kept as fallback (doesn't short-circuit past tag preference)
- AcoustID None artist/title falls back to tag data via 'or' operator
- Stop retrying failed/unidentified items every scan cycle
2026-04-17 23:18:03 -07:00
Broque Thomas
7bad4a4fa9 Stop auto-import retrying failed/unidentified items every scan cycle
Items with status needs_identification, failed, or rejected were not
in the skip list, causing them to be re-scanned and re-logged every
60 seconds indefinitely. Now skips all terminal statuses.
2026-04-17 22:52:01 -07:00
Broque Thomas
43dedeb2ee Add SoulSync standalone library — no media server required
New 'soulsync' media server option manages the library directly from
the filesystem, bypassing Plex/Jellyfin/Navidrome entirely.

Two paths populate the library:
1. Downloads/imports write artist/album/track to DB immediately at
   post-processing completion, with pre-populated enrichment IDs
   (Spotify, Deezer, MusicBrainz) so workers skip re-discovery
2. soulsync_client.py scans Transfer folder for incremental/deep scan
   via DatabaseUpdateWorker (same interface as server clients)

New files:
- core/soulsync_client.py: filesystem scanner implementing the same
  interface as Plex/Jellyfin/Navidrome clients. Recursive folder scan,
  Mutagen tag reading, artist/album/track grouping, hash-based stable
  IDs, incremental scan by modification time.

Modified:
- web_server.py: _record_soulsync_library_entry() at post-processing
  completion, client init, scan endpoint integration, status endpoint,
  web_scan_manager media_clients dict, test-connection cache updates
- config/settings.py: accept 'soulsync' in set_active_media_server,
  get_active_media_server_config, is_configured, validate_config
- core/web_scan_manager.py: add soulsync to server_client_map

Dedup: checks existing artist/album by name across ALL server sources
before inserting to avoid duplicates. Enrichment IDs only written when
the column is empty (won't overwrite existing data).
2026-04-17 20:34:55 -07:00
Broque Thomas
bbf5af1ce1 Fix auto-import rescan race condition, coverage penalty, and UI
Race condition: scanner re-scanned folders while post-processing was
still moving files, causing partial matches and ghost failures. Now
tracks in-progress paths and skips them on subsequent scans.

Coverage penalty fix: individual tracks that match at 80%+ confidence
now auto-import even when overall album coverage is low (e.g. 2 of 18
tracks present). Previously low coverage killed the entire import.

Import page: stats bar, filter pills, Scan Now, Approve All, Clear
History (clears imported + failed), live scan progress.
2026-04-17 19:37:42 -07:00
Broque Thomas
a2e3ce8000 Fix auto-import track numbers, dates, cover art, and track name display
- Track numbers defaulted to 1 instead of using metadata source values
- Release dates not captured, causing missing year in path templates
- Cover art missing for Deezer (direct image_url not checked)
- Track names in expanded view showed Unknown (wrong JSON field name)
- Read year/date from embedded file tags as fallback
- Add Deezer get_album_metadata/get_album_tracks fallbacks
- Handle Deezer tracks.data response format
2026-04-17 19:05:52 -07:00
Broque Thomas
d2c6979ce4 Recursive staging scan, singles support, and improved import UI
Auto-import now scans the staging folder recursively — any folder
structure depth works (Artist/Album/tracks, Album/tracks, etc.).
Loose audio files are treated as singles with tag/filename/AcoustID
identification.

Import results UI redesigned:
- Click cards to expand per-track match details with confidence scores
- Shows identification method badge (Tags, Folder Name, AcoustID)
- Per-track grid: track name, matched filename, confidence percentage
- Time ago labels, folder path, better status badges
- Approve/Dismiss buttons use event.stopPropagation for clean UX
2026-04-17 17:48:00 -07:00
Broque Thomas
d66adb3c6e Add single file support to auto-import worker
Loose audio files in the staging root are now picked up alongside album
folders. Singles are identified via embedded tags, filename parsing
(Artist - Title.ext), or AcoustID fingerprinting, then matched against
the configured metadata source. Confidence-gated processing applies
the same way as album folders (90%+ auto, 70-90% review, <70% manual).
2026-04-17 17:15:56 -07:00
BoulderBadgeDad
0cf5cbe9cd
Merge pull request #311 from kettui/feat/remove-desktop-app
Remove desktop app, clean up test files, remove unused dependencies
2026-04-17 12:52:10 -07:00
BoulderBadgeDad
e11c303dac
Merge pull request #308 from kettui/fix/acoustid-scanner
Fix generated IDs in repair flows & acoustid_scanner NPE
2026-04-17 12:41:03 -07:00
Broque Thomas
04b8c02ea9 Reject junk artist Soulseek results and cancel downloads on wishlist clear
Soulseek results from "Various Artists", "VA", "Unknown Artist", and
"Unknown Album" folders are now rejected before scoring. These
compilation folders rarely contain properly tagged files for the target
artist.

Clearing the wishlist now also cancels any active wishlist download
batch and resets the auto-processing flag, so downloads don't keep
running after the source tracks are removed.
2026-04-17 12:24:50 -07:00
Broque Thomas
6989701d65 Include album name in Soulseek search queries
Priority 0 query (artist + album + title) was gated behind a download
mode check that excluded Soulseek, the source that benefits most from
it. Soulseek searches match against file paths where users organize as
Artist/Album/Track — without the album name, ambiguous artist names
could match wrong-artist results (e.g. "Bleakness" as an album folder
instead of an artist). Removed the mode gate so all sources get the
most specific query first.
2026-04-17 11:12:07 -07:00
Antti Kettunen
64d9db57ea More dead code removal after desktop app removal 2026-04-17 20:06:24 +03:00
Antti Kettunen
a17e1030d3 Remove desktop app
Development has shifted fully towards the web application, so removing the desktop app so it doesn't cause any confusion in the codebase
2026-04-17 19:51:14 +03:00
Antti Kettunen
0e85931cc8 Fix track ID generation in repair flows
Repair-worker album fills now generate explicit track IDs when copying rows, instead of relying on SQLite auto-assignment that no longer exists for TEXT primary keys. The unknown-artist fixer now does the same for new artists.

Also add a regression test for the album-fill copy branch and keep the AcoustID scanner resilient to legacy null-ID rows.
2026-04-17 19:26:08 +03:00
Antti Kettunen
88e2527b96 Fix null-pointer error in acoustid_scanner
The root cause (null track ids) needs to be solved elsewhere, but this is a band-aid for now
2026-04-17 19:17:24 +03:00
BoulderBadgeDad
2539617853
Merge pull request #306 from kettui/fix/update_discovery_pool_incremental-fixes
Refactor similar artist flows and incremental discovery pool updates in watchlist scanner
2026-04-17 07:25:36 -07:00
Broque Thomas
308773ea7c Add Auto-Import — background staging folder watcher with smart matching
Full auto-import pipeline: background worker watches the staging folder,
identifies music using embedded tags → folder name parsing → AcoustID
fingerprinting, matches files to metadata source tracklists, and
processes high-confidence matches through the existing post-processing
pipeline automatically.

Worker: AutoImportWorker with start/stop/pause/resume, configurable
scan interval (default 60s), confidence threshold (default 90%), and
auto-process toggle. Processes one folder per cycle, alphabetical
order. Disc folder detection, stability checking, content hash dedup.

Confidence gate: 90%+ auto-processes silently, 70-90% queued as
pending review with approve/dismiss actions, <70% flagged for manual
identification. Track matching uses weighted algorithm (title 45%,
artist 15%, track number 30%, album tag 10%).

Database: auto_import_history table tracks every scan result with
folder hash, match data JSON, confidence, status, timestamps.

API: 7 endpoints — status, toggle, settings (GET/POST), results
(filtered/paginated), approve, reject.

UI: Auto tab on Import page with enable toggle, confidence slider,
scan interval selector. Live result cards with album art, confidence
bar (green/yellow/red), status badges, match stats. 5-second polling.
2026-04-17 06:51:08 -07:00
Antti Kettunen
7d18d4ecb2 Clarify comments 2026-04-17 10:02:00 +03:00
Antti Kettunen
eead0c3dac Clarify similar-artist freshness and backfill
Freshness is now age-only, and scan-time backfill runs separately without Spotify-auth gating or retired iTunes compatibility flags.
2026-04-17 09:53:03 +03:00
Antti Kettunen
8382b8e247 Refactor similar artist backfill
Switch similar-artist backfill to the shared provider-priority flow instead of assuming iTunes as the fallback.
Reuse the generic metadata search helpers, keep a compatibility alias for the old helper name, and update the scanner tests to cover the new path.

Add a regression test that verifies backfill walks each available fallback provider and persists the resolved IDs per source.
2026-04-17 09:33:01 +03:00
Antti Kettunen
47a6c257ad Refactor MusicMap similar artist matching
Shift similar-artist lookup to the shared metadata provider priority flow.
Use generic provider clients for search and metadata extraction instead of
branching on Spotify/iTunes-specific paths.

Add a regression test that verifies MusicMap matching queries the provider
priority list and preserves canonical metadata from the best match.
2026-04-17 09:24:05 +03:00
Antti Kettunen
7e1fc13e52 Make watchlist update_discovery_pool_incremental use provider priority
Continuation on recent changes
2026-04-17 09:08:36 +03:00
Antti Kettunen
bc83874c6f Discovery fan-out and playlists follow source priority
Make discovery pool population and curated playlists follow the configured metadata source order. Keep Spotify strict where fallback would corrupt source-specific IDs, and trim fan-out with smaller similar-artist samples and page caps. Leave the remaining incremental path for follow-up.
2026-04-17 08:49:19 +03:00
Antti Kettunen
030374c5b0 Tune discovery fan-out and caching
Reduce request volume in the discovery helpers while keeping the source-priority model intact.

- make cache_discovery_recent_albums source-priority aware
- cap Spotify artist-album pagination in the discovery and incremental paths
- reduce the similar-artist sample size for the cache-refresh helper
- keep Spotify strict where fallback would contaminate source-specific IDs
- add regression coverage for source order, strict Spotify lookups, and pagination caps
2026-04-17 08:27:36 +03:00
Antti Kettunen
6f9ea2de56 Remove redundant spotify auth check again 2026-04-17 07:30:04 +03:00
Broque Thomas
09d358ef69 Fix watchlist scan false failures, Spotify backfill, and wishlist remove
Watchlist scanner: empty discography (no new releases in lookback) was
treated as API failure, causing "Failed to get artist discography" for
artists like Kendrick Lamar who simply had no recent releases. Now
distinguishes None (API failure → try next source) from [] (success,
no new tracks). Spotify backfill now uses the authenticated client
instance instead of creating a fresh unauthenticated one.

Wishlist nebula: album remove now sends album_name (API updated to
accept album_name as fallback alongside album_id). Track remove
re-renders the nebula after deletion. Toned down processing pulse
animation.

Updated test to verify fallback triggers on API failure (None), not
on empty results.
2026-04-16 18:06:45 -07:00
Broque Thomas
258fc39364 Picard-style release preference scoring for MusicBrainz matching
Replaced track-count-only release selection with deterministic scoring
across 6 factors: track count match (40pts), release status (10pts),
country preference with US/worldwide bias (10pts), format preference
favoring Digital/CD over Vinyl/Cassette (10pts), barcode presence (3pts),
and date completeness (2pts). Same inputs always produce the same release.

Also fixed critical bug: _embed_source_ids was missing the context
parameter, silently skipping ALL source ID tag embedding since the
MusicBrainz consistency commit. Now passes context from the caller.
2026-04-16 13:29:04 -07:00
BoulderBadgeDad
61c7848121
Merge pull request #304 from kettui/fix/populate-discovery-pool-fixes
Make discovery pool population respect provider priority, reduce request volume
2026-04-16 11:57:38 -07:00
Antti Kettunen
e447cf6ab0 Reduce discovery fan-out and pagination
Make discovery pool population respect provider priority while keeping Spotify strict, and reduce unnecessary request volume in the hot discovery paths.

- keep discovery fan-out source-priority aware
- preserve cache use where freshness is not required
- cap Spotify artist-album pagination in discovery and cache refresh paths
- keep incremental release checks to a single page, since they only need the newest releases
- add regression coverage for provider order, strict Spotify handling, and pagination caps
2026-04-16 20:59:26 +03:00
Broque Thomas
223522ce99 Upgrade AcoustID scanner to scan full library with actionable fixes
Rewrote the AcoustID scanner job to scan all library tracks (via DB file
paths resolved to disk) instead of only the Transfer folder. Checkpoints
by track ID for robust resume across restarts. Defaults changed to
enabled, 24h interval, batch size 200.

Added _fix_acoustid_mismatch handler with three actions:
- retag: update DB title/artist to match actual audio content
- redownload: add expected track to wishlist and delete wrong file
- delete: remove wrong file and DB record

This catches cases like a file tagged as "Dinosaur Bones" that is
actually "Helicopters" — the scanner fingerprints the audio, detects
the mismatch, and the user can fix it from Library Maintenance findings.
2026-04-16 10:26:39 -07:00
Antti Kettunen
08ac39bc13 Fix watchlist discography lookback handling
Route get_artist_discography through the shared client helper so it uses the existing lookback logic instead of referencing an out-of-scope variable.
2026-04-16 10:09:42 +03:00
Antti Kettunen
e657a1d432 Make watchlist Spotify matching strict
Resolve Spotify artist matching through the exact Spotify client only, so watchlist ID backfill cannot drift to fallback-provider results. Remove the remaining preemptive provider availability check from the backfill loop.
2026-04-16 09:59:49 +03:00
Antti Kettunen
992be1a056 Use cache before Spotify auth checks
Allow cached Spotify search results to return even when Spotify is rate-limited or temporarily unavailable, and remove redundant rate-limit gating after auth checks.
2026-04-16 09:54:00 +03:00
Antti Kettunen
09a7465b22 Add strict fallback opt-out to Spotify client
Expose an allow_fallback flag on Spotify search and metadata methods so strict callers can avoid silently resolving through fallback providers.
2026-04-16 09:47:36 +03:00
Antti Kettunen
7b3a32ccc5 Remove dead watchlist source helpers
Drop the old active-provider artist lookup helpers from watchlist_scanner now that the web scan flow resolves sources through the shared metadata priority.

Keep the Spotify-specific feature toggles in place for discovery and sync paths that still use them.
2026-04-16 09:14:07 +03:00
Antti Kettunen
38b907097d Make watchlist scanning source-aware
Move the web watchlist scan core onto the shared metadata source priority so primary provider settings are respected during artist, album, and image resolution.

Add coverage for primary-source-first discography lookup and fallback to later providers when the primary source has no albums.
2026-04-16 09:13:15 +03:00
Antti Kettunen
9d73b8b561 Restore placeholder filtering and shared image backfill
Bring placeholder tracklist skipping back into the shared watchlist scan path, and centralize the DB-only artist image backfill helper so both web scan entrypoints reuse the same logic.
2026-04-16 08:31:04 +03:00
Antti Kettunen
40fa139804 Remove dead watchlist scan paths
Drop the legacy watchlist scan entrypoints that are no longer used by the web scan flow, and keep the live refresh path pointed at the shared scanner helper.
2026-04-16 08:27:41 +03:00
Antti Kettunen
657d86cace Consolidate web watchlist scanning
Move the shared watchlist scan loop into core/watchlist_scanner.py so web_server.py only handles triggers, locks, progress, and post-scan orchestration.

Manual and scheduled watchlist scans now share the same scanner-side core, while the web entrypoints keep profile selection and automation progress updates.
2026-04-16 08:20:48 +03:00
Broque Thomas
bf123fed63 Reject Qobuz 30-second sample/preview downloads
Two-layer detection: (1) check the Qobuz API response for sample=True
before downloading, and (2) validate actual file duration with mutagen
after download — if under 35 seconds, delete and return None. Qobuz
returns valid audio files for previews (~2-5MB FLAC) that pass the
existing 100KB size check, so duration is the reliable signal.
2026-04-15 20:41:48 -07:00
Broque Thomas
9d77c403cc Fix Spotify enrichment worker infinite loop on pre-matched artists
Artists with an existing spotify_artist_id but NULL spotify_match_status
were fetched by the priority queue every ~3 seconds. _process_artist
returned early (preserving the ID) without marking the status, so the
same artist was re-queued indefinitely — burning CPU and inflating API
call counters. Now marks the artist as 'matched' on the early-return
path.
2026-04-15 20:37:20 -07:00
BoulderBadgeDad
19a5256feb
Merge pull request #301 from kettui/fix/respect-metadata-provider-in-more-jobs
Respect the primary metadata provider in more jobs
2026-04-15 13:04:05 -07:00
Antti Kettunen
dab58766b7 Make library reorganize source-aware
Respect the configured metadata source order when looking up album years, and re-check provider availability during the scan so Spotify can drop out cleanly if it becomes rate-limited.
2026-04-15 21:35:58 +03:00
Antti Kettunen
03711c10df Make metadata gap filler source-aware
Only fetch source track details when ISRC enrichment is enabled, and show resolved source/track provenance in the UI.
2026-04-15 21:35:57 +03:00
Antti Kettunen
6df6ecb560 Respect source preference in cover art repair job
Cover art lookup now honors an explicit prefer_source first,
falls back to the runtime primary metadata source when unset,
and uses the shared source priority for the remaining fallbacks.
2026-04-15 21:28:12 +03:00
Antti Kettunen
e7faa9f02f Use metadata source priority in track number repair job
Use the shared metadata source priority when resolving album IDs,
album searches, and tracklists in track number repair.

Keeps Deezer and iTunes ahead of Spotify where configured, while
still allowing the job to fall back through other supported sources.
2026-04-15 21:28:12 +03:00
Antti Kettunen
6dca19ca1e Use metadata source priority in unknown artist fixer job
Unknown artist resolution now uses the shared metadata source priority and only filters to the sources that can actually participate in this job. Deezer and iTunes remain direct lookup sources, while Hydrabase can now join the title-search path when it is the configured priority source.
2026-04-15 21:28:10 +03:00
Broque Thomas
71a56bf9b7 Fix playlist cover art not syncing to Plex/Jellyfin from Deezer and other sources
- Pass playlist image_url to _run_sync_task from all source-specific sync
  start handlers (Deezer, Tidal, Spotify public, YouTube, automation mirror)
  — previously only the /api/sync/start endpoint passed it
- Fix plex_client.set_playlist_image: use uploadPoster(url=) instead of
  uploadPoster(data=) which is not a valid PlexAPI argument
- deezer_client: use picture_xl > picture_big > picture_medium fallback
  for better cover art resolution
- tidal_client: extract image_url in get_playlist() from JSON:API
  relationships (was only extracted in metadata-only listing)
- parse_youtube_playlist: capture playlist thumbnail from yt-dlp result
- Add visible logging for image upload attempts and outcomes
2026-04-15 10:25:38 -07:00
Broque Thomas
fe399636b2 Fix Spotify API calls leaking when Deezer/iTunes is primary source
Spotify was being called for album/artist data fetching across multiple
background workers and the Artists page search even when the user had
Deezer or iTunes set as their primary metadata source. Being authenticated
for playlist sync was treated as permission to use Spotify for everything.

- watchlist_scanner: add _spotify_is_primary_source() that checks both
  auth and primary source config; use it for all album/artist data fetching
  (discovery pool, recent album caching, playlist curation, similar artist
  ID matching, proactive ID backfill). _spotify_available_for_run() is kept
  for sync_spotify_library_cache which must run regardless of primary source
- repair_jobs/metadata_gap_filler: gate Spotify ISRC lookup on primary
  source being 'spotify'; MusicBrainz lookup unaffected
- repair_jobs/unknown_artist_fixer: replace hardcoded spotify_client with
  source-aware client selection — primary source ID tried first, each ID
  matched to its correct client (fixes latent bug passing Deezer IDs to
  Spotify)
- web_server.py /api/match/search: Artists page search was hardcoded to
  spotify_client.search_artists(); now uses _get_metadata_fallback_client()
  so results come from the configured primary source
2026-04-15 09:47:43 -07:00
BoulderBadgeDad
3618f3fa7f
Merge pull request #298 from kettui/fix/respect-metadata-provider-in-album-completeness-job
Honor primary metadata source in album_completeness job and associated repair flow
2026-04-15 07:15:15 -07:00
Broque Thomas
251c27e006 Add Last.fm Track Radio to Discover page
Adds a new Last.fm Radio section to the Discover page that lets users
search a track on Last.fm, generate a similar-tracks playlist, and run
it through the existing discovery/download/sync pipeline. Also generates
playlists automatically from top listening history during watchlist scans
(max once per week).

- core/lastfm_client.py: Add get_similar_tracks() using track.getsimilar
- core/listenbrainz_manager.py: Add save_lastfm_radio_playlist() with
  deterministic MBID (MD5 seed), cleanup limit of 5 for lastfm_radio type
- web_server.py: Add /api/lastfm/configured, /api/lastfm/search/tracks,
  /api/lastfm/radio/generate, /api/discover/listenbrainz/lastfm-radio;
  fix playlist['name'] KeyError in discovery worker that was resetting
  phase back to 'fresh' after completion
- core/watchlist_scanner.py: Add _generate_lastfm_radio_playlists() with
  weekly throttle, called at end of scan_all_watchlist_artists()
- webui/index.html: Add #lastfm-radio-section above ListenBrainz section,
  hidden unless Last.fm API key is configured
- webui/static/script.js: Search/generation/card-load functions; fix
  discovery modal labels (Last.fm Radio vs ListenBrainz), description
  update on completion, belt-and-suspenders completion handling inside
  updateYouTubeDiscoveryModal; fix album/duration display for tracks
  without metadata; music note SVG placeholder for missing art
- webui/static/style.css: Styles for search bar, dropdown, result rows
2026-04-14 23:41:36 -07:00
Antti Kettunen
106d202ccc Share metadata source priority
Centralize the ordered metadata source list and source-priority helper so album completeness and the repair worker follow the same Deezer/iTunes-first fallback order. This also removes the last duplicate priority logic from the touched repair paths.
2026-04-15 07:48:25 +03:00
Antti Kettunen
8a1ae00946 Utilize global Spotify client in repair jobs
Album completeness and any other repair job now uses the centralized source/client helpers instead of a worker-local Spotify client or override plumbing
  - This keeps source selection aligned with the configured primary provider and removes the last Spotify-only special case from the job path.

This change ultimately is a step towards further centralizing the Spotify client access and the associated `is_spotify_authenticated` check.
  - Currently these look-ups are done all over the place in different feature implementations directly, but moving forward, any feature that uses `get_primary_client` or `get_client_for_source` to access the Spotify client, won't have to duplicate any rate-limiting or auth checks as long as these getters are used
2026-04-15 07:18:34 +03:00
Broque Thomas
ce129010e1 Add ReplayGain analysis and tagging support
New core/replaygain.py module uses FFmpeg's ebur128 filter (already a
project dependency) to analyze integrated loudness and true peak, then
writes ReplayGain 2.0 tags (-18 LUFS reference) to MP3 (TXXX frames),
FLAC/OGG/Opus (Vorbis comments), and M4A/MP4 (freeform atoms).

Three analysis modes in the enhanced library view:
- Per-track RG button: synchronous single-track analysis (~1-3 s)
- Album "ReplayGain" button: background job writing both track gain
  and album gain (mean LUFS across all album tracks) to every file
- Bulk bar "ReplayGain" button: batch track-gain for selected tracks

read_file_tags() in tag_writer.py extended with four new optional keys
(replaygain_track_gain/_peak, replaygain_album_gain/_peak) so existing
RG values surface in the tag-preview diff view. Purely additive — no
existing endpoints or DB schema changed.
2026-04-14 19:09:25 -07:00
Broque Thomas
1071b2ebe5 Make OAuth callback ports configurable via environment variables
Hardcoded ports 8888/8889 conflict when SoulSync runs behind Gluetun or
other containers that claim those ports. Introduce SOULSYNC_SPOTIFY_CALLBACK_PORT
and SOULSYNC_TIDAL_CALLBACK_PORT env vars (defaulting to 8888/8889) so
users can remap without rebuilding the image.

docker-compose.yml exposes the vars with comments explaining how to keep
the port mappings in sync with the redirect URI in Settings → Connections.
2026-04-14 14:08:31 -07:00
Broque Thomas
6e405143a7 Improve Tidal download failure diagnostics and error messaging
Track per-quality-tier failure reasons across all failure paths (stream
error, empty manifest, download exception, stub file, MP4 extraction
failure) and include them in the exhausted-tiers log message so failures
are diagnosable from logs.

When HiRes is configured with no fallback and all tiers are exhausted,
log an actionable hint directing the user to enable Quality Fallback.

Surface Tidal-specific error messages in the UI task on retry
exhaustion: distinguishes HiRes-unavailable (with actionable guidance)
from general Tidal auth/quality failures, rather than showing the
generic Soulseek error string.
2026-04-14 14:07:50 -07:00
Broque Thomas
0edb8e93bb Fix MBID Mismatch Detector comparing MB title against DB title (filename) instead of embedded tag
The scan was reading `t.title` from the database for comparison, which is
populated from the filename rather than the file's embedded TITLE tag. Any
track whose filename differs from the clean MB title (e.g. includes artist,
album, bitrate) was incorrectly flagged as a mismatch.

Fix: extend `_read_mbid_from_file` → `_read_file_tags` to also read the
embedded TITLE tag (ID3 TIT2, Vorbis `title`, MP4 `©nam`). The comparison
now uses the embedded title, falling back to the DB title only when no TITLE
tag is present in the file.

Fixes #296
2026-04-14 12:19:41 -07:00
Antti Kettunen
1a459412a3 Honor primary metadata source in album_completeness job
Album completeness and downstream repair flow now follow the configured
primary provider first, with Discogs and Hydrabase support added alongside
existing Spotify, iTunes, and Deezer paths.

Keep spotify_track_id for compatibility while preserving source-aware track
IDs for provider-neutral handling.
2026-04-14 21:14:46 +03:00
Broque Thomas
3b8b369492 Add Your Albums — multi-source liked albums pool (Spotify, Tidal, Deezer)
Builds a new Your Albums section on the Discover page that aggregates
saved/liked albums from all connected services, mirroring the Your Artists
pattern. Deezer works via both OAuth and ARL.

- tidal_client: add get_favorite_albums() with V2/V1 API fallback
- deezer_client: add get_user_favorite_albums() via OAuth (user/me/albums)
- deezer_download_client: add get_user_favorite_albums() via ARL session
- music_database: add liked_albums_pool table (deduped by artist::album
  normalized key), upsert_liked_album, get_liked_albums,
  get_liked_albums_last_fetch, clear_liked_albums
- web_server: GET /api/discover/your-albums (ownership-checked, paginated),
  GET /api/discover/your-albums/sources, POST /api/discover/your-albums/refresh,
  _fetch_liked_albums background worker (Spotify + Tidal + Deezer OAuth/ARL)
- frontend: Your Albums section with source selector cog, album grid reusing
  spotify-library-card styles, search/filter/sort/pagination, download missing
  button, auto-refresh poll on first load

Also fix: Deezer greyed out in Your Artists sources when using ARL — connection
check now accepts ARL auth (deezer_dl.is_authenticated()) in addition to OAuth,
and _fetch_and_match_liked_artists falls back to ARL client for artist fetching.
2026-04-13 23:02:37 -07:00
Broque Thomas
453eb90f19 Add Deezer ARL favorite artists support
Add get_user_favorite_artists(limit=200) to DeezerDownloadClient to fetch a user's favorite artists via the public API using an ARL-authenticated session (paginated, error-handled, returns deezer_id, name, image_url).

Update web_server to treat Deezer as connected if either OAuth or ARL is authenticated, and to fetch favorite artists from OAuth client when available or from soulseek_client.deezer_dl (ARL) otherwise. Fetched artists are upserted into the database and appropriate log/console messages and counters are updated.
2026-04-13 19:46:39 -07:00
Broque Thomas
5bb36412be Fix Jellyfin playlist track fetch truncating at default API limit
get_playlist_tracks() had no Limit or StartIndex params, so Jellyfin
defaulted to ~100 items per response. This caused the Server Playlists
comparison to show most tracks as missing even though they were present.

Now paginates in batches of 1000 until a partial batch signals the last page.
2026-04-13 15:17:52 -07:00
Broque Thomas
20c8bff85c Upgrade artwork to highest available resolution for Spotify and iTunes
Spotify album art: replace the 4-char size segment after '0000ab67616d'
with '82c1' to request the original uploaded master (up to 2000px+).
Applied via _upgrade_spotify_image_url() in Track, Artist, and Album
dataclass constructors and as a catch-all in _download_cover_art.
Scoped to the ab67616d album art prefix only — artist images use a
different prefix (ab676161) where the trick does not apply.

iTunes/Apple Music: replace '100x100bb' with '3000x3000bb' in all
artworkUrl100 replacements across Track, Artist, Album, and the
get_album images arrays. Also applied as a catch-all in _download_cover_art.

Deezer already uses cover_xl at its maximum — no changes needed there.
2026-04-13 15:04:37 -07:00
Broque Thomas
352ad5ff68 Fix AcoustID test connection falsely failing for valid API keys
The fallback test (used when no audio files exist in the library) sends
a dummy fingerprint to the AcoustID API. The API correctly rejects the
dummy fingerprint but this is not error code 4 (invalid key), so the
test was returning False instead of True. Any non-code-4 error from the
fallback means the API key was accepted — only code 4 means a bad key.
2026-04-13 09:37:50 -07:00
Broque Thomas
0edd8f5c81 Raise artist discography limit from 50 to 200 with Deezer pagination
Deezer and iTunes defaulted to 50 albums max, silently truncating large
discographies. Deezer now paginates (100 per page) up to 200. iTunes
raised to 200 (single call). All callers in web_server.py updated to
use the new defaults instead of hardcoding limit=50.

Also adds diagnostic logging for allow_duplicates album comparison
to help debug inconsistent singles behavior.
2026-04-12 21:46:04 -07:00
Broque Thomas
3a7c25f20f Fix allow_duplicates not working for singles in watchlist scanner
Singles like "idol" weren't added when the same song existed on a
different album because check_track_exists used album-aware matching
that found the track via album name. Now skips the album hint when
allow_duplicates is on so matching is title+artist only, then compares
album names ourselves with a strict 0.85 threshold. Only affects users
with allow_duplicates enabled.
2026-04-12 17:41:55 -07:00
Broque Thomas
e65f73abe2 Fix allow_duplicates setting not working in watchlist scanner
The setting only affected wishlist dedup but the watchlist scanner's
library check still skipped tracks by title+artist regardless. Now
when allow_duplicates is enabled, the scanner compares album names
and only skips if the same album matches. Same song on a different
album is allowed through to the wishlist.
2026-04-12 15:41:55 -07:00
BoulderBadgeDad
4f25cf4661
Merge pull request #286 from kettui/fix/improve-graceful-shutdown
Improve shutdown procedures so that the application can close gracefully
2026-04-12 12:42:00 -07:00
Broque Thomas
445cb242f2 Use Deezer contributors field for multi-artist track tagging
Deezer's API returns a contributors array with all credited artists on
a track, but only the primary artist field was used. Now extracts all
contributor names into the artists array for feature/collab tracks.

Only affects the per-track ARTIST tag (TPE1) — album artist, folder
paths, and matching are unchanged. Falls back to single primary artist
when contributors field is absent (search results) or has only one
entry.
2026-04-12 11:52:06 -07:00
Broque Thomas
a7877e6e0b Skip albums with placeholder track names in watchlist scanner
Spotify lists unreleased albums with placeholder names like "Track 1",
"Track 2" before the real tracklist is revealed. The scanner was trying
to download these, searching Soulseek for "Track 1" by artist which
matches random files. Now skips any album where more than half the
tracks match the placeholder pattern. Covers both the watchlist scan
and discovery pool paths.
2026-04-12 10:55:06 -07:00
Broque Thomas
027549be59 Fix album matching using full similarity instead of word subset check
The old subset check treated "Paradise" as matching "Club Paradise"
because {'paradise'} is a subset of {'club', 'paradise'}. Both got
the same +0.10 bonus, so the wrong album could be selected.

Now uses SequenceMatcher for full-string similarity between the wanted
album name and each path segment. Exact matches (>= 0.85) get +0.10,
partial matches (>= 0.60) get +0.03, no match gets +0.00. No penalty
applied — purely adjusts bonus sizing so the correct album ranks higher.
2026-04-12 10:48:24 -07:00
Broque Thomas
1c1be6190a Fix Plex playlists crash on Tag objects and add Unknown Artist guard
Plex API can return Tag objects mixed with playlists — these lack the
playlistType attribute, causing AttributeError. Use getattr with safe
default instead of direct attribute access.

Add 3-tier Unknown Artist guard in post-processing: checks track_info
artists, original search result, then re-fetches from metadata API
before building folder paths or embedding tags. Prevents files from
landing in Unknown Artist folders when the download context has
incomplete artist data.
2026-04-12 10:43:51 -07:00
Broque Thomas
d77274c2ea Reject tracks with Unknown Artist from metadata cache
The junk entity filter checked track name but not artist_name, allowing
tracks like "Woman Like You by Unknown Artist" to be cached. Now rejects
any track or album where artist_name matches the junk names list
(unknown, unknown artist, empty, null, etc). Prevents stale incomplete
data from persisting across retries.
2026-04-12 09:27:17 -07:00
Antti Kettunen
29d964e8b0 Tighten up teardown logic
- Stop active DB update work before tearing down executor pools.
- Short-circuit scan completion callbacks during shutdown so in-flight timer ticks don’t queue follow-up work.
- Prevent the download monitor from draining deferred/completed tasks after shutdown starts.
- Make listening stats startup stop-aware so it exits cleanly if teardown begins during warmup.
- If a metadata update is already running, it can now observe should_stop and exit cleanly instead of continuing after SIGTERM.
2026-04-12 18:13:27 +03:00
Antti Kettunen
aec3047216 Improve graceful shutdown and rollback safety
- Add interruptible stop events to background workers so shutdown
  wakes out of long sleeps instead of waiting on fixed delays.
- Stop scan managers, repair worker, executors, and cleanup helpers
  deterministically so process exit does not leave background threads
  alive.
- Add startup warnings for stale SQLite WAL/SHM sidecars so unclean
  shutdowns are easier to spot before init/migration errors cascade.
- Prevent forced kills from leaving SQLite sidecars behind, which
  made rollbacks to older branches fail with malformed database
  errors.
2026-04-12 15:17:18 +03:00
Broque Thomas
08de91685d Add first-run setup wizard and fix download path reloading
7-step full-screen wizard: Welcome, Metadata Source, Download Source,
Paths & Media Server, Add Artists, First Download, Done. All settings
save to DB identically to the Settings page. Supports all 6 download
sources with inline config and test buttons. First download goes through
the full matched download pipeline with metadata context.

Fixes:
- Download clients (YouTube/HiFi/Tidal/Qobuz/Deezer) now reload
  download_path when settings change instead of caching from init
- watchlist_artists table migrations now include deezer_artist_id and
  discogs_artist_id in all 3 table rebuild locations (was being dropped)
- CREATE TABLE for watchlist_artists includes all provider ID columns
- Serverless download sources (YouTube/HiFi/Qobuz) show green status
  instead of red disconnected on sidebar and dashboard
- Suppress repeated slskd 401 errors — logs once then silences until
  connection recovers
2026-04-11 22:45:16 -07:00
Broque Thomas
71e4df65e3 Remove emojis from all Python log and print statements
Stripped 4,200+ emoji characters from print(), logger calls across
39 Python files. Logs are now clean text — easier to grep, more
professional, no encoding issues on terminals without Unicode support.

Seasonal config icons preserved for UI display.
2026-04-11 21:11:02 -07:00
Broque Thomas
a3ab5adcba Backfill MusicBrainz recording ID from Navidrome during database scan
Navidrome provides musicBrainzId on tracks — now captured during
database updates so the MusicBrainz enrichment worker can skip
tracks that already have an MBID.

Uses COALESCE on UPDATE to never overwrite existing enrichment data
with NULL (safe for Plex/Jellyfin which don't provide this field).

Inspired by PR #279 — fixed data loss bug in the original where
unconditional UPDATE would erase existing MBIDs.
2026-04-11 18:47:54 -07:00
Broque Thomas
7fb4cf6b0e Use per-track artist in tag writer for compilations and DJ mixes
Tag preview and writer now use track_artist (per-track artist) for the
Artist tag when available, falling back to artist_name (album artist)
when NULL. Album Artist tag always uses artist_name.

Fixes #277 — DJ mix albums no longer overwrite per-track artists
(Technique, Gouryella) with the album artist (Tiësto).
2026-04-11 18:31:11 -07:00
Broque Thomas
805f72c5fd Add acappella to live/commentary cleaner patterns 2026-04-11 13:56:56 -07:00
BoulderBadgeDad
8977120ba8
Merge pull request #274 from kettui/feat/metadata-client-caching
Add caching for metadata clients
2026-04-11 13:07:54 -07:00
BoulderBadgeDad
6d0ffae5fb
Merge pull request #275 from kettui/fix/spotify-ratelimited-search
Add missing rate-limit handling for Spotify search requests
2026-04-11 11:26:45 -07:00
BoulderBadgeDad
2ecc434c7a
Merge pull request #276 from kettui/feat/misc-cleanup
Reduce redundant logging during watchlist scan, initialize db once at startup
2026-04-11 11:10:38 -07:00
Broque Thomas
6b2352411b Add Lidarr to all remaining source lists in orchestrator and web_server 2026-04-11 08:05:54 -07:00
Broque Thomas
fc38ec4787 Add Lidarr as 7th download source and validate music video path
Lidarr integration:
- New core/lidarr_download_client.py with full interface parity
  (search, download, status, cancel — same as Qobuz/Tidal/HiFi)
- Registered in download orchestrator with source routing
- Settings: URL + API key on Downloads tab with connection test
- Available as standalone source or in Hybrid mode priority order
- API key encrypted at rest
- All streaming source checks updated to include 'lidarr'

Lidarr downloads full albums via Usenet/torrent — SoulSync imports
only the tracks it needs and discards the rest.

Music video path validation:
- Empty/unconfigured path returns clear error instead of silent failure
- Write permission test before starting download
- Default changed from './MusicVideos' to empty (must be configured)
2026-04-11 07:59:12 -07:00
Antti Kettunen
4946ff0d03 Remove redundant repetition of lookback period change during watchlist scan
Unnecessary noise on the logs
2026-04-11 14:01:52 +03:00
Antti Kettunen
fd6335a66e Add / improve metadata client caching
Clients are for the most part being initialized per-request, which leads to a lot of redundant client initialization, as well as noise on the logs, since each client initialization emits a row on the logs, eg. 'Deezer client initialized'
2026-04-11 13:55:10 +03:00
Antti Kettunen
1b979193eb Skip Spotify requests for the rest of the watchlist scan if rate-limited
State is stored per-scan
2026-04-11 13:41:05 +03:00
Antti Kettunen
36dbb3357e Add rate-limit handling for Spotify searches 2026-04-11 13:14:50 +03:00
Broque Thomas
54b7a0f0e8 Add music video download with progress and metadata matching
Click any video card in Music Videos tab to download. Flow:
1. Search primary metadata source for clean artist/title
2. Fall back to YouTube title parsing if no match
3. Download video via yt-dlp (best quality MP4)
4. Save to configured Music Videos folder as Artist/Title-video.mp4

UI shows circular progress ring on the thumbnail during download,
green checkmark on completion, red X on error (clickable to retry).
Cards are non-interactive while downloading.

Backend: /api/music-video/download and /api/music-video/status endpoints
YouTube client: download_music_video() method keeps video format
2026-04-10 23:14:20 -07:00
Broque Thomas
b44bb34b44 Add Music Videos search tab to enhanced and global search
New "Music Videos" pill tab alongside Spotify/Deezer/iTunes/Discogs
in both enhanced search and global search. Searches YouTube via yt-dlp
and displays results in a video card grid with 16:9 thumbnails, play
overlay, duration badge, channel name, and view count.

- Backend: /api/enhanced-search/source/youtube_videos endpoint with
  search_videos() method on YouTubeClient returning YouTubeSearchResult
- Frontend: Video grid layout with responsive cards, YouTube red tab
  color, proper section hiding when switching between metadata and
  video tabs
- Global search: Full parity with enhanced search video rendering
- No download functionality yet — display only
2026-04-10 23:07:14 -07:00
Broque Thomas
5be6a46fb0 Fix dismissed findings reappearing and reduce false orphan detections
The finding dedup check only looked for 'pending' and 'resolved' status,
missing 'dismissed'. Dismissed findings were recreated as new entries on
every scan. Now includes 'dismissed' in the dedup check.

Orphan file detector improvements:
- Increased path suffix matching depth from 3 to 4 segments (covers
  Genre/Artist/Album/track.flac paths)
- Added filename-based fallback when Mutagen can't read file tags —
  parses title from "NN - Title [Quality].ext" pattern and matches
  against parent/grandparent folder names as artist
2026-04-10 17:42:39 -07:00
Broque Thomas
603d66ba5d Add artist gate and fix substring matching in matching engine
Prevents downloading tracks from completely wrong artists by adding
minimum artist score gates:
- Soulseek: artist_score < 0.25 → reject (catches Belvedere vs Periphery)
- YouTube: artist_score < 0.15 → reject (catches lizzylou06 vs Muse)

Fixes artist substring matching to use word boundaries instead of plain
containment — "muse" no longer matches "museum", "art" no longer matches
"heart". This was causing false positives where wrong artists passed with
artist_score=1.0 due to accidental substring containment.

Improves similarity fallback by comparing against individual path segments
instead of the full filename, so misspelled artist names (Radiohedd vs
Radiohead) still match correctly.

Adjusts YouTube weights from Title 70%/Artist 10% to Title 60%/Artist 20%
to give artist more influence in YouTube matching.

Addresses user reports of unreleased albums being downloaded with garbage
content from wrong artists on Soulseek and YouTube.
2026-04-10 13:45:07 -07:00
Broque Thomas
a6117d5174 Replace all legacy metadata_service imports with canonical functions
All callers of _create_fallback_client() and _get_configured_fallback_source()
now use get_primary_client() and get_primary_source() directly. No more
legacy alias usage anywhere in the codebase.
2026-04-10 12:52:36 -07:00
Broque Thomas
10a2766557 Fix remaining Spotify-first source selection in seasonal discovery and search API
Seasonal discovery had 3 use_spotify checks using is_authenticated()
(always True) instead of deriving from the configured source. Search API
(tracks, albums, artists) also defaulted to Spotify when authenticated.

All now check configured primary source first via get_primary_source().
2026-04-10 12:47:16 -07:00
Broque Thomas
498c22e7c3 Centralize metadata source selection in core/metadata_service.py
All metadata source decisions now flow through get_primary_source() and
get_primary_client() in core/metadata_service.py. Previously 6 different
files reimplemented this logic with inconsistent defaults ('itunes' vs
'deezer') and auth checks, causing bugs when any one was missed.

Changes:
- metadata_service.py: Added canonical get_primary_source/get_primary_client
- web_server.py: _get_metadata_fallback_source() and _get_active_discovery_source()
  are now thin wrappers delegating to metadata_service
- seasonal_discovery.py: _get_source() delegates to metadata_service
- personalized_playlists.py: _get_active_source() delegates to metadata_service
- spotify_client.py: Fixed _fallback_source default from 'itunes' to 'deezer'
- watchlist_scanner.py: _get_fallback_metadata_client() delegates to metadata_service

Future changes to source selection only need to update one file.
2026-04-10 12:34:25 -07:00
Broque Thomas
52a5d93018 Add Fix Unknown Artists maintenance job
New repair job that scans the library for tracks filed under "Unknown
Artist" and corrects them. Resolves correct metadata by:
1. Reading embedded file tags (if file has correct artist)
2. Looking up by source track ID (Spotify/Deezer/iTunes)
3. Searching by title as last resort

Dry run mode (default) creates findings for review. Live mode re-tags
the audio file, moves it to the correct folder structure, and updates
the database. Includes fix handler for applying individual findings.
2026-04-10 12:17:11 -07:00
Broque Thomas
57fc18f994 Respect configured primary source in seasonal, playlists, and explorer
Seasonal discovery, personalized playlists, and playlist explorer all
defaulted to Spotify when authenticated, ignoring the user's configured
primary source. Now they read from config first.

Spotify's related_artists API (no Deezer/iTunes equivalent) is preserved
as a fallback for all users in personalized playlists. Artist discography
endpoint intentionally unchanged — ID-based lookups need the source that
owns the ID.
2026-04-10 11:43:06 -07:00
Broque Thomas
bbaa897cd2 Fix Deezer metadata cache storing incomplete album and track data
Deezer's /artist/{id}/albums endpoint returns albums without an artist
field, causing 98% of cached Deezer albums to have empty artist_name.
Now injects the known artist before caching.

Also fixes get_track_details cache validation — was trusting search
result cache (which has isrc but no track_position), returning
track_number=0. Now only trusts cache entries with track_position.
2026-04-10 10:43:45 -07:00
Broque Thomas
dd5291456b Fix playlist pipeline discovery data loss and Unknown Artist bug
Discovery workers now respect the user's configured primary metadata
source instead of always using Spotify when authenticated. This
completes the intent of commit 3c211ea.

The core fix addresses data loss in the discovery→sync→wishlist→download
pipeline: the Track dataclass strips album metadata to a plain string,
losing album ID, track_number, release_date, and images. Discovery
workers now enrich results via get_track_details() to recover this data.
Deezer's get_track_details() cache validation was incorrectly trusting
search-result cache (which lacks track_position), returning track_number=0.

Also fixes wishlist download processing where albums without IDs couldn't
map to artists, and the fallback read 'artist' (singular) instead of
'artists' (plural), always producing "Unknown Artist".

Includes a one-time migration to purge stale discovery cache entries.
2026-04-10 10:23:43 -07:00
Broque Thomas
d9e2e129b0 Allow selecting which duplicate to keep in duplicate detector
Duplicate finding detail now shows each version as clickable — user
can choose which to keep instead of relying on auto-selection. Added
track_number as tiebreaker in auto-pick (higher track number wins
over 01, catching leftover duplicates from the playlist sync track
number bug). Track number displayed in the detail view for clarity.
2026-04-09 18:46:49 -07:00
Broque Thomas
959bca2b8d Add Run Script action to automation engine
New automation action that executes user scripts from a dedicated
scripts/ directory. Available as both a DO action and THEN action.
Scripts are selected from a dropdown populated by /api/scripts.

Security: only scripts in the scripts dir can run, path traversal
blocked, no shell=True, stdout/stderr capped, configurable timeout
(max 300s). Scripts receive SOULSYNC_EVENT, SOULSYNC_AUTOMATION,
and SOULSYNC_SCRIPTS_DIR environment variables.

Includes Dockerfile + docker-compose.yml changes for the scripts
volume mount, and three example scripts (hello_world.sh,
system_info.py, notify_ntfy.sh).
2026-04-09 18:20:29 -07:00
Broque Thomas
7cfd1cae3f Fix AcoustID scanner creating thousands of no-match findings
The scanner was creating a finding for every file that couldn't be
identified by AcoustID, flooding the findings list with non-actionable
entries. Users saw the scanner "stuck scanning the same files over
and over" because the no-match findings were dismissed but recreated
on every run. Now only genuine mismatches (AcoustID identifies a
different track) create findings. Errors are counted and shown in
the job log with actual error messages for debugging.
2026-04-09 10:16:53 -07:00
Broque Thomas
963a003ca0 Set playlist poster image on Plex/Jellyfin/Emby after sync
After a successful playlist sync, if the source playlist has cover
art (Spotify, Tidal, Deezer, etc.), the image is downloaded and
uploaded as the playlist poster on the media server. Plex uses
uploadPoster(), Jellyfin/Emby uses POST /Items/{id}/Images/Primary.
Navidrome skipped (no playlist image API). Failure is silent — sync
result unchanged. Automation-triggered syncs and playlists without
images are unaffected.
2026-04-09 10:08:18 -07:00
Broque Thomas
4178c1eb56 Fix Deezer ARL sync/download rehydration and add album data caching
Sync rehydration: after loading Deezer ARL playlists, checks each
for active syncs via /api/sync/status and re-attaches polling with
live card updates. Download rehydration: rehydrateModal now handles
deezer_arl_ playlist IDs, and openDownloadMissingModal routes cache
misses to the correct ARL endpoint. Fix All now prompts for dead
file action.

Album data caching: get_playlist_tracks now checks the metadata
cache before fetching album release dates from the Deezer API.
Cache hits are instant, misses are fetched and stored for future
use across all playlists. Import fixed from core.metadata_cache
instead of web_server to avoid circular dependency.
2026-04-09 09:38:13 -07:00
Broque Thomas
37d325ee10 Add "Remove from DB" option for dead file findings
Dead file fix now prompts with two options: Re-download (existing
behavior — adds to wishlist + deletes DB entry) or Remove from DB
(just deletes the dead track record without re-downloading). Works
for both single and bulk fix. Solves the issue where dismissing
dead files didn't remove the underlying track record, causing them
to reappear on every scan.
2026-04-08 16:19:48 -07:00
Broque Thomas
ec87cb6d0e Skip zero-track albums in album completeness scanner
Albums with zero local tracks were flagged as incomplete but the
auto-fill fix failed because there were no existing tracks to
determine the album folder or quality standard from. Now skipped
during scan — they'll be detected once tracks are actually added.
2026-04-08 11:49:30 -07:00
Broque Thomas
3d96752087 Add Qobuz auth token login as CAPTCHA bypass alternative
Qobuz added reCAPTCHA to their login endpoint, blocking automated
email/password auth for new users. Token login lets users paste
their X-User-Auth-Token from the browser DevTools after logging in
manually. Added to both Connections and Downloads tabs with
instructions. Existing email/password flow completely unchanged.
Backend validates token via user/get API and saves the session
identically to email/password login.
2026-04-08 10:37:38 -07:00
Broque Thomas
06e32d84c3 Skip future/unreleased albums in watchlist scanner
Albums announced but not yet released have no real audio available,
causing Soulseek to match random tracks with similar names. Both
discography methods (Spotify and generic client) now filter out
albums with release dates in the future. Skipped albums are not
marked as processed — they will be picked up on the first scan
after their release date passes.
2026-04-07 13:31:15 -07:00
Broque Thomas
08a7408d8b Fix playlist sync failing on Emby due to integer ID validation
The _is_valid_guid method only accepted 32-char hex GUIDs (Jellyfin
format) but Emby uses plain integer IDs like "12345". All matched
tracks were rejected as "invalid/empty IDs" causing playlist creation
to fail with zero tracks. Now accepts both numeric strings (Emby)
and hex GUIDs (Jellyfin).
2026-04-07 13:12:51 -07:00
Broque Thomas
31518a3ef3 Add Deezer user playlists tab via ARL authentication
New "Deezer" tab on sync page shows authenticated user's playlists,
identical to the Spotify tab pattern — same card layout, details
modal with track list, sync button, and download missing tracks
flow. Existing URL import renamed to "Deezer Link" tab (unchanged).

Backend: get_user_playlists() and get_playlist_tracks() on the
download client fetch via public API with ARL session cookies.
Album release dates batch-fetched for $year template variable.
Three new endpoints: arl-status, arl-playlists, arl-playlist/<id>.

Frontend: cards use Spotify-identical HTML structure with live sync
status, progress indicators, and View Progress/Results buttons.
Downloads reuse openDownloadMissingModal with zero modifications.
Track data cached on first open, instant on subsequent clicks.
2026-04-07 12:47:15 -07:00
Broque Thomas
06defcfa3d Fix streaming source matching and global search download bubbles
Streaming matching: add artist gate rejecting candidates with artist
similarity below 0.4, raise threshold to 0.60, block fallback to
Soulseek filename matcher for Tidal/Qobuz/HiFi/Deezer. Fix single-
char artist containment bug where normalize_string strips non-ASCII
(e.g. "B小町" → "b") causing "b" to match any artist containing
that letter. Fixed in both score_track_match and the Soulseek scorer.
YouTube and Soulseek matching behavior unchanged.

Global search: add registerSearchDownload() calls to _gsClickAlbum
and _gsClickTrack so downloads create bubble snapshots on dashboard
and search page, matching the enhanced search standard.

Global search escaping: add _escAttr() helper to handle newlines in
album/artist names that broke inline onclick string literals.
2026-04-06 22:15:21 -07:00
Broque Thomas
1fb66b711d Fix Live/Commentary Cleaner missing _get_settings method 2026-04-06 07:45:07 -07:00
Broque Thomas
455945e6cb Fix duplicate detector missing cross-album duplicates (#252)
Changed ignore_cross_album default from True to False. Re-downloads of
the same song create separate album entries, so the detector was skipping
them. Users who want to keep compilations/greatest-hits intact can toggle
it back on. Updated help text to explain when to use this setting.
2026-04-05 18:48:03 -07:00
Broque Thomas
e674a79c88 Persist API call history, record rate limit events, fix Spotify re-auth issues
API Call Tracker:
- Save/load 24h minute-bucketed history + events to database/api_call_history.json
- Persists across server restarts via atexit + signal handler hooks
- New record_event() for rate limit bans (called from _set_global_rate_limit)
- New get_debug_summary() for Copy Debug Info — 24h totals, peak cpm with
  timestamp, per-endpoint breakdown, and last 20 rate limit events
- Fixed race condition: events iteration now inside lock during save

Spotify Rate Limit Mitigation:
- Enrichment worker: max_pages=5 on get_artist_albums (was unlimited — artist
  with 217 albums caused 22 paginated API calls, now capped at 5)
- Enrichment worker: inter_item_sleep raised from 0.5s to 1.5s

Spotify Re-Auth Fix:
- Both OAuth callbacks (port 8008 + 8888) now clear rate limit ban AND
  post-ban cooldown after successful re-auth — Spotify usable immediately
  instead of stuck on Deezer fallback for 5 minutes
- Auth cache invalidated on both global client and enrichment worker client
2026-04-05 12:36:58 -07:00
Broque Thomas
c3a3510c75 Add Live/Commentary Cleaner library maintenance job
New repair job that scans track and album titles for live performances,
commentary, interviews, skits, and spoken word content. Creates findings
for user review — no auto-fix.

Configurable per content type (live, commentary, interviews, spoken word),
with optional album title scanning and tracks/albums scope toggle.
Fix action removes track from DB + deletes file, cleans up empty albums
and directories. Follows existing repair job pattern exactly.
2026-04-05 00:27:55 -07:00
Broque Thomas
d34924e238 Apply same match validation to streaming download sources as Soulseek
Tidal, Qobuz, HiFi, and Deezer results were blindly taking the first
API result with minimal validation. Now all streaming sources use
score_track_match() — same 60% title / 30% artist / 10% duration
weighting as Soulseek, plus version detection penalties.

- web_server.py get_valid_candidates(): replaced loose title-sim check
  with matching engine scoring, version penalty for live/remix/acoustic
- download_orchestrator.py: optional expected_track param enables
  scoring in search_and_download_best (backward compatible)
- sync_service.py: passes spotify_track for validation
- Fixed wrong class name (MusicMatchingEngine not MatchingEngine)
2026-04-04 22:01:29 -07:00
Broque Thomas
58d8e830c6 Your Artists on Discover + Deezer OAuth + MB Lookups Manager + Explorer improvements + bug fixes
YOUR ARTISTS (major feature):
- Aggregates liked/followed artists from Spotify, Tidal, Last.fm, Deezer
- Matches to ALL metadata sources (Spotify, iTunes, Deezer, Discogs)
- DB-first matching: library → watchlist → cache → API search (capped)
- Image backfill from Spotify API for artists missing artwork
- Carousel on Discover page with 20 random matched artists
- View All modal with search, source filters, sort, pagination
- Artist info modal: hero image, matched source badges, genres, bio,
  listeners/plays from Last.fm, watchlist toggle, view discography
- Auto-refresh with loading state on first load, polls until ready
- Deduplication by normalized name across all services

DEEZER OAUTH:
- Full OAuth flow: /auth/deezer + /deezer/callback
- Settings UI on Connections tab (App ID, Secret, Redirect URI)
- Token stored encrypted, auto-included in API calls
- get_user_favorite_artists() for liked artists pool

SERVICE CLIENTS:
- Spotify: added user-follow-read scope + get_followed_artists()
- Tidal: get_favorite_artists() with V2/V1 fallback
- Last.fm: get_authenticated_username() + get_user_top_artists()

FAILED MB LOOKUPS MANAGER:
- Manage button on Cache Health modal
- Browse/filter/search all failed MusicBrainz lookups
- Search MusicBrainz directly and manually match entries
- Optimized cache health queries (11 → 4 consolidated)
- Dashboard cache stats now poll every 15s

EXPLORER IMPROVEMENTS:
- Discover button on undiscovered playlist cards
- Status badges: explored/wishlisted/downloaded/ready
- Auto-refresh during discovery via polling
- Redesigned controls: prominent Explore button, icons

BUG FIXES:
- Fix album artist splitting on collab albums (collab mode fed
  album-level artists instead of per-track)
- Fix cover.jpg not moving during library reorganize (post-pass sweep)
- Fix cover.jpg missing when album detection takes fallback path
- Fix wishlist auto-processing toast spam (was firing every 2s)
- Fix media player collapsing on short viewports
- Fix watchlist rate limiting (~90% fewer API calls)
- Configurable spotify.min_api_interval setting
- Better Retry-After header extraction
- Encrypt Last.fm and Discogs credentials at rest
- Add $discnum template variable (unpadded disc number)
2026-04-03 22:39:05 -07:00
Broque Thomas
93fb082172 Add Failed MB Lookups manager + optimize cache performance
New feature: Failed MusicBrainz Lookups management modal accessible
from Cache Health. Browse all failed lookups with type filter tabs,
search bar, pagination. Click any entry to search MusicBrainz and
manually match — saves MBID at 100% confidence. Clear individual
entries or bulk clear all.

Backend: 4 new endpoints — failed-mb-lookups list, mb-entry delete,
musicbrainz/search (artist/release/recording), mb-match save.

Performance: Cache health stats consolidated from 11 queries to 4
using CASE expressions. Added partial index on musicbrainz_cache for
failed lookups. Dashboard cache stats now poll every 15s instead of
single fire-and-forget fetch. Failed MB type counts cached on frontend,
only re-fetched after mutations.

Also includes: library reorganize now moves cover.jpg via post-pass
sidecar sweep, and changelog updates.
2026-04-03 11:38:50 -07:00
Broque Thomas
4e4f258d25 Reduce watchlist Spotify API calls ~90% + configurable rate interval
Addresses all three points from community rate-limiting report:

1. Watchlist scans fetched ALL albums then filtered — 262 albums = 27
   API calls per artist. Now determines upfront if full discography is
   needed: subsequent scans and time-bounded lookbacks use max_pages=1
   (1 API call). Only "full discography" global setting fetches all.

2. MIN_API_INTERVAL (350ms) now configurable via spotify.min_api_interval
   setting. Users who get rate-limited frequently can increase the delay.
   Floor at 100ms to prevent abuse.

3. Retry-After header extraction improved: added diagnostic logging when
   headers exist but lack Retry-After key, plus regex fallback to parse
   the value from the error message string.
2026-04-03 10:24:44 -07:00
Broque Thomas
b1f0a459c4 Fix all watchlist Discogs gaps + revert broken image upscaling
- Add discogs_artist_id to ALL watchlist WHERE clauses + bind params
- Fix artistPrimaryId + View Discography for Discogs-only artists
- Fix openWatchlistArtistDetailView destructuring
- Discogs source/provider badges + CSS + image fetch + ID resolution
- Revert thumb upscaling — Discogs blocks hotlinked higher-res URLs
  (403 Forbidden), thumbnails are a CDN limitation
2026-04-02 21:41:30 -07:00
Broque Thomas
82f9b84e5b Add Discogs to watchlist — column, backfill, matching
- Add discogs_artist_id column to watchlist_artists table (migration)
- Add discogs_artist_id to WatchlistArtist dataclass
- Add to get_watchlist_artists optional_columns and constructor
- Add update_watchlist_discogs_id DB method
- Backfill loop includes Discogs when token is configured
- Add _match_to_discogs for cross-provider artist matching
- Backfill maps updated: id_attr, match_fn, update_fn all include discogs
2026-04-02 20:59:34 -07:00
Broque Thomas
240dd87727 Fix Discogs cache — add field extractor, wire worker caching, browser UI
- Add _extract_discogs_fields to metadata cache — handles Discogs field
  names (title vs name, images array, Artist - Title format)
- Worker uses _fetch_and_cache_artist/_fetch_and_cache_album helpers
  that cache raw data while returning it for enrichment
- All search/lookup methods cache results for repeat queries
- Cache browser: Discogs stat pill, source filter, clear button, badge
- Fixes albums showing as 'Unknown' and artists missing images in cache
2026-04-02 19:47:36 -07:00
Broque Thomas
69a09826f4 Fix Discogs album lookups — try master ID before release ID
- get_album and get_album_tracks now try /masters/{id} first, fall back
  to /releases/{id} — artist discography returns master IDs which are
  in a different namespace than release IDs
- Fixes wrong album showing in download modal (master ID 3664443 for
  GNX was hitting /releases/3664443 which is a different album)
- Add Discogs source override to all 6 artist/album/track endpoints
- Add discogs_id to _resolve_db_album_id lookup
2026-04-02 18:35:20 -07:00
Broque Thomas
d98d3b33c4 Fix Discogs artist page load time — lazy track count loading
- Remove upfront master detail fetching (was 15+ API calls, 40+ seconds)
- Discography loads from releases list only (~1 second, 2 API calls)
- Track counts populate on-demand via get_album_tracks when clicking album
- New get_album_tracks method: tries /masters first, falls back to /releases,
  returns Spotify-compatible format with proper disc/track numbering
- Album type defaults to 'album' for masters without format metadata —
  Discogs limitation, singles only detectable from individual release format
2026-04-02 18:22:02 -07:00
Broque Thomas
39d11602ce Fix Discogs format field parsing — handle list or string type
- Search results return format as list ['Vinyl', 'LP'] while artist
  releases return comma-separated string — handle both
- Fixes "'list' object has no attribute 'lower'" error
2026-04-02 18:09:11 -07:00
Broque Thomas
f8b774e22d Fetch master release details for accurate track counts and album types
- Master releases now fetch /masters/{id} to get actual tracklist,
  genres, styles, and images — fixes 0/0 track count display
- Album type re-evaluated with real track count: 1-3 = single,
  4-6 = EP, 7+ = album
- Cover art from master detail used when search results have none
- Tested: Kendrick Lamar shows correct track counts, proper types,
  and images for all albums
2026-04-02 18:08:17 -07:00
Broque Thomas
92befdb029 Filter featured releases from Discogs artist discography
- Fetch artist name first, then compare against each release's primary
  artist — skip releases where the artist is listed after Feat./Ft./&
- "Beyoncé Feat. Kendrick Lamar" → skipped (Kendrick is featured)
- "Kendrick Lamar Feat. Rihanna" → kept (Kendrick is primary)
- Fixes artist pages showing unrelated albums from other artists
- Add _normalize_name helper and re import to DiscogsClient
2026-04-02 18:01:14 -07:00
Broque Thomas
5b23905973 Fix Discogs artist albums — prefer masters, filter features, fix types
- Prefer master releases over individual pressings to avoid duplicates
  (multiple pressings of same album showing separately)
- Individual releases only included if no master exists for that title
- Skip non-main roles (appearances, features, remixes by others)
- Better album type detection from format string: catches LP, Album,
  EP, Single, Compilation from comma-separated format field
- Fetch more results (3x limit) to compensate for filtering
2026-04-02 17:54:22 -07:00
Broque Thomas
a9eccfe1c5 Fix Discogs source display, album types, and track search
- Fix source name mapping so sidebar/dashboard shows 'Discogs' instead
  of falling through to 'iTunes'
- Fix album type detection: parse format string from artist releases
  endpoint (e.g. "File, FLAC, Single, 320") to correctly identify
  singles, EPs, albums, compilations — was defaulting everything to
  'single' because track count was 0
- Remove fake track search that returned albums as tracks — Discogs
  has no track-level search API, so tracks section is empty (honest)
- Track data available via album tracklists instead
2026-04-02 17:48:57 -07:00
Broque Thomas
cc95cfcdf2 Wire Discogs as fully featured fallback metadata source
- SpotifyClient: add _discogs lazy-load property, route _fallback to
  DiscogsClient when configured (requires token, falls back to iTunes)
- web_server: _get_metadata_fallback_client returns DiscogsClient when
  selected and token present
- Enhanced search: Discogs added as source tab with NDJSON streaming,
  only available when token configured
- Alternate sources list includes Discogs when token is set
- Frontend: source labels, tab styling, fetch list all include Discogs
- Consistent with iTunes/Deezer pattern — same interfaces, same routing
2026-04-02 17:32:28 -07:00
Broque Thomas
b68aa09469 Add Discogs enrichment worker with full metadata extraction
- New core/discogs_worker.py — background worker enriching artists and
  albums with Discogs metadata following AudioDBWorker pattern exactly
- Artist enrichment: discogs_id, bio, members, URLs, image backfill,
  genre backfill, summary backfill from bio
- Album enrichment: discogs_id, genres, styles (400+ taxonomy), label,
  catalog number, country, community rating, image backfill
- DB migration: discogs columns on artists (id, match_status, bio,
  members, urls) and albums (id, match_status, genres, styles, label,
  catno, country, rating, rating_count)
- Worker initialization with pause/resume persistence
- Status/pause/resume API endpoints
- Integrated into enrichment status system, rate monitor, auto-pause
  during downloads/scans, WebSocket status emission
2026-04-02 16:57:18 -07:00
Broque Thomas
44a8be4469 Add Discogs to Settings connections and rate monitor
- New Discogs section on Settings → Connections with personal token input
- Discogs added as fallback metadata source option alongside iTunes/Deezer
- Token saved to discogs.token config key
- Discogs added to API rate monitor gauges (60/min with auth)
- Help text links to discogs.com/settings/developers for token generation
2026-04-02 16:25:23 -07:00
Broque Thomas
c787d56500 Add Discogs API client for music metadata (#244)
- Full parity with iTunes/Deezer clients — same Track/Artist/Album
  dataclasses, same method signatures (search_artists, search_albums,
  search_tracks, get_artist, get_album, get_artist_albums)
- 25 req/min unauthenticated, 60 req/min with free personal token
- Rate limited via same decorator pattern with API call tracking
- Unique data: 400+ genre/style taxonomy, label info, catalog numbers,
  community ratings, artist bios
- Smart "Artist - Title" parsing for search results
- Release deduplication (Discogs has many pressings of same album)
- Track search via release tracklist extraction
- Tested: artist/album/track search, artist detail with bio, album
  detail with full tracklist + genres + styles + label
2026-04-02 15:59:15 -07:00
Broque Thomas
f6b0bd30e3 Backfill all metadata source IDs at start of every watchlist scan
- Was only backfilling the active provider — artists added via Deezer
  never got Spotify/iTunes IDs, and vice versa
- Now backfills iTunes (always), Deezer (always), and Spotify (if
  authenticated) at the start of every scan
- Added _match_to_deezer() and update_watchlist_deezer_id() for
  Deezer cross-provider matching
- Generalized backfill with provider→attribute/function maps
2026-04-02 13:51:46 -07:00
Broque Thomas
bec81cfd8d Add webhook POST then-action for automation engine
- New 'webhook' then-action: sends HTTP POST with JSON payload to any
  user-configured URL (Gotify, Home Assistant, Slack, n8n, etc.)
- Config: URL, optional custom headers (Key: Value per line with
  variable substitution), optional custom message
- Payload includes all event variables as JSON fields
- 15s timeout, errors on 400+ status codes
- Follows exact same pattern as Discord/Pushbullet/Telegram handlers
- Frontend: config fields, config reader, icon, help docs
- Updated changelogs with webhook, M3U fix, orchestrator hardening
2026-04-02 12:22:07 -07:00
Broque Thomas
22552032e2 Harden download orchestrator and surface init failures in debug info
- Each of the 6 download clients initializes independently via
  _safe_init() — one failing client no longer kills the orchestrator
- All methods guarded against None clients with appropriate fallbacks
- Init failures logged at startup and tracked in _init_failures list
- Copy Debug Info shows "Download Client Failures" section when any
  client failed to initialize, or "ALL" if orchestrator itself is dead
2026-04-02 11:40:11 -07:00
Broque Thomas
e42fe995d3 Throttle Spotify pagination and harden watchlist scanner against rate limits
- Add rate limiting to all 4 Spotify pagination loops (get_artist_albums,
  get_user_playlists, get_playlist_tracks, get_album_tracks) — these
  called sp.next() bypassing the rate_limited decorator entirely, causing
  unthrottled API calls that triggered 429 bans
- Track pagination calls in API rate monitor (separate endpoint names)
- Increase DELAY_BETWEEN_ARTISTS from 2s to 4s in watchlist scanner
- Abort watchlist scan immediately if Spotify rate limit detected mid-scan
  instead of continuing to hammer the API
2026-04-02 10:50:49 -07:00
Broque Thomas
559b89353f Add API Rate Monitor dashboard with real-time speedometer gauges
- New core/api_call_tracker.py — centralized tracker with rolling 60s
  timestamps (speedometer) and 24h minute-bucketed history (charts)
- Instrument all 9 service client rate_limited decorators to record
  actual API calls with per-endpoint tracking for Spotify
- 1-second WebSocket push loop for real-time gauge updates
- Modern radial arc gauges with service brand colors, glowing active
  arc, endpoint dot, 0/max scale labels, smooth CSS transitions
- Click any gauge to open detail modal with 24h call history chart
  (Canvas 2D, HiDPI, gradient fill, grid lines, danger zone band)
- Spotify modal shows per-endpoint history lines with color legend
  and live per-endpoint breakdown bars
- Rate limited state indicator — blinking red badge with countdown
  timer appears on gauge card when Spotify ban is active
- REST endpoint GET /api/rate-monitor/history/<service> for chart data
- Responsive grid layout (5 cols desktop, 3 tablet, 2 phone)
2026-04-02 10:46:43 -07:00
Broque Thomas
b194e1e15b Add discovery artist blacklist — block artists from all discovery playlists
- New discovery_artist_blacklist table with NOCASE name matching
- Filter blacklisted artists from all 6 discovery pool queries, hero
  endpoint, and recent releases via SQL subquery and Python set check
- Name-based filtering means one block covers all sources (Spotify/iTunes/Deezer)
- Hover any discovery track row → ✕ button to quick-block that artist
- 🚫 button on Discover hero opens management modal with search-to-add
  (powered by enhanced search) and list of blocked artists with unblock
- CRUD API: GET/POST/DELETE /api/discover/artist-blacklist
- Updated changelogs
2026-04-01 23:35:56 -07:00
Broque Thomas
7acf7a7d80 Expose MusicBrainz cache in UI — browse, clear, and unified health display
- Add MusicBrainz to Cache Browser: stats pill, source filter, dedicated
  browse endpoint, cards with matched/failed status indicators
- Add Clear MusicBrainz and Clear Failed MB Only to cache clear dropdown
- Move MusicBrainz into Cache Health "By Source" bar chart alongside
  Spotify/iTunes/Deezer instead of isolated metric row
- Rename ambiguous "Failed Lookups" to "Failed MB Lookups" in summary cards
- Add browse-musicbrainz and clear-musicbrainz API endpoints
- Add musicbrainz_total/musicbrainz_failed to cache stats response
- Add Global Search Bar and MusicBrainz cache to changelogs
2026-04-01 23:12:16 -07:00
Broque Thomas
5d2215c1d2 Increase Genius API interval from 1.5s to 2s to reduce 429 rate limits
Genius rate limits are undocumented but users hit 429s at ~40 req/min.
Bumping interval from 1.5s to 2s drops throughput to ~30 req/min which
stays under the threshold. No functional change — just slower enrichment.
2026-04-01 08:42:55 -07:00
Broque Thomas
778e68a844 Improve Genius artist search for manual matching (#233)
The manual match modal for Genius only returned 0 or 1 artist result
because search_artist() searched songs (per_page=5), extracted the
primary artist, and returned the first match or None.

Added search_artists() that returns multiple unique artists extracted
from song results with broader search (per_page=20). The manual match
endpoint now shows up to 8 artist candidates and multiple track results
instead of one-or-nothing. Also shows the Genius URL as extra info.
2026-04-01 08:31:17 -07:00
Broque Thomas
f608331867 Ensure all watchlist scanner album fetches bypass cache
The previous commit only added skip_cache=True to one call site in
web_server.py. The watchlist scanner in core/watchlist_scanner.py has
6 Spotify get_artist_albums calls that also need fresh data to detect
new releases. All now bypass cache. iTunes/Deezer calls are unaffected
(they don't have the skip_cache param, detected via hasattr check).
2026-03-31 22:25:25 -07:00
Broque Thomas
89ef5f931f Route all Spotify search calls through cached methods
Five places in web_server.py called spotify_client.sp.search() directly,
bypassing the cached search_tracks()/search_artists() methods. Each
discovery worker (Tidal, YouTube, ListenBrainz, Beatport) was also
doubling API calls — sp.search() for raw data then search_tracks() for
Track objects.

Now all use cached methods only. Raw track data for album art is
retrieved from the metadata cache by track ID after matching. Also fixed
a pre-existing bug where Tidal discovery could pair stale Spotify raw
data with a newer iTunes match.

Bumped is_spotify_authenticated() probe cache TTL from 5 to 15 minutes
to reduce /v1/me calls (~288/day → ~96/day). Manual disconnect still
takes effect immediately via _invalidate_auth_cache().
2026-03-31 22:18:02 -07:00
Broque Thomas
b5c2878533 Fix get_artist_albums cache not actually storing data
The cache stores raw_data through _extract_fields which expects a dict
with a 'name' field. Storing a raw list caused silent AttributeError,
and storing a dict without 'name' triggered junk entity rejection
(empty string is in _JUNK_NAMES). Now wraps the albums list in a dict
with a valid name field so it passes validation and persists correctly.
2026-03-31 22:05:01 -07:00
Broque Thomas
a7ebde8c01 Add skip_cache param to get_artist_albums for watchlist scans
The watchlist auto-scan needs fresh data from Spotify to detect new
releases, so it bypasses the cache added in the previous commit.
All other callers (UI browsing, completion badges, discography views)
continue to benefit from cached results.
2026-03-31 21:59:46 -07:00
Broque Thomas
62da959889 Cache get_artist_albums to reduce Spotify API rate limiting
get_artist_albums was making fresh API calls on every invocation with
no cache check, despite being one of the most called methods (discography
views, completion badges, watchlist scans). The method already cached
individual albums opportunistically but never checked for a cached result
before hitting the API.

Now follows the same check-then-fetch-then-cache pattern used by
get_album_tracks and get_artist. Cache key includes album_type param
so different queries (album,single vs compilation) are cached separately.
2026-03-31 21:51:10 -07:00
Broque Thomas
f275a9831e Strip '- Topic' suffix from YouTube auto-generated channel names (#231)
YouTube's auto-generated artist channels use the format "Artist - Topic"
as the channel name. This suffix was not being stripped during playlist
parsing, causing metadata discovery to fail (e.g., searching for
"Koven - Topic" instead of "Koven" on iTunes/Deezer).

Fixed in all three places where YouTube artist names are cleaned:
- web_server.py clean_youtube_artist() — playlist parsing
- ui/pages/sync.py clean_youtube_artist() — UI-side parsing
- core/youtube_client.py — search result fallback artist extraction
2026-03-31 21:06:05 -07:00
Broque Thomas
d1397722e2 Increase Navidrome API timeout from 10s to 60s
Large libraries (first import) can take longer than 10 seconds for
getArtists to respond. The short timeout caused the library fetch
to fail with 0 artists returned.
2026-03-31 12:44:40 -07:00
Broque Thomas
f4407490c1 Skip slskd connection check when Soulseek is not an active source
The dashboard status poll and hybrid connection check were pinging
slskd every 2 minutes regardless of download source, flooding logs
with connection errors when slskd wasn't running. Now only checks
slskd when the download mode is 'soulseek' or when 'soulseek' is
in the hybrid order. Hybrid mode also only checks sources in the
configured priority order instead of all six.
2026-03-31 12:37:14 -07:00
Broque Thomas
8bb2729dc3 Fix Tidal playlists showing 0 tracks and broken auto-mirror
The metadata-only optimization broke two things:
1. Cards showed 0 tracks because tracks were no longer in the listing
2. Auto-mirror skipped all playlists because tracks array was empty

Fix: cards render instantly from metadata, then tracks are fetched
per-playlist in the background via /api/tidal/playlist/<id>. As each
playlist's tracks arrive, the card count updates and the playlist
is auto-mirrored. Also tried multiple V2 attribute names for track
count (numberOfTracks, numberOfItems, etc.) and fixed the card DOM
selector for count updates.
2026-03-31 09:33:52 -07:00
Broque Thomas
3154d16cf3 Fix slow Tidal playlist loading — metadata only, no track fetching
get_user_playlists_metadata_only() was fetching full track lists for
every playlist sequentially (1+ API calls per playlist with 1s sleep
between pagination pages). For 20+ playlists this took 30-60 seconds.

Now returns only metadata (name, ID, track count, image) from a
single V2 API call. Track count comes from the numberOfTracks
attribute. Tracks are fetched on-demand when the user selects a
specific playlist to sync/mirror via the existing get_playlist()
endpoint.
2026-03-31 09:27:15 -07:00
Broque Thomas
03298afac1 Change fresh install defaults: hybrid download + Deezer metadata
Fresh installs now default to hybrid download mode (HiFi → YouTube →
Soulseek) instead of Soulseek-only, and Deezer as the metadata
fallback source instead of iTunes. Existing users with saved settings
are unaffected — defaults only apply when config keys don't exist.
2026-03-31 09:04:35 -07:00
Broque Thomas
1a0fd8b95e Apply manual match protection to all enrichment workers (#226)
The original #221 fix only covered Genius and AudioDB. All other
workers (Spotify, iTunes, Last.fm, MusicBrainz, Deezer, Tidal,
Qobuz) had the same bug: enrichment overwrites manual match status
to not_found when name search fails. Each worker now checks for an
existing service ID before searching by name and returns early if
one exists, preserving the manual match.
2026-03-31 08:24:30 -07:00
Broque Thomas
06a68348ac Increase rate limit ban for severe Spotify 429s
When spotipy exhausts all retries on 429 errors, the actual
Retry-After value (often 10+ hours) is consumed internally by
spotipy and not passed in the exception. The default ban was
only 1 hour, causing an endless retry cycle. Increased to 4
hours to match the escalation max and give Spotify's ban time
to expire.
2026-03-31 07:53:15 -07:00
Broque Thomas
d944d4a7d2 Fix Japanese/CJK text mangled in Soulseek search queries
normalize_string() was running unidecode on all text, converting
Japanese kanji to Chinese pinyin gibberish (命の灯火 → "tvanimedei").
Now detects CJK characters (kanji, hiragana, katakana, hangul,
fullwidth forms) and skips unidecode for text containing them —
just lowercases instead. Non-CJK text (Latin accents, Cyrillic)
still goes through unidecode normally.
2026-03-30 18:28:25 -07:00
Broque Thomas
fde1a7d77e Fix .lrc files written without timestamps for plain lyrics
Plain (unsynced) lyrics were being saved with .lrc extension despite
having no timestamps, making them invalid for Plex and other players
that expect LRC format. Synced lyrics now write as .lrc, plain lyrics
write as .txt. Both types still get embedded in audio file tags.
Updated all file move/rename operations to handle .txt sidecars
alongside .lrc.
2026-03-30 13:28:41 -07:00
Broque Thomas
f2e24a36df Fix enrichment overwriting manual match status (#221)
When a user manually matched an artist to a service ID then triggered
enrichment, the worker re-searched by name, failed to find a match,
and overwrote the status back to not_found — despite the ID being
valid. Now both Genius and AudioDB workers check for existing service
IDs before searching by name. If an ID exists (from manual match),
the worker uses it for a direct API lookup to enrich metadata while
preserving the matched status. Added AudioDB lookup-by-ID client
methods for artist, album, and track.
2026-03-30 11:07:48 -07:00
Broque Thomas
7133595e0d Fix enrichment widget showing Running when rate limited (#217)
The tooltip only checked paused/authenticated/idle/running states.
When Spotify was rate limited or daily budget exhausted, the worker
thread was still alive (sleeping in guards) so it showed "Running"
with no current item and stale 0% progress.

Now checks rate_limited and daily_budget.exhausted before running:
- Rate limited: "Rate Limited — Waiting Xm for rate limit to clear"
- Budget exhausted: "Daily Limit Reached — Resets in Xh Xm"
- No current item: "Waiting for next item..." instead of blank

Also adds rate_limit info object to get_stats() response for the
countdown display.
2026-03-30 08:12:56 -07:00
Broque Thomas
59587162cd Add metadata cache maintenance and health monitoring
Cache maintenance:
- Input validation rejects junk entities (Unknown Artist, empty names)
  from being cached, with exemptions for synthetic entries (_features,
  _tracks suffixes)
- CacheEvictorJob expanded to 4 phases: TTL eviction, junk cleanup,
  orphaned search cleanup, MusicBrainz failed lookup cleanup
- MusicBrainz null results now expire after 30 days (was 90) so failed
  lookups get retried sooner

Cache health UI:
- Polished modal accessible from Dashboard "Cache Health" button and
  repair dashboard health bar
- Shows health status banner (healthy/fair/poor), stat cards, source
  breakdown with colored progress bars, type pills, and metrics table
- Repair dashboard shows compact bar with health dot indicator
2026-03-30 07:40:20 -07:00
Broque Thomas
7cff379aa7 Embed lyrics directly in audio file tags
Lyrics from LRClib are now embedded in audio file tags (USLT for MP3,
lyrics for FLAC/OGG, ©lyr for M4A) in addition to the .lrc sidecar
file. Navidrome, Jellyfin, and Plex can read embedded lyrics but not
all support .lrc files.

Embedding is best-effort — if it fails, the .lrc file still exists.
Only adds to existing tags, never creates tags from scratch.
2026-03-29 20:15:26 -07:00
Broque Thomas
f68cae64a7 Skip AcoustID verification for high-confidence cross-language matches
When the fingerprint score is >=0.95 but title/artist don't match
(e.g. English expected vs Japanese returned), SKIP instead of FAIL.
A 95%+ fingerprint means the audio IS the correct recording — the
metadata mismatch is just a language/script difference, not a wrong
file. Prevents Japanese, Chinese, Korean, and other non-Latin tracks
from being falsely quarantined.

Happy path unchanged — matching title/artist still returns PASS at
the earlier check before this code is reached.
2026-03-29 20:04:41 -07:00
Broque Thomas
7a2bc49458 Add raw non-ASCII query for CJK track search on Soulseek
When artist or title contains non-ASCII characters (Japanese, Chinese,
Korean, etc.), prepend the original un-romanized text as the first
search query. unidecode converts Japanese kanji to Chinese pinyin
(e.g. "藤澤慶昌" → "wu zhi zhuan sheng") which never matches on
Soulseek. The original characters match filenames directly.

Romanized fallback queries are still generated after for coverage.
Zero impact on ASCII-only tracks (isascii check skips them).
2026-03-29 18:34:39 -07:00
Broque Thomas
98463e5d43 Fix library maintenance path fixes failing silently (#207)
fix_finding() was using a potentially stale transfer_folder that was
only refreshed during scheduled job runs, not on manual fix attempts.
Now re-reads the transfer path from config before each fix, matching
the same logic used by _run_next_job().

Also surfaces fix failure reasons to the user — bulk fix now logs each
failure with finding ID and error, and the frontend toast shows the
actual error message instead of just "X failed".
2026-03-29 16:25:36 -07:00
Broque Thomas
20452859c5 Improve enrichment matching to pick best result instead of first (#210)
Artist/album/track matching previously took the first Spotify search
result above the 0.80 similarity threshold. If Spotify returned a
near-match before the exact match (e.g. "Brother's Keeper" before
"Brothers Keepers"), the wrong entity would be selected.

Now scores all candidates and picks the highest, so an exact match
(1.0) always wins over a near-match (0.94). No change to threshold
or batch matching logic — strictly better or equal results.
2026-03-29 16:13:17 -07:00
Broque Thomas
3f866ebf5e Add daily budget to Spotify enrichment worker to prevent rate limit bans
The background enrichment worker now caps itself at 3,000 processed items
per calendar day. Counter resets at midnight automatically. When exhausted,
the worker sleeps and checks every 5 minutes for a new day.

This is scoped entirely to the enrichment worker — user-initiated Spotify
API calls (searches, playlist ops, album lookups, etc.) are completely
unaffected. Budget status is exposed in the worker's get_stats() response
for the dashboard widget.
2026-03-29 15:34:41 -07:00
Broque Thomas
0ffef39853 Backfill missing watchlist artist images during scan
The image update code only checked Spotify's images array format,
missing iTunes direct image_url. Also used spotify_artist_id for
DB lookup which missed Deezer-only artists. Now handles both image
formats and tries all provider IDs for the DB update.

Old Deezer watchlist artists missing images will get backfilled
on the next watchlist scan automatically.
2026-03-28 11:13:16 -07:00
Broque Thomas
be86ed8799 Fix OPUS files losing all metadata during import/post-processing
OggOpus (Mutagen) is a separate class from OggVorbis but uses the
same VorbisComment tag API. All isinstance checks for (FLAC, OggVorbis)
missed OggOpus, so tags were cleared but never rewritten — resulting
in empty metadata and "Unknown Artist" on the media server.

Added _is_ogg_opus() helper and applied to all 15 format checks in
web_server.py and 2 in core/tag_writer.py. No change to FLAC/OGG/
MP3/M4A handling (short-circuit evaluation skips the new check).
2026-03-28 09:37:12 -07:00
Broque Thomas
08f708eb71 Add logging to path mismatch fix handler for diagnosis
The fix was failing silently — returning error results without
writing to any log. Added info/warning logs at each failure point
(missing paths, source not found, path escapes transfer, destination
conflict) so Docker path resolution issues can be diagnosed.
2026-03-28 09:26:19 -07:00
Broque Thomas
b49806a83a Add collaborative album artist handling with per-source resolution
New setting in Settings → Library → File Organization: "Collaborative
Album Artist" — choose between first listed artist (default) or all
artists combined for $albumartist in folder paths and album_artist tag.

Per-source resolution:
- Spotify: artists array has separate objects — picks first directly
- Deezer: API already returns first artist only — no change needed
- iTunes: combined string ("Larry June, Curren$y & The Alchemist") —
  resolves via artistId API lookup to get primary name ("Larry June").
  Safe for "Tyler, the Creator" and "Simon & Garfunkel" because their
  IDs resolve to the same combined name (no change).

Applied to both folder path ($albumartist template) and album_artist
metadata tag for consistency. Track artist tag always keeps all artists.
iTunes lookup only fires when source is iTunes (numeric ID + not Deezer).
2026-03-27 13:39:27 -07:00
Broque Thomas
7f9755a26e Add album-aware track matching for multi-artist albums
When artist-specific track search fails, falls back to album-aware
matching: finds the album by title (any artist), then checks if the
track exists on it. Fixes daily re-downloads of collaborative albums
filed under a different artist (e.g., "Spiral Staircases" tagged
under "The Alchemist" but scanned from "Larry June's" watchlist).

- check_track_exists: new album parameter, album-aware fallback with
  0.8 album title threshold + 0.7 track title threshold
- Watchlist scanner: passes album_data.get('name') to track checks
- Download modal: passes batch_album_context to fallback track search
- Wishlist callers (4 spots): extract and pass track album name
- Backwards compatible: album=None default, no change for callers
  without album context (singles, playlists)
2026-03-27 11:44:01 -07:00
Broque Thomas
cab12b61a6 Fix missing year and track numbers when Deezer metadata cache is stale
Root cause: search_albums cached album data without release_date or
track_position. get_album_metadata accepted this incomplete cache hit
(only checked for title), never called the full API. Result: year
empty, all tracks numbered 01.

Fix: get_album_metadata now requires release_date in cache to use it.
Search results without release_date trigger a fresh /album/{id} API
call which returns complete data. Also improved track_number fallback
in download context builder when Spotify isn't configured.
2026-03-27 11:24:36 -07:00
Broque Thomas
a33f891fa6 Add per-artist watchlist lookback period override
New "Scan Lookback" dropdown in the watchlist artist config modal.
Each artist can override the global lookback period (7d to entire
discography). Default is "Use Global Setting" (NULL in DB).

- Database: lookback_days INTEGER DEFAULT NULL on watchlist_artists,
  auto-migrated on startup
- Scanner: checks per-artist lookback_days first, falls back to
  global discovery_lookback_period if NULL
- Backend: GET/POST /api/watchlist/artist/<id>/config includes
  lookback_days. Changing lookback clears last_scan_timestamp to
  force a rescan with the new window
- Frontend: dropdown with 8 options in artist config modal
- Fully backwards compatible — existing artists unchanged
2026-03-27 08:17:02 -07:00
Broque Thomas
6a41f5c0d7 Fix YouTube downloads failing with "Requested format not available"
Root cause: two issues compounding.

1. extractor_args with player_client: ['android', 'web'] + skip: ['hls', 'dash']
   stripped all real audio formats. Android client returns HLS/DASH streams,
   skip removes them, leaving only storyboard thumbnails. bestaudio then fails
   because there's nothing valid to select.

2. Browser cookies (cookiesfrombrowser) cause authenticated YouTube sessions
   to return restricted format data for some videos. Same video works fine
   without cookies.

Fix:
- Removed all hardcoded player_client and skip overrides from 4 locations
  (download opts, connection check, search, retry). Let yt-dlp use its own
  defaults which are updated with each release.
- Retry strategy: attempt 1 uses cookies (respects user setting), attempt 2
  drops cookies (fixes auth-restricted formats), attempt 3 uses format 'best'
  as last resort.
- Updated user_agent to Chrome 131.
2026-03-26 19:07:50 -07:00
Broque Thomas
8e41feaade Fix Navidrome playlist sync truncation, clarify discovery label
Navidrome fix:
- createPlaylist and other write operations now use POST instead of
  GET. Large playlists (161+ tracks) exceeded URL length limits when
  all songId params were in the query string, causing silent truncation
  (e.g., only 6 of 161 tracks added). POST sends params as form body
  with no size limit.
- Write operation timeout bumped to 30s (was 10s)
- _WRITE_ENDPOINTS set defines which Subsonic endpoints use POST

UX fix:
- Mirrored playlist cards now show "161/161 discovered on Spotify"
  instead of just "161/161 discovered" — clarifies that discovery
  means metadata matching, not library ownership
2026-03-26 14:15:20 -07:00
Broque Thomas
c1287f0ec0 Helper V2 complete + enrichment worker fixes
Helper system phases 2-7:
- Setup Progress: onboarding checklist with progress ring, auto-detection
  via /status, /api/settings, /api/library, /api/watchlist, /api/automations
- Quick Actions: accent pill buttons in popovers (service cards get
  "Open Settings" and "View Docs" actions)
- Keyboard Shortcuts: full-screen overlay with key cap styling, grouped
  by scope (Global, Player, Helper, Forms)
- Search: fuzzy search across 200+ help entries, 11 tours, and shortcuts
  with cross-page navigation via _guessPageFromSelector()
- What's New: version-tagged highlights with "Show me" navigation,
  red badge on ? button for unseen versions, older version cycling
- Troubleshoot: scans dashboard service cards for disconnected/error
  states, shows fix steps with action buttons, "All Clear" when healthy
- Contextual menu: page-aware tour suggestion at top of menu
- Ctrl+K / Cmd+K opens helper search globally
- First-launch welcome tooltip with pulsing ? button
- Redesigned floating button (48px, accent gradient, glass effect)
- Redesigned menu (unified card panel, accent left-stripe on contextual)

Enrichment worker fixes:
- AcoustID: individual recording matches downgraded INFO→DEBUG to reduce
  log noise (14 lines for one track → 1 summary line)
- Name normalization: strip " - Suffix" dash format (Spotify) same as
  "(Suffix)" parens format across all 8 workers. Fixes false mismatch
  on tracks like "Electric Eyes (Studio Brussels Remix)" vs
  "Electric Eyes - Studio Brussels Remix" (was 0.54, now matches)
2026-03-26 12:20:58 -07:00
Broque Thomas
6820e2d4e3 Enforce FLAC bit depth preference and prioritize audio quality in sorting
Two changes to the Soulseek quality filter:

1. Sort candidates by effective bitrate first, peer quality second.
Previously a 16-bit FLAC from a fast peer could beat a 24-bit FLAC
from a slower peer. Now highest audio quality always wins within the
same format tier, with peer speed as tiebreaker.

2. Enforce the FLAC bit depth UI preference (Any/16-bit/24-bit) that
was previously cosmetic-only. Uses effective bitrate threshold of
1450 kbps to distinguish 16-bit (<1411 kbps theoretical) from 24-bit
(>2116 kbps theoretical). Respects the bit_depth_fallback toggle —
when enabled, gracefully accepts any FLAC if preferred depth is
unavailable. When disabled, strictly rejects non-matching depth.

Default bit_depth="any" — zero behavior change for existing users.
2026-03-25 15:59:22 -07:00
Broque Thomas
cb008a2e61 Cache Deezer enrichment worker API calls in metadata cache
The Deezer enrichment worker's 5 raw API methods (search_artist,
search_album, search_track, get_album_raw, get_track_raw) were
bypassing the metadata cache — every enrichment cycle hit the
Deezer API fresh for every item. Spotify and iTunes workers properly
cached all results.

Now all 5 methods store results via store_entity(). get_album_raw
and get_track_raw also check cache first (verified by presence of
full-detail fields like 'label' and 'bpm' to distinguish from
search stubs). Cache failures are silently ignored to never block
enrichment. This eliminates redundant API calls and automatically
populates genre data for the genre explorer.
2026-03-25 15:13:55 -07:00
Broque Thomas
e08462a002 Multi-source genre explorer with Deezer genre support and cross-source routing
Genre explorer and deep dive modal now combine data from all available
metadata sources (iTunes + Deezer always, Spotify when authenticated).
Artists are deduplicated by name across sources, preferring entries
with images. Source dots (green/red/purple) indicate data origin.

Deezer genre support:
- Extract genre_id from Deezer album search responses via ID-to-name
  mapping table (26 Deezer genre categories)
- Extract full genre names from Deezer get_album responses
- One-time backfill updates existing cached albums from stored raw_json
- Propagate album genres to Deezer artist entities

Cross-source album routing:
- /api/discover/album endpoint uses source-specific client (iTunes or
  Deezer) based on the item's source, not just the active fallback
- Spotify path falls back to active fallback when album not found
- Track clicks use album_id directly instead of name-based resolution
- resolve-cache-album adds partial match and live search fallback

Other fixes:
- Genre explorer positioned at top of Discover page (below hero)
- Genre explorer results cached 24hr in-memory for fast reload
- Related genres computed from all albums by matched artists
- Artist clicks open Artists page with discography (not library detail)
- Discovery pool genre queries restored to source-filtered (Browse by
  Genre tabs stay source-isolated as designed)
2026-03-24 19:36:11 -07:00
Broque Thomas
f3bb8d2f0f Fix genre browser returning empty when metadata source changes
The genre query filtered by active source (spotify/deezer/itunes)
but discovery pool entries keep their original source. Switching
metadata sources caused all genres to disappear. Removed the source
filter since artist genres are source-agnostic metadata.
2026-03-24 14:50:37 -07:00
Broque Thomas
bb9564ee88 Downgrade 'Soulseek client not configured' from ERROR to DEBUG (#201)
Users who intentionally don't use Soulseek were getting spammed with
ERROR-level logs every second. These are expected when Soulseek isn't
configured as a source and don't need user attention.
2026-03-24 14:03:06 -07:00
Broque Thomas
616b377225 Fix Deezer download crash: config_manager not in scope
Deezer client imports config_manager locally in __init__ and stores
it as self._config, unlike the other clients which import at module
level. The allow_fallback line was referencing the wrong name.
2026-03-24 13:12:23 -07:00
Broque Thomas
89cfea0fe7 Add per-source quality fallback toggle for streaming downloads (#187)
Each streaming source (Tidal, Qobuz, HiFi, Deezer) now has an "Allow
quality fallback" checkbox in Settings. When disabled, the source only
tries the exact quality selected — if unavailable, it skips and lets
the orchestrator try the next source. Default is ON (current behavior).
2026-03-24 11:42:47 -07:00
Broque Thomas
16be814a67 Fix album completeness fix returning 400 for stale findings
When missing_tracks was empty (API unavailable at scan time) or the
album was completed between scan and fix, the handler returned a 400
error. Now it re-fetches missing tracks from Spotify/iTunes/Deezer at
fix time and auto-resolves findings where the album is already complete.
2026-03-24 09:32:17 -07:00
Broque Thomas
01b0b70515 Fix enhanced view reorganize not moving sidecars, add template debug logging
Enhanced album reorganize now moves LRC, cover.jpg, folder.jpg and other
sidecar files alongside audio files. Added debug log to library reorganize
repair job showing which template is loaded from config vs default.
2026-03-24 07:12:35 -07:00
Broque Thomas
f247841665 Fix Album Tag Consistency job: add missing _get_settings method 2026-03-24 06:38:05 -07:00
Broque Thomas
e8a5e253f6 Rebuild artist SoulID: track-verified canonical ID from Deezer + iTunes
New algorithm: pick first track alphabetically from artist's library, search
both Deezer and iTunes APIs for 'artist track' to find the exact artist,
verify name matches, then use max(deezer_id, itunes_id) as the canonical
differentiator. Deterministic across instances — any SoulSync with the same
artist and at least one matching track will produce the same soul_id.

Fallback chain: canonical API ID → first album title → name only.
Migration clears all artist soul_ids on first v2.1 startup for regeneration.
2026-03-23 18:34:36 -07:00
Broque Thomas
b8ec6d66af Add missing _get_artist_image_from_albums to HydrabaseClient
Method exists on iTunesClient and DeezerClient but was missing from
HydrabaseClient, causing AttributeError when Hydrabase is active and
Build a Playlist tries to fetch artist images.
2026-03-23 17:54:21 -07:00
Broque Thomas
9c60040a9b Integrate Deezer download source into orchestrator
Added deezer_dl to all orchestrator methods: init, is_configured,
get_source_status, check_connection, search (single + hybrid), download,
get_all_downloads, get_download_status, cancel_download, clear_completed,
cancel_all, and reload_settings. Full parity with existing 5 sources.
2026-03-23 15:34:27 -07:00
Broque Thomas
2ae5050ef1 Add Deezer download source: client, settings UI, ARL authentication
- New core/deezer_download_client.py: full download client with ARL auth,
  Blowfish decryption, quality fallback (FLAC/MP3 320/MP3 128), search,
  thread-safe download tracking. Not yet integrated into orchestrator.
- Settings UI: Deezer download quality selector, ARL token input, test
  connection button. Appears in download source dropdown and hybrid list.
- Test endpoint: /api/deezer-download/test verifies ARL and returns tier.
- Added deezer_download to settings save whitelist and sensitive paths.
- Fixed Spotify enrichment worker default to unpaused (like other workers).
2026-03-23 15:06:40 -07:00
Broque Thomas
d4a57ae654 Start Spotify enrichment worker unpaused by default like other workers 2026-03-23 13:23:00 -07:00
Broque Thomas
5d6ccee066 Add Picard-style MB release preflight for album downloads
Before album tracks start post-processing, pre-populate the MusicBrainz
release cache with ONE verified release. All tracks then hit the cache
during per-track processing and get the same release MBID — no second pass,
no file lock contention, tags correct on first write.

Handles edition name mismatches (Spotify says 'Super Deluxe', MB says
just the base name) by stripping qualifiers and searching again.
Validates track count to pick the right edition. Three download paths
covered: download missing modal, enhanced album, and matched album.
2026-03-23 12:42:29 -07:00
Broque Thomas
1e54ff54ac Fix album tag consistency handler: open each file once for all field changes 2026-03-23 10:46:28 -07:00
Broque Thomas
d75893bc30 Add Album Tag Consistency repair job: detect and fix inconsistent tags across album tracks
New maintenance job scans albums for tracks with mismatched album names, album
artist names, or MusicBrainz release IDs. These inconsistencies cause Navidrome
and other media servers to split one album into multiple entries. The fix
normalizes outlier tracks to the majority value by rewriting file tags.
2026-03-23 10:44:36 -07:00
Broque Thomas
df390feece Fix automation signal chain: forward event data (playlist_id) to action handlers
Event-triggered automations now receive playlist_id from the triggering event
when the action config doesn't have one set. Fixes silent 'No playlist specified'
failures in Discover/Sync chains. Added debug logging to trace event matching.
2026-03-23 08:51:52 -07:00
Broque Thomas
655e1e251d Add rate limit check to search_tracks and search_albums in Spotify client
Both methods were calling Spotify API without checking the global rate limit,
causing 429 errors to spam the logs even when the ban was active.
2026-03-23 07:53:46 -07:00
Broque Thomas
429306c7f3 Fix enrichment retry loops, cover art finding dupes, and Spotify rate limit during art scan
- All 9 enrichment workers: stop auto-retrying 'error' status items (was infinite loop)
  Only 'not_found' items retry after configured days; errors require manual full refresh
- Cover art dedup: check both 'pending' AND 'resolved' findings to prevent recreation
- Cover art scanner: top-level Spotify rate limit check skips Spotify entirely when
  banned, falls back to iTunes/Deezer only, logs once instead of spamming 429s
2026-03-22 23:24:42 -07:00
Broque Thomas
d8217d66ba Speed up metadata cache browser: add composite indexes, remove full-scan LIKE filters 2026-03-22 23:13:34 -07:00
Broque Thomas
e3d70da55a Add DB storage visualization + cache-powered discovery sections + Genre Deep Dive
- Stats page: database storage donut chart with per-table breakdown and total size
- Discover page: 5 new sections mined from metadata cache (zero API calls):
  Undiscovered Albums, New In Your Genres, From Your Labels, Deep Cuts, Genre Explorer
- Genre Deep Dive modal: artists (clickable → artist page), popular tracks,
  albums with download flow, related genre pills, in-library badges
- All cache queries filtered by active metadata source (Spotify/iTunes/Deezer)
- Stale cache entries (404) gracefully fall back to name+artist resolution
- Album cards show "In Library" badge, artist avatars scaled by prominence
2026-03-22 21:35:18 -07:00
Broque Thomas
01d96866e4 Increase stats page top artists/albums/tracks from 10 to 25 2026-03-22 16:39:53 -07:00
Broque Thomas
93005298ee Cap Opus bitrate at 256kbps, fix lossy copy flavor text, redesign artist action buttons 2026-03-22 16:34:36 -07:00
Broque Thomas
9e75731f6c Add scrobbling to Last.fm/ListenBrainz + update What's New
Scrobbling:
- Last.fm: API signing, web auth flow, batch scrobble (50/request)
- ListenBrainz: submit_listens (batch 1000, listen_type=import)
- Worker hook: scrobbles unscrobbled events after each poll
- DB: scrobbled_lastfm/scrobbled_listenbrainz tracking columns
- Settings: API secret input, authorize button, scrobble toggles
- Config: lastfm.api_secret, lastfm.session_key, *.scrobble_enabled

What's New: added all features from this session (scrobbling,
personalized discovery, stats page, SoulID, lossy codecs, import,
hero redesign, Hydrabase, orphan fixes, year collection).
2026-03-22 14:38:53 -07:00
Broque Thomas
232481fd13 Personalize discovery playlists using listening stats
Integrates play history data into the discovery algorithm:

- Listening profile: _get_listening_profile() builds user's top artists,
  genres, play counts, and listening velocity from the last 30 days
- Artist genre cache: pre-built from local DB for O(1) genre lookups
- Release Radar: +10 genre affinity, +15 artist familiarity, -10 overplay
  penalty. Weights rebalanced to 45% recency + 25% popularity + bonuses
- Discovery Weekly: serendipity scoring within tiers — boosts unheard
  artists in preferred genres, penalizes overplayed artists
- Recent Albums: adaptive time window (21-60 days) based on listening
  velocity — heavy listeners get fresher content, casual listeners more
- New "Because You Listen To" sections: personalized carousels based on
  user's top 3 played artists via similar artists + genre fallback
- New endpoint: /api/discover/because-you-listen-to with artist images
- Frontend: BYLT sections with artist photo headers on discover page
- All changes gracefully fall back when no listening data exists
2026-03-22 13:54:37 -07:00
Broque Thomas
cfb0e85564 Add Listening Stats page with media server play data integration
Full stats dashboard that polls Plex/Jellyfin/Navidrome for play
history and presents it with Chart.js visualizations:

Backend:
- ListeningStatsWorker polls active server every 30 min
- listening_history DB table with dedup, play_count/last_played on tracks
- get_play_history() and get_track_play_counts() for all 3 servers
- Pre-computed cache for all time ranges (7d/30d/12m/all) rebuilt each sync
- Single cached endpoint serves all stats data instantly
- Stats query methods: top artists/albums/tracks, timeline, genres, health

Frontend:
- New Stats nav page with glassmorphic container matching dashboard style
- Overview cards (plays, time, artists, albums, tracks) with accent hover
- Listening timeline bar chart (Chart.js)
- Genre breakdown doughnut chart with legend
- Top artists visual bubbles with profile pictures + ranked list
- Top albums and tracks ranked lists with album art
- Library health: format breakdown bar, unplayed count, enrichment coverage
- Recently played timeline with relative timestamps
- Time range pills with instant switching via cache
- Sync Now button with spinner, last synced timestamp
- Clickable artist names navigate to library artist detail
- Last.fm global listeners shown alongside personal play counts
- SoulID badges on matched artists
- Empty state when no data synced yet
- Mobile responsive layout

DB migrations: listening_history table, play_count/last_played columns,
all with idempotent CREATE IF NOT EXISTS / PRAGMA checks.
2026-03-22 13:18:14 -07:00
Broque Thomas
aca8a8e996 Fix Opus cover art and quality: map audio only, embed art via Mutagen
Opus ffmpeg command now uses -map 0:a to extract only the audio stream,
matching the user's working command and preventing picture stream
interference with the encoder. After conversion, cover art is embedded
from the source FLAC using Mutagen: Opus gets METADATA_BLOCK_PICTURE
(base64-encoded per OGG spec), AAC gets MP4Cover. Applied to both
the post-download lossy copy and the repair job fix handler.
2026-03-22 08:32:04 -07:00
Broque Thomas
b0b1c22f80 Read codec/bitrate from current settings at fix time, not scan time
The lossy converter fix handler now reads codec and bitrate fresh
from the Settings page when converting, not from what was stored
in the finding details at scan time. Users can change their codec
preference after scanning and Fix All will use the new setting.
2026-03-22 08:24:35 -07:00
Broque Thomas
bd27bbe1b2 Add independent Blasphemy Mode setting for lossy converter job
The lossy converter fix handler now reads delete_original from its
own job settings (repair.jobs.lossy_converter.settings.delete_original)
instead of the global lossy_copy.delete_original. Defaults to false.
Separate from the per-download Blasphemy Mode toggle in Settings.
2026-03-22 08:08:47 -07:00
Broque Thomas
adefe1c892 Register lossy_converter in repair jobs module list
Job was not appearing in Library Maintenance because the module
was not in the _JOB_MODULES import list.
2026-03-22 07:58:47 -07:00
Broque Thomas
bc5dd75c8e Add lossy converter repair job for retroactive FLAC conversion
New library maintenance job that scans for FLAC files missing a lossy
copy (MP3/Opus/AAC) and creates findings. Fix action converts via
ffmpeg using the configured codec/bitrate from Settings. Supports
Blasphemy Mode (delete original + update DB path). Finding details
store codec/bitrate from scan time for consistency. Disabled by
default, manual-run only, no auto-schedule.
2026-03-22 07:43:19 -07:00
Broque Thomas
491b89a1d2 Redesign library artist hero with Last.fm integration
- Add get_artist_top_tracks to Last.fm client (up to 100 tracks)
- Include lastfm_listeners, lastfm_playcount, lastfm_tags, lastfm_bio,
  and soul_id in artist detail API response
- New endpoint: /api/artist/<id>/lastfm-top-tracks for lazy loading
- Hero layout: image (160px) | center (name, badges, genres, bio,
  listener/play stats, progress bars) | right card (scrollable top
  100 tracks from Last.fm)
- Badges 36px with hover lift, bio in subtle card with Read More
  toggle, Last.fm tags merged with existing genres
- Numbers formatted: 1234567 → 1.2M
- Graceful degradation: sections hidden when Last.fm data unavailable
2026-03-21 23:04:34 -07:00
Broque Thomas
4dba3757be Fix orphan detector false positives and add staging/delete choice
Orphan detector: add normalized tag matching that strips parentheticals
and brackets (feat. X, [FLAC 16bit], etc.) and tries first-artist-only
for comma-separated artists. Prevents false orphan flags for tracks
like "The Mountain (feat. Dennis Hopper...)" that exist in DB as
"The Mountain". All lookups remain O(1) set operations.

Orphan fix: replace auto-delete with user choice prompt. Single Fix
and Fix All both show modal asking "Move to Staging" or "Delete".
Move to Staging relocates file to import staging folder for proper
re-import with metadata matching. Fix action flows through API
endpoint → repair_worker.fix_finding → _fix_orphan_file handler.
Staging path uses docker_resolve_path for container compatibility.
2026-03-21 11:27:51 -07:00
Broque Thomas
f991d02f0c Fix MusicBrainz release matching preferring standard over deluxe
When searching for "Playing the Angel (Deluxe)", MusicBrainz would
match the standard release because its higher popularity score
outweighed the lower title similarity. Now match_release extracts
version qualifiers (Deluxe, Remastered, etc.) from both the query
and each result, applying a +10 bonus for exact qualifier match,
+5 for partial, and -5 penalty when the query has a qualifier but
the result doesn't. This ensures deluxe downloads get the deluxe
MBID, preventing Navidrome from splitting albums.
2026-03-21 10:48:35 -07:00
Broque Thomas
2d511d0a16 Add SoulID worker with API-based debut year disambiguation
SoulID worker generates deterministic soul IDs for all library entities:
  - Artists: hash(name + debut_year) — searches iTunes + Deezer APIs,
    verifies correct artist by matching discography against local DB
    albums via MusicMatchingEngine, pools years from both sources and
    picks the earliest. Falls back to hash(name) if no match found.
  - Albums: hash(artist + album)
  - Tracks: song ID hash(artist + track) + album ID hash(artist + album + track)

Dashboard button with trans2.png logo, rainbow spinner, hover tooltip.
Worker orb with rainbow effect. SoulSync badge on library artist cards.
DB migration adds soul_id columns with indexes to artists/albums/tracks.
Migration version flag auto-resets artist soul IDs when algorithm changes.
2026-03-21 10:21:06 -07:00
Broque Thomas
ee3500242e Fix Hydrabase search types, ID routing, and plugin passthrough
- Use correct server request types: 'tracks', 'albums', 'artists',
  'artist.albums', 'album.tracks' (were singular, caused timeouts)
- Normalize artists to strings (server may send dicts)
- Use native plugin IDs (iTunes/Spotify) instead of soul_id for
  album/artist/track IDs so downstream endpoints can resolve them
- Carry soul_id and plugin_id in external_urls for routing
- Pass plugin param from frontend to server for correct client routing
  (iTunes vs Deezer vs Spotify) with isdigit() fallback
- Route source=hydrabase to iTunes client for artist images
- Include external_urls in enhanced search API response
- Reduce WebSocket timeout from 15s to 8s
2026-03-20 21:04:56 -07:00
Broque Thomas
a4f0745547 Fix Hydrabase not appearing as enhanced search source tab
- Remove stale hydrabase.enabled check, use is_connected() directly
- Add hydrabase to frontend alternate source fetch list
- Normalize Hydrabase artists to strings (server may send dicts),
  fixing silent crashes that prevented albums/tracks from appearing
2026-03-20 18:24:20 -07:00
Broque Thomas
a8c5a6ccaa Add full Spotify-compatible interface to Hydrabase client
Implements get_track_details, get_album, get_artist, get_artist_albums,
get_track_features, is_authenticated, and reload_config to match the
iTunes/Deezer client interface. Each method tries a specific request
type first then falls back to search if the server doesn't support it.
Preserves existing get_album_tracks (List[Track]) for backward compat.
2026-03-20 18:14:31 -07:00
Broque Thomas
c69040886e Collect release year from all metadata sources during post-processing
Post-processing now extracts release year from MusicBrainz, Deezer,
Tidal, Qobuz, and Spotify context (first source wins). Writes
ORIGINALDATE and DATE tags to file, and backfills the album year
in the DB if currently missing. Fixes Library Reorganize showing
blank years for Tidal-only downloads.

Also raises Library Reorganize API year lookup cap from 50 to 200.
2026-03-20 17:31:00 -07:00
Broque Thomas
ce89154952 Fix hybrid source toggle/reorder not saving and skip unconfigured sources
- Add debouncedAutoSaveSettings() to moveHybridSource and toggleHybridSource
- Skip unconfigured sources at search time with is_configured() check
- Add get_source_status() to orchestrator, include in settings API response
- Auto-disable unconfigured sources in UI on settings load
2026-03-20 16:22:48 -07:00
Broque Thomas
10361bb837 Complete Hydrabase as selectable fallback metadata source
- Remove redundant enable checkbox — fallback dropdown is the enable
- Hydrabase option only appears in dropdown when connected
- Connect/disconnect dynamically adds/removes dropdown option
- _is_hydrabase_active checks fallback_source == hydrabase (not config toggle)
- Fallback client returns hydrabase_client when selected, iTunes if disconnected
- Auto-reconnect respects fallback selection for dev_mode handling
- hydrabase added to settings save service list for persistence
- Status shows green Connected on page load when auto-connected
2026-03-20 14:18:10 -07:00
Broque Thomas
3c51f27e97 Fix Spotify enrichment worker rejecting every track via fallback
Worker checked self.client.sp (non-None even without Spotify auth due
to fallback) instead of is_spotify_authenticated(). Searched via
iTunes/Deezer fallback, got numeric IDs, rejected them all with
warnings. Now sleeps when Spotify isn't authenticated instead of
making pointless fallback searches.
2026-03-20 12:45:36 -07:00
Broque Thomas
be77397132 Fix enrichment workers never showing idle/complete status
Pending count queries included NULL-ID rows that _get_next_item filters
out, so pending stayed > 0 even when no processable items remained.
Workers reported running instead of idle, UI never turned green. Added
AND id IS NOT NULL to _count_pending_items across all 9 workers to
match the _get_next_item filter.
2026-03-20 10:07:27 -07:00
Broque Thomas
e0533215da Fix enrichment workers looping on tracks with NULL IDs
Workers would endlessly match the same track because UPDATE WHERE id =
NULL matches 0 rows in SQL. Added AND id IS NOT NULL to all enrichment
queries (individual, batch EXISTS, and batch fetch) across all 9
workers. Also added process-level guard for belt-and-suspenders safety.

Fix Deezer get_track → get_track_details method name mismatch.
2026-03-20 09:36:25 -07:00
Broque Thomas
81da57f306 Add min completion percentage filter to Album Completeness job
New setting: Min Completion % (default 0 = disabled). Skips albums
where the user has fewer than N% of tracks — filters out playlist
imports where a single track exists but the full album was never
intended to be downloaded. Catches real failed downloads (6/12)
while ignoring incidental singles (1/12).
2026-03-20 08:19:56 -07:00
Broque Thomas
67db796958 Fix Tidal token refresh hammering when client credentials removed
Skip refresh attempt if client_id/secret not configured — clears stale
tokens to stop retry loop permanently. Add 5-minute backoff after
failed refresh to prevent hammering Tidal API every 2 seconds.
2026-03-20 07:28:32 -07:00
Broque Thomas
6ee20973a8 Fix Album Completeness wishlist adding missing cover art and track data
Wishlist entries from auto-fill were missing album images, album ID,
track number, disc number, and total tracks. Downloads would have no
cover art and wrong file organization. Now includes full album context
matching the standard wishlist data format.
2026-03-19 22:05:06 -07:00
Broque Thomas
dadc489b08 Add tag-based fallback to orphan detector to prevent false positives
When suffix path matching fails, reads file tags (title+artist) and
checks against DB tracks. Prevents false orphan detection from path
mismatches in Docker where DB paths differ from filesystem paths.
Only runs for files that fail suffix matching — zero overhead in
normal cases.
2026-03-19 22:01:27 -07:00
Broque Thomas
c53583716a Fix batch_complete firing with 0 downloads and album ID int cast error
- Only emit batch_complete automation event when successful_downloads > 0
- Remove int() cast on album_id in _fix_incomplete_album — Plex/Jellyfin
  use hash/text IDs that SQLite stores as-is in INTEGER columns
- Use string comparison for album_id equality check
2026-03-19 19:48:06 -07:00
Broque Thomas
1b95c05041 Fix Track Number Repair findings returning 400 on fix
Finding was created with entity_id=None (file-based) but fix handler
required entity_id for DB update. Rewrote handler to work with file_path
as primary — writes corrected track number to file tags, renames file
if needed, updates DB path. Also stores total_tracks in finding details
for correct tag writing.
2026-03-19 19:21:24 -07:00
Broque Thomas
fc4e16337a Redesign hybrid mode with N-source priority ordering
Replace fixed primary/secondary hybrid dropdowns with an ordered list
of all 5 download sources. Users enable/disable each source and reorder
with up/down arrows to set download priority. Sources are tried in
order until one returns results.

- New hybrid_order config field (backward compat with legacy primary/secondary)
- Download orchestrator loops ordered list with per-source error handling
- Sortable source list UI with icons, toggle switches, priority numbers
- Source-specific settings shown for all enabled hybrid sources
- Seamless migration from legacy 2-source to N-source format
2026-03-19 18:51:52 -07:00
Broque Thomas
b9c83a50fa Add Soulseek peer queue filtering and configurable download timeout
- Add max_peer_queue setting to skip peers with long queues (soft filter
  with fallback to unfiltered if all results removed)
- Add download_timeout setting replacing hardcoded 10-minute limit
- Include quality_score (peer health: upload speed, free slots, queue
  length) in result ranking — was calculated but never used in sort key
- New UI controls in Soulseek settings section
2026-03-19 11:47:37 -07:00
Broque Thomas
f0270ce7a5 Expand Album Completeness to support iTunes and Deezer sources
Was Spotify-only — users without Spotify got zero results. Now queries
albums with any source ID (spotify_album_id, itunes_album_id, deezer_id)
and uses the matching API client for track count and missing track lookup.
Falls back gracefully across sources with client-type detection.
2026-03-19 11:27:08 -07:00
Broque Thomas
e2345c659d Add mass orphan safety guard to prevent accidental library deletion
When >50% of files are flagged as orphans (likely a DB path mismatch),
findings are marked as warnings with mass_orphan flag. Fixing these
requires typing "witness me" to confirm — prevents nuking an entire
library from a false-positive orphan scan.
2026-03-19 11:04:11 -07:00
Broque Thomas
ade189fa38 Fix Library Reorganize producing (_) in paths when year is empty
_sanitize_context_values passed empty strings through _sanitize_filename
which converts '' to '_' (empty-name fallback). Template $album ($year)
became Album (_) instead of Album () which the cleanup regex couldn't
match. Now preserves empty strings so the existing () cleanup works.
2026-03-19 10:36:21 -07:00
Broque Thomas
4eb028a7ce Add fix handler for Library Reorganize findings and expand year sources
- Add _fix_path_mismatch handler so fixing Library Reorganize dry-run
  findings actually moves files (was returning 400 with no handler)
- Add path_mismatch to fixable_types and _execute_fix handler map
- Add recent_releases and wishlist_tracks as year sources in
  _load_album_years to cover more playlist-synced tracks missing years
- Add sys and json imports needed by new code
2026-03-19 09:24:09 -07:00
Broque Thomas
0de8841b14 Fix bulk Fix All ignoring Single/Album Dedup findings and expand version keywords
- Add single_album_redundant to fixable_types in bulk_fix_findings so
  Fix All actually includes these findings (Fix Selected worked, Fix All
  silently returned 0)
- Expand version keyword regex from 9 to 25 terms (remastered, deluxe,
  unplugged, etc.) to reduce false positives in Single/Album Dedup
- Add word boundary anchors to prevent substring matches (e.g. "live"
  inside "Alive", "edit" inside "Meditate")
- Cast similarity thresholds to float for config type safety
2026-03-19 08:53:14 -07:00
Broque Thomas
8754e160b0 Fix Album Completeness job scanning no albums due to wrong HAVING filter
The SQL HAVING clause filtered on local track count instead of expected
track count, excluding albums with fewer than 3 local tracks from the
scan entirely. Now fetches all Spotify-matched albums and filters by
expected track count in the loop.
2026-03-19 08:36:15 -07:00
Broque Thomas
99481a0232 Fix Track Match search ignoring Track/Artist fields and low result limit
- Frontend was concatenating Track and Artist inputs into a single
  query string, causing Spotify to return mixed results matching
  either word in any field. Now sends track and artist as separate
  params; backend builds field-filtered query (track:X artist:Y).
- Result limit was silently capped at 10 in spotify_client.search_tracks
  via min(limit, 10). Raised to respect requested limit up to
  Spotify's API max of 50.
- iTunes fallback endpoint updated with same field-specific params.
- Legacy ?query= param still supported for backward compatibility.

Fixes #194
2026-03-19 08:23:19 -07:00
Broque Thomas
0dd0d50837 Fix Single/Album Dedup flagging EP tracks as redundant singles
EPs are no longer classified as singles — removing EP tracks would
break the release and conflict with album completeness checks.
Only actual singles (album_type='single' or unknown type with <=2
tracks) are flagged as redundant when the same song exists on an album.
2026-03-18 17:48:01 -07:00
Broque Thomas
e1b203405f Add ignore cross-album duplicates setting to Duplicate Detector
Tracks on different albums (e.g., same song on a studio album and a
compilation) are no longer flagged as duplicates by default, keeping
albums complete. Setting is enabled by default and configurable via
the job settings UI.
2026-03-18 17:42:49 -07:00
Broque Thomas
ebc0713348 Add API year lookup fallback for Library Reorganize job
Playlist-synced tracks often have no year in file tags or the DB albums
table. Now checks discovery_pool release_date as a second DB source,
then batch-lookups remaining missing years from the user's active
metadata source (Spotify/iTunes/Deezer) capped at 50 API calls.
2026-03-18 14:49:35 -07:00
Broque Thomas
2c2c006f83 Fix wishlisted tracks showing 'Unknown Album' with no cover art
Multiple code paths built wishlist album data as a bare string instead
of a dict with images/album_type/total_tracks, causing wishlist entries
to lose album name and artwork. Fixed in: Beatport fallback conversion,
sync normalization, _ensure_spotify_track_format, and wishlist service
track reconstruction. Now uses track name as album fallback and always
includes required album dict keys.
2026-03-18 14:23:53 -07:00
Broque Thomas
4a7f297b2c Add minimum peer upload speed setting and fix quality scoring tiers
New dropdown in Soulseek settings lets users filter out slow peers at
search time (Any/1/2/3/4/5/10 Mbps). Passes minimumPeerUploadSpeed
to slskd API in bytes/sec.

Also fixes quality scoring tiers which were using wrong units — old
thresholds (5000, 1000, 500) treated bytes/sec values as kbps,
making speed scoring effectively meaningless. Now uses correct
bytes/sec thresholds based on real peer data.
2026-03-18 14:07:39 -07:00
Broque Thomas
e8e8a2e934 Fix deep scan skipping file path updates for existing tracks
Deep scan passed skip_existing_tracks=True which skipped calling
insert_or_update_media_track entirely for known tracks, so stale
file paths were never refreshed from the media server. Now always
calls insert_or_update (which safely uses UPDATE for existing tracks,
preserving enrichment data) so file paths stay in sync.
2026-03-18 13:03:01 -07:00
Broque Thomas
8abcf386d5 Fix duplicate detector ignoring similarity settings for remixes
Normalization was stripping parenthetical content before comparison,
so 'title' and 'title (xxx remix)' both became 'title' and always
matched at 1.0 regardless of the user's threshold setting.
2026-03-18 12:47:34 -07:00
Broque Thomas
f66eecebf5 Fix seasonal playlists not appearing when Deezer is active source
_get_source() hardcoded 'itunes' as fallback, so seasonal content was
stored with source='itunes' but API endpoints queried source='deezer' —
resulting in empty discover page seasons. Now reads the configured
fallback source from metadata_service.
2026-03-18 10:28:54 -07:00
Broque Thomas
a42927f369 Add re-download capability to dead file findings (#189)
Dead file Fix button now adds the track to wishlist for re-download
instead of just removing the DB entry. Builds full wishlist-compatible
track data from DB (artist, album, artwork, IDs) so the download
pipeline can process it like any other wishlist item.
2026-03-18 09:49:38 -07:00
Broque Thomas
2888a5dda1 Fix Soulseek test crash, dedup query, and reorganize missing year
- Add null guard for soulseek_client in test-connection endpoint (#190)
- Fix single_album_dedup query: use al.record_type and al.track_count
  (al.album_type and al.total_tracks don't exist on main albums table)
- Fix library reorganize missing year: pre-load album years from DB as
  fallback when file tags lack the date field (common with playlist syncs)
2026-03-18 09:09:45 -07:00
Broque Thomas
2656927f79 Beatport enrichment progress, metadata cache fixes, per-source cache clearing
- Fix enrichment progress never updating: remove `continue` that skipped
  progress_callback for successful tracks in enrich_chart_tracks
- Split chart/extract into two-step flow: extract raw tracks, then enrich
  via polling endpoint with live progress overlay updates
- Move Beatport enrichment cache to persistent metadata cache system
- Fix metadata cache detail modal for Beatport (URL entity_ids with slashes)
- Add per-source Clear dropdown (Spotify/iTunes/Deezer/Beatport) to cache browser
- Remove debug logging from enrichment progress tracking
2026-03-18 08:39:02 -07:00
Broque Thomas
cde5754f0f Add Auto-Fill fix handler for incomplete album findings
When the maintenance worker flags an incomplete album, users can now
click "Auto-Fill" to automatically locate missing tracks in the library,
move/copy them into the album folder, and apply full metadata enhancement
(MusicBrainz, Deezer, cover art, etc.). Singles are moved; tracks from
multi-track albums are copied. Quality gate prevents filling FLAC albums
with lossy files. Tracks not found in library are added to wishlist with
album context for auto-download.
2026-03-17 22:56:24 -07:00
Broque Thomas
9e82456caf Fix album_type field missing from Deezer and Spotify Track dataclasses
Both clients have their own Track class separate from iTunes. The
album preference commit added album_type/total_tracks to from_*_track()
constructors but not to the class definitions, crashing all Deezer and
Spotify discovery searches.
2026-03-17 16:02:32 -07:00
Broque Thomas
a31dd9b81e Add Single/Album Dedup maintenance job to flag redundant singles
Scans library for tracks on singles/EPs that also exist on a full album,
letting users clean up fragmented libraries. Includes version-tag safety
(live, acoustic, remix etc. won't cross-match) and verifies the album
version still exists before removing a single.
2026-03-17 15:30:29 -07:00
Broque Thomas
6bf337423d Prefer album versions over singles when matching tracks to metadata sources
Add album_type and total_tracks fields to Track dataclass, populate from
Spotify/iTunes/Deezer API responses, and apply a small tiebreaker bonus
(+0.02 for albums, +0.01 for EPs) in all matching loops so album versions
win when confidence scores are otherwise equal.
2026-03-17 15:18:28 -07:00
Broque Thomas
d31bba8999 Fix Deezer metadata cache returning incomplete data for track details
Deezer search results (cached without release_date, track_position, isrc)
were being served by get_track_details as cache hits, preventing the actual
/track/{id} API call that returns complete data. This caused $year template
variable to remain empty even with the discovery worker fix in place.
2026-03-17 13:14:08 -07:00
Broque Thomas
31e9ff3385 Fix Navidrome library scan triggering via Subsonic startScan endpoint
trigger_library_scan and is_library_scanning were no-ops assuming
Navidrome auto-detects new files. Navidrome supports startScan and
getScanStatus via the Subsonic API, so use them like Plex and Jellyfin.
2026-03-17 10:29:12 -07:00
Broque Thomas
bd56c96634 Fix library reorganize not updating DB paths and leaving sidecars behind
- DB path update uses suffix matching when exact match fails, so server
  paths (/mnt/musicBackup/...) are found and updated after local moves
- Escape LIKE wildcards in artist/album names to prevent false matches
- Move album-level sidecars (cover.jpg, folder.jpg, etc.) alongside audio
- Post-pass sweep moves all remaining sidecars from emptied directories
- Multi-disc albums place album art in album root, not disc subfolders
2026-03-17 10:15:46 -07:00
Broque Thomas
0a5a874c08 Fix dead file cleaner false positives from transfer path resolution failure
Use config_manager for transfer path (same source as playback) instead of
separate DB read that silently falls back to ./Transfer under contention.
Abort scan if transfer folder doesn't exist to prevent mass false findings.
2026-03-17 09:51:31 -07:00
Broque Thomas
cc62541a65 Add Select All, Fix Selected & Fix All to Library Maintenance findings 2026-03-17 09:25:21 -07:00
Broque Thomas
7d0df2b9ed Fix discover page Deezer source support
- personalized_playlists._get_active_source() now returns 'deezer' when
  configured instead of always falling back to 'itunes'
- Add deezer_track_id to _build_track_dict() for discovery pool tracks
- Include album_deezer_id and artist_deezer_id in get_discovery_recent_albums()
  response — fixes "No deezer album ID available" error when clicking cards
- Skip Spotify library section entirely when Spotify is not authenticated
2026-03-16 19:35:26 -07:00
Broque Thomas
42a4285e09 Add clear findings button to library maintenance modal
Per-job and per-status filtering — respects active toolbar filters so
users can clear e.g. only track number findings or only dismissed items.
Confirmation uses the app's styled modal instead of browser confirm().
2026-03-16 15:37:33 -07:00
Broque Thomas
c3546ac0bd Fix HiFi client not failing over to next instance on HTTP 500
Previously only 502/503/504 triggered instance rotation. A 500 from one
instance (e.g. triton.squid.wtf choking on a specific query) would stop
the search entirely instead of trying the next instance.
2026-03-16 13:56:20 -07:00
Broque Thomas
46ac46134b Add Deezer as configurable free metadata fallback source alongside iTunes
Users can now choose between iTunes/Apple Music and Deezer as their free
metadata source in Settings. Spotify always takes priority when authenticated;
the fallback handles all lookups when it's not.

Core changes:
- DeezerClient: full metadata interface (search, albums, artists, tracks)
  matching iTunesClient's API surface with identical dataclass return types
- SpotifyClient: configurable _fallback property switches between iTunes/Deezer
  based on live config reads (no restart needed)
- MetadataService, web_server, watchlist_scanner, api/search, repair_worker,
  seasonal_discovery, personalized_playlists: all direct iTunesClient imports
  replaced with fallback-aware helpers

Database:
- deezer_artist_id on watchlist_artists and similar_artists tables
- deezer_track_id/album_id/artist_id on discovery_pool and discovery_cache
- Full CRUD for Deezer IDs: add, read, update, backfill, metadata enrichment
- Watchlist duplicate detection by artist name prevents re-adding across sources
- SimilarArtist dataclass and all query/insert methods handle Deezer columns

Bug fixes found during review:
- Similar artist backfill was writing Deezer IDs into iTunes columns
- Discover hero was storing resolved Deezer IDs in wrong column
- Status cache not invalidating on settings save (source name lag)
- Watchlist add allowing duplicates when switching metadata sources
2026-03-16 13:12:12 -07:00
Broque Thomas
87b39634a0 Fix MusicBrainz recording matches with wrong titles & add MBID mismatch repair job
Add minimum 60% title similarity gate to match_recording() — prevents
artist bonus + MB score from pushing unrelated titles past the confidence
threshold (e.g. "Sweet Surrender" matching "Answers" by same artist).

New MBID Mismatch Detector repair job reads embedded MusicBrainz recording
IDs from audio files, verifies them against the MusicBrainz API, and flags
tracks where the MBID points to a different song. Fix action strips the bad
MBID tag so media servers like Navidrome fall back to correct file tags.
2026-03-16 07:52:00 -07:00
Broque Thomas
ec389c5ae8 Add HiFi as free lossless download source via public hifi-api instances
New download mode alongside Soulseek, YouTube, Tidal, and Qobuz. Uses
community-run REST API instances (no auth required) that serve Tidal CDN
FLAC streams. Features quality fallback chain (hires→lossless→high→low),
automatic instance rotation on failure, and full hybrid mode support.

Also fixes 6 missing streaming source checks for HiFi and Qobuz in the
frontend that were blocking playback with "format not supported" errors.
2026-03-15 22:53:34 -07:00
Broque Thomas
483e45cbc0 Add Spotify Link tab for public playlist/album scraping without API credentials
Scrapes Spotify's embed endpoint to extract track data from any public
playlist or album URL. Full discovery flow with Deezer parity: parse →
card → discovery modal → live progress → sync → download missing.

- New scraper: core/spotify_public_scraper.py (embed endpoint parsing)
- 12 API endpoints mirroring Deezer's discovery/sync/download flow
- WebSocket live discovery updates via spotify_public_discovery_states
- Green-branded tab, cards, and input styling (#1DB954)
- Album vs playlist detection with distinct card icons (💿/🎵)
- Download persistence: card click reopens download modal after close
- Card phase reset on cancel/completion (closeDownloadMissingModal)
- Backend download linking for spotify_public_ and deezer_ prefixes
- Completion handlers (V2 + no-missing-tracks) for both platforms
- Source URLs stored on mirrored playlists for future auto-refresh
2026-03-15 21:25:05 -07:00
Broque Thomas
0742cc45e6 Add hemisphere setting for seasonal playlists on Discover page
Southern hemisphere users now see correct seasons (e.g. March = Autumn,
December = Summer). Holidays (Halloween, Christmas, Valentine's) stay
calendar-fixed regardless of hemisphere.

- Hemisphere dropdown in Discovery Pool Settings
- GET/POST /api/discovery/hemisphere endpoints
- Season detection offsets months by 6 for southern hemisphere
- Stored in metadata table, defaults to northern
2026-03-15 18:49:51 -07:00
Broque Thomas
2003e58358 Add Spotify rate limit guards to all repair jobs
Repair jobs were making Spotify API calls without checking the global
rate limit ban, causing them to churn through every item getting rejected
one by one during a ban. Now all Spotify calls in repair jobs check
context.is_spotify_rate_limited() first and skip/fallback gracefully.

- Add is_spotify_rate_limited() helper to JobContext (base.py)
- Guard calls in track_number_repair, album_completeness,
  missing_cover_art, and metadata_gap_filler
- Jobs fall through to iTunes/MusicBrainz fallbacks when rate-limited
2026-03-15 16:01:48 -07:00
Broque Thomas
ab21855af3 Enrich repair findings with album art, artist images & live job progress
- All 9 repair jobs now emit report_progress() for real-time card updates
  (phase, log lines, per-item activity) via WebSocket repair:progress events
- Enrich finding details with album/artist thumb URLs across all repair jobs
  (dead_file, duplicate, metadata_gap, album_completeness, missing_cover_art,
  acoustid_scanner, track_number_repair, fake_lossless, orphan_file)
- Track number repair: return match_score from fuzzy matching, add suffix-based
  DB lookup for album/artist art (handles cross-environment path mismatches)
- Fix Plex/Jellyfin relative thumb URLs in findings endpoint via fix_artist_image_url
- Labeled media cards in finding detail panels (album title + artist name under images)
- Dashboard tooltip shows current job name + per-job progress instead of stale stats
2026-03-15 14:08:26 -07:00
Broque Thomas
f08550140f Add whistle logo to maintenance modal header & harden fix handlers
- Add whisoul.png to modal header with subtitle text and gradient background
- Responsive: smaller logo on mobile, hide subtitle
- Share _resolve_file_path in repair_worker for cross-environment path compat
- Use path resolution in orphan and duplicate file deletion
- Guard directory cleanup against removing the transfer folder itself
- Restore correct button label text on fix error recovery
2026-03-15 13:25:40 -07:00
Broque Thomas
7a706e8c11 Add fix actions for maintenance findings (dead files, orphans, duplicates, etc.)
- New fix_finding() dispatcher in repair_worker with per-type handlers:
  dead_file (remove DB entry), orphan_file (delete from disk),
  track_number_mismatch (update DB), missing_cover_art (apply artwork URL),
  metadata_gap (apply found fields), duplicate_tracks (keep best quality)
- New POST /api/repair/findings/<id>/fix endpoint
- Frontend: contextual fix buttons per finding type, bulk "Fix Selected"
- Removed path_mismatch from fixable types (dry-run preview only)
2026-03-15 12:51:18 -07:00
Broque Thomas
61ed4086e0 Redesign maintenance findings/history tabs with dashboard & fix path resolution bugs
- Add findings dashboard with summary stats and per-job clickable filter chips
- Redesign findings cards with expandable detail panels and per-type renderers
- Redesign history tab with status dots, stat pills, and full timestamps
- Fix dead file cleaner false positives by using suffix-based path resolution
- Fix orphan file detector false positives by matching via path suffixes
- Add help text modal for each repair job card
- Enlarge maintenance modal (1100px wide, 90vh tall)
2026-03-15 12:20:02 -07:00
Broque Thomas
4635ea895b include a '?' to include more details for each job. 2026-03-15 11:19:09 -07:00
Broque Thomas
dab94ce65d update soulsync repair worker with new jobs 2026-03-15 10:18:50 -07:00
Broque Thomas
264e696fe3 Fix per-profile ListenBrainz playlist cache scoping and stale data recovery 2026-03-14 21:11:59 -07:00
Broque Thomas
8f0b9518bc Add per-profile ListenBrainz support with personal settings modal 2026-03-14 20:12:19 -07:00
Broque Thomas
60261f2e91 Fix watchlist scan failing entirely when Spotify is rate limited by adding iTunes provider fallback and missing rate limit ban detection 2026-03-14 14:58:12 -07:00
Broque Thomas
cf917279c2 Harden metadata cache: prevent simplified data from overwriting full entries, fix connection leaks, and add inline TTL enforcement 2026-03-14 13:39:12 -07:00
Broque Thomas
0b8bfa1e6b Scope automation-triggered watchlist scans to the calling profile & Fix watchlist scan silently skipping all albums due to metadata cache returning incomplete data 2026-03-14 13:23:03 -07:00
Broque Thomas
c3eecc88ad Retry Tidal download at lower quality when hi-res returns garbage file 2026-03-14 10:54:33 -07:00
Broque Thomas
363b3d0922 Fix MusicBrainz API call in metadata gap filler job 2026-03-14 09:46:02 -07:00
Broque Thomas
945f86c643 Library Repair Worker: multi-job background maintenance daemon with 10 jobs, findings system, and management modal 2026-03-14 09:09:18 -07:00
Broque Thomas
6de3ab7cef Add universal metadata cache for Spotify & iTunes API responses with browsable dashboard tool 2026-03-14 01:26:45 -07:00
Broque Thomas
c54e52e18d Add Spotify Library discovery section, instrumental filter, custom exclusion terms & album download modal fixes 2026-03-14 00:21:44 -07:00
Broque Thomas
a5e72cff05 Add instrumental filter & custom exclusion terms to watchlist content filters 2026-03-13 23:09:12 -07:00
Broque Thomas
e3fdb12d78 Preserve watchlist scan timestamps for UI display instead of wiping on lookback changes 2026-03-13 22:46:41 -07:00
Broque Thomas
32f1cc946c Improve watchlist cross-provider matching accuracy and add manual artist linking UI 2026-03-13 22:28:13 -07:00
Broque Thomas
e5a6ac7821 Strip all parentheticals in AcoustID title normalization 2026-03-13 21:28:18 -07:00
Broque Thomas
a557074d3c Add Spotify rate limit modal with live countdown and ban duration escalation 2026-03-13 15:36:24 -07:00
Broque Thomas
de2cc6db7a Detect rate limits in methods that swallow 429 exceptions so the modal appears 2026-03-13 14:00:23 -07:00
Broque Thomas
c9549e8a9a Fix iTunes discovery playlists: synthetic popularity, EP inclusion, and seasonal iTunes support 2026-03-13 13:52:32 -07:00
Broque Thomas
7da7f3b112 Cache similar artist metadata at scan time to eliminate redundant Spotify API calls 2026-03-13 13:35:54 -07:00
Broque Thomas
55186c6a51 Add Deezer playlist sync tab with discovery, fix modal, and cache persistence 2026-03-13 12:29:49 -07:00
Broque Thomas
d2a241cc04 Fix Tidal playlist sync dropping remix/version info from track titles 2026-03-12 21:12:34 -07:00
Broque Thomas
6c4de45b32 fix acoustID match issue and css changes 2026-03-12 16:43:22 -07:00
Broque Thomas
ded906bef4 Fix false positive track matching & tag writing visibility for library files 2026-03-12 16:08:44 -07:00
Broque Thomas
66e9457d0e Stop unnecessary Spotify API call every 60s from enrichment status polling 2026-03-12 12:06:25 -07:00
Broque Thomas
822568a2d5 Fix automated scans for non-Plex servers & incremental scan performance 2026-03-12 11:17:32 -07:00
Broque Thomas
dc9a8a3845 Fix wrong track downloads when album name matches a track title in hybrid mode 2026-03-12 10:40:33 -07:00
Broque Thomas
5db10b552d Fix Tidal/Qobuz enrichment backfill failing on dict-type copyright and isrc fields 2026-03-12 10:26:35 -07:00
Broque Thomas
f77066f9a7 Fix Spotify rate limit loop causing indefinite API call cycle during ban 2026-03-12 09:49:07 -07:00
Broque Thomas
917c25afd9 Fix Tidal OAuth — override Accept header on token requests to application/json 2026-03-12 09:35:36 -07:00
Broque Thomas
691e026208 Fix Tidal API search — correct endpoint casing, JSON:API Accept header, and best-match selection 2026-03-11 22:51:51 -07:00
Broque Thomas
ecfa30c918 Fix Tidal V2 search endpoint, duration parsing, and library badge display 2026-03-11 22:08:25 -07:00
Broque Thomas
cc35864e7d Tidal & Qobuz Enrichment Workers - Bug Fixes & Rate Limiting 2026-03-11 21:49:31 -07:00
Broque Thomas
ac2c710a1e Tidal & Qobuz Background Enrichment Workers 2026-03-11 21:26:20 -07:00
Broque Thomas
70c32aa640 Hybrid Mode Redesign 2026-03-11 18:10:35 -07:00
Broque Thomas
fb04d0f4bc Full qobuz support 2026-03-11 17:43:27 -07:00
Broque Thomas
4390036556 Include Tidal version field in track names — fixes remixes all resolving to base title 2026-03-11 15:51:20 -07:00
Broque Thomas
d329dd4fe8 Fix Tidal playlist endpoints redundantly re-fetching all playlists — use direct single-playlist fetch 2026-03-11 13:50:53 -07:00
Broque Thomas
27bd896540 Use largest available Spotify album artwork instead of medium size 2026-03-11 12:59:23 -07:00
Broque Thomas
2930b5334e Fix Tidal playlist pagination rate limiting — exponential backoff, inter-page delay, and HTTPError propagation 2026-03-11 12:49:02 -07:00
Broque Thomas
d452cd0a55 Validate Tidal downloads and clean up unplayable hi-res stubs 2026-03-11 11:59:13 -07:00
Broque Thomas
bc22bdca07 Fix infinite Spotify rate limit loop from unguarded auth probes and swallowed errors 2026-03-11 11:07:48 -07:00
Broque Thomas
bde2be1cfa Spotify rate limit re-trigger loop caused by periodic auth probes 2026-03-11 08:28:36 -07:00
Broque Thomas
0aa8950436 fix build a playlist functionality and update the ui 2026-03-11 07:52:58 -07:00
Broque Thomas
603db69b10 Fix Genius 429 backoff by re-raising rate limit errors from _make_request 2026-03-10 15:46:37 -07:00
Broque Thomas
1b0fca9009 Service Badges, Page Headers, Docs Page, and Bug Fixe 2026-03-10 14:47:11 -07:00
Broque Thomas
c8975f8186 Make iTunes country setting apply immediately without restart 2026-03-10 12:56:22 -07:00
Broque Thomas
87d567151e iTunes storefront fallback with configurable country setting 2026-03-10 12:13:21 -07:00
Broque Thomas
87db94554a Fix Genius search blind fallback matching wrong artists/songs 2026-03-10 10:38:52 -07:00
Broque Thomas
f26f6f8266 Last.fm & Genius full worker parity, clickable service badges, and playlist folder race condition fix 2026-03-10 10:35:55 -07:00
Broque Thomas
92ba36a9ba Add no-auth state to Last.fm and Genius dashboard buttons with greyed-out UI and settings guidance 2026-03-10 09:20:16 -07:00
Broque Thomas
dc7140c459 Add Last.fm and Genius to on-demand enrichment, settings reload, and enrich dropdown parity 2026-03-10 09:12:36 -07:00
Broque Thomas
f8d23ec37c Add Last.fm and Genius API clients with settings integration 2026-03-09 22:35:10 -07:00
Broque Thomas
aa93458ed3 Configurable ListenBrainz API endpoint for self-hosted instances 2026-03-09 20:52:33 -07:00
Broque Thomas
07a79e7af6 Full Cleanup automation: combined housekeeping sweep for quarantine, downloads, staging, and search history 2026-03-09 16:39:29 -07:00