Files
musicseerr/backend/api/v1/routes/playlists.py
T
2026-04-03 15:53:00 +01:00

361 lines
12 KiB
Python

import logging
from fastapi import APIRouter, File, UploadFile
from fastapi.responses import FileResponse
from api.v1.schemas.common import StatusMessageResponse
from api.v1.schemas.playlists import (
AddTracksRequest,
AddTracksResponse,
CheckTrackMembershipRequest,
CheckTrackMembershipResponse,
CoverUploadResponse,
CreatePlaylistRequest,
PlaylistDetailResponse,
PlaylistListResponse,
PlaylistSummaryResponse,
PlaylistTrackResponse,
RemoveTracksRequest,
ReorderTrackRequest,
ReorderTrackResponse,
ResolveSourcesResponse,
UpdatePlaylistRequest,
UpdateTrackRequest,
)
from core.dependencies import JellyfinLibraryServiceDep, LocalFilesServiceDep, NavidromeLibraryServiceDep, PlaylistServiceDep
from core.exceptions import PlaylistNotFoundError
from infrastructure.msgspec_fastapi import MsgSpecBody, MsgSpecRoute
logger = logging.getLogger(__name__)
router = APIRouter(
route_class=MsgSpecRoute,
prefix="/playlists",
tags=["playlists"],
)
def _normalize_cover_url(url: str | None) -> str | None:
if url and url.startswith("/api/covers/"):
return "/api/v1/covers/" + url[len("/api/covers/"):]
return url
def _normalize_source_type(source_type: str) -> str:
return source_type
def _normalize_available_sources(sources: list[str] | None) -> list[str] | None:
if sources is None:
return None
return sources
def _custom_cover_url(playlist_id: str, cover_image_path: str | None) -> str | None:
if cover_image_path:
return f"/api/v1/playlists/{playlist_id}/cover"
return None
def _track_to_response(t) -> PlaylistTrackResponse:
return PlaylistTrackResponse(
id=t.id,
position=t.position,
track_name=t.track_name,
artist_name=t.artist_name,
album_name=t.album_name,
album_id=t.album_id,
artist_id=t.artist_id,
track_source_id=t.track_source_id,
cover_url=_normalize_cover_url(t.cover_url),
source_type=_normalize_source_type(t.source_type),
available_sources=_normalize_available_sources(t.available_sources),
format=t.format,
track_number=t.track_number,
disc_number=t.disc_number,
duration=t.duration,
created_at=t.created_at,
)
@router.get("", response_model=PlaylistListResponse)
async def list_playlists(
service: PlaylistServiceDep,
) -> PlaylistListResponse:
summaries = await service.get_all_playlists()
return PlaylistListResponse(
playlists=[
PlaylistSummaryResponse(
id=s.id,
name=s.name,
track_count=s.track_count,
total_duration=s.total_duration,
cover_urls=[_normalize_cover_url(u) for u in s.cover_urls] if s.cover_urls else [],
custom_cover_url=_custom_cover_url(s.id, s.cover_image_path),
created_at=s.created_at,
updated_at=s.updated_at,
)
for s in summaries
]
)
@router.post("/check-tracks", response_model=CheckTrackMembershipResponse)
async def check_track_membership(
service: PlaylistServiceDep,
body: CheckTrackMembershipRequest = MsgSpecBody(CheckTrackMembershipRequest),
) -> CheckTrackMembershipResponse:
tracks = [(t.track_name, t.artist_name, t.album_name) for t in body.tracks]
membership = await service.check_track_membership(tracks)
return CheckTrackMembershipResponse(membership=membership)
@router.post("", response_model=PlaylistDetailResponse, status_code=201)
async def create_playlist(
service: PlaylistServiceDep,
body: CreatePlaylistRequest = MsgSpecBody(CreatePlaylistRequest),
) -> PlaylistDetailResponse:
playlist = await service.create_playlist(body.name)
return PlaylistDetailResponse(
id=playlist.id,
name=playlist.name,
custom_cover_url=_custom_cover_url(playlist.id, playlist.cover_image_path),
tracks=[],
track_count=0,
total_duration=None,
created_at=playlist.created_at,
updated_at=playlist.updated_at,
)
@router.get("/{playlist_id}", response_model=PlaylistDetailResponse)
async def get_playlist(
playlist_id: str,
service: PlaylistServiceDep,
) -> PlaylistDetailResponse:
playlist, tracks = await service.get_playlist_with_tracks(playlist_id)
track_responses = [_track_to_response(t) for t in tracks]
cover_urls = list(dict.fromkeys(_normalize_cover_url(t.cover_url) for t in tracks if t.cover_url))[:4]
total_duration = sum(t.duration for t in tracks if t.duration)
return PlaylistDetailResponse(
id=playlist.id,
name=playlist.name,
cover_urls=cover_urls,
custom_cover_url=_custom_cover_url(playlist.id, playlist.cover_image_path),
tracks=track_responses,
track_count=len(tracks),
total_duration=total_duration or None,
created_at=playlist.created_at,
updated_at=playlist.updated_at,
)
@router.put("/{playlist_id}", response_model=PlaylistDetailResponse)
async def update_playlist(
playlist_id: str,
service: PlaylistServiceDep,
body: UpdatePlaylistRequest = MsgSpecBody(UpdatePlaylistRequest),
) -> PlaylistDetailResponse:
playlist, tracks = await service.update_playlist_with_detail(playlist_id, name=body.name)
track_responses = [_track_to_response(t) for t in tracks]
cover_urls = list(dict.fromkeys(_normalize_cover_url(t.cover_url) for t in tracks if t.cover_url))[:4]
total_duration = sum(t.duration for t in tracks if t.duration)
return PlaylistDetailResponse(
id=playlist.id,
name=playlist.name,
cover_urls=cover_urls,
custom_cover_url=_custom_cover_url(playlist.id, playlist.cover_image_path),
tracks=track_responses,
track_count=len(tracks),
total_duration=total_duration or None,
created_at=playlist.created_at,
updated_at=playlist.updated_at,
)
@router.delete("/{playlist_id}", response_model=StatusMessageResponse)
async def delete_playlist(
playlist_id: str,
service: PlaylistServiceDep,
) -> StatusMessageResponse:
await service.delete_playlist(playlist_id)
return StatusMessageResponse(status="ok", message="Playlist deleted")
@router.post(
"/{playlist_id}/tracks",
response_model=AddTracksResponse,
status_code=201,
)
async def add_tracks(
playlist_id: str,
service: PlaylistServiceDep,
body: AddTracksRequest = MsgSpecBody(AddTracksRequest),
) -> AddTracksResponse:
track_dicts = [
{
"track_name": t.track_name,
"artist_name": t.artist_name,
"album_name": t.album_name,
"album_id": t.album_id,
"artist_id": t.artist_id,
"track_source_id": t.track_source_id,
"cover_url": t.cover_url,
"source_type": t.source_type,
"available_sources": t.available_sources,
"format": t.format,
"track_number": t.track_number,
"disc_number": t.disc_number,
"duration": int(t.duration) if t.duration is not None else None,
}
for t in body.tracks
]
created = await service.add_tracks(playlist_id, track_dicts, body.position)
return AddTracksResponse(tracks=[_track_to_response(t) for t in created])
@router.post(
"/{playlist_id}/tracks/remove",
response_model=StatusMessageResponse,
)
async def remove_tracks(
playlist_id: str,
service: PlaylistServiceDep,
body: RemoveTracksRequest = MsgSpecBody(RemoveTracksRequest),
) -> StatusMessageResponse:
removed = await service.remove_tracks(playlist_id, body.track_ids)
return StatusMessageResponse(status="ok", message=f"{removed} track(s) removed")
@router.delete(
"/{playlist_id}/tracks/{track_id}",
response_model=StatusMessageResponse,
)
async def remove_track(
playlist_id: str,
track_id: str,
service: PlaylistServiceDep,
) -> StatusMessageResponse:
await service.remove_track(playlist_id, track_id)
return StatusMessageResponse(status="ok", message="Track removed")
# Reorder must be registered before the {track_id} PATCH to avoid
# "reorder" being captured as a track_id path parameter.
@router.patch(
"/{playlist_id}/tracks/reorder",
response_model=ReorderTrackResponse,
)
async def reorder_track(
playlist_id: str,
service: PlaylistServiceDep,
body: ReorderTrackRequest = MsgSpecBody(ReorderTrackRequest),
) -> ReorderTrackResponse:
actual_position = await service.reorder_track(playlist_id, body.track_id, body.new_position)
return ReorderTrackResponse(
status="ok",
message="Track reordered",
actual_position=actual_position,
)
@router.patch(
"/{playlist_id}/tracks/{track_id}",
response_model=PlaylistTrackResponse,
)
async def update_track(
playlist_id: str,
track_id: str,
service: PlaylistServiceDep,
jf_service: JellyfinLibraryServiceDep,
local_service: LocalFilesServiceDep,
nd_service: NavidromeLibraryServiceDep,
body: UpdateTrackRequest = MsgSpecBody(UpdateTrackRequest),
) -> PlaylistTrackResponse:
result = await service.update_track_source(
playlist_id, track_id,
source_type=body.source_type,
available_sources=body.available_sources,
jf_service=jf_service,
local_service=local_service,
nd_service=nd_service,
)
return _track_to_response(result)
@router.post(
"/{playlist_id}/resolve-sources",
response_model=ResolveSourcesResponse,
)
async def resolve_sources(
playlist_id: str,
service: PlaylistServiceDep,
jf_service: JellyfinLibraryServiceDep,
local_service: LocalFilesServiceDep,
nd_service: NavidromeLibraryServiceDep,
) -> ResolveSourcesResponse:
sources = await service.resolve_track_sources(
playlist_id, jf_service=jf_service, local_service=local_service, nd_service=nd_service,
)
return ResolveSourcesResponse(sources=sources)
@router.post("/{playlist_id}/cover", response_model=CoverUploadResponse)
async def upload_cover(
playlist_id: str,
service: PlaylistServiceDep,
cover_image: UploadFile = File(...),
) -> CoverUploadResponse:
max_size = 2 * 1024 * 1024 # 2 MB
chunk_size = 8192
chunks: list[bytes] = []
total = 0
while True:
chunk = await cover_image.read(chunk_size)
if not chunk:
break
total += len(chunk)
if total > max_size:
from core.exceptions import InvalidPlaylistDataError
raise InvalidPlaylistDataError("Image too large. Maximum size is 2 MB")
chunks.append(chunk)
data = b"".join(chunks)
cover_url = await service.upload_cover(
playlist_id, data, cover_image.content_type or "",
)
return CoverUploadResponse(cover_url=cover_url)
@router.get("/{playlist_id}/cover")
async def get_cover(
playlist_id: str,
service: PlaylistServiceDep,
):
path = await service.get_cover_path(playlist_id)
if path is None:
raise PlaylistNotFoundError("No cover found")
media_type = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
}.get(path.suffix.lower(), "application/octet-stream")
return FileResponse(
path,
media_type=media_type,
headers={"Cache-Control": "public, max-age=3600"},
)
@router.delete(
"/{playlist_id}/cover",
response_model=StatusMessageResponse,
)
async def remove_cover(
playlist_id: str,
service: PlaylistServiceDep,
) -> StatusMessageResponse:
await service.remove_cover(playlist_id)
return StatusMessageResponse(status="ok", message="Cover removed")