Compare commits

..

2 Commits

Author SHA1 Message Date
JSC
12243b1424 feat: Clear and manage play_next queue on playlist changes
Some checks failed
Backend CI / lint (push) Failing after 9s
Backend CI / test (push) Failing after 1m36s
2025-10-04 19:39:44 +02:00
JSC
f7197a89a7 feat: Add play next functionality to player service and API 2025-10-04 19:16:37 +02:00
4 changed files with 123 additions and 1 deletions

View File

@@ -249,3 +249,21 @@ async def get_state(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get player state", detail="Failed to get player state",
) from e ) from e
@router.post("/play-next/{sound_id}")
async def add_to_play_next(
sound_id: int,
current_user: Annotated[User, Depends(get_current_active_user_flexible)], # noqa: ARG001
) -> MessageResponse:
"""Add a sound to the play next queue."""
try:
player = get_player_service()
await player.add_to_play_next(sound_id)
return MessageResponse(message=f"Added sound {sound_id} to play next queue")
except Exception as e:
logger.exception("Error adding sound to play next queue: %s", sound_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to add sound to play next queue",
) from e

View File

@@ -49,3 +49,7 @@ class PlayerStateResponse(BaseModel):
None, None,
description="Current track index in playlist", description="Current track index in playlist",
) )
play_next_queue: list[dict[str, Any]] = Field(
default_factory=list,
description="Play next queue",
)

View File

@@ -8,6 +8,8 @@ from enum import Enum
from typing import Any from typing import Any
import vlc # type: ignore[import-untyped] import vlc # type: ignore[import-untyped]
from sqlalchemy.orm import selectinload
from sqlmodel import select
from app.core.logging import get_logger from app.core.logging import get_logger
from app.models.playlist import Playlist from app.models.playlist import Playlist
@@ -62,6 +64,7 @@ class PlayerState:
self.playlist_length: int = 0 self.playlist_length: int = 0
self.playlist_duration: int = 0 self.playlist_duration: int = 0
self.playlist_sounds: list[Sound] = [] self.playlist_sounds: list[Sound] = []
self.play_next_queue: list[Sound] = []
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
"""Convert player state to dictionary for serialization.""" """Convert player state to dictionary for serialization."""
@@ -87,6 +90,9 @@ class PlayerState:
if self.playlist_id if self.playlist_id
else None else None
), ),
"play_next_queue": [
self._serialize_sound(sound) for sound in self.play_next_queue
],
} }
def _serialize_sound(self, sound: Sound | None) -> dict[str, Any] | None: def _serialize_sound(self, sound: Sound | None) -> dict[str, Any] | None:
@@ -342,6 +348,11 @@ class PlayerService:
async def next(self) -> None: async def next(self) -> None:
"""Skip to next track.""" """Skip to next track."""
# Check if there's a track in the play_next queue
if self.state.play_next_queue:
await self._play_next_from_queue()
return
if not self.state.playlist_sounds: if not self.state.playlist_sounds:
return return
@@ -431,6 +442,52 @@ class PlayerService:
await self._broadcast_state() await self._broadcast_state()
logger.info("Playback mode set to: %s", mode.value) logger.info("Playback mode set to: %s", mode.value)
async def add_to_play_next(self, sound_id: int) -> None:
"""Add a sound to the play_next queue."""
session = self.db_session_factory()
try:
# Eagerly load extractions to avoid lazy loading issues
statement = select(Sound).where(Sound.id == sound_id)
statement = statement.options(selectinload(Sound.extractions)) # type: ignore[arg-type]
result = await session.exec(statement)
sound = result.first()
if not sound:
logger.warning("Sound %s not found for play_next", sound_id)
return
self.state.play_next_queue.append(sound)
await self._broadcast_state()
logger.info("Added sound %s to play_next queue", sound.name)
finally:
await session.close()
async def _play_next_from_queue(self) -> None:
"""Play the first track from the play_next queue."""
if not self.state.play_next_queue:
return
# Get the first sound from the queue
next_sound = self.state.play_next_queue.pop(0)
# Stop current playback and process play count
if self.state.status != PlayerStatus.STOPPED:
await self._stop_playback()
# Set the sound as current (without index since it's from play_next)
self.state.current_sound = next_sound
self.state.current_sound_id = next_sound.id
self.state.current_sound_index = None # No index for play_next tracks
# Play the sound
if not self._validate_sound_file():
return
if not self._load_and_play_media():
return
await self._handle_successful_playback()
async def reload_playlist(self) -> None: async def reload_playlist(self) -> None:
"""Reload current playlist from database.""" """Reload current playlist from database."""
session = self.db_session_factory() session = self.db_session_factory()
@@ -519,6 +576,11 @@ class PlayerService:
current_id, current_id,
) )
# Clear play_next queue when playlist changes
if self.state.play_next_queue:
logger.info("Clearing play_next queue due to playlist change")
self.state.play_next_queue.clear()
if self.state.status != PlayerStatus.STOPPED: if self.state.status != PlayerStatus.STOPPED:
await self._stop_playback() await self._stop_playback()
@@ -534,6 +596,9 @@ class PlayerService:
sounds: list[Sound], sounds: list[Sound],
) -> None: ) -> None:
"""Handle track checking when playlist ID is the same.""" """Handle track checking when playlist ID is the same."""
# Remove tracks from play_next queue that are no longer in the playlist
self._clean_play_next_queue(sounds)
# Find the current track in the new playlist # Find the current track in the new playlist
new_index = self._find_sound_index(previous_sound_id, sounds) new_index = self._find_sound_index(previous_sound_id, sounds)
@@ -591,6 +656,29 @@ class PlayerService:
return i return i
return None return None
def _clean_play_next_queue(self, playlist_sounds: list[Sound]) -> None:
"""Remove tracks from play_next queue that are no longer in the playlist."""
if not self.state.play_next_queue:
return
# Get IDs of all sounds in the current playlist
playlist_sound_ids = {sound.id for sound in playlist_sounds}
# Filter out tracks that are no longer in the playlist
original_length = len(self.state.play_next_queue)
self.state.play_next_queue = [
sound
for sound in self.state.play_next_queue
if sound.id in playlist_sound_ids
]
removed_count = original_length - len(self.state.play_next_queue)
if removed_count > 0:
logger.info(
"Removed %s track(s) from play_next queue (no longer in playlist)",
removed_count,
)
def _set_first_track_as_current(self, sounds: list[Sound]) -> None: def _set_first_track_as_current(self, sounds: list[Sound]) -> None:
"""Set the first track as the current track.""" """Set the first track as the current track."""
self.state.current_sound_index = 0 self.state.current_sound_index = 0
@@ -780,7 +868,12 @@ class PlayerService:
"""Handle when a track finishes playing.""" """Handle when a track finishes playing."""
await self._process_play_count() await self._process_play_count()
# Auto-advance to next track # Check if there's a track in the play_next queue
if self.state.play_next_queue:
await self._play_next_from_queue()
return
# Auto-advance to next track in playlist
if self.state.current_sound_index is not None: if self.state.current_sound_index is not None:
next_index = self._get_next_index(self.state.current_sound_index) next_index = self._get_next_index(self.state.current_sound_index)
if next_index is not None: if next_index is not None:
@@ -788,6 +881,12 @@ class PlayerService:
else: else:
await self._stop_playback() await self._stop_playback()
await self._broadcast_state() await self._broadcast_state()
elif self.state.playlist_sounds:
# Current track was from play_next, go to playlist
await self.play(0)
else:
await self._stop_playback()
await self._broadcast_state()
async def _broadcast_state(self) -> None: async def _broadcast_state(self) -> None:
"""Broadcast current player state via WebSocket.""" """Broadcast current player state via WebSocket."""

View File

@@ -537,6 +537,7 @@ class TestPlayerEndpoints:
"duration": 30000, "duration": 30000,
"sounds": [], "sounds": [],
}, },
"play_next_queue": [],
} }
mock_player_service.get_state.return_value = mock_state mock_player_service.get_state.return_value = mock_state