feat: cover-art pipeline (§1D)
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

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:
Senko-san
2026-06-13 12:10:05 +03:00
parent c7e078d758
commit 0bb752f582
23 changed files with 834 additions and 30 deletions
+57
View File
@@ -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)
+32 -6
View File
@@ -13,9 +13,17 @@ from typing import Annotated
from fastapi import APIRouter, Header, Query
from fastapi.responses import Response, StreamingResponse
from app.api.deps import StreamingServiceDep, SubsonicUser, TrackRepoDep
from app.api.rest.ids import decode_track, parse
from app.domain.errors import NotFoundError
from app.api.covers import resolve_album_for_track, stream_cover
from app.api.deps import (
AlbumRepoDep,
FileStorageDep,
StreamingServiceDep,
SubsonicUser,
TrackRepoDep,
)
from app.api.rest.ids import IdKind, decode_track, parse
from app.domain.entities.album import Album
from app.domain.errors import NotFoundError, StorageError
router = APIRouter()
@@ -69,10 +77,28 @@ async def download(
@router.api_route("/getCoverArt.view", methods=["GET", "POST"])
async def get_cover_art(
_user: SubsonicUser,
album_repo: AlbumRepoDep,
track_repo: TrackRepoDep,
storage: FileStorageDep,
id: Annotated[str, Query()],
size: Annotated[int | None, Query()] = None,
) -> Response:
# Validate the id shape so clients get a clean error on garbage, then serve a
# placeholder. TODO: stream real covers once the cover pipeline exists.
parse(id)
# Cover ids reuse the entity id: ``al-<uuid>`` (album) or ``tr-<uuid>``
# (track → its album). Unlike the native API, Subsonic clients expect an
# image either way, so a missing cover falls back to a placeholder rather
# than 404. ``size`` is accepted but ignored (we serve the stored image).
kind, value = parse(id)
album: Album | None
if kind is IdKind.ALBUM:
album = await album_repo.get_by_id(value)
elif kind is IdKind.TRACK:
album = await resolve_album_for_track(track_repo, album_repo, value)
else:
album = None
if album is not None and album.cover_path:
try:
return await stream_cover(storage, album.cover_path)
except NotFoundError, StorageError:
pass
return Response(content=_PLACEHOLDER_PNG, media_type="image/png")
+1
View File
@@ -13,4 +13,5 @@ class AlbumOut(BaseModel):
artist_name: str
year: int | None
track_count: int
has_cover: bool
created_at: dt.datetime
+1
View File
@@ -18,6 +18,7 @@ class TrackOut(BaseModel):
file_size: int
metadata_status: str
source: str
has_cover: bool
created_at: dt.datetime
+22 -3
View File
@@ -1,11 +1,19 @@
"""Album endpoints."""
import uuid
from typing import Any
from fastapi import APIRouter, Query
from fastapi.responses import StreamingResponse
from app.api.deps import AlbumRepoDep, ArtistRepoDep, CurrentUser, TrackRepoDep
from app.api.covers import stream_cover
from app.api.deps import (
AlbumRepoDep,
ArtistRepoDep,
CurrentUser,
FileStorageDep,
StreamUser,
TrackRepoDep,
)
from app.api.schemas.album import AlbumOut
from app.api.schemas.pagination import PagedResponse
from app.api.schemas.track import TrackOut
@@ -30,6 +38,7 @@ async def _build_album_out(
artist_name=artists[a.artist_id].name if a.artist_id in artists else "Unknown Artist",
year=a.year,
track_count=track_counts.get(a.id, 0),
has_cover=bool(a.cover_path),
created_at=a.created_at,
)
for a in albums
@@ -109,4 +118,14 @@ async def get_album_tracks(
@router.get("/{album_id}/cover")
async def get_album_cover(album_id: uuid.UUID, _: CurrentUser) -> Any: ...
async def get_album_cover(
album_id: uuid.UUID,
album_repo: AlbumRepoDep,
storage: FileStorageDep,
_: StreamUser,
) -> StreamingResponse:
# ``<img>`` can't send a bearer header → StreamUser accepts ``?token=``.
album = await album_repo.get_by_id(album_id)
if album is None or not album.cover_path:
raise NotFoundError("Cover not found.")
return await stream_cover(storage, album.cover_path)
+24 -2
View File
@@ -4,8 +4,17 @@ import uuid
from typing import Any
from fastapi import APIRouter, Query, Response
from fastapi.responses import StreamingResponse
from app.api.deps import AlbumRepoDep, ArtistRepoDep, CurrentUser, FileStorageDep, TrackRepoDep
from app.api.covers import resolve_album_for_track, stream_cover
from app.api.deps import (
AlbumRepoDep,
ArtistRepoDep,
CurrentUser,
FileStorageDep,
StreamUser,
TrackRepoDep,
)
from app.api.schemas.pagination import PagedResponse
from app.api.schemas.track import TrackOut, TrackUpdate
from app.domain.entities.album import Album
@@ -34,6 +43,7 @@ async def _build_track_out(
file_size=t.file_size,
metadata_status=t.metadata_status,
source=t.source,
has_cover=bool(t.album_id and albums.get(t.album_id) and albums[t.album_id].cover_path),
created_at=t.created_at,
)
for t in tracks
@@ -144,7 +154,19 @@ async def optimize_track(track_id: uuid.UUID, _: CurrentUser) -> Any: ...
@router.get("/{track_id}/cover")
async def get_track_cover(track_id: uuid.UUID, _: CurrentUser) -> Any: ...
async def get_track_cover(
track_id: uuid.UUID,
track_repo: TrackRepoDep,
album_repo: AlbumRepoDep,
storage: FileStorageDep,
_: StreamUser,
) -> StreamingResponse:
# A track's cover is its album's cover. ``<img>`` can't send a bearer
# header → StreamUser accepts ``?token=``.
album = await resolve_album_for_track(track_repo, album_repo, track_id)
if album is None or not album.cover_path:
raise NotFoundError("Cover not found.")
return await stream_cover(storage, album.cover_path)
@router.post("/{track_id}/metadata/enrich")