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 @@ import pytest
|
||||
from app.application.metadata_service import MetadataEnrichmentService
|
||||
from app.domain.entities import Artist, Track
|
||||
from app.domain.entities.album import Album
|
||||
from app.domain.entities.cover import CoverArt
|
||||
from app.domain.entities.metadata import AudioTags, Fingerprint, RecordingMatch
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
@@ -67,8 +68,10 @@ class FakeArtistRepo:
|
||||
|
||||
|
||||
class FakeAlbumRepo:
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, *, cover_path: str | None = None) -> None:
|
||||
self.created: list[tuple[str, uuid.UUID]] = []
|
||||
self.covers: dict[uuid.UUID, str] = {}
|
||||
self._existing_cover = cover_path
|
||||
|
||||
async def get_or_create(
|
||||
self, *, title: str, artist_id: uuid.UUID, year: int | None, musicbrainz_id: str | None
|
||||
@@ -80,18 +83,52 @@ class FakeAlbumRepo:
|
||||
title=title,
|
||||
artist_id=artist_id,
|
||||
year=year,
|
||||
cover_path=None,
|
||||
cover_path=self._existing_cover,
|
||||
musicbrainz_id=musicbrainz_id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
async def set_cover_path(self, album_id: uuid.UUID, cover_path: str) -> None:
|
||||
self.covers[album_id] = cover_path
|
||||
|
||||
|
||||
class FakeStorage:
|
||||
def __init__(self) -> None:
|
||||
self.saved: list[str] = []
|
||||
|
||||
@asynccontextmanager
|
||||
async def as_local_path(self, key: str) -> AsyncIterator[Path]:
|
||||
yield Path("/tmp") / key
|
||||
|
||||
async def save_file(self, key: str, src_path: Path) -> int:
|
||||
self.saved.append(key)
|
||||
return 1
|
||||
|
||||
|
||||
class FakeCoverExtractor:
|
||||
def __init__(self, cover: CoverArt | None) -> None:
|
||||
self._cover = cover
|
||||
self.calls = 0
|
||||
|
||||
async def extract(self, path: Path) -> CoverArt | None:
|
||||
self.calls += 1
|
||||
return self._cover
|
||||
|
||||
|
||||
class FakeCoverProvider:
|
||||
def __init__(self, cover: CoverArt | None, *, available: bool = True) -> None:
|
||||
self._cover = cover
|
||||
self._available = available
|
||||
self.calls = 0
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return self._available
|
||||
|
||||
async def fetch_release_group(self, release_group_mbid: str) -> CoverArt | None:
|
||||
self.calls += 1
|
||||
return self._cover
|
||||
|
||||
|
||||
class FakeTagReader:
|
||||
def __init__(self, tags: AudioTags | None) -> None:
|
||||
@@ -281,3 +318,114 @@ async def test_fingerprint_skipped_when_acoustid_unavailable() -> None:
|
||||
# tags still enrich, but no AcoustID call is attempted
|
||||
assert acoustid.calls == 0
|
||||
assert result.status == "enriched"
|
||||
|
||||
|
||||
# -- cover-art resolution -----------------------------------------------------
|
||||
_PNG = CoverArt(data=b"\x89PNG\r\n", content_type="image/png")
|
||||
_JPG = CoverArt(data=b"\xff\xd8\xff", content_type="image/jpeg")
|
||||
|
||||
|
||||
def _cover_service(
|
||||
*,
|
||||
track: Track,
|
||||
tags: AudioTags | None = None,
|
||||
match: RecordingMatch | None = None,
|
||||
fp: Fingerprint | None = None,
|
||||
extractor: FakeCoverExtractor | None = None,
|
||||
provider: FakeCoverProvider | None = None,
|
||||
existing_cover: str | None = None,
|
||||
) -> tuple[MetadataEnrichmentService, FakeAlbumRepo, FakeStorage]:
|
||||
albums = FakeAlbumRepo(cover_path=existing_cover)
|
||||
storage = FakeStorage()
|
||||
service = MetadataEnrichmentService(
|
||||
tracks=FakeTrackRepo(track), # type: ignore[arg-type]
|
||||
artists=FakeArtistRepo(), # type: ignore[arg-type]
|
||||
albums=albums, # type: ignore[arg-type]
|
||||
storage=storage, # type: ignore[arg-type]
|
||||
tag_reader=FakeTagReader(tags), # type: ignore[arg-type]
|
||||
fingerprinter=FakeFingerprinter(fp), # type: ignore[arg-type]
|
||||
acoustid=FakeAcoustId(match), # type: ignore[arg-type]
|
||||
cover_extractor=extractor, # type: ignore[arg-type]
|
||||
cover_provider=provider, # type: ignore[arg-type]
|
||||
)
|
||||
return service, albums, storage
|
||||
|
||||
|
||||
async def test_cover_extracted_from_embedded_art() -> None:
|
||||
track = _track()
|
||||
extractor = FakeCoverExtractor(_PNG)
|
||||
provider = FakeCoverProvider(_JPG)
|
||||
service, albums, storage = _cover_service(
|
||||
track=track, tags=AudioTags(album="The Wall", artist="PF"),
|
||||
extractor=extractor, provider=provider,
|
||||
)
|
||||
|
||||
await service.enrich(track.id)
|
||||
|
||||
assert extractor.calls == 1
|
||||
assert provider.calls == 0 # embedded art wins → no network fetch
|
||||
assert len(albums.covers) == 1
|
||||
key = next(iter(albums.covers.values()))
|
||||
assert key.startswith("covers/") and key.endswith(".png")
|
||||
assert storage.saved == [key]
|
||||
|
||||
|
||||
async def test_cover_falls_back_to_archive() -> None:
|
||||
track = _track()
|
||||
extractor = FakeCoverExtractor(None) # no embedded art
|
||||
provider = FakeCoverProvider(_JPG)
|
||||
match = RecordingMatch(acoustid="ac", score=1.0, release_group_mbid="rg-123", album="The Wall")
|
||||
fp = Fingerprint(fingerprint="AQAA", duration_seconds=200)
|
||||
service, albums, storage = _cover_service(
|
||||
track=track, tags=AudioTags(album="The Wall", artist="PF"),
|
||||
match=match, fp=fp, extractor=extractor, provider=provider,
|
||||
)
|
||||
|
||||
await service.enrich(track.id)
|
||||
|
||||
assert extractor.calls == 1
|
||||
assert provider.calls == 1
|
||||
key = next(iter(albums.covers.values()))
|
||||
assert key.endswith(".jpg")
|
||||
assert storage.saved == [key]
|
||||
|
||||
|
||||
async def test_cover_not_fetched_without_release_group() -> None:
|
||||
track = _track()
|
||||
provider = FakeCoverProvider(_JPG)
|
||||
service, albums, _ = _cover_service(
|
||||
track=track, tags=AudioTags(album="The Wall", artist="PF"),
|
||||
extractor=FakeCoverExtractor(None), provider=provider,
|
||||
)
|
||||
|
||||
await service.enrich(track.id)
|
||||
|
||||
assert provider.calls == 0 # no release-group mbid → nothing to look up
|
||||
assert albums.covers == {}
|
||||
|
||||
|
||||
async def test_existing_cover_is_not_overwritten() -> None:
|
||||
track = _track()
|
||||
extractor = FakeCoverExtractor(_PNG)
|
||||
service, albums, storage = _cover_service(
|
||||
track=track, tags=AudioTags(album="The Wall", artist="PF"),
|
||||
extractor=extractor, existing_cover="covers/old.jpg",
|
||||
)
|
||||
|
||||
await service.enrich(track.id)
|
||||
|
||||
assert extractor.calls == 0 # album already has a cover → skip entirely
|
||||
assert albums.covers == {}
|
||||
assert storage.saved == []
|
||||
|
||||
|
||||
async def test_cover_skipped_when_no_album() -> None:
|
||||
track = _track()
|
||||
extractor = FakeCoverExtractor(_PNG)
|
||||
# no album tag and no match → no album resolved → no cover work
|
||||
service, _albums, storage = _cover_service(track=track, extractor=extractor)
|
||||
|
||||
await service.enrich(track.id)
|
||||
|
||||
assert extractor.calls == 0
|
||||
assert storage.saved == []
|
||||
|
||||
Reference in New Issue
Block a user