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>
58 lines
1.9 KiB
Python
58 lines
1.9 KiB
Python
"""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)
|