"""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-`` (album) or ``tr-`` # (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")