feat: cover-art pipeline (§1D)
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>
This commit is contained in:
@@ -15,6 +15,7 @@ from typing import Protocol
|
||||
from app.domain.entities import (
|
||||
Album,
|
||||
AudioTags,
|
||||
CoverArt,
|
||||
Credentials,
|
||||
Fingerprint,
|
||||
Like,
|
||||
@@ -188,6 +189,7 @@ class AlbumRepository(Protocol):
|
||||
year: int | None,
|
||||
musicbrainz_id: str | None,
|
||||
) -> Album: ...
|
||||
async def set_cover_path(self, album_id: uuid.UUID, cover_path: str) -> None: ...
|
||||
async def get_by_id(self, album_id: uuid.UUID) -> Album | None: ...
|
||||
async def get_many(self, ids: list[uuid.UUID]) -> list[Album]: ...
|
||||
async def count(self, *, artist_id: uuid.UUID | None, q: str | None) -> int: ...
|
||||
@@ -297,3 +299,19 @@ class AcoustIdClient(Protocol):
|
||||
|
||||
def is_available(self) -> bool: ...
|
||||
async def lookup(self, fingerprint: Fingerprint) -> RecordingMatch | None: ...
|
||||
|
||||
|
||||
class CoverArtExtractor(Protocol):
|
||||
"""Pulls embedded cover art out of a local audio file (offline, no network).
|
||||
Returns ``None`` when the file has no picture or can't be parsed — never raises."""
|
||||
|
||||
async def extract(self, path: Path) -> CoverArt | None: ...
|
||||
|
||||
|
||||
class CoverArtProvider(Protocol):
|
||||
"""Fetches cover art from an external service (Cover Art Archive) by
|
||||
MusicBrainz release-group id. ``is_available`` may gate it off; ``fetch``
|
||||
returns ``None`` (not found / service down), never raising."""
|
||||
|
||||
def is_available(self) -> bool: ...
|
||||
async def fetch_release_group(self, release_group_mbid: str) -> CoverArt | None: ...
|
||||
|
||||
Reference in New Issue
Block a user