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.
This commit is contained in:
@@ -9,15 +9,14 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import vlc # type: ignore[import-untyped]
|
||||
from sqlmodel import select
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.models.sound import Sound
|
||||
from app.models.sound_played import SoundPlayed
|
||||
from app.repositories.playlist import PlaylistRepository
|
||||
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__)
|
||||
|
||||
@@ -198,7 +197,7 @@ class PlayerService:
|
||||
return
|
||||
|
||||
# Get sound file path
|
||||
sound_path = self._get_sound_file_path(self.state.current_sound)
|
||||
sound_path = get_sound_file_path(self.state.current_sound)
|
||||
if not sound_path.exists():
|
||||
logger.error("Sound file not found: %s", sound_path)
|
||||
return
|
||||
@@ -344,6 +343,12 @@ class PlayerService:
|
||||
if self.state.status != PlayerStatus.STOPPED:
|
||||
await self._stop_playback()
|
||||
|
||||
# Set first track as current if no current track and playlist has sounds
|
||||
if not self.state.current_sound_id and sounds:
|
||||
self.state.current_sound_index = 0
|
||||
self.state.current_sound = sounds[0]
|
||||
self.state.current_sound_id = sounds[0].id
|
||||
|
||||
logger.info(
|
||||
"Loaded playlist: %s (%s sounds)",
|
||||
current_playlist.name,
|
||||
@@ -360,21 +365,6 @@ class PlayerService:
|
||||
"""Get current player state."""
|
||||
return self.state.to_dict()
|
||||
|
||||
def _get_sound_file_path(self, sound: Sound) -> Path:
|
||||
"""Get the file path for a sound."""
|
||||
# Determine the correct subdirectory based on sound type
|
||||
subdir = "extracted" if sound.type.upper() == "EXT" else sound.type.lower()
|
||||
|
||||
# Use normalized file if available, otherwise original
|
||||
if sound.is_normalized and sound.normalized_filename:
|
||||
return (
|
||||
Path("sounds/normalized")
|
||||
/ subdir
|
||||
/ sound.normalized_filename
|
||||
)
|
||||
return (
|
||||
Path("sounds/originals") / subdir / sound.filename
|
||||
)
|
||||
|
||||
def _get_next_index(self, current_index: int) -> int | None:
|
||||
"""Get next track index based on current mode."""
|
||||
@@ -501,7 +491,6 @@ class PlayerService:
|
||||
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)
|
||||
@@ -519,37 +508,17 @@ class PlayerService:
|
||||
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:
|
||||
# Check if already recorded for this user using proper query
|
||||
stmt = select(SoundPlayed).where(
|
||||
SoundPlayed.user_id == admin_user.id,
|
||||
SoundPlayed.sound_id == sound_id,
|
||||
)
|
||||
result = await session.exec(stmt)
|
||||
existing = result.first()
|
||||
|
||||
if not existing:
|
||||
sound_played = SoundPlayed(
|
||||
user_id=admin_user.id,
|
||||
sound_id=sound_id,
|
||||
)
|
||||
session.add(sound_played)
|
||||
logger.info(
|
||||
"Created SoundPlayed record for user %s, sound %s",
|
||||
admin_user.id,
|
||||
sound_id,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"SoundPlayed record already exists for user %s, sound %s",
|
||||
admin_user.id,
|
||||
sound_id,
|
||||
)
|
||||
else:
|
||||
logger.warning("Admin user (ID 1) not found for play history")
|
||||
# Record play history without user_id for player-based plays
|
||||
# Always create a new SoundPlayed record for each play event
|
||||
sound_played = SoundPlayed(
|
||||
user_id=None, # No user_id for player-based plays
|
||||
sound_id=sound_id,
|
||||
)
|
||||
session.add(sound_played)
|
||||
logger.info(
|
||||
"Created SoundPlayed record for player play, sound %s",
|
||||
sound_id,
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
logger.info("Successfully recorded play count for sound %s", sound_id)
|
||||
|
||||
313
app/services/vlc_player.py
Normal file
313
app/services/vlc_player.py
Normal file
@@ -0,0 +1,313 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user