Files
sdb-back/app/services/vlc_service.py

224 lines
8.1 KiB
Python

"""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()