"""VLC subprocess-based player service for immediate sound playback.""" import asyncio import subprocess from collections.abc import Callable from pathlib import Path from typing import Any from sqlmodel.ext.asyncio.session import AsyncSession from app.core.logging import get_logger from app.models.sound import Sound from app.models.sound_played import SoundPlayed from app.repositories.sound import SoundRepository from app.repositories.user import UserRepository from app.services.socket import socket_manager from app.utils.audio import get_sound_file_path logger = get_logger(__name__) class VLCPlayerService: """Service for launching VLC instances via subprocess to play sounds.""" def __init__( self, db_session_factory: Callable[[], AsyncSession] | None = None, ) -> None: """Initialize the VLC player service.""" self.vlc_executable = self._find_vlc_executable() self.db_session_factory = db_session_factory logger.info( "VLC Player Service initialized with executable: %s", self.vlc_executable, ) def _find_vlc_executable(self) -> str: """Find VLC executable path based on the operating system.""" # Common VLC executable paths possible_paths = [ "vlc", # Linux/Mac with VLC in PATH "/usr/bin/vlc", # Linux "/usr/local/bin/vlc", # Linux/Mac "/Applications/VLC.app/Contents/MacOS/VLC", # macOS "C:\\Program Files\\VideoLAN\\VLC\\vlc.exe", # Windows "C:\\Program Files (x86)\\VideoLAN\\VLC\\vlc.exe", # Windows 32-bit ] for path in possible_paths: try: if Path(path).exists(): return path # For "vlc", try to find it in PATH if path == "vlc": result = subprocess.run( ["which", "vlc"], capture_output=True, check=False, text=True, ) if result.returncode == 0: return path except (OSError, subprocess.SubprocessError): continue # Default to 'vlc' and let the system handle it logger.warning( "VLC executable not found in common paths, using 'vlc' from PATH", ) return "vlc" async def play_sound(self, sound: Sound) -> bool: """Play a sound using a new VLC subprocess instance. Args: sound: The Sound object to play Returns: bool: True if VLC process was launched successfully, False otherwise """ try: sound_path = get_sound_file_path(sound) if not sound_path.exists(): logger.error("Sound file not found: %s", sound_path) return False # VLC command arguments for immediate playback cmd = [ self.vlc_executable, str(sound_path), "--play-and-exit", # Exit VLC when playback finishes "--intf", "dummy", # No interface "--no-video", # Audio only "--no-repeat", # Don't repeat "--no-loop", # Don't loop ] # Launch VLC process asynchronously without waiting process = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, ) logger.info( "Launched VLC process (PID: %s) for sound: %s", process.pid, sound.name, ) # Record play count and emit event if self.db_session_factory and sound.id: asyncio.create_task(self._record_play_count(sound.id, sound.name)) return True except Exception: logger.exception("Failed to launch VLC for sound %s", sound.name) return False async def stop_all_vlc_instances(self) -> dict[str, Any]: """Stop all running VLC processes by killing them. Returns: dict: Results of the stop operation including counts and any errors """ try: # Find all VLC processes find_cmd = ["pgrep", "-f", "vlc"] find_process = await asyncio.create_subprocess_exec( *find_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout, stderr = await find_process.communicate() if find_process.returncode != 0: # No VLC processes found logger.info("No VLC processes found to stop") return { "success": True, "processes_found": 0, "processes_killed": 0, "message": "No VLC processes found", } # Parse PIDs from output pids = [] if stdout: pids = [ pid.strip() for pid in stdout.decode().strip().split("\n") if pid.strip() ] if not pids: logger.info("No VLC processes found to stop") return { "success": True, "processes_found": 0, "processes_killed": 0, "message": "No VLC processes found", } logger.info("Found %s VLC processes: %s", len(pids), ", ".join(pids)) # Kill all VLC processes kill_cmd = ["pkill", "-f", "vlc"] kill_process = await asyncio.create_subprocess_exec( *kill_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) await kill_process.communicate() # Verify processes were killed verify_process = await asyncio.create_subprocess_exec( *find_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout_verify, _ = await verify_process.communicate() remaining_pids = [] if verify_process.returncode == 0 and stdout_verify: remaining_pids = [ pid.strip() for pid in stdout_verify.decode().strip().split("\n") if pid.strip() ] processes_killed = len(pids) - len(remaining_pids) logger.info( "Kill operation completed. Found: %s, Killed: %s, Remaining: %s", len(pids), processes_killed, len(remaining_pids), ) return { "success": True, "processes_found": len(pids), "processes_killed": processes_killed, "processes_remaining": len(remaining_pids), "message": f"Killed {processes_killed} VLC processes", } except Exception as e: logger.exception("Failed to stop VLC processes") return { "success": False, "processes_found": 0, "processes_killed": 0, "error": str(e), "message": "Failed to stop VLC processes", } async def _record_play_count(self, sound_id: int, sound_name: str) -> None: """Record a play count for a sound and emit sound_played event.""" if not self.db_session_factory: logger.warning( "No database session factory available for play count recording", ) return logger.info("Recording play count for sound %s", sound_id) session = self.db_session_factory() try: sound_repo = SoundRepository(session) user_repo = UserRepository(session) # Update sound play count sound = await sound_repo.get_by_id(sound_id) old_count = 0 if sound: old_count = sound.play_count await sound_repo.update( sound, {"play_count": sound.play_count + 1}, ) logger.info( "Updated sound %s play_count: %s -> %s", sound_id, old_count, old_count + 1, ) else: logger.warning("Sound %s not found for play count update", sound_id) # Record play history for admin user (ID 1) as placeholder # This could be refined to track per-user play history admin_user = await user_repo.get_by_id(1) admin_user_id = None if admin_user: admin_user_id = admin_user.id # Always create a new SoundPlayed record for each play event sound_played = SoundPlayed( user_id=admin_user_id, # Can be None for player-based plays sound_id=sound_id, ) session.add(sound_played) logger.info( "Created SoundPlayed record for user %s, sound %s", admin_user_id, sound_id, ) await session.commit() logger.info("Successfully recorded play count for sound %s", sound_id) # Emit sound_played event via WebSocket try: event_data = { "sound_id": sound_id, "sound_name": sound_name, "user_id": admin_user_id, "play_count": (old_count + 1) if sound else None, } await socket_manager.broadcast_to_all("sound_played", event_data) logger.info("Broadcasted sound_played event for sound %s", sound_id) except Exception: logger.exception( "Failed to broadcast sound_played event for sound %s", sound_id, ) except Exception: logger.exception("Error recording play count for sound %s", sound_id) await session.rollback() finally: await session.close() # Global VLC player service instance vlc_player_service: VLCPlayerService | None = None def get_vlc_player_service( db_session_factory: Callable[[], AsyncSession] | None = None, ) -> VLCPlayerService: """Get the global VLC player service instance.""" global vlc_player_service # noqa: PLW0603 if vlc_player_service is None: vlc_player_service = VLCPlayerService(db_session_factory) return vlc_player_service