"""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.credit_action import CreditActionType 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"], # noqa: S607 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: task = asyncio.create_task( self._record_play_count(sound.id, sound.name), ) # Store reference to prevent garbage collection self._background_tasks = getattr(self, "_background_tasks", set()) self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) except Exception: logger.exception("Failed to launch VLC for sound %s", sound.name) return False else: return True 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 admin_user_name = None if admin_user: admin_user_id = admin_user.id admin_user_name = admin_user.name # 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, "user_name": admin_user_name, "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() async def play_sound_with_credits( self, sound_id: int, user_id: int, ) -> dict[str, str | int | bool]: """Play sound with VLC with credit validation and deduction. This method combines credit checking, sound playing, and credit deduction in a single operation. Used by both HTTP and WebSocket endpoints. Args: sound_id: ID of the sound to play user_id: ID of the user playing the sound Returns: dict: Result information including success status and message Raises: HTTPException: For various error conditions (sound not found, insufficient credits, VLC failure) """ from fastapi import HTTPException, status # noqa: PLC0415, I001 from app.services.credit import CreditService, InsufficientCreditsError # noqa: PLC0415 if not self.db_session_factory: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Database session factory not configured", ) async with self.db_session_factory() as session: sound_repo = SoundRepository(session) # Get the sound sound = await sound_repo.get_by_id(sound_id) if not sound: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Sound with ID {sound_id} not found", ) # Get credit service credit_service = CreditService(self.db_session_factory) # Check and validate credits before playing try: await credit_service.validate_and_reserve_credits( user_id, CreditActionType.VLC_PLAY_SOUND, ) except InsufficientCreditsError as e: raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, detail=( f"Insufficient credits: {e.required} required, " f"{e.available} available" ), ) from e # Play the sound using VLC success = await self.play_sound(sound) # Deduct credits based on success await credit_service.deduct_credits( user_id, CreditActionType.VLC_PLAY_SOUND, success=success, metadata={"sound_id": sound_id, "sound_name": sound.name}, ) if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to launch VLC for sound playback", ) return { "message": f"Sound '{sound.name}' is now playing via VLC", "sound_id": sound_id, "sound_name": sound.name, "success": True, "credits_deducted": 1, } # 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 return vlc_player_service