soulsync/core/metadata/providers/itunes.py
Antti Kettunen 896b1b1012
Add typed metadata engine baseline
- introduce the in-process metadata engine and fresh provider adapters
- normalize external identity through source_id while keeping id as a compatibility alias
- route metadata callers through the new seam and add regression coverage
2026-05-09 11:44:47 +03:00

193 lines
7.3 KiB
Python

"""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]] = {}
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
for album_data in results:
if album_data.get("wrapperType") != "collection":
continue
normalized_name = _normalize_album_name(str(album_data.get("collectionName", "") or ""))
current = seen.get(normalized_name)
is_explicit = album_data.get("collectionExplicitness") == "explicit"
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