Files
sdb2-backend/app/services/playlist.py
JSC c13e18c290
Some checks failed
Backend CI / lint (push) Failing after 9s
Backend CI / test (push) Failing after 1m34s
feat: Implement playlist sound deletion and update current playlist logic on deletion
2025-09-21 18:32:48 +02:00

572 lines
21 KiB
Python

"""Playlist service for business logic operations."""
from typing import Any, TypedDict
from fastapi import HTTPException, status
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.logging import get_logger
from app.models.playlist import Playlist
from app.models.playlist_sound import PlaylistSound
from app.models.sound import Sound
from app.repositories.playlist import PlaylistRepository, PlaylistSortField, SortOrder
from app.repositories.sound import SoundRepository
logger = get_logger(__name__)
class PaginatedPlaylistsResponse(TypedDict):
"""Response type for paginated playlists."""
playlists: list[dict]
total: int
page: int
limit: int
total_pages: int
async def _reload_player_playlist() -> None:
"""Reload the player playlist after current playlist changes."""
try:
# Import here to avoid circular import issues
from app.services.player import get_player_service # noqa: PLC0415
player = get_player_service()
await player.reload_playlist()
logger.debug("Player playlist reloaded after current playlist change")
except Exception: # noqa: BLE001
# Don't fail the playlist operation if player reload fails
logger.warning("Failed to reload player playlist", exc_info=True)
async def _is_current_playlist(session: AsyncSession, playlist_id: int) -> bool:
"""Check if the given playlist is the current playlist."""
try:
from app.repositories.playlist import PlaylistRepository # noqa: PLC0415
playlist_repo = PlaylistRepository(session)
current_playlist = await playlist_repo.get_current_playlist()
except Exception: # noqa: BLE001
logger.warning("Failed to check if playlist is current", exc_info=True)
return False
else:
return current_playlist is not None and current_playlist.id == playlist_id
class PlaylistService:
"""Service for playlist operations."""
def __init__(self, session: AsyncSession) -> None:
"""Initialize the playlist service."""
self.session = session
self.playlist_repo = PlaylistRepository(session)
self.sound_repo = SoundRepository(session)
async def _is_main_playlist(self, playlist_id: int) -> bool:
"""Check if the given playlist is the main playlist."""
try:
playlist = await self.playlist_repo.get_by_id(playlist_id)
except Exception:
logger.exception("Failed to check if playlist is main: %s", playlist_id)
return False
else:
return playlist is not None and playlist.is_main
async def get_playlist_by_id(self, playlist_id: int) -> Playlist:
"""Get a playlist by ID."""
playlist = await self.playlist_repo.get_by_id(playlist_id)
if not playlist:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Playlist not found",
)
return playlist
async def get_user_playlists(self, user_id: int) -> list[Playlist]:
"""Get all playlists for a user."""
return await self.playlist_repo.get_by_user_id(user_id)
async def get_all_playlists(self) -> list[Playlist]:
"""Get all playlists from all users."""
return await self.playlist_repo.get_all()
async def get_main_playlist(self) -> Playlist:
"""Get the global main playlist."""
main_playlist = await self.playlist_repo.get_main_playlist()
if not main_playlist:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Main playlist not found. Make sure to run database seeding.",
)
return main_playlist
async def get_current_playlist(self) -> Playlist:
"""Get the global current playlist, fallback to main playlist."""
current_playlist = await self.playlist_repo.get_current_playlist()
if current_playlist:
return current_playlist
# Fallback to main playlist if no current playlist is set
return await self.get_main_playlist()
async def create_playlist( # noqa: PLR0913
self,
user_id: int,
name: str,
description: str | None = None,
genre: str | None = None,
*,
is_main: bool = False,
is_current: bool = False,
is_deletable: bool = True,
) -> Playlist:
"""Create a new playlist."""
# Check if name already exists for this user
existing_playlist = await self.playlist_repo.get_by_name(name)
if existing_playlist and existing_playlist.user_id == user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A playlist with this name already exists",
)
# If this is set as current, unset the previous current playlist
if is_current:
await self._unset_current_playlist()
playlist_data = {
"user_id": user_id,
"name": name,
"description": description,
"genre": genre,
"is_main": is_main,
"is_current": is_current,
"is_deletable": is_deletable,
}
playlist = await self.playlist_repo.create(playlist_data)
logger.info("Created playlist '%s' for user %s", name, user_id)
# If this was set as current, reload player playlist
if is_current:
await _reload_player_playlist()
return playlist
async def update_playlist( # noqa: PLR0913
self,
playlist_id: int,
user_id: int,
*,
name: str | None = None,
description: str | None = None,
genre: str | None = None,
is_current: bool | None = None,
) -> Playlist:
"""Update a playlist."""
# Check if this is the main playlist
if await self._is_main_playlist(playlist_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="The main playlist cannot be edited",
)
playlist = await self.get_playlist_by_id(playlist_id)
update_data: dict[str, Any] = {}
if name is not None:
# Check if new name conflicts with existing playlist
existing_playlist = await self.playlist_repo.get_by_name(name)
if (
existing_playlist
and existing_playlist.id != playlist_id
and existing_playlist.user_id == user_id
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A playlist with this name already exists",
)
update_data["name"] = name
if description is not None:
update_data["description"] = description
if genre is not None:
update_data["genre"] = genre
if is_current is not None:
if is_current:
await self._unset_current_playlist()
update_data["is_current"] = is_current
if update_data:
playlist = await self.playlist_repo.update(playlist, update_data)
logger.info("Updated playlist %s for user %s", playlist_id, user_id)
# If is_current was changed, reload player playlist
if "is_current" in update_data:
await _reload_player_playlist()
return playlist
async def delete_playlist(self, playlist_id: int, user_id: int) -> None:
"""Delete a playlist."""
# Check if this is the main playlist
if await self._is_main_playlist(playlist_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="The main playlist cannot be deleted",
)
playlist = await self.get_playlist_by_id(playlist_id)
if not playlist.is_deletable:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This playlist cannot be deleted",
)
# Check if this was the current playlist before deleting
was_current = playlist.is_current
# First, delete all playlist_sound relationships
await self._delete_playlist_sounds(playlist_id)
# Then delete the playlist itself
await self.playlist_repo.delete(playlist)
logger.info("Deleted playlist %s for user %s", playlist_id, user_id)
# If the deleted playlist was current, set main playlist as current
if was_current:
main_playlist = await self.get_main_playlist()
await self.playlist_repo.update(main_playlist, {"is_current": True})
logger.info("Set main playlist as current after deleting current playlist %s", playlist_id)
# Reload player to reflect the change
await _reload_player_playlist()
async def search_playlists(self, query: str, user_id: int) -> list[Playlist]:
"""Search user's playlists by name."""
return await self.playlist_repo.search_by_name(query, user_id)
async def search_all_playlists(self, query: str) -> list[Playlist]:
"""Search all playlists by name."""
return await self.playlist_repo.search_by_name(query)
async def search_and_sort_playlists( # noqa: PLR0913
self,
search_query: str | None = None,
sort_by: PlaylistSortField | None = None,
sort_order: SortOrder = SortOrder.ASC,
user_id: int | None = None,
*,
include_stats: bool = False,
limit: int | None = None,
offset: int = 0,
favorites_only: bool = False,
current_user_id: int | None = None,
) -> list[dict]:
"""Search and sort playlists with optional statistics."""
return await self.playlist_repo.search_and_sort(
search_query=search_query,
sort_by=sort_by,
sort_order=sort_order,
user_id=user_id,
include_stats=include_stats,
limit=limit,
offset=offset,
favorites_only=favorites_only,
current_user_id=current_user_id,
)
async def search_and_sort_playlists_paginated( # noqa: PLR0913
self,
search_query: str | None = None,
sort_by: PlaylistSortField | None = None,
sort_order: SortOrder = SortOrder.ASC,
user_id: int | None = None,
*,
include_stats: bool = False,
page: int = 1,
limit: int = 50,
favorites_only: bool = False,
current_user_id: int | None = None,
) -> PaginatedPlaylistsResponse:
"""Search and sort playlists with pagination."""
offset = (page - 1) * limit
playlists, total_count = await self.playlist_repo.search_and_sort(
search_query=search_query,
sort_by=sort_by,
sort_order=sort_order,
user_id=user_id,
include_stats=include_stats,
limit=limit,
offset=offset,
favorites_only=favorites_only,
current_user_id=current_user_id,
return_count=True,
)
total_pages = (total_count + limit - 1) // limit # Ceiling division
return PaginatedPlaylistsResponse(
playlists=playlists,
total=total_count,
page=page,
limit=limit,
total_pages=total_pages,
)
async def get_playlist_sounds(self, playlist_id: int) -> list[Sound]:
"""Get all sounds in a playlist."""
await self.get_playlist_by_id(playlist_id) # Verify playlist exists
return await self.playlist_repo.get_playlist_sounds(playlist_id)
async def add_sound_to_playlist(
self,
playlist_id: int,
sound_id: int,
user_id: int,
position: int | None = None,
) -> None:
"""Add a sound to a playlist."""
# Check if this is the main playlist
if await self._is_main_playlist(playlist_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Sounds cannot be added to the main playlist",
)
# Verify playlist exists
await self.get_playlist_by_id(playlist_id)
# Verify sound exists
sound = await self.sound_repo.get_by_id(sound_id)
if not sound:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Sound not found",
)
# Check if sound is already in playlist
if await self.playlist_repo.is_sound_in_playlist(playlist_id, sound_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Sound is already in this playlist",
)
# If position is None or beyond current positions, place at the end
if position is None:
current_sounds = await self.playlist_repo.get_playlist_sounds(playlist_id)
position = len(current_sounds)
else:
# Ensure position doesn't create gaps - if too high, place at end
current_sounds = await self.playlist_repo.get_playlist_sounds(playlist_id)
max_position = len(current_sounds)
position = min(position, max_position)
await self.playlist_repo.add_sound_to_playlist(playlist_id, sound_id, position)
logger.info(
"Added sound %s to playlist %s for user %s at position %s",
sound_id,
playlist_id,
user_id,
position,
)
# If this is the current playlist, reload player
if await _is_current_playlist(self.session, playlist_id):
await _reload_player_playlist()
async def remove_sound_from_playlist(
self,
playlist_id: int,
sound_id: int,
user_id: int,
) -> None:
"""Remove a sound from a playlist."""
# Check if this is the main playlist
if await self._is_main_playlist(playlist_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Sounds cannot be removed from the main playlist",
)
# Verify playlist exists
await self.get_playlist_by_id(playlist_id)
# Check if sound is in playlist
if not await self.playlist_repo.is_sound_in_playlist(playlist_id, sound_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Sound not found in this playlist",
)
await self.playlist_repo.remove_sound_from_playlist(playlist_id, sound_id)
# Reorder remaining sounds to eliminate gaps
await self._reorder_playlist_positions(playlist_id)
logger.info(
"Removed sound %s from playlist %s for user %s and reordered positions",
sound_id,
playlist_id,
user_id,
)
# If this is the current playlist, reload player
if await _is_current_playlist(self.session, playlist_id):
await _reload_player_playlist()
async def _reorder_playlist_positions(self, playlist_id: int) -> None:
"""Reorder all sounds in a playlist to eliminate position gaps."""
sounds = await self.playlist_repo.get_playlist_sounds(playlist_id)
if not sounds:
return
# Create sequential positions: 0, 1, 2, 3...
sound_positions = [(sound.id, index) for index, sound in enumerate(sounds)]
await self.playlist_repo.reorder_playlist_sounds(playlist_id, sound_positions)
logger.debug(
"Reordered %s sounds in playlist %s to eliminate gaps",
len(sounds),
playlist_id,
)
async def reorder_playlist_sounds(
self,
playlist_id: int,
user_id: int,
sound_positions: list[tuple[int, int]],
) -> None:
"""Reorder sounds in a playlist."""
# Verify playlist exists
await self.get_playlist_by_id(playlist_id)
# Validate all sounds are in the playlist
for sound_id, _ in sound_positions:
if not await self.playlist_repo.is_sound_in_playlist(playlist_id, sound_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Sound {sound_id} is not in this playlist",
)
await self.playlist_repo.reorder_playlist_sounds(playlist_id, sound_positions)
logger.info("Reordered sounds in playlist %s for user %s", playlist_id, user_id)
# If this is the current playlist, reload player
if await _is_current_playlist(self.session, playlist_id):
await _reload_player_playlist()
async def get_playlist_stats(self, playlist_id: int) -> dict[str, Any]:
"""Get statistics for a playlist."""
await self.get_playlist_by_id(playlist_id) # Verify playlist exists
sound_count = await self.playlist_repo.get_playlist_sound_count(playlist_id)
sounds = await self.playlist_repo.get_playlist_sounds(playlist_id)
total_duration = sum(sound.duration or 0 for sound in sounds)
total_plays = sum(sound.play_count or 0 for sound in sounds)
return {
"sound_count": sound_count,
"total_duration_ms": total_duration,
"total_play_count": total_plays,
}
async def add_sound_to_main_playlist(
self,
sound_id: int, # noqa: ARG002
user_id: int, # noqa: ARG002
) -> None:
"""Add a sound to the global main playlist."""
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Sounds cannot be added to the main playlist",
)
async def _add_sound_to_main_playlist_internal(
self,
sound_id: int,
user_id: int,
) -> None:
"""Add sound to main playlist bypassing restrictions.
This method is intended for internal system use only (e.g., extraction service).
It bypasses the main playlist modification restrictions.
"""
main_playlist = await self.get_main_playlist()
if main_playlist.id is None:
msg = "Main playlist has no ID, cannot add sound"
raise ValueError(msg)
# Extract ID before async operations to avoid session issues
main_playlist_id = main_playlist.id
# Check if sound is already in main playlist
if not await self.playlist_repo.is_sound_in_playlist(
main_playlist_id,
sound_id,
):
await self.playlist_repo.add_sound_to_playlist(main_playlist_id, sound_id)
logger.info(
"Added sound %s to main playlist for user %s (internal)",
sound_id,
user_id,
)
# If main playlist is current, reload player
if await _is_current_playlist(self.session, main_playlist_id):
await _reload_player_playlist()
# Current playlist methods (global by default)
async def set_current_playlist(self, playlist_id: int) -> Playlist:
"""Set a playlist as the current playlist (app-wide)."""
playlist = await self.get_playlist_by_id(playlist_id)
# Unset any existing current playlist globally
await self._unset_current_playlist()
# Set new current playlist
playlist = await self.playlist_repo.update(playlist, {"is_current": True})
logger.info("Set playlist %s as current playlist", playlist_id)
# Reload player playlist to reflect the change
await _reload_player_playlist()
return playlist
async def unset_current_playlist(self) -> None:
"""Unset the current playlist (main playlist becomes fallback)."""
await self._unset_current_playlist()
logger.info("Unset current playlist, main playlist is now fallback")
# Reload player playlist to reflect the change (will fallback to main)
await _reload_player_playlist()
async def _delete_playlist_sounds(self, playlist_id: int) -> None:
"""Delete all playlist_sound records for a given playlist."""
# Get all playlist_sound records for this playlist
stmt = select(PlaylistSound).where(PlaylistSound.playlist_id == playlist_id)
result = await self.session.exec(stmt)
playlist_sounds = result.all()
# Delete each playlist_sound record
for playlist_sound in playlist_sounds:
await self.session.delete(playlist_sound)
await self.session.commit()
logger.info("Deleted %d playlist_sound records for playlist %s", len(playlist_sounds), playlist_id)
async def _unset_current_playlist(self) -> None:
"""Unset any current playlist globally."""
current_playlist = await self.playlist_repo.get_current_playlist()
if current_playlist:
await self.playlist_repo.update(current_playlist, {"is_current": False})