- Added MusicPlayerService for managing VLC music playback with playlist support. - Implemented methods for loading playlists, controlling playback (play, pause, stop, next, previous), and managing volume and play modes. - Integrated real-time synchronization with VLC state using a background thread. - Added SocketIO event emissions for player state updates. - Enhanced logging for better debugging and tracking of player state changes. fix: Improve SocketIO service logging and event handling - Added detailed logging for SocketIO events and user authentication. - Implemented a test event handler to verify SocketIO functionality. - Enhanced error handling and logging for better traceability. chore: Update dependencies and logging configuration - Added python-vlc dependency for VLC integration. - Configured logging to show INFO and DEBUG messages for better visibility during development. - Updated main application entry point to allow unsafe Werkzeug for debugging purposes.
615 lines
22 KiB
Python
615 lines
22 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
|
|
|
|
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 = 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 = 1.0 # seconds
|
|
self.lock = threading.Lock()
|
|
self._sync_thread = None
|
|
self._stop_sync = False
|
|
|
|
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")
|
|
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) -> 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)
|
|
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 _load_playlist_with_context(self, playlist) -> 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)
|
|
|
|
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
|
|
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
|
|
|
|
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) -> 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:
|
|
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": (
|
|
f"/api/sounds/{sound.type.lower()}/thumbnails/{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": (
|
|
f"/api/sounds/{sound.type.lower()}/thumbnails/{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."""
|
|
return {
|
|
"is_playing": self.is_playing,
|
|
"current_time": self.current_time,
|
|
"duration": self.duration,
|
|
"volume": self.volume,
|
|
"play_mode": self.play_mode,
|
|
"current_track": self.get_current_track(),
|
|
"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()
|
|
|
|
# Check if track ended and handle auto-advance
|
|
if state == vlc.State.Ended and self.play_mode in [
|
|
"continuous",
|
|
"loop-playlist",
|
|
"random",
|
|
]:
|
|
self.next_track()
|
|
elif state == vlc.State.Ended and self.play_mode == "loop-one":
|
|
# Restart the same track
|
|
self.player.set_position(0)
|
|
self.play()
|
|
|
|
# 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_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_main_playlist(self) -> bool:
|
|
"""Load main playlist if available (to be called from within Flask context)."""
|
|
try:
|
|
logger.info("Attempting to load main playlist...")
|
|
main_playlist = Playlist.query.filter_by(name="Main").first()
|
|
if main_playlist:
|
|
logger.info(
|
|
f"Found main playlist with {len(main_playlist.playlist_sounds)} tracks"
|
|
)
|
|
result = self.load_playlist(main_playlist.id)
|
|
logger.info(f"Load playlist result: {result}")
|
|
if result:
|
|
logger.info(
|
|
f"Successfully loaded main playlist with {len(main_playlist.playlist_sounds)} tracks"
|
|
)
|
|
return True
|
|
else:
|
|
logger.warning("Failed to load main playlist")
|
|
return False
|
|
else:
|
|
logger.warning(
|
|
"Main playlist not found, player ready without playlist"
|
|
)
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Error loading main playlist: {e}")
|
|
return False
|
|
|
|
|
|
# Global music player service instance
|
|
music_player_service = MusicPlayerService()
|