Compare commits

...

17 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
23 changed files with 853 additions and 1038 deletions

View File

@@ -100,6 +100,7 @@ def create_app():
auth, auth,
main, main,
player, player,
referential,
soundboard, soundboard,
sounds, sounds,
stream, stream,
@@ -109,6 +110,7 @@ def create_app():
app.register_blueprint(auth.bp, url_prefix="/api/auth") app.register_blueprint(auth.bp, url_prefix="/api/auth")
app.register_blueprint(admin.bp, url_prefix="/api/admin") app.register_blueprint(admin.bp, url_prefix="/api/admin")
app.register_blueprint(admin_sounds.bp, url_prefix="/api/admin/sounds") 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(soundboard.bp, url_prefix="/api/soundboard")
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")

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,76 +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

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

@@ -37,3 +37,139 @@ def manual_credit_refill() -> dict:
return scheduler_service.trigger_credit_refill_now() 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 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

@@ -1,72 +1,232 @@
"""Main routes for the application.""" """Main routes for the application."""
from flask import Blueprint from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from app.services.decorators import ( from flask import Blueprint, request
get_current_user, from sqlalchemy import desc, func
require_auth,
require_credits, 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 = 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>") 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_auth
@require_credits(5) def dashboard_stats() -> dict:
def use_credits(amount: int) -> dict[str, str]: """Get dashboard statistics."""
"""Test endpoint that costs 5 credits to use.""" # Count soundboard sounds (type = SDB)
user = get_current_user() 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 { return {
"message": f"Successfully used endpoint! You requested amount: {amount}", "soundboard_sounds": soundboard_count,
"user": user["email"], "tracks": track_count,
"remaining_credits": user["credits"] "playlists": playlist_count,
- 5, # Note: credits already deducted by decorator "total_size": total_size,
"original_size": original_size,
"normalized_size": normalized_size,
} }
@bp.route("/expensive-operation") @bp.route("/dashboard/top-sounds")
@require_auth @require_auth
@require_credits(10) def top_sounds() -> dict:
def expensive_operation() -> dict[str, str]: """Get top played sounds for a specific period."""
"""Test endpoint that costs 10 credits to use.""" period = request.args.get("period", "all")
user = get_current_user() 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 { return {
"message": "Expensive operation completed successfully!", "period": period,
"user": user["email"], "sounds": top_sounds_list,
"operation_cost": 10, }
@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() 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)

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 # Get sounds from database
sounds = Sound.find_by_type(sound_type) sounds = Sound.find_by_type(sound_type)
# Order by name
sounds = sorted(sounds, key=lambda s: s.name.lower())
# Convert to dict format # Convert to dict format
sounds_data = [sound.to_dict() for sound in sounds] sounds_data = [sound.to_dict() for sound in sounds]
@@ -59,6 +62,25 @@ def play_sound(sound_id: int):
) )
if success: 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({"message": "Sound playing", "sound_id": sound_id})
return ( return (
jsonify({"error": "Sound not found or cannot be played"}), jsonify({"error": "Sound not found or cannot be played"}),
@@ -140,99 +162,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

@@ -106,34 +106,3 @@ class CreditService:
"error": str(e), "error": str(e),
"message": "Credit refill failed", "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 # Check if user has enough credits
if user.credits < credits_needed: 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 ( return (
jsonify( jsonify(
{ {

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,17 +42,30 @@ 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
self.duration = 0 self.duration = 0
self.last_sync_time = 0 self.last_sync_time = 0
self.sync_interval = 0.5 # seconds (increased frequency to catch track endings) self.sync_interval = (
0.5 # seconds (increased frequency to catch track endings)
)
self.lock = threading.Lock() self.lock = threading.Lock()
self._sync_thread = None self._sync_thread = None
self._stop_sync = False self._stop_sync = False
self._track_ending_handled = False # Flag to prevent duplicate ending triggers self._track_ending_handled = (
False # Flag to prevent duplicate ending triggers
)
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."""
@@ -68,10 +91,10 @@ class MusicPlayerService:
self.player.audio_set_volume(self.volume) self.player.audio_set_volume(self.volume)
logger.info("VLC music player started successfully") logger.info("VLC music player started successfully")
# Automatically load the main playlist # Automatically load the current playlist
self._load_main_playlist_on_startup() self._load_current_playlist_on_startup()
self._start_sync_thread() self._start_sync_thread()
return True return True
@@ -100,7 +123,7 @@ class MusicPlayerService:
logger.error(f"Error stopping VLC instance: {e}") logger.error(f"Error stopping VLC instance: {e}")
return False 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.""" """Load a playlist into VLC."""
try: try:
if not self.instance or not self.player: if not self.instance or not self.player:
@@ -115,7 +138,9 @@ class MusicPlayerService:
if not playlist: if not playlist:
return False return False
return self._load_playlist_with_context(playlist) return self._load_playlist_with_context(
playlist, reload
)
else: else:
# Fallback for when no Flask context is available # Fallback for when no Flask context is available
logger.warning( logger.warning(
@@ -126,12 +151,14 @@ class MusicPlayerService:
logger.error(f"Error loading playlist {playlist_id}: {e}") logger.error(f"Error loading playlist {playlist_id}: {e}")
return False 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.""" """Build absolute thumbnail URL."""
try: try:
# Try to get base URL from current request context # Try to get base URL from current request context
if request: if request:
base_url = request.url_root.rstrip('/') base_url = request.url_root.rstrip("/")
else: else:
# Fallback to localhost if no request context # Fallback to localhost if no request context
base_url = "http://localhost:5000" base_url = "http://localhost:5000"
@@ -140,7 +167,23 @@ class MusicPlayerService:
# Fallback if request context is not available # Fallback if request context is not available
return f"http://localhost:5000/api/sounds/{sound_type.lower()}/thumbnails/{thumbnail_filename}" 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.""" """Load playlist with database context already established."""
try: try:
# Clear current playlist # Clear current playlist
@@ -156,12 +199,32 @@ class MusicPlayerService:
if file_path and os.path.exists(file_path): if file_path and os.path.exists(file_path):
self.playlist_files.append(file_path) self.playlist_files.append(file_path)
self.current_playlist_id = playlist.id deleted = False
self.current_track_index = 0 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 not reload or deleted:
if self.playlist_files: self.current_playlist_id = playlist.id
self._load_track_at_index(0) self.current_track_index = 0
# Load first track if available
if self.playlist_files:
self._load_track_at_index(0)
# Emit playlist loaded event # Emit playlist loaded event
self._emit_player_state() self._emit_player_state()
@@ -186,30 +249,54 @@ 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:
if sound.type == "STR": base_path = "sounds/stream"
# Stream sounds base_normalized_path = "sounds/normalized/stream"
base_path = "sounds/stream"
elif sound.type == "SAY":
# Say sounds
base_path = "sounds/say"
else:
# Soundboard sounds
base_path = "sounds/soundboard"
# Check for normalized version first # Check for normalized version first
if sound.is_normalized and sound.normalized_filename: if sound.is_normalized and sound.normalized_filename:
normalized_path = os.path.join( normalized_path = os.path.join(
"sounds/normalized", base_normalized_path,
sound.type.lower(),
sound.normalized_filename, sound.normalized_filename,
) )
if os.path.exists(normalized_path): if os.path.exists(normalized_path):
@@ -234,10 +321,13 @@ class MusicPlayerService:
# Reset track ending flag when starting playback # Reset track ending flag when starting playback
self._track_ending_handled = False self._track_ending_handled = False
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
@@ -274,7 +364,7 @@ class MusicPlayerService:
logger.error(f"Error stopping playback: {e}") logger.error(f"Error stopping playback: {e}")
return False return False
def next_track(self) -> bool: def next_track(self, force_play: bool = False) -> bool:
"""Skip to next track.""" """Skip to next track."""
try: try:
if not self.playlist_files: if not self.playlist_files:
@@ -297,7 +387,7 @@ class MusicPlayerService:
return True return True
if self._load_track_at_index(next_index): if self._load_track_at_index(next_index):
if self.is_playing: if self.is_playing or force_play:
self.play() self.play()
self._emit_player_state() self._emit_player_state()
return True return True
@@ -369,7 +459,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
@@ -400,9 +496,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
@@ -416,16 +513,26 @@ class MusicPlayerService:
sound = current_playlist_sound.sound sound = current_playlist_sound.sound
if 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 { return {
"id": sound.id, "id": sound.id,
"title": sound.name, "title": sound.name,
"artist": None, # Could be extracted from metadata "artist": None, # Could be extracted from metadata
"duration": sound.duration or 0, "duration": sound.duration or 0,
"thumbnail": ( "thumbnail": (
self._build_thumbnail_url(sound.type, sound.thumbnail) self._build_thumbnail_url(
sound.type, sound.thumbnail
)
if sound.thumbnail if sound.thumbnail
else None else None
), ),
"file_url": self._build_stream_url(sound.type, sound.filename),
"service_url": service_url,
"type": sound.type, "type": sound.type,
} }
return None return None
@@ -450,6 +557,12 @@ class MusicPlayerService:
): ):
sound = playlist_sound.sound sound = playlist_sound.sound
if 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( tracks.append(
{ {
"id": sound.id, "id": sound.id,
@@ -457,10 +570,14 @@ class MusicPlayerService:
"artist": None, "artist": None,
"duration": sound.duration or 0, "duration": sound.duration or 0,
"thumbnail": ( "thumbnail": (
self._build_thumbnail_url(sound.type, sound.thumbnail) self._build_thumbnail_url(
sound.type, sound.thumbnail
)
if sound.thumbnail if sound.thumbnail
else None else None
), ),
"file_url": self._build_stream_url(sound.type, sound.filename),
"service_url": service_url,
"type": sound.type, "type": sound.type,
} }
) )
@@ -471,13 +588,15 @@ class MusicPlayerService:
def get_player_state(self) -> dict[str, Any]: def get_player_state(self) -> dict[str, Any]:
"""Get complete player state.""" """Get complete player state."""
current_track = self.get_current_track()
return { return {
"is_playing": self.is_playing, "is_playing": self.is_playing,
"current_time": self.current_time, "current_time": self.current_time,
"duration": self.duration, "duration": self.duration,
"volume": self.volume, "volume": self.volume,
"play_mode": self.play_mode, "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, "current_track_index": self.current_track_index,
"playlist": self.get_playlist_tracks(), "playlist": self.get_playlist_tracks(),
"playlist_id": self.current_playlist_id, "playlist_id": self.current_playlist_id,
@@ -529,51 +648,96 @@ class MusicPlayerService:
# Enhanced track ending detection # Enhanced track ending detection
track_ended = False track_ended = False
# Check for ended state # Check for ended state
if state == vlc.State.Ended: if state == vlc.State.Ended:
track_ended = True track_ended = True
logger.info(f"Track ended via VLC State.Ended, mode: {self.play_mode}") logger.info(
f"Track ended via VLC State.Ended, mode: {self.play_mode}"
)
# Also check if we're very close to the end (within 500ms) and not playing # 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 elif (
self.current_time >= (self.duration - 500) and self.duration > 0
not self.is_playing and old_playing): and self.current_time > 0
and self.current_time >= (self.duration - 500)
and not self.is_playing
and old_playing
):
track_ended = True track_ended = True
logger.info(f"Track ended via time check, mode: {self.play_mode}") logger.info(
f"Track ended via time check, mode: {self.play_mode}"
)
# Handle track ending based on play mode (only if not already handled) # Handle track ending based on play mode (only if not already handled)
if track_ended and not self._track_ending_handled: if track_ended and not self._track_ending_handled:
self._track_ending_handled = True self._track_ending_handled = True
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")
# Stop first, then reload and play self.play_track_at_index(self.current_track_index)
self.player.stop() elif self.play_mode == "single":
# Reload the current track logger.info(
if (self.current_track_index < len(self.playlist_files)): "Track ended in single mode - stopping playback"
media = self.instance.media_new( )
self.playlist_files[self.current_track_index] self.stop()
) elif self.play_mode in [
self.player.set_media(media) "continuous",
self.player.play() "loop-playlist",
# Reset the flag after a short delay to allow for new track "random",
self._track_ending_handled = False ]:
elif self.play_mode in ["continuous", "loop-playlist", "random"]: logger.info(
logger.info(f"Advancing to next track for {self.play_mode} mode") f"Advancing to next track for {self.play_mode} mode"
self.next_track() )
# Reset the flag after track change self.next_track(True)
self._track_ending_handled = False
# Reset the flag after track change
self._track_ending_handled = False
# Reset the flag if we're playing again (new track started) # Reset the flag if we're playing again (new track started)
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
@@ -588,15 +752,17 @@ class MusicPlayerService:
try: try:
# Update state from VLC before emitting # Update state from VLC before emitting
self._sync_vlc_state_only() self._sync_vlc_state_only()
# Try to use Flask context for database queries # Try to use Flask context for database queries
app_to_use = self.app or current_app app_to_use = self.app or current_app
if app_to_use: if app_to_use:
with app_to_use.app_context(): with app_to_use.app_context():
state = self.get_player_state() state = self.get_player_state()
socketio_service.emit_to_all("player_state_update", 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: else:
# Fallback when no Flask context - emit basic state without database queries # Fallback when no Flask context - emit basic state without database queries
basic_state = { basic_state = {
@@ -606,12 +772,15 @@ class MusicPlayerService:
"volume": self.volume, "volume": self.volume,
"play_mode": self.play_mode, "play_mode": self.play_mode,
"current_track": None, "current_track": None,
"current_track_id": None,
"current_track_index": self.current_track_index, "current_track_index": self.current_track_index,
"playlist": [], "playlist": [],
"playlist_id": self.current_playlist_id, "playlist_id": self.current_playlist_id,
} }
socketio_service.emit_to_all("player_state_update", basic_state) 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: except Exception as e:
logger.debug(f"Error emitting player state: {e}") logger.debug(f"Error emitting player state: {e}")
@@ -635,29 +804,74 @@ class MusicPlayerService:
except Exception as e: except Exception as e:
logger.debug(f"Error syncing VLC state: {e}") logger.debug(f"Error syncing VLC state: {e}")
def _load_current_playlist_on_startup(self):
def _load_main_playlist_on_startup(self): """Load the current playlist automatically on startup."""
"""Load the main playlist automatically on startup."""
try: try:
if not self.app: 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 return
with self.app.app_context(): with self.app.app_context():
# Find the main playlist # Find the current playlist
main_playlist = Playlist.find_main_playlist() current_playlist = Playlist.find_current_playlist()
if main_playlist: if current_playlist:
success = self.load_playlist(main_playlist.id) success = self.load_playlist(current_playlist.id)
if success: 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: else:
logger.warning("Failed to load main playlist on startup") logger.warning(
"Failed to load current playlist on startup"
)
else: 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: 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 # 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 flask_socketio import disconnect, emit, join_room, leave_room
from app import socketio from app import socketio
from app.services.decorators import require_credits
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -30,7 +31,9 @@ class SocketIOService:
"""Emit an event to all connected clients.""" """Emit an event to all connected clients."""
try: try:
socketio.emit(event, data) 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: except Exception as e:
logger.error(f"Failed to emit {event}: {e}") logger.error(f"Failed to emit {event}: {e}")
@@ -43,6 +46,23 @@ class SocketIOService:
{"credits": new_credits}, {"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 @staticmethod
def get_user_from_socketio() -> dict | None: def get_user_from_socketio() -> dict | None:
"""Get user from SocketIO connection using cookies.""" """Get user from SocketIO connection using cookies."""
@@ -52,8 +72,10 @@ class SocketIOService:
# Check if we have the access_token cookie # Check if we have the access_token cookie
access_token = request.cookies.get("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: if not access_token:
logger.debug("No access token found in cookies") logger.debug("No access token found in cookies")
return None return None
@@ -64,7 +86,7 @@ class SocketIOService:
decoded_token = decode_token(access_token) decoded_token = decode_token(access_token)
current_user_id = decoded_token["sub"] current_user_id = decoded_token["sub"]
logger.debug(f"Decoded user ID: {current_user_id}") logger.debug(f"Decoded user ID: {current_user_id}")
if not current_user_id: if not current_user_id:
logger.debug("No user ID in token") logger.debug("No user ID in token")
return None return None
@@ -77,7 +99,9 @@ class SocketIOService:
user = User.query.get(int(current_user_id)) user = User.query.get(int(current_user_id))
if not user or not user.is_active: 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 return None
logger.debug(f"Successfully found user: {user.email}") logger.debug(f"Successfully found user: {user.email}")
@@ -97,7 +121,9 @@ class SocketIOService:
def handle_connect(auth=None): def handle_connect(auth=None):
"""Handle client connection.""" """Handle client connection."""
try: 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}") logger.info(f"Session ID: {request.sid}")
except Exception: except Exception:
@@ -110,10 +136,10 @@ def handle_authenticate(data):
"""Handle authentication after connection.""" """Handle authentication after connection."""
try: try:
user = SocketIOService.get_user_from_socketio() user = SocketIOService.get_user_from_socketio()
if not user: if not user:
logger.warning("SocketIO authentication failed - no user found") logger.warning("SocketIO authentication failed - no user found")
emit("auth_error", {"error": "Authentication failed"}) # emit("auth_error", {"error": "Authentication failed"})
disconnect() disconnect()
return return
@@ -126,20 +152,56 @@ def handle_authenticate(data):
logger.info(f"User {user_id} authenticated and joined room {user_room}") logger.info(f"User {user_id} authenticated and joined room {user_room}")
# Send current credits on authentication # Send current credits on authentication
emit("auth_success", {"user": user}) SocketIOService.emit_to_user(user_id, "auth_success", {"user": user})
emit("credits_changed", {"credits": user["credits"]}) SocketIOService.emit_to_user(
user_id, "credits_changed", {"credits": user["credits"]}
)
except Exception: except Exception:
logger.exception("Error handling SocketIO authentication") logger.exception("Error handling SocketIO authentication")
emit("auth_error", {"error": "Authentication failed"}) # emit("auth_error", {"error": "Authentication failed"})
disconnect() disconnect()
@socketio.on("test_event") # @socketio.on("play_sound")
def handle_test_event(data): # @require_credits(1)
"""Test handler to verify SocketIO events are working.""" # def handle_play_sound(data):
logger.debug(f"Test event received: {data}") # """Handle play_sound event from client."""
emit("test_response", {"message": "Test event received successfully"}) # 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") @socketio.on("disconnect")

View File

@@ -7,7 +7,6 @@ import re
from pathlib import Path from pathlib import Path
import ffmpeg import ffmpeg
from pydub import AudioSegment
from app.database import db from app.database import db
from app.models.sound import Sound from app.models.sound import Sound
@@ -632,9 +631,17 @@ class SoundNormalizerService:
# Calculate file hash # Calculate file hash
file_hash = SoundNormalizerService._calculate_file_hash(file_path) file_hash = SoundNormalizerService._calculate_file_hash(file_path)
# Get duration using pydub # Get duration using ffmpeg
audio = AudioSegment.from_wav(file_path) probe = ffmpeg.probe(file_path)
duration = len(audio) # Duration in milliseconds 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 { return {
"duration": duration, "duration": duration,

View File

@@ -4,8 +4,7 @@ import hashlib
import logging import logging
from pathlib import Path from pathlib import Path
from pydub import AudioSegment import ffmpeg
from pydub.utils import mediainfo
from app.database import db from app.database import db
from app.models.sound import Sound from app.models.sound import Sound
@@ -281,32 +280,31 @@ class SoundScannerService:
@staticmethod @staticmethod
def _extract_audio_metadata(file_path: str) -> dict: 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: try:
# Get file size # Get file size
file_size = Path(file_path).stat().st_size file_size = Path(file_path).stat().st_size
# Load audio file with pydub for basic info # Use ffmpeg to probe audio metadata
audio = AudioSegment.from_file(file_path) 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 # Extract metadata from ffmpeg probe
duration = len(audio) duration = int(float(audio_stream.get('duration', 0)) * 1000) # Convert to milliseconds
channels = audio.channels channels = int(audio_stream.get('channels', 0))
sample_rate = audio.frame_rate 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
# Use mediainfo for more accurate bitrate information
bitrate = None # Fallback bitrate calculation if not available
try: if not bitrate and duration > 0:
info = mediainfo(file_path) file_size_bits = file_size * 8
if info and "bit_rate" in info: bitrate = int(file_size_bits / (duration / 1000))
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)
return { return {
"duration": duration, "duration": duration,

View File

@@ -538,6 +538,7 @@ class StreamProcessingService:
"""Add a sound to the main playlist.""" """Add a sound to the main playlist."""
try: try:
from app.models.playlist import Playlist from app.models.playlist import Playlist
from app.services.music_player_service import music_player_service
# Find the main playlist # Find the main playlist
main_playlist = Playlist.find_main_playlist() main_playlist = Playlist.find_main_playlist()
@@ -546,6 +547,9 @@ class StreamProcessingService:
# Add sound to the main playlist # Add sound to the main playlist
main_playlist.add_sound(sound.id, commit=True) main_playlist.add_sound(sound.id, commit=True)
logger.info(f"Added sound {sound.id} to main playlist") 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: else:
logger.warning("Main playlist not found - sound not added to any playlist") logger.warning("Main playlist not found - sound not added to any playlist")

View File

@@ -1,4 +1,5 @@
import logging import logging
from dotenv import load_dotenv from dotenv import load_dotenv
from app import create_app, socketio from app import create_app, socketio
@@ -9,8 +10,8 @@ load_dotenv()
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt='%H:%M:%S' datefmt="%H:%M:%S",
) )
@@ -18,7 +19,7 @@ def main() -> None:
"""Run the Flask application with SocketIO.""" """Run the Flask application with SocketIO."""
app = create_app() app = create_app()
socketio.run( 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-migrate==4.1.0",
"flask-socketio==5.5.1", "flask-socketio==5.5.1",
"flask-sqlalchemy==3.1.1", "flask-sqlalchemy==3.1.1",
"pydub==0.25.1",
"python-dotenv==1.1.1", "python-dotenv==1.1.1",
"python-vlc>=3.0.21203", "python-vlc>=3.0.21203",
"requests==2.32.4", "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 }, { 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]] [[package]]
name = "pygments" name = "pygments"
version = "2.19.2" version = "2.19.2"
@@ -645,7 +636,6 @@ dependencies = [
{ name = "flask-migrate" }, { name = "flask-migrate" },
{ name = "flask-socketio" }, { name = "flask-socketio" },
{ name = "flask-sqlalchemy" }, { name = "flask-sqlalchemy" },
{ name = "pydub" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "python-vlc" }, { name = "python-vlc" },
{ name = "requests" }, { name = "requests" },
@@ -671,7 +661,6 @@ requires-dist = [
{ name = "flask-migrate", specifier = "==4.1.0" }, { name = "flask-migrate", specifier = "==4.1.0" },
{ name = "flask-socketio", specifier = "==5.5.1" }, { name = "flask-socketio", specifier = "==5.5.1" },
{ name = "flask-sqlalchemy", specifier = "==3.1.1" }, { name = "flask-sqlalchemy", specifier = "==3.1.1" },
{ name = "pydub", specifier = "==0.25.1" },
{ name = "python-dotenv", specifier = "==1.1.1" }, { name = "python-dotenv", specifier = "==1.1.1" },
{ name = "python-vlc", specifier = ">=3.0.21203" }, { name = "python-vlc", specifier = ">=3.0.21203" },
{ name = "requests", specifier = "==2.32.4" }, { name = "requests", specifier = "==2.32.4" },