feat: Implement playlist management routes and integrate with music player service
This commit is contained in:
@@ -38,11 +38,15 @@ class MusicPlayerService:
|
||||
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.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
|
||||
self._track_ending_handled = (
|
||||
False # Flag to prevent duplicate ending triggers
|
||||
)
|
||||
|
||||
def start_vlc_instance(self) -> bool:
|
||||
"""Start a VLC instance with Python bindings."""
|
||||
@@ -68,10 +72,10 @@ class MusicPlayerService:
|
||||
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
|
||||
|
||||
@@ -100,7 +104,7 @@ class MusicPlayerService:
|
||||
logger.error(f"Error stopping VLC instance: {e}")
|
||||
return False
|
||||
|
||||
def load_playlist(self, playlist_id: int) -> bool:
|
||||
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:
|
||||
@@ -115,7 +119,9 @@ class MusicPlayerService:
|
||||
if not playlist:
|
||||
return False
|
||||
|
||||
return self._load_playlist_with_context(playlist)
|
||||
return self._load_playlist_with_context(
|
||||
playlist, reload
|
||||
)
|
||||
else:
|
||||
# Fallback for when no Flask context is available
|
||||
logger.warning(
|
||||
@@ -126,12 +132,14 @@ class MusicPlayerService:
|
||||
logger.error(f"Error loading playlist {playlist_id}: {e}")
|
||||
return False
|
||||
|
||||
def _build_thumbnail_url(self, sound_type: str, thumbnail_filename: str) -> str:
|
||||
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('/')
|
||||
base_url = request.url_root.rstrip("/")
|
||||
else:
|
||||
# Fallback to localhost if no request context
|
||||
base_url = "http://localhost:5000"
|
||||
@@ -140,7 +148,9 @@ class MusicPlayerService:
|
||||
# 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) -> bool:
|
||||
def _load_playlist_with_context(
|
||||
self, playlist, reload: bool = False
|
||||
) -> bool:
|
||||
"""Load playlist with database context already established."""
|
||||
try:
|
||||
# Clear current playlist
|
||||
@@ -156,12 +166,32 @@ class MusicPlayerService:
|
||||
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
|
||||
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
|
||||
|
||||
# Load first track if available
|
||||
if self.playlist_files:
|
||||
self._load_track_at_index(0)
|
||||
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()
|
||||
@@ -234,7 +264,7 @@ class MusicPlayerService:
|
||||
|
||||
# Reset track ending flag when starting playback
|
||||
self._track_ending_handled = False
|
||||
|
||||
|
||||
result = self.player.play()
|
||||
if result == 0: # Success
|
||||
self.is_playing = True
|
||||
@@ -274,7 +304,7 @@ class MusicPlayerService:
|
||||
logger.error(f"Error stopping playback: {e}")
|
||||
return False
|
||||
|
||||
def next_track(self) -> bool:
|
||||
def next_track(self, force_play: bool = False) -> bool:
|
||||
"""Skip to next track."""
|
||||
try:
|
||||
if not self.playlist_files:
|
||||
@@ -297,7 +327,7 @@ class MusicPlayerService:
|
||||
return True
|
||||
|
||||
if self._load_track_at_index(next_index):
|
||||
if self.is_playing:
|
||||
if self.is_playing or force_play:
|
||||
self.play()
|
||||
self._emit_player_state()
|
||||
return True
|
||||
@@ -422,7 +452,9 @@ class MusicPlayerService:
|
||||
"artist": None, # Could be extracted from metadata
|
||||
"duration": sound.duration or 0,
|
||||
"thumbnail": (
|
||||
self._build_thumbnail_url(sound.type, sound.thumbnail)
|
||||
self._build_thumbnail_url(
|
||||
sound.type, sound.thumbnail
|
||||
)
|
||||
if sound.thumbnail
|
||||
else None
|
||||
),
|
||||
@@ -457,7 +489,9 @@ class MusicPlayerService:
|
||||
"artist": None,
|
||||
"duration": sound.duration or 0,
|
||||
"thumbnail": (
|
||||
self._build_thumbnail_url(sound.type, sound.thumbnail)
|
||||
self._build_thumbnail_url(
|
||||
sound.type, sound.thumbnail
|
||||
)
|
||||
if sound.thumbnail
|
||||
else None
|
||||
),
|
||||
@@ -471,13 +505,15 @@ class MusicPlayerService:
|
||||
|
||||
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": self.get_current_track(),
|
||||
"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,
|
||||
@@ -529,42 +565,47 @@ class MusicPlayerService:
|
||||
|
||||
# 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}")
|
||||
|
||||
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):
|
||||
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}")
|
||||
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")
|
||||
# Stop first, then reload and play
|
||||
self.player.stop()
|
||||
# Reload the current track
|
||||
if (self.current_track_index < len(self.playlist_files)):
|
||||
media = self.instance.media_new(
|
||||
self.playlist_files[self.current_track_index]
|
||||
)
|
||||
self.player.set_media(media)
|
||||
self.player.play()
|
||||
# Reset the flag after a short delay to allow for new track
|
||||
self._track_ending_handled = False
|
||||
elif self.play_mode in ["continuous", "loop-playlist", "random"]:
|
||||
logger.info(f"Advancing to next track for {self.play_mode} mode")
|
||||
self.next_track()
|
||||
# Reset the flag after track change
|
||||
self._track_ending_handled = False
|
||||
|
||||
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
|
||||
@@ -588,15 +629,17 @@ class MusicPlayerService:
|
||||
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')}")
|
||||
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 = {
|
||||
@@ -606,12 +649,15 @@ class MusicPlayerService:
|
||||
"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']}")
|
||||
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}")
|
||||
|
||||
@@ -635,30 +681,75 @@ class MusicPlayerService:
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user