"""Music player service using VLC Python bindings with playlist management and real-time sync.""" import os import threading import time from typing import Any, Optional import vlc from flask import current_app, request from app.models.playlist import Playlist from app.models.sound import Sound from app.services.logging_service import LoggingService from app.services.socketio_service import socketio_service logger = LoggingService.get_logger(__name__) class MusicPlayerService: """Service for managing a VLC music player with playlist support.""" def __init__(self): """Initialize the music player service.""" self.instance: Optional[vlc.Instance] = None self.player: Optional[vlc.MediaPlayer] = None self.app: Optional[Any] = None # Store Flask app instance for context self.current_playlist_id: Optional[int] = None self.current_track_index = 0 self.playlist_files: list[str] = ( [] ) # Store file paths for manual playlist management self.volume = 80 self.play_mode = ( "continuous" # continuous, loop-playlist, loop-one, random ) self.is_playing = False self.current_time = 0 self.duration = 0 self.last_sync_time = 0 self.sync_interval = 0.5 # seconds (increased frequency to catch track endings) self.lock = threading.Lock() self._sync_thread = None self._stop_sync = False self._track_ending_handled = False # Flag to prevent duplicate ending triggers def start_vlc_instance(self) -> bool: """Start a VLC instance with Python bindings.""" try: # Create VLC instance with audio output enabled vlc_args = [ "--intf=dummy", # No interface "--no-video", # Audio only ] self.instance = vlc.Instance(vlc_args) if not self.instance: logger.error("Failed to create VLC instance") return False # Create media player self.player = self.instance.media_player_new() if not self.player: logger.error("Failed to create VLC media player") return False # Set initial volume self.player.audio_set_volume(self.volume) logger.info("VLC music player started successfully") # Automatically load the current playlist self._load_current_playlist_on_startup() self._start_sync_thread() return True except Exception as e: logger.error(f"Error starting VLC instance: {e}") return False def stop_vlc_instance(self) -> bool: """Stop the VLC instance.""" try: self._stop_sync = True if self._sync_thread: self._sync_thread.join(timeout=2) if self.player: self.player.stop() # Release VLC objects self.player = None self.instance = None logger.info("VLC music player stopped") return True except Exception as e: logger.error(f"Error stopping VLC instance: {e}") return False def load_playlist(self, playlist_id: int) -> bool: """Load a playlist into VLC.""" try: if not self.instance or not self.player: logger.error("VLC not initialized") return False with self.lock: # Ensure we have Flask app context for database queries if current_app: with current_app.app_context(): playlist = Playlist.query.get(playlist_id) if not playlist: return False return self._load_playlist_with_context(playlist) else: # Fallback for when no Flask context is available logger.warning( "No Flask context available for loading playlist" ) return False except Exception as e: logger.error(f"Error loading playlist {playlist_id}: {e}") return False def _build_thumbnail_url(self, sound_type: str, thumbnail_filename: str) -> str: """Build absolute thumbnail URL.""" try: # Try to get base URL from current request context if request: base_url = request.url_root.rstrip('/') else: # Fallback to localhost if no request context base_url = "http://localhost:5000" return f"{base_url}/api/sounds/{sound_type.lower()}/thumbnails/{thumbnail_filename}" except Exception: # Fallback if request context is not available return f"http://localhost:5000/api/sounds/{sound_type.lower()}/thumbnails/{thumbnail_filename}" def _load_playlist_with_context(self, playlist) -> bool: """Load playlist with database context already established.""" try: # Clear current playlist self.playlist_files = [] # Add tracks to our internal playlist for playlist_sound in sorted( playlist.playlist_sounds, key=lambda x: x.order ): sound = playlist_sound.sound if sound: file_path = self._get_sound_file_path(sound) if file_path and os.path.exists(file_path): self.playlist_files.append(file_path) self.current_playlist_id = playlist.id self.current_track_index = 0 # Load first track if available if self.playlist_files: self._load_track_at_index(0) # Emit playlist loaded event self._emit_player_state() logger.info( f"Loaded playlist '{playlist.name}' with {len(self.playlist_files)} tracks" ) return True except Exception as e: logger.error(f"Error in _load_playlist_with_context: {e}") return False def _load_track_at_index(self, index: int) -> bool: """Load a specific track by index.""" try: if 0 <= index < len(self.playlist_files): file_path = self.playlist_files[index] media = self.instance.media_new(file_path) if media: self.player.set_media(media) self.current_track_index = index # Reset track ending flag when loading a new track self._track_ending_handled = False return True return False except Exception as e: logger.error(f"Error loading track at index {index}: {e}") return False def _get_sound_file_path(self, sound: Sound) -> Optional[str]: """Get the file path for a sound, preferring normalized version.""" try: if sound.type == "STR": # Stream sounds base_path = "sounds/stream" elif sound.type == "SAY": # Say sounds base_path = "sounds/say" else: # Soundboard sounds base_path = "sounds/soundboard" # Check for normalized version first if sound.is_normalized and sound.normalized_filename: normalized_path = os.path.join( "sounds/normalized", sound.type.lower(), sound.normalized_filename, ) if os.path.exists(normalized_path): return os.path.abspath(normalized_path) # Fall back to original file original_path = os.path.join(base_path, sound.filename) if os.path.exists(original_path): return os.path.abspath(original_path) return None except Exception as e: logger.error(f"Error getting file path for sound {sound.id}: {e}") return None def play(self) -> bool: """Start playback.""" try: if not self.player: return False # Reset track ending flag when starting playback self._track_ending_handled = False result = self.player.play() if result == 0: # Success self.is_playing = True self._emit_player_state() return True return False except Exception as e: logger.error(f"Error starting playback: {e}") return False def pause(self) -> bool: """Pause playback.""" try: if not self.player: return False self.player.pause() self.is_playing = False self._emit_player_state() return True except Exception as e: logger.error(f"Error pausing playback: {e}") return False def stop(self) -> bool: """Stop playback.""" try: if not self.player: return False self.player.stop() self.is_playing = False self.current_time = 0 self._emit_player_state() return True except Exception as e: logger.error(f"Error stopping playback: {e}") return False def next_track(self) -> bool: """Skip to next track.""" try: if not self.playlist_files: return False next_index = self.current_track_index + 1 # Handle different play modes if self.play_mode == "loop-playlist" and next_index >= len( self.playlist_files ): next_index = 0 elif self.play_mode == "random": import random next_index = random.randint(0, len(self.playlist_files) - 1) elif next_index >= len(self.playlist_files): # End of playlist in continuous mode self.stop() return True if self._load_track_at_index(next_index): if self.is_playing: self.play() self._emit_player_state() return True return False except Exception as e: logger.error(f"Error skipping to next track: {e}") return False def previous_track(self) -> bool: """Skip to previous track.""" try: if not self.playlist_files: return False prev_index = self.current_track_index - 1 # Handle different play modes if self.play_mode == "loop-playlist" and prev_index < 0: prev_index = len(self.playlist_files) - 1 elif self.play_mode == "random": import random prev_index = random.randint(0, len(self.playlist_files) - 1) elif prev_index < 0: prev_index = 0 if self._load_track_at_index(prev_index): if self.is_playing: self.play() self._emit_player_state() return True return False except Exception as e: logger.error(f"Error skipping to previous track: {e}") return False def seek(self, position: float) -> bool: """Seek to position (0.0 to 1.0).""" try: if not self.player: return False # Set position as percentage self.player.set_position(position) self.current_time = position * self.duration self._emit_player_state() return True except Exception as e: logger.error(f"Error seeking: {e}") return False def set_volume(self, volume: int) -> bool: """Set volume (0-100).""" try: if not self.player: return False volume = max(0, min(100, volume)) result = self.player.audio_set_volume(volume) if result == 0: # Success self.volume = volume self._emit_player_state() return True return False except Exception as e: logger.error(f"Error setting volume: {e}") return False def set_play_mode(self, mode: str) -> bool: """Set play mode.""" try: if mode in ["continuous", "loop-playlist", "loop-one", "random"]: self.play_mode = mode self._emit_player_state() return True return False except Exception as e: logger.error(f"Error setting play mode: {e}") return False def play_track_at_index(self, index: int) -> bool: """Play track at specific playlist index.""" try: if self._load_track_at_index(index): result = self.play() self._emit_player_state() return result return False except Exception as e: logger.error(f"Error playing track at index {index}: {e}") return False def _get_playlist_length(self) -> int: """Get current playlist length.""" return len(self.playlist_files) def get_current_track(self) -> Optional[dict]: """Get current track information.""" try: if not self.current_playlist_id: return None # Ensure we have Flask app context if current_app: with current_app.app_context(): playlist = Playlist.query.get(self.current_playlist_id) if playlist and 0 <= self.current_track_index < len( playlist.playlist_sounds ): playlist_sounds = sorted( playlist.playlist_sounds, key=lambda x: x.order ) current_playlist_sound = playlist_sounds[ self.current_track_index ] sound = current_playlist_sound.sound if sound: return { "id": sound.id, "title": sound.name, "artist": None, # Could be extracted from metadata "duration": sound.duration or 0, "thumbnail": ( self._build_thumbnail_url(sound.type, sound.thumbnail) if sound.thumbnail else None ), "type": sound.type, } return None except Exception as e: logger.error(f"Error getting current track: {e}") return None def get_playlist_tracks(self) -> list[dict]: """Get all tracks in current playlist.""" try: tracks = [] if not self.current_playlist_id: return tracks # Ensure we have Flask app context if current_app: with current_app.app_context(): playlist = Playlist.query.get(self.current_playlist_id) if playlist: for playlist_sound in sorted( playlist.playlist_sounds, key=lambda x: x.order ): sound = playlist_sound.sound if sound: tracks.append( { "id": sound.id, "title": sound.name, "artist": None, "duration": sound.duration or 0, "thumbnail": ( self._build_thumbnail_url(sound.type, sound.thumbnail) if sound.thumbnail else None ), "type": sound.type, } ) return tracks except Exception as e: logger.error(f"Error getting playlist tracks: {e}") return [] def get_player_state(self) -> dict[str, Any]: """Get complete player state.""" return { "is_playing": self.is_playing, "current_time": self.current_time, "duration": self.duration, "volume": self.volume, "play_mode": self.play_mode, "current_track": self.get_current_track(), "current_track_index": self.current_track_index, "playlist": self.get_playlist_tracks(), "playlist_id": self.current_playlist_id, } def _start_sync_thread(self): """Start background thread to sync with VLC state.""" self._stop_sync = False self._sync_thread = threading.Thread( target=self._sync_loop, daemon=True ) self._sync_thread.start() def _sync_loop(self): """Background loop to sync player state with VLC.""" while not self._stop_sync: try: current_time = time.time() if current_time - self.last_sync_time >= self.sync_interval: self._sync_with_vlc() self.last_sync_time = current_time time.sleep(0.1) # Small sleep to prevent busy waiting except Exception as e: logger.debug(f"Error in sync loop: {e}") time.sleep(1) # Longer sleep on error def _sync_with_vlc(self): """Sync internal state with VLC.""" try: if not self.player: return # Update playback state old_playing = self.is_playing old_time = self.current_time # Get current state from VLC state = self.player.get_state() self.is_playing = state == vlc.State.Playing # Get time and duration (in milliseconds) self.current_time = self.player.get_time() self.duration = self.player.get_length() # Get volume self.volume = self.player.audio_get_volume() # Enhanced track ending detection track_ended = False # Check for ended state if state == vlc.State.Ended: track_ended = True logger.info(f"Track ended via VLC State.Ended, mode: {self.play_mode}") # Also check if we're very close to the end (within 500ms) and not playing elif (self.duration > 0 and self.current_time > 0 and self.current_time >= (self.duration - 500) and not self.is_playing and old_playing): track_ended = True logger.info(f"Track ended via time check, mode: {self.play_mode}") # Handle track ending based on play mode (only if not already handled) if track_ended and not self._track_ending_handled: self._track_ending_handled = True if self.play_mode == "loop-one": logger.info("Restarting track for loop-one mode") # Stop first, then reload and play self.player.stop() # Reload the current track if (self.current_track_index < len(self.playlist_files)): media = self.instance.media_new( self.playlist_files[self.current_track_index] ) self.player.set_media(media) self.player.play() # Reset the flag after a short delay to allow for new track self._track_ending_handled = False elif self.play_mode in ["continuous", "loop-playlist", "random"]: logger.info(f"Advancing to next track for {self.play_mode} mode") self.next_track() # Reset the flag after track change self._track_ending_handled = False # Reset the flag if we're playing again (new track started) elif self.is_playing and not old_playing: self._track_ending_handled = False # Emit updates if state changed significantly or periodically state_changed = ( old_playing != self.is_playing or abs(old_time - self.current_time) > 1000 # More than 1 second difference ) # Always emit if playing to keep frontend updated if state_changed or self.is_playing: self._emit_player_state() except Exception as e: logger.debug(f"Error syncing with VLC: {e}") def _emit_player_state(self): """Emit current player state via SocketIO.""" try: # Update state from VLC before emitting self._sync_vlc_state_only() # Try to use Flask context for database queries app_to_use = self.app or current_app if app_to_use: with app_to_use.app_context(): state = self.get_player_state() socketio_service.emit_to_all("player_state_update", state) logger.info(f"Emitted player state: playing={state['is_playing']}, time={state['current_time']}, track={state.get('current_track', {}).get('title', 'None')}") else: # Fallback when no Flask context - emit basic state without database queries basic_state = { "is_playing": self.is_playing, "current_time": self.current_time, "duration": self.duration, "volume": self.volume, "play_mode": self.play_mode, "current_track": None, "current_track_index": self.current_track_index, "playlist": [], "playlist_id": self.current_playlist_id, } socketio_service.emit_to_all("player_state_update", basic_state) logger.info(f"Emitted basic player state: playing={basic_state['is_playing']}, time={basic_state['current_time']}") except Exception as e: logger.debug(f"Error emitting player state: {e}") def _sync_vlc_state_only(self): """Sync only the VLC state without auto-advance logic.""" try: if not self.player: return # Get current state from VLC state = self.player.get_state() self.is_playing = state == vlc.State.Playing # Get time and duration (in milliseconds) self.current_time = self.player.get_time() self.duration = self.player.get_length() # Get volume self.volume = self.player.audio_get_volume() except Exception as e: logger.debug(f"Error syncing VLC state: {e}") def _load_current_playlist_on_startup(self): """Load the current playlist automatically on startup.""" try: if not self.app: logger.warning("No Flask app context available, skipping current playlist load") return with self.app.app_context(): # Find the current playlist current_playlist = Playlist.find_current_playlist() if current_playlist: success = self.load_playlist(current_playlist.id) if success: logger.info(f"Automatically loaded current playlist '{current_playlist.name}' with {len(self.playlist_files)} tracks") else: logger.warning("Failed to load current playlist on startup") else: logger.info("No current playlist found to load on startup") except Exception as e: logger.error(f"Error loading current playlist on startup: {e}") # Global music player service instance music_player_service = MusicPlayerService()