797 lines
29 KiB
Python
797 lines
29 KiB
Python
"""Player service for audio playbook management."""
|
|
|
|
import asyncio
|
|
import threading
|
|
import time
|
|
from collections.abc import Callable, Coroutine
|
|
from enum import Enum
|
|
from typing import Any
|
|
|
|
import vlc # type: ignore[import-untyped]
|
|
|
|
from app.core.logging import get_logger
|
|
from app.models.playlist import Playlist
|
|
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.services.socket import socket_manager
|
|
from app.utils.audio import get_sound_file_path
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
class PlayerStatus(str, Enum):
|
|
"""Player status enumeration."""
|
|
|
|
STOPPED = "stopped"
|
|
PLAYING = "playing"
|
|
PAUSED = "paused"
|
|
|
|
|
|
class PlayerMode(str, Enum):
|
|
"""Player mode enumeration."""
|
|
|
|
CONTINUOUS = "continuous"
|
|
LOOP = "loop"
|
|
LOOP_ONE = "loop_one"
|
|
RANDOM = "random"
|
|
SINGLE = "single"
|
|
|
|
|
|
class PlayerState:
|
|
"""Player state data structure."""
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialize player state."""
|
|
self.status: PlayerStatus = PlayerStatus.STOPPED
|
|
self.mode: PlayerMode = PlayerMode.CONTINUOUS
|
|
self.volume: int = 80
|
|
self.previous_volume: int = 80
|
|
self.current_sound_id: int | None = None
|
|
self.current_sound_index: int | None = None
|
|
self.current_sound_position: int = 0
|
|
self.current_sound_duration: int = 0
|
|
self.current_sound: Sound | None = None
|
|
self.playlist_id: int | None = None
|
|
self.playlist_name: str = ""
|
|
self.playlist_length: int = 0
|
|
self.playlist_duration: int = 0
|
|
self.playlist_sounds: list[Sound] = []
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
"""Convert player state to dictionary for serialization."""
|
|
return {
|
|
"status": self.status.value,
|
|
"mode": self.mode.value,
|
|
"volume": self.volume,
|
|
"previous_volume": self.previous_volume,
|
|
"position": self.current_sound_position or 0,
|
|
"duration": self.current_sound_duration,
|
|
"index": self.current_sound_index,
|
|
"current_sound": self._serialize_sound(self.current_sound),
|
|
"playlist": (
|
|
{
|
|
"id": self.playlist_id,
|
|
"name": self.playlist_name,
|
|
"length": self.playlist_length,
|
|
"duration": self.playlist_duration,
|
|
"sounds": [
|
|
self._serialize_sound(sound) for sound in self.playlist_sounds
|
|
],
|
|
}
|
|
if self.playlist_id
|
|
else None
|
|
),
|
|
}
|
|
|
|
def _serialize_sound(self, sound: Sound | None) -> dict[str, Any] | None:
|
|
"""Serialize a sound object for JSON serialization."""
|
|
if not sound:
|
|
return None
|
|
|
|
# Get extraction URL if sound is linked to an extraction
|
|
extract_url = None
|
|
if hasattr(sound, "extractions") and sound.extractions:
|
|
# Get the first extraction (there should only be one per sound)
|
|
extraction = sound.extractions[0]
|
|
extract_url = extraction.url
|
|
|
|
return {
|
|
"id": sound.id,
|
|
"name": sound.name,
|
|
"filename": sound.filename,
|
|
"duration": sound.duration,
|
|
"size": sound.size,
|
|
"type": sound.type,
|
|
"thumbnail": sound.thumbnail,
|
|
"play_count": sound.play_count,
|
|
"extract_url": extract_url,
|
|
}
|
|
|
|
|
|
class PlayerService:
|
|
"""Service for audio playback management."""
|
|
|
|
def __init__(self, db_session_factory: Callable) -> None:
|
|
"""Initialize the player service."""
|
|
self.db_session_factory = db_session_factory
|
|
self.state = PlayerState()
|
|
self._vlc_instance = vlc.Instance()
|
|
|
|
if self._vlc_instance is None:
|
|
msg = (
|
|
"VLC instance could not be created. "
|
|
"Ensure VLC is installed and accessible."
|
|
)
|
|
raise RuntimeError(msg)
|
|
|
|
self._player = self._vlc_instance.media_player_new()
|
|
self._is_running = False
|
|
self._position_thread: threading.Thread | None = None
|
|
self._play_time_tracking: dict[int, dict[str, Any]] = {}
|
|
self._lock = threading.Lock()
|
|
self._background_tasks: set[asyncio.Task] = set()
|
|
self._loop: asyncio.AbstractEventLoop | None = None
|
|
self._last_position_broadcast: float = 0
|
|
|
|
async def start(self) -> None:
|
|
"""Start the player service."""
|
|
logger.info("Starting player service")
|
|
self._is_running = True
|
|
|
|
# Store the event loop for thread-safe task scheduling
|
|
self._loop = asyncio.get_running_loop()
|
|
|
|
# Load initial playlist
|
|
await self.reload_playlist()
|
|
|
|
# Start position tracking thread
|
|
self._position_thread = threading.Thread(
|
|
target=self._position_tracker,
|
|
daemon=True,
|
|
)
|
|
self._position_thread.start()
|
|
|
|
# Set initial volume
|
|
self._player.audio_set_volume(self.state.volume)
|
|
|
|
logger.info("Player service started")
|
|
|
|
async def stop(self) -> None:
|
|
"""Stop the player service."""
|
|
logger.info("Stopping player service")
|
|
self._is_running = False
|
|
|
|
# Stop playback
|
|
await self._stop_playback()
|
|
|
|
# Wait for position thread to finish
|
|
if self._position_thread and self._position_thread.is_alive():
|
|
self._position_thread.join(timeout=2.0)
|
|
|
|
# Release VLC player
|
|
self._player.release()
|
|
|
|
logger.info("Player service stopped")
|
|
|
|
async def play(self, index: int | None = None) -> None:
|
|
"""Play audio at specified index or current position."""
|
|
if self._should_resume_playback(index):
|
|
await self._resume_playback()
|
|
return
|
|
|
|
await self._start_new_track(index)
|
|
|
|
def _should_resume_playback(self, index: int | None) -> bool:
|
|
"""Check if we should resume paused playback."""
|
|
return (
|
|
index is None
|
|
and self.state.status == PlayerStatus.PAUSED
|
|
and self.state.current_sound is not None
|
|
)
|
|
|
|
async def _resume_playback(self) -> None:
|
|
"""Resume paused playback."""
|
|
result = self._player.play()
|
|
if result == 0: # VLC returns 0 on success
|
|
self.state.status = PlayerStatus.PLAYING
|
|
self._ensure_play_time_tracking_for_resume()
|
|
await self._broadcast_state()
|
|
|
|
sound_name = (
|
|
self.state.current_sound.name if self.state.current_sound else "Unknown"
|
|
)
|
|
logger.info("Resumed playing sound: %s", sound_name)
|
|
else:
|
|
logger.error("Failed to resume playback: VLC error code %s", result)
|
|
|
|
def _ensure_play_time_tracking_for_resume(self) -> None:
|
|
"""Ensure play time tracking is initialized for resumed track."""
|
|
if (
|
|
self.state.current_sound_id
|
|
and self.state.current_sound_id not in self._play_time_tracking
|
|
):
|
|
self._play_time_tracking[self.state.current_sound_id] = {
|
|
"total_time": 0,
|
|
"last_position": self.state.current_sound_position,
|
|
"last_update": time.time(),
|
|
"threshold_reached": False,
|
|
}
|
|
|
|
async def _start_new_track(self, index: int | None) -> None:
|
|
"""Start playing a new track."""
|
|
if not self._prepare_sound_for_playback(index):
|
|
return
|
|
|
|
if not self._load_and_play_media():
|
|
return
|
|
|
|
await self._handle_successful_playback()
|
|
|
|
def _prepare_sound_for_playback(self, index: int | None) -> bool:
|
|
"""Prepare sound for playback, return True if ready."""
|
|
if index is not None and not self._set_sound_by_index(index):
|
|
return False
|
|
|
|
if not self.state.current_sound:
|
|
logger.warning("No sound to play")
|
|
return False
|
|
|
|
return self._validate_sound_file()
|
|
|
|
def _set_sound_by_index(self, index: int) -> bool:
|
|
"""Set current sound by index, return True if valid."""
|
|
if index < 0 or index >= len(self.state.playlist_sounds):
|
|
msg = "Invalid sound index"
|
|
raise ValueError(msg)
|
|
|
|
self.state.current_sound_index = index
|
|
self.state.current_sound = self.state.playlist_sounds[index]
|
|
self.state.current_sound_id = self.state.current_sound.id
|
|
return True
|
|
|
|
def _validate_sound_file(self) -> bool:
|
|
"""Validate sound file exists, return True if valid."""
|
|
if not self.state.current_sound:
|
|
return False
|
|
|
|
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 False
|
|
return True
|
|
|
|
def _load_and_play_media(self) -> bool:
|
|
"""Load media and start playback, return True if successful."""
|
|
if self._vlc_instance is None:
|
|
logger.error("VLC instance is not initialized. Cannot play media.")
|
|
return False
|
|
|
|
if not self.state.current_sound:
|
|
logger.error("No current sound to play")
|
|
return False
|
|
|
|
sound_path = get_sound_file_path(self.state.current_sound)
|
|
media = self._vlc_instance.media_new(str(sound_path))
|
|
self._player.set_media(media)
|
|
|
|
result = self._player.play()
|
|
if result != 0: # VLC returns 0 on success
|
|
logger.error("Failed to start playback: VLC error code %s", result)
|
|
return False
|
|
|
|
return True
|
|
|
|
async def _handle_successful_playback(self) -> None:
|
|
"""Handle successful playback start."""
|
|
if not self.state.current_sound:
|
|
logger.error("No current sound for successful playback")
|
|
return
|
|
|
|
self.state.status = PlayerStatus.PLAYING
|
|
self.state.current_sound_duration = self.state.current_sound.duration or 0
|
|
|
|
self._initialize_play_time_tracking()
|
|
await self._broadcast_state()
|
|
logger.info("Started playing sound: %s", self.state.current_sound.name)
|
|
|
|
def _initialize_play_time_tracking(self) -> None:
|
|
"""Initialize play time tracking for new track."""
|
|
if self.state.current_sound_id:
|
|
self._play_time_tracking[self.state.current_sound_id] = {
|
|
"total_time": 0,
|
|
"last_position": 0,
|
|
"last_update": time.time(),
|
|
"threshold_reached": False,
|
|
}
|
|
logger.info(
|
|
"Initialized play time tracking for sound %s (duration: %s ms)",
|
|
self.state.current_sound_id,
|
|
self.state.current_sound_duration,
|
|
)
|
|
|
|
async def pause(self) -> None:
|
|
"""Pause playback."""
|
|
if self.state.status == PlayerStatus.PLAYING:
|
|
self._player.pause()
|
|
self.state.status = PlayerStatus.PAUSED
|
|
await self._broadcast_state()
|
|
logger.info("Playback paused")
|
|
|
|
async def stop_playback(self) -> None:
|
|
"""Stop playback."""
|
|
await self._stop_playback()
|
|
await self._broadcast_state()
|
|
|
|
async def _stop_playback(self) -> None:
|
|
"""Stop playback internal method."""
|
|
if self.state.status != PlayerStatus.STOPPED:
|
|
self._player.stop()
|
|
self.state.status = PlayerStatus.STOPPED
|
|
self.state.current_sound_position = 0
|
|
|
|
# Process any pending play counts
|
|
await self._process_play_count()
|
|
|
|
logger.info("Playback stopped")
|
|
|
|
async def next(self) -> None:
|
|
"""Skip to next track."""
|
|
if not self.state.playlist_sounds:
|
|
return
|
|
|
|
current_index = self.state.current_sound_index or 0
|
|
next_index = self._get_next_index(current_index)
|
|
|
|
if next_index is not None:
|
|
await self.play(next_index)
|
|
else:
|
|
await self._stop_playback()
|
|
await self._broadcast_state()
|
|
|
|
async def previous(self) -> None:
|
|
"""Go to previous track."""
|
|
if not self.state.playlist_sounds:
|
|
return
|
|
|
|
current_index = self.state.current_sound_index or 0
|
|
prev_index = self._get_previous_index(current_index)
|
|
|
|
if prev_index is not None:
|
|
await self.play(prev_index)
|
|
|
|
async def seek(self, position_ms: int) -> None:
|
|
"""Seek to specific position in current track."""
|
|
if self.state.status == PlayerStatus.STOPPED:
|
|
return
|
|
|
|
# Convert milliseconds to VLC position (0.0 to 1.0)
|
|
if self.state.current_sound_duration > 0:
|
|
position = position_ms / self.state.current_sound_duration
|
|
position = max(0.0, min(1.0, position)) # Clamp to valid range
|
|
|
|
self._player.set_position(position)
|
|
self.state.current_sound_position = position_ms
|
|
|
|
await self._broadcast_state()
|
|
logger.debug("Seeked to position: %sms", position_ms)
|
|
|
|
async def set_volume(self, volume: int) -> None:
|
|
"""Set playback volume (0-100)."""
|
|
volume = max(0, min(100, volume)) # Clamp to valid range
|
|
|
|
# Store previous volume when muting (going from >0 to 0)
|
|
if self.state.volume > 0 and volume == 0:
|
|
self.state.previous_volume = self.state.volume
|
|
|
|
self.state.volume = volume
|
|
self._player.audio_set_volume(volume)
|
|
|
|
await self._broadcast_state()
|
|
logger.debug("Volume set to: %s", volume)
|
|
|
|
async def mute(self) -> None:
|
|
"""Mute the player (stores current volume as previous_volume)."""
|
|
if self.state.volume > 0:
|
|
await self.set_volume(0)
|
|
|
|
async def unmute(self) -> None:
|
|
"""Unmute the player (restores previous_volume)."""
|
|
if self.state.volume == 0 and self.state.previous_volume > 0:
|
|
await self.set_volume(self.state.previous_volume)
|
|
|
|
async def set_mode(self, mode: PlayerMode) -> None:
|
|
"""Set playback mode."""
|
|
self.state.mode = mode
|
|
await self._broadcast_state()
|
|
logger.info("Playback mode set to: %s", mode.value)
|
|
|
|
async def reload_playlist(self) -> None:
|
|
"""Reload current playlist from database."""
|
|
session = self.db_session_factory()
|
|
try:
|
|
playlist_repo = PlaylistRepository(session)
|
|
current_playlist = await playlist_repo.get_current_playlist()
|
|
|
|
if current_playlist and current_playlist.id:
|
|
sounds = await playlist_repo.get_playlist_sounds(current_playlist.id)
|
|
await self._handle_playlist_reload(current_playlist, sounds)
|
|
logger.info(
|
|
"Loaded playlist: %s (%s sounds)",
|
|
current_playlist.name,
|
|
len(sounds),
|
|
)
|
|
else:
|
|
logger.warning("No playlist found to load")
|
|
finally:
|
|
await session.close()
|
|
|
|
await self._broadcast_state()
|
|
|
|
async def _handle_playlist_reload(
|
|
self,
|
|
current_playlist: Playlist,
|
|
sounds: list[Sound],
|
|
) -> None:
|
|
"""Handle playlist reload logic with ID comparison."""
|
|
# Store previous state for comparison
|
|
previous_playlist_id = self.state.playlist_id
|
|
previous_current_sound_id = self.state.current_sound_id
|
|
previous_current_sound_index = self.state.current_sound_index
|
|
|
|
# Update basic playlist state
|
|
self._update_playlist_state(current_playlist, sounds)
|
|
|
|
# Handle playlist changes based on ID comparison
|
|
if (
|
|
current_playlist.id is not None
|
|
and previous_playlist_id != current_playlist.id
|
|
):
|
|
await self._handle_playlist_id_changed(
|
|
previous_playlist_id,
|
|
current_playlist.id,
|
|
sounds,
|
|
)
|
|
elif previous_current_sound_id:
|
|
await self._handle_same_playlist_track_check(
|
|
previous_current_sound_id,
|
|
previous_current_sound_index,
|
|
sounds,
|
|
)
|
|
elif sounds:
|
|
self._set_first_track_as_current(sounds)
|
|
|
|
async def _handle_playlist_id_changed(
|
|
self,
|
|
previous_id: int | None,
|
|
current_id: int,
|
|
sounds: list[Sound],
|
|
) -> None:
|
|
"""Handle when playlist ID changes - stop player and reset to first track."""
|
|
logger.info(
|
|
"Playlist changed from %s to %s - stopping player and resetting",
|
|
previous_id,
|
|
current_id,
|
|
)
|
|
|
|
if self.state.status != PlayerStatus.STOPPED:
|
|
await self._stop_playback()
|
|
|
|
if sounds:
|
|
self._set_first_track_as_current(sounds)
|
|
else:
|
|
self._clear_current_track()
|
|
|
|
async def _handle_same_playlist_track_check(
|
|
self,
|
|
previous_sound_id: int,
|
|
previous_index: int | None,
|
|
sounds: list[Sound],
|
|
) -> None:
|
|
"""Handle track checking when playlist ID is the same."""
|
|
# Find the current track in the new playlist
|
|
new_index = self._find_sound_index(previous_sound_id, sounds)
|
|
|
|
if new_index is not None:
|
|
# Track still exists - update index if it changed
|
|
if new_index != previous_index:
|
|
logger.info(
|
|
"Current track %s moved from index %s to %s",
|
|
previous_sound_id,
|
|
previous_index,
|
|
new_index,
|
|
)
|
|
# Always set the index and sound reference
|
|
self.state.current_sound_index = new_index
|
|
self.state.current_sound = sounds[new_index]
|
|
else:
|
|
# Current track no longer exists in playlist
|
|
await self._handle_track_removed(previous_sound_id, sounds)
|
|
|
|
async def _handle_track_removed(
|
|
self,
|
|
previous_sound_id: int,
|
|
sounds: list[Sound],
|
|
) -> None:
|
|
"""Handle when current track no longer exists in playlist."""
|
|
logger.info(
|
|
"Current track %s no longer exists in playlist - stopping and resetting",
|
|
previous_sound_id,
|
|
)
|
|
|
|
if self.state.status != PlayerStatus.STOPPED:
|
|
await self._stop_playback()
|
|
|
|
if sounds:
|
|
self._set_first_track_as_current(sounds)
|
|
else:
|
|
self._clear_current_track()
|
|
|
|
def _update_playlist_state(
|
|
self,
|
|
current_playlist: Playlist,
|
|
sounds: list[Sound],
|
|
) -> None:
|
|
"""Update basic playlist state information."""
|
|
self.state.playlist_id = current_playlist.id
|
|
self.state.playlist_name = current_playlist.name
|
|
self.state.playlist_sounds = sounds
|
|
self.state.playlist_length = len(sounds)
|
|
self.state.playlist_duration = sum(sound.duration or 0 for sound in sounds)
|
|
|
|
def _find_sound_index(self, sound_id: int, sounds: list[Sound]) -> int | None:
|
|
"""Find the index of a sound in the sounds list."""
|
|
for i, sound in enumerate(sounds):
|
|
if sound.id == sound_id:
|
|
return i
|
|
return None
|
|
|
|
def _set_first_track_as_current(self, sounds: list[Sound]) -> None:
|
|
"""Set the first track as the current track."""
|
|
self.state.current_sound_index = 0
|
|
self.state.current_sound = sounds[0]
|
|
self.state.current_sound_id = sounds[0].id
|
|
|
|
def _clear_current_track(self) -> None:
|
|
"""Clear the current track state."""
|
|
self.state.current_sound_index = None
|
|
self.state.current_sound = None
|
|
self.state.current_sound_id = None
|
|
|
|
def get_state(self) -> dict[str, Any]:
|
|
"""Get current player state."""
|
|
return self.state.to_dict()
|
|
|
|
def _get_next_index(self, current_index: int) -> int | None:
|
|
"""Get next track index based on current mode."""
|
|
if not self.state.playlist_sounds:
|
|
return None
|
|
|
|
playlist_length = len(self.state.playlist_sounds)
|
|
|
|
if self.state.mode == PlayerMode.SINGLE:
|
|
return None
|
|
if self.state.mode == PlayerMode.LOOP_ONE:
|
|
return current_index
|
|
if self.state.mode == PlayerMode.RANDOM:
|
|
import random # noqa: PLC0415
|
|
|
|
indices = list(range(playlist_length))
|
|
indices.remove(current_index)
|
|
return random.choice(indices) if indices else None # noqa: S311
|
|
# CONTINUOUS or LOOP
|
|
next_index = current_index + 1
|
|
if next_index >= playlist_length:
|
|
return 0 if self.state.mode == PlayerMode.LOOP else None
|
|
return next_index
|
|
|
|
def _get_previous_index(self, current_index: int) -> int | None:
|
|
"""Get previous track index."""
|
|
if not self.state.playlist_sounds:
|
|
return None
|
|
|
|
playlist_length = len(self.state.playlist_sounds)
|
|
prev_index = current_index - 1
|
|
|
|
if prev_index < 0:
|
|
return playlist_length - 1 if self.state.mode == PlayerMode.LOOP else None
|
|
return prev_index
|
|
|
|
def _position_tracker(self) -> None:
|
|
"""Background thread to track playback position and handle auto-advance."""
|
|
while self._is_running:
|
|
if self.state.status == PlayerStatus.PLAYING:
|
|
# Update position
|
|
vlc_position = self._player.get_position()
|
|
if vlc_position >= 0: # Valid position
|
|
self.state.current_sound_position = int(
|
|
vlc_position * self.state.current_sound_duration,
|
|
)
|
|
|
|
# Check if track finished
|
|
player_state = self._player.get_state()
|
|
vlc_state_ended = 6 # vlc.State.Ended value
|
|
if player_state == vlc_state_ended:
|
|
# Track finished, handle auto-advance
|
|
self._schedule_async_task(self._handle_track_finished())
|
|
|
|
# Update play time tracking
|
|
self._update_play_time()
|
|
|
|
# Broadcast state every second while playing
|
|
broadcast_interval = 1
|
|
current_time = time.time()
|
|
if current_time - self._last_position_broadcast >= broadcast_interval:
|
|
self._last_position_broadcast = current_time
|
|
self._schedule_async_task(self._broadcast_state())
|
|
|
|
time.sleep(0.1) # 100ms update interval
|
|
|
|
def _update_play_time(self) -> None:
|
|
"""Update play time tracking for current sound."""
|
|
if not self.state.current_sound_id or self.state.status != PlayerStatus.PLAYING:
|
|
return
|
|
|
|
sound_id = self.state.current_sound_id
|
|
current_time = time.time()
|
|
current_position = self.state.current_sound_position
|
|
|
|
with self._lock:
|
|
if sound_id in self._play_time_tracking:
|
|
tracking = self._play_time_tracking[sound_id]
|
|
|
|
# Calculate time elapsed (only if position advanced reasonably)
|
|
time_elapsed = current_time - tracking["last_update"]
|
|
position_diff = abs(current_position - tracking["last_position"])
|
|
|
|
# Only count if position advanced naturally (not seeking)
|
|
max_position_jump = 5000 # 5 seconds in milliseconds
|
|
if time_elapsed > 0 and position_diff < max_position_jump:
|
|
# Add real time elapsed (converted to ms)
|
|
tracking["total_time"] += time_elapsed * 1000
|
|
|
|
tracking["last_position"] = current_position
|
|
tracking["last_update"] = current_time
|
|
|
|
# Check if 20% threshold reached
|
|
play_threshold = 0.2 # 20% of track duration
|
|
threshold_time = self.state.current_sound_duration * play_threshold
|
|
|
|
if (
|
|
not tracking["threshold_reached"]
|
|
and self.state.current_sound_duration > 0
|
|
and tracking["total_time"] >= threshold_time
|
|
):
|
|
tracking["threshold_reached"] = True
|
|
logger.info(
|
|
"Play count threshold reached for sound %s: %s/%s ms (%.1f%%)",
|
|
sound_id,
|
|
tracking["total_time"],
|
|
self.state.current_sound_duration,
|
|
(tracking["total_time"] / self.state.current_sound_duration)
|
|
* 100,
|
|
)
|
|
self._schedule_async_task(self._record_play_count(sound_id))
|
|
|
|
async def _record_play_count(self, sound_id: int) -> None:
|
|
"""Record a play count for a sound."""
|
|
logger.info("Recording play count for sound %s", sound_id)
|
|
session = self.db_session_factory()
|
|
try:
|
|
sound_repo = SoundRepository(session)
|
|
|
|
# Update sound play count
|
|
sound = await sound_repo.get_by_id(sound_id)
|
|
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 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)
|
|
except Exception:
|
|
logger.exception("Error recording play count for sound %s", sound_id)
|
|
await session.rollback()
|
|
finally:
|
|
await session.close()
|
|
|
|
async def _process_play_count(self) -> None:
|
|
"""Process any pending play counts when stopping."""
|
|
if not self.state.current_sound_id:
|
|
return
|
|
|
|
sound_id = self.state.current_sound_id
|
|
with self._lock:
|
|
if (
|
|
sound_id in self._play_time_tracking
|
|
and self._play_time_tracking[sound_id]["threshold_reached"]
|
|
):
|
|
# Already processed
|
|
del self._play_time_tracking[sound_id]
|
|
|
|
async def _handle_track_finished(self) -> None:
|
|
"""Handle when a track finishes playing."""
|
|
await self._process_play_count()
|
|
|
|
# Auto-advance to next track
|
|
if self.state.current_sound_index is not None:
|
|
next_index = self._get_next_index(self.state.current_sound_index)
|
|
if next_index is not None:
|
|
await self.play(next_index)
|
|
else:
|
|
await self._stop_playback()
|
|
await self._broadcast_state()
|
|
|
|
async def _broadcast_state(self) -> None:
|
|
"""Broadcast current player state via WebSocket."""
|
|
try:
|
|
state_data = self.get_state()
|
|
await socket_manager.broadcast_to_all("player_state", state_data)
|
|
except Exception:
|
|
logger.exception("Error broadcasting player state")
|
|
|
|
def _track_task(self, task: asyncio.Task) -> None:
|
|
"""Track background task to prevent garbage collection."""
|
|
self._background_tasks.add(task)
|
|
task.add_done_callback(self._background_tasks.discard)
|
|
|
|
def _schedule_async_task(self, coro: Coroutine[Any, Any, Any]) -> None:
|
|
"""Schedule an async task from a background thread."""
|
|
if self._loop and not self._loop.is_closed():
|
|
try:
|
|
# Use run_coroutine_threadsafe to schedule the coroutine
|
|
asyncio.run_coroutine_threadsafe(coro, self._loop)
|
|
# Don't wait for the result to avoid blocking the thread
|
|
except Exception:
|
|
logger.exception("Error scheduling async task")
|
|
|
|
|
|
# Global player service instance
|
|
player_service: PlayerService | None = None
|
|
|
|
|
|
def get_player_service() -> PlayerService:
|
|
"""Get the global player service instance."""
|
|
if player_service is None:
|
|
msg = "Player service not initialized"
|
|
raise RuntimeError(msg)
|
|
return player_service
|
|
|
|
|
|
async def initialize_player_service(db_session_factory: Callable) -> None:
|
|
"""Initialize the global player service."""
|
|
global player_service # noqa: PLW0603
|
|
player_service = PlayerService(db_session_factory)
|
|
await player_service.start()
|
|
|
|
|
|
async def shutdown_player_service() -> None:
|
|
"""Shutdown the global player service."""
|
|
global player_service # noqa: PLW0603
|
|
if player_service:
|
|
await player_service.stop()
|
|
player_service = None
|