Merge bf0bfe5976 into 2da1e8b2d9
This commit is contained in:
commit
a942696618
38 changed files with 3350 additions and 185 deletions
|
|
@ -184,7 +184,7 @@ def run_playlist_discovery_worker(playlists, automation_id=None, deps: PlaylistD
|
|||
if cached_match and deps.validate_discovery_cache_artist(artist_name, cached_match):
|
||||
extra_data = {
|
||||
'discovered': True,
|
||||
'provider': discovery_source,
|
||||
'source': discovery_source,
|
||||
'confidence': cached_match.get('confidence', 0.85),
|
||||
'matched_data': cached_match,
|
||||
}
|
||||
|
|
@ -309,7 +309,7 @@ def run_playlist_discovery_worker(playlists, automation_id=None, deps: PlaylistD
|
|||
|
||||
extra_data = {
|
||||
'discovered': True,
|
||||
'provider': discovery_source,
|
||||
'source': discovery_source,
|
||||
'confidence': best_confidence,
|
||||
'matched_data': matched_data,
|
||||
}
|
||||
|
|
@ -337,7 +337,7 @@ def run_playlist_discovery_worker(playlists, automation_id=None, deps: PlaylistD
|
|||
stub = deps.build_discovery_wing_it_stub(track_name, artist_name, duration_ms)
|
||||
extra_data = {
|
||||
'discovered': True,
|
||||
'provider': 'wing_it_fallback',
|
||||
'source': 'wing_it_fallback',
|
||||
'confidence': 0,
|
||||
'wing_it_fallback': True,
|
||||
'matched_data': stub,
|
||||
|
|
|
|||
|
|
@ -243,7 +243,6 @@ def _normalize_track_match(track_item: Any, provider: str) -> dict:
|
|||
'preview_url': _extract_lookup_value(track_item, 'preview_url', default=None),
|
||||
'external_urls': _extract_lookup_value(track_item, 'external_urls', default={}) or {},
|
||||
'popularity': _extract_lookup_value(track_item, 'popularity', default=0) or 0,
|
||||
'provider': provider,
|
||||
'source': provider,
|
||||
}
|
||||
if not track_data['image_url']:
|
||||
|
|
@ -612,7 +611,7 @@ def run_quality_scanner(scope='watchlist', profile_id=1, deps: QualityScannerDep
|
|||
'bitrate': bitrate,
|
||||
'matched': matched,
|
||||
'match_id': matched_track_data['id'] if matched_track_data else None,
|
||||
'provider': best_source if matched else None,
|
||||
'source': best_source if matched else None,
|
||||
'spotify_id': matched_track_data['id'] if matched_track_data else None,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -349,7 +349,7 @@ def run_youtube_discovery_worker(url_hash, deps: YoutubeDiscoveryDeps):
|
|||
if result.get('status_class') in ('found', 'wing-it') and result.get('matched_data'):
|
||||
extra_data = {
|
||||
'discovered': True,
|
||||
'provider': result.get('discovery_source', discovery_source),
|
||||
'source': result.get('discovery_source', discovery_source),
|
||||
'confidence': result.get('confidence', 0),
|
||||
'matched_data': result['matched_data'],
|
||||
}
|
||||
|
|
@ -357,13 +357,13 @@ def run_youtube_discovery_worker(url_hash, deps: YoutubeDiscoveryDeps):
|
|||
extra_data['manual_match'] = True
|
||||
if result.get('wing_it_fallback'):
|
||||
extra_data['wing_it_fallback'] = True
|
||||
extra_data['provider'] = 'wing_it_fallback'
|
||||
extra_data['source'] = 'wing_it_fallback'
|
||||
db.update_mirrored_track_extra_data(db_track_id, extra_data)
|
||||
else:
|
||||
extra_data = {
|
||||
'discovered': False,
|
||||
'discovery_attempted': True,
|
||||
'provider': discovery_source,
|
||||
'source': discovery_source,
|
||||
}
|
||||
db.update_mirrored_track_extra_data(db_track_id, extra_data)
|
||||
logger.info(f"Wrote discovery results to DB for {url_hash}")
|
||||
|
|
|
|||
|
|
@ -96,16 +96,6 @@ def _normalize_album_source(album: Dict[str, Any], source: str = "") -> str:
|
|||
return str(album_source).strip().lower()
|
||||
|
||||
|
||||
def _strip_legacy_source_fields(payload: Any) -> Any:
|
||||
if not isinstance(payload, dict):
|
||||
return payload
|
||||
|
||||
cleaned = dict(payload)
|
||||
cleaned.pop("_source", None)
|
||||
cleaned.pop("provider", None)
|
||||
return cleaned
|
||||
|
||||
|
||||
def _extract_track_artist_name(track: Dict[str, Any]) -> str:
|
||||
artists = track.get("artists") or []
|
||||
if isinstance(artists, (str, bytes)):
|
||||
|
|
@ -138,8 +128,6 @@ def _coerce_track_int(value: Any, default: int = 1) -> int:
|
|||
|
||||
def _normalize_match_track(track: Dict[str, Any], source: str, album: Dict[str, Any]) -> Dict[str, Any]:
|
||||
track_album = track.get("album") if isinstance(track.get("album"), dict) else album
|
||||
if isinstance(track_album, dict):
|
||||
track_album = _strip_legacy_source_fields(track_album)
|
||||
track_source = _normalize_album_source(track, source)
|
||||
track_artists = _normalize_artist_entries(track.get("artists") or [])
|
||||
|
||||
|
|
@ -284,7 +272,6 @@ def build_album_import_context(
|
|||
else:
|
||||
artist_ctx = resolve_album_artist_context(album, source)
|
||||
|
||||
artist_ctx = _strip_legacy_source_fields(artist_ctx)
|
||||
artist_ctx.setdefault("genres", [])
|
||||
artist_ctx.setdefault("source", source)
|
||||
artist_ctx["genres"] = artist_ctx.get("genres") or []
|
||||
|
|
@ -382,10 +369,6 @@ def build_album_import_context(
|
|||
}
|
||||
|
||||
normalized_context = normalize_import_context(context)
|
||||
normalized_context["artist"] = _strip_legacy_source_fields(normalized_context.get("artist"))
|
||||
normalized_context["album"] = _strip_legacy_source_fields(normalized_context.get("album"))
|
||||
normalized_context["track_info"] = _strip_legacy_source_fields(normalized_context.get("track_info"))
|
||||
normalized_context["original_search_result"] = _strip_legacy_source_fields(normalized_context.get("original_search_result"))
|
||||
return normalized_context
|
||||
|
||||
|
||||
|
|
@ -405,7 +388,7 @@ def build_album_import_match_payload(
|
|||
source=source,
|
||||
)
|
||||
|
||||
album = _strip_legacy_source_fields(dict(album_response.get("album") or {}))
|
||||
album = dict(album_response.get("album") or {})
|
||||
source = _normalize_album_source(album, album_response.get("source") or source or "")
|
||||
tracks = list(album_response.get("tracks") or [])
|
||||
if not album_response.get("success") or not tracks:
|
||||
|
|
|
|||
|
|
@ -55,11 +55,11 @@ def extract_artist_name(artist: Any) -> str:
|
|||
|
||||
|
||||
def normalize_import_context(context: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Normalize an import context to neutral fields in place and drop legacy aliases."""
|
||||
"""Normalize an import context to neutral fields in place."""
|
||||
if not isinstance(context, dict):
|
||||
return {}
|
||||
|
||||
source = context.get("source") or context.get("_source") or ""
|
||||
source = context.get("source") or ""
|
||||
artist = _as_dict(context.get("artist") or context.get("spotify_artist"))
|
||||
album = _as_dict(context.get("album") or context.get("spotify_album"))
|
||||
track_info = _as_dict(context.get("track_info"))
|
||||
|
|
@ -73,7 +73,6 @@ def normalize_import_context(context: Optional[Dict[str, Any]]) -> Dict[str, Any
|
|||
context["album"] = album
|
||||
context["track_info"] = track_info
|
||||
context["original_search_result"] = normalized_search
|
||||
context.pop("_source", None)
|
||||
context.pop("spotify_artist", None)
|
||||
context.pop("spotify_album", None)
|
||||
|
||||
|
|
|
|||
|
|
@ -251,7 +251,7 @@ def _build_single_import_context_payload(
|
|||
'album_id': album_id,
|
||||
'album_type': album_type,
|
||||
'release_date': release_date,
|
||||
'_source': source or '',
|
||||
'source': source or '',
|
||||
}
|
||||
|
||||
album_payload = {
|
||||
|
|
@ -263,14 +263,14 @@ def _build_single_import_context_payload(
|
|||
'image_url': album_image_url,
|
||||
'images': album_images,
|
||||
'artists': album_artists,
|
||||
'_source': source or '',
|
||||
'source': source or '',
|
||||
}
|
||||
|
||||
artist_payload = {
|
||||
'id': primary_artist_id,
|
||||
'name': primary_artist_name,
|
||||
'genres': [],
|
||||
'_source': source or '',
|
||||
'source': source or '',
|
||||
}
|
||||
|
||||
original_search = {
|
||||
|
|
@ -285,7 +285,7 @@ def _build_single_import_context_payload(
|
|||
'artists': track_info['artists'],
|
||||
'duration_ms': track_info['duration_ms'],
|
||||
'id': track_id,
|
||||
'_source': source or '',
|
||||
'source': source or '',
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -322,7 +322,7 @@ def _build_single_import_fallback_context(
|
|||
'id': '',
|
||||
'name': artist_name,
|
||||
'genres': [],
|
||||
'_source': '',
|
||||
'source': '',
|
||||
},
|
||||
'album': {
|
||||
'id': '',
|
||||
|
|
@ -333,7 +333,7 @@ def _build_single_import_fallback_context(
|
|||
'image_url': '',
|
||||
'images': [],
|
||||
'artists': [],
|
||||
'_source': '',
|
||||
'source': '',
|
||||
},
|
||||
'track_info': {
|
||||
'id': '',
|
||||
|
|
@ -347,7 +347,7 @@ def _build_single_import_fallback_context(
|
|||
'album_id': '',
|
||||
'album_type': 'album',
|
||||
'release_date': '',
|
||||
'_source': '',
|
||||
'source': '',
|
||||
},
|
||||
'original_search_result': {
|
||||
'title': title,
|
||||
|
|
@ -361,7 +361,7 @@ def _build_single_import_fallback_context(
|
|||
'artists': [{'name': artist_name}],
|
||||
'duration_ms': 0,
|
||||
'id': '',
|
||||
'_source': '',
|
||||
'source': '',
|
||||
},
|
||||
'is_album_download': False,
|
||||
'has_clean_metadata': False,
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ def redownload_start(track_id):
|
|||
|
||||
# Fetch full track details from the metadata source for pipeline parity
|
||||
# This gives us track_number, disc_number, full album data
|
||||
meta_source = metadata.get('_source', '')
|
||||
meta_source = metadata.get('source', '')
|
||||
meta_id = metadata.get('id', '')
|
||||
full_track_details = None
|
||||
full_album_data = None
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Metadata package public surface."""
|
||||
|
||||
from core.metadata.engine import MetadataEngine, MetadataSourceFacade, get_metadata_engine
|
||||
from core.metadata.album_tracks import (
|
||||
get_album_for_source,
|
||||
get_album_tracks_for_source,
|
||||
|
|
@ -10,6 +11,11 @@ from core.metadata.album_tracks import (
|
|||
from core.metadata.artist_image import get_artist_image_url
|
||||
from core.metadata.artwork import is_internal_image_host, normalize_image_url
|
||||
from core.metadata.cache import MetadataCache, get_metadata_cache
|
||||
from core.metadata.constants import (
|
||||
METADATA_PROVIDER_SOURCES,
|
||||
METADATA_SOURCE_LABELS,
|
||||
METADATA_SOURCE_PRIORITY,
|
||||
)
|
||||
from core.metadata.completion import (
|
||||
check_album_completion,
|
||||
check_artist_discography_completion,
|
||||
|
|
@ -20,9 +26,27 @@ from core.metadata.discography import (
|
|||
get_artist_detail_discography,
|
||||
get_artist_discography,
|
||||
)
|
||||
from core.metadata.contracts import (
|
||||
MetadataLookupOutcome,
|
||||
MetadataLookupRequest,
|
||||
MetadataProviderStatus,
|
||||
MetadataSearchOutcome,
|
||||
MetadataSearchRequest,
|
||||
)
|
||||
from core.metadata.exceptions import (
|
||||
MetadataNotFound,
|
||||
MetadataProviderError,
|
||||
MetadataRateLimited,
|
||||
)
|
||||
from core.metadata.lookup import MetadataLookupOptions
|
||||
from core.metadata.models import (
|
||||
MetadataAlbum,
|
||||
MetadataArtist,
|
||||
MetadataPlaylist,
|
||||
MetadataRecord,
|
||||
MetadataTrack,
|
||||
)
|
||||
from core.metadata.registry import (
|
||||
METADATA_SOURCE_PRIORITY,
|
||||
clear_cached_metadata_client,
|
||||
clear_cached_metadata_clients,
|
||||
clear_cached_profile_spotify_client,
|
||||
|
|
@ -30,6 +54,7 @@ from core.metadata.registry import (
|
|||
get_deezer_client,
|
||||
get_discogs_client,
|
||||
get_hydrabase_client,
|
||||
get_enabled_metadata_sources,
|
||||
get_itunes_client,
|
||||
get_primary_client,
|
||||
get_primary_source,
|
||||
|
|
@ -38,6 +63,7 @@ from core.metadata.registry import (
|
|||
get_source_priority,
|
||||
get_spotify_client,
|
||||
is_hydrabase_enabled,
|
||||
is_metadata_source_enabled,
|
||||
register_profile_spotify_credentials_provider,
|
||||
register_runtime_clients,
|
||||
)
|
||||
|
|
@ -56,11 +82,27 @@ from core.metadata.similar_artists import (
|
|||
|
||||
__all__ = [
|
||||
"METADATA_SOURCE_PRIORITY",
|
||||
"METADATA_SOURCE_LABELS",
|
||||
"METADATA_PROVIDER_SOURCES",
|
||||
"METADATA_SOURCE_STATUS_TTL",
|
||||
"MetadataAlbum",
|
||||
"MetadataCache",
|
||||
"MetadataArtist",
|
||||
"MetadataEngine",
|
||||
"MetadataLookupOutcome",
|
||||
"MetadataLookupRequest",
|
||||
"MetadataLookupOptions",
|
||||
"MetadataProvider",
|
||||
"MetadataProviderError",
|
||||
"MetadataProviderStatus",
|
||||
"MetadataRateLimited",
|
||||
"MetadataNotFound",
|
||||
"MetadataRecord",
|
||||
"MetadataService",
|
||||
"MetadataSearchOutcome",
|
||||
"MetadataSearchRequest",
|
||||
"MetadataSourceFacade",
|
||||
"MetadataTrack",
|
||||
"check_album_completion",
|
||||
"check_artist_discography_completion",
|
||||
"check_single_completion",
|
||||
|
|
@ -77,12 +119,14 @@ __all__ = [
|
|||
"get_client_for_source",
|
||||
"get_deezer_client",
|
||||
"get_discogs_client",
|
||||
"get_enabled_metadata_sources",
|
||||
"get_hydrabase_client",
|
||||
"get_itunes_client",
|
||||
"get_metadata_cache",
|
||||
"get_metadata_source_status",
|
||||
"get_metadata_service",
|
||||
"get_musicmap_similar_artists",
|
||||
"get_metadata_engine",
|
||||
"get_primary_client",
|
||||
"get_primary_source",
|
||||
"get_spotify_client_for_profile",
|
||||
|
|
@ -94,6 +138,7 @@ __all__ = [
|
|||
"iter_artist_discography_completion_events",
|
||||
"iter_musicmap_similar_artist_events",
|
||||
"is_hydrabase_enabled",
|
||||
"is_metadata_source_enabled",
|
||||
"is_internal_image_host",
|
||||
"register_profile_spotify_credentials_provider",
|
||||
"register_runtime_clients",
|
||||
|
|
|
|||
|
|
@ -60,10 +60,14 @@ def _get_source_chain_for_lookup(options: MetadataLookupOptions) -> List[str]:
|
|||
primary_source = metadata_registry.get_primary_source()
|
||||
source_chain = list(metadata_registry.get_source_priority(primary_source))
|
||||
override = (options.source_override or '').strip().lower()
|
||||
enabled_sources = tuple(source.strip().lower() for source in (options.enabled_sources or ()) if source and str(source).strip())
|
||||
|
||||
if override:
|
||||
source_chain = [override] + [source for source in source_chain if source != override]
|
||||
|
||||
if enabled_sources:
|
||||
source_chain = [source for source in source_chain if source in enabled_sources]
|
||||
|
||||
if not options.allow_fallback:
|
||||
source_chain = source_chain[:1]
|
||||
|
||||
|
|
@ -176,11 +180,12 @@ def _normalize_context_artists(artists: Any) -> List[Dict[str, Any]]:
|
|||
for artist in artists:
|
||||
if isinstance(artist, dict):
|
||||
name = _extract_lookup_value(artist, 'name', 'artist_name', 'title', default='') or ''
|
||||
artist_id = _extract_lookup_value(artist, 'id', 'artist_id', default='') or ''
|
||||
artist_id = _extract_lookup_value(artist, 'source_id', 'id', 'artist_id', default='') or ''
|
||||
entry: Dict[str, Any] = {}
|
||||
if name:
|
||||
entry['name'] = str(name)
|
||||
if artist_id:
|
||||
entry['source_id'] = str(artist_id)
|
||||
entry['id'] = str(artist_id)
|
||||
genres = _extract_lookup_value(artist, 'genres', default=None)
|
||||
if genres is not None:
|
||||
|
|
@ -289,7 +294,7 @@ def _build_album_info_legacy(album_data: Any, album_id: str,
|
|||
or ''
|
||||
)
|
||||
resolved_artist_id = str(
|
||||
_extract_lookup_value(primary_artist, 'id', default='')
|
||||
_extract_lookup_value(primary_artist, 'source_id', 'id', default='')
|
||||
or _extract_lookup_value(album_data, 'artist_id', default='')
|
||||
or ''
|
||||
).strip()
|
||||
|
|
@ -300,8 +305,10 @@ def _build_album_info_legacy(album_data: Any, album_id: str,
|
|||
if not image_url:
|
||||
image_url = _extract_lookup_value(album_data, 'image_url', 'thumb_url')
|
||||
|
||||
source_id = _extract_lookup_value(album_data, 'source_id', 'id', 'album_id', 'collectionId', 'release_id', default=album_id) or album_id
|
||||
return {
|
||||
'id': _extract_lookup_value(album_data, 'id', 'album_id', 'collectionId', 'release_id', default=album_id) or album_id,
|
||||
'source_id': source_id,
|
||||
'id': source_id,
|
||||
'name': _extract_lookup_value(album_data, 'name', 'title', default=album_name or album_id) or album_name or album_id,
|
||||
'artist': resolved_artist_name or '',
|
||||
'artist_name': resolved_artist_name or '',
|
||||
|
|
@ -320,8 +327,10 @@ def _build_album_track_entry(track_item: Any, album_info: Dict[str, Any], source
|
|||
if isinstance(explicit_value, str):
|
||||
explicit_value = explicit_value.lower() == 'explicit'
|
||||
|
||||
source_id = _extract_lookup_value(track_item, 'source_id', 'id', 'track_id', 'trackId', default='') or ''
|
||||
return {
|
||||
'id': _extract_lookup_value(track_item, 'id', 'track_id', 'trackId', default='') or '',
|
||||
'source_id': source_id,
|
||||
'id': source_id,
|
||||
'name': _extract_lookup_value(track_item, 'name', 'track_name', 'trackName', default='Unknown Track') or 'Unknown Track',
|
||||
'artists': _normalize_track_artists(track_item),
|
||||
'duration_ms': _extract_lookup_value(track_item, 'duration_ms', 'trackTimeMillis', default=0) or 0,
|
||||
|
|
@ -333,8 +342,6 @@ def _build_album_track_entry(track_item: Any, album_info: Dict[str, Any], source
|
|||
'uri': _extract_lookup_value(track_item, 'uri', default='') or '',
|
||||
'album': album_info,
|
||||
'source': source,
|
||||
'provider': source,
|
||||
'_source': source,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -351,8 +358,6 @@ def _build_album_tracks_payload(
|
|||
album_name=album_name, artist_name=artist_name, source=source,
|
||||
)
|
||||
album_info['source'] = source
|
||||
album_info['_source'] = source
|
||||
album_info['provider'] = source
|
||||
track_items = _extract_album_track_items(album_data, tracks_data)
|
||||
tracks = [_build_album_track_entry(track, album_info, source) for track in track_items]
|
||||
|
||||
|
|
@ -439,7 +444,7 @@ def get_artist_albums_for_source(
|
|||
if not best:
|
||||
return albums
|
||||
|
||||
found_artist_id = _extract_lookup_value(best, 'id', 'artist_id')
|
||||
found_artist_id = _extract_lookup_value(best, 'source_id', 'id', 'artist_id')
|
||||
if not found_artist_id:
|
||||
return albums
|
||||
|
||||
|
|
@ -519,9 +524,9 @@ def resolve_album_reference(
|
|||
for album in results:
|
||||
candidate_name = str(_extract_lookup_value(album, 'name', 'title', default='') or '').strip().lower()
|
||||
if candidate_name and candidate_name == str(search_title).strip().lower():
|
||||
return _extract_lookup_value(album, 'id', 'album_id', 'release_id'), source
|
||||
return _extract_lookup_value(album, 'source_id', 'id', 'album_id', 'release_id'), source
|
||||
best = results[0]
|
||||
return _extract_lookup_value(best, 'id', 'album_id', 'release_id'), source
|
||||
return _extract_lookup_value(best, 'source_id', 'id', 'album_id', 'release_id'), source
|
||||
|
||||
if not album_name and not artist_name:
|
||||
return None, None
|
||||
|
|
@ -536,9 +541,9 @@ def resolve_album_reference(
|
|||
for album in results:
|
||||
candidate_name = str(_extract_lookup_value(album, 'name', 'title', default='') or '').strip().lower()
|
||||
if album_name and candidate_name == album_name.strip().lower():
|
||||
return _extract_lookup_value(album, 'id', 'album_id', 'release_id'), source
|
||||
return _extract_lookup_value(album, 'source_id', 'id', 'album_id', 'release_id'), source
|
||||
best = results[0]
|
||||
return _extract_lookup_value(best, 'id', 'album_id', 'release_id'), source
|
||||
return _extract_lookup_value(best, 'source_id', 'id', 'album_id', 'release_id'), source
|
||||
except Exception as e:
|
||||
logger.debug("Error resolving album reference %s: %s", album_id, e)
|
||||
|
||||
|
|
@ -634,6 +639,7 @@ def get_artist_album_tracks(
|
|||
'resolved_album_id': resolved_album_id,
|
||||
'tracks': [],
|
||||
'album': {
|
||||
'source_id': resolved_album_id,
|
||||
'id': resolved_album_id,
|
||||
'name': album_name or resolved_album_id,
|
||||
'image_url': None,
|
||||
|
|
@ -652,6 +658,7 @@ def get_artist_album_tracks(
|
|||
'resolved_album_id': None,
|
||||
'tracks': [],
|
||||
'album': {
|
||||
'source_id': album_id,
|
||||
'id': album_id,
|
||||
'name': album_name or album_id,
|
||||
'image_url': None,
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ def _resolve_completion_track_total(release: Dict[str, Any], source_chain: List[
|
|||
if total_tracks:
|
||||
return int(total_tracks)
|
||||
|
||||
release_id = _extract_lookup_value(release, 'id', 'album_id', 'release_id')
|
||||
release_id = _extract_lookup_value(release, 'source_id', 'id', 'album_id', 'release_id')
|
||||
if not release_id:
|
||||
return 0
|
||||
|
||||
|
|
@ -111,7 +111,7 @@ def check_album_completion(
|
|||
source_chain = source_chain or _get_completion_source_chain(source_override)
|
||||
album_name = album_data.get('name', '')
|
||||
total_tracks = _resolve_completion_track_total(album_data, source_chain)
|
||||
album_id = album_data.get('id', '')
|
||||
album_id = album_data.get('source_id') or album_data.get('id', '')
|
||||
|
||||
# If total_tracks is 0 (Discogs masters don't include track counts),
|
||||
# try to fetch the real count from the prioritized metadata sources.
|
||||
|
|
@ -136,6 +136,7 @@ def check_album_completion(
|
|||
except Exception as db_error:
|
||||
logger.error(f"Database error for album '{album_name}': {db_error}")
|
||||
return {
|
||||
"source_id": album_id,
|
||||
"id": album_id,
|
||||
"name": album_name,
|
||||
"status": "error",
|
||||
|
|
@ -172,6 +173,7 @@ def check_album_completion(
|
|||
)
|
||||
|
||||
return {
|
||||
"source_id": album_id,
|
||||
"id": album_id,
|
||||
"name": album_name,
|
||||
"status": status,
|
||||
|
|
@ -186,7 +188,8 @@ def check_album_completion(
|
|||
except Exception as e:
|
||||
logger.error(f"Error checking album completion for '{album_data.get('name', 'Unknown')}': {e}")
|
||||
return {
|
||||
"id": album_data.get('id', ''),
|
||||
"source_id": album_data.get('source_id') or album_data.get('id', ''),
|
||||
"id": album_data.get('source_id') or album_data.get('id', ''),
|
||||
"name": album_data.get('name', 'Unknown'),
|
||||
"status": "error",
|
||||
"owned_tracks": 0,
|
||||
|
|
@ -213,7 +216,7 @@ def check_single_completion(
|
|||
single_name = single_data.get('name', '')
|
||||
raw_total_tracks = single_data.get('total_tracks', 1)
|
||||
total_tracks = raw_total_tracks if raw_total_tracks is not None else 1
|
||||
single_id = single_data.get('id', '')
|
||||
single_id = single_data.get('source_id') or single_data.get('id', '')
|
||||
album_type = single_data.get('album_type', 'single')
|
||||
formats = []
|
||||
|
||||
|
|
@ -267,6 +270,7 @@ def check_single_completion(
|
|||
)
|
||||
|
||||
return {
|
||||
"source_id": single_id,
|
||||
"id": single_id,
|
||||
"name": single_name,
|
||||
"status": status,
|
||||
|
|
@ -316,6 +320,7 @@ def check_single_completion(
|
|||
)
|
||||
|
||||
return {
|
||||
"source_id": single_id,
|
||||
"id": single_id,
|
||||
"name": single_name,
|
||||
"status": status,
|
||||
|
|
@ -331,7 +336,8 @@ def check_single_completion(
|
|||
except Exception as e:
|
||||
logger.error(f"Error checking single/EP completion for '{single_data.get('name', 'Unknown')}': {e}")
|
||||
return {
|
||||
"id": single_data.get('id', ''),
|
||||
"source_id": single_data.get('source_id') or single_data.get('id', ''),
|
||||
"id": single_data.get('source_id') or single_data.get('id', ''),
|
||||
"name": single_data.get('name', 'Unknown'),
|
||||
"status": "error",
|
||||
"owned_tracks": 0,
|
||||
|
|
@ -411,7 +417,8 @@ def iter_artist_discography_completion_events(
|
|||
yield {
|
||||
'type': 'error',
|
||||
'container_type': 'albums',
|
||||
'id': album.get('id', ''),
|
||||
'source_id': album.get('source_id') or album.get('id', ''),
|
||||
'id': album.get('source_id') or album.get('id', ''),
|
||||
'name': album.get('name', 'Unknown'),
|
||||
'error': str(e),
|
||||
}
|
||||
|
|
@ -436,7 +443,8 @@ def iter_artist_discography_completion_events(
|
|||
yield {
|
||||
'type': 'error',
|
||||
'container_type': 'singles',
|
||||
'id': single.get('id', ''),
|
||||
'source_id': single.get('source_id') or single.get('id', ''),
|
||||
'id': single.get('source_id') or single.get('id', ''),
|
||||
'name': single.get('name', 'Unknown'),
|
||||
'error': str(e),
|
||||
}
|
||||
|
|
|
|||
15
core/metadata/constants.py
Normal file
15
core/metadata/constants.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
"""Shared metadata source constants."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
METADATA_SOURCE_PRIORITY = ("deezer", "itunes", "spotify", "discogs", "hydrabase")
|
||||
|
||||
METADATA_SOURCE_LABELS = {
|
||||
"spotify": "Spotify",
|
||||
"itunes": "iTunes",
|
||||
"deezer": "Deezer",
|
||||
"discogs": "Discogs",
|
||||
"hydrabase": "Hydrabase",
|
||||
}
|
||||
|
||||
METADATA_PROVIDER_SOURCES = ("spotify", "deezer", "itunes", "discogs")
|
||||
71
core/metadata/contracts.py
Normal file
71
core/metadata/contracts.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"""Typed metadata engine contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Generic, Literal, Optional, Sequence, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
MetadataEntityKind = Literal["artist", "album", "track"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MetadataSearchRequest:
|
||||
entity_kind: MetadataEntityKind
|
||||
query: str
|
||||
limit: int = 20
|
||||
source_override: Optional[str] = None
|
||||
enabled_sources: Optional[Sequence[str]] = None
|
||||
allow_fallback: bool = True
|
||||
skip_cache: bool = False
|
||||
max_pages: int = 0
|
||||
dedup_variants: bool = True
|
||||
artist_source_ids: Optional[dict[str, str]] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MetadataLookupRequest:
|
||||
entity_kind: MetadataEntityKind
|
||||
entity_id: str
|
||||
source_override: Optional[str] = None
|
||||
enabled_sources: Optional[Sequence[str]] = None
|
||||
allow_fallback: bool = True
|
||||
skip_cache: bool = False
|
||||
include_tracks: bool = True
|
||||
limit: int = 50
|
||||
max_pages: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MetadataSearchOutcome(Generic[T]):
|
||||
items: list[T] = field(default_factory=list)
|
||||
source: Optional[str] = None
|
||||
attempted_sources: tuple[str, ...] = ()
|
||||
cache_hit: bool = False
|
||||
status: str = "miss"
|
||||
errors: tuple[str, ...] = ()
|
||||
raw_payload: Any = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MetadataLookupOutcome(Generic[T]):
|
||||
value: Optional[T] = None
|
||||
source: Optional[str] = None
|
||||
attempted_sources: tuple[str, ...] = ()
|
||||
cache_hit: bool = False
|
||||
status: str = "miss"
|
||||
errors: tuple[str, ...] = ()
|
||||
raw_payload: Any = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MetadataProviderStatus:
|
||||
provider: str
|
||||
configured: bool
|
||||
available: bool
|
||||
authenticated: bool = False
|
||||
rate_limited: bool = False
|
||||
retry_after: Optional[int] = None
|
||||
last_error: Optional[str] = None
|
||||
details: dict[str, Any] = field(default_factory=dict)
|
||||
|
|
@ -68,10 +68,14 @@ def _get_source_chain_for_lookup(options: MetadataLookupOptions) -> List[str]:
|
|||
primary_source = metadata_registry.get_primary_source()
|
||||
source_chain = list(metadata_registry.get_source_priority(primary_source))
|
||||
override = (options.source_override or '').strip().lower()
|
||||
enabled_sources = tuple(source.strip().lower() for source in (options.enabled_sources or ()) if source and str(source).strip())
|
||||
|
||||
if override:
|
||||
source_chain = [override] + [source for source in source_chain if source != override]
|
||||
|
||||
if enabled_sources:
|
||||
source_chain = [source for source in source_chain if source in enabled_sources]
|
||||
|
||||
if not options.allow_fallback:
|
||||
source_chain = source_chain[:1]
|
||||
|
||||
|
|
@ -142,6 +146,7 @@ def _build_discography_release_dict(release: Any, artist_id: str,
|
|||
return None
|
||||
artist_name = typed_album.artists[0] if typed_album.artists else ''
|
||||
return {
|
||||
'source_id': typed_album.id,
|
||||
'id': typed_album.id,
|
||||
'name': typed_album.name or typed_album.id,
|
||||
'artist_name': artist_name,
|
||||
|
|
@ -152,7 +157,7 @@ def _build_discography_release_dict(release: Any, artist_id: str,
|
|||
'external_urls': typed_album.external_urls or {},
|
||||
}
|
||||
|
||||
release_id = _extract_lookup_value(release, 'id', 'album_id', 'release_id')
|
||||
release_id = _extract_lookup_value(release, 'source_id', 'id', 'album_id', 'release_id')
|
||||
if not release_id:
|
||||
return None
|
||||
|
||||
|
|
@ -160,6 +165,7 @@ def _build_discography_release_dict(release: Any, artist_id: str,
|
|||
release_date = _extract_lookup_value(release, 'release_date')
|
||||
|
||||
return {
|
||||
'source_id': release_id,
|
||||
'id': release_id,
|
||||
'name': _extract_lookup_value(release, 'name', 'title', default=release_id),
|
||||
'artist_name': _extract_release_artist_name(release),
|
||||
|
|
@ -374,7 +380,7 @@ def get_artist_discography(
|
|||
if not release_data:
|
||||
continue
|
||||
|
||||
release_id = release_data['id']
|
||||
release_id = _extract_lookup_value(release_data, 'source_id', 'id')
|
||||
if release_id in seen_albums:
|
||||
continue
|
||||
seen_albums.add(release_id)
|
||||
|
|
@ -427,6 +433,7 @@ def _build_artist_detail_release_card(release: Dict[str, Any],
|
|||
release_year = None
|
||||
|
||||
card = {
|
||||
'source_id': typed_album.id,
|
||||
'id': typed_album.id,
|
||||
'name': typed_album.name or typed_album.id,
|
||||
'title': typed_album.name or typed_album.id,
|
||||
|
|
@ -443,7 +450,7 @@ def _build_artist_detail_release_card(release: Dict[str, Any],
|
|||
card['release_date'] = f"{release_year}-01-01"
|
||||
return card
|
||||
|
||||
release_id = _extract_lookup_value(release, 'id', 'album_id', 'release_id')
|
||||
release_id = _extract_lookup_value(release, 'source_id', 'id', 'album_id', 'release_id')
|
||||
if not release_id:
|
||||
return None
|
||||
|
||||
|
|
@ -461,6 +468,7 @@ def _build_artist_detail_release_card(release: Dict[str, Any],
|
|||
release_year = str(release_year)
|
||||
|
||||
card = {
|
||||
'source_id': release_id,
|
||||
'id': release_id,
|
||||
'name': _extract_lookup_value(release, 'name', 'title', default=release_id),
|
||||
'title': _extract_lookup_value(release, 'name', 'title', default=release_id),
|
||||
|
|
@ -502,7 +510,7 @@ def get_artist_detail_discography(
|
|||
if not card:
|
||||
continue
|
||||
|
||||
release_id = card['id']
|
||||
release_id = _extract_lookup_value(card, 'source_id', 'id')
|
||||
if release_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(release_id)
|
||||
|
|
|
|||
1144
core/metadata/engine.py
Normal file
1144
core/metadata/engine.py
Normal file
File diff suppressed because it is too large
Load diff
34
core/metadata/exceptions.py
Normal file
34
core/metadata/exceptions.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
"""Metadata engine and provider exceptions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
class MetadataProviderError(RuntimeError):
|
||||
"""Base error for metadata provider failures."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider: str,
|
||||
operation: str,
|
||||
message: str,
|
||||
*,
|
||||
status_code: Optional[int] = None,
|
||||
retry_after: Optional[int] = None,
|
||||
payload: Any = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.provider = provider
|
||||
self.operation = operation
|
||||
self.status_code = status_code
|
||||
self.retry_after = retry_after
|
||||
self.payload = payload
|
||||
|
||||
|
||||
class MetadataNotFound(MetadataProviderError):
|
||||
"""Raised when a provider cannot resolve the requested entity."""
|
||||
|
||||
|
||||
class MetadataRateLimited(MetadataProviderError):
|
||||
"""Raised when a provider asks us to back off."""
|
||||
|
|
@ -13,10 +13,10 @@ class MetadataLookupOptions:
|
|||
"""Generic metadata lookup policy shared by metadata services."""
|
||||
|
||||
source_override: Optional[str] = None
|
||||
enabled_sources: Optional[tuple[str, ...]] = None
|
||||
allow_fallback: bool = True
|
||||
skip_cache: bool = False
|
||||
max_pages: int = 0
|
||||
limit: int = 50
|
||||
artist_source_ids: Optional[Dict[str, str]] = None
|
||||
dedup_variants: bool = True
|
||||
|
||||
|
|
|
|||
202
core/metadata/models.py
Normal file
202
core/metadata/models.py
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
"""Canonical typed metadata entities.
|
||||
|
||||
The metadata layer treats ``source_id`` as the authoritative external
|
||||
identifier. ``id`` stays as a compatibility alias for older callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
class MetadataRecord(dict):
|
||||
"""Dictionary payload with attribute access for compatibility.
|
||||
|
||||
``source_id`` is the primary external identifier. ``id`` mirrors it
|
||||
so older call sites keep working while new code can depend on the
|
||||
explicit source-scoped field.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._sync_identity_aliases()
|
||||
|
||||
def __getattr__(self, item: str) -> Any:
|
||||
try:
|
||||
return self[item]
|
||||
except KeyError as exc: # pragma: no cover - standard attribute fallback
|
||||
raise AttributeError(item) from exc
|
||||
|
||||
def __setattr__(self, key: str, value: Any) -> None:
|
||||
self[key] = value
|
||||
|
||||
def __setitem__(self, key: str, value: Any) -> None:
|
||||
if key == "id":
|
||||
source_id = dict.get(self, "source_id")
|
||||
if source_id not in (None, ""):
|
||||
value = source_id
|
||||
dict.__setitem__(self, key, value)
|
||||
self._sync_identity_aliases()
|
||||
|
||||
def update(self, *args, **kwargs) -> None: # pragma: no cover - thin dict wrapper
|
||||
dict.update(self, *args, **kwargs)
|
||||
self._sync_identity_aliases()
|
||||
|
||||
def _sync_identity_aliases(self) -> None:
|
||||
source_id = dict.get(self, "source_id")
|
||||
entity_id = dict.get(self, "id")
|
||||
|
||||
if source_id not in (None, ""):
|
||||
dict.__setitem__(self, "source_id", source_id)
|
||||
dict.__setitem__(self, "id", source_id)
|
||||
return
|
||||
|
||||
if entity_id not in (None, ""):
|
||||
dict.__setitem__(self, "source_id", entity_id)
|
||||
dict.__setitem__(self, "id", entity_id)
|
||||
|
||||
def copy(self): # pragma: no cover - dict compatibility helper
|
||||
return MetadataRecord(super().copy())
|
||||
|
||||
|
||||
def as_metadata_record(data: Optional[dict[str, Any]] = None, **extra: Any) -> MetadataRecord:
|
||||
payload = MetadataRecord(data or {})
|
||||
payload.update(extra)
|
||||
return payload
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MetadataTrack:
|
||||
id: str
|
||||
name: str
|
||||
artists: list[str]
|
||||
album: str
|
||||
duration_ms: int
|
||||
popularity: int
|
||||
preview_url: Optional[str] = None
|
||||
external_urls: Optional[dict[str, str]] = None
|
||||
image_url: Optional[str] = None
|
||||
release_date: Optional[str] = None
|
||||
track_number: Optional[int] = None
|
||||
disc_number: Optional[int] = None
|
||||
album_type: Optional[str] = None
|
||||
total_tracks: Optional[int] = None
|
||||
source: Optional[str] = None
|
||||
source_id: Optional[str] = None
|
||||
raw_data: Optional[dict[str, Any]] = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
source_id = self.source_id or self.id or ""
|
||||
compat_id = source_id or self.id or ""
|
||||
data = {
|
||||
"source_id": source_id,
|
||||
"id": compat_id,
|
||||
"name": self.name,
|
||||
"artists": list(self.artists),
|
||||
"album": self.album,
|
||||
"duration_ms": self.duration_ms,
|
||||
"popularity": self.popularity,
|
||||
"preview_url": self.preview_url,
|
||||
"external_urls": dict(self.external_urls or {}),
|
||||
"image_url": self.image_url,
|
||||
"release_date": self.release_date,
|
||||
"track_number": self.track_number,
|
||||
"disc_number": self.disc_number,
|
||||
"album_type": self.album_type,
|
||||
"total_tracks": self.total_tracks,
|
||||
}
|
||||
if self.source:
|
||||
data["source"] = self.source
|
||||
if self.raw_data is not None:
|
||||
data["raw_data"] = self.raw_data
|
||||
return data
|
||||
|
||||
def to_record(self) -> MetadataRecord:
|
||||
return as_metadata_record(self.to_dict())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MetadataArtist:
|
||||
id: str
|
||||
name: str
|
||||
popularity: int
|
||||
genres: list[str]
|
||||
followers: int
|
||||
image_url: Optional[str] = None
|
||||
external_urls: Optional[dict[str, str]] = None
|
||||
source: Optional[str] = None
|
||||
source_id: Optional[str] = None
|
||||
raw_data: Optional[dict[str, Any]] = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
source_id = self.source_id or self.id or ""
|
||||
compat_id = source_id or self.id or ""
|
||||
data = {
|
||||
"source_id": source_id,
|
||||
"id": compat_id,
|
||||
"name": self.name,
|
||||
"popularity": self.popularity,
|
||||
"genres": list(self.genres),
|
||||
"followers": self.followers,
|
||||
"image_url": self.image_url,
|
||||
"external_urls": dict(self.external_urls or {}),
|
||||
}
|
||||
if self.source:
|
||||
data["source"] = self.source
|
||||
if self.raw_data is not None:
|
||||
data["raw_data"] = self.raw_data
|
||||
return data
|
||||
|
||||
def to_record(self) -> MetadataRecord:
|
||||
return as_metadata_record(self.to_dict())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MetadataAlbum:
|
||||
id: str
|
||||
name: str
|
||||
artists: list[str]
|
||||
release_date: str
|
||||
total_tracks: int
|
||||
album_type: str
|
||||
image_url: Optional[str] = None
|
||||
external_urls: Optional[dict[str, str]] = None
|
||||
source: Optional[str] = None
|
||||
source_id: Optional[str] = None
|
||||
raw_data: Optional[dict[str, Any]] = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
source_id = self.source_id or self.id or ""
|
||||
compat_id = source_id or self.id or ""
|
||||
data = {
|
||||
"source_id": source_id,
|
||||
"id": compat_id,
|
||||
"name": self.name,
|
||||
"artists": list(self.artists),
|
||||
"release_date": self.release_date,
|
||||
"total_tracks": self.total_tracks,
|
||||
"album_type": self.album_type,
|
||||
"image_url": self.image_url,
|
||||
"external_urls": dict(self.external_urls or {}),
|
||||
}
|
||||
if self.source:
|
||||
data["source"] = self.source
|
||||
if self.raw_data is not None:
|
||||
data["raw_data"] = self.raw_data
|
||||
return data
|
||||
|
||||
def to_record(self) -> MetadataRecord:
|
||||
return as_metadata_record(self.to_dict())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MetadataPlaylist:
|
||||
id: str
|
||||
name: str
|
||||
description: Optional[str]
|
||||
owner: str
|
||||
public: bool
|
||||
collaborative: bool
|
||||
tracks: list[MetadataTrack] = field(default_factory=list)
|
||||
total_tracks: int = 0
|
||||
456
core/metadata/normalize.py
Normal file
456
core/metadata/normalize.py
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
"""Normalization helpers for provider payloads."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
from core.metadata.models import MetadataAlbum, MetadataArtist, MetadataTrack
|
||||
|
||||
|
||||
def _clean_itunes_album_name(album_name: str) -> str:
|
||||
if not album_name:
|
||||
return album_name
|
||||
|
||||
for suffix in (" - Single", " - EP"):
|
||||
if album_name.endswith(suffix):
|
||||
return album_name[: -len(suffix)]
|
||||
return album_name
|
||||
|
||||
|
||||
def _as_list(value: Any) -> list[Any]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if isinstance(value, (str, bytes)):
|
||||
return [value]
|
||||
if isinstance(value, dict):
|
||||
return [value]
|
||||
try:
|
||||
return list(value)
|
||||
except TypeError:
|
||||
return [value]
|
||||
|
||||
|
||||
def _extract_image_url(raw: Any, *keys: str) -> Optional[str]:
|
||||
for key in keys:
|
||||
if isinstance(raw, dict) and raw.get(key):
|
||||
return raw.get(key)
|
||||
return None
|
||||
|
||||
|
||||
def _pick_first_image(images: Any) -> Optional[str]:
|
||||
images = _as_list(images)
|
||||
if not images:
|
||||
return None
|
||||
first = images[0]
|
||||
if isinstance(first, dict):
|
||||
return first.get("url") or first.get("uri")
|
||||
return None
|
||||
|
||||
|
||||
def _first_non_empty(*values: Any) -> Any:
|
||||
for value in values:
|
||||
if value not in (None, "", [], {}):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_external_urls(raw: Any, provider: str, fallback_key: str = "link") -> dict[str, str]:
|
||||
if isinstance(raw, dict):
|
||||
external_urls = raw.get("external_urls")
|
||||
if isinstance(external_urls, dict) and external_urls:
|
||||
return dict(external_urls)
|
||||
url = raw.get(fallback_key)
|
||||
if url:
|
||||
return {provider: str(url)}
|
||||
return {}
|
||||
|
||||
|
||||
def _normalize_artists(raw_artists: Any) -> list[str]:
|
||||
artists: list[str] = []
|
||||
for artist in _as_list(raw_artists):
|
||||
if isinstance(artist, dict):
|
||||
name = artist.get("name") or artist.get("artist_name") or artist.get("title")
|
||||
else:
|
||||
name = artist
|
||||
if name:
|
||||
artists.append(str(name))
|
||||
return artists or ["Unknown Artist"]
|
||||
|
||||
|
||||
def _infer_album_type_from_track_count(track_count: int) -> str:
|
||||
if track_count <= 3:
|
||||
return "single"
|
||||
if track_count <= 6:
|
||||
return "ep"
|
||||
return "album"
|
||||
|
||||
|
||||
def _normalize_itunes_album_type(collection_type: str, track_count: int) -> str:
|
||||
collection_type = (collection_type or "").lower()
|
||||
if "compilation" in collection_type:
|
||||
return "compilation"
|
||||
if track_count <= 3:
|
||||
return "single"
|
||||
if track_count <= 6:
|
||||
return "ep"
|
||||
return "album"
|
||||
|
||||
|
||||
def _normalize_discogs_album_type(raw: dict[str, Any]) -> str:
|
||||
formats = raw.get("formats", []) or []
|
||||
format_name = str(formats[0].get("name", "")).lower() if formats else ""
|
||||
descriptions = [str(desc).lower() for desc in (formats[0].get("descriptions", []) if formats else [])]
|
||||
raw_format = raw.get("format") or ""
|
||||
if isinstance(raw_format, list):
|
||||
format_str = ", ".join(raw_format).lower()
|
||||
else:
|
||||
format_str = str(raw_format).lower()
|
||||
|
||||
if "single" in descriptions or "single" in format_name or "single" in format_str:
|
||||
return "single"
|
||||
if "ep" in descriptions or ", ep" in format_str or format_str.endswith("ep"):
|
||||
return "ep"
|
||||
if "compilation" in descriptions or "compilation" in format_str:
|
||||
return "compilation"
|
||||
return "album"
|
||||
|
||||
|
||||
def normalize_spotify_artist(raw: dict[str, Any]) -> MetadataArtist:
|
||||
images = _as_list(raw.get("images"))
|
||||
image_url = _pick_first_image(images)
|
||||
return MetadataArtist(
|
||||
id=str(raw.get("id", "")),
|
||||
name=str(raw.get("name", "")),
|
||||
popularity=int(raw.get("popularity", 0) or 0),
|
||||
genres=list(raw.get("genres") or []),
|
||||
followers=int(_first_non_empty((raw.get("followers") or {}).get("total"), 0) or 0),
|
||||
image_url=image_url,
|
||||
external_urls=dict(raw.get("external_urls") or {}),
|
||||
source="spotify",
|
||||
source_id=str(raw.get("id", "")),
|
||||
raw_data=raw,
|
||||
)
|
||||
|
||||
|
||||
def normalize_spotify_album(raw: dict[str, Any]) -> MetadataAlbum:
|
||||
images = _as_list(raw.get("images"))
|
||||
image_url = _pick_first_image(images)
|
||||
artists = _normalize_artists(raw.get("artists"))
|
||||
return MetadataAlbum(
|
||||
id=str(raw.get("id", "")),
|
||||
name=str(raw.get("name", "")),
|
||||
artists=artists,
|
||||
release_date=str(raw.get("release_date", "") or ""),
|
||||
total_tracks=int(raw.get("total_tracks", 0) or 0),
|
||||
album_type=str(raw.get("album_type", "album") or "album"),
|
||||
image_url=image_url,
|
||||
external_urls=dict(raw.get("external_urls") or {}),
|
||||
source="spotify",
|
||||
source_id=str(raw.get("id", "")),
|
||||
raw_data=raw,
|
||||
)
|
||||
|
||||
|
||||
def normalize_spotify_track(raw: dict[str, Any]) -> MetadataTrack:
|
||||
album = raw.get("album") or {}
|
||||
album_name = str(album.get("name", "") or "")
|
||||
artists = _normalize_artists(raw.get("artists"))
|
||||
image_url = _pick_first_image(album.get("images"))
|
||||
return MetadataTrack(
|
||||
id=str(raw.get("id", "")),
|
||||
name=str(raw.get("name", "")),
|
||||
artists=artists,
|
||||
album=album_name,
|
||||
duration_ms=int(raw.get("duration_ms", 0) or 0),
|
||||
popularity=int(raw.get("popularity", 0) or 0),
|
||||
preview_url=raw.get("preview_url"),
|
||||
external_urls=dict(raw.get("external_urls") or {}),
|
||||
image_url=image_url,
|
||||
release_date=str(album.get("release_date", "") or raw.get("release_date", "") or ""),
|
||||
track_number=raw.get("track_number"),
|
||||
disc_number=raw.get("disc_number"),
|
||||
album_type=str(album.get("album_type", "") or raw.get("album_type", "") or "") or None,
|
||||
total_tracks=int(album.get("total_tracks", 0) or 0) or None,
|
||||
source="spotify",
|
||||
source_id=str(raw.get("id", "")),
|
||||
raw_data=raw,
|
||||
)
|
||||
|
||||
|
||||
def normalize_deezer_artist(raw: dict[str, Any]) -> MetadataArtist:
|
||||
image_url = _first_non_empty(raw.get("picture_xl"), raw.get("picture_big"), raw.get("picture_medium"))
|
||||
return MetadataArtist(
|
||||
id=str(raw.get("id", "")),
|
||||
name=str(raw.get("name", "")),
|
||||
popularity=int(raw.get("rank", 0) or 0),
|
||||
genres=[],
|
||||
followers=int(raw.get("nb_fan", 0) or 0),
|
||||
image_url=image_url,
|
||||
external_urls=_normalize_external_urls(raw, "deezer"),
|
||||
source="deezer",
|
||||
source_id=str(raw.get("id", "")),
|
||||
raw_data=raw,
|
||||
)
|
||||
|
||||
|
||||
def normalize_deezer_album(raw: dict[str, Any]) -> MetadataAlbum:
|
||||
image_url = _first_non_empty(raw.get("cover_xl"), raw.get("cover_big"), raw.get("cover_medium"))
|
||||
artist = raw.get("artist") or {}
|
||||
artists = _normalize_artists([artist] if artist else [])
|
||||
record_type = str(raw.get("record_type", "album") or "album").lower()
|
||||
if record_type == "single":
|
||||
album_type = "single"
|
||||
elif record_type == "ep":
|
||||
album_type = "ep"
|
||||
elif record_type == "compile":
|
||||
album_type = "compilation"
|
||||
else:
|
||||
album_type = _infer_album_type_from_track_count(int(raw.get("nb_tracks", 0) or 0))
|
||||
return MetadataAlbum(
|
||||
id=str(raw.get("id", "")),
|
||||
name=str(raw.get("title", "")),
|
||||
artists=artists,
|
||||
release_date=str(raw.get("release_date", "") or ""),
|
||||
total_tracks=int(raw.get("nb_tracks", 0) or 0),
|
||||
album_type=album_type,
|
||||
image_url=image_url,
|
||||
external_urls=_normalize_external_urls(raw, "deezer"),
|
||||
source="deezer",
|
||||
source_id=str(raw.get("id", "")),
|
||||
raw_data=raw,
|
||||
)
|
||||
|
||||
|
||||
def normalize_deezer_track(raw: dict[str, Any]) -> MetadataTrack:
|
||||
album = raw.get("album") or {}
|
||||
artist = raw.get("artist") or {}
|
||||
contributors = raw.get("contributors") or []
|
||||
artists = _normalize_artists(contributors if len(_as_list(contributors)) > 1 else [artist] if artist else [])
|
||||
image_url = _first_non_empty(album.get("cover_xl"), album.get("cover_big"), album.get("cover_medium"))
|
||||
album_type = str(album.get("type") or "").lower()
|
||||
if not album_type or album_type == "track":
|
||||
nb_tracks = int(album.get("nb_tracks", 0) or 0)
|
||||
album_type = _infer_album_type_from_track_count(nb_tracks)
|
||||
elif album_type == "compile":
|
||||
album_type = "compilation"
|
||||
return MetadataTrack(
|
||||
id=str(raw.get("id", "")),
|
||||
name=str(raw.get("title", "")),
|
||||
artists=artists,
|
||||
album=str(album.get("title", "") or ""),
|
||||
duration_ms=int(raw.get("duration", 0) or 0) * 1000,
|
||||
popularity=int(raw.get("rank", 0) or 0),
|
||||
preview_url=raw.get("preview"),
|
||||
external_urls=_normalize_external_urls(raw, "deezer"),
|
||||
image_url=image_url,
|
||||
release_date=str(raw.get("release_date", "") or album.get("release_date", "") or ""),
|
||||
track_number=raw.get("track_position"),
|
||||
disc_number=raw.get("disk_number", 1) or 1,
|
||||
album_type=str(album_type or "album"),
|
||||
total_tracks=int(album.get("nb_tracks", 0) or 0) or None,
|
||||
source="deezer",
|
||||
source_id=str(raw.get("id", "")),
|
||||
raw_data=raw,
|
||||
)
|
||||
|
||||
|
||||
def normalize_itunes_artist(raw: dict[str, Any]) -> MetadataArtist:
|
||||
image_url = raw.get("artworkUrl100")
|
||||
if image_url:
|
||||
image_url = image_url.replace("100x100bb", "3000x3000bb")
|
||||
return MetadataArtist(
|
||||
id=str(raw.get("artistId", "")),
|
||||
name=str(raw.get("artistName", "")),
|
||||
popularity=0,
|
||||
genres=[raw["primaryGenreName"]] if raw.get("primaryGenreName") else [],
|
||||
followers=0,
|
||||
image_url=image_url,
|
||||
external_urls=_normalize_external_urls(raw, "itunes", "artistViewUrl"),
|
||||
source="itunes",
|
||||
source_id=str(raw.get("artistId", "")),
|
||||
raw_data=raw,
|
||||
)
|
||||
|
||||
|
||||
def normalize_itunes_album(raw: dict[str, Any]) -> MetadataAlbum:
|
||||
artwork = raw.get("artworkUrl100")
|
||||
image_url = artwork.replace("100x100bb", "3000x3000bb") if artwork else None
|
||||
track_count = int(raw.get("trackCount", 0) or 0)
|
||||
return MetadataAlbum(
|
||||
id=str(raw.get("collectionId", "")),
|
||||
name=_clean_itunes_album_name(str(raw.get("collectionName", "") or "")),
|
||||
artists=[str(raw.get("artistName", "Unknown Artist") or "Unknown Artist")],
|
||||
release_date=str(raw.get("releaseDate", "") or ""),
|
||||
total_tracks=track_count,
|
||||
album_type=_normalize_itunes_album_type(str(raw.get("collectionType", "") or ""), track_count),
|
||||
image_url=image_url,
|
||||
external_urls=_normalize_external_urls(raw, "itunes", "collectionViewUrl"),
|
||||
source="itunes",
|
||||
source_id=str(raw.get("collectionId", "")),
|
||||
raw_data=raw,
|
||||
)
|
||||
|
||||
|
||||
def normalize_itunes_track(raw: dict[str, Any], *, clean_artist_name: Optional[str] = None) -> MetadataTrack:
|
||||
artwork = raw.get("artworkUrl100")
|
||||
image_url = artwork.replace("100x100bb", "3000x3000bb") if artwork else None
|
||||
track_count = int(raw.get("trackCount", 0) or 0)
|
||||
if clean_artist_name:
|
||||
artists = [clean_artist_name]
|
||||
else:
|
||||
artists = [str(raw.get("artistName", "Unknown Artist") or "Unknown Artist")]
|
||||
return MetadataTrack(
|
||||
id=str(raw.get("trackId", "")),
|
||||
name=str(raw.get("trackName", "")),
|
||||
artists=artists,
|
||||
album=_clean_itunes_album_name(str(raw.get("collectionName", "") or "")),
|
||||
duration_ms=int(raw.get("trackTimeMillis", 0) or 0),
|
||||
popularity=0,
|
||||
preview_url=raw.get("previewUrl"),
|
||||
external_urls=_normalize_external_urls(raw, "itunes", "trackViewUrl"),
|
||||
image_url=image_url,
|
||||
release_date=str(raw.get("releaseDate", "") or "").split("T")[0] if raw.get("releaseDate") else None,
|
||||
track_number=raw.get("trackNumber"),
|
||||
disc_number=raw.get("discNumber", 1) or 1,
|
||||
album_type=_normalize_itunes_album_type(str(raw.get("collectionType", "") or ""), track_count),
|
||||
total_tracks=track_count or None,
|
||||
source="itunes",
|
||||
source_id=str(raw.get("trackId", "")),
|
||||
raw_data=raw,
|
||||
)
|
||||
|
||||
|
||||
def normalize_discogs_artist(raw: dict[str, Any]) -> MetadataArtist:
|
||||
images = _as_list(raw.get("images"))
|
||||
image_url = _pick_first_image(images)
|
||||
if not image_url:
|
||||
image_url = raw.get("cover_image") or raw.get("thumb")
|
||||
if image_url and "spacer.gif" in str(image_url):
|
||||
image_url = None
|
||||
external_urls = {}
|
||||
if raw.get("uri"):
|
||||
uri = str(raw["uri"])
|
||||
external_urls["discogs"] = f"https://www.discogs.com{uri}" if uri.startswith("/") else uri
|
||||
elif raw.get("resource_url"):
|
||||
external_urls["discogs_api"] = str(raw["resource_url"])
|
||||
return MetadataArtist(
|
||||
id=str(raw.get("id", "")),
|
||||
name=str(raw.get("name", raw.get("title", "")) or ""),
|
||||
popularity=0,
|
||||
genres=[],
|
||||
followers=0,
|
||||
image_url=image_url,
|
||||
external_urls=external_urls,
|
||||
source="discogs",
|
||||
source_id=str(raw.get("id", "")),
|
||||
raw_data=raw,
|
||||
)
|
||||
|
||||
|
||||
def normalize_discogs_album(raw: dict[str, Any]) -> MetadataAlbum:
|
||||
title = str(raw.get("title", "") or "")
|
||||
artists = []
|
||||
if raw.get("artists"):
|
||||
artists = [str(a.get("name", "") or "") for a in raw["artists"] if a.get("name")]
|
||||
elif raw.get("artist"):
|
||||
artists = [str(raw.get("artist") or "")]
|
||||
elif " - " in title:
|
||||
artists = [title.split(" - ", 1)[0].strip()]
|
||||
title = title.split(" - ", 1)[1].strip()
|
||||
if not artists:
|
||||
artists = ["Unknown Artist"]
|
||||
images = _as_list(raw.get("images"))
|
||||
image_url = _pick_first_image(images)
|
||||
if not image_url:
|
||||
image_url = raw.get("cover_image") or raw.get("thumb")
|
||||
if image_url and "spacer.gif" in str(image_url):
|
||||
image_url = None
|
||||
external_urls = {}
|
||||
if raw.get("uri"):
|
||||
uri = str(raw["uri"])
|
||||
external_urls["discogs"] = f"https://www.discogs.com{uri}" if uri.startswith("/") else uri
|
||||
elif raw.get("resource_url"):
|
||||
external_urls["discogs_api"] = str(raw["resource_url"])
|
||||
total_tracks = int(_first_non_empty(len(raw.get("tracklist", []) or []), raw.get("format_quantity", 0), 0) or 0)
|
||||
release_date = str(_first_non_empty(raw.get("year"), raw.get("released"), "") or "")
|
||||
return MetadataAlbum(
|
||||
id=str(raw.get("id", "")),
|
||||
name=title,
|
||||
artists=artists,
|
||||
release_date=release_date,
|
||||
total_tracks=total_tracks,
|
||||
album_type=_normalize_discogs_album_type(raw),
|
||||
image_url=image_url,
|
||||
external_urls=external_urls,
|
||||
source="discogs",
|
||||
source_id=str(raw.get("id", "")),
|
||||
raw_data=raw,
|
||||
)
|
||||
|
||||
|
||||
def normalize_discogs_track(raw: dict[str, Any], release_raw: Optional[dict[str, Any]] = None) -> MetadataTrack:
|
||||
release = release_raw or {}
|
||||
position = str(raw.get("position", "") or "")
|
||||
track_number = None
|
||||
disc_number = 1
|
||||
if position:
|
||||
if "-" in position and position.replace("-", "").isdigit():
|
||||
parts = position.split("-")
|
||||
disc_number = int(parts[0])
|
||||
track_number = int(parts[1])
|
||||
elif position.isdigit():
|
||||
track_number = int(position)
|
||||
else:
|
||||
digits = "".join(c for c in position if c.isdigit())
|
||||
if digits:
|
||||
track_number = int(digits)
|
||||
|
||||
duration_ms = 0
|
||||
dur_str = str(raw.get("duration", "") or "")
|
||||
if ":" in dur_str:
|
||||
parts = dur_str.split(":")
|
||||
try:
|
||||
duration_ms = (int(parts[0]) * 60 + int(parts[1])) * 1000
|
||||
except (ValueError, IndexError):
|
||||
duration_ms = 0
|
||||
|
||||
track_artists = []
|
||||
if raw.get("artists"):
|
||||
track_artists = [a.get("name", "") for a in raw["artists"] if a.get("name")]
|
||||
if not track_artists and release.get("artists"):
|
||||
track_artists = [a.get("name", "") for a in release["artists"] if a.get("name")]
|
||||
if not track_artists:
|
||||
track_artists = ["Unknown Artist"]
|
||||
|
||||
image_url = None
|
||||
images = release.get("images", [])
|
||||
if images:
|
||||
primary = next((img for img in images if img.get("type") == "primary"), None)
|
||||
image_url = (primary or images[0]).get("uri")
|
||||
external_urls = {}
|
||||
if release.get("uri"):
|
||||
uri = str(release["uri"])
|
||||
external_urls["discogs"] = f"https://www.discogs.com{uri}" if uri.startswith("/") else uri
|
||||
|
||||
return MetadataTrack(
|
||||
id=f"{release.get('id', '')}_t{track_number or 0}",
|
||||
name=str(raw.get("title", "") or ""),
|
||||
artists=track_artists,
|
||||
album=str(release.get("title", "") or ""),
|
||||
duration_ms=duration_ms,
|
||||
popularity=int(_first_non_empty(release.get("community", {}).get("have"), 0) or 0),
|
||||
preview_url=None,
|
||||
external_urls=external_urls,
|
||||
image_url=image_url,
|
||||
release_date=str(release.get("year", "") or "") if release.get("year") else None,
|
||||
track_number=track_number,
|
||||
disc_number=disc_number,
|
||||
album_type="album",
|
||||
total_tracks=int(len(release.get("tracklist", []) or [])) or None,
|
||||
source="discogs",
|
||||
source_id=f"{release.get('id', '')}_t{track_number or 0}",
|
||||
raw_data=raw,
|
||||
)
|
||||
13
core/metadata/providers/__init__.py
Normal file
13
core/metadata/providers/__init__.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"""Metadata provider adapters."""
|
||||
|
||||
from .spotify import SpotifyMetadataAdapter
|
||||
from .deezer import DeezerMetadataAdapter
|
||||
from .itunes import ITunesMetadataAdapter
|
||||
from .discogs import DiscogsMetadataAdapter
|
||||
|
||||
__all__ = [
|
||||
"SpotifyMetadataAdapter",
|
||||
"DeezerMetadataAdapter",
|
||||
"ITunesMetadataAdapter",
|
||||
"DiscogsMetadataAdapter",
|
||||
]
|
||||
167
core/metadata/providers/base.py
Normal file
167
core/metadata/providers/base.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
"""Shared transport helpers for metadata providers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from core.metadata.exceptions import MetadataProviderError, MetadataRateLimited
|
||||
from core.metadata.contracts import MetadataProviderStatus
|
||||
|
||||
|
||||
class BaseMetadataAdapter:
|
||||
"""Shared HTTP transport helpers for provider adapters."""
|
||||
|
||||
provider_name: str = "unknown"
|
||||
min_api_interval: float = 0.0
|
||||
timeout: float = 15.0
|
||||
max_retries: int = 2
|
||||
retry_backoff: float = 0.5
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.session = requests.Session()
|
||||
self._lock = threading.RLock()
|
||||
self._next_request_at = 0.0
|
||||
self._rate_limited_until = 0.0
|
||||
self._last_error: Optional[str] = None
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return True
|
||||
|
||||
def is_authenticated(self) -> bool:
|
||||
return self.is_available()
|
||||
|
||||
def reload_config(self) -> None:
|
||||
"""Refresh adapter-local configuration state."""
|
||||
|
||||
def _set_last_error(self, message: Optional[str]) -> None:
|
||||
self._last_error = message
|
||||
|
||||
def _throttle(self) -> None:
|
||||
if self.min_api_interval <= 0:
|
||||
return
|
||||
with self._lock:
|
||||
now = time.time()
|
||||
if now < self._next_request_at:
|
||||
time.sleep(self._next_request_at - now)
|
||||
self._next_request_at = time.time() + self.min_api_interval
|
||||
|
||||
def _request_json(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
params: Optional[dict[str, Any]] = None,
|
||||
headers: Optional[dict[str, str]] = None,
|
||||
timeout: Optional[float] = None,
|
||||
data: Any = None,
|
||||
) -> Any:
|
||||
last_error: Optional[Exception] = None
|
||||
for attempt in range(self.max_retries + 1):
|
||||
self._throttle()
|
||||
try:
|
||||
response = self.session.request(
|
||||
method,
|
||||
url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=timeout or self.timeout,
|
||||
data=data,
|
||||
)
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
self._set_last_error(str(exc))
|
||||
if attempt < self.max_retries:
|
||||
time.sleep(self.retry_backoff * (attempt + 1))
|
||||
continue
|
||||
raise MetadataProviderError(self.provider_name, method.lower(), str(exc)) from exc
|
||||
|
||||
if response.status_code == 429:
|
||||
retry_after = self._parse_retry_after(response.headers.get("Retry-After"))
|
||||
self._rate_limited_until = time.time() + float(retry_after or 60)
|
||||
message = f"{self.provider_name} rate limited"
|
||||
self._set_last_error(message)
|
||||
raise MetadataRateLimited(
|
||||
self.provider_name,
|
||||
method.lower(),
|
||||
message,
|
||||
status_code=429,
|
||||
retry_after=retry_after,
|
||||
payload=response.text,
|
||||
)
|
||||
|
||||
if response.status_code in (404, 204):
|
||||
return None
|
||||
|
||||
if response.status_code >= 500 and attempt < self.max_retries:
|
||||
last_error = MetadataProviderError(
|
||||
self.provider_name,
|
||||
method.lower(),
|
||||
f"{self.provider_name} returned HTTP {response.status_code}",
|
||||
status_code=response.status_code,
|
||||
payload=response.text,
|
||||
)
|
||||
time.sleep(self.retry_backoff * (attempt + 1))
|
||||
continue
|
||||
|
||||
if response.status_code >= 400:
|
||||
message = f"{self.provider_name} returned HTTP {response.status_code}"
|
||||
self._set_last_error(message)
|
||||
raise MetadataProviderError(
|
||||
self.provider_name,
|
||||
method.lower(),
|
||||
message,
|
||||
status_code=response.status_code,
|
||||
payload=response.text,
|
||||
)
|
||||
|
||||
if not response.content:
|
||||
return None
|
||||
|
||||
try:
|
||||
return response.json()
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
self._set_last_error(str(exc))
|
||||
if attempt < self.max_retries:
|
||||
time.sleep(self.retry_backoff * (attempt + 1))
|
||||
continue
|
||||
raise MetadataProviderError(
|
||||
self.provider_name,
|
||||
method.lower(),
|
||||
f"{self.provider_name} returned invalid JSON",
|
||||
status_code=response.status_code,
|
||||
payload=response.text,
|
||||
) from exc
|
||||
|
||||
if last_error is not None:
|
||||
raise MetadataProviderError(self.provider_name, method.lower(), str(last_error)) from last_error
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_retry_after(value: Optional[str]) -> Optional[int]:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return max(0, int(float(value)))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def get_status(self) -> MetadataProviderStatus:
|
||||
retry_after = None
|
||||
rate_limited = False
|
||||
if self._rate_limited_until > time.time():
|
||||
rate_limited = True
|
||||
retry_after = int(self._rate_limited_until - time.time())
|
||||
return MetadataProviderStatus(
|
||||
provider=self.provider_name,
|
||||
configured=self.is_authenticated(),
|
||||
available=self.is_available(),
|
||||
authenticated=self.is_authenticated(),
|
||||
rate_limited=rate_limited,
|
||||
retry_after=retry_after,
|
||||
last_error=self._last_error,
|
||||
)
|
||||
118
core/metadata/providers/deezer.py
Normal file
118
core/metadata/providers/deezer.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"""Deezer metadata adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from core.metadata.providers.base import BaseMetadataAdapter
|
||||
|
||||
|
||||
class DeezerMetadataAdapter(BaseMetadataAdapter):
|
||||
provider_name = "deezer"
|
||||
min_api_interval = 1.0
|
||||
timeout = 15.0
|
||||
|
||||
BASE_URL = "https://api.deezer.com"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._access_token: Optional[str] = None
|
||||
|
||||
def reload_config(self) -> None:
|
||||
self._access_token = None
|
||||
|
||||
def _read_access_token(self) -> Optional[str]:
|
||||
try:
|
||||
from config.settings import config_manager
|
||||
|
||||
token = config_manager.get("deezer.access_token", None)
|
||||
except Exception:
|
||||
token = None
|
||||
return (str(token).strip() or None) if token else None
|
||||
|
||||
def _request_api(self, endpoint: str, params: Optional[dict[str, Any]] = None, timeout: int = 15) -> Optional[dict[str, Any]]:
|
||||
url = f"{self.BASE_URL}/{endpoint.lstrip('/')}"
|
||||
params = dict(params or {})
|
||||
token = self._read_access_token()
|
||||
if token and "access_token" not in params:
|
||||
params["access_token"] = token
|
||||
payload = self._request_json("GET", url, params=params, timeout=timeout)
|
||||
return payload if isinstance(payload, dict) else None
|
||||
|
||||
def is_authenticated(self) -> bool:
|
||||
return True
|
||||
|
||||
def search_tracks_raw(self, query: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||
payload = self._request_api("search/track", {"q": query, "limit": min(limit, 100)})
|
||||
return list(payload.get("data") or []) if payload else []
|
||||
|
||||
def search_artists_raw(self, query: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||
payload = self._request_api("search/artist", {"q": query, "limit": min(limit, 100)})
|
||||
return list(payload.get("data") or []) if payload else []
|
||||
|
||||
def search_albums_raw(self, query: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||
payload = self._request_api("search/album", {"q": query, "limit": min(limit, 100)})
|
||||
return list(payload.get("data") or []) if payload else []
|
||||
|
||||
def get_track_raw(self, track_id: str) -> Optional[dict[str, Any]]:
|
||||
return self._request_api(f"track/{track_id}")
|
||||
|
||||
def get_album_raw(self, album_id: str) -> Optional[dict[str, Any]]:
|
||||
return self._request_api(f"album/{album_id}")
|
||||
|
||||
def get_artist_raw(self, artist_id: str) -> Optional[dict[str, Any]]:
|
||||
return self._request_api(f"artist/{artist_id}")
|
||||
|
||||
def get_album_tracks_raw(self, album_id: str, limit: int = 500, max_pages: int = 0) -> Optional[dict[str, Any]]:
|
||||
data = self._request_api(f"album/{album_id}/tracks", {"limit": min(limit, 500)})
|
||||
if not data or not data.get("data"):
|
||||
album_data = self._request_api(f"album/{album_id}")
|
||||
if album_data and album_data.get("tracks") and album_data["tracks"].get("data"):
|
||||
data = album_data["tracks"]
|
||||
else:
|
||||
return None
|
||||
|
||||
album_info = self._request_api(f"album/{album_id}") or {}
|
||||
items = list(data.get("data") or [])
|
||||
return {
|
||||
"items": items,
|
||||
"total": len(items),
|
||||
"limit": len(items),
|
||||
"next": None,
|
||||
"album": album_info,
|
||||
}
|
||||
|
||||
def get_artist_albums_raw(self, artist_id: str, album_type: str = "album,single", limit: int = 200, max_pages: int = 0) -> list[dict[str, Any]]:
|
||||
albums: list[dict[str, Any]] = []
|
||||
offset = 0
|
||||
page_size = 100
|
||||
artist_data = self.get_artist_raw(artist_id) or {}
|
||||
artist_name = str(artist_data.get("name", "") or "").strip()
|
||||
artist_stub: dict[str, Any] = {"id": artist_data.get("id") or artist_id}
|
||||
if artist_name:
|
||||
artist_stub["name"] = artist_name
|
||||
while offset < limit:
|
||||
fetch_limit = min(page_size, limit - offset)
|
||||
data = self._request_api(f"artist/{artist_id}/albums", {"limit": fetch_limit, "index": offset})
|
||||
if not data or not data.get("data"):
|
||||
break
|
||||
for item in list(data.get("data") or []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
enriched = dict(item)
|
||||
album_artist = enriched.get("artist")
|
||||
if isinstance(album_artist, dict):
|
||||
merged_artist = dict(artist_stub)
|
||||
merged_artist.update(album_artist)
|
||||
enriched["artist"] = merged_artist
|
||||
else:
|
||||
enriched["artist"] = dict(artist_stub)
|
||||
if artist_name and not enriched.get("artist_name"):
|
||||
enriched["artist_name"] = artist_name
|
||||
albums.append(enriched)
|
||||
if len(data.get("data") or []) < fetch_limit:
|
||||
break
|
||||
offset += len(data.get("data") or [])
|
||||
if max_pages and offset >= max_pages * page_size:
|
||||
break
|
||||
return albums[:limit]
|
||||
174
core/metadata/providers/discogs.py
Normal file
174
core/metadata/providers/discogs.py
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
"""Discogs metadata adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
from core.metadata.providers.base import BaseMetadataAdapter
|
||||
|
||||
|
||||
class DiscogsMetadataAdapter(BaseMetadataAdapter):
|
||||
provider_name = "discogs"
|
||||
min_api_interval = 2.5
|
||||
timeout = 15.0
|
||||
|
||||
BASE_URL = "https://api.discogs.com"
|
||||
|
||||
def __init__(self, token: Optional[str] = None) -> None:
|
||||
super().__init__()
|
||||
self.token = token
|
||||
self.reload_config()
|
||||
|
||||
def reload_config(self) -> None:
|
||||
if self.token:
|
||||
self.session.headers["Authorization"] = f"Discogs token={self.token}"
|
||||
else:
|
||||
self.session.headers.pop("Authorization", None)
|
||||
|
||||
def _read_token(self) -> Optional[str]:
|
||||
if self.token is not None:
|
||||
return self.token or None
|
||||
try:
|
||||
from config.settings import config_manager
|
||||
|
||||
token = config_manager.get("discogs.token", "")
|
||||
except Exception:
|
||||
token = ""
|
||||
return (str(token).strip() or None) if token else None
|
||||
|
||||
def _sync_auth_state(self) -> None:
|
||||
token = self._read_token()
|
||||
self.token = token
|
||||
if token:
|
||||
self.session.headers["Authorization"] = f"Discogs token={token}"
|
||||
self.min_api_interval = 1.0
|
||||
else:
|
||||
self.session.headers.pop("Authorization", None)
|
||||
self.min_api_interval = 2.5
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return True
|
||||
|
||||
def is_authenticated(self) -> bool:
|
||||
return bool(self._read_token())
|
||||
|
||||
def _request_api(self, endpoint: str, params: Optional[dict[str, Any]] = None) -> Optional[dict[str, Any]]:
|
||||
self._sync_auth_state()
|
||||
url = f"{self.BASE_URL}/{endpoint.lstrip('/')}"
|
||||
payload = self._request_json("GET", url, params=params, timeout=self.timeout)
|
||||
return payload if isinstance(payload, dict) else None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_name(name: str) -> str:
|
||||
name = (name or "").lower().strip()
|
||||
name = re.sub(r"\s*\(.*?\)\s*", " ", name)
|
||||
name = re.sub(r"[^\w\s]", "", name)
|
||||
name = re.sub(r"\s+", " ", name).strip()
|
||||
return name
|
||||
|
||||
def search_artists_raw(self, query: str, limit: int = 10) -> list[dict[str, Any]]:
|
||||
data = self._request_api(
|
||||
"/database/search",
|
||||
{"q": query, "type": "artist", "per_page": min(limit, 50)},
|
||||
)
|
||||
if not data:
|
||||
return []
|
||||
return list(data.get("results") or [])[:limit]
|
||||
|
||||
def search_albums_raw(self, query: str, limit: int = 10) -> list[dict[str, Any]]:
|
||||
data = self._request_api(
|
||||
"/database/search",
|
||||
{"q": query, "type": "release", "per_page": min(limit, 50)},
|
||||
)
|
||||
if not data:
|
||||
return []
|
||||
return list(data.get("results") or [])[: limit * 2]
|
||||
|
||||
def search_tracks_raw(self, query: str, limit: int = 10) -> list[dict[str, Any]]:
|
||||
return []
|
||||
|
||||
def get_artist_raw(self, artist_id: str) -> Optional[dict[str, Any]]:
|
||||
return self._request_api(f"/artists/{artist_id}")
|
||||
|
||||
def get_album_raw(self, release_id: str) -> Optional[dict[str, Any]]:
|
||||
data = self._request_api(f"/masters/{release_id}")
|
||||
if not data or not data.get("title"):
|
||||
data = self._request_api(f"/releases/{release_id}")
|
||||
return data
|
||||
|
||||
def get_artist_albums_raw(self, artist_id: str, album_type: str = "album,single", limit: int = 50, max_pages: int = 0) -> list[dict[str, Any]]:
|
||||
artist_data = self._request_api(f"/artists/{artist_id}")
|
||||
artist_name = str((artist_data or {}).get("name", "") or "").lower()
|
||||
data = self._request_api(
|
||||
f"/artists/{artist_id}/releases",
|
||||
{"sort": "year", "sort_order": "desc", "per_page": min(limit * 3, 200)},
|
||||
)
|
||||
if not data:
|
||||
return []
|
||||
|
||||
masters: list[dict[str, Any]] = []
|
||||
releases_no_master: list[dict[str, Any]] = []
|
||||
master_titles = set()
|
||||
for item in data.get("releases") or []:
|
||||
role = str(item.get("role", "Main") or "Main").lower()
|
||||
if role not in ("main", ""):
|
||||
continue
|
||||
release_artist = str(item.get("artist", "") or "")
|
||||
if artist_name and release_artist:
|
||||
primary = re.split(r"\s+(?:feat\.?|ft\.?|featuring)\s+", release_artist, flags=re.IGNORECASE)[0]
|
||||
primary = re.split(r"\s*[&,]\s*", primary)[0].strip()
|
||||
if self._normalize_name(primary) != self._normalize_name(artist_name):
|
||||
continue
|
||||
if item.get("type") == "master":
|
||||
masters.append(item)
|
||||
master_titles.add(str(item.get("title", "")).lower())
|
||||
else:
|
||||
releases_no_master.append(item)
|
||||
|
||||
ordered = masters + [r for r in releases_no_master if str(r.get("title", "")).lower() not in master_titles]
|
||||
seen_titles = set()
|
||||
allowed_types = {part.strip() for part in (album_type or "album,single").split(",") if part.strip()}
|
||||
albums: list[dict[str, Any]] = []
|
||||
for item in ordered:
|
||||
title = str(item.get("title", "") or "").lower().strip()
|
||||
if title in seen_titles:
|
||||
continue
|
||||
seen_titles.add(title)
|
||||
album_type_value = "album"
|
||||
formats = item.get("formats", []) or []
|
||||
if formats:
|
||||
fmt = formats[0]
|
||||
descriptions = [str(desc).lower() for desc in (fmt.get("descriptions", []) or [])]
|
||||
format_name = str(fmt.get("name", "") or "").lower()
|
||||
raw_format = item.get("format") or ""
|
||||
if isinstance(raw_format, list):
|
||||
format_str = ", ".join(raw_format).lower()
|
||||
else:
|
||||
format_str = str(raw_format).lower()
|
||||
if "single" in descriptions or "single" in format_name or "single" in format_str:
|
||||
album_type_value = "single"
|
||||
elif "ep" in descriptions or ", ep" in format_str or format_str.endswith("ep"):
|
||||
album_type_value = "ep"
|
||||
elif "compilation" in descriptions or "compilation" in format_str:
|
||||
album_type_value = "compilation"
|
||||
if album_type_value in allowed_types or (album_type_value == "ep" and "single" in allowed_types):
|
||||
albums.append(item)
|
||||
if len(albums) >= limit:
|
||||
break
|
||||
return albums
|
||||
|
||||
def get_album_tracks_raw(self, release_id: str, limit: int = 50, max_pages: int = 0) -> Optional[dict[str, Any]]:
|
||||
data = self._request_api(f"/masters/{release_id}")
|
||||
if not data or not data.get("tracklist"):
|
||||
data = self._request_api(f"/releases/{release_id}")
|
||||
if not data or not data.get("tracklist"):
|
||||
return None
|
||||
track_items = [t for t in data.get("tracklist") or [] if t.get("type_", "") in ("track", "") or not t.get("type_")]
|
||||
return {
|
||||
"items": track_items,
|
||||
"total": len(track_items),
|
||||
"limit": len(track_items),
|
||||
"next": None,
|
||||
"album": data,
|
||||
}
|
||||
210
core/metadata/providers/itunes.py
Normal file
210
core/metadata/providers/itunes.py
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
"""iTunes metadata adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
from core.metadata.providers.base import BaseMetadataAdapter
|
||||
|
||||
|
||||
class ITunesMetadataAdapter(BaseMetadataAdapter):
|
||||
provider_name = "itunes"
|
||||
min_api_interval = 3.0
|
||||
timeout = 30.0
|
||||
|
||||
SEARCH_URL = "https://itunes.apple.com/search"
|
||||
LOOKUP_URL = "https://itunes.apple.com/lookup"
|
||||
FALLBACK_COUNTRIES = ["US", "GB", "FR", "DE", "JP", "AU", "CA", "BR", "KR", "SE"]
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._fixed_country: Optional[str] = None
|
||||
self.reload_config()
|
||||
|
||||
def reload_config(self) -> None:
|
||||
try:
|
||||
from config.settings import config_manager
|
||||
|
||||
country = config_manager.get("itunes.country", "US")
|
||||
except Exception:
|
||||
country = "US"
|
||||
if self._fixed_country:
|
||||
country = self._fixed_country
|
||||
self._country = (country or "US").upper()
|
||||
|
||||
@property
|
||||
def country(self) -> str:
|
||||
try:
|
||||
from config.settings import config_manager
|
||||
|
||||
if self._fixed_country:
|
||||
return self._fixed_country
|
||||
country = config_manager.get("itunes.country", self._country or "US") or "US"
|
||||
return str(country).upper()
|
||||
except Exception:
|
||||
return self._country or "US"
|
||||
|
||||
def is_authenticated(self) -> bool:
|
||||
return True
|
||||
|
||||
def _search_raw(self, term: str, entity: str, limit: int = 50) -> list[dict[str, Any]]:
|
||||
payload = self._request_json(
|
||||
"GET",
|
||||
self.SEARCH_URL,
|
||||
params={
|
||||
"term": term,
|
||||
"country": self.country,
|
||||
"media": "music",
|
||||
"entity": entity,
|
||||
"limit": min(limit, 200),
|
||||
"explicit": "Yes",
|
||||
},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
if not payload:
|
||||
return []
|
||||
results = list(payload.get("results") or [])
|
||||
if entity == "song":
|
||||
return [item for item in results if item.get("wrapperType") == "track" and item.get("kind") == "song"]
|
||||
if entity == "album":
|
||||
return [item for item in results if item.get("wrapperType") == "collection"]
|
||||
if entity == "musicArtist":
|
||||
return [item for item in results if item.get("wrapperType") == "artist"]
|
||||
return results
|
||||
|
||||
def _lookup_raw(self, **params) -> list[dict[str, Any]]:
|
||||
params = dict(params or {})
|
||||
params["country"] = self.country
|
||||
payload = self._request_json("GET", self.LOOKUP_URL, params=params, timeout=self.timeout)
|
||||
if not payload:
|
||||
return []
|
||||
results = list(payload.get("results") or [])
|
||||
if results:
|
||||
return results
|
||||
|
||||
if "id" in params:
|
||||
for fallback in self.FALLBACK_COUNTRIES:
|
||||
if fallback == self.country:
|
||||
continue
|
||||
fallback_params = dict(params)
|
||||
fallback_params["country"] = fallback
|
||||
try:
|
||||
payload = self._request_json("GET", self.LOOKUP_URL, params=fallback_params, timeout=15)
|
||||
except Exception:
|
||||
continue
|
||||
if payload and payload.get("results"):
|
||||
return list(payload.get("results") or [])
|
||||
return []
|
||||
|
||||
def search_tracks_raw(self, query: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||
return self._search_raw(query, "song", limit)
|
||||
|
||||
def search_artists_raw(self, query: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||
return self._search_raw(query, "musicArtist", limit)
|
||||
|
||||
def search_albums_raw(self, query: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||
return self._search_raw(query, "album", limit * 2)
|
||||
|
||||
def get_track_raw(self, track_id: str) -> Optional[dict[str, Any]]:
|
||||
results = self._lookup_raw(id=track_id)
|
||||
for item in results:
|
||||
if item.get("wrapperType") == "track":
|
||||
return item
|
||||
return None
|
||||
|
||||
def get_album_raw(self, album_id: str) -> Optional[dict[str, Any]]:
|
||||
results = self._lookup_raw(id=album_id)
|
||||
for item in results:
|
||||
if item.get("wrapperType") == "collection":
|
||||
return item
|
||||
return None
|
||||
|
||||
def get_artist_raw(self, artist_id: str) -> Optional[dict[str, Any]]:
|
||||
results = self._lookup_raw(id=artist_id)
|
||||
for item in results:
|
||||
if item.get("wrapperType") == "artist":
|
||||
return item
|
||||
return None
|
||||
|
||||
def get_album_tracks_raw(self, album_id: str, limit: int = 50, max_pages: int = 0) -> Optional[dict[str, Any]]:
|
||||
results = self._lookup_raw(id=album_id, entity="song", limit=min(limit, 200))
|
||||
if not results:
|
||||
return None
|
||||
|
||||
album_raw = None
|
||||
tracks: list[dict[str, Any]] = []
|
||||
for item in results:
|
||||
if item.get("wrapperType") == "collection" and album_raw is None:
|
||||
album_raw = item
|
||||
elif item.get("wrapperType") == "track" and item.get("kind") == "song":
|
||||
tracks.append(item)
|
||||
|
||||
if not tracks:
|
||||
return None
|
||||
|
||||
return {
|
||||
"items": tracks,
|
||||
"total": len(tracks),
|
||||
"limit": len(tracks),
|
||||
"next": None,
|
||||
"album": album_raw,
|
||||
}
|
||||
|
||||
def get_artist_albums_raw(self, artist_id: str, album_type: str = "album,single", limit: int = 200, max_pages: int = 0) -> list[dict[str, Any]]:
|
||||
results = self._lookup_raw(id=artist_id, entity="album", limit=min(limit, 200))
|
||||
if not results:
|
||||
return []
|
||||
|
||||
seen: dict[str, dict[str, Any]] = {}
|
||||
track_count_cache: dict[str, int] = {}
|
||||
|
||||
def _normalize_album_name(name: str) -> str:
|
||||
normalized = (name or "").lower().strip()
|
||||
normalized = re.sub(
|
||||
r"\s*[\(\[]\s*(deluxe|explicit|clean|remaster|expanded|anniversary|edition|version|bonus|special|standard).*?[\)\]]",
|
||||
"",
|
||||
normalized,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
normalized = re.sub(r"\s*[-–—]\s*(deluxe|explicit|clean|remaster|expanded|anniversary|edition|version).*?$", "", normalized, flags=re.IGNORECASE)
|
||||
normalized = re.sub(r"\s+", " ", normalized).strip()
|
||||
return normalized
|
||||
|
||||
def _album_has_tracks(collection_id: str) -> bool:
|
||||
if not collection_id:
|
||||
return False
|
||||
if collection_id not in track_count_cache:
|
||||
items = self._lookup_raw(id=collection_id, entity="song", limit=200)
|
||||
track_count_cache[collection_id] = sum(
|
||||
1
|
||||
for item in items
|
||||
if item.get("wrapperType") == "track" and item.get("kind") == "song"
|
||||
)
|
||||
return track_count_cache[collection_id] > 0
|
||||
|
||||
for album_data in results:
|
||||
if album_data.get("wrapperType") != "collection":
|
||||
continue
|
||||
normalized_name = _normalize_album_name(str(album_data.get("collectionName", "") or ""))
|
||||
collection_id = str(album_data.get("collectionId", "") or "")
|
||||
current = seen.get(normalized_name)
|
||||
is_explicit = album_data.get("collectionExplicitness") == "explicit"
|
||||
has_tracks = not is_explicit or _album_has_tracks(collection_id)
|
||||
if not has_tracks:
|
||||
continue
|
||||
if current is None:
|
||||
seen[normalized_name] = {"data": album_data, "is_explicit": is_explicit}
|
||||
continue
|
||||
if is_explicit and not current["is_explicit"]:
|
||||
seen[normalized_name] = {"data": album_data, "is_explicit": True}
|
||||
|
||||
albums = [item["data"] for item in seen.values()]
|
||||
return albums[:limit]
|
||||
|
||||
def get_artist_image_from_albums_raw(self, artist_id: str) -> Optional[str]:
|
||||
results = self._lookup_raw(id=artist_id, entity="album", limit=1)
|
||||
for item in results:
|
||||
if item.get("wrapperType") == "collection" and item.get("artworkUrl100"):
|
||||
return str(item["artworkUrl100"]).replace("100x100bb", "600x600bb")
|
||||
return None
|
||||
213
core/metadata/providers/spotify.py
Normal file
213
core/metadata/providers/spotify.py
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
"""Spotify metadata adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
from core.metadata.exceptions import MetadataProviderError, MetadataRateLimited
|
||||
from core.metadata.providers.base import BaseMetadataAdapter
|
||||
|
||||
|
||||
class SpotifyMetadataAdapter(BaseMetadataAdapter):
|
||||
provider_name = "spotify"
|
||||
min_api_interval = 0.35
|
||||
timeout = 15.0
|
||||
|
||||
TOKEN_URL = "https://accounts.spotify.com/api/token"
|
||||
BASE_URL = "https://api.spotify.com/v1"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._credential_fingerprint: Optional[tuple[str, str]] = None
|
||||
self._access_token: Optional[str] = None
|
||||
self._token_expires_at = 0.0
|
||||
|
||||
def _read_credentials(self) -> tuple[Optional[str], Optional[str]]:
|
||||
try:
|
||||
from config.settings import config_manager
|
||||
|
||||
config = config_manager.get("spotify", {}) or {}
|
||||
except Exception:
|
||||
config = {}
|
||||
client_id = (config.get("client_id") or "").strip() or None
|
||||
client_secret = (config.get("client_secret") or "").strip() or None
|
||||
return client_id, client_secret
|
||||
|
||||
def is_available(self) -> bool:
|
||||
client_id, client_secret = self._read_credentials()
|
||||
return bool(client_id and client_secret)
|
||||
|
||||
def is_authenticated(self) -> bool:
|
||||
return self.is_available()
|
||||
|
||||
def reload_config(self) -> None:
|
||||
self._credential_fingerprint = None
|
||||
self._access_token = None
|
||||
self._token_expires_at = 0.0
|
||||
|
||||
def _get_access_token(self) -> Optional[str]:
|
||||
client_id, client_secret = self._read_credentials()
|
||||
if not client_id or not client_secret:
|
||||
return None
|
||||
|
||||
fingerprint = (client_id, client_secret)
|
||||
if fingerprint != self._credential_fingerprint:
|
||||
self._credential_fingerprint = fingerprint
|
||||
self._access_token = None
|
||||
self._token_expires_at = 0.0
|
||||
|
||||
now = time.time()
|
||||
if self._access_token and now < self._token_expires_at:
|
||||
return self._access_token
|
||||
|
||||
basic = base64.b64encode(f"{client_id}:{client_secret}".encode("utf-8")).decode("ascii")
|
||||
headers = {
|
||||
"Authorization": f"Basic {basic}",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
}
|
||||
response = self.session.post(
|
||||
self.TOKEN_URL,
|
||||
headers=headers,
|
||||
data={"grant_type": "client_credentials"},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
if response.status_code == 429:
|
||||
retry_after = self._parse_retry_after(response.headers.get("Retry-After"))
|
||||
raise MetadataRateLimited(
|
||||
self.provider_name,
|
||||
"token",
|
||||
"Spotify token endpoint rate limited",
|
||||
status_code=429,
|
||||
retry_after=retry_after,
|
||||
payload=response.text,
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
raise MetadataProviderError(
|
||||
self.provider_name,
|
||||
"token",
|
||||
f"Spotify token request failed with HTTP {response.status_code}",
|
||||
status_code=response.status_code,
|
||||
payload=response.text,
|
||||
)
|
||||
|
||||
payload = response.json()
|
||||
token = payload.get("access_token")
|
||||
if not token:
|
||||
raise MetadataProviderError(
|
||||
self.provider_name,
|
||||
"token",
|
||||
"Spotify token response did not include an access token",
|
||||
status_code=response.status_code,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
expires_in = int(payload.get("expires_in", 3600) or 3600)
|
||||
self._access_token = str(token)
|
||||
self._token_expires_at = now + max(60, expires_in - 60)
|
||||
return self._access_token
|
||||
|
||||
def _auth_headers(self) -> dict[str, str]:
|
||||
token = self._get_access_token()
|
||||
if not token:
|
||||
return {}
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
def _request_api(
|
||||
self,
|
||||
method: str,
|
||||
path_or_url: str,
|
||||
*,
|
||||
params: Optional[dict[str, Any]] = None,
|
||||
) -> Any:
|
||||
url = path_or_url if path_or_url.startswith("http") else f"{self.BASE_URL}/{path_or_url.lstrip('/')}"
|
||||
return self._request_json(method, url, params=params, headers=self._auth_headers())
|
||||
|
||||
def search_tracks_raw(self, query: str, limit: int = 10) -> list[dict[str, Any]]:
|
||||
payload = self._request_api(
|
||||
"GET",
|
||||
"search",
|
||||
params={"q": query, "type": "track", "limit": min(limit, 50)},
|
||||
)
|
||||
if not payload:
|
||||
return []
|
||||
return list((payload.get("tracks") or {}).get("items") or [])
|
||||
|
||||
def search_artists_raw(self, query: str, limit: int = 10) -> list[dict[str, Any]]:
|
||||
search_query = f"artist:{query}" if len((query or "").strip()) <= 4 else query
|
||||
payload = self._request_api(
|
||||
"GET",
|
||||
"search",
|
||||
params={"q": search_query, "type": "artist", "limit": min(limit, 50)},
|
||||
)
|
||||
if not payload:
|
||||
return []
|
||||
return list((payload.get("artists") or {}).get("items") or [])
|
||||
|
||||
def search_albums_raw(self, query: str, limit: int = 10) -> list[dict[str, Any]]:
|
||||
payload = self._request_api(
|
||||
"GET",
|
||||
"search",
|
||||
params={"q": query, "type": "album", "limit": min(limit, 50)},
|
||||
)
|
||||
if not payload:
|
||||
return []
|
||||
return list((payload.get("albums") or {}).get("items") or [])
|
||||
|
||||
def get_track_raw(self, track_id: str) -> Optional[dict[str, Any]]:
|
||||
return self._request_api("GET", f"tracks/{track_id}")
|
||||
|
||||
def get_album_raw(self, album_id: str) -> Optional[dict[str, Any]]:
|
||||
return self._request_api("GET", f"albums/{album_id}")
|
||||
|
||||
def get_artist_raw(self, artist_id: str) -> Optional[dict[str, Any]]:
|
||||
return self._request_api("GET", f"artists/{artist_id}")
|
||||
|
||||
def get_track_features_raw(self, track_id: str) -> Optional[dict[str, Any]]:
|
||||
return self._request_api("GET", f"audio-features/{track_id}")
|
||||
|
||||
def get_album_tracks_raw(self, album_id: str, limit: int = 50, max_pages: int = 0) -> Optional[dict[str, Any]]:
|
||||
first_page = self._request_api("GET", f"albums/{album_id}/tracks", params={"limit": min(limit, 50)})
|
||||
if not first_page or not first_page.get("items"):
|
||||
return None
|
||||
|
||||
all_tracks = list(first_page.get("items") or [])
|
||||
page_count = 1
|
||||
next_page = first_page
|
||||
while next_page.get("next") and (max_pages <= 0 or page_count < max_pages):
|
||||
next_page = self._request_api("GET", next_page["next"])
|
||||
page_count += 1
|
||||
if next_page and next_page.get("items"):
|
||||
all_tracks.extend(next_page.get("items") or [])
|
||||
|
||||
result = dict(first_page)
|
||||
result["items"] = all_tracks
|
||||
result["next"] = None
|
||||
result["limit"] = len(all_tracks)
|
||||
return result
|
||||
|
||||
def get_artist_albums_raw(
|
||||
self,
|
||||
artist_id: str,
|
||||
album_type: str = "album,single",
|
||||
limit: int = 10,
|
||||
max_pages: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
results = self._request_api(
|
||||
"GET",
|
||||
f"artists/{artist_id}/albums",
|
||||
params={"include_groups": album_type, "limit": min(limit, 50)},
|
||||
)
|
||||
if not results:
|
||||
return []
|
||||
|
||||
albums = list(results.get("items") or [])
|
||||
page_count = 1
|
||||
next_page = results
|
||||
while next_page.get("next") and (max_pages <= 0 or page_count < max_pages):
|
||||
next_page = self._request_api("GET", next_page["next"])
|
||||
page_count += 1
|
||||
if next_page and next_page.get("items"):
|
||||
albums.extend(next_page.get("items") or [])
|
||||
return albums
|
||||
|
|
@ -12,21 +12,18 @@ import hashlib
|
|||
import time
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from core.metadata.constants import (
|
||||
METADATA_PROVIDER_SOURCES,
|
||||
METADATA_SOURCE_LABELS,
|
||||
METADATA_SOURCE_PRIORITY,
|
||||
)
|
||||
from core.metadata.engine import clear_metadata_engine_cache, get_metadata_engine
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("metadata.registry")
|
||||
|
||||
MetadataClientFactory = Callable[[], Any]
|
||||
|
||||
METADATA_SOURCE_PRIORITY = ("deezer", "itunes", "spotify", "discogs", "hydrabase")
|
||||
METADATA_SOURCE_LABELS = {
|
||||
"spotify": "Spotify",
|
||||
"itunes": "iTunes",
|
||||
"deezer": "Deezer",
|
||||
"discogs": "Discogs",
|
||||
"hydrabase": "Hydrabase",
|
||||
}
|
||||
|
||||
_UNSET = object()
|
||||
_client_cache_lock = threading.RLock()
|
||||
_client_cache: Dict[str, Any] = {}
|
||||
|
|
@ -83,12 +80,21 @@ def clear_cached_metadata_clients() -> None:
|
|||
"""
|
||||
with _client_cache_lock:
|
||||
_client_cache.clear()
|
||||
try:
|
||||
clear_metadata_engine_cache()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def clear_cached_metadata_client(cache_key: str) -> None:
|
||||
"""Clear one lazily-created client singleton by cache key."""
|
||||
with _client_cache_lock:
|
||||
_client_cache.pop(cache_key, None)
|
||||
if cache_key == "metadata_engine":
|
||||
try:
|
||||
clear_metadata_engine_cache()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def clear_cached_profile_spotify_client(profile_id: int) -> None:
|
||||
|
|
@ -108,6 +114,40 @@ def _get_config_value(key: str, default: Any = None) -> Any:
|
|||
return default
|
||||
|
||||
|
||||
def _normalize_source(source: Optional[str]) -> str:
|
||||
return (source or "").strip().lower()
|
||||
|
||||
|
||||
def get_enabled_metadata_sources() -> tuple[str, ...]:
|
||||
"""Return globally-enabled metadata provider sources."""
|
||||
configured = _get_config_value("metadata.enabled_sources", None)
|
||||
if configured is None:
|
||||
return METADATA_SOURCE_PRIORITY
|
||||
if isinstance(configured, str):
|
||||
enabled = tuple(
|
||||
source.strip().lower()
|
||||
for source in configured.split(",")
|
||||
if source.strip()
|
||||
)
|
||||
return enabled
|
||||
try:
|
||||
enabled = tuple(
|
||||
source.strip().lower()
|
||||
for source in configured
|
||||
if str(source).strip()
|
||||
)
|
||||
return enabled
|
||||
except TypeError:
|
||||
return METADATA_SOURCE_PRIORITY
|
||||
|
||||
|
||||
def is_metadata_source_enabled(source: str) -> bool:
|
||||
enabled = get_enabled_metadata_sources()
|
||||
if not enabled:
|
||||
return False
|
||||
return _normalize_source(source) in enabled
|
||||
|
||||
|
||||
def _get_spotify_factory(client_factory: Optional[MetadataClientFactory]) -> MetadataClientFactory:
|
||||
if client_factory is not None:
|
||||
return client_factory
|
||||
|
|
@ -289,16 +329,32 @@ def get_hydrabase_client(allow_fallback: bool = True, require_enabled: bool = Tr
|
|||
def get_primary_source(spotify_client_factory: Optional[MetadataClientFactory] = None) -> str:
|
||||
"""Return configured primary metadata source."""
|
||||
source = _get_config_value("metadata.fallback_source", "deezer") or "deezer"
|
||||
source = _normalize_source(source)
|
||||
|
||||
if source == "spotify":
|
||||
try:
|
||||
spotify = get_spotify_client(client_factory=spotify_client_factory)
|
||||
if not spotify or not spotify.is_spotify_authenticated():
|
||||
return "deezer"
|
||||
except Exception:
|
||||
return "deezer"
|
||||
candidates = get_source_priority(source)
|
||||
enabled_sources = set(get_enabled_metadata_sources())
|
||||
for candidate in candidates:
|
||||
if enabled_sources and candidate not in enabled_sources:
|
||||
continue
|
||||
if candidate == "spotify":
|
||||
try:
|
||||
spotify = get_spotify_client(client_factory=spotify_client_factory)
|
||||
if spotify and spotify.is_spotify_authenticated():
|
||||
return candidate
|
||||
except Exception:
|
||||
continue
|
||||
continue
|
||||
if candidate == "hydrabase":
|
||||
if is_hydrabase_enabled():
|
||||
return candidate
|
||||
continue
|
||||
client = get_client_for_source(candidate)
|
||||
if client is not None:
|
||||
return candidate
|
||||
|
||||
return source
|
||||
if enabled_sources and source in enabled_sources:
|
||||
return source
|
||||
return get_source_priority(source)[0]
|
||||
|
||||
|
||||
def get_spotify_disconnect_source(configured_source: Optional[str] = None) -> str:
|
||||
|
|
@ -333,13 +389,29 @@ def get_primary_client(
|
|||
discogs_client_factory: Optional[MetadataClientFactory] = None,
|
||||
):
|
||||
"""Return client for configured primary source."""
|
||||
return get_client_for_source(
|
||||
get_primary_source(spotify_client_factory=spotify_client_factory),
|
||||
spotify_client_factory=spotify_client_factory,
|
||||
itunes_client_factory=itunes_client_factory,
|
||||
deezer_client_factory=deezer_client_factory,
|
||||
discogs_client_factory=discogs_client_factory,
|
||||
)
|
||||
primary_source = get_primary_source(spotify_client_factory=spotify_client_factory)
|
||||
if primary_source == "spotify":
|
||||
try:
|
||||
client = get_spotify_client(client_factory=spotify_client_factory)
|
||||
if client and client.is_spotify_authenticated():
|
||||
return client
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
if primary_source == "deezer":
|
||||
return get_deezer_client(client_factory=deezer_client_factory)
|
||||
|
||||
if primary_source == "itunes":
|
||||
return get_itunes_client(client_factory=itunes_client_factory)
|
||||
|
||||
if primary_source == "discogs":
|
||||
return get_discogs_client(client_factory=discogs_client_factory)
|
||||
|
||||
if primary_source == "hydrabase":
|
||||
return get_hydrabase_client(allow_fallback=False)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_primary_source_status(
|
||||
|
|
@ -350,26 +422,20 @@ def get_primary_source_status(
|
|||
discogs_client_factory: Optional[MetadataClientFactory] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return a generic status snapshot for the active primary metadata source."""
|
||||
source = _get_config_value("metadata.fallback_source", "deezer") or "deezer"
|
||||
source = get_primary_source(spotify_client_factory=spotify_client_factory)
|
||||
started = time.time()
|
||||
connected = False
|
||||
|
||||
try:
|
||||
client = get_client_for_source(
|
||||
source,
|
||||
spotify_client_factory=spotify_client_factory,
|
||||
itunes_client_factory=itunes_client_factory,
|
||||
deezer_client_factory=deezer_client_factory,
|
||||
discogs_client_factory=discogs_client_factory,
|
||||
)
|
||||
if source == "spotify":
|
||||
connected = bool(client and client.is_spotify_authenticated())
|
||||
elif source == "hydrabase":
|
||||
if source == "hydrabase":
|
||||
client = get_hydrabase_client(allow_fallback=False)
|
||||
connected = bool(client and (client.is_connected() if hasattr(client, "is_connected") else client.is_authenticated()))
|
||||
elif client is not None and hasattr(client, "is_authenticated"):
|
||||
connected = bool(client.is_authenticated())
|
||||
elif source == "spotify":
|
||||
client = get_spotify_client(client_factory=spotify_client_factory)
|
||||
connected = bool(client and client.is_spotify_authenticated())
|
||||
else:
|
||||
connected = client is not None
|
||||
status = get_metadata_engine().get_provider_status(source)
|
||||
connected = bool(status.authenticated or status.available)
|
||||
except Exception:
|
||||
connected = False
|
||||
|
||||
|
|
@ -389,25 +455,30 @@ def get_client_for_source(
|
|||
discogs_client_factory: Optional[MetadataClientFactory] = None,
|
||||
):
|
||||
"""Return exact client for a source, or None if unavailable."""
|
||||
if source == "spotify":
|
||||
try:
|
||||
client = get_spotify_client(client_factory=spotify_client_factory)
|
||||
if client and client.is_spotify_authenticated():
|
||||
return client
|
||||
except Exception as e:
|
||||
logger.debug("spotify client get_for_source: %s", e)
|
||||
return None
|
||||
|
||||
if source == "deezer":
|
||||
return get_deezer_client(client_factory=deezer_client_factory)
|
||||
|
||||
if source == "discogs":
|
||||
return get_discogs_client(client_factory=discogs_client_factory)
|
||||
source = _normalize_source(source)
|
||||
|
||||
if source == "hydrabase":
|
||||
if not is_metadata_source_enabled(source):
|
||||
return None
|
||||
return get_hydrabase_client(allow_fallback=False)
|
||||
|
||||
if source == "itunes":
|
||||
return get_itunes_client(client_factory=itunes_client_factory)
|
||||
if source not in METADATA_PROVIDER_SOURCES:
|
||||
return None
|
||||
|
||||
return None
|
||||
if not is_metadata_source_enabled(source):
|
||||
return None
|
||||
|
||||
engine = get_metadata_engine()
|
||||
facade = engine.get_source_facade(source)
|
||||
if facade is None:
|
||||
return None
|
||||
|
||||
if source == "spotify":
|
||||
try:
|
||||
status = engine.get_provider_status(source)
|
||||
if not status.authenticated:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
return facade
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from __future__ import annotations
|
|||
|
||||
from typing import Any, Dict, List, Optional, Literal
|
||||
|
||||
from core.metadata.engine import get_metadata_engine
|
||||
from core.metadata.registry import (
|
||||
get_client_for_source,
|
||||
get_primary_source,
|
||||
|
|
@ -62,60 +63,148 @@ class MetadataService:
|
|||
def _get_client(self):
|
||||
provider = self.get_active_provider()
|
||||
if provider == "spotify":
|
||||
if not self.spotify or not self.spotify.is_spotify_authenticated():
|
||||
logger.warning(
|
||||
"Spotify requested but not authenticated, falling back to %s",
|
||||
self._fallback_source,
|
||||
)
|
||||
return self.itunes
|
||||
return self.spotify
|
||||
spotify_client = get_client_for_source("spotify")
|
||||
return spotify_client or self.itunes
|
||||
return get_client_for_source(provider)
|
||||
|
||||
def _get_metadata_client(self, provider: str):
|
||||
provider = (provider or "").strip().lower()
|
||||
if provider == "hydrabase":
|
||||
return get_client_for_source(provider)
|
||||
return get_client_for_source(provider)
|
||||
|
||||
def _get_spotify_fallback_client(self):
|
||||
if self.spotify and self.spotify.is_spotify_authenticated():
|
||||
return None
|
||||
return self.itunes
|
||||
|
||||
def search_tracks(self, query: str, limit: int = 20) -> List:
|
||||
client = self._get_client()
|
||||
provider = self.get_active_provider()
|
||||
logger.debug("Searching tracks with %s: %r", provider, query)
|
||||
return client.search_tracks(query, limit)
|
||||
fallback_client = self._get_spotify_fallback_client() if provider == "spotify" else None
|
||||
if fallback_client and hasattr(fallback_client, "search_tracks"):
|
||||
return list(fallback_client.search_tracks(query, limit=limit))
|
||||
if provider == "hydrabase":
|
||||
client = self._get_metadata_client(provider)
|
||||
if client and hasattr(client, "search_tracks"):
|
||||
return list(client.search_tracks(query, limit=limit))
|
||||
return []
|
||||
outcome = get_metadata_engine().search_tracks(query, limit=limit, source_override=provider, allow_fallback=True)
|
||||
return list(outcome.items)
|
||||
|
||||
def search_artists(self, query: str, limit: int = 20) -> List:
|
||||
client = self._get_client()
|
||||
provider = self.get_active_provider()
|
||||
logger.debug("Searching artists with %s: %r", provider, query)
|
||||
return client.search_artists(query, limit)
|
||||
fallback_client = self._get_spotify_fallback_client() if provider == "spotify" else None
|
||||
if fallback_client and hasattr(fallback_client, "search_artists"):
|
||||
return list(fallback_client.search_artists(query, limit=limit))
|
||||
if provider == "hydrabase":
|
||||
client = self._get_metadata_client(provider)
|
||||
if client and hasattr(client, "search_artists"):
|
||||
return list(client.search_artists(query, limit=limit))
|
||||
return []
|
||||
outcome = get_metadata_engine().search_artists(query, limit=limit, source_override=provider, allow_fallback=True)
|
||||
return list(outcome.items)
|
||||
|
||||
def search_albums(self, query: str, limit: int = 20) -> List:
|
||||
client = self._get_client()
|
||||
provider = self.get_active_provider()
|
||||
logger.debug("Searching albums with %s: %r", provider, query)
|
||||
return client.search_albums(query, limit)
|
||||
fallback_client = self._get_spotify_fallback_client() if provider == "spotify" else None
|
||||
if fallback_client and hasattr(fallback_client, "search_albums"):
|
||||
return list(fallback_client.search_albums(query, limit=limit))
|
||||
if provider == "hydrabase":
|
||||
client = self._get_metadata_client(provider)
|
||||
if client and hasattr(client, "search_albums"):
|
||||
return list(client.search_albums(query, limit=limit))
|
||||
return []
|
||||
outcome = get_metadata_engine().search_albums(query, limit=limit, source_override=provider, allow_fallback=True)
|
||||
return list(outcome.items)
|
||||
|
||||
def get_track_details(self, track_id: str) -> Optional[Dict[str, Any]]:
|
||||
client = self._get_client()
|
||||
return client.get_track_details(track_id)
|
||||
provider = self.get_active_provider()
|
||||
fallback_client = self._get_spotify_fallback_client() if provider == "spotify" else None
|
||||
if fallback_client and hasattr(fallback_client, "get_track_details"):
|
||||
return fallback_client.get_track_details(track_id)
|
||||
if provider == "hydrabase":
|
||||
client = self._get_metadata_client(provider)
|
||||
if client and hasattr(client, "get_track_details"):
|
||||
return client.get_track_details(track_id)
|
||||
return None
|
||||
outcome = get_metadata_engine().get_track_details(track_id, source_override=provider, allow_fallback=True)
|
||||
return outcome.value
|
||||
|
||||
def get_album(self, album_id: str) -> Optional[Dict[str, Any]]:
|
||||
client = self._get_client()
|
||||
return client.get_album(album_id)
|
||||
provider = self.get_active_provider()
|
||||
fallback_client = self._get_spotify_fallback_client() if provider == "spotify" else None
|
||||
if fallback_client and hasattr(fallback_client, "get_album"):
|
||||
return fallback_client.get_album(album_id)
|
||||
if provider == "hydrabase":
|
||||
client = self._get_metadata_client(provider)
|
||||
if client and hasattr(client, "get_album"):
|
||||
return client.get_album(album_id)
|
||||
return None
|
||||
outcome = get_metadata_engine().get_album(album_id, source_override=provider, allow_fallback=True)
|
||||
return outcome.value
|
||||
|
||||
def get_album_tracks(self, album_id: str) -> Optional[Dict[str, Any]]:
|
||||
client = self._get_client()
|
||||
provider = self.get_active_provider()
|
||||
logger.debug("Fetching album tracks with %s: %s", provider, album_id)
|
||||
return client.get_album_tracks(album_id)
|
||||
fallback_client = self._get_spotify_fallback_client() if provider == "spotify" else None
|
||||
if fallback_client and hasattr(fallback_client, "get_album_tracks"):
|
||||
return fallback_client.get_album_tracks(album_id)
|
||||
if provider == "hydrabase":
|
||||
client = self._get_metadata_client(provider)
|
||||
if client and hasattr(client, "get_album_tracks"):
|
||||
return client.get_album_tracks(album_id)
|
||||
return None
|
||||
outcome = get_metadata_engine().get_album_tracks(album_id, source_override=provider, allow_fallback=True)
|
||||
return outcome.value
|
||||
|
||||
def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]:
|
||||
client = self._get_client()
|
||||
return client.get_artist(artist_id)
|
||||
provider = self.get_active_provider()
|
||||
fallback_client = self._get_spotify_fallback_client() if provider == "spotify" else None
|
||||
if fallback_client and hasattr(fallback_client, "get_artist"):
|
||||
return fallback_client.get_artist(artist_id)
|
||||
if provider == "hydrabase":
|
||||
client = self._get_metadata_client(provider)
|
||||
if client and hasattr(client, "get_artist"):
|
||||
return client.get_artist(artist_id)
|
||||
return None
|
||||
outcome = get_metadata_engine().get_artist(artist_id, source_override=provider, allow_fallback=True)
|
||||
return outcome.value
|
||||
|
||||
def get_artist_albums(self, artist_id: str, album_type: str = "album,single", limit: int = 50) -> List:
|
||||
client = self._get_client()
|
||||
provider = self.get_active_provider()
|
||||
logger.debug("Fetching artist albums with %s: %s", provider, artist_id)
|
||||
return client.get_artist_albums(artist_id, album_type, limit)
|
||||
fallback_client = self._get_spotify_fallback_client() if provider == "spotify" else None
|
||||
if fallback_client and hasattr(fallback_client, "get_artist_albums"):
|
||||
return list(fallback_client.get_artist_albums(artist_id, album_type=album_type, limit=limit))
|
||||
if provider == "hydrabase":
|
||||
client = self._get_metadata_client(provider)
|
||||
if client and hasattr(client, "get_artist_albums"):
|
||||
return list(client.get_artist_albums(artist_id, album_type=album_type, limit=limit))
|
||||
return []
|
||||
outcome = get_metadata_engine().get_artist_albums(
|
||||
artist_id,
|
||||
album_type=album_type,
|
||||
limit=limit,
|
||||
source_override=provider,
|
||||
allow_fallback=True,
|
||||
)
|
||||
return list(outcome.items)
|
||||
|
||||
def get_track_features(self, track_id: str) -> Optional[Dict[str, Any]]:
|
||||
client = self._get_client()
|
||||
return client.get_track_features(track_id)
|
||||
provider = self.get_active_provider()
|
||||
fallback_client = self._get_spotify_fallback_client() if provider == "spotify" else None
|
||||
if fallback_client and hasattr(fallback_client, "get_track_features"):
|
||||
return fallback_client.get_track_features(track_id)
|
||||
if provider == "hydrabase":
|
||||
client = self._get_metadata_client(provider)
|
||||
if client and hasattr(client, "get_track_features"):
|
||||
return client.get_track_features(track_id)
|
||||
return None
|
||||
outcome = get_metadata_engine().get_track_features(track_id, source_override=provider, allow_fallback=True)
|
||||
return outcome.value
|
||||
|
||||
def get_user_playlists(self) -> List:
|
||||
if self.spotify and self.spotify.is_spotify_authenticated():
|
||||
|
|
@ -155,6 +244,7 @@ class MetadataService:
|
|||
logger.info("Reloading metadata service configuration")
|
||||
if self.spotify and hasattr(self.spotify, "reload_config"):
|
||||
self.spotify.reload_config()
|
||||
get_metadata_engine().reload_config()
|
||||
new_source = get_primary_source()
|
||||
self._fallback_source = new_source
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -29,10 +29,14 @@ def _get_source_chain_for_lookup(options: MetadataLookupOptions) -> List[str]:
|
|||
primary_source = metadata_registry.get_primary_source()
|
||||
source_chain = list(metadata_registry.get_source_priority(primary_source))
|
||||
override = (options.source_override or '').strip().lower()
|
||||
enabled_sources = tuple(source.strip().lower() for source in (options.enabled_sources or ()) if source and str(source).strip())
|
||||
|
||||
if override:
|
||||
source_chain = [override] + [source for source in source_chain if source != override]
|
||||
|
||||
if enabled_sources:
|
||||
source_chain = [source for source in source_chain if source in enabled_sources]
|
||||
|
||||
if not options.allow_fallback:
|
||||
source_chain = source_chain[:1]
|
||||
|
||||
|
|
@ -82,7 +86,7 @@ def _fetch_musicmap_similar_artist_names(artist_name: str) -> List[str]:
|
|||
|
||||
|
||||
def _build_similar_artist_payload(artist_data: Any, source: str) -> Optional[Dict[str, Any]]:
|
||||
artist_id = _extract_lookup_value(artist_data, 'id', 'artist_id', 'spotify_id', 'itunes_id', 'deezer_id')
|
||||
artist_id = _extract_lookup_value(artist_data, 'source_id', 'id', 'artist_id', 'spotify_id', 'itunes_id', 'deezer_id')
|
||||
if not artist_id:
|
||||
return None
|
||||
|
||||
|
|
@ -113,6 +117,7 @@ def _build_similar_artist_payload(artist_data: Any, source: str) -> Optional[Dic
|
|||
popularity = 0
|
||||
|
||||
return {
|
||||
'source_id': str(artist_id),
|
||||
'id': str(artist_id),
|
||||
'name': str(name or artist_id),
|
||||
'image_url': _extract_artist_image_url(artist_data),
|
||||
|
|
@ -132,7 +137,7 @@ def _resolve_musicmap_artist_source_ids(artist_name: str, source_chain: List[str
|
|||
continue
|
||||
|
||||
search_results = _search_artists_for_source(source, client, artist_name, limit=1)
|
||||
searched_source_ids[source] = _extract_lookup_value(search_results[0], 'id', 'artist_id') if search_results else None
|
||||
searched_source_ids[source] = _extract_lookup_value(search_results[0], 'source_id', 'id', 'artist_id') if search_results else None
|
||||
|
||||
return searched_source_ids
|
||||
|
||||
|
|
@ -165,7 +170,7 @@ def _match_musicmap_similar_artist(
|
|||
if matched_name and matched_name == searched_name:
|
||||
continue
|
||||
|
||||
matched_id = _extract_lookup_value(matched_artist, 'id', 'artist_id')
|
||||
matched_id = _extract_lookup_value(matched_artist, 'source_id', 'id', 'artist_id')
|
||||
if not matched_id:
|
||||
continue
|
||||
|
||||
|
|
@ -244,7 +249,7 @@ def iter_musicmap_similar_artist_events(
|
|||
if not payload:
|
||||
continue
|
||||
|
||||
payload_id = str(payload.get('id') or '')
|
||||
payload_id = str(payload.get('source_id') or payload.get('id') or '')
|
||||
if payload_id in seen_ids:
|
||||
continue
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from core.metadata.album_tracks import (
|
|||
get_artist_albums_for_source,
|
||||
resolve_album_reference,
|
||||
)
|
||||
from core.metadata.engine import MetadataEngine, MetadataSourceFacade, get_metadata_engine
|
||||
from core.metadata.artist_image import get_artist_image_url
|
||||
from core.metadata.cache import MetadataCache, get_metadata_cache
|
||||
from core.metadata.completion import (
|
||||
|
|
@ -45,6 +46,7 @@ from core.metadata.registry import (
|
|||
get_client_for_source,
|
||||
get_deezer_client,
|
||||
get_discogs_client,
|
||||
get_enabled_metadata_sources,
|
||||
get_hydrabase_client,
|
||||
get_itunes_client,
|
||||
get_primary_client,
|
||||
|
|
@ -54,6 +56,7 @@ from core.metadata.registry import (
|
|||
get_source_priority,
|
||||
get_spotify_client,
|
||||
is_hydrabase_enabled,
|
||||
is_metadata_source_enabled,
|
||||
register_profile_spotify_credentials_provider,
|
||||
register_runtime_clients,
|
||||
)
|
||||
|
|
@ -76,9 +79,11 @@ except Exception: # pragma: no cover - optional dependency fallback
|
|||
__all__ = [
|
||||
"METADATA_SOURCE_PRIORITY",
|
||||
"MetadataCache",
|
||||
"MetadataEngine",
|
||||
"MetadataLookupOptions",
|
||||
"MetadataProvider",
|
||||
"MetadataService",
|
||||
"MetadataSourceFacade",
|
||||
"SpotifyClient",
|
||||
"iTunesClient",
|
||||
"_build_artist_detail_release_card",
|
||||
|
|
@ -106,9 +111,11 @@ __all__ = [
|
|||
"get_client_for_source",
|
||||
"get_deezer_client",
|
||||
"get_discogs_client",
|
||||
"get_enabled_metadata_sources",
|
||||
"get_hydrabase_client",
|
||||
"get_itunes_client",
|
||||
"get_metadata_cache",
|
||||
"get_metadata_engine",
|
||||
"get_metadata_service",
|
||||
"get_musicmap_similar_artists",
|
||||
"get_primary_client",
|
||||
|
|
@ -120,6 +127,7 @@ __all__ = [
|
|||
"iter_artist_discography_completion_events",
|
||||
"iter_musicmap_similar_artist_events",
|
||||
"is_hydrabase_enabled",
|
||||
"is_metadata_source_enabled",
|
||||
"register_profile_spotify_credentials_provider",
|
||||
"register_runtime_clients",
|
||||
"requests",
|
||||
|
|
|
|||
|
|
@ -105,8 +105,8 @@ def playlist_explorer_build_tree(deps: PlaylistExplorerDeps):
|
|||
|
||||
# Only use discovery data if it matches the active metadata source
|
||||
is_discovered = extra.get('discovered', False)
|
||||
provider = (extra.get('provider') or '').lower()
|
||||
source_matches = provider == source_name or (provider in ('itunes', 'apple') and source_name == 'itunes')
|
||||
source = (extra.get('source') or '').lower()
|
||||
source_matches = source == source_name or (source in ('itunes', 'apple') and source_name == 'itunes')
|
||||
|
||||
matched = extra.get('matched_data', {}) if (is_discovered and source_matches) else {}
|
||||
artists_list = matched.get('artists', [])
|
||||
|
|
@ -359,5 +359,3 @@ def playlist_explorer_build_tree(deps: PlaylistExplorerDeps):
|
|||
import traceback
|
||||
traceback.print_exc()
|
||||
return deps.flask_jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -365,7 +365,7 @@ class AlbumCompletenessJob(RepairJob):
|
|||
'track_number': tn,
|
||||
'name': item.get('name', ''),
|
||||
'disc_number': item.get('disc_number', 1),
|
||||
'source': item.get('_source', primary_source),
|
||||
'source': item.get('source', primary_source),
|
||||
'source_track_id': item.get('id', ''),
|
||||
'track_id': item.get('id', ''),
|
||||
'spotify_track_id': item.get('id', ''),
|
||||
|
|
|
|||
|
|
@ -1725,12 +1725,11 @@ class WatchlistScanner:
|
|||
def _match_to_itunes(self, artist_name: str) -> Optional[str]:
|
||||
"""Match artist name to iTunes ID using fuzzy name comparison."""
|
||||
try:
|
||||
if hasattr(self, '_metadata_service') and self._metadata_service:
|
||||
results = self._metadata_service.itunes.search_artists(artist_name, limit=5)
|
||||
else:
|
||||
logger.warning("Cannot match to iTunes - MetadataService not available")
|
||||
client = get_client_for_source('itunes')
|
||||
if not client or not hasattr(client, 'search_artists'):
|
||||
logger.warning("Cannot match to iTunes - provider unavailable")
|
||||
return None
|
||||
|
||||
results = client.search_artists(artist_name, limit=5)
|
||||
return self._best_artist_match(results, artist_name)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not match {artist_name} to iTunes: {e}")
|
||||
|
|
@ -1739,17 +1738,9 @@ class WatchlistScanner:
|
|||
def _match_to_deezer(self, artist_name: str) -> Optional[str]:
|
||||
"""Match artist name to Deezer ID using fuzzy name comparison."""
|
||||
try:
|
||||
# Try MetadataService fallback client (if it's Deezer)
|
||||
if hasattr(self, '_metadata_service') and self._metadata_service:
|
||||
client = self._metadata_service.itunes # Named 'itunes' but may be DeezerClient
|
||||
from core.deezer_client import DeezerClient
|
||||
if isinstance(client, DeezerClient):
|
||||
results = client.search_artists(artist_name, limit=5)
|
||||
return self._best_artist_match(results, artist_name)
|
||||
|
||||
# Fallback: use cached Deezer client
|
||||
from core.metadata.registry import get_deezer_client
|
||||
client = get_deezer_client()
|
||||
client = get_client_for_source('deezer')
|
||||
if not client or not hasattr(client, 'search_artists'):
|
||||
return None
|
||||
results = client.search_artists(artist_name, limit=5)
|
||||
return self._best_artist_match(results, artist_name)
|
||||
except Exception as e:
|
||||
|
|
@ -1759,8 +1750,9 @@ class WatchlistScanner:
|
|||
def _match_to_discogs(self, artist_name: str) -> Optional[str]:
|
||||
"""Match artist name to Discogs ID using fuzzy name comparison."""
|
||||
try:
|
||||
from core.metadata.registry import get_discogs_client
|
||||
client = get_discogs_client()
|
||||
client = get_client_for_source('discogs')
|
||||
if not client or not hasattr(client, 'search_artists'):
|
||||
return None
|
||||
results = client.search_artists(artist_name, limit=5)
|
||||
return self._best_artist_match(results, artist_name)
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -193,11 +193,7 @@ class WishlistService:
|
|||
"track_name": track_name,
|
||||
"artist_name": artist_name,
|
||||
"album_name": album_name,
|
||||
"provider": (
|
||||
track_data.get("provider") or track_data.get("source")
|
||||
if isinstance(track_data, dict)
|
||||
else None
|
||||
),
|
||||
"source": track_data.get("source") if isinstance(track_data, dict) else None,
|
||||
"spotify_track_id": wishlist_track["spotify_track_id"],
|
||||
"spotify_data": track_data,
|
||||
"failure_reason": wishlist_track["failure_reason"],
|
||||
|
|
|
|||
|
|
@ -270,7 +270,7 @@ def test_match_above_threshold_writes_extra_data():
|
|||
assert len(deps._db.extra_data_writes) == 1
|
||||
_, extra = deps._db.extra_data_writes[0]
|
||||
assert extra['discovered'] is True
|
||||
assert extra['provider'] == 'spotify'
|
||||
assert extra['source'] == 'spotify'
|
||||
assert extra['confidence'] == 0.92
|
||||
assert deps._db.cache_saves # saved to cache
|
||||
|
||||
|
|
@ -289,7 +289,7 @@ def test_match_below_threshold_falls_back_to_wing_it():
|
|||
|
||||
assert len(deps._db.extra_data_writes) == 1
|
||||
_, extra = deps._db.extra_data_writes[0]
|
||||
assert extra['provider'] == 'wing_it_fallback'
|
||||
assert extra['source'] == 'wing_it_fallback'
|
||||
assert extra['wing_it_fallback'] is True
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -326,7 +326,6 @@ def test_low_quality_tracks_follow_source_priority(mock_db_and_wishlist):
|
|||
assert spotify_client.search_calls == []
|
||||
assert len(ws.added) == 1
|
||||
add_args = ws.added[0]
|
||||
assert add_args['track_data']['provider'] == 'deezer'
|
||||
assert add_args['track_data']['source'] == 'deezer'
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -64,9 +64,9 @@ def test_normalize_import_context_promotes_neutral_fields_without_legacy_aliases
|
|||
assert get_import_has_full_metadata(normalized) is False
|
||||
|
||||
|
||||
def test_normalize_import_context_promotes_legacy_source_alias():
|
||||
def test_normalize_import_context_keeps_source_field():
|
||||
context = {
|
||||
"_source": "spotify",
|
||||
"source": "spotify",
|
||||
"artist": {"name": "Artist One", "id": "artist-1"},
|
||||
"album": {"name": "Album One", "id": "album-1"},
|
||||
"track_info": {"name": "Song One", "id": "track-1"},
|
||||
|
|
@ -76,7 +76,6 @@ def test_normalize_import_context_promotes_legacy_source_alias():
|
|||
normalized = normalize_import_context(context)
|
||||
|
||||
assert normalized["source"] == "spotify"
|
||||
assert "_source" not in normalized
|
||||
assert get_import_source(normalized) == "spotify"
|
||||
|
||||
|
||||
|
|
|
|||
141
tests/metadata/test_metadata_engine.py
Normal file
141
tests/metadata/test_metadata_engine.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
if "spotipy" not in sys.modules:
|
||||
spotipy = types.ModuleType("spotipy")
|
||||
oauth2 = types.ModuleType("spotipy.oauth2")
|
||||
|
||||
class _DummySpotify:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
class _DummyOAuth:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
spotipy.Spotify = _DummySpotify
|
||||
oauth2.SpotifyOAuth = _DummyOAuth
|
||||
oauth2.SpotifyClientCredentials = _DummyOAuth
|
||||
spotipy.oauth2 = oauth2
|
||||
sys.modules["spotipy"] = spotipy
|
||||
sys.modules["spotipy.oauth2"] = oauth2
|
||||
|
||||
if "config.settings" not in sys.modules:
|
||||
config_pkg = types.ModuleType("config")
|
||||
settings_mod = types.ModuleType("config.settings")
|
||||
|
||||
class _DummyConfigManager:
|
||||
def get(self, key, default=None):
|
||||
return default
|
||||
|
||||
def get_active_media_server(self):
|
||||
return "primary"
|
||||
|
||||
settings_mod.config_manager = _DummyConfigManager()
|
||||
config_pkg.settings = settings_mod
|
||||
sys.modules["config"] = config_pkg
|
||||
sys.modules["config.settings"] = settings_mod
|
||||
|
||||
from core.metadata.engine import MetadataEngine, MetadataSourceFacade
|
||||
from core.metadata.models import MetadataArtist, MetadataRecord
|
||||
from core.metadata.registry import get_client_for_source
|
||||
from core.metadata.service import MetadataService
|
||||
|
||||
|
||||
def test_get_client_for_source_returns_source_facade():
|
||||
client = get_client_for_source("itunes")
|
||||
|
||||
assert isinstance(client, MetadataSourceFacade)
|
||||
assert client.source == "itunes"
|
||||
assert client.is_connected() is True
|
||||
assert hasattr(client, "_get_artist_image_from_albums")
|
||||
|
||||
|
||||
def test_metadata_service_searches_via_engine(monkeypatch):
|
||||
calls = []
|
||||
|
||||
class _FakeOutcome:
|
||||
def __init__(self, items):
|
||||
self.items = items
|
||||
|
||||
class _FakeEngine:
|
||||
def search_artists(self, query, **kwargs):
|
||||
calls.append(("artists", query, dict(kwargs)))
|
||||
return _FakeOutcome([SimpleNamespace(id="artist-1", name="Artist One")])
|
||||
|
||||
def search_tracks(self, query, **kwargs):
|
||||
calls.append(("tracks", query, dict(kwargs)))
|
||||
return _FakeOutcome([])
|
||||
|
||||
def search_albums(self, query, **kwargs):
|
||||
calls.append(("albums", query, dict(kwargs)))
|
||||
return _FakeOutcome([])
|
||||
|
||||
monkeypatch.setattr("core.metadata.service.get_spotify_client", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr("core.metadata.service.get_primary_source", lambda *args, **kwargs: "deezer")
|
||||
monkeypatch.setattr("core.metadata.service.get_client_for_source", lambda source: SimpleNamespace(source=source))
|
||||
monkeypatch.setattr("core.metadata.service.get_metadata_engine", lambda: _FakeEngine())
|
||||
|
||||
service = MetadataService()
|
||||
results = service.search_artists("Artist One", limit=5)
|
||||
|
||||
assert [result.name for result in results] == ["Artist One"]
|
||||
assert calls[0][0] == "artists"
|
||||
assert calls[0][1] == "Artist One"
|
||||
assert calls[0][2]["limit"] == 5
|
||||
assert calls[0][2]["source_override"] == "deezer"
|
||||
|
||||
|
||||
def test_metadata_record_mirrors_source_id_and_id():
|
||||
record = MetadataRecord({"id": "provider-1"})
|
||||
assert record["source_id"] == "provider-1"
|
||||
assert record["id"] == "provider-1"
|
||||
|
||||
record = MetadataRecord({"source_id": "provider-2"})
|
||||
assert record["source_id"] == "provider-2"
|
||||
assert record["id"] == "provider-2"
|
||||
|
||||
record["id"] = "different-id"
|
||||
assert record["source_id"] == "provider-2"
|
||||
assert record["id"] == "provider-2"
|
||||
|
||||
|
||||
def test_metadata_artist_to_dict_prefers_source_id():
|
||||
artist = MetadataArtist(
|
||||
id="legacy-id",
|
||||
name="Artist One",
|
||||
popularity=0,
|
||||
genres=[],
|
||||
followers=0,
|
||||
source_id="provider-id",
|
||||
)
|
||||
|
||||
payload = artist.to_dict()
|
||||
|
||||
assert payload["source_id"] == "provider-id"
|
||||
assert payload["id"] == "provider-id"
|
||||
|
||||
|
||||
def test_metadata_engine_caches_search_results_by_source_id(monkeypatch):
|
||||
stored_rows = []
|
||||
|
||||
class _FakeAdapter:
|
||||
def search_artists_raw(self, query, limit=20):
|
||||
return [{"id": "provider-1", "name": "Artist One"}]
|
||||
|
||||
class _FakeCache:
|
||||
def store_entities_bulk(self, source, entity_kind, rows, skip_if_exists=True):
|
||||
stored_rows.append((source, entity_kind, rows, skip_if_exists))
|
||||
|
||||
engine = MetadataEngine()
|
||||
monkeypatch.setattr(engine, "_get_adapter", lambda source: _FakeAdapter())
|
||||
monkeypatch.setattr("core.metadata.engine.get_metadata_cache", lambda: _FakeCache())
|
||||
|
||||
outcome = engine.search_artists("Artist One", source_override="deezer", allow_fallback=False)
|
||||
|
||||
assert outcome.items[0]["source_id"] == "provider-1"
|
||||
assert stored_rows[0][0] == "deezer"
|
||||
assert stored_rows[0][1] == "artist"
|
||||
assert stored_rows[0][2][0][0] == "provider-1"
|
||||
|
|
@ -269,7 +269,7 @@ def test_discovered_artist_grouping_uses_matched_data():
|
|||
'album_name': 'Local Album',
|
||||
'extra_data': json.dumps({
|
||||
'discovered': True,
|
||||
'provider': 'spotify',
|
||||
'source': 'spotify',
|
||||
'matched_data': {
|
||||
'artists': [{'name': 'Discovered Artist', 'id': 'sp-aid'}],
|
||||
'album': {'name': 'Discovered Album'},
|
||||
|
|
@ -306,7 +306,7 @@ def test_provider_mismatch_falls_back_to_raw_track_name():
|
|||
'album_name': 'Raw Album',
|
||||
'extra_data': json.dumps({
|
||||
'discovered': True,
|
||||
'provider': 'itunes', # mismatch
|
||||
'source': 'itunes', # mismatch
|
||||
'matched_data': {
|
||||
'artists': [{'name': 'iTunes Artist'}],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -163,7 +163,7 @@ def test_get_wishlist_tracks_for_download_formats_modal_shape():
|
|||
"track_name": "Song One",
|
||||
"artist_name": "Artist One",
|
||||
"album_name": "Album One",
|
||||
"provider": None,
|
||||
"source": None,
|
||||
"spotify_data": {
|
||||
"id": "sp-1",
|
||||
"name": "Song One",
|
||||
|
|
|
|||
Loading…
Reference in a new issue