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>
132 lines
4.0 KiB
Python
132 lines
4.0 KiB
Python
"""Album endpoints."""
|
|
|
|
import uuid
|
|
|
|
from fastapi import APIRouter, Query
|
|
from fastapi.responses import StreamingResponse
|
|
|
|
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
|
|
from app.api.v1.tracks import _build_track_out
|
|
from app.domain.entities.album import Album
|
|
from app.domain.entities.track import Artist
|
|
from app.domain.errors import NotFoundError
|
|
|
|
router = APIRouter(prefix="/albums", tags=["albums"])
|
|
|
|
|
|
async def _build_album_out(
|
|
albums: list[Album],
|
|
artists: dict[uuid.UUID, Artist],
|
|
track_counts: dict[uuid.UUID, int],
|
|
) -> list[AlbumOut]:
|
|
return [
|
|
AlbumOut(
|
|
id=a.id,
|
|
title=a.title,
|
|
artist_id=a.artist_id,
|
|
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
|
|
]
|
|
|
|
|
|
@router.get("")
|
|
async def list_albums(
|
|
album_repo: AlbumRepoDep,
|
|
artist_repo: ArtistRepoDep,
|
|
_: CurrentUser,
|
|
artist_id: uuid.UUID | None = None,
|
|
q: str | None = None,
|
|
limit: int = Query(50, ge=1, le=200),
|
|
offset: int = Query(0, ge=0),
|
|
) -> PagedResponse[AlbumOut]:
|
|
albums = await album_repo.list(artist_id=artist_id, q=q, limit=limit, offset=offset)
|
|
total = await album_repo.count(artist_id=artist_id, q=q)
|
|
|
|
artist_ids = list({a.artist_id for a in albums})
|
|
artists = {a.id: a for a in await artist_repo.get_many(artist_ids)}
|
|
track_counts = await album_repo.track_count_many([a.id for a in albums])
|
|
|
|
items = await _build_album_out(albums, artists, track_counts)
|
|
return PagedResponse(items=items, total=total, limit=limit, offset=offset)
|
|
|
|
|
|
@router.get("/{album_id}")
|
|
async def get_album(
|
|
album_id: uuid.UUID,
|
|
album_repo: AlbumRepoDep,
|
|
artist_repo: ArtistRepoDep,
|
|
_: CurrentUser,
|
|
) -> AlbumOut:
|
|
album = await album_repo.get_by_id(album_id)
|
|
if album is None:
|
|
raise NotFoundError(f"Album {album_id} not found.")
|
|
|
|
artists = {a.id: a for a in await artist_repo.get_many([album.artist_id])}
|
|
track_counts = await album_repo.track_count_many([album.id])
|
|
|
|
items = await _build_album_out([album], artists, track_counts)
|
|
return items[0]
|
|
|
|
|
|
@router.get("/{album_id}/tracks")
|
|
async def get_album_tracks(
|
|
album_id: uuid.UUID,
|
|
track_repo: TrackRepoDep,
|
|
artist_repo: ArtistRepoDep,
|
|
album_repo: AlbumRepoDep,
|
|
_: CurrentUser,
|
|
limit: int = Query(50, ge=1, le=200),
|
|
offset: int = Query(0, ge=0),
|
|
) -> PagedResponse[TrackOut]:
|
|
album = await album_repo.get_by_id(album_id)
|
|
if album is None:
|
|
raise NotFoundError(f"Album {album_id} not found.")
|
|
|
|
tracks = await track_repo.list(
|
|
artist_id=None,
|
|
album_id=album_id,
|
|
q=None,
|
|
sort_by="title",
|
|
order="asc",
|
|
limit=limit,
|
|
offset=offset,
|
|
)
|
|
total = await track_repo.count(artist_id=None, album_id=album_id, q=None)
|
|
|
|
artist_ids = list({t.artist_id for t in tracks})
|
|
artists = {a.id: a for a in await artist_repo.get_many(artist_ids)}
|
|
albums = {album.id: album}
|
|
|
|
items = await _build_track_out(tracks, artists, albums)
|
|
return PagedResponse(items=items, total=total, limit=limit, offset=offset)
|
|
|
|
|
|
@router.get("/{album_id}/cover")
|
|
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)
|