Files
sdb2-backend/app/services/player.py
JSC 17eafa4872
Some checks failed
Backend CI / test (push) Failing after 2m17s
Backend CI / lint (push) Failing after 14m55s
feat: Enhance play_next functionality by storing and restoring playlist index
2025-10-05 04:07:34 +02:00

1000 lines
36 KiB
Python

"""Player service for audio playbook management."""
import asyncio
import threading
import time
from collections.abc import Callable, Coroutine
from enum import Enum
from typing import Any
import vlc # type: ignore[import-untyped]
from sqlalchemy.orm import selectinload
from sqlmodel import select
from app.core.logging import get_logger
from app.models.playlist import Playlist
from app.models.sound import Sound
from app.models.sound_played import SoundPlayed
from app.repositories.playlist import PlaylistRepository
from app.repositories.sound import SoundRepository
from app.services.socket import socket_manager
from app.services.volume import volume_service
from app.utils.audio import get_sound_file_path
logger = get_logger(__name__)
class PlayerStatus(str, Enum):
"""Player status enumeration."""
STOPPED = "stopped"
PLAYING = "playing"
PAUSED = "paused"
class PlayerMode(str, Enum):
"""Player mode enumeration."""
CONTINUOUS = "continuous"
LOOP = "loop"
LOOP_ONE = "loop_one"
RANDOM = "random"
SINGLE = "single"
class PlayerState:
"""Player state data structure."""
def __init__(self) -> None:
"""Initialize player state."""
self.status: PlayerStatus = PlayerStatus.STOPPED
self.mode: PlayerMode = PlayerMode.CONTINUOUS
# Initialize volume from host system or default to 80
host_volume = volume_service.get_volume()
self.volume: int = host_volume if host_volume is not None else 80
self.previous_volume: int = self.volume
self.current_sound_id: int | None = None
self.current_sound_index: int | None = None
self.current_sound_position: int = 0
self.current_sound_duration: int = 0
self.current_sound: Sound | None = None
self.playlist_id: int | None = None
self.playlist_name: str = ""
self.playlist_length: int = 0
self.playlist_duration: int = 0
self.playlist_sounds: list[Sound] = []
self.play_next_queue: list[Sound] = []
self.playlist_index_before_play_next: int | None = None
def to_dict(self) -> dict[str, Any]:
"""Convert player state to dictionary for serialization."""
return {
"status": self.status.value,
"mode": self.mode.value if isinstance(self.mode, PlayerMode) else self.mode,
"volume": self.volume,
"previous_volume": self.previous_volume,
"position": self.current_sound_position or 0,
"duration": self.current_sound_duration,
"index": self.current_sound_index,
"current_sound": self._serialize_sound(self.current_sound),
"playlist": (
{
"id": self.playlist_id,
"name": self.playlist_name,
"length": self.playlist_length,
"duration": self.playlist_duration,
"sounds": [
self._serialize_sound(sound) for sound in self.playlist_sounds
],
}
if self.playlist_id
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:
"""Serialize a sound object for JSON serialization."""
if not sound:
return None
# Get extraction URL if sound is linked to an extraction
extract_url = None
if hasattr(sound, "extractions") and sound.extractions:
# Get the first extraction (there should only be one per sound)
extraction = sound.extractions[0]
extract_url = extraction.url
return {
"id": sound.id,
"name": sound.name,
"filename": sound.filename,
"duration": sound.duration,
"size": sound.size,
"type": sound.type,
"thumbnail": sound.thumbnail,
"play_count": sound.play_count,
"extract_url": extract_url,
}
class PlayerService:
"""Service for audio playback management."""
def __init__(self, db_session_factory: Callable) -> None:
"""Initialize the player service."""
self.db_session_factory = db_session_factory
self.state = PlayerState()
self._vlc_instance = vlc.Instance()
if self._vlc_instance is None:
msg = (
"VLC instance could not be created. "
"Ensure VLC is installed and accessible."
)
raise RuntimeError(msg)
self._player = self._vlc_instance.media_player_new()
self._is_running = False
self._position_thread: threading.Thread | None = None
self._play_time_tracking: dict[int, dict[str, Any]] = {}
self._lock = threading.Lock()
self._background_tasks: set[asyncio.Task] = set()
self._loop: asyncio.AbstractEventLoop | None = None
self._last_position_broadcast: float = 0
async def start(self) -> None:
"""Start the player service."""
logger.info("Starting player service")
self._is_running = True
# Store the event loop for thread-safe task scheduling
self._loop = asyncio.get_running_loop()
# Load initial playlist
await self.reload_playlist()
# Start position tracking thread
self._position_thread = threading.Thread(
target=self._position_tracker,
daemon=True,
)
self._position_thread.start()
# Set VLC to 100% volume - host volume is controlled separately
self._player.audio_set_volume(100)
logger.info("Player service started")
async def stop(self) -> None:
"""Stop the player service."""
logger.info("Stopping player service")
self._is_running = False
# Stop playback
await self._stop_playback()
# Wait for position thread to finish
if self._position_thread and self._position_thread.is_alive():
self._position_thread.join(timeout=2.0)
# Release VLC player
self._player.release()
logger.info("Player service stopped")
async def play(self, index: int | None = None) -> None:
"""Play audio at specified index or current position."""
if self._should_resume_playback(index):
await self._resume_playback()
return
await self._start_new_track(index)
def _should_resume_playback(self, index: int | None) -> bool:
"""Check if we should resume paused playback."""
return (
index is None
and self.state.status == PlayerStatus.PAUSED
and self.state.current_sound is not None
)
async def _resume_playback(self) -> None:
"""Resume paused playback."""
result = self._player.play()
if result == 0: # VLC returns 0 on success
self.state.status = PlayerStatus.PLAYING
self._ensure_play_time_tracking_for_resume()
await self._broadcast_state()
sound_name = (
self.state.current_sound.name if self.state.current_sound else "Unknown"
)
logger.info("Resumed playing sound: %s", sound_name)
else:
logger.error("Failed to resume playback: VLC error code %s", result)
def _ensure_play_time_tracking_for_resume(self) -> None:
"""Ensure play time tracking is initialized for resumed track."""
if (
self.state.current_sound_id
and self.state.current_sound_id not in self._play_time_tracking
):
self._play_time_tracking[self.state.current_sound_id] = {
"total_time": 0,
"last_position": self.state.current_sound_position,
"last_update": time.time(),
"threshold_reached": False,
}
async def _start_new_track(self, index: int | None) -> None:
"""Start playing a new track."""
if not self._prepare_sound_for_playback(index):
return
if not self._load_and_play_media():
return
await self._handle_successful_playback()
def _prepare_sound_for_playback(self, index: int | None) -> bool:
"""Prepare sound for playback, return True if ready."""
if index is not None and not self._set_sound_by_index(index):
return False
if not self.state.current_sound:
logger.warning("No sound to play")
return False
return self._validate_sound_file()
def _set_sound_by_index(self, index: int) -> bool:
"""Set current sound by index, return True if valid."""
if index < 0 or index >= len(self.state.playlist_sounds):
msg = "Invalid sound index"
raise ValueError(msg)
self.state.current_sound_index = index
self.state.current_sound = self.state.playlist_sounds[index]
self.state.current_sound_id = self.state.current_sound.id
return True
def _validate_sound_file(self) -> bool:
"""Validate sound file exists, return True if valid."""
if not self.state.current_sound:
return False
sound_path = get_sound_file_path(self.state.current_sound)
if not sound_path.exists():
logger.error("Sound file not found: %s", sound_path)
return False
return True
def _load_and_play_media(self) -> bool:
"""Load media and start playback, return True if successful."""
if self._vlc_instance is None:
logger.error("VLC instance is not initialized. Cannot play media.")
return False
if not self.state.current_sound:
logger.error("No current sound to play")
return False
sound_path = get_sound_file_path(self.state.current_sound)
media = self._vlc_instance.media_new(str(sound_path))
self._player.set_media(media)
result = self._player.play()
if result != 0: # VLC returns 0 on success
logger.error("Failed to start playback: VLC error code %s", result)
return False
return True
async def _handle_successful_playback(self) -> None:
"""Handle successful playback start."""
if not self.state.current_sound:
logger.error("No current sound for successful playback")
return
self.state.status = PlayerStatus.PLAYING
self.state.current_sound_duration = self.state.current_sound.duration or 0
self._initialize_play_time_tracking()
await self._broadcast_state()
logger.info("Started playing sound: %s", self.state.current_sound.name)
def _initialize_play_time_tracking(self) -> None:
"""Initialize play time tracking for new track."""
if self.state.current_sound_id:
self._play_time_tracking[self.state.current_sound_id] = {
"total_time": 0,
"last_position": 0,
"last_update": time.time(),
"threshold_reached": False,
}
logger.info(
"Initialized play time tracking for sound %s (duration: %s ms)",
self.state.current_sound_id,
self.state.current_sound_duration,
)
async def pause(self) -> None:
"""Pause playback."""
if self.state.status == PlayerStatus.PLAYING:
self._player.pause()
self.state.status = PlayerStatus.PAUSED
await self._broadcast_state()
logger.info("Playback paused")
async def stop_playback(self) -> None:
"""Stop playback."""
await self._stop_playback()
await self._broadcast_state()
async def _stop_playback(self) -> None:
"""Stop playback internal method."""
if self.state.status != PlayerStatus.STOPPED:
self._player.stop()
self.state.status = PlayerStatus.STOPPED
self.state.current_sound_position = 0
# Process any pending play counts
await self._process_play_count()
logger.info("Playback stopped")
async def next(self) -> None:
"""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 currently playing from play_next queue (no index but have stored index)
if (
self.state.current_sound_index is None
and self.state.playlist_index_before_play_next is not None
and self.state.playlist_sounds
):
# Skipped the last play_next track, go to next in playlist
restored_index = self.state.playlist_index_before_play_next
next_index = self._get_next_index(restored_index)
# Clear the stored index
self.state.playlist_index_before_play_next = None
if next_index is not None:
await self.play(next_index)
else:
await self._stop_playback()
await self._broadcast_state()
return
if not self.state.playlist_sounds:
return
current_index = self.state.current_sound_index or 0
next_index = self._get_next_index(current_index)
if next_index is not None:
await self.play(next_index)
else:
await self._stop_playback()
await self._broadcast_state()
async def previous(self) -> None:
"""Go to previous track."""
if not self.state.playlist_sounds:
return
current_index = self.state.current_sound_index or 0
prev_index = self._get_previous_index(current_index)
if prev_index is not None:
await self.play(prev_index)
async def seek(self, position_ms: int) -> None:
"""Seek to specific position in current track."""
if self.state.status == PlayerStatus.STOPPED:
return
# Convert milliseconds to VLC position (0.0 to 1.0)
if self.state.current_sound_duration > 0:
position = position_ms / self.state.current_sound_duration
position = max(0.0, min(1.0, position)) # Clamp to valid range
self._player.set_position(position)
self.state.current_sound_position = position_ms
await self._broadcast_state()
logger.debug("Seeked to position: %sms", position_ms)
async def set_volume(self, volume: int) -> None:
"""Set playback volume (0-100) by controlling host system volume."""
volume = max(0, min(100, volume)) # Clamp to valid range
# Store previous volume when muting (going from >0 to 0)
if self.state.volume > 0 and volume == 0:
self.state.previous_volume = self.state.volume
self.state.volume = volume
# Control host system volume instead of VLC volume
if volume == 0:
# Mute the host system
volume_service.set_mute(muted=True)
else:
# Unmute and set host volume
if volume_service.is_muted():
volume_service.set_mute(muted=False)
volume_service.set_volume(volume)
# Keep VLC at 100% volume
self._player.audio_set_volume(100)
await self._broadcast_state()
logger.debug("Host volume set to: %s", volume)
async def mute(self) -> None:
"""Mute the host system (stores current volume as previous_volume)."""
if self.state.volume > 0:
await self.set_volume(0)
async def unmute(self) -> None:
"""Unmute the host system (restores previous_volume)."""
if self.state.volume == 0 and self.state.previous_volume > 0:
await self.set_volume(self.state.previous_volume)
async def set_mode(self, mode: PlayerMode | str) -> None:
"""Set playback mode."""
if isinstance(mode, str):
# Convert string to PlayerMode enum
try:
mode = PlayerMode(mode)
except ValueError:
logger.exception("Invalid player mode: %s", mode)
return
self.state.mode = mode
await self._broadcast_state()
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
# Store current playlist index before switching to play_next track
# Only store if we're currently playing from the playlist
if (
self.state.current_sound_index is not None
and self.state.playlist_index_before_play_next is None
):
self.state.playlist_index_before_play_next = (
self.state.current_sound_index
)
logger.info(
"Stored playlist index %s before playing from play_next queue",
self.state.playlist_index_before_play_next,
)
# 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:
"""Reload current playlist from database."""
session = self.db_session_factory()
try:
playlist_repo = PlaylistRepository(session)
current_playlist = await playlist_repo.get_current_playlist()
if current_playlist and current_playlist.id:
sounds = await playlist_repo.get_playlist_sounds(current_playlist.id)
await self._handle_playlist_reload(current_playlist, sounds)
logger.info(
"Loaded playlist: %s (%s sounds)",
current_playlist.name,
len(sounds),
)
else:
logger.warning("No playlist found to load")
finally:
await session.close()
await self._broadcast_state()
async def load_playlist(self, playlist_id: int) -> None:
"""Load a specific playlist by ID."""
session = self.db_session_factory()
try:
playlist_repo = PlaylistRepository(session)
playlist = await playlist_repo.get_by_id(playlist_id)
if playlist and playlist.id:
sounds = await playlist_repo.get_playlist_sounds(playlist.id)
await self._handle_playlist_reload(playlist, sounds)
logger.info(
"Loaded playlist: %s (%s sounds)",
playlist.name,
len(sounds),
)
else:
logger.warning("Playlist not found: %s", playlist_id)
finally:
await session.close()
await self._broadcast_state()
async def _handle_playlist_reload(
self,
current_playlist: Playlist,
sounds: list[Sound],
) -> None:
"""Handle playlist reload logic with ID comparison."""
# Store previous state for comparison
previous_playlist_id = self.state.playlist_id
previous_current_sound_id = self.state.current_sound_id
previous_current_sound_index = self.state.current_sound_index
# Update basic playlist state
self._update_playlist_state(current_playlist, sounds)
# Handle playlist changes based on ID comparison
if (
current_playlist.id is not None
and previous_playlist_id != current_playlist.id
):
await self._handle_playlist_id_changed(
previous_playlist_id,
current_playlist.id,
sounds,
)
elif previous_current_sound_id:
await self._handle_same_playlist_track_check(
previous_current_sound_id,
previous_current_sound_index,
sounds,
)
elif sounds:
self._set_first_track_as_current(sounds)
async def _handle_playlist_id_changed(
self,
previous_id: int | None,
current_id: int,
sounds: list[Sound],
) -> None:
"""Handle when playlist ID changes - stop player and reset to first track."""
logger.info(
"Playlist changed from %s to %s - stopping player and resetting",
previous_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()
# Clear stored playlist index
if self.state.playlist_index_before_play_next is not None:
logger.info("Clearing stored playlist index due to playlist change")
self.state.playlist_index_before_play_next = None
if self.state.status != PlayerStatus.STOPPED:
await self._stop_playback()
if sounds:
self._set_first_track_as_current(sounds)
else:
self._clear_current_track()
async def _handle_same_playlist_track_check(
self,
previous_sound_id: int,
previous_index: int | None,
sounds: list[Sound],
) -> None:
"""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
new_index = self._find_sound_index(previous_sound_id, sounds)
if new_index is not None:
# Track still exists - update index if it changed
if new_index != previous_index:
logger.info(
"Current track %s moved from index %s to %s",
previous_sound_id,
previous_index,
new_index,
)
# Always set the index and sound reference
self.state.current_sound_index = new_index
self.state.current_sound = sounds[new_index]
else:
# Current track no longer exists in playlist
await self._handle_track_removed(previous_sound_id, sounds)
async def _handle_track_removed(
self,
previous_sound_id: int,
sounds: list[Sound],
) -> None:
"""Handle when current track no longer exists in playlist."""
logger.info(
"Current track %s no longer exists in playlist - stopping and resetting",
previous_sound_id,
)
if self.state.status != PlayerStatus.STOPPED:
await self._stop_playback()
if sounds:
self._set_first_track_as_current(sounds)
else:
self._clear_current_track()
def _update_playlist_state(
self,
current_playlist: Playlist,
sounds: list[Sound],
) -> None:
"""Update basic playlist state information."""
self.state.playlist_id = current_playlist.id
self.state.playlist_name = current_playlist.name
self.state.playlist_sounds = sounds
self.state.playlist_length = len(sounds)
self.state.playlist_duration = sum(sound.duration or 0 for sound in sounds)
def _find_sound_index(self, sound_id: int, sounds: list[Sound]) -> int | None:
"""Find the index of a sound in the sounds list."""
for i, sound in enumerate(sounds):
if sound.id == sound_id:
return i
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:
"""Set the first track as the current track."""
self.state.current_sound_index = 0
self.state.current_sound = sounds[0]
self.state.current_sound_id = sounds[0].id
def _clear_current_track(self) -> None:
"""Clear the current track state."""
self.state.current_sound_index = None
self.state.current_sound = None
self.state.current_sound_id = None
def get_state(self) -> dict[str, Any]:
"""Get current player state."""
return self.state.to_dict()
def _get_next_index(self, current_index: int) -> int | None:
"""Get next track index based on current mode."""
if not self.state.playlist_sounds:
return None
playlist_length = len(self.state.playlist_sounds)
if self.state.mode == PlayerMode.SINGLE:
return None
if self.state.mode == PlayerMode.LOOP_ONE:
return current_index
if self.state.mode == PlayerMode.RANDOM:
import random # noqa: PLC0415
indices = list(range(playlist_length))
indices.remove(current_index)
return random.choice(indices) if indices else None # noqa: S311
# CONTINUOUS or LOOP
next_index = current_index + 1
if next_index >= playlist_length:
return 0 if self.state.mode == PlayerMode.LOOP else None
return next_index
def _get_previous_index(self, current_index: int) -> int | None:
"""Get previous track index."""
if not self.state.playlist_sounds:
return None
playlist_length = len(self.state.playlist_sounds)
prev_index = current_index - 1
if prev_index < 0:
return playlist_length - 1 if self.state.mode == PlayerMode.LOOP else None
return prev_index
def _position_tracker(self) -> None:
"""Background thread to track playback position and handle auto-advance."""
while self._is_running:
if self.state.status == PlayerStatus.PLAYING:
# Update position
vlc_position = self._player.get_position()
if vlc_position >= 0: # Valid position
self.state.current_sound_position = int(
vlc_position * self.state.current_sound_duration,
)
# Check if track finished
player_state = self._player.get_state()
vlc_state_ended = 6 # vlc.State.Ended value
if player_state == vlc_state_ended:
# Track finished, handle auto-advance
self._schedule_async_task(self._handle_track_finished())
# Update play time tracking
self._update_play_time()
# Broadcast state every second while playing
broadcast_interval = 1
current_time = time.time()
if current_time - self._last_position_broadcast >= broadcast_interval:
self._last_position_broadcast = current_time
self._schedule_async_task(self._broadcast_state())
time.sleep(0.1) # 100ms update interval
def _update_play_time(self) -> None:
"""Update play time tracking for current sound."""
if not self.state.current_sound_id or self.state.status != PlayerStatus.PLAYING:
return
sound_id = self.state.current_sound_id
current_time = time.time()
current_position = self.state.current_sound_position
with self._lock:
if sound_id in self._play_time_tracking:
tracking = self._play_time_tracking[sound_id]
# Calculate time elapsed (only if position advanced reasonably)
time_elapsed = current_time - tracking["last_update"]
position_diff = abs(current_position - tracking["last_position"])
# Only count if position advanced naturally (not seeking)
max_position_jump = 5000 # 5 seconds in milliseconds
if time_elapsed > 0 and position_diff < max_position_jump:
# Add real time elapsed (converted to ms)
tracking["total_time"] += time_elapsed * 1000
tracking["last_position"] = current_position
tracking["last_update"] = current_time
# Check if 20% threshold reached
play_threshold = 0.2 # 20% of track duration
threshold_time = self.state.current_sound_duration * play_threshold
if (
not tracking["threshold_reached"]
and self.state.current_sound_duration > 0
and tracking["total_time"] >= threshold_time
):
tracking["threshold_reached"] = True
logger.info(
"Play count threshold reached for sound %s: %s/%s ms (%.1f%%)",
sound_id,
tracking["total_time"],
self.state.current_sound_duration,
(tracking["total_time"] / self.state.current_sound_duration)
* 100,
)
self._schedule_async_task(self._record_play_count(sound_id))
async def _record_play_count(self, sound_id: int) -> None:
"""Record a play count for a sound."""
logger.info("Recording play count for sound %s", sound_id)
session = self.db_session_factory()
try:
sound_repo = SoundRepository(session)
# Update sound play count
sound = await sound_repo.get_by_id(sound_id)
if sound:
old_count = sound.play_count
await sound_repo.update(
sound,
{"play_count": sound.play_count + 1},
)
logger.info(
"Updated sound %s play_count: %s -> %s",
sound_id,
old_count,
old_count + 1,
)
else:
logger.warning("Sound %s not found for play count update", sound_id)
# Record play history without user_id for player-based plays
# Always create a new SoundPlayed record for each play event
sound_played = SoundPlayed(
user_id=None, # No user_id for player-based plays
sound_id=sound_id,
)
session.add(sound_played)
logger.info(
"Created SoundPlayed record for player play, sound %s",
sound_id,
)
await session.commit()
logger.info("Successfully recorded play count for sound %s", sound_id)
except Exception:
logger.exception("Error recording play count for sound %s", sound_id)
await session.rollback()
finally:
await session.close()
async def _process_play_count(self) -> None:
"""Process any pending play counts when stopping."""
if not self.state.current_sound_id:
return
sound_id = self.state.current_sound_id
with self._lock:
if (
sound_id in self._play_time_tracking
and self._play_time_tracking[sound_id]["threshold_reached"]
):
# Already processed
del self._play_time_tracking[sound_id]
async def _handle_track_finished(self) -> None:
"""Handle when a track finishes playing."""
await self._process_play_count()
# 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:
next_index = self._get_next_index(self.state.current_sound_index)
if next_index is not None:
await self.play(next_index)
else:
await self._stop_playback()
await self._broadcast_state()
elif (
self.state.playlist_sounds
and self.state.playlist_index_before_play_next is not None
):
# Current track was from play_next queue, restore to next track in playlist
restored_index = self.state.playlist_index_before_play_next
logger.info(
"Play next queue finished, continuing from playlist index %s",
restored_index,
)
# Get the next index based on the stored position
next_index = self._get_next_index(restored_index)
# Clear the stored index since we're done with play_next queue
self.state.playlist_index_before_play_next = None
if next_index is not None:
await self.play(next_index)
else:
# No next track (end of playlist in non-loop mode)
await self._stop_playback()
await self._broadcast_state()
else:
await self._stop_playback()
await self._broadcast_state()
async def _broadcast_state(self) -> None:
"""Broadcast current player state via WebSocket."""
try:
state_data = self.get_state()
await socket_manager.broadcast_to_all("player_state", state_data)
except Exception:
logger.exception("Error broadcasting player state")
def _track_task(self, task: asyncio.Task) -> None:
"""Track background task to prevent garbage collection."""
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
def _schedule_async_task(self, coro: Coroutine[Any, Any, Any]) -> None:
"""Schedule an async task from a background thread."""
if self._loop and not self._loop.is_closed():
try:
# Use run_coroutine_threadsafe to schedule the coroutine
asyncio.run_coroutine_threadsafe(coro, self._loop)
# Don't wait for the result to avoid blocking the thread
except Exception:
logger.exception("Error scheduling async task")
# Global player service instance
player_service: PlayerService | None = None
def get_player_service() -> PlayerService:
"""Get the global player service instance."""
if player_service is None:
msg = "Player service not initialized"
raise RuntimeError(msg)
return player_service
async def initialize_player_service(db_session_factory: Callable) -> None:
"""Initialize the global player service."""
global player_service # noqa: PLW0603
player_service = PlayerService(db_session_factory)
await player_service.start()
async def shutdown_player_service() -> None:
"""Shutdown the global player service."""
global player_service # noqa: PLW0603
if player_service:
await player_service.stop()
player_service = None