Files
mcma-backend/app/api/v1/playlists.py
T
Цвылев Александр Вадимович a263272935 feat(api): finish Group A stubbed endpoints
Implements previously-stubbed /api/v1 endpoints (hexagonal: ports -> repos
-> services -> routers wired in deps):

- playlists: GET /{id}/cover (serves stored cover, 404 when absent)
- settings: GET/PATCH /settings + GET/PUT /settings/scrobbling — lazy
  per-user row, write-only Fernet-encrypted scrobble session key; adds
  user_settings table + migration (chains off dc126696f5a6)
- storage: GET /duplicates, /broken, /missing-metadata + admin POST
  /cleanup (arq cleanup_storage worker; reconciles local refs only,
  guarded against a storage-outage mass delete)
- admin: GET /services, /sources, /settings + POST /reindex; PATCH
  /settings and /sources/{source} return 501 (config is env-managed)

Adds NotSupportedError (-> HTTP 501). Integration tests for each surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 14:33:12 +03:00

234 lines
7.8 KiB
Python

"""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:
# ``<img>`` 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)