Files
sdb2-backend/app/services/player.py
2025-07-31 21:01:40 +02:00

706 lines
26 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 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.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
self.volume: int = 50
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] = []
def to_dict(self) -> dict[str, Any]:
"""Convert player state to dictionary for serialization."""
return {
"status": self.status.value,
"mode": self.mode.value,
"volume": self.volume,
"position_ms": self.current_sound_position or 0,
"duration_ms": 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,
}
def _serialize_sound(self, sound: Sound | None) -> dict[str, Any] | None:
"""Serialize a sound object for JSON serialization."""
if not sound:
return None
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,
}
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()
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 initial volume
self._player.audio_set_volume(self.state.volume)
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."""
# Check if we're resuming from pause
is_resuming = (
index is None and
self.state.status == PlayerStatus.PAUSED and
self.state.current_sound is not None
)
if is_resuming:
# Simply resume playback
result = self._player.play()
if result == 0: # VLC returns 0 on success
self.state.status = PlayerStatus.PLAYING
# 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,
}
await self._broadcast_state()
logger.info("Resumed playing sound: %s", self.state.current_sound.name)
else:
logger.error("Failed to resume playback: VLC error code %s", result)
return
# Starting new track or changing track
if index is not None:
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
if not self.state.current_sound:
logger.warning("No sound to play")
return
# Get sound file path
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
# Load and play media (new track)
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
self.state.status = PlayerStatus.PLAYING
self.state.current_sound_duration = self.state.current_sound.duration or 0
# 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,
)
await self._broadcast_state()
logger.info("Started playing sound: %s", self.state.current_sound.name)
else:
logger.error("Failed to start playback: VLC error code %s", result)
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."""
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)."""
volume = max(0, min(100, volume)) # Clamp to valid range
self.state.volume = volume
self._player.audio_set_volume(volume)
await self._broadcast_state()
logger.debug("Volume set to: %s", volume)
async def set_mode(self, mode: PlayerMode) -> None:
"""Set playback mode."""
self.state.mode = mode
await self._broadcast_state()
logger.info("Playback mode set to: %s", mode.value)
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_main_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 _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,
)
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."""
# 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 _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()
if hasattr(vlc, "State") and 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 0.5 seconds while playing
broadcast_interval = 0.5
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()
# Auto-advance to next track
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()
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