feat: Implement host system volume control and update player service to use it
This commit is contained in:
@@ -16,6 +16,7 @@ from app.models.sound_played import SoundPlayed
|
|||||||
from app.repositories.playlist import PlaylistRepository
|
from app.repositories.playlist import PlaylistRepository
|
||||||
from app.repositories.sound import SoundRepository
|
from app.repositories.sound import SoundRepository
|
||||||
from app.services.socket import socket_manager
|
from app.services.socket import socket_manager
|
||||||
|
from app.services.volume import volume_service
|
||||||
from app.utils.audio import get_sound_file_path
|
from app.utils.audio import get_sound_file_path
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -46,8 +47,11 @@ class PlayerState:
|
|||||||
"""Initialize player state."""
|
"""Initialize player state."""
|
||||||
self.status: PlayerStatus = PlayerStatus.STOPPED
|
self.status: PlayerStatus = PlayerStatus.STOPPED
|
||||||
self.mode: PlayerMode = PlayerMode.CONTINUOUS
|
self.mode: PlayerMode = PlayerMode.CONTINUOUS
|
||||||
self.volume: int = 80
|
|
||||||
self.previous_volume: int = 80
|
# Initialize volume from host system or default to 80
|
||||||
|
host_volume = volume_service.get_volume()
|
||||||
|
self.volume: int = host_volume if host_volume is not None else 80
|
||||||
|
self.previous_volume: int = self.volume
|
||||||
self.current_sound_id: int | None = None
|
self.current_sound_id: int | None = None
|
||||||
self.current_sound_index: int | None = None
|
self.current_sound_index: int | None = None
|
||||||
self.current_sound_position: int = 0
|
self.current_sound_position: int = 0
|
||||||
@@ -153,8 +157,8 @@ class PlayerService:
|
|||||||
)
|
)
|
||||||
self._position_thread.start()
|
self._position_thread.start()
|
||||||
|
|
||||||
# Set initial volume
|
# Set VLC to 100% volume - host volume is controlled separately
|
||||||
self._player.audio_set_volume(self.state.volume)
|
self._player.audio_set_volume(100)
|
||||||
|
|
||||||
logger.info("Player service started")
|
logger.info("Player service started")
|
||||||
|
|
||||||
@@ -378,7 +382,7 @@ class PlayerService:
|
|||||||
logger.debug("Seeked to position: %sms", position_ms)
|
logger.debug("Seeked to position: %sms", position_ms)
|
||||||
|
|
||||||
async def set_volume(self, volume: int) -> None:
|
async def set_volume(self, volume: int) -> None:
|
||||||
"""Set playback volume (0-100)."""
|
"""Set playback volume (0-100) by controlling host system volume."""
|
||||||
volume = max(0, min(100, volume)) # Clamp to valid range
|
volume = max(0, min(100, volume)) # Clamp to valid range
|
||||||
|
|
||||||
# Store previous volume when muting (going from >0 to 0)
|
# Store previous volume when muting (going from >0 to 0)
|
||||||
@@ -386,18 +390,30 @@ class PlayerService:
|
|||||||
self.state.previous_volume = self.state.volume
|
self.state.previous_volume = self.state.volume
|
||||||
|
|
||||||
self.state.volume = volume
|
self.state.volume = volume
|
||||||
self._player.audio_set_volume(volume)
|
|
||||||
|
# Control host system volume instead of VLC volume
|
||||||
|
if volume == 0:
|
||||||
|
# Mute the host system
|
||||||
|
volume_service.set_mute(muted=True)
|
||||||
|
else:
|
||||||
|
# Unmute and set host volume
|
||||||
|
if volume_service.is_muted():
|
||||||
|
volume_service.set_mute(muted=False)
|
||||||
|
volume_service.set_volume(volume)
|
||||||
|
|
||||||
|
# Keep VLC at 100% volume
|
||||||
|
self._player.audio_set_volume(100)
|
||||||
|
|
||||||
await self._broadcast_state()
|
await self._broadcast_state()
|
||||||
logger.debug("Volume set to: %s", volume)
|
logger.debug("Host volume set to: %s", volume)
|
||||||
|
|
||||||
async def mute(self) -> None:
|
async def mute(self) -> None:
|
||||||
"""Mute the player (stores current volume as previous_volume)."""
|
"""Mute the host system (stores current volume as previous_volume)."""
|
||||||
if self.state.volume > 0:
|
if self.state.volume > 0:
|
||||||
await self.set_volume(0)
|
await self.set_volume(0)
|
||||||
|
|
||||||
async def unmute(self) -> None:
|
async def unmute(self) -> None:
|
||||||
"""Unmute the player (restores previous_volume)."""
|
"""Unmute the host system (restores previous_volume)."""
|
||||||
if self.state.volume == 0 and self.state.previous_volume > 0:
|
if self.state.volume == 0 and self.state.previous_volume > 0:
|
||||||
await self.set_volume(self.state.previous_volume)
|
await self.set_volume(self.state.previous_volume)
|
||||||
|
|
||||||
|
|||||||
@@ -70,12 +70,14 @@ class VLCPlayerService:
|
|||||||
)
|
)
|
||||||
return "vlc"
|
return "vlc"
|
||||||
|
|
||||||
async def play_sound(self, sound: Sound, volume: int | None = None) -> bool:
|
async def play_sound(self, sound: Sound) -> bool:
|
||||||
"""Play a sound using a new VLC subprocess instance.
|
"""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:
|
Args:
|
||||||
sound: The Sound object to play
|
sound: The Sound object to play
|
||||||
volume: Volume level (0-100). If None, uses current player volume.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if VLC process was launched successfully, False otherwise
|
bool: True if VLC process was launched successfully, False otherwise
|
||||||
@@ -88,19 +90,6 @@ class VLCPlayerService:
|
|||||||
logger.error("Sound file not found: %s", sound_path)
|
logger.error("Sound file not found: %s", sound_path)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Get volume from player service if not provided
|
|
||||||
if volume is None:
|
|
||||||
try:
|
|
||||||
from app.services.player import get_player_service # noqa: PLC0415
|
|
||||||
player = get_player_service()
|
|
||||||
volume = player.state.volume
|
|
||||||
except RuntimeError:
|
|
||||||
logger.warning("Could not get player volume, using default 80")
|
|
||||||
volume = 80
|
|
||||||
|
|
||||||
# Ensure volume is in valid range and is an integer
|
|
||||||
volume = max(0, min(100, int(volume))) if volume is not None else 80
|
|
||||||
|
|
||||||
# VLC command arguments for immediate playback
|
# VLC command arguments for immediate playback
|
||||||
cmd = [
|
cmd = [
|
||||||
self.vlc_executable,
|
self.vlc_executable,
|
||||||
@@ -111,7 +100,7 @@ class VLCPlayerService:
|
|||||||
"--no-video", # Audio only
|
"--no-video", # Audio only
|
||||||
"--no-repeat", # Don't repeat
|
"--no-repeat", # Don't repeat
|
||||||
"--no-loop", # Don't loop
|
"--no-loop", # Don't loop
|
||||||
f"--volume={volume}", # Set volume to match player
|
"--volume=100", # Always use 100% VLC volume
|
||||||
]
|
]
|
||||||
|
|
||||||
# Launch VLC process asynchronously without waiting
|
# Launch VLC process asynchronously without waiting
|
||||||
@@ -347,7 +336,10 @@ class VLCPlayerService:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
from fastapi import HTTPException, status # noqa: PLC0415, I001
|
from fastapi import HTTPException, status # noqa: PLC0415, I001
|
||||||
from app.services.credit import CreditService, InsufficientCreditsError # noqa: PLC0415
|
from app.services.credit import (
|
||||||
|
CreditService,
|
||||||
|
InsufficientCreditsError,
|
||||||
|
) # noqa: PLC0415
|
||||||
|
|
||||||
if not self.db_session_factory:
|
if not self.db_session_factory:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -384,19 +376,8 @@ class VLCPlayerService:
|
|||||||
),
|
),
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
# Get current player volume
|
# Play the sound using VLC (always at 100% VLC volume)
|
||||||
try:
|
success = await self.play_sound(sound)
|
||||||
from app.services.player import get_player_service # noqa: PLC0415
|
|
||||||
player = get_player_service()
|
|
||||||
current_volume = player.state.volume
|
|
||||||
except RuntimeError:
|
|
||||||
logger.warning(
|
|
||||||
"Could not get player volume for credit play, using default 80",
|
|
||||||
)
|
|
||||||
current_volume = 80
|
|
||||||
|
|
||||||
# Play the sound using VLC with current player volume
|
|
||||||
success = await self.play_sound(sound, current_volume)
|
|
||||||
|
|
||||||
# Deduct credits based on success
|
# Deduct credits based on success
|
||||||
await credit_service.deduct_credits(
|
await credit_service.deduct_credits(
|
||||||
@@ -433,4 +414,3 @@ def get_vlc_player_service(
|
|||||||
if vlc_player_service is None:
|
if vlc_player_service is None:
|
||||||
vlc_player_service = VLCPlayerService(db_session_factory)
|
vlc_player_service = VLCPlayerService(db_session_factory)
|
||||||
return vlc_player_service
|
return vlc_player_service
|
||||||
return vlc_player_service
|
|
||||||
|
|||||||
251
app/services/volume.py
Normal file
251
app/services/volume.py
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
"""Volume service for host system volume control."""
|
||||||
|
|
||||||
|
import platform
|
||||||
|
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# Constants
|
||||||
|
MIN_VOLUME = 0
|
||||||
|
MAX_VOLUME = 100
|
||||||
|
|
||||||
|
|
||||||
|
class VolumeService:
|
||||||
|
"""Service for controlling host system volume."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialize volume service."""
|
||||||
|
self._system = platform.system().lower()
|
||||||
|
self._pycaw_available = False
|
||||||
|
self._pulsectl_available = False
|
||||||
|
|
||||||
|
# Try to import Windows volume control
|
||||||
|
if self._system == "windows":
|
||||||
|
try:
|
||||||
|
from comtypes import ( # noqa: PLC0415
|
||||||
|
CLSCTX_ALL, # type: ignore[import-untyped]
|
||||||
|
)
|
||||||
|
from pycaw.pycaw import ( # type: ignore[import-untyped] # noqa: PLC0415
|
||||||
|
AudioUtilities,
|
||||||
|
IAudioEndpointVolume,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._AudioUtilities = AudioUtilities
|
||||||
|
self._IAudioEndpointVolume = IAudioEndpointVolume
|
||||||
|
self._CLSCTX_ALL = CLSCTX_ALL
|
||||||
|
self._pycaw_available = True
|
||||||
|
logger.info("Windows volume control (pycaw) initialized")
|
||||||
|
except ImportError as e:
|
||||||
|
logger.warning("pycaw not available: %s", e)
|
||||||
|
|
||||||
|
# Try to import Linux volume control
|
||||||
|
elif self._system == "linux":
|
||||||
|
try:
|
||||||
|
import pulsectl # type: ignore[import-untyped] # noqa: PLC0415
|
||||||
|
|
||||||
|
self._pulsectl = pulsectl
|
||||||
|
self._pulsectl_available = True
|
||||||
|
logger.info("Linux volume control (pulsectl) initialized")
|
||||||
|
except ImportError as e:
|
||||||
|
logger.warning("pulsectl not available: %s", e)
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.warning("Volume control not supported on %s", self._system)
|
||||||
|
|
||||||
|
def get_volume(self) -> int | None:
|
||||||
|
"""Get the current system volume as a percentage (0-100).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Volume level as percentage, or None if not available
|
||||||
|
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if self._system == "windows" and self._pycaw_available:
|
||||||
|
return self._get_windows_volume()
|
||||||
|
if self._system == "linux" and self._pulsectl_available:
|
||||||
|
return self._get_linux_volume()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to get volume")
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
logger.warning("Volume control not available for this system")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_volume(self, volume: int) -> bool:
|
||||||
|
"""Set the system volume to a percentage (0-100).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
volume: Volume level as percentage (0-100)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not (MIN_VOLUME <= volume <= MAX_VOLUME):
|
||||||
|
logger.error(
|
||||||
|
"Volume must be between %s and %s, got %s",
|
||||||
|
MIN_VOLUME,
|
||||||
|
MAX_VOLUME,
|
||||||
|
volume,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self._system == "windows" and self._pycaw_available:
|
||||||
|
return self._set_windows_volume(volume)
|
||||||
|
if self._system == "linux" and self._pulsectl_available:
|
||||||
|
return self._set_linux_volume(volume)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to set volume")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
logger.warning("Volume control not available for this system")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def is_muted(self) -> bool | None:
|
||||||
|
"""Check if the system is muted.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if muted, False if not muted, None if not available
|
||||||
|
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if self._system == "windows" and self._pycaw_available:
|
||||||
|
return self._get_windows_mute_status()
|
||||||
|
if self._system == "linux" and self._pulsectl_available:
|
||||||
|
return self._get_linux_mute_status()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to get mute status")
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
logger.warning("Mute status not available for this system")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_mute(self, *, muted: bool) -> bool:
|
||||||
|
"""Set the system mute status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
muted: True to mute, False to unmute
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if self._system == "windows" and self._pycaw_available:
|
||||||
|
return self._set_windows_mute(muted=muted)
|
||||||
|
if self._system == "linux" and self._pulsectl_available:
|
||||||
|
return self._set_linux_mute(muted=muted)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to set mute status")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
logger.warning("Mute control not available for this system")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _get_windows_volume(self) -> int:
|
||||||
|
"""Get Windows volume using pycaw."""
|
||||||
|
devices = self._AudioUtilities.GetSpeakers()
|
||||||
|
interface = devices.Activate(
|
||||||
|
self._IAudioEndpointVolume._iid_, self._CLSCTX_ALL, None,
|
||||||
|
)
|
||||||
|
volume = interface.QueryInterface(self._IAudioEndpointVolume)
|
||||||
|
current_volume = volume.GetMasterVolume()
|
||||||
|
|
||||||
|
# Convert from scalar (0.0-1.0) to percentage (0-100)
|
||||||
|
return int(current_volume * MAX_VOLUME)
|
||||||
|
|
||||||
|
def _set_windows_volume(self, volume_percent: int) -> bool:
|
||||||
|
"""Set Windows volume using pycaw."""
|
||||||
|
devices = self._AudioUtilities.GetSpeakers()
|
||||||
|
interface = devices.Activate(
|
||||||
|
self._IAudioEndpointVolume._iid_, self._CLSCTX_ALL, None,
|
||||||
|
)
|
||||||
|
volume = interface.QueryInterface(self._IAudioEndpointVolume)
|
||||||
|
|
||||||
|
# Convert from percentage (0-100) to scalar (0.0-1.0)
|
||||||
|
volume_scalar = volume_percent / MAX_VOLUME
|
||||||
|
volume.SetMasterVolume(volume_scalar, None)
|
||||||
|
logger.info("Windows volume set to %s%%", volume_percent)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _get_windows_mute_status(self) -> bool:
|
||||||
|
"""Get Windows mute status using pycaw."""
|
||||||
|
devices = self._AudioUtilities.GetSpeakers()
|
||||||
|
interface = devices.Activate(
|
||||||
|
self._IAudioEndpointVolume._iid_, self._CLSCTX_ALL, None,
|
||||||
|
)
|
||||||
|
volume = interface.QueryInterface(self._IAudioEndpointVolume)
|
||||||
|
return bool(volume.GetMute())
|
||||||
|
|
||||||
|
def _set_windows_mute(self, *, muted: bool) -> bool:
|
||||||
|
"""Set Windows mute status using pycaw."""
|
||||||
|
devices = self._AudioUtilities.GetSpeakers()
|
||||||
|
interface = devices.Activate(
|
||||||
|
self._IAudioEndpointVolume._iid_, self._CLSCTX_ALL, None,
|
||||||
|
)
|
||||||
|
volume = interface.QueryInterface(self._IAudioEndpointVolume)
|
||||||
|
volume.SetMute(muted, None)
|
||||||
|
logger.info("Windows mute set to %s", muted)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _get_linux_volume(self) -> int:
|
||||||
|
"""Get Linux volume using pulsectl."""
|
||||||
|
with self._pulsectl.Pulse("volume-service") as pulse:
|
||||||
|
# Get the default sink (output device)
|
||||||
|
default_sink = pulse.get_sink_by_name(pulse.server_info().default_sink_name)
|
||||||
|
if default_sink is None:
|
||||||
|
logger.error("No default audio sink found")
|
||||||
|
return MIN_VOLUME
|
||||||
|
|
||||||
|
# Get volume as percentage (PulseAudio uses 0.0-1.0, we convert to 0-100)
|
||||||
|
volume = default_sink.volume
|
||||||
|
avg_volume = sum(volume.values) / len(volume.values)
|
||||||
|
return int(avg_volume * MAX_VOLUME)
|
||||||
|
|
||||||
|
def _set_linux_volume(self, volume_percent: int) -> bool:
|
||||||
|
"""Set Linux volume using pulsectl."""
|
||||||
|
with self._pulsectl.Pulse("volume-service") as pulse:
|
||||||
|
# Get the default sink (output device)
|
||||||
|
default_sink = pulse.get_sink_by_name(pulse.server_info().default_sink_name)
|
||||||
|
if default_sink is None:
|
||||||
|
logger.error("No default audio sink found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Convert percentage to PulseAudio volume (0.0-1.0)
|
||||||
|
volume_scalar = volume_percent / MAX_VOLUME
|
||||||
|
|
||||||
|
# Set volume for all channels
|
||||||
|
pulse.volume_set_all_chans(default_sink, volume_scalar)
|
||||||
|
logger.info("Linux volume set to %s%%", volume_percent)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _get_linux_mute_status(self) -> bool:
|
||||||
|
"""Get Linux mute status using pulsectl."""
|
||||||
|
with self._pulsectl.Pulse("volume-service") as pulse:
|
||||||
|
# Get the default sink (output device)
|
||||||
|
default_sink = pulse.get_sink_by_name(pulse.server_info().default_sink_name)
|
||||||
|
if default_sink is None:
|
||||||
|
logger.error("No default audio sink found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return bool(default_sink.mute)
|
||||||
|
|
||||||
|
def _set_linux_mute(self, *, muted: bool) -> bool:
|
||||||
|
"""Set Linux mute status using pulsectl."""
|
||||||
|
with self._pulsectl.Pulse("volume-service") as pulse:
|
||||||
|
# Get the default sink (output device)
|
||||||
|
default_sink = pulse.get_sink_by_name(pulse.server_info().default_sink_name)
|
||||||
|
if default_sink is None:
|
||||||
|
logger.error("No default audio sink found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Set mute status
|
||||||
|
pulse.mute(default_sink, muted)
|
||||||
|
logger.info("Linux mute set to %s", muted)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# Global volume service instance
|
||||||
|
volume_service = VolumeService()
|
||||||
@@ -24,6 +24,8 @@ dependencies = [
|
|||||||
"yt-dlp==2025.9.26",
|
"yt-dlp==2025.9.26",
|
||||||
"asyncpg==0.30.0",
|
"asyncpg==0.30.0",
|
||||||
"psycopg[binary]==3.2.10",
|
"psycopg[binary]==3.2.10",
|
||||||
|
"pycaw>=20240210",
|
||||||
|
"pulsectl>=24.12.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
|
|||||||
@@ -26,12 +26,17 @@ class TestPlayerState:
|
|||||||
|
|
||||||
def test_init_creates_default_state(self) -> None:
|
def test_init_creates_default_state(self) -> None:
|
||||||
"""Test that player state initializes with default values."""
|
"""Test that player state initializes with default values."""
|
||||||
|
# Mock volume service to return a specific volume
|
||||||
|
with patch("app.services.player.volume_service") as mock_volume_service:
|
||||||
|
mock_volume_service.get_volume.return_value = 80
|
||||||
|
|
||||||
state = PlayerState()
|
state = PlayerState()
|
||||||
|
|
||||||
assert state.status == PlayerStatus.STOPPED
|
assert state.status == PlayerStatus.STOPPED
|
||||||
assert state.mode == PlayerMode.CONTINUOUS
|
assert state.mode == PlayerMode.CONTINUOUS
|
||||||
assert state.volume == 80
|
assert state.volume == 80
|
||||||
assert state.previous_volume == 80
|
assert state.previous_volume == 80
|
||||||
|
mock_volume_service.get_volume.assert_called_once()
|
||||||
assert state.current_sound_id is None
|
assert state.current_sound_id is None
|
||||||
assert state.current_sound_index is None
|
assert state.current_sound_index is None
|
||||||
assert state.current_sound_position == 0
|
assert state.current_sound_position == 0
|
||||||
@@ -181,6 +186,8 @@ class TestPlayerService:
|
|||||||
mock_socket_manager,
|
mock_socket_manager,
|
||||||
):
|
):
|
||||||
"""Create a player service instance for testing."""
|
"""Create a player service instance for testing."""
|
||||||
|
with patch("app.services.player.volume_service") as mock_volume_service:
|
||||||
|
mock_volume_service.get_volume.return_value = 80
|
||||||
return PlayerService(mock_db_session_factory)
|
return PlayerService(mock_db_session_factory)
|
||||||
|
|
||||||
def test_init_creates_player_service(
|
def test_init_creates_player_service(
|
||||||
@@ -217,7 +224,8 @@ class TestPlayerService:
|
|||||||
assert player_service._loop is not None
|
assert player_service._loop is not None
|
||||||
assert player_service._position_thread is not None
|
assert player_service._position_thread is not None
|
||||||
assert player_service._position_thread.daemon is True
|
assert player_service._position_thread.daemon is True
|
||||||
player_service._player.audio_set_volume.assert_called_once_with(80)
|
# VLC is now always set to 100% volume
|
||||||
|
player_service._player.audio_set_volume.assert_called_once_with(100)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_stop_cleans_up_service(self, player_service) -> None:
|
async def test_stop_cleans_up_service(self, player_service) -> None:
|
||||||
@@ -399,16 +407,25 @@ class TestPlayerService:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_set_volume(self, player_service) -> None:
|
async def test_set_volume(self, player_service) -> None:
|
||||||
"""Test setting volume."""
|
"""Test setting volume."""
|
||||||
|
with patch("app.services.player.volume_service") as mock_volume_service:
|
||||||
with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock):
|
with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock):
|
||||||
|
mock_volume_service.is_muted.return_value = False
|
||||||
|
|
||||||
await player_service.set_volume(75)
|
await player_service.set_volume(75)
|
||||||
|
|
||||||
assert player_service.state.volume == 75
|
assert player_service.state.volume == 75
|
||||||
player_service._player.audio_set_volume.assert_called_once_with(75)
|
# VLC volume is always set to 100%, host volume is controlled separately
|
||||||
|
player_service._player.audio_set_volume.assert_called_once_with(100)
|
||||||
|
# Verify host volume was set
|
||||||
|
mock_volume_service.set_volume.assert_called_once_with(75)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_set_volume_clamping(self, player_service) -> None:
|
async def test_set_volume_clamping(self, player_service) -> None:
|
||||||
"""Test volume clamping to valid range."""
|
"""Test volume clamping to valid range."""
|
||||||
|
with patch("app.services.player.volume_service") as mock_volume_service:
|
||||||
with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock):
|
with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock):
|
||||||
|
mock_volume_service.is_muted.return_value = False
|
||||||
|
|
||||||
# Test upper bound
|
# Test upper bound
|
||||||
await player_service.set_volume(150)
|
await player_service.set_volume(150)
|
||||||
assert player_service.state.volume == 100
|
assert player_service.state.volume == 100
|
||||||
|
|||||||
51
uv.lock
generated
51
uv.lock
generated
@@ -234,6 +234,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "comtypes"
|
||||||
|
version = "1.4.12"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b6/b8/3af03195b9de515448292169c6d6d7a630de02bedf891a47b809638c186f/comtypes-1.4.12.zip", hash = "sha256:3ff06c442c2de8a2b25785407f244eb5b6f809d21cf068a855071ba80a76876f", size = 280541 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/01/89285549c5138009db68f26c80f2174d0ec82a858547df0cc40a8b0a47d6/comtypes-1.4.12-py3-none-any.whl", hash = "sha256:e0fa9cc19c489fa7feea4c1710f4575c717e2673edef5b99bf99efd507908e44", size = 253704 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "coverage"
|
name = "coverage"
|
||||||
version = "7.10.7"
|
version = "7.10.7"
|
||||||
@@ -699,6 +708,22 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psutil"
|
||||||
|
version = "7.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b3/31/4723d756b59344b643542936e37a31d1d3204bcdc42a7daa8ee9eb06fb50/psutil-7.1.0.tar.gz", hash = "sha256:655708b3c069387c8b77b072fc429a57d0e214221d01c0a772df7dfedcb3bcd2", size = 497660 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/62/ce4051019ee20ce0ed74432dd73a5bb087a6704284a470bb8adff69a0932/psutil-7.1.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:76168cef4397494250e9f4e73eb3752b146de1dd950040b29186d0cce1d5ca13", size = 245242 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/61/f76959fba841bf5b61123fbf4b650886dc4094c6858008b5bf73d9057216/psutil-7.1.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:5d007560c8c372efdff9e4579c2846d71de737e4605f611437255e81efcca2c5", size = 246682 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/7a/37c99d2e77ec30d63398ffa6a660450b8a62517cabe44b3e9bae97696e8d/psutil-7.1.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22e4454970b32472ce7deaa45d045b34d3648ce478e26a04c7e858a0a6e75ff3", size = 287994 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/de/04c8c61232f7244aa0a4b9a9fbd63a89d5aeaf94b2fc9d1d16e2faa5cbb0/psutil-7.1.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c70e113920d51e89f212dd7be06219a9b88014e63a4cec69b684c327bc474e3", size = 291163 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/58/c4f976234bf6d4737bc8c02a81192f045c307b72cf39c9e5c5a2d78927f6/psutil-7.1.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d4a113425c037300de3ac8b331637293da9be9713855c4fc9d2d97436d7259d", size = 293625 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/87/157c8e7959ec39ced1b11cc93c730c4fb7f9d408569a6c59dbd92ceb35db/psutil-7.1.0-cp37-abi3-win32.whl", hash = "sha256:09ad740870c8d219ed8daae0ad3b726d3bf9a028a198e7f3080f6a1888b99bca", size = 244812 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/e9/b44c4f697276a7a95b8e94d0e320a7bf7f3318521b23de69035540b39838/psutil-7.1.0-cp37-abi3-win_amd64.whl", hash = "sha256:57f5e987c36d3146c0dd2528cd42151cf96cd359b9d67cfff836995cc5df9a3d", size = 247965 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/65/1070a6e3c036f39142c2820c4b52e9243246fcfc3f96239ac84472ba361e/psutil-7.1.0-cp37-abi3-win_arm64.whl", hash = "sha256:6937cb68133e7c97b6cc9649a570c9a18ba0efebed46d8c5dae4c07fa1b67a07", size = 244971 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "psycopg"
|
name = "psycopg"
|
||||||
version = "3.2.10"
|
version = "3.2.10"
|
||||||
@@ -751,6 +776,28 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/5a/dd/464bd739bacb3b745a1c93bc15f20f0b1e27f0a64ec693367794b398673b/psycopg_binary-3.2.10-cp314-cp314-win_amd64.whl", hash = "sha256:d5c6a66a76022af41970bf19f51bc6bf87bd10165783dd1d40484bfd87d6b382", size = 2973554 },
|
{ url = "https://files.pythonhosted.org/packages/5a/dd/464bd739bacb3b745a1c93bc15f20f0b1e27f0a64ec693367794b398673b/psycopg_binary-3.2.10-cp314-cp314-win_amd64.whl", hash = "sha256:d5c6a66a76022af41970bf19f51bc6bf87bd10165783dd1d40484bfd87d6b382", size = 2973554 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pulsectl"
|
||||||
|
version = "24.12.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f5/c5/f070a8c5f0a5742f7aebb5d90869ee1805174c03928dfafd3833de58bd57/pulsectl-24.12.0.tar.gz", hash = "sha256:288d6715232ac6f3dcdb123fbecaa2c0b9a50ea4087e6e87c3f841ab0a8a07fc", size = 41200 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/a9/5b119f86dd1a053c55da7d0355fca2ad215bae6f7f4777d46b307a8cc3e9/pulsectl-24.12.0-py2.py3-none-any.whl", hash = "sha256:13a60be940594f03ead3245b3dfe3aff4a3f9a792af347674bde5e716d4f76d2", size = 35133 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pycaw"
|
||||||
|
version = "20240210"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "comtypes" },
|
||||||
|
{ name = "psutil" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3b/1a/f1fa3ceca06eceb5184b907413306b99dd790855ffdf2aee8210fa0fc192/pycaw-20240210.tar.gz", hash = "sha256:55e49359e9f227053f4fa15817a02d4a7bc52fc3db32e123894719a123c41d06", size = 22417 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/e2/89e3e096d8926f19cbcf2991ae86d19e6705ea75ad0212862461cb4b83d8/pycaw-20240210-py3-none-any.whl", hash = "sha256:fbbe0ee67a7d32714240e26913266a386ae4375778c417a7e8ad6076eca62f1e", size = 24760 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.11.7"
|
version = "2.11.7"
|
||||||
@@ -1091,6 +1138,8 @@ dependencies = [
|
|||||||
{ name = "gtts" },
|
{ name = "gtts" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "psycopg", extra = ["binary"] },
|
{ name = "psycopg", extra = ["binary"] },
|
||||||
|
{ name = "pulsectl" },
|
||||||
|
{ name = "pycaw" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
{ name = "pyjwt" },
|
{ name = "pyjwt" },
|
||||||
{ name = "python-socketio" },
|
{ name = "python-socketio" },
|
||||||
@@ -1125,6 +1174,8 @@ requires-dist = [
|
|||||||
{ name = "gtts", specifier = "==2.5.4" },
|
{ name = "gtts", specifier = "==2.5.4" },
|
||||||
{ name = "httpx", specifier = "==0.28.1" },
|
{ name = "httpx", specifier = "==0.28.1" },
|
||||||
{ name = "psycopg", extras = ["binary"], specifier = "==3.2.10" },
|
{ name = "psycopg", extras = ["binary"], specifier = "==3.2.10" },
|
||||||
|
{ name = "pulsectl", specifier = ">=24.12.0" },
|
||||||
|
{ name = "pycaw", specifier = ">=20240210" },
|
||||||
{ name = "pydantic-settings", specifier = "==2.11.0" },
|
{ name = "pydantic-settings", specifier = "==2.11.0" },
|
||||||
{ name = "pyjwt", specifier = "==2.10.1" },
|
{ name = "pyjwt", specifier = "==2.10.1" },
|
||||||
{ name = "python-socketio", specifier = "==5.13.0" },
|
{ name = "python-socketio", specifier = "==5.13.0" },
|
||||||
|
|||||||
Reference in New Issue
Block a user