"""VLC service for playing sounds using subprocess.""" import os import signal import subprocess import threading import time from typing import Dict, List, Optional from app.database import db from app.models.sound import Sound from app.models.sound_played import SoundPlayed 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, ip_address: str | None = None, user_agent: str | 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 print( f"Started VLC process {process.pid} ({process_id}) for sound {sound.name}. Total 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, ip_address=ip_address, user_agent=user_agent, commit=True, ) except Exception as e: print(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: print(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: print(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: print( f"Process {process.pid} still running, terminating" ) process.terminate() # Give it a moment to terminate gracefully try: process.wait(timeout=2) except subprocess.TimeoutExpired: print( f"Process {process.pid} didn't terminate, killing" ) process.kill() print(f"Successfully cleaned up process {process_id}") except Exception as e: print(f"Error during cleanup of {process_id}: {e}") finally: # Always remove from tracking del self.processes[process_id] print( f"Removed process {process_id}. Remaining processes: {len(self.processes)}" ) else: print(f"Process {process_id} not found during cleanup") def stop_all(self) -> None: """Stop all playing sounds by killing VLC processes.""" with self.lock: processes_copy = dict(self.processes) print( f"Stopping {len(processes_copy)} VLC processes: {list(processes_copy.keys())}" ) for process_id, process in processes_copy.items(): try: if process.poll() is None: # Process is still running print( f"Terminating process {process.pid} ({process_id})" ) process.terminate() # Give it a moment to terminate gracefully try: process.wait(timeout=1) print( f"Process {process.pid} terminated gracefully" ) except subprocess.TimeoutExpired: print( f"Process {process.pid} didn't terminate, killing forcefully" ) process.kill() process.wait() # Wait for it to be killed else: print( f"Process {process.pid} ({process_id}) already finished" ) except Exception as e: print(f"Error stopping process {process_id}: {e}") # Clear all processes self.processes.clear() print(f"Cleared all processes. Remaining: {len(self.processes)}") 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) print(f"Force stopping {stopped_count} VLC processes") # # Kill all VLC processes aggressively # for process_id, process in list(self.processes.items()): # try: # if process.poll() is None: # Process is still running # print(f"Force killing process {process.pid} ({process_id})") # process.kill() # process.wait() # Wait for it to be killed # print(f"Process {process.pid} killed") # else: # print(f"Process {process.pid} ({process_id}) already finished") # except Exception as e: # print(f"Error force-stopping process {process_id}: {e}") # Also try to kill any remaining VLC processes system-wide try: subprocess.run(["pkill", "-f", "vlc"], check=False) print("Killed any remaining VLC processes system-wide") except Exception as e: print(f"Error killing system VLC processes: {e}") # Clear all processes self.processes.clear() print( f"Force stop completed. Processes remaining: {len(self.processes)}" ) return stopped_count # Global VLC service instance vlc_service = VLCService()