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: 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 = 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")
|
|
|
|
# Automatically load the main playlist
|
|
self._load_main_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) -> 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_on_startup(self):
|
|
"""Load the main playlist automatically on startup."""
|
|
try:
|
|
if not self.app:
|
|
logger.warning("No Flask app context available, skipping main playlist load")
|
|
return
|
|
|
|
with self.app.app_context():
|
|
# Find the main playlist
|
|
main_playlist = Playlist.find_main_playlist()
|
|
|
|
if main_playlist:
|
|
success = self.load_playlist(main_playlist.id)
|
|
if success:
|
|
logger.info(f"Automatically loaded main playlist '{main_playlist.name}' with {len(self.playlist_files)} tracks")
|
|
else:
|
|
logger.warning("Failed to load main playlist on startup")
|
|
else:
|
|
logger.info("No main playlist found to load on startup")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error loading main playlist on startup: {e}")
|
|
|
|
|
|
# Global music player service instance
|
|
music_player_service = MusicPlayerService()
|