soulsync/core/enrichment/manual_match_honoring.py
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

128 lines
4.9 KiB
Python

"""Honor manually-matched source IDs in per-source enrichment workers.
GitHub issue #501 (@Tacobell444): every per-source enrichment worker's
``_process_*_individual`` method ran a fuzzy text search on the album /
track name and overwrote the stored source ID with whatever the search
returned. If the user had manually matched an album to a specific source
ID (e.g. set ``albums.spotify_album_id = 'ABC'`` via the match-chip UI),
the next "Enrich" click would search by name → pick a different result
→ overwrite the manual match with the wrong ID, OR fail to match
anything and revert the status to ``not_found``.
This module lifts the "honor stored ID" fast path into one shared
helper. Each per-source worker (Spotify / iTunes / Deezer / Discogs /
MusicBrainz / AudioDB / Tidal / Qobuz) calls it before falling back
to its existing search-by-name flow. Same fix in 8 workers gets
exactly one implementation; per-worker variability (column name,
client fetch method, response shape) plugs in via callbacks.
Lift what's truly shared. Caller knows its own column + client
method + update logic; the helper just orchestrates.
"""
from __future__ import annotations
from typing import Any, Callable, Optional
from utils.logging_config import get_logger
logger = get_logger("enrichment.manual_match_honoring")
def _read_id_column(db, entity_table: str, entity_id, id_column: str) -> Optional[str]:
"""Read the stored source ID for one entity. Returns None when the
column is empty / unset."""
if entity_table not in ('albums', 'tracks', 'artists'):
# Defensive: we only operate on these three. Avoids SQL injection
# via a bad table name (id_column is also restricted to known
# column names by callers but defense in depth never hurts).
return None
conn = db._get_connection()
try:
cursor = conn.cursor()
cursor.execute(
f"SELECT {id_column} FROM {entity_table} WHERE id = ?",
(entity_id,),
)
row = cursor.fetchone()
finally:
conn.close()
if not row:
return None
value = row[0] if not hasattr(row, 'keys') else row[id_column]
return str(value).strip() if value else None
def honor_stored_match(
*,
db,
entity_table: str,
entity_id,
id_column: str,
client_fetch_fn: Callable[[str], Any],
on_match_fn: Callable[[Any, str, Any], None],
log_prefix: str = '',
) -> bool:
"""Fast-path enrichment via a stored source ID — preserves manual
matches.
Args:
db: ``MusicDatabase`` instance (for the column read).
entity_table: ``'albums'``, ``'tracks'``, or ``'artists'``.
entity_id: Library DB ID of the entity to enrich.
id_column: Column on ``entity_table`` that stores the source-
specific ID (``spotify_album_id`` / ``itunes_album_id`` /
``deezer_id`` / etc).
client_fetch_fn: Callable taking the stored ID and returning
the source's raw response (Album dataclass, dict, or
whatever the client returns). Typically
``self.client.get_album`` or ``self.client.get_track``.
on_match_fn: Worker callback invoked with
``(entity_id, stored_id, api_response)`` to apply the
metadata refresh. Worker knows the response shape; helper
doesn't.
log_prefix: Display name for log lines (``'Spotify'`` /
``'iTunes'`` / etc).
Returns:
True if a stored ID was found AND the fetch returned data AND
the on-match callback ran. Caller skips its search-by-name
flow and counts a match.
False if no stored ID is set, the fetch failed, or the fetch
returned empty. Caller falls through to its existing search-
by-name flow (the legacy behavior for un-matched entities).
Notes:
- Exceptions in ``client_fetch_fn`` are caught and logged at
warning level — caller falls through to search.
- Exceptions in ``on_match_fn`` propagate (those are real
DB errors the worker should know about).
"""
stored_id = _read_id_column(db, entity_table, entity_id, id_column)
if not stored_id:
return False
try:
api_data = client_fetch_fn(stored_id)
except Exception as exc:
logger.warning(
f"[{log_prefix}] Stored-ID fetch failed for "
f"{entity_table[:-1]} #{entity_id} (id={stored_id}): {exc}"
)
return False
if not api_data:
logger.debug(
f"[{log_prefix}] Stored ID {stored_id} for "
f"{entity_table[:-1]} #{entity_id} returned empty data — "
f"falling through to search-by-name"
)
return False
on_match_fn(entity_id, stored_id, api_data)
logger.info(
f"[{log_prefix}] Honored manual match: "
f"{entity_table[:-1]} #{entity_id}{id_column}={stored_id}"
)
return True