Files
sdb2-backend/app/services/vlc_player.py
JSC 7b59a8216a
Some checks failed
Backend CI / lint (push) Failing after 9s
Backend CI / test (push) Failing after 1m28s
fix: Correct import formatting for CreditService in VLCPlayerService
2025-09-27 03:34:19 +02:00

417 lines
15 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.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.
VLC always plays at 100% volume. Host system volume is controlled separately
by the player service.
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
"--volume=100", # Always use 100% VLC volume
]
# 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)
# Initialize variables for WebSocket event
old_count = 0
sound = None
admin_user_id = None
admin_user_name = None
try:
async with self.db_session_factory() as session:
sound_repo = SoundRepository(session)
user_repo = UserRepository(session)
# Update sound play count
sound = await sound_repo.get_by_id(sound_id)
if sound:
old_count = sound.play_count
# Update the sound's play count using direct attribute modification
sound.play_count = sound.play_count + 1
session.add(sound)
await session.commit()
await session.refresh(sound)
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)
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)
except Exception:
logger.exception("Error recording play count for sound %s", sound_id)
# Emit sound_played event via WebSocket (outside session context)
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,
)
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 ( # noqa: PLC0415
CreditService,
InsufficientCreditsError,
)
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 (always at 100% VLC volume)
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