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)
|
||||
+32
-6
@@ -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")
|
||||
|
||||
@@ -13,4 +13,5 @@ class AlbumOut(BaseModel):
|
||||
artist_name: str
|
||||
year: int | None
|
||||
track_count: int
|
||||
has_cover: bool
|
||||
created_at: dt.datetime
|
||||
|
||||
@@ -18,6 +18,7 @@ class TrackOut(BaseModel):
|
||||
file_size: int
|
||||
metadata_status: str
|
||||
source: str
|
||||
has_cover: bool
|
||||
created_at: dt.datetime
|
||||
|
||||
|
||||
|
||||
+22
-3
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user