Files
Senko-san 0bb752f582
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
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>
2026-06-13 12:10:05 +03:00

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)