Commit graph

11 commits

Author SHA1 Message Date
Broque Thomas
f3ad65de34 Complete MusicBrainz watchlist source parity
Add MusicBrainz watchlist artist ID storage, badges, linked-provider editing, and per-artist preferred source support.

Backfill watchlist MusicBrainz matches from already-enriched library artists so existing MusicBrainz worker matches appear in watchlist cards and settings.

Extend bulk watchlist add, liked artist matching, artist map source picking, and service status labels to recognize MusicBrainz, with regression tests for watchlist ID persistence and backfill.
2026-05-18 19:19:25 -07:00
Broque Thomas
5bc5fbb662 Add MusicBrainz as a metadata source
Register MusicBrainz as a first-class metadata source alongside Deezer, iTunes, Spotify, Discogs, and Hydrabase. Expose the shared client through metadata services, add the settings option, and expand the MusicBrainz search adapter with source-compatible artist, album, track, and detail methods.

Carry MusicBrainz IDs through similar-artist discovery, recommended artists, artist map serialization, and personalized playlist selection. Update DB migrations and lookup filters so similar_artist_musicbrainz_id is preserved on older schemas and used for source requirements and library exclusion.

Normalize MusicBrainz album adapter output for import context and add regression coverage for registry mapping, typed album conversion, and similar-artist filtering. Verified by user with 120 focused tests passing.
2026-05-18 18:47:13 -07:00
Broque Thomas
08725094db get_current_profile_id: catch RuntimeError so background callers don't crash
Reproduced on the personalized playlist pipeline: selecting Fresh Tape
(or any kind) and running the automation surfaced
"Working outside of application context" in the UI.

Root cause: `get_current_profile_id` reads Flask's `g.profile_id` and
only catches `AttributeError`. Outside a request — automation engine,
sync threads, watchlist scanner — `g` raises `RuntimeError` instead,
so the except misses and the handler dies.

Mirrored playlist pipeline never hit this because it hardcodes
profile_id=1 in its sync call. The personalized pipeline calls
`deps.get_current_profile_id()` from a background thread, which is
what tripped the bug. Fresh Tape's generator also resolves the
profile via the same function — same path, same crash.

Fix: broaden the except to `(AttributeError, RuntimeError)` in all
three copies of the helper (`web_server.py`, `core/artists/map.py`,
`core/discovery/hero.py`). All three now safely degrade to profile_id=1
(admin profile) when called outside a request context — matches the
existing intent that single-admin installs Just Work.

No test changes — the existing pipeline tests stub the helper, so
they never exercised the bug. The fix is in the layer above the
stubs.
2026-05-15 21:06:17 -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
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
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
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
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