Files
mcma-backend/app/api/rest/playlists.py
T
Senko-san 551afbab13
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(subsonic): browsing, search, media, playlist, annotation endpoints
Thin adapters over the existing services/repositories (no business logic):

- system: ping (auth check), getLicense
- browsing: getArtists/getArtist/getAlbum, getAlbumList(2) (newest/alpha/random),
  getSong, getGenres, getMusicFolders/getIndexes/getMusicDirectory (one folder)
- search: search3 (delegates to the library repos)
- media: stream + download (reuse StreamingService, honor Range); getCoverArt
  returns a placeholder until the cover pipeline lands
- playlists: get/create/update/delete over the playlist repo (owner-scoped)
- annotation: star/unstar → append-only like log, scrobble → play history,
  setRating → clean no-op
- all endpoints also accept the .view suffix and GET+POST for client compat

Repo support: album list ordering (newest/random), track genre facets.
README documents the mandatory-HTTPS requirement and app-password workflow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 18:24:06 +03:00

183 lines
6.8 KiB
Python

"""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)