"""Subsonic playlist endpoints — adapters over the playlist repository. Playlists are private to their owner (no public-playlist concept yet), so every read/write is scoped to the authenticated user. ``createPlaylist`` doubles as a full replace when given a ``playlistId`` (Subsonic overloads it that way). """ from typing import Annotated, Any from fastapi import APIRouter, Query, Response from app.api.deps import ( AlbumRepoDep, ArtistRepoDep, PlaylistRepoDep, SubsonicFormat, SubsonicUser, ) from app.api.rest.envelope import subsonic_response from app.api.rest.ids import decode_playlist, decode_track, encode_playlist from app.api.rest.serializers import iso, song_dict from app.domain.entities import Playlist, User from app.domain.errors import NotFoundError, PermissionDeniedError router = APIRouter() def _playlist_dict(playlist: Playlist, owner: str, *, song_count: int) -> dict[str, Any]: return { "id": encode_playlist(playlist.id), "name": playlist.name, "comment": playlist.description, "owner": owner, "public": False, "songCount": song_count, "created": iso(playlist.created_at), "changed": iso(playlist.updated_at), } async def _owned_playlist( playlist_id_raw: str, playlist_repo: PlaylistRepoDep, user: User ) -> Playlist: playlist = await playlist_repo.get_by_id(decode_playlist(playlist_id_raw)) if playlist is None: raise NotFoundError("Playlist not found.") if playlist.owner_id != user.id: raise PermissionDeniedError("You don't own this playlist.") return playlist async def _playlist_songs( playlist_id: str, playlist_repo: PlaylistRepoDep, artist_repo: ArtistRepoDep, album_repo: AlbumRepoDep, ) -> list[dict[str, Any]]: tracks = await playlist_repo.get_tracks(decode_playlist(playlist_id), limit=10_000, offset=0) artist_map = {a.id: a for a in await artist_repo.get_many(list({t.artist_id for t in tracks}))} album_map = { a.id: a for a in await album_repo.get_many( list({t.album_id for t in tracks if t.album_id is not None}) ) } return [ song_dict( t, artist_map.get(t.artist_id), album_map.get(t.album_id) if t.album_id is not None else None, ) for t in tracks ] @router.api_route("/getPlaylists", methods=["GET", "POST"]) @router.api_route("/getPlaylists.view", methods=["GET", "POST"]) async def get_playlists( user: SubsonicUser, fmt: SubsonicFormat, playlist_repo: PlaylistRepoDep ) -> Response: playlists = await playlist_repo.list(owner_id=user.id, limit=500, offset=0) counts = await playlist_repo.track_count_many([p.id for p in playlists]) items = [_playlist_dict(p, user.username, song_count=counts.get(p.id, 0)) for p in playlists] return subsonic_response({"playlists": {"playlist": items}}, fmt=fmt) @router.api_route("/getPlaylist", methods=["GET", "POST"]) @router.api_route("/getPlaylist.view", methods=["GET", "POST"]) async def get_playlist( user: SubsonicUser, fmt: SubsonicFormat, playlist_repo: PlaylistRepoDep, artist_repo: ArtistRepoDep, album_repo: AlbumRepoDep, id: Annotated[str, Query()], ) -> Response: playlist = await _owned_playlist(id, playlist_repo, user) songs = await _playlist_songs(id, playlist_repo, artist_repo, album_repo) payload = {**_playlist_dict(playlist, user.username, song_count=len(songs)), "entry": songs} return subsonic_response({"playlist": payload}, fmt=fmt) @router.api_route("/createPlaylist", methods=["GET", "POST"]) @router.api_route("/createPlaylist.view", methods=["GET", "POST"]) async def create_playlist( user: SubsonicUser, fmt: SubsonicFormat, playlist_repo: PlaylistRepoDep, artist_repo: ArtistRepoDep, album_repo: AlbumRepoDep, name: Annotated[str | None, Query()] = None, playlistId: Annotated[str | None, Query()] = None, songId: Annotated[list[str] | None, Query()] = None, ) -> Response: song_ids = [decode_track(s) for s in (songId or [])] if playlistId is not None: # Overloaded form: replace the existing playlist's tracks (and name). playlist = await _owned_playlist(playlistId, playlist_repo, user) if name is not None: playlist = await playlist_repo.update(playlist.id, name=name, description=None) existing = await playlist_repo.get_tracks(playlist.id, limit=10_000, offset=0) for t in existing: await playlist_repo.remove_track(playlist.id, t.id) else: playlist = await playlist_repo.add( name=name or "Untitled", description=None, owner_id=user.id ) for position, track_id in enumerate(song_ids, start=1): await playlist_repo.add_track(playlist.id, track_id, position=float(position)) encoded = encode_playlist(playlist.id) songs = await _playlist_songs(encoded, playlist_repo, artist_repo, album_repo) payload = {**_playlist_dict(playlist, user.username, song_count=len(songs)), "entry": songs} return subsonic_response({"playlist": payload}, fmt=fmt) @router.api_route("/updatePlaylist", methods=["GET", "POST"]) @router.api_route("/updatePlaylist.view", methods=["GET", "POST"]) async def update_playlist( user: SubsonicUser, fmt: SubsonicFormat, playlist_repo: PlaylistRepoDep, playlistId: Annotated[str, Query()], name: Annotated[str | None, Query()] = None, comment: Annotated[str | None, Query()] = None, songIdToAdd: Annotated[list[str] | None, Query()] = None, songIndexToRemove: Annotated[list[int] | None, Query()] = None, ) -> Response: playlist = await _owned_playlist(playlistId, playlist_repo, user) if name is not None or comment is not None: playlist = await playlist_repo.update(playlist.id, name=name, description=comment) # Removals are by index into the current ordered track list — resolve first. if songIndexToRemove: current = await playlist_repo.get_tracks(playlist.id, limit=10_000, offset=0) for index in sorted(set(songIndexToRemove)): if 0 <= index < len(current): await playlist_repo.remove_track(playlist.id, current[index].id) if songIdToAdd: position = await playlist_repo.max_position(playlist.id) for raw in songIdToAdd: position += 1.0 await playlist_repo.add_track(playlist.id, decode_track(raw), position=position) return subsonic_response(fmt=fmt) @router.api_route("/deletePlaylist", methods=["GET", "POST"]) @router.api_route("/deletePlaylist.view", methods=["GET", "POST"]) async def delete_playlist( user: SubsonicUser, fmt: SubsonicFormat, playlist_repo: PlaylistRepoDep, id: Annotated[str, Query()], ) -> Response: playlist = await _owned_playlist(id, playlist_repo, user) await playlist_repo.delete(playlist.id) return subsonic_response(fmt=fmt)