324 lines
11 KiB
Python
324 lines
11 KiB
Python
"""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"], # 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()
|
|
|
|
|
|
# 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
|