"""Music player service using VLC Python bindings with playlist management and real-time sync.""" import os import threading import time from datetime import datetime from typing import Any, Optional from zoneinfo import ZoneInfo import vlc from flask import current_app, request from app.models.playlist import Playlist from app.models.sound import Sound from app.models.sound_played import SoundPlayed from app.services.logging_service import LoggingService from app.services.socketio_service import socketio_service logger = LoggingService.get_logger(__name__) # Constants TRACK_START_THRESHOLD_MS = 500 # 500 milliseconds - threshold for considering a track as "starting fresh" STATE_CHANGE_THRESHOLD_MS = ( 1000 # 1 second threshold for state change detection ) PLAY_COMPLETION_THRESHOLD = 0.20 # 20% completion threshold to count as a play 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 ) self._track_play_tracked = ( False # Flag to track if current track play has been logged ) 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, reload: bool = False) -> 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, reload ) 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, reload: bool = False ) -> 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) deleted = False if reload: # Set current track index to the real index of the current track # in case the order has changed or the track has been deleted current_track = self.get_current_track() current_track_id = ( current_track["id"] if current_track else None ) sound_ids = [ ps.sound.id for ps in sorted( playlist.playlist_sounds, key=lambda x: x.order ) ] if current_track_id in sound_ids: self.current_track_index = sound_ids.index(current_track_id) else: deleted = True if not reload or deleted: 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 self._track_play_tracked = ( False # Reset play tracking for new track ) return True return False except Exception as e: logger.error(f"Error loading track at index {index}: {e}") return False def _track_sound_play(self, sound_id: int) -> None: """Track that a sound has been played.""" try: # Use stored app instance or current_app app_to_use = self.app or current_app if app_to_use: with app_to_use.app_context(): # Get the sound and increment its play count sound = Sound.query.get(sound_id) if sound: sound.play_count += 1 sound.updated_at = datetime.now(tz=ZoneInfo("UTC")) logger.info( f"Incremented play count for sound '{sound.name}' (ID: {sound_id})" ) # Create a sound played record without user_id (anonymous play) SoundPlayed.create_play_record( user_id=None, sound_id=sound_id, commit=True ) logger.info( f"Created anonymous play record for sound ID: {sound_id}" ) except Exception as e: logger.error(f"Error tracking sound play for sound {sound_id}: {e}") 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._track_play_tracked = ( False # Track when we first start playing ) 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, force_play: bool = False) -> 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 or force_play: 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 # Use stored app instance or current_app app_to_use = self.app or current_app if app_to_use: with app_to_use.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.""" current_track = self.get_current_track() return { "is_playing": self.is_playing, "current_time": self.current_time, "duration": self.duration, "volume": self.volume, "play_mode": self.play_mode, "current_track": current_track, "current_track_id": current_track["id"] if current_track else None, "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") self.play_track_at_index(self.current_track_index) elif self.play_mode in [ "continuous", "loop-playlist", "random", ]: logger.info( f"Advancing to next track for {self.play_mode} mode" ) self.next_track(True) # 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 # Track play event when song reaches 20% completion (but only once per track load) # Only track if playing, haven't tracked yet, have valid duration, and reached threshold if ( self.is_playing and not self._track_play_tracked and self.duration > 0 and self.current_time > 0 # Ensure we have valid playback time and self.current_time >= (self.duration * PLAY_COMPLETION_THRESHOLD) ): current_track = self.get_current_track() if current_track: self._track_sound_play(current_track["id"]) self._track_play_tracked = True logger.info( f"Tracked play for '{current_track['title']}' at {self.current_time}ms " f"({(self.current_time/self.duration)*100:.1f}% completion)" ) # Emit updates if state changed significantly or periodically state_changed = ( old_playing != self.is_playing or abs(old_time - self.current_time) > STATE_CHANGE_THRESHOLD_MS # 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_id": 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}") def reload_current_playlist_if_modified( self, modified_playlist_id: int ) -> bool: """Reload the current playlist if it's the one that was modified.""" try: if not self.app: logger.warning( "No Flask app context available, skipping playlist reload" ) return False with self.app.app_context(): # Find the current playlist current_playlist = Playlist.find_current_playlist() if ( current_playlist and current_playlist.id == modified_playlist_id ): # Reload the playlist success = self.load_playlist(current_playlist.id, True) if success: logger.info( f"Reloaded current playlist '{current_playlist.name}' after modification" ) return True else: logger.warning( "Failed to reload current playlist after modification" ) return False else: # Not the current playlist, no need to reload return True except Exception as e: logger.error(f"Error reloading current playlist: {e}") return False # Global music player service instance music_player_service = MusicPlayerService()