0bb752f582
Resolve, store and serve album cover art.
Sources (tag-first, mirroring enrichment): embedded artwork extracted
offline via mutagen (ID3 APIC / FLAC+OGG Picture / MP4 covr), then Cover
Art Archive by release-group MBID as a network fallback. Resolution runs
inside MetadataEnrichmentService after album resolution, only when the
album has no cover yet (idempotent, never overwrites), and is best-effort
so a cover failure never affects enrichment status.
- CoverArt value object + CoverArtExtractor/CoverArtProvider ports
- MutagenCoverExtractor + CoverArtArchiveClient adapters
- AcoustID parser now captures release_group_mbid
- Covers stored via FileStorage at covers/{album_id}.{ext} (local + S3)
- AlbumRepository.set_cover_path
- Serve real covers: GET /api/v1/albums|tracks/{id}/cover (StreamUser,
?token=), Subsonic getCoverArt (placeholder fallback)
- has_cover flag on AlbumOut/TrackOut
- coverart_enabled / coverart_base_url settings
- tests: cover resolution units + release_group parse + DB-backed
test_cover_api.py (139 green via make test-api)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
84 lines
2.9 KiB
Python
84 lines
2.9 KiB
Python
"""CoverArtArchiveClient — fetches front cover art from the Cover Art Archive.
|
|
|
|
The network fallback when a file carries no embedded artwork: given a
|
|
MusicBrainz **release-group** id (supplied by the AcoustID lookup), request the
|
|
front image from ``coverartarchive.org``. The CAA redirects to the Internet
|
|
Archive, so redirects are followed. ``thumbnail`` 500px keeps payloads small.
|
|
|
|
Graceful degradation (CLAUDE.md): no release-group id → never called; any
|
|
network/HTTP error (incl. 404 "no cover") → returns ``None``, never raises. A
|
|
small inter-call delay respects the shared MusicBrainz/CAA infrastructure.
|
|
"""
|
|
|
|
import asyncio
|
|
import time
|
|
|
|
import httpx
|
|
|
|
from app.core.logging import get_logger
|
|
from app.domain.entities.cover import CoverArt
|
|
|
|
log = get_logger(__name__)
|
|
|
|
_DEFAULT_BASE_URL = "https://coverartarchive.org"
|
|
_TIMEOUT_SECONDS = 15.0
|
|
_MIN_INTERVAL_SECONDS = 1.0 # CAA piggybacks on MusicBrainz infra; stay polite
|
|
_MAX_BYTES = 10 * 1024 * 1024 # ignore absurdly large images
|
|
|
|
|
|
class CoverArtArchiveClient:
|
|
"""Implements :class:`app.domain.ports.CoverArtProvider`."""
|
|
|
|
_throttle_lock = asyncio.Lock()
|
|
_last_call_monotonic = 0.0
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
user_agent: str,
|
|
enabled: bool = True,
|
|
base_url: str = _DEFAULT_BASE_URL,
|
|
) -> None:
|
|
self._user_agent = user_agent
|
|
self._enabled = enabled
|
|
self._base_url = base_url.rstrip("/")
|
|
|
|
def is_available(self) -> bool:
|
|
return self._enabled
|
|
|
|
async def fetch_release_group(self, release_group_mbid: str) -> CoverArt | None:
|
|
if not self._enabled or not release_group_mbid:
|
|
return None
|
|
url = f"{self._base_url}/release-group/{release_group_mbid}/front-500"
|
|
try:
|
|
await self._throttle()
|
|
async with httpx.AsyncClient(
|
|
timeout=_TIMEOUT_SECONDS,
|
|
follow_redirects=True,
|
|
headers={"User-Agent": self._user_agent},
|
|
) as client:
|
|
resp = await client.get(url)
|
|
if resp.status_code == 404:
|
|
return None # no cover for this release group — normal, not an error
|
|
resp.raise_for_status()
|
|
except httpx.HTTPError:
|
|
log.warning("coverart_fetch_failed", release_group=release_group_mbid)
|
|
return None
|
|
|
|
data = resp.content
|
|
if not data or len(data) > _MAX_BYTES:
|
|
return None
|
|
content_type = resp.headers.get("content-type", "image/jpeg").split(";")[0].strip()
|
|
if not content_type.startswith("image/"):
|
|
return None
|
|
return CoverArt(data=data, content_type=content_type)
|
|
|
|
@classmethod
|
|
async def _throttle(cls) -> None:
|
|
async with cls._throttle_lock:
|
|
elapsed = time.monotonic() - cls._last_call_monotonic
|
|
wait = _MIN_INTERVAL_SECONDS - elapsed
|
|
if wait > 0:
|
|
await asyncio.sleep(wait)
|
|
cls._last_call_monotonic = time.monotonic()
|