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:
+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)
|
||||
|
||||
Reference in New Issue
Block a user