Compare commits

...

5 Commits

13 changed files with 142 additions and 1146 deletions

View File

@@ -100,7 +100,6 @@ def create_app():
auth, auth,
main, main,
player, player,
playlist,
soundboard, soundboard,
sounds, sounds,
stream, stream,
@@ -114,7 +113,6 @@ def create_app():
app.register_blueprint(sounds.bp, url_prefix="/api/sounds") app.register_blueprint(sounds.bp, url_prefix="/api/sounds")
app.register_blueprint(stream.bp, url_prefix="/api/stream") app.register_blueprint(stream.bp, url_prefix="/api/stream")
app.register_blueprint(player.bp, url_prefix="/api/player") 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 # Shutdown services when app is torn down
@app.teardown_appcontext @app.teardown_appcontext

View File

@@ -81,19 +81,6 @@ class Playlist(db.Model):
), ),
} }
def to_detailed_dict(self) -> dict:
"""Convert playlist to detailed dictionary with sounds."""
playlist_dict = self.to_dict()
playlist_dict["sounds"] = [
{
"sound": ps.sound.to_dict() if ps.sound else None,
"order": ps.order,
"added_at": ps.added_at.isoformat() if ps.added_at else None,
}
for ps in sorted(self.playlist_sounds, key=lambda x: x.order)
]
return playlist_dict
@classmethod @classmethod
def create_playlist( def create_playlist(
cls, cls,
@@ -123,26 +110,6 @@ class Playlist(db.Model):
return playlist return playlist
@classmethod
def find_by_name(
cls, name: str, user_id: Optional[int] = None
) -> Optional["Playlist"]:
"""Find playlist by name, optionally filtered by user."""
query = cls.query.filter_by(name=name)
if user_id is not None:
query = query.filter_by(user_id=user_id)
return query.first()
@classmethod
def find_by_user(cls, user_id: int) -> list["Playlist"]:
"""Find all playlists for a user."""
return cls.query.filter_by(user_id=user_id).order_by(cls.name).all()
@classmethod
def find_system_playlists(cls) -> list["Playlist"]:
"""Find all system playlists (user_id is None)."""
return cls.query.filter_by(user_id=None).order_by(cls.name).all()
@classmethod @classmethod
def find_current_playlist( def find_current_playlist(
cls, user_id: Optional[int] = None cls, user_id: Optional[int] = None
@@ -163,22 +130,6 @@ class Playlist(db.Model):
query = query.filter_by(user_id=user_id) query = query.filter_by(user_id=user_id)
return query.first() return query.first()
def set_as_current(self, commit: bool = True) -> None:
"""Set this playlist as the current one and unset others."""
# Unset other current playlists for the same user/system
if self.user_id is not None:
Playlist.query.filter_by(
user_id=self.user_id, is_current=True
).update({"is_current": False})
else:
Playlist.query.filter_by(user_id=None, is_current=True).update(
{"is_current": False}
)
self.is_current = True
if commit:
db.session.commit()
def add_sound( def add_sound(
self, sound_id: int, order: Optional[int] = None, commit: bool = True self, sound_id: int, order: Optional[int] = None, commit: bool = True
) -> "PlaylistSound": ) -> "PlaylistSound":
@@ -203,87 +154,3 @@ class Playlist(db.Model):
db.session.commit() db.session.commit()
return playlist_sound return playlist_sound
def remove_sound(self, sound_id: int, commit: bool = True) -> bool:
"""Remove a sound from the playlist."""
from app.models.playlist_sound import PlaylistSound
playlist_sound = PlaylistSound.query.filter_by(
playlist_id=self.id, sound_id=sound_id
).first()
if playlist_sound:
db.session.delete(playlist_sound)
if commit:
db.session.commit()
return True
return False
def reorder_sounds(
self, sound_orders: list[dict], commit: bool = True
) -> None:
"""Reorder sounds in the playlist.
Args:
sound_orders: List of dicts with 'sound_id' and 'order' keys
"""
from app.models.playlist_sound import PlaylistSound
for item in sound_orders:
playlist_sound = PlaylistSound.query.filter_by(
playlist_id=self.id, sound_id=item["sound_id"]
).first()
if playlist_sound:
playlist_sound.order = item["order"]
if commit:
db.session.commit()
def get_total_duration(self) -> int:
"""Get total duration of all sounds in the playlist in milliseconds."""
from app.models.sound import Sound
total = (
db.session.query(db.func.sum(Sound.duration))
.join(self.playlist_sounds)
.filter(Sound.id.in_([ps.sound_id for ps in self.playlist_sounds]))
.scalar()
)
return total or 0
def duplicate(
self, new_name: str, user_id: Optional[int] = None, commit: bool = True
) -> "Playlist":
"""Create a duplicate of this playlist."""
new_playlist = Playlist.create_playlist(
name=new_name,
description=self.description,
genre=self.genre,
user_id=user_id,
is_main=False,
is_deletable=True,
is_current=False,
commit=commit,
)
# Copy all sounds with their order
for ps in self.playlist_sounds:
new_playlist.add_sound(ps.sound_id, ps.order, commit=False)
if commit:
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()

View File

@@ -63,126 +63,3 @@ class PlaylistSound(db.Model):
"added_at": self.added_at.isoformat() if self.added_at else None, "added_at": self.added_at.isoformat() if self.added_at else None,
"sound": self.sound.to_dict() if self.sound else None, "sound": self.sound.to_dict() if self.sound else None,
} }
@classmethod
def create_playlist_sound(
cls,
playlist_id: int,
sound_id: int,
order: int,
commit: bool = True,
) -> "PlaylistSound":
"""Create a new playlist-sound relationship."""
playlist_sound = cls(
playlist_id=playlist_id,
sound_id=sound_id,
order=order,
)
db.session.add(playlist_sound)
if commit:
db.session.commit()
return playlist_sound
@classmethod
def find_by_playlist(cls, playlist_id: int) -> list["PlaylistSound"]:
"""Find all sounds in a playlist ordered by their position."""
return (
cls.query.filter_by(playlist_id=playlist_id)
.order_by(cls.order)
.all()
)
@classmethod
def find_by_sound(cls, sound_id: int) -> list["PlaylistSound"]:
"""Find all playlists containing a specific sound."""
return cls.query.filter_by(sound_id=sound_id).all()
@classmethod
def find_by_playlist_and_sound(
cls, playlist_id: int, sound_id: int
) -> Optional["PlaylistSound"]:
"""Find a specific playlist-sound relationship."""
return cls.query.filter_by(
playlist_id=playlist_id, sound_id=sound_id
).first()
@classmethod
def get_next_order(cls, playlist_id: int) -> int:
"""Get the next order number for a playlist."""
max_order = (
db.session.query(db.func.max(cls.order))
.filter_by(playlist_id=playlist_id)
.scalar()
)
return (max_order or 0) + 1
@classmethod
def reorder_playlist(
cls, playlist_id: int, sound_orders: list[dict], commit: bool = True
) -> None:
"""Reorder all sounds in a playlist.
Args:
playlist_id: ID of the playlist
sound_orders: List of dicts with 'sound_id' and 'order' keys
"""
for item in sound_orders:
playlist_sound = cls.query.filter_by(
playlist_id=playlist_id, sound_id=item["sound_id"]
).first()
if playlist_sound:
playlist_sound.order = item["order"]
if commit:
db.session.commit()
def move_to_position(self, new_order: int, commit: bool = True) -> None:
"""Move this sound to a new position in the playlist."""
old_order = self.order
if new_order == old_order:
return
# Get all other sounds in the playlist
other_sounds = (
PlaylistSound.query.filter_by(playlist_id=self.playlist_id)
.filter(PlaylistSound.id != self.id)
.order_by(PlaylistSound.order)
.all()
)
# Remove this sound from its current position
remaining_sounds = [ps for ps in other_sounds if ps.order != old_order]
# Insert at new position
if new_order <= len(remaining_sounds):
remaining_sounds.insert(new_order - 1, self)
else:
remaining_sounds.append(self)
# Update all order values
for i, ps in enumerate(remaining_sounds, 1):
ps.order = i
if commit:
db.session.commit()
def get_previous_sound(self) -> Optional["PlaylistSound"]:
"""Get the previous sound in the playlist."""
return (
PlaylistSound.query.filter_by(playlist_id=self.playlist_id)
.filter(PlaylistSound.order < self.order)
.order_by(PlaylistSound.order.desc())
.first()
)
def get_next_sound(self) -> Optional["PlaylistSound"]:
"""Get the next sound in the playlist."""
return (
PlaylistSound.query.filter_by(playlist_id=self.playlist_id)
.filter(PlaylistSound.order > self.order)
.order_by(PlaylistSound.order.asc())
.first()
)

View File

@@ -197,21 +197,6 @@ class Sound(db.Model):
"""Find all sounds by type.""" """Find all sounds by type."""
return cls.query.filter_by(type=sound_type).all() return cls.query.filter_by(type=sound_type).all()
@classmethod
def get_most_played(cls, limit: int = 10) -> list["Sound"]:
"""Get the most played sounds."""
return cls.query.order_by(cls.play_count.desc()).limit(limit).all()
@classmethod
def get_music_sounds(cls) -> list["Sound"]:
"""Get all music sounds."""
return cls.query.filter_by(is_music=True).all()
@classmethod
def get_deletable_sounds(cls) -> list["Sound"]:
"""Get all deletable sounds."""
return cls.query.filter_by(is_deletable=True).all()
@classmethod @classmethod
def create_sound( def create_sound(
cls, cls,

View File

@@ -77,7 +77,7 @@ class SoundPlayed(db.Model):
@classmethod @classmethod
def create_play_record( def create_play_record(
cls, cls,
user_id: int, user_id: int | None,
sound_id: int, sound_id: int,
*, *,
commit: bool = True, commit: bool = True,
@@ -92,173 +92,3 @@ class SoundPlayed(db.Model):
if commit: if commit:
db.session.commit() db.session.commit()
return play_record return play_record
@classmethod
def get_user_plays(
cls,
user_id: int,
limit: int = 50,
offset: int = 0,
) -> list["SoundPlayed"]:
"""Get recent plays for a specific user."""
return (
cls.query.filter_by(user_id=user_id)
.order_by(cls.played_at.desc())
.offset(offset)
.limit(limit)
.all()
)
@classmethod
def get_sound_plays(
cls,
sound_id: int,
limit: int = 50,
offset: int = 0,
) -> list["SoundPlayed"]:
"""Get recent plays for a specific sound."""
return (
cls.query.filter_by(sound_id=sound_id)
.order_by(cls.played_at.desc())
.offset(offset)
.limit(limit)
.all()
)
@classmethod
def get_recent_plays(
cls,
limit: int = 100,
offset: int = 0,
) -> list["SoundPlayed"]:
"""Get recent plays across all users and sounds."""
return (
cls.query.order_by(cls.played_at.desc())
.offset(offset)
.limit(limit)
.all()
)
@classmethod
def get_user_play_count(cls, user_id: int) -> int:
"""Get total play count for a user."""
return cls.query.filter_by(user_id=user_id).count()
@classmethod
def get_sound_play_count(cls, sound_id: int) -> int:
"""Get total play count for a sound."""
return cls.query.filter_by(sound_id=sound_id).count()
@classmethod
def get_popular_sounds(
cls,
limit: int = 10,
days: int | None = None,
) -> list[dict]:
"""Get most popular sounds with play counts."""
from app.models.sound import Sound
query = (
db.session.query(
cls.sound_id,
func.count(cls.id).label("play_count"),
func.max(cls.played_at).label("last_played"),
)
.group_by(cls.sound_id)
.order_by(func.count(cls.id).desc())
)
if days:
query = query.filter(
cls.played_at >= text(f"datetime('now', '-{days} days')"),
)
results = query.limit(limit).all()
# Get sound details
popular_sounds = []
for result in results:
sound = Sound.query.get(result.sound_id)
if sound:
popular_sounds.append(
{
"sound": sound.to_dict(),
"play_count": result.play_count,
"last_played": (
result.last_played.isoformat()
if result.last_played
else None
),
},
)
return popular_sounds
@classmethod
def get_user_stats(cls, user_id: int) -> dict:
"""Get comprehensive stats for a user."""
from app.models.sound import Sound
total_plays = cls.query.filter_by(user_id=user_id).count()
if total_plays == 0:
return {
"total_plays": 0,
"unique_sounds": 0,
"favorite_sound": None,
"first_play": None,
"last_play": None,
}
# Get unique sounds count
unique_sounds = (
db.session.query(cls.sound_id)
.filter_by(user_id=user_id)
.distinct()
.count()
)
# Get favorite sound
favorite_query = (
db.session.query(
cls.sound_id,
func.count(cls.id).label("play_count"),
)
.filter_by(user_id=user_id)
.group_by(cls.sound_id)
.order_by(func.count(cls.id).desc())
.first()
)
favorite_sound = None
if favorite_query:
sound = Sound.query.get(favorite_query.sound_id)
if sound:
favorite_sound = {
"sound": sound.to_dict(),
"play_count": favorite_query.play_count,
}
# Get first and last play dates
first_play = (
cls.query.filter_by(user_id=user_id)
.order_by(cls.played_at.asc())
.first()
)
last_play = (
cls.query.filter_by(user_id=user_id)
.order_by(cls.played_at.desc())
.first()
)
return {
"total_plays": total_plays,
"unique_sounds": unique_sounds,
"favorite_sound": favorite_sound,
"first_play": (
first_play.played_at.isoformat() if first_play else None
),
"last_play": (
last_play.played_at.isoformat() if last_play else None
),
}

View File

@@ -4,7 +4,14 @@ from datetime import datetime
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint from sqlalchemy import (
DateTime,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import db from app.database import db
@@ -51,9 +58,7 @@ class Stream(db.Model):
# Constraints # Constraints
__table_args__ = ( __table_args__ = (
UniqueConstraint( UniqueConstraint("service", "service_id", name="unique_service_stream"),
"service", "service_id", name="unique_service_stream"
),
) )
def __repr__(self) -> str: def __repr__(self) -> str:
@@ -117,70 +122,3 @@ class Stream(db.Model):
db.session.commit() db.session.commit()
return stream return stream
@classmethod
def find_by_service_and_id(
cls, service: str, service_id: str
) -> Optional["Stream"]:
"""Find stream by service and service_id."""
return cls.query.filter_by(
service=service, service_id=service_id
).first()
@classmethod
def find_by_sound(cls, sound_id: int) -> list["Stream"]:
"""Find all streams for a specific sound."""
return cls.query.filter_by(sound_id=sound_id).all()
@classmethod
def find_by_service(cls, service: str) -> list["Stream"]:
"""Find all streams for a specific service."""
return cls.query.filter_by(service=service).all()
@classmethod
def find_by_status(cls, status: str) -> list["Stream"]:
"""Find all streams with a specific status."""
return cls.query.filter_by(status=status).all()
@classmethod
def find_active_streams(cls) -> list["Stream"]:
"""Find all active streams."""
return cls.query.filter_by(status="active").all()
def update_metadata(
self,
title: Optional[str] = None,
track: Optional[str] = None,
artist: Optional[str] = None,
album: Optional[str] = None,
genre: Optional[str] = None,
commit: bool = True,
) -> None:
"""Update stream metadata."""
if title is not None:
self.title = title
if track is not None:
self.track = track
if artist is not None:
self.artist = artist
if album is not None:
self.album = album
if genre is not None:
self.genre = genre
if commit:
db.session.commit()
def set_status(self, status: str, commit: bool = True) -> None:
"""Update stream status."""
self.status = status
if commit:
db.session.commit()
def is_active(self) -> bool:
"""Check if stream is active."""
return self.status == "active"
def get_display_name(self) -> str:
"""Get a display name for the stream (title or track or service_id)."""
return self.title or self.track or self.service_id

View File

@@ -82,53 +82,3 @@ def check_ffmpeg():
return jsonify(ffmpeg_status), 200 return jsonify(ffmpeg_status), 200
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@bp.route("/list", methods=["GET"])
@require_admin
def list_sounds():
"""Get detailed list of all sounds with normalization status."""
from app.services.sound_management_service import SoundManagementService
return ErrorHandlingService.wrap_service_call(
SoundManagementService.get_sounds_with_file_status,
request.args.get("type", "SDB"),
int(request.args.get("page", 1)),
int(request.args.get("per_page", 50)),
)
@bp.route("/<int:sound_id>", methods=["DELETE"])
@require_admin
def delete_sound(sound_id: int):
"""Delete a sound and its files."""
from app.services.sound_management_service import SoundManagementService
return ErrorHandlingService.wrap_service_call(
SoundManagementService.delete_sound_with_files,
sound_id,
)
@bp.route("/<int:sound_id>/normalize", methods=["POST"])
@require_admin
def normalize_single_sound(sound_id: int):
"""Normalize a specific sound."""
try:
from app.services.sound_management_service import SoundManagementService
data = request.get_json() or {}
overwrite = data.get("overwrite", False)
two_pass = data.get("two_pass", True)
result = SoundManagementService.normalize_sound(
sound_id,
overwrite,
two_pass,
)
if result["success"]:
return jsonify(result), 200
return jsonify(result), 400
except Exception as e:
return jsonify({"error": str(e)}), 500

View File

@@ -2,71 +2,10 @@
from flask import Blueprint from flask import Blueprint
from app.services.decorators import (
get_current_user,
require_auth,
require_credits,
)
bp = Blueprint("main", __name__) bp = Blueprint("main", __name__)
@bp.route("/")
def index() -> dict[str, str]:
"""Root endpoint that returns API status."""
return {"message": "API is running", "status": "ok"}
@bp.route("/protected")
@require_auth
def protected() -> dict[str, str]:
"""Protected endpoint that requires authentication."""
user = get_current_user()
return {
"message": f"Hello {user['name']}, this is a protected endpoint!",
"user": user,
}
@bp.route("/api-protected")
@require_auth
def api_protected() -> dict[str, str]:
"""Protected endpoint that accepts JWT or API token authentication."""
user = get_current_user()
return {
"message": f"Hello {user['name']}, you accessed this via {user['provider']}!",
"user": user,
}
@bp.route("/health") @bp.route("/health")
def health() -> dict[str, str]: def health() -> dict[str, str]:
"""Health check endpoint.""" """Health check endpoint."""
return {"status": "ok"} return {"status": "ok"}
@bp.route("/use-credits/<int:amount>")
@require_auth
@require_credits(5)
def use_credits(amount: int) -> dict[str, str]:
"""Test endpoint that costs 5 credits to use."""
user = get_current_user()
return {
"message": f"Successfully used endpoint! You requested amount: {amount}",
"user": user["email"],
"remaining_credits": user["credits"]
- 5, # Note: credits already deducted by decorator
}
@bp.route("/expensive-operation")
@require_auth
@require_credits(10)
def expensive_operation() -> dict[str, str]:
"""Test endpoint that costs 10 credits to use."""
user = get_current_user()
return {
"message": "Expensive operation completed successfully!",
"user": user["email"],
"operation_cost": 10,
}

View File

@@ -93,11 +93,14 @@ def seek():
data = request.get_json() data = request.get_json()
if not data or "position" not in data: if not data or "position" not in data:
return jsonify({"error": "Position required"}), 400 return jsonify({"error": "Position required"}), 400
position = float(data["position"]) position = float(data["position"])
if not 0.0 <= position <= 1.0: if not 0.0 <= position <= 1.0:
return jsonify({"error": "Position must be between 0.0 and 1.0"}), 400 return (
jsonify({"error": "Position must be between 0.0 and 1.0"}),
400,
)
success = music_player_service.seek(position) success = music_player_service.seek(position)
if success: if success:
return jsonify({"message": "Seek successful"}), 200 return jsonify({"message": "Seek successful"}), 200
@@ -116,11 +119,11 @@ def set_volume():
data = request.get_json() data = request.get_json()
if not data or "volume" not in data: if not data or "volume" not in data:
return jsonify({"error": "Volume required"}), 400 return jsonify({"error": "Volume required"}), 400
volume = int(data["volume"]) volume = int(data["volume"])
if not 0 <= volume <= 100: if not 0 <= volume <= 100:
return jsonify({"error": "Volume must be between 0 and 100"}), 400 return jsonify({"error": "Volume must be between 0 and 100"}), 400
success = music_player_service.set_volume(volume) success = music_player_service.set_volume(volume)
if success: if success:
return jsonify({"message": "Volume set successfully"}), 200 return jsonify({"message": "Volume set successfully"}), 200
@@ -139,12 +142,23 @@ def set_play_mode():
data = request.get_json() data = request.get_json()
if not data or "mode" not in data: if not data or "mode" not in data:
return jsonify({"error": "Mode required"}), 400 return jsonify({"error": "Mode required"}), 400
mode = data["mode"] mode = data["mode"]
valid_modes = ["continuous", "loop-playlist", "loop-one", "random"] valid_modes = [
"continuous",
"loop-playlist",
"loop-one",
"random",
"single",
]
if mode not in valid_modes: if mode not in valid_modes:
return jsonify({"error": f"Mode must be one of: {', '.join(valid_modes)}"}), 400 return (
jsonify(
{"error": f"Mode must be one of: {', '.join(valid_modes)}"}
),
400,
)
success = music_player_service.set_play_mode(mode) success = music_player_service.set_play_mode(mode)
if success: if success:
return jsonify({"message": "Play mode set successfully"}), 200 return jsonify({"message": "Play mode set successfully"}), 200
@@ -161,7 +175,7 @@ def load_playlist():
data = request.get_json() data = request.get_json()
if not data or "playlist_id" not in data: if not data or "playlist_id" not in data:
return jsonify({"error": "Playlist ID required"}), 400 return jsonify({"error": "Playlist ID required"}), 400
playlist_id = int(data["playlist_id"]) playlist_id = int(data["playlist_id"])
success = music_player_service.load_playlist(playlist_id) success = music_player_service.load_playlist(playlist_id)
if success: if success:
@@ -181,7 +195,7 @@ def play_track():
data = request.get_json() data = request.get_json()
if not data or "index" not in data: if not data or "index" not in data:
return jsonify({"error": "Track index required"}), 400 return jsonify({"error": "Track index required"}), 400
index = int(data["index"]) index = int(data["index"])
success = music_player_service.play_track_at_index(index) success = music_player_service.play_track_at_index(index)
if success: if success:
@@ -191,42 +205,3 @@ def play_track():
return jsonify({"error": "Invalid track index"}), 400 return jsonify({"error": "Invalid track index"}), 400
except Exception as e: except Exception as e:
return ErrorHandlingService.handle_generic_error(e) return ErrorHandlingService.handle_generic_error(e)
@bp.route("/start-instance", methods=["POST"])
@require_auth
def start_vlc_instance():
"""Start the VLC player instance."""
try:
success = music_player_service.start_vlc_instance()
if success:
return jsonify({"message": "VLC instance started successfully"}), 200
return jsonify({"error": "Failed to start VLC instance"}), 500
except Exception as e:
return ErrorHandlingService.handle_generic_error(e)
@bp.route("/stop-instance", methods=["POST"])
@require_auth
def stop_vlc_instance():
"""Stop the VLC player instance."""
try:
success = music_player_service.stop_vlc_instance()
if success:
return jsonify({"message": "VLC instance stopped successfully"}), 200
return jsonify({"error": "Failed to stop VLC instance"}), 500
except Exception as e:
return ErrorHandlingService.handle_generic_error(e)
@bp.route("/test-emit", methods=["POST"])
@require_auth
def test_emit():
"""Test SocketIO emission manually."""
try:
# Force emit player state
music_player_service._emit_player_state()
return jsonify({"message": "Test emission sent"}), 200
except Exception as e:
return ErrorHandlingService.handle_generic_error(e)

View File

@@ -1,250 +0,0 @@
"""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("/<int:playlist_id>", 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("/<int:playlist_id>", 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("/<int:playlist_id>", 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("/<int:playlist_id>/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("/<int:playlist_id>/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("/<int:playlist_id>/sounds/<int:sound_id>", 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("/<int:playlist_id>/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("/<int:playlist_id>/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

View File

@@ -140,99 +140,3 @@ def get_status():
) )
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@bp.route("/history", methods=["GET"])
@require_auth
def get_play_history():
"""Get recent play history."""
try:
page = int(request.args.get("page", 1))
per_page = min(int(request.args.get("per_page", 50)), 100)
offset = (page - 1) * per_page
recent_plays = SoundPlayed.get_recent_plays(
limit=per_page,
offset=offset,
)
return jsonify(
{
"plays": [play.to_dict() for play in recent_plays],
"page": page,
"per_page": per_page,
},
)
except Exception as e:
return jsonify({"error": str(e)}), 500
@bp.route("/my-history", methods=["GET"])
@require_auth
def get_my_play_history():
"""Get current user's play history."""
try:
user = get_current_user()
if not user:
return jsonify({"error": "User not found"}), 404
user_id = int(user["id"])
page = int(request.args.get("page", 1))
per_page = min(int(request.args.get("per_page", 50)), 100)
offset = (page - 1) * per_page
user_plays = SoundPlayed.get_user_plays(
user_id=user_id,
limit=per_page,
offset=offset,
)
return jsonify(
{
"plays": [play.to_dict() for play in user_plays],
"page": page,
"per_page": per_page,
"user_id": user_id,
},
)
except Exception as e:
return jsonify({"error": str(e)}), 500
@bp.route("/my-stats", methods=["GET"])
@require_auth
def get_my_stats():
"""Get current user's play statistics."""
try:
user = get_current_user()
if not user:
return jsonify({"error": "User not found"}), 404
user_id = int(user["id"])
stats = SoundPlayed.get_user_stats(user_id)
return jsonify(stats)
except Exception as e:
return jsonify({"error": str(e)}), 500
@bp.route("/popular", methods=["GET"])
@require_auth
def get_popular_sounds():
"""Get most popular sounds."""
try:
limit = min(int(request.args.get("limit", 10)), 50)
days = request.args.get("days")
days = int(days) if days and days.isdigit() else None
popular_sounds = SoundPlayed.get_popular_sounds(limit=limit, days=days)
return jsonify(
{
"popular_sounds": popular_sounds,
"limit": limit,
"days": days,
},
)
except Exception as e:
return jsonify({"error": str(e)}), 500

View File

@@ -90,121 +90,3 @@ def add_url():
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@bp.route("/", methods=["GET"])
@require_auth
def list_streams():
"""List all streams with optional filtering."""
try:
status = request.args.get("status")
service = request.args.get("service")
query = Stream.query
if status:
query = query.filter_by(status=status)
if service:
query = query.filter_by(service=service)
streams = query.order_by(Stream.created_at.desc()).all()
return (
jsonify({"streams": [stream.to_dict() for stream in streams]}),
200,
)
except Exception as e:
return jsonify({"error": str(e)}), 500
@bp.route("/<int:stream_id>", methods=["GET"])
@require_auth
def get_stream(stream_id):
"""Get a specific stream by ID."""
try:
stream = Stream.query.get_or_404(stream_id)
return jsonify({"stream": stream.to_dict()}), 200
except Exception as e:
return jsonify({"error": str(e)}), 500
@bp.route("/<int:stream_id>", methods=["PUT"])
@require_auth
def update_stream(stream_id):
"""Update stream metadata."""
try:
stream = Stream.query.get_or_404(stream_id)
data = request.get_json()
if not data:
return jsonify({"error": "No data provided"}), 400
# Update allowed fields
updatable_fields = [
"title",
"track",
"artist",
"album",
"genre",
"status",
]
for field in updatable_fields:
if field in data:
setattr(stream, field, data[field])
db.session.commit()
return (
jsonify(
{
"message": "Stream updated successfully",
"stream": stream.to_dict(),
}
),
200,
)
except Exception as e:
db.session.rollback()
return jsonify({"error": str(e)}), 500
@bp.route("/<int:stream_id>", methods=["DELETE"])
@require_auth
def delete_stream(stream_id):
"""Delete a stream."""
try:
stream = Stream.query.get_or_404(stream_id)
# If stream is being processed, mark for deletion instead
if stream.status == "processing":
stream.status = "cancelled"
db.session.commit()
return jsonify({"message": "Stream marked for cancellation"}), 200
db.session.delete(stream)
db.session.commit()
return jsonify({"message": "Stream deleted successfully"}), 200
except Exception as e:
db.session.rollback()
return jsonify({"error": str(e)}), 500
@bp.route("/queue/status", methods=["GET"])
@require_auth
def queue_status():
"""Get the current processing queue status."""
try:
from app.services.stream_processing_service import (
StreamProcessingService,
)
status = StreamProcessingService.get_queue_status()
return jsonify(status), 200
except Exception as e:
return jsonify({"error": str(e)}), 500

View File

@@ -3,18 +3,28 @@
import os import os
import threading import threading
import time import time
from datetime import datetime
from typing import Any, Optional from typing import Any, Optional
from zoneinfo import ZoneInfo
import vlc import vlc
from flask import current_app, request from flask import current_app, request
from app.models.playlist import Playlist from app.models.playlist import Playlist
from app.models.sound import Sound from app.models.sound import Sound
from app.models.sound_played import SoundPlayed
from app.services.logging_service import LoggingService from app.services.logging_service import LoggingService
from app.services.socketio_service import socketio_service from app.services.socketio_service import socketio_service
logger = LoggingService.get_logger(__name__) logger = LoggingService.get_logger(__name__)
# Constants
TRACK_START_THRESHOLD_MS = 500 # 500 milliseconds - threshold for considering a track as "starting fresh"
STATE_CHANGE_THRESHOLD_MS = (
1000 # 1 second threshold for state change detection
)
PLAY_COMPLETION_THRESHOLD = 0.20 # 20% completion threshold to count as a play
class MusicPlayerService: class MusicPlayerService:
"""Service for managing a VLC music player with playlist support.""" """Service for managing a VLC music player with playlist support."""
@@ -32,7 +42,7 @@ class MusicPlayerService:
) # Store file paths for manual playlist management ) # Store file paths for manual playlist management
self.volume = 80 self.volume = 80
self.play_mode = ( self.play_mode = (
"continuous" # continuous, loop-playlist, loop-one, random "continuous" # single, continuous, loop-playlist, loop-one, random
) )
self.is_playing = False self.is_playing = False
self.current_time = 0 self.current_time = 0
@@ -47,6 +57,15 @@ class MusicPlayerService:
self._track_ending_handled = ( self._track_ending_handled = (
False # Flag to prevent duplicate ending triggers False # Flag to prevent duplicate ending triggers
) )
self._track_play_tracked = (
False # Flag to track if current track play has been logged
)
self._cumulative_play_time = (
0 # Cumulative time actually played for current track
)
self._last_position_update = (
0 # Last position for calculating continuous play time
)
def start_vlc_instance(self) -> bool: def start_vlc_instance(self) -> bool:
"""Start a VLC instance with Python bindings.""" """Start a VLC instance with Python bindings."""
@@ -216,12 +235,44 @@ class MusicPlayerService:
self.current_track_index = index self.current_track_index = index
# Reset track ending flag when loading a new track # Reset track ending flag when loading a new track
self._track_ending_handled = False self._track_ending_handled = False
self._track_play_tracked = (
False # Reset play tracking for new track
)
# Reset cumulative play time tracking for new track
self._cumulative_play_time = 0
self._last_position_update = 0
return True return True
return False return False
except Exception as e: except Exception as e:
logger.error(f"Error loading track at index {index}: {e}") logger.error(f"Error loading track at index {index}: {e}")
return False return False
def _track_sound_play(self, sound_id: int) -> None:
"""Track that a sound has been played."""
try:
# Use stored app instance or current_app
app_to_use = self.app or current_app
if app_to_use:
with app_to_use.app_context():
# Get the sound and increment its play count
sound = Sound.query.get(sound_id)
if sound:
sound.play_count += 1
sound.updated_at = datetime.now(tz=ZoneInfo("UTC"))
logger.info(
f"Incremented play count for sound '{sound.name}' (ID: {sound_id})"
)
# Create a sound played record without user_id (anonymous play)
SoundPlayed.create_play_record(
user_id=None, sound_id=sound_id, commit=True
)
logger.info(
f"Created anonymous play record for sound ID: {sound_id}"
)
except Exception as e:
logger.error(f"Error tracking sound play for sound {sound_id}: {e}")
def _get_sound_file_path(self, sound: Sound) -> Optional[str]: def _get_sound_file_path(self, sound: Sound) -> Optional[str]:
"""Get the file path for a sound, preferring normalized version.""" """Get the file path for a sound, preferring normalized version."""
try: try:
@@ -268,6 +319,9 @@ class MusicPlayerService:
result = self.player.play() result = self.player.play()
if result == 0: # Success if result == 0: # Success
self.is_playing = True self.is_playing = True
self._track_play_tracked = (
False # Track when we first start playing
)
self._emit_player_state() self._emit_player_state()
return True return True
return False return False
@@ -399,7 +453,13 @@ class MusicPlayerService:
def set_play_mode(self, mode: str) -> bool: def set_play_mode(self, mode: str) -> bool:
"""Set play mode.""" """Set play mode."""
try: try:
if mode in ["continuous", "loop-playlist", "loop-one", "random"]: if mode in [
"continuous",
"loop-playlist",
"loop-one",
"random",
"single",
]:
self.play_mode = mode self.play_mode = mode
self._emit_player_state() self._emit_player_state()
return True return True
@@ -430,9 +490,10 @@ class MusicPlayerService:
if not self.current_playlist_id: if not self.current_playlist_id:
return None return None
# Ensure we have Flask app context # Use stored app instance or current_app
if current_app: app_to_use = self.app or current_app
with current_app.app_context(): if app_to_use:
with app_to_use.app_context():
playlist = Playlist.query.get(self.current_playlist_id) playlist = Playlist.query.get(self.current_playlist_id)
if playlist and 0 <= self.current_track_index < len( if playlist and 0 <= self.current_track_index < len(
playlist.playlist_sounds playlist.playlist_sounds
@@ -593,6 +654,11 @@ class MusicPlayerService:
if self.play_mode == "loop-one": if self.play_mode == "loop-one":
logger.info("Restarting track for loop-one mode") logger.info("Restarting track for loop-one mode")
self.play_track_at_index(self.current_track_index) self.play_track_at_index(self.current_track_index)
elif self.play_mode == "single":
logger.info(
"Track ended in single mode - stopping playback"
)
self.stop()
elif self.play_mode in [ elif self.play_mode in [
"continuous", "continuous",
"loop-playlist", "loop-playlist",
@@ -610,11 +676,46 @@ class MusicPlayerService:
elif self.is_playing and not old_playing: elif self.is_playing and not old_playing:
self._track_ending_handled = False self._track_ending_handled = False
# Update cumulative play time for continuous listening tracking
if self.is_playing and old_playing and self.current_time > 0:
# Calculate time elapsed since last update (but cap it to prevent huge jumps from seeking)
if self._last_position_update > 0:
time_diff = self.current_time - self._last_position_update
# Only add time if it's a reasonable progression (not a big jump from seeking)
if (
0 <= time_diff <= (self.sync_interval * 1000 * 2)
): # Max 2x sync interval
self._cumulative_play_time += time_diff
self._last_position_update = self.current_time
elif self.is_playing and not old_playing:
# Just started playing, initialize position tracking
self._last_position_update = (
self.current_time if self.current_time > 0 else 0
)
# Track play event when cumulative listening reaches 20% of track duration
if (
self.is_playing
and not self._track_play_tracked
and self.duration > 0
and self._cumulative_play_time
>= (self.duration * PLAY_COMPLETION_THRESHOLD)
):
current_track = self.get_current_track()
if current_track:
self._track_sound_play(current_track["id"])
self._track_play_tracked = True
logger.info(
f"Tracked play for '{current_track['title']}' after {self._cumulative_play_time}ms "
f"cumulative listening ({(self._cumulative_play_time/self.duration)*100:.1f}% of track)"
)
# Emit updates if state changed significantly or periodically # Emit updates if state changed significantly or periodically
state_changed = ( state_changed = (
old_playing != self.is_playing old_playing != self.is_playing
or abs(old_time - self.current_time) or abs(old_time - self.current_time)
> 1000 # More than 1 second difference > STATE_CHANGE_THRESHOLD_MS # More than 1 second difference
) )
# Always emit if playing to keep frontend updated # Always emit if playing to keep frontend updated