"""Player service for audio playbook management.""" import asyncio import threading import time from collections.abc import Callable, Coroutine from enum import Enum from pathlib import Path from typing import Any import vlc # type: ignore[import-untyped] from sqlmodel import select from app.core.logging import get_logger 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.repositories.user import UserRepository from app.services.socket import socket_manager 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, "current_sound_id": self.current_sound_id, "current_sound_index": self.current_sound_index, "current_sound_position": self.current_sound_position, "current_sound_duration": self.current_sound_duration, "current_sound": self._serialize_sound(self.current_sound), "playlist_id": self.playlist_id, "playlist_name": self.playlist_name, "playlist_length": self.playlist_length, "playlist_duration": self.playlist_duration, "playlist_sounds": [ self._serialize_sound(sound) for sound in self.playlist_sounds ], } 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 = self._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) # Get main playlist (fallback for now) current_playlist = await playlist_repo.get_main_playlist() if current_playlist and current_playlist.id: # Load playlist sounds sounds = await playlist_repo.get_playlist_sounds(current_playlist.id) # Update state 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 ) # Reset current sound if playlist changed if self.state.current_sound_id and not any( s.id == self.state.current_sound_id for s in sounds ): self.state.current_sound_id = None self.state.current_sound_index = None self.state.current_sound = None if self.state.status != PlayerStatus.STOPPED: await self._stop_playback() 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() def get_state(self) -> dict[str, Any]: """Get current player state.""" return self.state.to_dict() def _get_sound_file_path(self, sound: Sound) -> Path: """Get the file path for a sound.""" # Determine the correct subdirectory based on sound type subdir = "extracted" if sound.type.upper() == "EXT" else sound.type.lower() # Use normalized file if available, otherwise original if sound.is_normalized and sound.normalized_filename: return ( Path("sounds/normalized") / subdir / sound.normalized_filename ) return ( Path("sounds/originals") / subdir / sound.filename ) 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) user_repo = UserRepository(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 for admin user (ID 1) as placeholder # This could be refined to track per-user play history admin_user = await user_repo.get_by_id(1) if admin_user: # Check if already recorded for this user using proper query stmt = select(SoundPlayed).where( SoundPlayed.user_id == admin_user.id, SoundPlayed.sound_id == sound_id, ) result = await session.exec(stmt) existing = result.first() if not existing: sound_played = SoundPlayed( user_id=admin_user.id, sound_id=sound_id, ) session.add(sound_played) logger.info( "Created SoundPlayed record for user %s, sound %s", admin_user.id, sound_id, ) else: logger.info( "SoundPlayed record already exists for user %s, sound %s", admin_user.id, sound_id, ) else: logger.warning("Admin user (ID 1) not found for play history") 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