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

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)