Files
musicseerr/backend/repositories/audiodb_repository.py
T
Harvey a69a26852e Cut down unnecessary logging (#48)
* Cut down unnecessary logging

* fix format etc

* fix checks

* fix tests
2026-04-14 00:02:38 +01:00

276 lines
9.6 KiB
Python

import logging
import time
from typing import Any
import httpx
import msgspec
from core.exceptions import ExternalServiceError, RateLimitedError
from infrastructure.resilience.rate_limiter import TokenBucketRateLimiter
from infrastructure.resilience.retry import CircuitBreaker, CircuitOpenError, with_retry
from repositories.audiodb_models import (
AudioDBAlbumResponse,
AudioDBArtistResponse,
)
from services.preferences_service import PreferencesService
from infrastructure.degradation import try_get_degradation_context
from infrastructure.integration_result import IntegrationResult
logger = logging.getLogger(__name__)
_SOURCE = "audiodb"
def _record_degradation(msg: str) -> None:
ctx = try_get_degradation_context()
if ctx is not None:
ctx.record(IntegrationResult.error(source=_SOURCE, msg=msg))
AUDIODB_API_URL = "https://www.theaudiodb.com/api/v1/json"
def _log_circuit_state_change(
breaker: CircuitBreaker,
previous_state,
new_state,
reason: str,
) -> None:
level = logging.INFO if new_state.value == "closed" else logging.WARNING
logger.log(
level,
"audiodb.circuit_state_change service=%s previous_state=%s state=%s reason=%s",
breaker.name,
previous_state.value,
new_state.value,
reason,
)
_audiodb_circuit_breaker = CircuitBreaker(
failure_threshold=5,
success_threshold=2,
timeout=60.0,
name="audiodb",
on_state_change=_log_circuit_state_change,
)
AUDIODB_FREE_KEY = "123"
AudioDBJson = dict[str, Any]
def _make_rate_limiter(premium: bool = False) -> TokenBucketRateLimiter:
if premium:
return TokenBucketRateLimiter(rate=5.0, capacity=10)
return TokenBucketRateLimiter(rate=0.5, capacity=2)
def _decode_json_response(response: httpx.Response) -> AudioDBJson:
content = getattr(response, "content", None)
if isinstance(content, (bytes, bytearray, memoryview)):
return msgspec.json.decode(content, type=AudioDBJson)
return response.json()
def _extract_first(data: dict[str, Any], key: str) -> dict[str, Any] | None:
items = data.get(key)
if not items or not isinstance(items, list) or len(items) == 0:
return None
return items[0]
class AudioDBRepository:
def __init__(
self,
http_client: httpx.AsyncClient,
preferences_service: PreferencesService,
api_key: str = "123",
premium: bool = False,
):
self._client = http_client
self._preferences_service = preferences_service
self._api_key = api_key
self._rate_limiter = _make_rate_limiter(premium)
def _is_enabled(self) -> bool:
return self._preferences_service.get_advanced_settings().audiodb_enabled
def _effective_api_key(self) -> str:
settings_key = self._preferences_service.get_advanced_settings().audiodb_api_key
if settings_key and settings_key.strip():
return settings_key
return self._api_key
@staticmethod
def reset_circuit_breaker() -> None:
_audiodb_circuit_breaker.reset()
@with_retry(
max_attempts=3,
base_delay=2.0,
max_delay=10.0,
circuit_breaker=_audiodb_circuit_breaker,
retriable_exceptions=(httpx.HTTPError, ExternalServiceError, RateLimitedError),
)
async def _request(self, endpoint: str, params: dict[str, str] | None = None) -> dict[str, Any] | None:
await self._rate_limiter.acquire()
url = f"{AUDIODB_API_URL}/{self._effective_api_key()}/{endpoint}"
try:
t0 = time.monotonic()
response = await self._client.get(url, params=params, timeout=15.0)
elapsed_ms = (time.monotonic() - t0) * 1000
if response.status_code == 429:
logger.warning("audiodb.ratelimit status=429 elapsed_ms=%.1f retry_after_s=60", elapsed_ms)
raise RateLimitedError("AudioDB rate limit exceeded", retry_after_seconds=60)
if response.status_code == 404:
return None
if response.status_code != 200:
raise ExternalServiceError(
f"AudioDB request failed ({response.status_code})"
)
try:
data = _decode_json_response(response)
except (msgspec.DecodeError, ValueError, TypeError):
raise ExternalServiceError("AudioDB returned invalid JSON")
return data
except (ExternalServiceError, RateLimitedError):
raise
except httpx.HTTPError as e:
raise ExternalServiceError(f"AudioDB request failed: {e}")
async def get_artist_by_mbid(self, mbid: str) -> AudioDBArtistResponse | None:
if not self._is_enabled() or not mbid:
return None
try:
return await self._get_artist_by_mbid(mbid)
except CircuitOpenError:
logger.warning("audiodb.circuit_open entity=artist lookup_type=mbid mbid=%s", mbid)
_record_degradation(f"Circuit open: artist lookup by mbid {mbid}")
return None
async def _get_artist_by_mbid(self, mbid: str) -> AudioDBArtistResponse | None:
t0 = time.monotonic()
data = await self._request("artist-mb.php", params={"i": mbid})
elapsed_ms = (time.monotonic() - t0) * 1000
if data is None:
return None
item = _extract_first(data, "artists")
if item is None:
return None
try:
result = msgspec.convert(item, type=AudioDBArtistResponse)
except (msgspec.ValidationError, msgspec.DecodeError, TypeError, KeyError) as exc:
logger.warning("audiodb.schema_error entity=artist lookup_type=mbid mbid=%s error=%s", mbid, exc)
_record_degradation(f"Schema error for artist mbid {mbid}: {exc}")
return None
return result
async def get_album_by_mbid(self, mbid: str) -> AudioDBAlbumResponse | None:
if not self._is_enabled() or not mbid:
return None
try:
return await self._get_album_by_mbid(mbid)
except CircuitOpenError:
logger.warning("audiodb.circuit_open entity=album lookup_type=mbid mbid=%s", mbid)
_record_degradation(f"Circuit open: album lookup by mbid {mbid}")
return None
async def _get_album_by_mbid(self, mbid: str) -> AudioDBAlbumResponse | None:
t0 = time.monotonic()
data = await self._request("album-mb.php", params={"i": mbid})
elapsed_ms = (time.monotonic() - t0) * 1000
if data is None:
return None
item = _extract_first(data, "album")
if item is None:
return None
try:
result = msgspec.convert(item, type=AudioDBAlbumResponse)
except (msgspec.ValidationError, msgspec.DecodeError, TypeError, KeyError) as exc:
logger.warning("audiodb.schema_error entity=album lookup_type=mbid mbid=%s error=%s", mbid, exc)
_record_degradation(f"Schema error for album mbid {mbid}: {exc}")
return None
return result
async def search_artist_by_name(self, name: str) -> AudioDBArtistResponse | None:
if not self._is_enabled() or not name:
return None
try:
return await self._search_artist_by_name(name)
except CircuitOpenError:
logger.warning("audiodb.circuit_open entity=artist lookup_type=name name=%s", name)
_record_degradation("Circuit open: artist search by name")
return None
async def _search_artist_by_name(self, name: str) -> AudioDBArtistResponse | None:
t0 = time.monotonic()
data = await self._request("search.php", params={"s": name})
elapsed_ms = (time.monotonic() - t0) * 1000
if data is None:
return None
item = _extract_first(data, "artists")
if item is None:
return None
try:
result = msgspec.convert(item, type=AudioDBArtistResponse)
except (msgspec.ValidationError, msgspec.DecodeError, TypeError, KeyError) as exc:
logger.warning("audiodb.schema_error entity=artist lookup_type=name name=%s error=%s", name, exc)
_record_degradation(f"Schema error for artist name search: {exc}")
return None
return result
async def search_album_by_name(self, artist: str, album: str) -> AudioDBAlbumResponse | None:
if not self._is_enabled() or not artist or not album:
return None
try:
return await self._search_album_by_name(artist, album)
except CircuitOpenError:
logger.warning("audiodb.circuit_open entity=album lookup_type=name artist=%s album=%s", artist, album)
_record_degradation("Circuit open: album search by name")
return None
async def _search_album_by_name(self, artist: str, album: str) -> AudioDBAlbumResponse | None:
t0 = time.monotonic()
data = await self._request("searchalbum.php", params={"s": artist, "a": album})
elapsed_ms = (time.monotonic() - t0) * 1000
if data is None:
return None
item = _extract_first(data, "album")
if item is None:
return None
try:
result = msgspec.convert(item, type=AudioDBAlbumResponse)
except (msgspec.ValidationError, msgspec.DecodeError, TypeError, KeyError) as exc:
logger.warning(
"audiodb.schema_error entity=album lookup_type=name artist=%s album=%s error=%s",
artist,
album,
exc,
)
_record_degradation(f"Schema error for album name search: {exc}")
return None
return result