551afbab13
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>
183 lines
6.8 KiB
Python
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)
|