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>
105 lines
3.9 KiB
Python
105 lines
3.9 KiB
Python
"""Subsonic media endpoints: stream, download, cover art.
|
|
|
|
``stream`` and ``download`` reuse :class:`StreamingService` (honouring HTTP
|
|
Range) — they return raw bytes, not the Subsonic envelope. Transcoding params
|
|
(``maxBitRate``/``format``) are accepted but ignored; the original file is served
|
|
(no in-request ffmpeg — CLAUDE.md). ``getCoverArt`` returns a placeholder until
|
|
the cover pipeline lands (the ``/api/v1`` cover endpoints are still stubs).
|
|
"""
|
|
|
|
import base64
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Header, Query
|
|
from fastapi.responses import Response, StreamingResponse
|
|
|
|
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()
|
|
|
|
# 1x1 transparent PNG - a graceful placeholder until cover art is wired up.
|
|
_PLACEHOLDER_PNG = base64.b64decode(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC"
|
|
)
|
|
|
|
|
|
@router.api_route("/stream", methods=["GET", "POST"])
|
|
@router.api_route("/stream.view", methods=["GET", "POST"])
|
|
async def stream(
|
|
_user: SubsonicUser,
|
|
service: StreamingServiceDep,
|
|
id: Annotated[str, Query()],
|
|
range_header: Annotated[str | None, Header(alias="Range")] = None,
|
|
) -> StreamingResponse:
|
|
result = await service.open_stream(decode_track(id), range_header)
|
|
headers = {"Accept-Ranges": "bytes", "Content-Length": str(result.content_length)}
|
|
status_code = 200
|
|
if result.is_partial:
|
|
headers["Content-Range"] = f"bytes {result.start}-{result.end}/{result.total_size}"
|
|
status_code = 206
|
|
return StreamingResponse(
|
|
result.stream, status_code=status_code, headers=headers, media_type=result.content_type
|
|
)
|
|
|
|
|
|
@router.api_route("/download", methods=["GET", "POST"])
|
|
@router.api_route("/download.view", methods=["GET", "POST"])
|
|
async def download(
|
|
_user: SubsonicUser,
|
|
service: StreamingServiceDep,
|
|
track_repo: TrackRepoDep,
|
|
id: Annotated[str, Query()],
|
|
) -> StreamingResponse:
|
|
track_id = decode_track(id)
|
|
track = await track_repo.get_by_id(track_id)
|
|
if track is None:
|
|
raise NotFoundError("Song not found.")
|
|
result = await service.open_stream(track_id, None)
|
|
filename = f"{track.title}.{track.file_format}"
|
|
headers = {
|
|
"Content-Length": str(result.content_length),
|
|
"Content-Disposition": f'attachment; filename="{filename}"',
|
|
}
|
|
return StreamingResponse(result.stream, headers=headers, media_type=result.content_type)
|
|
|
|
|
|
@router.api_route("/getCoverArt", methods=["GET", "POST"])
|
|
@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:
|
|
# 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")
|