"""Playlist endpoints.""" import uuid from fastapi import APIRouter, Query, Response from fastapi.responses import StreamingResponse from app.api.covers import stream_cover from app.api.deps import ( AlbumRepoDep, ArtistRepoDep, CurrentUser, FileStorageDep, PlaylistRepoDep, StreamUser, TrackRepoDep, ) from app.api.schemas.pagination import PagedResponse from app.api.schemas.playlist import ( PlaylistAddTrack, PlaylistCreate, PlaylistOut, PlaylistReorder, PlaylistUpdate, ) from app.api.schemas.track import TrackOut from app.api.v1.tracks import _build_track_out from app.domain.entities.playlist import Playlist from app.domain.errors import NotFoundError, PermissionDeniedError, ValidationError from app.infrastructure.db.repositories.playlist_repository import SqlAlchemyPlaylistRepository router = APIRouter(prefix="/playlists", tags=["playlists"]) async def _build_playlist_out( playlists: list[Playlist], playlist_repo: SqlAlchemyPlaylistRepository ) -> list[PlaylistOut]: ids = [p.id for p in playlists] counts = await playlist_repo.track_count_many(ids) return [ PlaylistOut( id=p.id, name=p.name, description=p.description, owner_id=p.owner_id, version=p.version, track_count=counts.get(p.id, 0), created_at=p.created_at, ) for p in playlists ] @router.get("") async def list_playlists( playlist_repo: PlaylistRepoDep, user: CurrentUser, limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), ) -> PagedResponse[PlaylistOut]: playlists = await playlist_repo.list(owner_id=user.id, limit=limit, offset=offset) total = await playlist_repo.count(owner_id=user.id) items = await _build_playlist_out(playlists, playlist_repo) return PagedResponse(items=items, total=total, limit=limit, offset=offset) @router.post("", status_code=201) async def create_playlist( body: PlaylistCreate, playlist_repo: PlaylistRepoDep, user: CurrentUser, ) -> PlaylistOut: playlist = await playlist_repo.add( name=body.name, description=body.description, owner_id=user.id ) items = await _build_playlist_out([playlist], playlist_repo) return items[0] @router.get("/{playlist_id}") async def get_playlist( playlist_id: uuid.UUID, playlist_repo: PlaylistRepoDep, _: CurrentUser, ) -> PlaylistOut: playlist = await playlist_repo.get_by_id(playlist_id) if playlist is None: raise NotFoundError(f"Playlist {playlist_id} not found.") items = await _build_playlist_out([playlist], playlist_repo) return items[0] @router.patch("/{playlist_id}") async def update_playlist( playlist_id: uuid.UUID, body: PlaylistUpdate, playlist_repo: PlaylistRepoDep, user: CurrentUser, ) -> PlaylistOut: playlist = await playlist_repo.get_by_id(playlist_id) if playlist is None: raise NotFoundError(f"Playlist {playlist_id} not found.") if playlist.owner_id != user.id: raise PermissionDeniedError("You don't own this playlist.") updated = await playlist_repo.update(playlist_id, name=body.name, description=body.description) items = await _build_playlist_out([updated], playlist_repo) return items[0] @router.delete("/{playlist_id}", status_code=204) async def delete_playlist( playlist_id: uuid.UUID, playlist_repo: PlaylistRepoDep, user: CurrentUser, ) -> Response: playlist = await playlist_repo.get_by_id(playlist_id) if playlist is None: raise NotFoundError(f"Playlist {playlist_id} not found.") if playlist.owner_id != user.id: raise PermissionDeniedError("You don't own this playlist.") await playlist_repo.delete(playlist_id) return Response(status_code=204) @router.get("/{playlist_id}/tracks") async def get_playlist_tracks( playlist_id: uuid.UUID, playlist_repo: PlaylistRepoDep, artist_repo: ArtistRepoDep, album_repo: AlbumRepoDep, _: CurrentUser, limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), ) -> PagedResponse[TrackOut]: playlist = await playlist_repo.get_by_id(playlist_id) if playlist is None: raise NotFoundError(f"Playlist {playlist_id} not found.") tracks = await playlist_repo.get_tracks(playlist_id, limit=limit, offset=offset) total = await playlist_repo.get_track_total(playlist_id) artist_ids = list({t.artist_id for t in tracks}) album_ids = list({t.album_id for t in tracks if t.album_id is not None}) artists_map = {a.id: a for a in await artist_repo.get_many(artist_ids)} albums_map = {a.id: a for a in await album_repo.get_many(album_ids)} items = await _build_track_out(tracks, artists_map, albums_map) return PagedResponse(items=items, total=total, limit=limit, offset=offset) @router.post("/{playlist_id}/tracks", status_code=204) async def add_playlist_track( playlist_id: uuid.UUID, body: PlaylistAddTrack, playlist_repo: PlaylistRepoDep, track_repo: TrackRepoDep, user: CurrentUser, ) -> Response: playlist = await playlist_repo.get_by_id(playlist_id) if playlist is None: raise NotFoundError(f"Playlist {playlist_id} not found.") if playlist.owner_id != user.id: raise PermissionDeniedError("You don't own this playlist.") track = await track_repo.get_by_id(body.track_id) if track is None: raise NotFoundError(f"Track {body.track_id} not found.") position = body.position if position is None: position = await playlist_repo.max_position(playlist_id) + 1.0 await playlist_repo.add_track(playlist_id, body.track_id, position=position) return Response(status_code=204) @router.delete("/{playlist_id}/tracks/{track_id}", status_code=204) async def remove_playlist_track( playlist_id: uuid.UUID, track_id: uuid.UUID, playlist_repo: PlaylistRepoDep, user: CurrentUser, ) -> Response: playlist = await playlist_repo.get_by_id(playlist_id) if playlist is None: raise NotFoundError(f"Playlist {playlist_id} not found.") if playlist.owner_id != user.id: raise PermissionDeniedError("You don't own this playlist.") await playlist_repo.remove_track(playlist_id, track_id) return Response(status_code=204) @router.put("/{playlist_id}/tracks/reorder") async def reorder_playlist_tracks( playlist_id: uuid.UUID, body: PlaylistReorder, playlist_repo: PlaylistRepoDep, user: CurrentUser, ) -> PlaylistOut: playlist = await playlist_repo.get_by_id(playlist_id) if playlist is None: raise NotFoundError(f"Playlist {playlist_id} not found.") if playlist.owner_id != user.id: raise PermissionDeniedError("You don't own this playlist.") total = await playlist_repo.get_track_total(playlist_id) current_tracks = ( await playlist_repo.get_tracks(playlist_id, limit=total, offset=0) if total else [] ) current_ids = {t.id for t in current_tracks} given_ids = body.track_ids if len(given_ids) != len(set(given_ids)) or set(given_ids) != current_ids: raise ValidationError("track_ids must be a permutation of the playlist's current tracks.") await playlist_repo.reorder_tracks(playlist_id, given_ids) updated = await playlist_repo.get_by_id(playlist_id) assert updated is not None items = await _build_playlist_out([updated], playlist_repo) return items[0] @router.get("/{playlist_id}/cover") async def get_playlist_cover( playlist_id: uuid.UUID, playlist_repo: PlaylistRepoDep, storage: FileStorageDep, _: StreamUser, ) -> StreamingResponse: # ```` can't send a bearer header → StreamUser accepts ``?token=``. cover_path = await playlist_repo.get_cover_path(playlist_id) if not cover_path: raise NotFoundError("Cover not found.") return await stream_cover(storage, cover_path)