soulsync/core/source_ids.py
BoulderBadgeDad 4fcc461616 Source IDs: add canonical registry, adopt at the highest-value sites
The same provider ID is stored under inconsistent column names across tables
(deezer_id vs deezer_artist_id vs album_deezer_id vs similar_artist_deezer_id;
spotify/itunes keep an entity qualifier, others don't; musicbrainz uses three
nouns), so code checks 2-5 name variants everywhere.

Add core/source_ids.py as the single source of truth for (provider, entity) ->
column, with accessors that read an ID from a dict/sqlite3.Row robustly
(canonical column first, then known aliases). NO database columns are renamed —
these are the real names today; the registry just centralizes the knowledge.

Targeted adoption (behavior-identical, verified):
- core/artist_source_lookup.SOURCE_ID_FIELD now derives from the registry
  instead of duplicating the mapping (values unchanged).
- web_server.py artist-detail builds artist_source_ids via source_id_map(...)
  instead of a hand-rolled per-source .get() dict.

Broader call-site adoption deferred as clearly-scoped follow-up.

Tests: tests/test_source_ids_registry.py (canonical columns, alias fallback,
canonical-preferred, sqlite3.Row, source_id_map, SOURCE_ID_FIELD unchanged).
Existing artist_source_lookup + artist_full_detail suites still green.
2026-05-29 12:19:59 -07:00

144 lines
5.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Canonical registry of external/source ID column names.
SoulSync stores each metadata provider's ID for an artist/album/track under a
column whose NAME is inconsistent across tables — e.g. Deezer's artist id is
``deezer_id`` on the ``artists`` table but ``deezer_artist_id`` on
``watchlist_artists`` and ``album_deezer_id`` / ``similar_artist_deezer_id`` on
the discovery tables. Spotify/iTunes keep an entity qualifier on the core tables
while Deezer/Amazon/Tidal/... don't, and MusicBrainz uses three different nouns.
The result is code that checks 25 property-name variants everywhere.
This module is the single source of truth for "(provider, entity) → column".
It does NOT rename any database column — these ARE the real names today; the
registry just centralizes the knowledge and offers accessors that read an ID
from a dict / sqlite3.Row robustly (canonical column first, then known aliases),
so callers stop hand-rolling variant checks.
"""
from __future__ import annotations
from typing import Any, Dict, Iterable, Optional
# Entity types this registry knows about.
ENTITIES = ("artist", "album", "track")
# Canonical column name on the CORE table (artists / albums / tracks) for each
# (entity, provider). This is the name to prefer when reading/writing.
_CORE_ID_COLUMNS: Dict[str, Dict[str, str]] = {
"artist": {
"spotify": "spotify_artist_id",
"itunes": "itunes_artist_id",
"deezer": "deezer_id",
"musicbrainz": "musicbrainz_id",
"discogs": "discogs_id",
"amazon": "amazon_id",
"tidal": "tidal_id",
"qobuz": "qobuz_id",
"audiodb": "audiodb_id",
"genius": "genius_id",
"hydrabase": "soul_id",
},
"album": {
"spotify": "spotify_album_id",
"itunes": "itunes_album_id",
"deezer": "deezer_id",
"musicbrainz": "musicbrainz_release_id",
"discogs": "discogs_id",
"amazon": "amazon_id",
"tidal": "tidal_id",
"qobuz": "qobuz_id",
"audiodb": "audiodb_id",
"hydrabase": "soul_id",
},
"track": {
"spotify": "spotify_track_id",
"itunes": "itunes_track_id",
"deezer": "deezer_id",
"musicbrainz": "musicbrainz_recording_id",
"amazon": "amazon_id",
"tidal": "tidal_id",
"qobuz": "qobuz_id",
"audiodb": "audiodb_id",
"genius": "genius_id",
"hydrabase": "soul_id",
},
}
# Other column / dict-key names the SAME (entity, provider) ID appears under
# elsewhere (satellite tables, API payloads). Accessors check the canonical
# column first, then these, so a read works regardless of where the row/dict
# came from. Keyed by (entity, provider).
_ALIASES: Dict[tuple, tuple] = {
("artist", "spotify"): ("similar_artist_spotify_id",),
("artist", "itunes"): ("artist_itunes_id", "similar_artist_itunes_id"),
("artist", "deezer"): ("deezer_artist_id", "artist_deezer_id", "similar_artist_deezer_id"),
("artist", "musicbrainz"): ("musicbrainz_artist_id", "similar_artist_musicbrainz_id"),
("artist", "discogs"): ("discogs_artist_id",),
("artist", "amazon"): ("amazon_artist_id",),
("album", "spotify"): ("album_spotify_id",),
("album", "itunes"): ("album_itunes_id",),
("album", "deezer"): ("deezer_album_id", "album_deezer_id"),
("album", "discogs"): ("discogs_release_id",),
("track", "deezer"): ("deezer_track_id",),
}
def id_column(provider: str, entity: str = "artist") -> Optional[str]:
"""Canonical core-table column for this provider + entity, or None if the
provider isn't tracked for that entity."""
return _CORE_ID_COLUMNS.get(entity, {}).get(provider)
def id_keys(provider: str, entity: str = "artist") -> tuple:
"""All known key names (canonical first, then aliases) the ID may live
under. Useful for code that needs the full variant list explicitly."""
keys = []
canon = id_column(provider, entity)
if canon:
keys.append(canon)
for alias in _ALIASES.get((entity, provider), ()): # preserve order, no dups
if alias not in keys:
keys.append(alias)
return tuple(keys)
def _read(data: Any, key: str) -> Any:
"""Read ``key`` from a dict or sqlite3.Row, returning None if absent."""
try:
keys = data.keys() # dict and sqlite3.Row both support .keys()
except AttributeError:
return None
if key in keys:
try:
return data[key]
except (KeyError, IndexError):
return None
return None
def get_id(data: Any, provider: str, entity: str = "artist") -> Optional[str]:
"""Read this provider's ID for ``entity`` from a dict / sqlite3.Row.
Tries the canonical column first, then every known alias, and returns the
first non-empty value (or None). Replaces hand-rolled
``row.get('deezer_id') or row.get('deezer_artist_id')`` chains.
"""
for key in id_keys(provider, entity):
value = _read(data, key)
if value:
return value
return None
def source_id_map(
data: Any,
entity: str = "artist",
providers: Optional[Iterable[str]] = None,
) -> Dict[str, Optional[str]]:
"""Build a ``{provider: id}`` dict for ``entity`` from a row/dict — the
common "artist_source_ids" pattern. Defaults to every provider known for the
entity; pass ``providers`` to restrict/order the result.
"""
if providers is None:
providers = list(_CORE_ID_COLUMNS.get(entity, {}).keys())
return {p: get_id(data, p, entity) for p in providers}