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:
JSC
2025-07-30 20:46:49 +02:00
parent 1b0d291ad3
commit dd10ef5d41
9 changed files with 1413 additions and 134 deletions

View File

@@ -8,10 +8,12 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.database import get_db
from app.core.dependencies import get_current_active_user_flexible
from app.models.user import User
from app.repositories.sound import SoundRepository
from app.services.extraction import ExtractionInfo, ExtractionService
from app.services.extraction_processor import extraction_processor
from app.services.sound_normalizer import NormalizationResults, SoundNormalizerService
from app.services.sound_scanner import ScanResults, SoundScannerService
from app.services.vlc_player import get_vlc_player_service, VLCPlayerService
router = APIRouter(prefix="/sounds", tags=["sounds"])
@@ -37,6 +39,19 @@ async def get_extraction_service(
return ExtractionService(session)
def get_vlc_player() -> VLCPlayerService:
"""Get the VLC player service."""
from app.core.database import get_session_factory
return get_vlc_player_service(get_session_factory())
async def get_sound_repository(
session: Annotated[AsyncSession, Depends(get_db)],
) -> SoundRepository:
"""Get the sound repository."""
return SoundRepository(session)
# SCAN
@router.post("/scan")
async def scan_sounds(
@@ -349,3 +364,64 @@ async def get_user_extractions(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get extractions: {e!s}",
) from e
# VLC PLAYER
@router.post("/vlc/play/{sound_id}")
async def play_sound_with_vlc(
sound_id: int,
current_user: Annotated[User, Depends(get_current_active_user_flexible)],
vlc_player: Annotated[VLCPlayerService, Depends(get_vlc_player)],
sound_repo: Annotated[SoundRepository, Depends(get_sound_repository)],
) -> dict[str, str | int | bool]:
"""Play a sound using VLC subprocess."""
try:
# 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",
)
# Play the sound using VLC
success = await vlc_player.play_sound(sound)
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,
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to play sound: {e!s}",
) from e
@router.post("/vlc/stop-all")
async def stop_all_vlc_instances(
current_user: Annotated[User, Depends(get_current_active_user_flexible)],
vlc_player: Annotated[VLCPlayerService, Depends(get_vlc_player)],
) -> dict:
"""Stop all running VLC instances."""
try:
result = await vlc_player.stop_all_vlc_instances()
return result
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to stop VLC instances: {e!s}",
) from e

View File

@@ -1,6 +1,6 @@
from typing import TYPE_CHECKING
from sqlmodel import Field, Relationship, UniqueConstraint
from sqlmodel import Field, Relationship
from app.models.base import BaseModel
@@ -14,18 +14,9 @@ class SoundPlayed(BaseModel, table=True):
__tablename__ = "sound_played" # pyright: ignore[reportAssignmentType]
user_id: int = Field(foreign_key="user.id", nullable=False)
user_id: int | None = Field(foreign_key="user.id", nullable=True)
sound_id: int = Field(foreign_key="sound.id", nullable=False)
# constraints
__table_args__ = (
UniqueConstraint(
"user_id",
"sound_id",
name="uq_sound_played_user_sound",
),
)
# relationships
user: "User" = Relationship(back_populates="sounds_played")
sound: "Sound" = Relationship(back_populates="play_history")

View File

@@ -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
View 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

View File

@@ -2,11 +2,15 @@
import hashlib
from pathlib import Path
from typing import TYPE_CHECKING
import ffmpeg # type: ignore[import-untyped]
from app.core.logging import get_logger
if TYPE_CHECKING:
from app.models.sound import Sound
logger = get_logger(__name__)
@@ -33,3 +37,30 @@ def get_audio_duration(file_path: Path) -> int:
except Exception as e:
logger.warning("Failed to get duration for %s: %s", file_path, e)
return 0
def get_sound_file_path(sound: "Sound") -> Path:
"""Get the file path for a sound based on its type and normalization status.
Args:
sound: The Sound object to get the path for
Returns:
Path: The full path to the sound file
"""
# Determine the correct subdirectory based on sound type
if sound.type.upper() == "EXT":
subdir = "extracted"
elif sound.type.upper() == "SDB":
subdir = "soundboard"
elif sound.type.upper() == "TTS":
subdir = "text_to_speech"
else:
# Fallback to lowercase type
subdir = 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