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>
29 lines
777 B
Python
29 lines
777 B
Python
"""Cover-art value object — raw image bytes plus their MIME type.
|
|
|
|
Crosses the domain boundary between the cover sources (embedded extractor,
|
|
Cover Art Archive) and the storage/serving layers. The bytes are the encoded
|
|
image as-is; we never decode/resize in Phase 1.
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class CoverArt:
|
|
data: bytes
|
|
content_type: str # "image/jpeg" | "image/png" | …
|
|
|
|
@property
|
|
def extension(self) -> str:
|
|
"""File extension for the content type (no leading dot)."""
|
|
return _EXT_BY_TYPE.get(self.content_type.lower(), "jpg")
|
|
|
|
|
|
_EXT_BY_TYPE: dict[str, str] = {
|
|
"image/jpeg": "jpg",
|
|
"image/jpg": "jpg",
|
|
"image/png": "png",
|
|
"image/webp": "webp",
|
|
"image/gif": "gif",
|
|
}
|