diff --git a/app/__init__.py b/app/__init__.py index f494729..4b8525b 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -100,6 +100,7 @@ def create_app(): auth, main, player, + playlist, soundboard, sounds, stream, @@ -113,6 +114,7 @@ def create_app(): app.register_blueprint(sounds.bp, url_prefix="/api/sounds") app.register_blueprint(stream.bp, url_prefix="/api/stream") app.register_blueprint(player.bp, url_prefix="/api/player") + app.register_blueprint(playlist.bp, url_prefix="/api/playlists") # Shutdown services when app is torn down @app.teardown_appcontext diff --git a/app/models/playlist.py b/app/models/playlist.py index 063b975..864dc90 100644 --- a/app/models/playlist.py +++ b/app/models/playlist.py @@ -276,3 +276,14 @@ class Playlist(db.Model): db.session.commit() return new_playlist + + def save(self, commit: bool = True) -> None: + """Save changes to the playlist.""" + if commit: + db.session.commit() + + def delete(self, commit: bool = True) -> None: + """Delete the playlist.""" + db.session.delete(self) + if commit: + db.session.commit() diff --git a/app/routes/playlist.py b/app/routes/playlist.py new file mode 100644 index 0000000..7e21b49 --- /dev/null +++ b/app/routes/playlist.py @@ -0,0 +1,250 @@ +"""Playlist management routes.""" + +from flask import Blueprint, jsonify, request +from app.models.playlist import Playlist +from app.models.sound import Sound +from app.services.decorators import require_auth +from app.services.music_player_service import music_player_service +from app.services.logging_service import LoggingService + +logger = LoggingService.get_logger(__name__) + +bp = Blueprint("playlist", __name__) + + +@bp.route("/", methods=["GET"]) +@require_auth +def get_playlists(): + """Get all playlists.""" + try: + # Get system playlists and user playlists + system_playlists = Playlist.find_system_playlists() + user_playlists = [] # TODO: Add user-specific playlists when user auth is implemented + + playlists = system_playlists + user_playlists + + return jsonify({ + "playlists": [playlist.to_dict() for playlist in playlists] + }) + except Exception as e: + logger.error(f"Error getting playlists: {e}") + return jsonify({"error": "Failed to get playlists"}), 500 + + +@bp.route("/", methods=["GET"]) +@require_auth +def get_playlist(playlist_id): + """Get a specific playlist with sounds.""" + try: + playlist = Playlist.query.get_or_404(playlist_id) + return jsonify({"playlist": playlist.to_detailed_dict()}) + except Exception as e: + logger.error(f"Error getting playlist {playlist_id}: {e}") + return jsonify({"error": "Failed to get playlist"}), 500 + + +@bp.route("/", methods=["POST"]) +@require_auth +def create_playlist(): + """Create a new playlist.""" + try: + data = request.get_json() + name = data.get("name") + description = data.get("description") + genre = data.get("genre") + + if not name: + return jsonify({"error": "Playlist name is required"}), 400 + + # Check if playlist with same name already exists + existing = Playlist.find_by_name(name) + if existing: + return jsonify({"error": "Playlist with this name already exists"}), 400 + + playlist = Playlist.create_playlist( + name=name, + description=description, + genre=genre, + user_id=None, # System playlist for now + is_deletable=True + ) + + return jsonify({"playlist": playlist.to_dict()}), 201 + except Exception as e: + logger.error(f"Error creating playlist: {e}") + return jsonify({"error": "Failed to create playlist"}), 500 + + +@bp.route("/", methods=["PUT"]) +@require_auth +def update_playlist(playlist_id): + """Update a playlist.""" + try: + playlist = Playlist.query.get_or_404(playlist_id) + data = request.get_json() + + if "name" in data: + playlist.name = data["name"] + if "description" in data: + playlist.description = data["description"] + if "genre" in data: + playlist.genre = data["genre"] + + playlist.save() + + return jsonify({"playlist": playlist.to_dict()}) + except Exception as e: + logger.error(f"Error updating playlist {playlist_id}: {e}") + return jsonify({"error": "Failed to update playlist"}), 500 + + +@bp.route("/", methods=["DELETE"]) +@require_auth +def delete_playlist(playlist_id): + """Delete a playlist.""" + try: + playlist = Playlist.query.get_or_404(playlist_id) + + if not playlist.is_deletable: + return jsonify({"error": "This playlist cannot be deleted"}), 400 + + # If this is the current playlist, clear it from the player + current_playlist = Playlist.find_current_playlist() + if current_playlist and current_playlist.id == playlist_id: + # Set main playlist as current if it exists + main_playlist = Playlist.find_main_playlist() + if main_playlist: + main_playlist.set_as_current() + music_player_service.reload_current_playlist_if_modified(main_playlist.id) + + playlist.delete() + + return jsonify({"message": "Playlist deleted successfully"}) + except Exception as e: + logger.error(f"Error deleting playlist {playlist_id}: {e}") + return jsonify({"error": "Failed to delete playlist"}), 500 + + +@bp.route("//set-current", methods=["POST"]) +@require_auth +def set_current_playlist(playlist_id): + """Set a playlist as the current one.""" + try: + playlist = Playlist.query.get_or_404(playlist_id) + playlist.set_as_current() + + # Reload the playlist in the music player + music_player_service.reload_current_playlist_if_modified(playlist_id) + + return jsonify({"message": "Playlist set as current"}) + except Exception as e: + logger.error(f"Error setting current playlist {playlist_id}: {e}") + return jsonify({"error": "Failed to set current playlist"}), 500 + + +@bp.route("//sounds", methods=["POST"]) +@require_auth +def add_sound_to_playlist(playlist_id): + """Add a sound to a playlist.""" + try: + playlist = Playlist.query.get_or_404(playlist_id) + data = request.get_json() + sound_id = data.get("sound_id") + order = data.get("order") + + if not sound_id: + return jsonify({"error": "Sound ID is required"}), 400 + + # Verify sound exists + sound = Sound.query.get_or_404(sound_id) + + # Add sound to playlist + playlist_sound = playlist.add_sound(sound_id, order) + + # Reload playlist in music player if it's the current one + music_player_service.reload_current_playlist_if_modified(playlist_id) + + return jsonify({ + "message": "Sound added to playlist", + "playlist_sound": { + "sound_id": playlist_sound.sound_id, + "order": playlist_sound.order + } + }), 201 + except Exception as e: + logger.error(f"Error adding sound to playlist {playlist_id}: {e}") + return jsonify({"error": "Failed to add sound to playlist"}), 500 + + +@bp.route("//sounds/", methods=["DELETE"]) +@require_auth +def remove_sound_from_playlist(playlist_id, sound_id): + """Remove a sound from a playlist.""" + try: + playlist = Playlist.query.get_or_404(playlist_id) + + success = playlist.remove_sound(sound_id) + if not success: + return jsonify({"error": "Sound not found in playlist"}), 404 + + # Reload playlist in music player if it's the current one + music_player_service.reload_current_playlist_if_modified(playlist_id) + + return jsonify({"message": "Sound removed from playlist"}) + except Exception as e: + logger.error(f"Error removing sound from playlist {playlist_id}: {e}") + return jsonify({"error": "Failed to remove sound from playlist"}), 500 + + +@bp.route("//sounds/reorder", methods=["PUT"]) +@require_auth +def reorder_playlist_sounds(playlist_id): + """Reorder sounds in a playlist.""" + try: + playlist = Playlist.query.get_or_404(playlist_id) + data = request.get_json() + sound_orders = data.get("sound_orders", []) + + if not sound_orders: + return jsonify({"error": "Sound orders are required"}), 400 + + # Validate sound_orders format + for item in sound_orders: + if not isinstance(item, dict) or "sound_id" not in item or "order" not in item: + return jsonify({"error": "Invalid sound_orders format"}), 400 + + # Reorder sounds + playlist.reorder_sounds(sound_orders) + + # Reload playlist in music player if it's the current one + music_player_service.reload_current_playlist_if_modified(playlist_id) + + return jsonify({"message": "Playlist sounds reordered"}) + except Exception as e: + logger.error(f"Error reordering playlist sounds {playlist_id}: {e}") + return jsonify({"error": "Failed to reorder playlist sounds"}), 500 + + +@bp.route("//duplicate", methods=["POST"]) +@require_auth +def duplicate_playlist(playlist_id): + """Duplicate a playlist.""" + try: + playlist = Playlist.query.get_or_404(playlist_id) + data = request.get_json() + new_name = data.get("name") + + if not new_name: + return jsonify({"error": "New playlist name is required"}), 400 + + # Check if playlist with same name already exists + existing = Playlist.find_by_name(new_name) + if existing: + return jsonify({"error": "Playlist with this name already exists"}), 400 + + new_playlist = playlist.duplicate(new_name) + + return jsonify({"playlist": new_playlist.to_dict()}), 201 + except Exception as e: + logger.error(f"Error duplicating playlist {playlist_id}: {e}") + return jsonify({"error": "Failed to duplicate playlist"}), 500 \ No newline at end of file diff --git a/app/services/music_player_service.py b/app/services/music_player_service.py index 1a29e63..313b14d 100644 --- a/app/services/music_player_service.py +++ b/app/services/music_player_service.py @@ -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() diff --git a/app/services/stream_processing_service.py b/app/services/stream_processing_service.py index 9344e6b..db38605 100644 --- a/app/services/stream_processing_service.py +++ b/app/services/stream_processing_service.py @@ -538,6 +538,7 @@ class StreamProcessingService: """Add a sound to the main playlist.""" try: from app.models.playlist import Playlist + from app.services.music_player_service import music_player_service # Find the main playlist main_playlist = Playlist.find_main_playlist() @@ -546,6 +547,9 @@ class StreamProcessingService: # Add sound to the main playlist main_playlist.add_sound(sound.id, commit=True) logger.info(f"Added sound {sound.id} to main playlist") + + # Reload the playlist in music player if it's the current one + music_player_service.reload_current_playlist_if_modified(main_playlist.id) else: logger.warning("Main playlist not found - sound not added to any playlist")