"""VLC service for playing sounds using subprocess.""" import os import subprocess import threading import time from app.models.sound import Sound from app.models.sound_played import SoundPlayed from app.services.logging_service import LoggingService logger = LoggingService.get_logger(__name__) class VLCService: """Service for playing sounds using VLC subprocess.""" def __init__(self) -> None: """Initialize VLC service.""" self.processes: dict[str, subprocess.Popen] = {} self.lock = threading.Lock() def play_sound(self, sound_id: int, user_id: int | None = None) -> bool: """Play a sound by ID using VLC subprocess.""" try: # Get sound from database sound = Sound.query.get(sound_id) if not sound: return False # Use normalized file if available, otherwise use original if sound.is_normalized and sound.normalized_filename: sound_path = os.path.join( "sounds", "normalized", "soundboard", sound.normalized_filename, ) else: sound_path = os.path.join( "sounds", "soundboard", sound.filename, ) # Check if file exists if not os.path.exists(sound_path): return False # Convert to absolute path sound_path = os.path.abspath(sound_path) # Create unique process ID process_id = f"sound_{sound_id}_{int(time.time() * 1000000)}" # Start VLC process vlc_cmd = [ "vlc", sound_path, "--intf", "dummy", # No interface "--play-and-exit", # Exit after playing "--no-video", # Audio only "--quiet", # Reduce output ] process = subprocess.Popen( vlc_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, preexec_fn=os.setsid, # Create new process group ) # Store process for tracking with self.lock: self.processes[process_id] = process logger.info( f"Started VLC process {process.pid} for sound '{sound.name}'. " f"Total active processes: {len(self.processes)}", ) # Increment play count sound.increment_play_count() # Record play event if user is provided if user_id: try: SoundPlayed.create_play_record( user_id=user_id, sound_id=sound_id, commit=True, ) except Exception as e: logger.error(f"Error recording play event: {e}") # Schedule cleanup after sound duration threading.Thread( target=self._cleanup_after_playback, args=(process_id, sound.duration if sound.duration else 10000), daemon=True, ).start() return True except Exception as e: logger.error( f"Error starting VLC process for sound {sound_id}: {e}" ) return False def _cleanup_after_playback(self, process_id: str, duration: int) -> None: """Clean up VLC process after playback.""" # Wait for playback to finish (duration + 1 second buffer) time.sleep(duration / 1000 + 1) # Convert ms to seconds with self.lock: if process_id in self.processes: logger.debug(f"Cleaning up process {process_id} after playback") process = self.processes[process_id] try: # Check if process is still running if process.poll() is None: logger.debug( f"Process {process.pid} still running, terminating" ) process.terminate() # Give it a moment to terminate gracefully try: process.wait(timeout=2) except subprocess.TimeoutExpired: logger.debug( f"Process {process.pid} didn't terminate, killing" ) process.kill() logger.debug( f"Successfully cleaned up process {process_id}" ) except Exception as e: logger.warning(f"Error during cleanup of {process_id}: {e}") finally: # Always remove from tracking del self.processes[process_id] logger.debug( f"Removed process {process_id}. Remaining processes: {len(self.processes)}", ) def stop_all(self) -> None: """Stop all playing sounds by killing VLC processes.""" with self.lock: processes_copy = dict(self.processes) if processes_copy: logger.info(f"Stopping {len(processes_copy)} VLC processes") for process_id, process in processes_copy.items(): try: if process.poll() is None: # Process is still running logger.debug(f"Terminating process {process.pid}") process.terminate() # Give it a moment to terminate gracefully try: process.wait(timeout=1) logger.debug( f"Process {process.pid} terminated gracefully" ) except subprocess.TimeoutExpired: logger.debug( f"Process {process.pid} didn't terminate, killing forcefully" ) process.kill() process.wait() # Wait for it to be killed else: logger.debug(f"Process {process.pid} already finished") except Exception as e: logger.warning(f"Error stopping process {process_id}: {e}") # Clear all processes self.processes.clear() if processes_copy: logger.info("All VLC processes stopped") def get_playing_count(self) -> int: """Get number of currently playing sounds.""" with self.lock: # Clean up finished processes and return count finished_processes = [] for process_id, process in self.processes.items(): if process.poll() is not None: # Process has finished finished_processes.append(process_id) # Remove finished processes for process_id in finished_processes: del self.processes[process_id] return len(self.processes) def force_stop_all(self) -> int: """Force stop all sounds by killing VLC processes aggressively.""" with self.lock: stopped_count = len(self.processes) if stopped_count > 0: logger.warning(f"Force stopping {stopped_count} VLC processes") # Also try to kill any remaining VLC processes system-wide try: subprocess.run(["pkill", "-f", "vlc"], check=False) logger.info("Killed any remaining VLC processes system-wide") except Exception as e: logger.error(f"Error killing system VLC processes: {e}") # Clear all processes self.processes.clear() if stopped_count > 0: logger.info("Force stop completed") return stopped_count # Global VLC service instance vlc_service = VLCService()