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:
@@ -1,6 +1,7 @@
|
||||
"""Domain entities and value objects — pure, framework-free."""
|
||||
|
||||
from app.domain.entities.album import Album
|
||||
from app.domain.entities.cover import CoverArt
|
||||
from app.domain.entities.history import PlayHistoryEntry
|
||||
from app.domain.entities.like import Like
|
||||
from app.domain.entities.metadata import AudioTags, Fingerprint, RecordingMatch
|
||||
@@ -13,6 +14,7 @@ __all__ = [
|
||||
"Album",
|
||||
"Artist",
|
||||
"AudioTags",
|
||||
"CoverArt",
|
||||
"Credentials",
|
||||
"Fingerprint",
|
||||
"Like",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""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",
|
||||
}
|
||||
@@ -47,6 +47,7 @@ class RecordingMatch:
|
||||
acoustid: str
|
||||
score: float
|
||||
recording_mbid: str | None = None
|
||||
release_group_mbid: str | None = None
|
||||
title: str | None = None
|
||||
artist: str | None = None
|
||||
album: str | None = None
|
||||
|
||||
@@ -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