Compare commits

...

20 Commits

Author SHA1 Message Date
JSC
b1f9667edd feat: Replace pydub with ffmpeg for audio duration and metadata extraction in sound services 2025-07-19 09:40:31 +02:00
JSC
4cfc2ec0a2 feat: Remove unused get_user_credit_info method from CreditService 2025-07-18 23:32:14 +02:00
JSC
39b7e14ae9 feat: Add stream URL generation and service URL retrieval for sounds in music player service 2025-07-18 22:38:40 +02:00
JSC
d0bda6c930 feat: Add sorting by name for soundboard sounds and improve socket emission logging 2025-07-18 21:10:08 +02:00
JSC
010f18bff4 feat: Add referential routes for listing available plans and remove plans endpoint from admin routes 2025-07-16 15:44:57 +02:00
JSC
e874d0665f feat: Add user management endpoints for listing, updating, activating, and deactivating users 2025-07-16 15:24:20 +02:00
JSC
ae238d3d18 feat: Emit sound play count change event to connected clients after playing a sound 2025-07-16 13:54:50 +02:00
JSC
7226d87a77 refactor: Comment out play_sound event handler and related logic for future use 2025-07-13 17:39:17 +02:00
JSC
b17e0db2b0 feat: Emit credits required event via SocketIO when user lacks sufficient credits 2025-07-13 01:46:23 +02:00
JSC
64074685a3 refactor: Simplify sound file path retrieval by consolidating stream sound handling 2025-07-12 22:53:07 +02:00
JSC
688b95b6af refactor: Remove unused playlist routes and related logic; clean up sound and stream models 2025-07-12 22:00:04 +02:00
JSC
627b95c961 feat: Add 'single' play mode to music player and update related logic 2025-07-12 20:49:20 +02:00
JSC
fc734e2581 feat: Enhance play tracking to accumulate play time and trigger at 20% of cumulative listening 2025-07-12 16:26:53 +02:00
JSC
4e96c3538c feat: Update play tracking to trigger at 20% completion instead of start 2025-07-12 16:13:13 +02:00
JSC
6bbf3dce66 feat: Update SoundPlayed model to accept nullable user_id and enhance sound tracking in MusicPlayerService 2025-07-12 15:56:13 +02:00
JSC
842e1dff13 feat: Implement playlist management routes and integrate with music player service 2025-07-12 15:17:45 +02:00
JSC
93897921fb feat: Update playlist loading method to use current playlist on startup 2025-07-11 23:26:28 +02:00
JSC
4f702d3302 feat: Update SoundPlayed model to allow nullable user_id 2025-07-09 14:00:55 +02:00
JSC
7d224d1db7 feat: Restrict JWT access cookie path and update Socket.IO CORS path 2025-07-08 22:35:47 +02:00
JSC
2e464dc977 feat: Enhance track ending detection and handling in MusicPlayerService 2025-07-08 13:36:51 +02:00
23 changed files with 878 additions and 1027 deletions

View File

@@ -37,9 +37,8 @@ def create_app():
app.config["JWT_TOKEN_LOCATION"] = ["cookies"]
app.config["JWT_COOKIE_SECURE"] = False # Set to True in production
app.config["JWT_COOKIE_CSRF_PROTECT"] = False
app.config["JWT_ACCESS_COOKIE_PATH"] = (
"/" # Allow access to all paths including SocketIO
)
app.config["JWT_COOKIE_SAMESITE"] = "Lax" # Allow cross-origin requests
app.config["JWT_ACCESS_COOKIE_PATH"] = "/api/" # Restrict to API paths only
app.config["JWT_REFRESH_COOKIE_PATH"] = "/api/auth/refresh"
# Initialize CORS
@@ -56,6 +55,7 @@ def create_app():
app,
cors_allowed_origins="http://localhost:3000",
cors_credentials=True,
path="/api/socket.io/", # Use /api prefix for Socket.IO
)
# Initialize JWT manager
@@ -100,6 +100,7 @@ def create_app():
auth,
main,
player,
referential,
soundboard,
sounds,
stream,
@@ -109,6 +110,7 @@ def create_app():
app.register_blueprint(auth.bp, url_prefix="/api/auth")
app.register_blueprint(admin.bp, url_prefix="/api/admin")
app.register_blueprint(admin_sounds.bp, url_prefix="/api/admin/sounds")
app.register_blueprint(referential.bp, url_prefix="/api/referential")
app.register_blueprint(soundboard.bp, url_prefix="/api/soundboard")
app.register_blueprint(sounds.bp, url_prefix="/api/sounds")
app.register_blueprint(stream.bp, url_prefix="/api/stream")
@@ -120,6 +122,6 @@ def create_app():
"""Stop services when app context is torn down."""
if exception:
scheduler_service.stop()
music_player_service.stop_vlc_instance()
# music_player_service.stop_vlc_instance()
return app

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
def create_playlist(
cls,
@@ -123,26 +110,6 @@ class Playlist(db.Model):
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
def find_current_playlist(
cls, user_id: Optional[int] = None
@@ -163,22 +130,6 @@ class Playlist(db.Model):
query = query.filter_by(user_id=user_id)
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(
self, sound_id: int, order: Optional[int] = None, commit: bool = True
) -> "PlaylistSound":
@@ -203,76 +154,3 @@ class Playlist(db.Model):
db.session.commit()
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

View File

@@ -63,126 +63,3 @@ class PlaylistSound(db.Model):
"added_at": self.added_at.isoformat() if self.added_at 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."""
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
def create_sound(
cls,

View File

@@ -20,7 +20,7 @@ class SoundPlayed(db.Model):
user_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("user.id"),
nullable=False,
nullable=True,
)
sound_id: Mapped[int] = mapped_column(
Integer,
@@ -77,7 +77,7 @@ class SoundPlayed(db.Model):
@classmethod
def create_play_record(
cls,
user_id: int,
user_id: int | None,
sound_id: int,
*,
commit: bool = True,
@@ -92,173 +92,3 @@ class SoundPlayed(db.Model):
if commit:
db.session.commit()
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 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 app.database import db
@@ -51,9 +58,7 @@ class Stream(db.Model):
# Constraints
__table_args__ = (
UniqueConstraint(
"service", "service_id", name="unique_service_stream"
),
UniqueConstraint("service", "service_id", name="unique_service_stream"),
)
def __repr__(self) -> str:
@@ -117,70 +122,3 @@ class Stream(db.Model):
db.session.commit()
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

@@ -37,3 +37,139 @@ def manual_credit_refill() -> dict:
return scheduler_service.trigger_credit_refill_now()
@bp.route("/users")
@require_auth
@require_role("admin")
def list_users() -> dict:
"""List all users (admin only)."""
from app.models.user import User
users = User.query.order_by(User.created_at.desc()).all()
return {
"users": [user.to_dict() for user in users],
"total": len(users)
}
@bp.route("/users/<int:user_id>", methods=["PATCH"])
@require_auth
@require_role("admin")
def update_user(user_id: int) -> dict:
"""Update user information (admin only)."""
from flask import request
from app.database import db
from app.models.user import User
from app.models.plan import Plan
data = request.get_json()
if not data:
return {"error": "No data provided"}, 400
user = User.query.get(user_id)
if not user:
return {"error": "User not found"}, 404
# Validate and update fields
try:
if "name" in data:
name = data["name"].strip()
if not name:
return {"error": "Name cannot be empty"}, 400
if len(name) > 100:
return {"error": "Name too long (max 100 characters)"}, 400
user.name = name
if "credits" in data:
credits = data["credits"]
if not isinstance(credits, int) or credits < 0:
return {"error": "Credits must be a non-negative integer"}, 400
user.credits = credits
if "plan_id" in data:
plan_id = data["plan_id"]
if not isinstance(plan_id, int):
return {"error": "Plan ID must be an integer"}, 400
plan = Plan.query.get(plan_id)
if not plan:
return {"error": "Plan not found"}, 404
user.plan_id = plan_id
if "is_active" in data:
is_active = data["is_active"]
if not isinstance(is_active, bool):
return {"error": "is_active must be a boolean"}, 400
user.is_active = is_active
db.session.commit()
return {
"message": "User updated successfully",
"user": user.to_dict()
}
except Exception as e:
db.session.rollback()
return {"error": f"Failed to update user: {str(e)}"}, 500
@bp.route("/users/<int:user_id>/deactivate", methods=["POST"])
@require_auth
@require_role("admin")
def deactivate_user(user_id: int) -> dict:
"""Deactivate a user (admin only)."""
from app.database import db
from app.models.user import User
user = User.query.get(user_id)
if not user:
return {"error": "User not found"}, 404
# Prevent admin from deactivating themselves
current_user = get_current_user()
if str(user.id) == current_user["id"]:
return {"error": "Cannot deactivate your own account"}, 400
try:
user.deactivate()
db.session.commit()
return {
"message": "User deactivated successfully",
"user": user.to_dict()
}
except Exception as e:
db.session.rollback()
return {"error": f"Failed to deactivate user: {str(e)}"}, 500
@bp.route("/users/<int:user_id>/activate", methods=["POST"])
@require_auth
@require_role("admin")
def activate_user(user_id: int) -> dict:
"""Activate a user (admin only)."""
from app.database import db
from app.models.user import User
user = User.query.get(user_id)
if not user:
return {"error": "User not found"}, 404
try:
user.activate()
db.session.commit()
return {
"message": "User activated successfully",
"user": user.to_dict()
}
except Exception as e:
db.session.rollback()
return {"error": f"Failed to activate user: {str(e)}"}, 500

View File

@@ -82,53 +82,3 @@ def check_ffmpeg():
return jsonify(ffmpeg_status), 200
except Exception as e:
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

@@ -1,72 +1,232 @@
"""Main routes for the application."""
from flask import Blueprint
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from app.services.decorators import (
get_current_user,
require_auth,
require_credits,
)
from flask import Blueprint, request
from sqlalchemy import desc, func
from app.database import db
from app.models.playlist import Playlist
from app.models.sound import Sound
from app.models.sound_played import SoundPlayed
from app.models.user import User
from app.services.decorators import require_auth
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")
def health() -> dict[str, str]:
"""Health check endpoint."""
return {"status": "ok"}
@bp.route("/use-credits/<int:amount>")
def get_period_filter(period: str) -> datetime | None:
"""Get the start date for the specified period."""
now = datetime.now(tz=ZoneInfo("UTC"))
if period == "today":
return now.replace(hour=0, minute=0, second=0, microsecond=0)
if period == "week":
return now - timedelta(days=7)
if period == "month":
return now - timedelta(days=30)
if period == "year":
return now - timedelta(days=365)
if period == "all":
return None
# Default to all time
return None
@bp.route("/dashboard/stats")
@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()
def dashboard_stats() -> dict:
"""Get dashboard statistics."""
# Count soundboard sounds (type = SDB)
soundboard_count = Sound.query.filter_by(type="SDB").count()
# Count tracks (type = STR)
track_count = Sound.query.filter_by(type="STR").count()
# Count playlists
playlist_count = Playlist.query.count()
# Calculate total size of all sounds (original + normalized)
total_size_result = db.session.query(
func.sum(Sound.size).label("original_size"),
func.sum(Sound.normalized_size).label("normalized_size"),
).first()
original_size = getattr(total_size_result, "original_size", 0) or 0
normalized_size = getattr(total_size_result, "normalized_size", 0) or 0
total_size = original_size + normalized_size
return {
"message": f"Successfully used endpoint! You requested amount: {amount}",
"user": user["email"],
"remaining_credits": user["credits"]
- 5, # Note: credits already deducted by decorator
"soundboard_sounds": soundboard_count,
"tracks": track_count,
"playlists": playlist_count,
"total_size": total_size,
"original_size": original_size,
"normalized_size": normalized_size,
}
@bp.route("/expensive-operation")
@bp.route("/dashboard/top-sounds")
@require_auth
@require_credits(10)
def expensive_operation() -> dict[str, str]:
"""Test endpoint that costs 10 credits to use."""
user = get_current_user()
def top_sounds() -> dict:
"""Get top played sounds for a specific period."""
period = request.args.get("period", "all")
limit = int(request.args.get("limit", 5))
period_start = get_period_filter(period)
# Base query for soundboard sounds with play counts
query = (
db.session.query(
Sound.id,
Sound.name,
Sound.filename,
Sound.thumbnail,
Sound.type,
func.count(SoundPlayed.id).label("play_count"),
)
.outerjoin(SoundPlayed, Sound.id == SoundPlayed.sound_id)
.filter(Sound.type == "SDB") # Only soundboard sounds
.group_by(
Sound.id,
Sound.name,
Sound.filename,
Sound.thumbnail,
Sound.type,
)
)
# Apply period filter if specified
if period_start:
query = query.filter(SoundPlayed.played_at >= period_start)
# Order by play count and limit results
results = query.order_by(desc("play_count")).limit(limit).all()
# Convert to list of dictionaries
top_sounds_list = [
{
"id": result.id,
"name": result.name,
"filename": result.filename,
"thumbnail": result.thumbnail,
"type": result.type,
"play_count": result.play_count,
}
for result in results
]
return {
"message": "Expensive operation completed successfully!",
"user": user["email"],
"operation_cost": 10,
"period": period,
"sounds": top_sounds_list,
}
@bp.route("/dashboard/top-tracks")
@require_auth
def top_tracks() -> dict:
"""Get top played tracks for a specific period."""
period = request.args.get("period", "all")
limit = int(request.args.get("limit", 10))
period_start = get_period_filter(period)
# Base query for tracks with play counts
query = (
db.session.query(
Sound.id,
Sound.name,
Sound.filename,
Sound.thumbnail,
Sound.type,
func.count(SoundPlayed.id).label("play_count"),
)
.outerjoin(SoundPlayed, Sound.id == SoundPlayed.sound_id)
.filter(Sound.type == "STR") # Only tracks
.group_by(
Sound.id,
Sound.name,
Sound.filename,
Sound.thumbnail,
Sound.type,
)
)
# Apply period filter if specified
if period_start:
query = query.filter(SoundPlayed.played_at >= period_start)
# Order by play count and limit results
results = query.order_by(desc("play_count")).limit(limit).all()
# Convert to list of dictionaries
top_tracks_list = [
{
"id": result.id,
"name": result.name,
"filename": result.filename,
"thumbnail": result.thumbnail,
"type": result.type,
"play_count": result.play_count,
}
for result in results
]
return {
"period": period,
"tracks": top_tracks_list,
}
@bp.route("/dashboard/top-users")
@require_auth
def top_users() -> dict:
"""Get top users by play count for a specific period."""
period = request.args.get("period", "all")
limit = int(request.args.get("limit", 10))
period_start = get_period_filter(period)
# Base query for users with play counts
query = (
db.session.query(
User.id,
User.name,
User.email,
User.picture,
func.count(SoundPlayed.id).label("play_count"),
)
.outerjoin(SoundPlayed, User.id == SoundPlayed.user_id)
.group_by(User.id, User.name, User.email, User.picture)
)
# Apply period filter if specified
if period_start:
query = query.filter(SoundPlayed.played_at >= period_start)
# Order by play count and limit results
results = query.order_by(desc("play_count")).limit(limit).all()
# Convert to list of dictionaries
top_users_list = [
{
"id": result.id,
"name": result.name,
"email": result.email,
"picture": result.picture,
"play_count": result.play_count,
}
for result in results
]
return {
"period": period,
"users": top_users_list,
}

View File

@@ -93,11 +93,14 @@ def seek():
data = request.get_json()
if not data or "position" not in data:
return jsonify({"error": "Position required"}), 400
position = float(data["position"])
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)
if success:
return jsonify({"message": "Seek successful"}), 200
@@ -116,11 +119,11 @@ def set_volume():
data = request.get_json()
if not data or "volume" not in data:
return jsonify({"error": "Volume required"}), 400
volume = int(data["volume"])
if not 0 <= volume <= 100:
return jsonify({"error": "Volume must be between 0 and 100"}), 400
success = music_player_service.set_volume(volume)
if success:
return jsonify({"message": "Volume set successfully"}), 200
@@ -139,12 +142,23 @@ def set_play_mode():
data = request.get_json()
if not data or "mode" not in data:
return jsonify({"error": "Mode required"}), 400
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:
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)
if success:
return jsonify({"message": "Play mode set successfully"}), 200
@@ -161,7 +175,7 @@ def load_playlist():
data = request.get_json()
if not data or "playlist_id" not in data:
return jsonify({"error": "Playlist ID required"}), 400
playlist_id = int(data["playlist_id"])
success = music_player_service.load_playlist(playlist_id)
if success:
@@ -181,7 +195,7 @@ def play_track():
data = request.get_json()
if not data or "index" not in data:
return jsonify({"error": "Track index required"}), 400
index = int(data["index"])
success = music_player_service.play_track_at_index(index)
if success:
@@ -191,42 +205,3 @@ def play_track():
return jsonify({"error": "Invalid track index"}), 400
except Exception as 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)

17
app/routes/referential.py Normal file
View File

@@ -0,0 +1,17 @@
"""Referential routes for reference data."""
from flask import Blueprint
bp = Blueprint("referential", __name__)
@bp.route("/plans")
def list_plans() -> dict:
"""List all available plans."""
from app.models.plan import Plan
plans = Plan.query.order_by(Plan.id).all()
return {
"plans": [plan.to_dict() for plan in plans],
"total": len(plans)
}

View File

@@ -29,6 +29,9 @@ def get_sounds():
# Get sounds from database
sounds = Sound.find_by_type(sound_type)
# Order by name
sounds = sorted(sounds, key=lambda s: s.name.lower())
# Convert to dict format
sounds_data = [sound.to_dict() for sound in sounds]
@@ -59,6 +62,25 @@ def play_sound(sound_id: int):
)
if success:
# Get updated sound data to emit the new play count
sound = Sound.query.get(sound_id)
if sound:
# Emit sound_changed event to all connected clients
try:
from app.services.socketio_service import SocketIOService
SocketIOService.emit_sound_play_count_changed(
sound_id, sound.play_count
)
except Exception as e:
# Don't fail the request if socket emission fails
import logging
logger = logging.getLogger(__name__)
logger.warning(
f"Failed to emit sound_play_count_changed event: {e}"
)
return jsonify({"message": "Sound playing", "sound_id": sound_id})
return (
jsonify({"error": "Sound not found or cannot be played"}),
@@ -140,99 +162,3 @@ def get_status():
)
except Exception as e:
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:
db.session.rollback()
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

@@ -106,34 +106,3 @@ class CreditService:
"error": str(e),
"message": "Credit refill failed",
}
@staticmethod
def get_user_credit_info(user_id: int) -> dict:
"""Get detailed credit information for a specific user.
Args:
user_id: The user's ID
Returns:
dict: User's credit information
"""
user = User.query.get(user_id)
if not user:
return {"error": "User not found"}
if not user.plan:
return {"error": "User has no plan assigned"}
return {
"user_id": user.id,
"email": user.email,
"current_credits": user.credits,
"plan": {
"code": user.plan.code,
"name": user.plan.name,
"daily_credits": user.plan.credits,
"max_credits": user.plan.max_credits,
},
"is_active": user.is_active,
}

View File

@@ -172,6 +172,22 @@ def require_credits(credits_needed: int):
# Check if user has enough credits
if user.credits < credits_needed:
# Emit credits required event via SocketIO
try:
from app.services.socketio_service import socketio_service
socketio_service.emit_credits_required(
user.id, credits_needed
)
except Exception as e:
# Don't fail the request if SocketIO emission fails
import logging
logger = logging.getLogger(__name__)
logger.warning(
f"Failed to emit credits_required event: {e}"
)
return (
jsonify(
{

View File

@@ -3,18 +3,28 @@
import os
import threading
import time
from datetime import datetime
from typing import Any, Optional
from zoneinfo import ZoneInfo
import vlc
from flask import current_app, request
from app.models.playlist import Playlist
from app.models.sound import Sound
from app.models.sound_played import SoundPlayed
from app.services.logging_service import LoggingService
from app.services.socketio_service import socketio_service
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:
"""Service for managing a VLC music player with playlist support."""
@@ -32,16 +42,30 @@ class MusicPlayerService:
) # Store file paths for manual playlist management
self.volume = 80
self.play_mode = (
"continuous" # continuous, loop-playlist, loop-one, random
"continuous" # single, 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.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_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:
"""Start a VLC instance with Python bindings."""
@@ -67,10 +91,10 @@ class MusicPlayerService:
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()
# Automatically load the current playlist
self._load_current_playlist_on_startup()
self._start_sync_thread()
return True
@@ -99,7 +123,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:
@@ -114,7 +138,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(
@@ -125,12 +151,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"
@@ -139,7 +167,23 @@ 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 _build_stream_url(self, sound_type: str, filename: str) -> str:
"""Build absolute stream URL."""
try:
# Try to get base URL from current request context
if request:
base_url = request.url_root.rstrip("/")
else:
# Fallback to localhost if no request context
base_url = "http://localhost:5000"
return f"{base_url}/api/sounds/{sound_type.lower()}/audio/{filename}"
except Exception:
# Fallback if request context is not available
return f"http://localhost:5000/api/sounds/{sound_type.lower()}/audio/{filename}"
def _load_playlist_with_context(
self, playlist, reload: bool = False
) -> bool:
"""Load playlist with database context already established."""
try:
# Clear current playlist
@@ -155,12 +199,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()
@@ -183,30 +247,56 @@ class MusicPlayerService:
if media:
self.player.set_media(media)
self.current_track_index = index
# Reset track ending flag when loading a new track
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 False
except Exception as e:
logger.error(f"Error loading track at index {index}: {e}")
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]:
"""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"
base_path = "sounds/stream"
base_normalized_path = "sounds/normalized/stream"
# Check for normalized version first
if sound.is_normalized and sound.normalized_filename:
normalized_path = os.path.join(
"sounds/normalized",
sound.type.lower(),
base_normalized_path,
sound.normalized_filename,
)
if os.path.exists(normalized_path):
@@ -229,9 +319,15 @@ class MusicPlayerService:
if not self.player:
return False
# Reset track ending flag when starting playback
self._track_ending_handled = False
result = self.player.play()
if result == 0: # Success
self.is_playing = True
self._track_play_tracked = (
False # Track when we first start playing
)
self._emit_player_state()
return True
return False
@@ -268,7 +364,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:
@@ -291,7 +387,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
@@ -363,7 +459,13 @@ class MusicPlayerService:
def set_play_mode(self, mode: str) -> bool:
"""Set play mode."""
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._emit_player_state()
return True
@@ -394,9 +496,10 @@ class MusicPlayerService:
if not self.current_playlist_id:
return None
# Ensure we have Flask app context
if current_app:
with current_app.app_context():
# 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():
playlist = Playlist.query.get(self.current_playlist_id)
if playlist and 0 <= self.current_track_index < len(
playlist.playlist_sounds
@@ -410,16 +513,26 @@ class MusicPlayerService:
sound = current_playlist_sound.sound
if sound:
# Get the service URL from the associated stream
service_url = None
if sound.streams:
# Get the first stream's URL if available
service_url = sound.streams[0].url
return {
"id": sound.id,
"title": sound.name,
"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
),
"file_url": self._build_stream_url(sound.type, sound.filename),
"service_url": service_url,
"type": sound.type,
}
return None
@@ -444,6 +557,12 @@ class MusicPlayerService:
):
sound = playlist_sound.sound
if sound:
# Get the service URL from the associated stream
service_url = None
if sound.streams:
# Get the first stream's URL if available
service_url = sound.streams[0].url
tracks.append(
{
"id": sound.id,
@@ -451,10 +570,14 @@ 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
),
"file_url": self._build_stream_url(sound.type, sound.filename),
"service_url": service_url,
"type": sound.type,
}
)
@@ -465,13 +588,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,
@@ -521,23 +646,98 @@ class MusicPlayerService:
# 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()
# 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}"
)
# 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
):
track_ended = True
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")
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 [
"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
# 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
state_changed = (
old_playing != self.is_playing
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
@@ -552,15 +752,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 = {
@@ -570,12 +772,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}")
@@ -599,29 +804,74 @@ class MusicPlayerService:
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."""
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 main playlist load")
logger.warning(
"No Flask app context available, skipping current 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)
# 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 main playlist '{main_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 main playlist on startup")
logger.warning(
"Failed to load current playlist on startup"
)
else:
logger.info("No main playlist found to load on startup")
logger.info("No current playlist found to load on startup")
except Exception as e:
logger.error(f"Error loading main playlist on startup: {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

View File

@@ -6,6 +6,7 @@ from flask import request
from flask_socketio import disconnect, emit, join_room, leave_room
from app import socketio
from app.services.decorators import require_credits
logger = logging.getLogger(__name__)
@@ -30,7 +31,9 @@ class SocketIOService:
"""Emit an event to all connected clients."""
try:
socketio.emit(event, data)
logger.info(f"Successfully emitted {event} to all clients with data keys: {list(data.keys())}")
logger.info(
f"Successfully emitted {event} to all clients with data keys: {list(data.keys())}"
)
except Exception as e:
logger.error(f"Failed to emit {event}: {e}")
@@ -43,6 +46,23 @@ class SocketIOService:
{"credits": new_credits},
)
@staticmethod
def emit_sound_play_count_changed(sound_id: int, new_play_count: int) -> None:
"""Emit sound_play_count_changed event to all connected clients."""
SocketIOService.emit_to_all(
"sound_play_count_changed",
{"sound_id": sound_id, "play_count": new_play_count},
)
@staticmethod
def emit_credits_required(user_id: int, credits_needed: int) -> None:
"""Emit an event when credits are required."""
SocketIOService.emit_to_user(
user_id,
"credits_required",
{"credits_needed": credits_needed},
)
@staticmethod
def get_user_from_socketio() -> dict | None:
"""Get user from SocketIO connection using cookies."""
@@ -52,8 +72,10 @@ class SocketIOService:
# Check if we have the access_token cookie
access_token = request.cookies.get("access_token_cookie")
logger.debug(f"Access token from cookies: {access_token[:20] if access_token else None}...")
logger.debug(
f"Access token from cookies: {access_token[:20] if access_token else None}..."
)
if not access_token:
logger.debug("No access token found in cookies")
return None
@@ -64,7 +86,7 @@ class SocketIOService:
decoded_token = decode_token(access_token)
current_user_id = decoded_token["sub"]
logger.debug(f"Decoded user ID: {current_user_id}")
if not current_user_id:
logger.debug("No user ID in token")
return None
@@ -77,7 +99,9 @@ class SocketIOService:
user = User.query.get(int(current_user_id))
if not user or not user.is_active:
logger.debug(f"User not found or inactive: {current_user_id}")
logger.debug(
f"User not found or inactive: {current_user_id}"
)
return None
logger.debug(f"Successfully found user: {user.email}")
@@ -97,7 +121,9 @@ class SocketIOService:
def handle_connect(auth=None):
"""Handle client connection."""
try:
logger.info(f"SocketIO connection established from {request.remote_addr}")
logger.info(
f"SocketIO connection established from {request.remote_addr}"
)
logger.info(f"Session ID: {request.sid}")
except Exception:
@@ -110,10 +136,10 @@ def handle_authenticate(data):
"""Handle authentication after connection."""
try:
user = SocketIOService.get_user_from_socketio()
if not user:
logger.warning("SocketIO authentication failed - no user found")
emit("auth_error", {"error": "Authentication failed"})
# emit("auth_error", {"error": "Authentication failed"})
disconnect()
return
@@ -126,20 +152,56 @@ def handle_authenticate(data):
logger.info(f"User {user_id} authenticated and joined room {user_room}")
# Send current credits on authentication
emit("auth_success", {"user": user})
emit("credits_changed", {"credits": user["credits"]})
SocketIOService.emit_to_user(user_id, "auth_success", {"user": user})
SocketIOService.emit_to_user(
user_id, "credits_changed", {"credits": user["credits"]}
)
except Exception:
logger.exception("Error handling SocketIO authentication")
emit("auth_error", {"error": "Authentication failed"})
# emit("auth_error", {"error": "Authentication failed"})
disconnect()
@socketio.on("test_event")
def handle_test_event(data):
"""Test handler to verify SocketIO events are working."""
logger.debug(f"Test event received: {data}")
emit("test_response", {"message": "Test event received successfully"})
# @socketio.on("play_sound")
# @require_credits(1)
# def handle_play_sound(data):
# """Handle play_sound event from client."""
# try:
# user = SocketIOService.get_user_from_socketio()
# if not user:
# logger.warning("SocketIO play_sound failed - no authenticated user")
# # emit("error", {"message": "Authentication required"})
# return
# user_id = int(user["id"])
# sound_id = data.get("soundId")
# if not sound_id:
# logger.warning("SocketIO play_sound failed - no soundId provided")
# SocketIOService.emit_to_user(
# user_id, "error", {"message": "Sound ID required"}
# )
# return
# # Import and use the VLC service to play the sound
# from app.services.vlc_service import vlc_service
# logger.info(f"User {user_id} playing sound {sound_id} via SocketIO")
# # Play the sound using the VLC service
# success = vlc_service.play_sound(sound_id, user_id)
# if not success:
# SocketIOService.emit_to_user(
# user_id,
# "error",
# {"message": f"Failed to play sound {sound_id}"},
# )
# except Exception as e:
# logger.exception(f"Error handling play_sound event: {e}")
# # emit("error", {"message": "Failed to play sound"})
@socketio.on("disconnect")

View File

@@ -7,7 +7,6 @@ import re
from pathlib import Path
import ffmpeg
from pydub import AudioSegment
from app.database import db
from app.models.sound import Sound
@@ -632,9 +631,17 @@ class SoundNormalizerService:
# Calculate file hash
file_hash = SoundNormalizerService._calculate_file_hash(file_path)
# Get duration using pydub
audio = AudioSegment.from_wav(file_path)
duration = len(audio) # Duration in milliseconds
# Get duration using ffmpeg
probe = ffmpeg.probe(file_path)
audio_stream = next(
(s for s in probe['streams'] if s['codec_type'] == 'audio'),
None
)
if audio_stream and 'duration' in audio_stream:
duration = int(float(audio_stream['duration']) * 1000) # Convert to milliseconds
else:
duration = 0
return {
"duration": duration,

View File

@@ -4,8 +4,7 @@ import hashlib
import logging
from pathlib import Path
from pydub import AudioSegment
from pydub.utils import mediainfo
import ffmpeg
from app.database import db
from app.models.sound import Sound
@@ -281,32 +280,31 @@ class SoundScannerService:
@staticmethod
def _extract_audio_metadata(file_path: str) -> dict:
"""Extract metadata from audio file using pydub and mediainfo."""
"""Extract metadata from audio file using ffmpeg-python."""
try:
# Get file size
file_size = Path(file_path).stat().st_size
# Load audio file with pydub for basic info
audio = AudioSegment.from_file(file_path)
# Use ffmpeg to probe audio metadata
probe = ffmpeg.probe(file_path)
audio_stream = next(
(s for s in probe['streams'] if s['codec_type'] == 'audio'),
None
)
if not audio_stream:
raise ValueError("No audio stream found in file")
# Extract basic metadata from AudioSegment
duration = len(audio)
channels = audio.channels
sample_rate = audio.frame_rate
# Use mediainfo for more accurate bitrate information
bitrate = None
try:
info = mediainfo(file_path)
if info and "bit_rate" in info:
bitrate = int(info["bit_rate"])
elif info and "bitrate" in info:
bitrate = int(info["bitrate"])
except (ValueError, KeyError, TypeError):
# Fallback to calculated bitrate if mediainfo fails
if duration > 0:
file_size_bits = file_size * 8
bitrate = int(file_size_bits / duration / 1000)
# Extract metadata from ffmpeg probe
duration = int(float(audio_stream.get('duration', 0)) * 1000) # Convert to milliseconds
channels = int(audio_stream.get('channels', 0))
sample_rate = int(audio_stream.get('sample_rate', 0))
bitrate = int(audio_stream.get('bit_rate', 0)) if audio_stream.get('bit_rate') else None
# Fallback bitrate calculation if not available
if not bitrate and duration > 0:
file_size_bits = file_size * 8
bitrate = int(file_size_bits / (duration / 1000))
return {
"duration": duration,

View File

@@ -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")

View File

@@ -1,4 +1,5 @@
import logging
from dotenv import load_dotenv
from app import create_app, socketio
@@ -9,8 +10,8 @@ load_dotenv()
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
datefmt='%H:%M:%S'
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%H:%M:%S",
)
@@ -18,7 +19,7 @@ def main() -> None:
"""Run the Flask application with SocketIO."""
app = create_app()
socketio.run(
app, debug=True, host="0.0.0.0", port=5000, allow_unsafe_werkzeug=True
app, debug=True, host="127.0.0.1", port=5000, allow_unsafe_werkzeug=True
)

View File

@@ -15,7 +15,6 @@ dependencies = [
"flask-migrate==4.1.0",
"flask-socketio==5.5.1",
"flask-sqlalchemy==3.1.1",
"pydub==0.25.1",
"python-dotenv==1.1.1",
"python-vlc>=3.0.21203",
"requests==2.32.4",

11
uv.lock generated
View File

@@ -505,15 +505,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 },
]
[[package]]
name = "pydub"
version = "0.25.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/9a/e6bca0eed82db26562c73b5076539a4a08d3cffd19c3cc5913a3e61145fd/pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f", size = 38326 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327 },
]
[[package]]
name = "pygments"
version = "2.19.2"
@@ -645,7 +636,6 @@ dependencies = [
{ name = "flask-migrate" },
{ name = "flask-socketio" },
{ name = "flask-sqlalchemy" },
{ name = "pydub" },
{ name = "python-dotenv" },
{ name = "python-vlc" },
{ name = "requests" },
@@ -671,7 +661,6 @@ requires-dist = [
{ name = "flask-migrate", specifier = "==4.1.0" },
{ name = "flask-socketio", specifier = "==5.5.1" },
{ name = "flask-sqlalchemy", specifier = "==3.1.1" },
{ name = "pydub", specifier = "==0.25.1" },
{ name = "python-dotenv", specifier = "==1.1.1" },
{ name = "python-vlc", specifier = ">=3.0.21203" },
{ name = "requests", specifier = "==2.32.4" },