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:
@@ -0,0 +1,57 @@
|
||||
"""Shared cover-art serving helper (presentation).
|
||||
|
||||
Streams a stored cover image from the :class:`FileStorage` port. Used by the
|
||||
native ``/api/v1`` cover endpoints and the Subsonic ``getCoverArt`` adapter so
|
||||
the streaming/content-type logic lives in one place.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.domain.entities.album import Album
|
||||
from app.domain.errors import NotFoundError, StorageError
|
||||
from app.domain.ports import AlbumRepository, FileStorage, TrackRepository
|
||||
|
||||
_CONTENT_TYPE_BY_EXT: dict[str, str] = {
|
||||
"jpg": "image/jpeg",
|
||||
"jpeg": "image/jpeg",
|
||||
"png": "image/png",
|
||||
"webp": "image/webp",
|
||||
"gif": "image/gif",
|
||||
}
|
||||
|
||||
# Covers are immutable for a given album (a new cover means a new key), so let
|
||||
# clients cache aggressively.
|
||||
_CACHE_CONTROL = "public, max-age=86400"
|
||||
|
||||
|
||||
def _content_type_for(key: str) -> str:
|
||||
ext = key.rsplit(".", 1)[-1].lower() if "." in key else ""
|
||||
return _CONTENT_TYPE_BY_EXT.get(ext, "application/octet-stream")
|
||||
|
||||
|
||||
async def stream_cover(storage: FileStorage, cover_path: str) -> StreamingResponse:
|
||||
"""Stream a stored cover by its storage key. Raises ``NotFoundError`` if the
|
||||
object is missing (a dangling ``cover_path`` reads as "no cover")."""
|
||||
try:
|
||||
stream, total = await storage.open_range(cover_path, 0, None)
|
||||
except StorageError as exc:
|
||||
raise NotFoundError("Cover not found.") from exc
|
||||
return StreamingResponse(
|
||||
stream,
|
||||
media_type=_content_type_for(cover_path),
|
||||
headers={"Content-Length": str(total), "Cache-Control": _CACHE_CONTROL},
|
||||
)
|
||||
|
||||
|
||||
async def resolve_album_for_track(
|
||||
track_repo: TrackRepository,
|
||||
album_repo: AlbumRepository,
|
||||
track_id: uuid.UUID,
|
||||
) -> Album | None:
|
||||
"""The album that owns a track (cover lives on the album), or ``None``."""
|
||||
track = await track_repo.get_by_id(track_id)
|
||||
if track is None or track.album_id is None:
|
||||
return None
|
||||
return await album_repo.get_by_id(track.album_id)
|
||||
Reference in New Issue
Block a user