Files
sdb-back/app/services/music_player_service.py

756 lines
27 KiB
Python

"""Music player service using VLC Python bindings with playlist management and real-time sync."""
import os
import threading
import time
from typing import Any, Optional
import vlc
from flask import current_app, request
from app.models.playlist import Playlist
from app.models.sound import Sound
from app.services.logging_service import LoggingService
from app.services.socketio_service import socketio_service
logger = LoggingService.get_logger(__name__)
class MusicPlayerService:
"""Service for managing a VLC music player with playlist support."""
def __init__(self):
"""Initialize the music player service."""
self.instance: Optional[vlc.Instance] = None
self.player: Optional[vlc.MediaPlayer] = None
self.app: Optional[Any] = None # Store Flask app instance for context
self.current_playlist_id: Optional[int] = None
self.current_track_index = 0
self.playlist_files: list[str] = (
[]
) # Store file paths for manual playlist management
self.volume = 80
self.play_mode = (
"continuous" # continuous, loop-playlist, loop-one, random
)
self.is_playing = False
self.current_time = 0
self.duration = 0
self.last_sync_time = 0
self.sync_interval = (
0.5 # seconds (increased frequency to catch track endings)
)
self.lock = threading.Lock()
self._sync_thread = None
self._stop_sync = False
self._track_ending_handled = (
False # Flag to prevent duplicate ending triggers
)
def start_vlc_instance(self) -> bool:
"""Start a VLC instance with Python bindings."""
try:
# Create VLC instance with audio output enabled
vlc_args = [
"--intf=dummy", # No interface
"--no-video", # Audio only
]
self.instance = vlc.Instance(vlc_args)
if not self.instance:
logger.error("Failed to create VLC instance")
return False
# Create media player
self.player = self.instance.media_player_new()
if not self.player:
logger.error("Failed to create VLC media player")
return False
# Set initial volume
self.player.audio_set_volume(self.volume)
logger.info("VLC music player started successfully")
# Automatically load the current playlist
self._load_current_playlist_on_startup()
self._start_sync_thread()
return True
except Exception as e:
logger.error(f"Error starting VLC instance: {e}")
return False
def stop_vlc_instance(self) -> bool:
"""Stop the VLC instance."""
try:
self._stop_sync = True
if self._sync_thread:
self._sync_thread.join(timeout=2)
if self.player:
self.player.stop()
# Release VLC objects
self.player = None
self.instance = None
logger.info("VLC music player stopped")
return True
except Exception as e:
logger.error(f"Error stopping VLC instance: {e}")
return False
def load_playlist(self, playlist_id: int, reload: bool = False) -> bool:
"""Load a playlist into VLC."""
try:
if not self.instance or not self.player:
logger.error("VLC not initialized")
return False
with self.lock:
# Ensure we have Flask app context for database queries
if current_app:
with current_app.app_context():
playlist = Playlist.query.get(playlist_id)
if not playlist:
return False
return self._load_playlist_with_context(
playlist, reload
)
else:
# Fallback for when no Flask context is available
logger.warning(
"No Flask context available for loading playlist"
)
return False
except Exception as e:
logger.error(f"Error loading playlist {playlist_id}: {e}")
return False
def _build_thumbnail_url(
self, sound_type: str, thumbnail_filename: str
) -> str:
"""Build absolute thumbnail URL."""
try:
# Try to get base URL from current request context
if request:
base_url = request.url_root.rstrip("/")
else:
# Fallback to localhost if no request context
base_url = "http://localhost:5000"
return f"{base_url}/api/sounds/{sound_type.lower()}/thumbnails/{thumbnail_filename}"
except Exception:
# Fallback if request context is not available
return f"http://localhost:5000/api/sounds/{sound_type.lower()}/thumbnails/{thumbnail_filename}"
def _load_playlist_with_context(
self, playlist, reload: bool = False
) -> bool:
"""Load playlist with database context already established."""
try:
# Clear current playlist
self.playlist_files = []
# Add tracks to our internal playlist
for playlist_sound in sorted(
playlist.playlist_sounds, key=lambda x: x.order
):
sound = playlist_sound.sound
if sound:
file_path = self._get_sound_file_path(sound)
if file_path and os.path.exists(file_path):
self.playlist_files.append(file_path)
deleted = False
if reload:
# Set current track index to the real index of the current track
# in case the order has changed or the track has been deleted
current_track = self.get_current_track()
current_track_id = (
current_track["id"] if current_track else None
)
sound_ids = [
ps.sound.id
for ps in sorted(
playlist.playlist_sounds, key=lambda x: x.order
)
]
if current_track_id in sound_ids:
self.current_track_index = sound_ids.index(current_track_id)
else:
deleted = True
if not reload or deleted:
self.current_playlist_id = playlist.id
self.current_track_index = 0
# Load first track if available
if self.playlist_files:
self._load_track_at_index(0)
# Emit playlist loaded event
self._emit_player_state()
logger.info(
f"Loaded playlist '{playlist.name}' with {len(self.playlist_files)} tracks"
)
return True
except Exception as e:
logger.error(f"Error in _load_playlist_with_context: {e}")
return False
def _load_track_at_index(self, index: int) -> bool:
"""Load a specific track by index."""
try:
if 0 <= index < len(self.playlist_files):
file_path = self.playlist_files[index]
media = self.instance.media_new(file_path)
if media:
self.player.set_media(media)
self.current_track_index = index
# Reset track ending flag when loading a new track
self._track_ending_handled = False
return True
return False
except Exception as e:
logger.error(f"Error loading track at index {index}: {e}")
return False
def _get_sound_file_path(self, sound: Sound) -> Optional[str]:
"""Get the file path for a sound, preferring normalized version."""
try:
if sound.type == "STR":
# Stream sounds
base_path = "sounds/stream"
elif sound.type == "SAY":
# Say sounds
base_path = "sounds/say"
else:
# Soundboard sounds
base_path = "sounds/soundboard"
# Check for normalized version first
if sound.is_normalized and sound.normalized_filename:
normalized_path = os.path.join(
"sounds/normalized",
sound.type.lower(),
sound.normalized_filename,
)
if os.path.exists(normalized_path):
return os.path.abspath(normalized_path)
# Fall back to original file
original_path = os.path.join(base_path, sound.filename)
if os.path.exists(original_path):
return os.path.abspath(original_path)
return None
except Exception as e:
logger.error(f"Error getting file path for sound {sound.id}: {e}")
return None
def play(self) -> bool:
"""Start playback."""
try:
if not self.player:
return False
# Reset track ending flag when starting playback
self._track_ending_handled = False
result = self.player.play()
if result == 0: # Success
self.is_playing = True
self._emit_player_state()
return True
return False
except Exception as e:
logger.error(f"Error starting playback: {e}")
return False
def pause(self) -> bool:
"""Pause playback."""
try:
if not self.player:
return False
self.player.pause()
self.is_playing = False
self._emit_player_state()
return True
except Exception as e:
logger.error(f"Error pausing playback: {e}")
return False
def stop(self) -> bool:
"""Stop playback."""
try:
if not self.player:
return False
self.player.stop()
self.is_playing = False
self.current_time = 0
self._emit_player_state()
return True
except Exception as e:
logger.error(f"Error stopping playback: {e}")
return False
def next_track(self, force_play: bool = False) -> bool:
"""Skip to next track."""
try:
if not self.playlist_files:
return False
next_index = self.current_track_index + 1
# Handle different play modes
if self.play_mode == "loop-playlist" and next_index >= len(
self.playlist_files
):
next_index = 0
elif self.play_mode == "random":
import random
next_index = random.randint(0, len(self.playlist_files) - 1)
elif next_index >= len(self.playlist_files):
# End of playlist in continuous mode
self.stop()
return True
if self._load_track_at_index(next_index):
if self.is_playing or force_play:
self.play()
self._emit_player_state()
return True
return False
except Exception as e:
logger.error(f"Error skipping to next track: {e}")
return False
def previous_track(self) -> bool:
"""Skip to previous track."""
try:
if not self.playlist_files:
return False
prev_index = self.current_track_index - 1
# Handle different play modes
if self.play_mode == "loop-playlist" and prev_index < 0:
prev_index = len(self.playlist_files) - 1
elif self.play_mode == "random":
import random
prev_index = random.randint(0, len(self.playlist_files) - 1)
elif prev_index < 0:
prev_index = 0
if self._load_track_at_index(prev_index):
if self.is_playing:
self.play()
self._emit_player_state()
return True
return False
except Exception as e:
logger.error(f"Error skipping to previous track: {e}")
return False
def seek(self, position: float) -> bool:
"""Seek to position (0.0 to 1.0)."""
try:
if not self.player:
return False
# Set position as percentage
self.player.set_position(position)
self.current_time = position * self.duration
self._emit_player_state()
return True
except Exception as e:
logger.error(f"Error seeking: {e}")
return False
def set_volume(self, volume: int) -> bool:
"""Set volume (0-100)."""
try:
if not self.player:
return False
volume = max(0, min(100, volume))
result = self.player.audio_set_volume(volume)
if result == 0: # Success
self.volume = volume
self._emit_player_state()
return True
return False
except Exception as e:
logger.error(f"Error setting volume: {e}")
return False
def set_play_mode(self, mode: str) -> bool:
"""Set play mode."""
try:
if mode in ["continuous", "loop-playlist", "loop-one", "random"]:
self.play_mode = mode
self._emit_player_state()
return True
return False
except Exception as e:
logger.error(f"Error setting play mode: {e}")
return False
def play_track_at_index(self, index: int) -> bool:
"""Play track at specific playlist index."""
try:
if self._load_track_at_index(index):
result = self.play()
self._emit_player_state()
return result
return False
except Exception as e:
logger.error(f"Error playing track at index {index}: {e}")
return False
def _get_playlist_length(self) -> int:
"""Get current playlist length."""
return len(self.playlist_files)
def get_current_track(self) -> Optional[dict]:
"""Get current track information."""
try:
if not self.current_playlist_id:
return None
# Ensure we have Flask app context
if current_app:
with current_app.app_context():
playlist = Playlist.query.get(self.current_playlist_id)
if playlist and 0 <= self.current_track_index < len(
playlist.playlist_sounds
):
playlist_sounds = sorted(
playlist.playlist_sounds, key=lambda x: x.order
)
current_playlist_sound = playlist_sounds[
self.current_track_index
]
sound = current_playlist_sound.sound
if sound:
return {
"id": sound.id,
"title": sound.name,
"artist": None, # Could be extracted from metadata
"duration": sound.duration or 0,
"thumbnail": (
self._build_thumbnail_url(
sound.type, sound.thumbnail
)
if sound.thumbnail
else None
),
"type": sound.type,
}
return None
except Exception as e:
logger.error(f"Error getting current track: {e}")
return None
def get_playlist_tracks(self) -> list[dict]:
"""Get all tracks in current playlist."""
try:
tracks = []
if not self.current_playlist_id:
return tracks
# Ensure we have Flask app context
if current_app:
with current_app.app_context():
playlist = Playlist.query.get(self.current_playlist_id)
if playlist:
for playlist_sound in sorted(
playlist.playlist_sounds, key=lambda x: x.order
):
sound = playlist_sound.sound
if sound:
tracks.append(
{
"id": sound.id,
"title": sound.name,
"artist": None,
"duration": sound.duration or 0,
"thumbnail": (
self._build_thumbnail_url(
sound.type, sound.thumbnail
)
if sound.thumbnail
else None
),
"type": sound.type,
}
)
return tracks
except Exception as e:
logger.error(f"Error getting playlist tracks: {e}")
return []
def get_player_state(self) -> dict[str, Any]:
"""Get complete player state."""
current_track = self.get_current_track()
return {
"is_playing": self.is_playing,
"current_time": self.current_time,
"duration": self.duration,
"volume": self.volume,
"play_mode": self.play_mode,
"current_track": current_track,
"current_track_id": current_track["id"] if current_track else None,
"current_track_index": self.current_track_index,
"playlist": self.get_playlist_tracks(),
"playlist_id": self.current_playlist_id,
}
def _start_sync_thread(self):
"""Start background thread to sync with VLC state."""
self._stop_sync = False
self._sync_thread = threading.Thread(
target=self._sync_loop, daemon=True
)
self._sync_thread.start()
def _sync_loop(self):
"""Background loop to sync player state with VLC."""
while not self._stop_sync:
try:
current_time = time.time()
if current_time - self.last_sync_time >= self.sync_interval:
self._sync_with_vlc()
self.last_sync_time = current_time
time.sleep(0.1) # Small sleep to prevent busy waiting
except Exception as e:
logger.debug(f"Error in sync loop: {e}")
time.sleep(1) # Longer sleep on error
def _sync_with_vlc(self):
"""Sync internal state with VLC."""
try:
if not self.player:
return
# Update playback state
old_playing = self.is_playing
old_time = self.current_time
# Get current state from VLC
state = self.player.get_state()
self.is_playing = state == vlc.State.Playing
# Get time and duration (in milliseconds)
self.current_time = self.player.get_time()
self.duration = self.player.get_length()
# Get volume
self.volume = self.player.audio_get_volume()
# Enhanced track ending detection
track_ended = False
# Check for ended state
if state == vlc.State.Ended:
track_ended = True
logger.info(
f"Track ended via VLC State.Ended, mode: {self.play_mode}"
)
# Also check if we're very close to the end (within 500ms) and not playing
elif (
self.duration > 0
and self.current_time > 0
and self.current_time >= (self.duration - 500)
and not self.is_playing
and old_playing
):
track_ended = True
logger.info(
f"Track ended via time check, mode: {self.play_mode}"
)
# Handle track ending based on play mode (only if not already handled)
if track_ended and not self._track_ending_handled:
self._track_ending_handled = True
if self.play_mode == "loop-one":
logger.info("Restarting track for loop-one mode")
self.play_track_at_index(self.current_track_index)
elif self.play_mode in [
"continuous",
"loop-playlist",
"random",
]:
logger.info(
f"Advancing to next track for {self.play_mode} mode"
)
self.next_track(True)
# Reset the flag after track change
self._track_ending_handled = False
# Reset the flag if we're playing again (new track started)
elif self.is_playing and not old_playing:
self._track_ending_handled = False
# Emit updates if state changed significantly or periodically
state_changed = (
old_playing != self.is_playing
or abs(old_time - self.current_time)
> 1000 # More than 1 second difference
)
# Always emit if playing to keep frontend updated
if state_changed or self.is_playing:
self._emit_player_state()
except Exception as e:
logger.debug(f"Error syncing with VLC: {e}")
def _emit_player_state(self):
"""Emit current player state via SocketIO."""
try:
# Update state from VLC before emitting
self._sync_vlc_state_only()
# Try to use Flask context for database queries
app_to_use = self.app or current_app
if app_to_use:
with app_to_use.app_context():
state = self.get_player_state()
socketio_service.emit_to_all("player_state_update", state)
logger.info(
f"Emitted player state: playing={state['is_playing']}, time={state['current_time']}, track={state.get('current_track', {}).get('title', 'None')}"
)
else:
# Fallback when no Flask context - emit basic state without database queries
basic_state = {
"is_playing": self.is_playing,
"current_time": self.current_time,
"duration": self.duration,
"volume": self.volume,
"play_mode": self.play_mode,
"current_track": None,
"current_track_id": None,
"current_track_index": self.current_track_index,
"playlist": [],
"playlist_id": self.current_playlist_id,
}
socketio_service.emit_to_all("player_state_update", basic_state)
logger.info(
f"Emitted basic player state: playing={basic_state['is_playing']}, time={basic_state['current_time']}"
)
except Exception as e:
logger.debug(f"Error emitting player state: {e}")
def _sync_vlc_state_only(self):
"""Sync only the VLC state without auto-advance logic."""
try:
if not self.player:
return
# Get current state from VLC
state = self.player.get_state()
self.is_playing = state == vlc.State.Playing
# Get time and duration (in milliseconds)
self.current_time = self.player.get_time()
self.duration = self.player.get_length()
# Get volume
self.volume = self.player.audio_get_volume()
except Exception as e:
logger.debug(f"Error syncing VLC state: {e}")
def _load_current_playlist_on_startup(self):
"""Load the current playlist automatically on startup."""
try:
if not self.app:
logger.warning(
"No Flask app context available, skipping current playlist load"
)
return
with self.app.app_context():
# Find the current playlist
current_playlist = Playlist.find_current_playlist()
if current_playlist:
success = self.load_playlist(current_playlist.id)
if success:
logger.info(
f"Automatically loaded current playlist '{current_playlist.name}' with {len(self.playlist_files)} tracks"
)
else:
logger.warning(
"Failed to load current playlist on startup"
)
else:
logger.info("No current playlist found to load on startup")
except Exception as e:
logger.error(f"Error loading current playlist on startup: {e}")
def reload_current_playlist_if_modified(
self, modified_playlist_id: int
) -> bool:
"""Reload the current playlist if it's the one that was modified."""
try:
if not self.app:
logger.warning(
"No Flask app context available, skipping playlist reload"
)
return False
with self.app.app_context():
# Find the current playlist
current_playlist = Playlist.find_current_playlist()
if (
current_playlist
and current_playlist.id == modified_playlist_id
):
# Reload the playlist
success = self.load_playlist(current_playlist.id, True)
if success:
logger.info(
f"Reloaded current playlist '{current_playlist.name}' after modification"
)
return True
else:
logger.warning(
"Failed to reload current playlist after modification"
)
return False
else:
# Not the current playlist, no need to reload
return True
except Exception as e:
logger.error(f"Error reloading current playlist: {e}")
return False
# Global music player service instance
music_player_service = MusicPlayerService()