diff --git a/core/library/track_identity.py b/core/library/track_identity.py index 584fba45..3d2ff982 100644 --- a/core/library/track_identity.py +++ b/core/library/track_identity.py @@ -67,15 +67,23 @@ def _get(track: Any, *names: str) -> Optional[str]: return None -def extract_external_ids(track: Any) -> Dict[str, str]: +def extract_external_ids(track: Any, source_hint: Optional[str] = None) -> Dict[str, str]: """Pull every recognized external ID off a metadata-source track. Handles the source-source naming drift: Spotify tracks expose ``id`` as the Spotify track ID; Deezer tracks expose ``id`` as the Deezer track ID; iTunes tracks may use ``trackId`` or ``id``. The disamb- - iguating field is ``provider`` / ``source``. Tracks coming from a - SoulSync internal pipeline often carry every known ID set to its - source-specific value — we just collect whatever's there. + iguating field is ``provider`` / ``source`` / ``_source``. Tracks + coming from a SoulSync internal pipeline often carry every known ID + set to its source-specific value — we just collect whatever's there. + + ``source_hint`` is the caller's known answer to "where did this + track dict come from?" — used as a fallback when the track itself + doesn't carry a provider / source / _source field. Spotify and + iTunes return raw API responses without provider tags, so the + watchlist scanner passes ``get_primary_source()`` here to make sure + a Spotify-primary scan isn't silently no-opping just because the + raw API track has no provider key. Returns a dict mapping conceptual ID name → ID value. Keys present in ``EXTERNAL_ID_COLUMNS``. Empty dict when no IDs are available. @@ -106,8 +114,13 @@ def extract_external_ids(track: Any) -> Dict[str, str]: # Provider field tells us which native ``id`` belongs to. Without # this, a Deezer track's ``id`` field would be silently ignored - # (we wouldn't know to map it to deezer_id). - provider = (_get(track, 'provider', 'source') or '').lower() + # (we wouldn't know to map it to deezer_id). Convention varies by + # client: Spotify-shaped tracks usually have no provider field, + # Deezer / Discogs / Hydrabase clients tag tracks with ``_source``, + # internal pipeline normalization may use ``source`` or ``provider``. + # Fall back to the caller's source_hint when the track has no + # provider field of its own (Spotify / iTunes raw API responses). + provider = (_get(track, 'provider', 'source', '_source') or source_hint or '').lower() native_id = _get(track, 'id') if native_id and provider: provider_to_key = { diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index 50bda80b..e7528697 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -2077,7 +2077,16 @@ class WatchlistScanner: find_provenance_by_external_id, ) import os as _os_local - source_ids = extract_external_ids(track) + # Pass the configured primary source as a hint so the + # extractor can disambiguate raw Spotify / iTunes API + # responses that don't carry a provider / source field + # of their own (Deezer / Discogs / Hydrabase clients + # already tag tracks with _source). + try: + _source_hint = get_primary_source() + except Exception: + _source_hint = None + source_ids = extract_external_ids(track, source_hint=_source_hint) if source_ids: matched = find_library_track_by_external_id( self.database, diff --git a/tests/test_library_track_identity.py b/tests/test_library_track_identity.py index a7e41a7d..b1e24923 100644 --- a/tests/test_library_track_identity.py +++ b/tests/test_library_track_identity.py @@ -99,11 +99,43 @@ class TestExtractExternalIdsFromProviderField: track = {'source': 'deezer', 'id': 'dz1', 'name': 'Hello'} assert extract_external_ids(track) == {'deezer_id': 'dz1'} + def test_underscore_source_field_treated_same_as_provider(self): + """Deezer / Discogs / Hydrabase clients tag normalized tracks + with ``_source`` (underscore prefix). Must be recognized as the + provider disambiguator — otherwise watchlist scans against those + sources silently miss the ID match and fall through to fuzzy.""" + track = {'_source': 'deezer', 'id': 'dz1', 'name': 'Hello'} + assert extract_external_ids(track) == {'deezer_id': 'dz1'} + + def test_underscore_source_hydrabase(self): + track = {'_source': 'hydrabase', 'id': 'hyd-soul-1', 'name': 'Hello'} + assert extract_external_ids(track) == {'soul_id': 'hyd-soul-1'} + def test_native_id_without_provider_is_ignored(self): """Without a provider field we can't tell which source 'id' belongs to.""" track = {'id': 'unknown', 'name': 'Hello'} assert extract_external_ids(track) == {} + def test_source_hint_disambiguates_when_track_has_no_provider(self): + """Spotify and iTunes raw API responses don't carry a provider / + source / _source field. The watchlist scanner passes the + configured primary source as a hint so the extractor can map + the track's native ``id`` field correctly.""" + track = {'id': 'sp1', 'name': 'Hello'} # No provider field + assert extract_external_ids(track, source_hint='spotify') == {'spotify_id': 'sp1'} + assert extract_external_ids(track, source_hint='itunes') == {'itunes_id': 'sp1'} + assert extract_external_ids(track, source_hint='deezer') == {'deezer_id': 'sp1'} + + def test_source_hint_does_not_override_track_provider(self): + """If the track already carries an explicit provider, that wins + over the hint — defensive against the hint being wrong.""" + track = {'_source': 'deezer', 'id': 'dz1', 'name': 'Hello'} + assert extract_external_ids(track, source_hint='spotify') == {'deezer_id': 'dz1'} + + def test_source_hint_none_is_ignored(self): + track = {'id': 'unknown', 'name': 'Hello'} + assert extract_external_ids(track, source_hint=None) == {} + class TestExtractExternalIdsMixedAndDefensive: def test_track_with_multiple_provider_specific_fields(self):