Files
sdb2-backend/app/services/vlc_player.py
JSC dd10ef5d41 feat: Add VLC player API endpoints and associated tests
- Implemented VLC player API endpoints for playing and stopping sounds.
- Added tests for successful playback, error handling, and authentication scenarios.
- Created utility function to get sound file paths based on sound properties.
- Refactored player service to utilize shared sound path utility.
- Enhanced test coverage for sound file path utility with various sound types.
- Introduced tests for VLC player service, including subprocess handling and play count tracking.
2025-07-30 20:46:49 +02:00

314 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 import select
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