Compare commits
33 Commits
f68d046653
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1f9667edd | ||
|
|
4cfc2ec0a2 | ||
|
|
39b7e14ae9 | ||
|
|
d0bda6c930 | ||
|
|
010f18bff4 | ||
|
|
e874d0665f | ||
|
|
ae238d3d18 | ||
|
|
7226d87a77 | ||
|
|
b17e0db2b0 | ||
|
|
64074685a3 | ||
|
|
688b95b6af | ||
|
|
627b95c961 | ||
|
|
fc734e2581 | ||
|
|
4e96c3538c | ||
|
|
6bbf3dce66 | ||
|
|
842e1dff13 | ||
|
|
93897921fb | ||
|
|
4f702d3302 | ||
|
|
7d224d1db7 | ||
|
|
2e464dc977 | ||
|
|
193bd5ebf4 | ||
|
|
96ab2bdf77 | ||
|
|
bcd6ca8104 | ||
|
|
9ac55f8904 | ||
|
|
e7d958eb39 | ||
|
|
c44b055f83 | ||
|
|
fe628b99d4 | ||
|
|
d7c6efcd0e | ||
|
|
4f18f3e64e | ||
|
|
61db6c56dc | ||
|
|
fac4fdf212 | ||
|
|
024c58f013 | ||
|
|
21541c8184 |
@@ -17,3 +17,6 @@ GOOGLE_CLIENT_SECRET=your_google_client_secret_here
|
|||||||
# GitHub OAuth
|
# GitHub OAuth
|
||||||
GITHUB_CLIENT_ID=your_github_client_id_here
|
GITHUB_CLIENT_ID=your_github_client_id_here
|
||||||
GITHUB_CLIENT_SECRET=your_github_client_secret_here
|
GITHUB_CLIENT_SECRET=your_github_client_secret_here
|
||||||
|
|
||||||
|
# Stream Processing Configuration
|
||||||
|
STREAM_MAX_CONCURRENT=2
|
||||||
@@ -37,9 +37,8 @@ def create_app():
|
|||||||
app.config["JWT_TOKEN_LOCATION"] = ["cookies"]
|
app.config["JWT_TOKEN_LOCATION"] = ["cookies"]
|
||||||
app.config["JWT_COOKIE_SECURE"] = False # Set to True in production
|
app.config["JWT_COOKIE_SECURE"] = False # Set to True in production
|
||||||
app.config["JWT_COOKIE_CSRF_PROTECT"] = False
|
app.config["JWT_COOKIE_CSRF_PROTECT"] = False
|
||||||
app.config["JWT_ACCESS_COOKIE_PATH"] = (
|
app.config["JWT_COOKIE_SAMESITE"] = "Lax" # Allow cross-origin requests
|
||||||
"/" # Allow access to all paths including SocketIO
|
app.config["JWT_ACCESS_COOKIE_PATH"] = "/api/" # Restrict to API paths only
|
||||||
)
|
|
||||||
app.config["JWT_REFRESH_COOKIE_PATH"] = "/api/auth/refresh"
|
app.config["JWT_REFRESH_COOKIE_PATH"] = "/api/auth/refresh"
|
||||||
|
|
||||||
# Initialize CORS
|
# Initialize CORS
|
||||||
@@ -56,6 +55,7 @@ def create_app():
|
|||||||
app,
|
app,
|
||||||
cors_allowed_origins="http://localhost:3000",
|
cors_allowed_origins="http://localhost:3000",
|
||||||
cors_credentials=True,
|
cors_credentials=True,
|
||||||
|
path="/api/socket.io/", # Use /api prefix for Socket.IO
|
||||||
)
|
)
|
||||||
|
|
||||||
# Initialize JWT manager
|
# Initialize JWT manager
|
||||||
@@ -82,20 +82,46 @@ def create_app():
|
|||||||
# Start scheduler for background tasks
|
# Start scheduler for background tasks
|
||||||
scheduler_service.start()
|
scheduler_service.start()
|
||||||
|
|
||||||
|
# Initialize stream processing service
|
||||||
|
from app.services.stream_processing_service import StreamProcessingService
|
||||||
|
|
||||||
|
StreamProcessingService.initialize(app)
|
||||||
|
|
||||||
|
# Initialize music player service
|
||||||
|
from app.services.music_player_service import music_player_service
|
||||||
|
|
||||||
|
music_player_service.app = app # Store app instance for Flask context
|
||||||
|
music_player_service.start_vlc_instance()
|
||||||
|
|
||||||
# Register blueprints
|
# Register blueprints
|
||||||
from app.routes import admin, admin_sounds, auth, main, soundboard
|
from app.routes import (
|
||||||
|
admin,
|
||||||
|
admin_sounds,
|
||||||
|
auth,
|
||||||
|
main,
|
||||||
|
player,
|
||||||
|
referential,
|
||||||
|
soundboard,
|
||||||
|
sounds,
|
||||||
|
stream,
|
||||||
|
)
|
||||||
|
|
||||||
app.register_blueprint(main.bp, url_prefix="/api")
|
app.register_blueprint(main.bp, url_prefix="/api")
|
||||||
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)
|
app.register_blueprint(admin_sounds.bp, url_prefix="/api/admin/sounds")
|
||||||
app.register_blueprint(soundboard.bp)
|
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")
|
||||||
|
app.register_blueprint(player.bp, url_prefix="/api/player")
|
||||||
|
|
||||||
# Shutdown scheduler when app is torn down
|
# Shutdown services when app is torn down
|
||||||
@app.teardown_appcontext
|
@app.teardown_appcontext
|
||||||
def shutdown_scheduler(exception):
|
def shutdown_services(exception):
|
||||||
"""Stop scheduler when app context is torn down."""
|
"""Stop services when app context is torn down."""
|
||||||
if exception:
|
if exception:
|
||||||
scheduler_service.stop()
|
scheduler_service.stop()
|
||||||
|
# music_player_service.stop_vlc_instance()
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ def init_database():
|
|||||||
# Seed plans if they don't exist
|
# Seed plans if they don't exist
|
||||||
seed_plans()
|
seed_plans()
|
||||||
|
|
||||||
# Migrate existing users to have plans
|
# Create default Main playlist if it doesn't exist
|
||||||
migrate_users_to_plans()
|
seed_main_playlist()
|
||||||
|
|
||||||
|
|
||||||
def seed_plans():
|
def seed_plans():
|
||||||
@@ -55,69 +55,35 @@ def seed_plans():
|
|||||||
print(f"Seeded {len(plans_data)} plans into database")
|
print(f"Seeded {len(plans_data)} plans into database")
|
||||||
|
|
||||||
|
|
||||||
def migrate_users_to_plans():
|
def seed_main_playlist():
|
||||||
"""Assign plans to existing users who don't have one."""
|
"""Create the default Main playlist if it doesn't exist."""
|
||||||
from app.models.user import User
|
from app.models.playlist import Playlist
|
||||||
|
|
||||||
try:
|
# Check if Main playlist already exists
|
||||||
# Find users without plans
|
main_playlist = Playlist.query.filter_by(name="Main", user_id=None).first()
|
||||||
users_without_plans = User.query.filter(User.plan_id.is_(None)).all()
|
|
||||||
|
|
||||||
# Find users with plans but NULL credits (only if credits column exists)
|
if main_playlist is None:
|
||||||
# Note: We only migrate users with NULL credits, not 0 credits
|
# Create the Main playlist
|
||||||
# 0 credits means they spent them, NULL means they never got assigned
|
main_playlist = Playlist.create_playlist(
|
||||||
try:
|
name="Main",
|
||||||
users_without_credits = User.query.filter(
|
description="Default main playlist for all sounds",
|
||||||
User.plan_id.isnot(None),
|
genre=None,
|
||||||
User.credits.is_(None),
|
user_id=None, # System playlist
|
||||||
).all()
|
is_main=True,
|
||||||
except Exception:
|
is_deletable=False,
|
||||||
# Credits column doesn't exist yet, will be handled by create_all
|
is_current=True,
|
||||||
users_without_credits = []
|
commit=True,
|
||||||
|
|
||||||
if not users_without_plans and not users_without_credits:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Get default and pro plans
|
|
||||||
default_plan = Plan.get_default_plan()
|
|
||||||
pro_plan = Plan.get_pro_plan()
|
|
||||||
|
|
||||||
# Get the first user (admin) from all users ordered by ID
|
|
||||||
first_user = User.query.order_by(User.id).first()
|
|
||||||
|
|
||||||
updated_count = 0
|
|
||||||
|
|
||||||
# Assign plans to users without plans
|
|
||||||
for user in users_without_plans:
|
|
||||||
# First user gets pro plan, others get free plan
|
|
||||||
if user.id == first_user.id:
|
|
||||||
user.plan_id = pro_plan.id
|
|
||||||
# Only set credits if the column exists
|
|
||||||
try:
|
|
||||||
user.credits = pro_plan.credits
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
user.plan_id = default_plan.id
|
|
||||||
# Only set credits if the column exists
|
|
||||||
try:
|
|
||||||
user.credits = default_plan.credits
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
updated_count += 1
|
|
||||||
|
|
||||||
# Assign credits to users with plans but no credits
|
|
||||||
for user in users_without_credits:
|
|
||||||
user.credits = user.plan.credits
|
|
||||||
updated_count += 1
|
|
||||||
|
|
||||||
if updated_count > 0:
|
|
||||||
db.session.commit()
|
|
||||||
print(
|
|
||||||
f"Updated {updated_count} existing users with plans and credits",
|
|
||||||
)
|
)
|
||||||
|
print("Created default Main playlist")
|
||||||
except Exception:
|
else:
|
||||||
# If there's any error (like missing columns), just skip migration
|
# Ensure the existing Main playlist has correct properties
|
||||||
# The database will be properly created by create_all()
|
if (
|
||||||
pass
|
not main_playlist.is_main
|
||||||
|
or main_playlist.is_deletable
|
||||||
|
or not main_playlist.is_current
|
||||||
|
):
|
||||||
|
main_playlist.is_main = True
|
||||||
|
main_playlist.is_deletable = False
|
||||||
|
main_playlist.is_current = True
|
||||||
|
db.session.commit()
|
||||||
|
print("Updated existing Main playlist properties")
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
"""Database models."""
|
"""Database models."""
|
||||||
|
|
||||||
from .plan import Plan
|
from .plan import Plan
|
||||||
|
from .playlist import Playlist
|
||||||
|
from .playlist_sound import PlaylistSound
|
||||||
from .sound import Sound
|
from .sound import Sound
|
||||||
|
from .stream import Stream
|
||||||
from .user import User
|
from .user import User
|
||||||
from .user_oauth import UserOAuth
|
from .user_oauth import UserOAuth
|
||||||
|
|
||||||
__all__ = ["Plan", "Sound", "User", "UserOAuth"]
|
__all__ = ["Plan", "Playlist", "PlaylistSound", "Sound", "Stream", "User", "UserOAuth"]
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from app.database import db
|
|||||||
class Plan(db.Model):
|
class Plan(db.Model):
|
||||||
"""Plan model for user subscription plans."""
|
"""Plan model for user subscription plans."""
|
||||||
|
|
||||||
__tablename__ = "plans"
|
__tablename__ = "plan"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
code = Column(String(50), unique=True, nullable=False, index=True)
|
code = Column(String(50), unique=True, nullable=False, index=True)
|
||||||
|
|||||||
156
app/models/playlist.py
Normal file
156
app/models/playlist.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"""Playlist model for managing sound playlists."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import db
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.playlist_sound import PlaylistSound
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class Playlist(db.Model):
|
||||||
|
"""Model for playlists containing sounds."""
|
||||||
|
|
||||||
|
__tablename__ = "playlist"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
genre: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
|
user_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("user.id"), nullable=True
|
||||||
|
)
|
||||||
|
is_main: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=False, nullable=False
|
||||||
|
)
|
||||||
|
is_deletable: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=True, nullable=True
|
||||||
|
)
|
||||||
|
is_current: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=False, nullable=False
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
default=lambda: datetime.now(tz=ZoneInfo("UTC")),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
default=lambda: datetime.now(tz=ZoneInfo("UTC")),
|
||||||
|
onupdate=lambda: datetime.now(tz=ZoneInfo("UTC")),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user: Mapped[Optional["User"]] = relationship(
|
||||||
|
"User", back_populates="playlists"
|
||||||
|
)
|
||||||
|
playlist_sounds: Mapped[list["PlaylistSound"]] = relationship(
|
||||||
|
"PlaylistSound", back_populates="playlist", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
"""String representation of the playlist."""
|
||||||
|
return f"<Playlist(id={self.id}, name='{self.name}', user_id={self.user_id})>"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert playlist to dictionary representation."""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"description": self.description,
|
||||||
|
"genre": self.genre,
|
||||||
|
"user_id": self.user_id,
|
||||||
|
"is_main": self.is_main,
|
||||||
|
"is_deletable": self.is_deletable,
|
||||||
|
"is_current": self.is_current,
|
||||||
|
"created_at": (
|
||||||
|
self.created_at.isoformat() if self.created_at else None
|
||||||
|
),
|
||||||
|
"updated_at": (
|
||||||
|
self.updated_at.isoformat() if self.updated_at else None
|
||||||
|
),
|
||||||
|
"sound_count": (
|
||||||
|
len(self.playlist_sounds) if self.playlist_sounds else 0
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_playlist(
|
||||||
|
cls,
|
||||||
|
name: str,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
genre: Optional[str] = None,
|
||||||
|
user_id: Optional[int] = None,
|
||||||
|
is_main: bool = False,
|
||||||
|
is_deletable: bool = True,
|
||||||
|
is_current: bool = False,
|
||||||
|
commit: bool = True,
|
||||||
|
) -> "Playlist":
|
||||||
|
"""Create a new playlist."""
|
||||||
|
playlist = cls(
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
genre=genre,
|
||||||
|
user_id=user_id,
|
||||||
|
is_main=is_main,
|
||||||
|
is_deletable=is_deletable,
|
||||||
|
is_current=is_current,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(playlist)
|
||||||
|
if commit:
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return playlist
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_current_playlist(
|
||||||
|
cls, user_id: Optional[int] = None
|
||||||
|
) -> Optional["Playlist"]:
|
||||||
|
"""Find the current active playlist."""
|
||||||
|
query = cls.query.filter_by(is_current=True)
|
||||||
|
if user_id is not None:
|
||||||
|
query = query.filter_by(user_id=user_id)
|
||||||
|
return query.first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_main_playlist(
|
||||||
|
cls, user_id: Optional[int] = None
|
||||||
|
) -> Optional["Playlist"]:
|
||||||
|
"""Find the main playlist."""
|
||||||
|
query = cls.query.filter_by(is_main=True)
|
||||||
|
if user_id is not None:
|
||||||
|
query = query.filter_by(user_id=user_id)
|
||||||
|
return query.first()
|
||||||
|
|
||||||
|
def add_sound(
|
||||||
|
self, sound_id: int, order: Optional[int] = None, commit: bool = True
|
||||||
|
) -> "PlaylistSound":
|
||||||
|
"""Add a sound to the playlist."""
|
||||||
|
from app.models.playlist_sound import PlaylistSound
|
||||||
|
|
||||||
|
if order is None:
|
||||||
|
# Get the next order number
|
||||||
|
max_order = (
|
||||||
|
db.session.query(db.func.max(PlaylistSound.order))
|
||||||
|
.filter_by(playlist_id=self.id)
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
order = (max_order or 0) + 1
|
||||||
|
|
||||||
|
playlist_sound = PlaylistSound(
|
||||||
|
playlist_id=self.id, sound_id=sound_id, order=order
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(playlist_sound)
|
||||||
|
if commit:
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return playlist_sound
|
||||||
65
app/models/playlist_sound.py
Normal file
65
app/models/playlist_sound.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""Playlist-Sound relationship model for managing sound order in playlists."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Integer, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import db
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.playlist import Playlist
|
||||||
|
from app.models.sound import Sound
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistSound(db.Model):
|
||||||
|
"""Model for playlist-sound relationships with ordering."""
|
||||||
|
|
||||||
|
__tablename__ = "playlist_sound"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
playlist_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("playlist.id"), nullable=False
|
||||||
|
)
|
||||||
|
sound_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("sound.id"), nullable=False
|
||||||
|
)
|
||||||
|
order: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
added_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
default=lambda: datetime.now(tz=ZoneInfo("UTC")),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
playlist: Mapped["Playlist"] = relationship(
|
||||||
|
"Playlist", back_populates="playlist_sounds"
|
||||||
|
)
|
||||||
|
sound: Mapped["Sound"] = relationship(
|
||||||
|
"Sound", back_populates="playlist_sounds"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Constraints
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"playlist_id", "sound_id", name="unique_playlist_sound"
|
||||||
|
),
|
||||||
|
UniqueConstraint("playlist_id", "order", name="unique_playlist_order"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
"""String representation of the playlist-sound relationship."""
|
||||||
|
return f"<PlaylistSound(playlist_id={self.playlist_id}, sound_id={self.sound_id}, order={self.order})>"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert playlist-sound relationship to dictionary representation."""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"playlist_id": self.playlist_id,
|
||||||
|
"sound_id": self.sound_id,
|
||||||
|
"order": self.order,
|
||||||
|
"added_at": self.added_at.isoformat() if self.added_at else None,
|
||||||
|
"sound": self.sound.to_dict() if self.sound else None,
|
||||||
|
}
|
||||||
@@ -2,14 +2,18 @@
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, Integer, String
|
from sqlalchemy import Boolean, DateTime, Integer, String
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.database import db
|
from app.database import db
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.playlist_sound import PlaylistSound
|
||||||
|
from app.models.stream import Stream
|
||||||
|
|
||||||
|
|
||||||
class SoundType(Enum):
|
class SoundType(Enum):
|
||||||
"""Sound type enumeration."""
|
"""Sound type enumeration."""
|
||||||
@@ -22,7 +26,7 @@ class SoundType(Enum):
|
|||||||
class Sound(db.Model):
|
class Sound(db.Model):
|
||||||
"""Sound model for storing sound file information."""
|
"""Sound model for storing sound file information."""
|
||||||
|
|
||||||
__tablename__ = "sounds"
|
__tablename__ = "sound"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
|
||||||
@@ -32,6 +36,9 @@ class Sound(db.Model):
|
|||||||
# Basic sound information
|
# Basic sound information
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
filename: Mapped[str] = mapped_column(String(500), nullable=False)
|
filename: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
|
thumbnail: Mapped[str | None] = mapped_column(
|
||||||
|
String(500), nullable=True
|
||||||
|
) # Thumbnail filename
|
||||||
duration: Mapped[int] = mapped_column(Integer, nullable=False)
|
duration: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
size: Mapped[int] = mapped_column(Integer, nullable=False) # Size in bytes
|
size: Mapped[int] = mapped_column(Integer, nullable=False) # Size in bytes
|
||||||
hash: Mapped[str] = mapped_column(String(64), nullable=False) # SHA256 hash
|
hash: Mapped[str] = mapped_column(String(64), nullable=False) # SHA256 hash
|
||||||
@@ -87,6 +94,18 @@ class Sound(db.Model):
|
|||||||
nullable=False,
|
nullable=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
playlist_sounds: Mapped[list["PlaylistSound"]] = relationship(
|
||||||
|
"PlaylistSound",
|
||||||
|
back_populates="sound",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
streams: Mapped[list["Stream"]] = relationship(
|
||||||
|
"Stream",
|
||||||
|
back_populates="sound",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
"""String representation of Sound."""
|
"""String representation of Sound."""
|
||||||
return f"<Sound {self.name} ({self.type}) - {self.play_count} plays>"
|
return f"<Sound {self.name} ({self.type}) - {self.play_count} plays>"
|
||||||
@@ -98,6 +117,7 @@ class Sound(db.Model):
|
|||||||
"type": self.type,
|
"type": self.type,
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
"filename": self.filename,
|
"filename": self.filename,
|
||||||
|
"thumbnail": self.thumbnail,
|
||||||
"duration": self.duration,
|
"duration": self.duration,
|
||||||
"size": self.size,
|
"size": self.size,
|
||||||
"hash": self.hash,
|
"hash": self.hash,
|
||||||
@@ -177,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,
|
||||||
@@ -201,6 +206,7 @@ class Sound(db.Model):
|
|||||||
duration: float,
|
duration: float,
|
||||||
size: int,
|
size: int,
|
||||||
hash_value: str,
|
hash_value: str,
|
||||||
|
thumbnail: Optional[str] = None,
|
||||||
is_music: bool = False,
|
is_music: bool = False,
|
||||||
is_deletable: bool = True,
|
is_deletable: bool = True,
|
||||||
commit: bool = True,
|
commit: bool = True,
|
||||||
@@ -214,6 +220,7 @@ class Sound(db.Model):
|
|||||||
type=sound_type,
|
type=sound_type,
|
||||||
name=name,
|
name=name,
|
||||||
filename=filename,
|
filename=filename,
|
||||||
|
thumbnail=thumbnail,
|
||||||
duration=duration,
|
duration=duration,
|
||||||
size=size,
|
size=size,
|
||||||
hash=hash_value,
|
hash=hash_value,
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ class SoundPlayed(db.Model):
|
|||||||
# Foreign keys
|
# Foreign keys
|
||||||
user_id: Mapped[int] = mapped_column(
|
user_id: Mapped[int] = mapped_column(
|
||||||
Integer,
|
Integer,
|
||||||
ForeignKey("users.id"),
|
ForeignKey("user.id"),
|
||||||
nullable=False,
|
nullable=True,
|
||||||
)
|
)
|
||||||
sound_id: Mapped[int] = mapped_column(
|
sound_id: Mapped[int] = mapped_column(
|
||||||
Integer,
|
Integer,
|
||||||
ForeignKey("sounds.id"),
|
ForeignKey("sound.id"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -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
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|||||||
124
app/models/stream.py
Normal file
124
app/models/stream.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"""Stream model for storing streaming service links to sounds."""
|
||||||
|
|
||||||
|
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.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import db
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.sound import Sound
|
||||||
|
|
||||||
|
|
||||||
|
class Stream(db.Model):
|
||||||
|
"""Model for storing streaming service information linked to sounds."""
|
||||||
|
|
||||||
|
__tablename__ = "stream"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
service: Mapped[str] = mapped_column(String(50), nullable=True)
|
||||||
|
service_id: Mapped[str] = mapped_column(String(255), nullable=True)
|
||||||
|
sound_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("sound.id"), nullable=True
|
||||||
|
)
|
||||||
|
url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
title: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
track: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
artist: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
album: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
genre: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(50), nullable=False, default="pending"
|
||||||
|
)
|
||||||
|
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
default=lambda: datetime.now(tz=ZoneInfo("UTC")),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
default=lambda: datetime.now(tz=ZoneInfo("UTC")),
|
||||||
|
onupdate=lambda: datetime.now(tz=ZoneInfo("UTC")),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
sound: Mapped["Sound"] = relationship("Sound", back_populates="streams")
|
||||||
|
|
||||||
|
# Constraints
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("service", "service_id", name="unique_service_stream"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
"""String representation of the stream."""
|
||||||
|
return f"<Stream(id={self.id}, service='{self.service}', service_id='{self.service_id}', sound_id={self.sound_id})>"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert stream to dictionary representation."""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"service": self.service,
|
||||||
|
"service_id": self.service_id,
|
||||||
|
"sound_id": self.sound_id,
|
||||||
|
"url": self.url,
|
||||||
|
"title": self.title,
|
||||||
|
"track": self.track,
|
||||||
|
"artist": self.artist,
|
||||||
|
"album": self.album,
|
||||||
|
"genre": self.genre,
|
||||||
|
"status": self.status,
|
||||||
|
"error": self.error,
|
||||||
|
"created_at": (
|
||||||
|
self.created_at.isoformat() if self.created_at else None
|
||||||
|
),
|
||||||
|
"updated_at": (
|
||||||
|
self.updated_at.isoformat() if self.updated_at else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_stream(
|
||||||
|
cls,
|
||||||
|
url: str,
|
||||||
|
service: Optional[str] = None,
|
||||||
|
service_id: Optional[str] = None,
|
||||||
|
sound_id: Optional[int] = None,
|
||||||
|
title: Optional[str] = None,
|
||||||
|
track: Optional[str] = None,
|
||||||
|
artist: Optional[str] = None,
|
||||||
|
album: Optional[str] = None,
|
||||||
|
genre: Optional[str] = None,
|
||||||
|
status: str = "active",
|
||||||
|
commit: bool = True,
|
||||||
|
) -> "Stream":
|
||||||
|
"""Create a new stream record."""
|
||||||
|
stream = cls(
|
||||||
|
service=service,
|
||||||
|
service_id=service_id,
|
||||||
|
sound_id=sound_id,
|
||||||
|
url=url,
|
||||||
|
title=title,
|
||||||
|
track=track,
|
||||||
|
artist=artist,
|
||||||
|
album=album,
|
||||||
|
genre=genre,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(stream)
|
||||||
|
if commit:
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return stream
|
||||||
@@ -13,13 +13,14 @@ from app.database import db
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.models.plan import Plan
|
from app.models.plan import Plan
|
||||||
|
from app.models.playlist import Playlist
|
||||||
from app.models.user_oauth import UserOAuth
|
from app.models.user_oauth import UserOAuth
|
||||||
|
|
||||||
|
|
||||||
class User(db.Model):
|
class User(db.Model):
|
||||||
"""User model for storing user information."""
|
"""User model for storing user information."""
|
||||||
|
|
||||||
__tablename__ = "users"
|
__tablename__ = "user"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ class User(db.Model):
|
|||||||
# Plan relationship
|
# Plan relationship
|
||||||
plan_id: Mapped[int] = mapped_column(
|
plan_id: Mapped[int] = mapped_column(
|
||||||
Integer,
|
Integer,
|
||||||
ForeignKey("plans.id"),
|
ForeignKey("plan.id"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -81,6 +82,11 @@ class User(db.Model):
|
|||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
)
|
)
|
||||||
plan: Mapped["Plan"] = relationship("Plan", back_populates="users")
|
plan: Mapped["Plan"] = relationship("Plan", back_populates="users")
|
||||||
|
playlists: Mapped[list["Playlist"]] = relationship(
|
||||||
|
"Playlist",
|
||||||
|
back_populates="user",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
"""String representation of User."""
|
"""String representation of User."""
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class UserOAuth(db.Model):
|
|||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
|
||||||
# User relationship
|
# User relationship
|
||||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
|
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), nullable=False)
|
||||||
|
|
||||||
# OAuth provider information
|
# OAuth provider information
|
||||||
provider: Mapped[str] = mapped_column(String(50), nullable=False)
|
provider: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
"""Admin routes for the application."""
|
"""Admin routes for the application."""
|
||||||
|
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint
|
||||||
|
|
||||||
from app.services.decorators import (
|
from app.services.decorators import get_current_user, require_auth, require_role
|
||||||
get_current_user,
|
|
||||||
require_auth,
|
|
||||||
require_role,
|
|
||||||
)
|
|
||||||
from app.services.scheduler_service import scheduler_service
|
from app.services.scheduler_service import scheduler_service
|
||||||
from app.services.sound_normalizer_service import SoundNormalizerService
|
|
||||||
from app.services.sound_scanner_service import SoundScannerService
|
|
||||||
|
|
||||||
bp = Blueprint("admin", __name__)
|
bp = Blueprint("admin", __name__)
|
||||||
|
|
||||||
@@ -43,53 +37,139 @@ def manual_credit_refill() -> dict:
|
|||||||
return scheduler_service.trigger_credit_refill_now()
|
return scheduler_service.trigger_credit_refill_now()
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/sounds/scan", methods=["POST"])
|
@bp.route("/users")
|
||||||
@require_auth
|
@require_auth
|
||||||
@require_role("admin")
|
@require_role("admin")
|
||||||
def manual_sound_scan() -> dict:
|
def list_users() -> dict:
|
||||||
"""Manually trigger sound directory scan (admin only)."""
|
"""List all users (admin only)."""
|
||||||
return scheduler_service.trigger_sound_scan_now()
|
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("/sounds/stats")
|
@bp.route("/users/<int:user_id>", methods=["PATCH"])
|
||||||
@require_auth
|
@require_auth
|
||||||
@require_role("admin")
|
@require_role("admin")
|
||||||
def sound_statistics() -> dict:
|
def update_user(user_id: int) -> dict:
|
||||||
"""Get sound database statistics (admin only)."""
|
"""Update user information (admin only)."""
|
||||||
return SoundScannerService.get_scan_statistics()
|
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("/sounds/normalize/<int:sound_id>", methods=["POST"])
|
@bp.route("/users/<int:user_id>/deactivate", methods=["POST"])
|
||||||
@require_auth
|
@require_auth
|
||||||
@require_role("admin")
|
@require_role("admin")
|
||||||
def normalize_sound(sound_id: int) -> dict:
|
def deactivate_user(user_id: int) -> dict:
|
||||||
"""Normalize a specific sound file (admin only)."""
|
"""Deactivate a user (admin only)."""
|
||||||
overwrite = request.args.get("overwrite", "false").lower() == "true"
|
from app.database import db
|
||||||
return SoundNormalizerService.normalize_sound(sound_id, overwrite)
|
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("/sounds/normalize-all", methods=["POST"])
|
@bp.route("/users/<int:user_id>/activate", methods=["POST"])
|
||||||
@require_auth
|
@require_auth
|
||||||
@require_role("admin")
|
@require_role("admin")
|
||||||
def normalize_all_sounds() -> dict:
|
def activate_user(user_id: int) -> dict:
|
||||||
"""Normalize all soundboard files (admin only)."""
|
"""Activate a user (admin only)."""
|
||||||
overwrite = request.args.get("overwrite", "false").lower() == "true"
|
from app.database import db
|
||||||
limit_str = request.args.get("limit")
|
from app.models.user import User
|
||||||
limit = int(limit_str) if limit_str else None
|
|
||||||
return SoundNormalizerService.normalize_all_sounds(overwrite, limit)
|
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
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/sounds/normalization-status")
|
|
||||||
@require_auth
|
|
||||||
@require_role("admin")
|
|
||||||
def normalization_status() -> dict:
|
|
||||||
"""Get normalization status statistics (admin only)."""
|
|
||||||
return SoundNormalizerService.get_normalization_status()
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/sounds/ffmpeg-check")
|
|
||||||
@require_auth
|
|
||||||
@require_role("admin")
|
|
||||||
def ffmpeg_check() -> dict:
|
|
||||||
"""Check ffmpeg availability and capabilities (admin only)."""
|
|
||||||
return SoundNormalizerService.check_ffmpeg_availability()
|
|
||||||
|
|||||||
@@ -2,21 +2,21 @@
|
|||||||
|
|
||||||
from flask import Blueprint, jsonify, request
|
from flask import Blueprint, jsonify, request
|
||||||
|
|
||||||
from app.services.decorators import require_admin
|
from app.services.decorators import require_admin, require_auth, require_role
|
||||||
from app.services.error_handling_service import ErrorHandlingService
|
from app.services.error_handling_service import ErrorHandlingService
|
||||||
|
from app.services.scheduler_service import scheduler_service
|
||||||
from app.services.sound_normalizer_service import SoundNormalizerService
|
from app.services.sound_normalizer_service import SoundNormalizerService
|
||||||
from app.services.sound_scanner_service import SoundScannerService
|
from app.services.sound_scanner_service import SoundScannerService
|
||||||
|
|
||||||
bp = Blueprint("admin_sounds", __name__, url_prefix="/api/admin/sounds")
|
bp = Blueprint("admin_sounds", __name__)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/scan", methods=["POST"])
|
@bp.route("/scan", methods=["POST"])
|
||||||
@require_admin
|
@require_admin
|
||||||
def scan_sounds():
|
def scan_sounds():
|
||||||
"""Manually trigger sound scanning."""
|
"""Manually trigger sound scanning."""
|
||||||
return ErrorHandlingService.wrap_service_call(
|
return ErrorHandlingService.handle_service_result(
|
||||||
SoundScannerService.scan_soundboard_directory,
|
scheduler_service.trigger_sound_scan_now()
|
||||||
request.get_json().get("directory") if request.get_json() else None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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))
|
||||||
return {
|
|
||||||
"message": "Expensive operation completed successfully!",
|
period_start = get_period_filter(period)
|
||||||
"user": user["email"],
|
|
||||||
"operation_cost": 10,
|
# 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 {
|
||||||
|
"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,
|
||||||
}
|
}
|
||||||
|
|||||||
207
app/routes/player.py
Normal file
207
app/routes/player.py
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
"""Music player API routes."""
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
|
||||||
|
from app.services.decorators import require_auth
|
||||||
|
from app.services.error_handling_service import ErrorHandlingService
|
||||||
|
from app.services.music_player_service import music_player_service
|
||||||
|
|
||||||
|
bp = Blueprint("player", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/state", methods=["GET"])
|
||||||
|
@require_auth
|
||||||
|
def get_player_state():
|
||||||
|
"""Get current player state."""
|
||||||
|
try:
|
||||||
|
state = music_player_service.get_player_state()
|
||||||
|
return jsonify(state), 200
|
||||||
|
except Exception as e:
|
||||||
|
return ErrorHandlingService.handle_generic_error(e)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/play", methods=["POST"])
|
||||||
|
@require_auth
|
||||||
|
def play():
|
||||||
|
"""Start playback."""
|
||||||
|
try:
|
||||||
|
success = music_player_service.play()
|
||||||
|
if success:
|
||||||
|
return jsonify({"message": "Playback started"}), 200
|
||||||
|
return jsonify({"error": "Failed to start playback"}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return ErrorHandlingService.handle_generic_error(e)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/pause", methods=["POST"])
|
||||||
|
@require_auth
|
||||||
|
def pause():
|
||||||
|
"""Pause playback."""
|
||||||
|
try:
|
||||||
|
success = music_player_service.pause()
|
||||||
|
if success:
|
||||||
|
return jsonify({"message": "Playback paused"}), 200
|
||||||
|
return jsonify({"error": "Failed to pause playback"}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return ErrorHandlingService.handle_generic_error(e)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/stop", methods=["POST"])
|
||||||
|
@require_auth
|
||||||
|
def stop():
|
||||||
|
"""Stop playback."""
|
||||||
|
try:
|
||||||
|
success = music_player_service.stop()
|
||||||
|
if success:
|
||||||
|
return jsonify({"message": "Playback stopped"}), 200
|
||||||
|
return jsonify({"error": "Failed to stop playback"}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return ErrorHandlingService.handle_generic_error(e)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/next", methods=["POST"])
|
||||||
|
@require_auth
|
||||||
|
def next_track():
|
||||||
|
"""Skip to next track."""
|
||||||
|
try:
|
||||||
|
success = music_player_service.next_track()
|
||||||
|
if success:
|
||||||
|
return jsonify({"message": "Skipped to next track"}), 200
|
||||||
|
return jsonify({"error": "Failed to skip to next track"}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return ErrorHandlingService.handle_generic_error(e)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/previous", methods=["POST"])
|
||||||
|
@require_auth
|
||||||
|
def previous_track():
|
||||||
|
"""Skip to previous track."""
|
||||||
|
try:
|
||||||
|
success = music_player_service.previous_track()
|
||||||
|
if success:
|
||||||
|
return jsonify({"message": "Skipped to previous track"}), 200
|
||||||
|
return jsonify({"error": "Failed to skip to previous track"}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return ErrorHandlingService.handle_generic_error(e)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/seek", methods=["POST"])
|
||||||
|
@require_auth
|
||||||
|
def seek():
|
||||||
|
"""Seek to position."""
|
||||||
|
try:
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
success = music_player_service.seek(position)
|
||||||
|
if success:
|
||||||
|
return jsonify({"message": "Seek successful"}), 200
|
||||||
|
return jsonify({"error": "Failed to seek"}), 400
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return jsonify({"error": "Invalid position value"}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return ErrorHandlingService.handle_generic_error(e)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/volume", methods=["POST"])
|
||||||
|
@require_auth
|
||||||
|
def set_volume():
|
||||||
|
"""Set volume."""
|
||||||
|
try:
|
||||||
|
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
|
||||||
|
return jsonify({"error": "Failed to set volume"}), 400
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return jsonify({"error": "Invalid volume value"}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return ErrorHandlingService.handle_generic_error(e)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/mode", methods=["POST"])
|
||||||
|
@require_auth
|
||||||
|
def set_play_mode():
|
||||||
|
"""Set play mode."""
|
||||||
|
try:
|
||||||
|
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",
|
||||||
|
"single",
|
||||||
|
]
|
||||||
|
if mode not in valid_modes:
|
||||||
|
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
|
||||||
|
return jsonify({"error": "Failed to set play mode"}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return ErrorHandlingService.handle_generic_error(e)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/playlist", methods=["POST"])
|
||||||
|
@require_auth
|
||||||
|
def load_playlist():
|
||||||
|
"""Load a playlist into the player."""
|
||||||
|
try:
|
||||||
|
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:
|
||||||
|
return jsonify({"message": "Playlist loaded successfully"}), 200
|
||||||
|
return jsonify({"error": "Failed to load playlist"}), 400
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return jsonify({"error": "Invalid playlist ID"}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return ErrorHandlingService.handle_generic_error(e)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/play-track", methods=["POST"])
|
||||||
|
@require_auth
|
||||||
|
def play_track():
|
||||||
|
"""Play track at specific index."""
|
||||||
|
try:
|
||||||
|
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:
|
||||||
|
return jsonify({"message": "Track playing"}), 200
|
||||||
|
return jsonify({"error": "Failed to play track"}), 400
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return jsonify({"error": "Invalid track index"}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return ErrorHandlingService.handle_generic_error(e)
|
||||||
17
app/routes/referential.py
Normal file
17
app/routes/referential.py
Normal 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)
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ from app.services.decorators import (
|
|||||||
)
|
)
|
||||||
from app.services.vlc_service import vlc_service
|
from app.services.vlc_service import vlc_service
|
||||||
|
|
||||||
bp = Blueprint("soundboard", __name__, url_prefix="/api/soundboard")
|
bp = Blueprint("soundboard", __name__)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/sounds", methods=["GET"])
|
@bp.route("/sounds", methods=["GET"])
|
||||||
@@ -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
|
|
||||||
|
|||||||
99
app/routes/sounds.py
Normal file
99
app/routes/sounds.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""Routes for serving sound files and thumbnails."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from flask import Blueprint, send_from_directory, abort
|
||||||
|
|
||||||
|
from app.services.decorators import require_auth
|
||||||
|
|
||||||
|
bp = Blueprint("sounds", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/<sound_type>/thumbnails/<path:filename>")
|
||||||
|
def serve_thumbnail(sound_type, filename):
|
||||||
|
"""Serve thumbnail files for sounds."""
|
||||||
|
try:
|
||||||
|
# Map sound type codes to directory names
|
||||||
|
type_mapping = {
|
||||||
|
"str": "stream",
|
||||||
|
"sdb": "soundboard",
|
||||||
|
"say": "say"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security: validate sound type
|
||||||
|
if sound_type not in type_mapping:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
# Basic filename validation (no path traversal)
|
||||||
|
if ".." in filename or "/" in filename or "\\" in filename:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
if not filename or not filename.strip():
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
# Get the actual directory name
|
||||||
|
directory_name = type_mapping[sound_type]
|
||||||
|
|
||||||
|
# Construct the thumbnail directory path
|
||||||
|
sounds_dir = os.path.join(os.getcwd(), "sounds")
|
||||||
|
thumbnail_dir = os.path.join(sounds_dir, directory_name, "thumbnails")
|
||||||
|
|
||||||
|
# Check if thumbnail directory exists
|
||||||
|
if not os.path.exists(thumbnail_dir):
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
# Check if file exists
|
||||||
|
file_path = os.path.join(thumbnail_dir, filename)
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
# Serve the file
|
||||||
|
return send_from_directory(thumbnail_dir, filename)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/<sound_type>/audio/<path:filename>")
|
||||||
|
@require_auth
|
||||||
|
def serve_audio(sound_type, filename):
|
||||||
|
"""Serve audio files for sounds."""
|
||||||
|
try:
|
||||||
|
# Map sound type codes to directory names
|
||||||
|
type_mapping = {
|
||||||
|
"str": "stream",
|
||||||
|
"sdb": "soundboard",
|
||||||
|
"say": "say"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security: validate sound type
|
||||||
|
if sound_type not in type_mapping:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
# Basic filename validation (no path traversal)
|
||||||
|
if ".." in filename or "/" in filename or "\\" in filename:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
if not filename or not filename.strip():
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
# Get the actual directory name
|
||||||
|
directory_name = type_mapping[sound_type]
|
||||||
|
|
||||||
|
# Construct the audio directory path
|
||||||
|
sounds_dir = os.path.join(os.getcwd(), "sounds")
|
||||||
|
audio_dir = os.path.join(sounds_dir, directory_name)
|
||||||
|
|
||||||
|
# Check if audio directory exists
|
||||||
|
if not os.path.exists(audio_dir):
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
# Check if file exists
|
||||||
|
file_path = os.path.join(audio_dir, filename)
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
# Serve the file
|
||||||
|
return send_from_directory(audio_dir, filename)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
abort(404)
|
||||||
92
app/routes/stream.py
Normal file
92
app/routes/stream.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""Stream routes for managing streaming service links."""
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
|
||||||
|
from app.database import db
|
||||||
|
from app.models.stream import Stream
|
||||||
|
from app.services.decorators import require_auth
|
||||||
|
|
||||||
|
bp = Blueprint("stream", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/add-url", methods=["POST"])
|
||||||
|
@require_auth
|
||||||
|
def add_url():
|
||||||
|
"""Add a URL to the stream processing queue."""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data or "url" not in data:
|
||||||
|
return jsonify({"error": "URL is required"}), 400
|
||||||
|
|
||||||
|
url = data["url"].strip()
|
||||||
|
if not url:
|
||||||
|
return jsonify({"error": "URL cannot be empty"}), 400
|
||||||
|
|
||||||
|
# Check if URL already exists
|
||||||
|
existing_stream = Stream.query.filter_by(url=url).first()
|
||||||
|
if existing_stream:
|
||||||
|
return (
|
||||||
|
jsonify(
|
||||||
|
{
|
||||||
|
"error": "URL already exists in stream",
|
||||||
|
"stream": existing_stream.to_dict(),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
409,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to extract basic metadata to check for service/service_id duplicates
|
||||||
|
from app.services.stream_processing_service import (
|
||||||
|
StreamProcessingService,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
metadata, _ = StreamProcessingService._extract_metadata(url)
|
||||||
|
if metadata:
|
||||||
|
service = metadata.get("service")
|
||||||
|
service_id = metadata.get("service_id")
|
||||||
|
|
||||||
|
if service and service_id:
|
||||||
|
existing_service_stream = Stream.query.filter_by(
|
||||||
|
service=service, service_id=service_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_service_stream:
|
||||||
|
return (
|
||||||
|
jsonify(
|
||||||
|
{
|
||||||
|
"error": f"Stream already exists with {service} ID: {service_id}",
|
||||||
|
"existing_stream": existing_service_stream.to_dict(),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
409,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# If metadata extraction fails here, we'll let the background process handle it
|
||||||
|
# This is just an early check to prevent obvious duplicates
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Create stream entry with pending status
|
||||||
|
stream = Stream.create_stream(url=url, status="pending", commit=True)
|
||||||
|
|
||||||
|
# Add to processing queue (will be implemented next)
|
||||||
|
from app.services.stream_processing_service import (
|
||||||
|
StreamProcessingService,
|
||||||
|
)
|
||||||
|
|
||||||
|
StreamProcessingService.add_to_queue(stream.id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
jsonify(
|
||||||
|
{
|
||||||
|
"message": "URL added to processing queue",
|
||||||
|
"stream": stream.to_dict(),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
201,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
878
app/services/music_player_service.py
Normal file
878
app/services/music_player_service.py
Normal file
@@ -0,0 +1,878 @@
|
|||||||
|
"""Music player service using VLC Python bindings with playlist management and real-time sync."""
|
||||||
|
|
||||||
|
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."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize the music player service."""
|
||||||
|
self.instance: Optional[vlc.Instance] = None
|
||||||
|
self.player: Optional[vlc.MediaPlayer] = None
|
||||||
|
self.app: Optional[Any] = None # Store Flask app instance for context
|
||||||
|
|
||||||
|
self.current_playlist_id: Optional[int] = None
|
||||||
|
self.current_track_index = 0
|
||||||
|
self.playlist_files: list[str] = (
|
||||||
|
[]
|
||||||
|
) # Store file paths for manual playlist management
|
||||||
|
self.volume = 80
|
||||||
|
self.play_mode = (
|
||||||
|
"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 = (
|
||||||
|
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."""
|
||||||
|
try:
|
||||||
|
# Create VLC instance with audio output enabled
|
||||||
|
vlc_args = [
|
||||||
|
"--intf=dummy", # No interface
|
||||||
|
"--no-video", # Audio only
|
||||||
|
]
|
||||||
|
|
||||||
|
self.instance = vlc.Instance(vlc_args)
|
||||||
|
if not self.instance:
|
||||||
|
logger.error("Failed to create VLC instance")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Create media player
|
||||||
|
self.player = self.instance.media_player_new()
|
||||||
|
if not self.player:
|
||||||
|
logger.error("Failed to create VLC media player")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Set initial volume
|
||||||
|
self.player.audio_set_volume(self.volume)
|
||||||
|
|
||||||
|
logger.info("VLC music player started successfully")
|
||||||
|
|
||||||
|
# Automatically load the current playlist
|
||||||
|
self._load_current_playlist_on_startup()
|
||||||
|
|
||||||
|
self._start_sync_thread()
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error starting VLC instance: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def stop_vlc_instance(self) -> bool:
|
||||||
|
"""Stop the VLC instance."""
|
||||||
|
try:
|
||||||
|
self._stop_sync = True
|
||||||
|
if self._sync_thread:
|
||||||
|
self._sync_thread.join(timeout=2)
|
||||||
|
|
||||||
|
if self.player:
|
||||||
|
self.player.stop()
|
||||||
|
|
||||||
|
# Release VLC objects
|
||||||
|
self.player = None
|
||||||
|
self.instance = None
|
||||||
|
|
||||||
|
logger.info("VLC music player stopped")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error stopping VLC instance: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
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:
|
||||||
|
logger.error("VLC not initialized")
|
||||||
|
return False
|
||||||
|
|
||||||
|
with self.lock:
|
||||||
|
# Ensure we have Flask app context for database queries
|
||||||
|
if current_app:
|
||||||
|
with current_app.app_context():
|
||||||
|
playlist = Playlist.query.get(playlist_id)
|
||||||
|
if not playlist:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return self._load_playlist_with_context(
|
||||||
|
playlist, reload
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Fallback for when no Flask context is available
|
||||||
|
logger.warning(
|
||||||
|
"No Flask context available for loading playlist"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading playlist {playlist_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
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("/")
|
||||||
|
else:
|
||||||
|
# Fallback to localhost if no request context
|
||||||
|
base_url = "http://localhost:5000"
|
||||||
|
return f"{base_url}/api/sounds/{sound_type.lower()}/thumbnails/{thumbnail_filename}"
|
||||||
|
except Exception:
|
||||||
|
# Fallback if request context is not available
|
||||||
|
return f"http://localhost:5000/api/sounds/{sound_type.lower()}/thumbnails/{thumbnail_filename}"
|
||||||
|
|
||||||
|
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
|
||||||
|
self.playlist_files = []
|
||||||
|
|
||||||
|
# Add tracks to our internal playlist
|
||||||
|
for playlist_sound in sorted(
|
||||||
|
playlist.playlist_sounds, key=lambda x: x.order
|
||||||
|
):
|
||||||
|
sound = playlist_sound.sound
|
||||||
|
if sound:
|
||||||
|
file_path = self._get_sound_file_path(sound)
|
||||||
|
if file_path and os.path.exists(file_path):
|
||||||
|
self.playlist_files.append(file_path)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Loaded playlist '{playlist.name}' with {len(self.playlist_files)} tracks"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in _load_playlist_with_context: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _load_track_at_index(self, index: int) -> bool:
|
||||||
|
"""Load a specific track by index."""
|
||||||
|
try:
|
||||||
|
if 0 <= index < len(self.playlist_files):
|
||||||
|
file_path = self.playlist_files[index]
|
||||||
|
media = self.instance.media_new(file_path)
|
||||||
|
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:
|
||||||
|
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(
|
||||||
|
base_normalized_path,
|
||||||
|
sound.normalized_filename,
|
||||||
|
)
|
||||||
|
if os.path.exists(normalized_path):
|
||||||
|
return os.path.abspath(normalized_path)
|
||||||
|
|
||||||
|
# Fall back to original file
|
||||||
|
original_path = os.path.join(base_path, sound.filename)
|
||||||
|
if os.path.exists(original_path):
|
||||||
|
return os.path.abspath(original_path)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting file path for sound {sound.id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def play(self) -> bool:
|
||||||
|
"""Start playback."""
|
||||||
|
try:
|
||||||
|
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
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error starting playback: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def pause(self) -> bool:
|
||||||
|
"""Pause playback."""
|
||||||
|
try:
|
||||||
|
if not self.player:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.player.pause()
|
||||||
|
self.is_playing = False
|
||||||
|
self._emit_player_state()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error pausing playback: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def stop(self) -> bool:
|
||||||
|
"""Stop playback."""
|
||||||
|
try:
|
||||||
|
if not self.player:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.player.stop()
|
||||||
|
self.is_playing = False
|
||||||
|
self.current_time = 0
|
||||||
|
self._emit_player_state()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error stopping playback: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def next_track(self, force_play: bool = False) -> bool:
|
||||||
|
"""Skip to next track."""
|
||||||
|
try:
|
||||||
|
if not self.playlist_files:
|
||||||
|
return False
|
||||||
|
|
||||||
|
next_index = self.current_track_index + 1
|
||||||
|
|
||||||
|
# Handle different play modes
|
||||||
|
if self.play_mode == "loop-playlist" and next_index >= len(
|
||||||
|
self.playlist_files
|
||||||
|
):
|
||||||
|
next_index = 0
|
||||||
|
elif self.play_mode == "random":
|
||||||
|
import random
|
||||||
|
|
||||||
|
next_index = random.randint(0, len(self.playlist_files) - 1)
|
||||||
|
elif next_index >= len(self.playlist_files):
|
||||||
|
# End of playlist in continuous mode
|
||||||
|
self.stop()
|
||||||
|
return True
|
||||||
|
|
||||||
|
if self._load_track_at_index(next_index):
|
||||||
|
if self.is_playing or force_play:
|
||||||
|
self.play()
|
||||||
|
self._emit_player_state()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error skipping to next track: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def previous_track(self) -> bool:
|
||||||
|
"""Skip to previous track."""
|
||||||
|
try:
|
||||||
|
if not self.playlist_files:
|
||||||
|
return False
|
||||||
|
|
||||||
|
prev_index = self.current_track_index - 1
|
||||||
|
|
||||||
|
# Handle different play modes
|
||||||
|
if self.play_mode == "loop-playlist" and prev_index < 0:
|
||||||
|
prev_index = len(self.playlist_files) - 1
|
||||||
|
elif self.play_mode == "random":
|
||||||
|
import random
|
||||||
|
|
||||||
|
prev_index = random.randint(0, len(self.playlist_files) - 1)
|
||||||
|
elif prev_index < 0:
|
||||||
|
prev_index = 0
|
||||||
|
|
||||||
|
if self._load_track_at_index(prev_index):
|
||||||
|
if self.is_playing:
|
||||||
|
self.play()
|
||||||
|
self._emit_player_state()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error skipping to previous track: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def seek(self, position: float) -> bool:
|
||||||
|
"""Seek to position (0.0 to 1.0)."""
|
||||||
|
try:
|
||||||
|
if not self.player:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Set position as percentage
|
||||||
|
self.player.set_position(position)
|
||||||
|
self.current_time = position * self.duration
|
||||||
|
self._emit_player_state()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error seeking: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def set_volume(self, volume: int) -> bool:
|
||||||
|
"""Set volume (0-100)."""
|
||||||
|
try:
|
||||||
|
if not self.player:
|
||||||
|
return False
|
||||||
|
|
||||||
|
volume = max(0, min(100, volume))
|
||||||
|
result = self.player.audio_set_volume(volume)
|
||||||
|
if result == 0: # Success
|
||||||
|
self.volume = volume
|
||||||
|
self._emit_player_state()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error setting volume: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def set_play_mode(self, mode: str) -> bool:
|
||||||
|
"""Set play mode."""
|
||||||
|
try:
|
||||||
|
if mode in [
|
||||||
|
"continuous",
|
||||||
|
"loop-playlist",
|
||||||
|
"loop-one",
|
||||||
|
"random",
|
||||||
|
"single",
|
||||||
|
]:
|
||||||
|
self.play_mode = mode
|
||||||
|
self._emit_player_state()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error setting play mode: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def play_track_at_index(self, index: int) -> bool:
|
||||||
|
"""Play track at specific playlist index."""
|
||||||
|
try:
|
||||||
|
if self._load_track_at_index(index):
|
||||||
|
result = self.play()
|
||||||
|
self._emit_player_state()
|
||||||
|
return result
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error playing track at index {index}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _get_playlist_length(self) -> int:
|
||||||
|
"""Get current playlist length."""
|
||||||
|
return len(self.playlist_files)
|
||||||
|
|
||||||
|
def get_current_track(self) -> Optional[dict]:
|
||||||
|
"""Get current track information."""
|
||||||
|
try:
|
||||||
|
if not self.current_playlist_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 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
|
||||||
|
):
|
||||||
|
playlist_sounds = sorted(
|
||||||
|
playlist.playlist_sounds, key=lambda x: x.order
|
||||||
|
)
|
||||||
|
current_playlist_sound = playlist_sounds[
|
||||||
|
self.current_track_index
|
||||||
|
]
|
||||||
|
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
|
||||||
|
)
|
||||||
|
if sound.thumbnail
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"file_url": self._build_stream_url(sound.type, sound.filename),
|
||||||
|
"service_url": service_url,
|
||||||
|
"type": sound.type,
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting current track: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_playlist_tracks(self) -> list[dict]:
|
||||||
|
"""Get all tracks in current playlist."""
|
||||||
|
try:
|
||||||
|
tracks = []
|
||||||
|
if not self.current_playlist_id:
|
||||||
|
return tracks
|
||||||
|
|
||||||
|
# Ensure we have Flask app context
|
||||||
|
if current_app:
|
||||||
|
with current_app.app_context():
|
||||||
|
playlist = Playlist.query.get(self.current_playlist_id)
|
||||||
|
if playlist:
|
||||||
|
for playlist_sound in sorted(
|
||||||
|
playlist.playlist_sounds, key=lambda x: x.order
|
||||||
|
):
|
||||||
|
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,
|
||||||
|
"title": sound.name,
|
||||||
|
"artist": None,
|
||||||
|
"duration": sound.duration or 0,
|
||||||
|
"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 tracks
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting playlist tracks: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
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": 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _start_sync_thread(self):
|
||||||
|
"""Start background thread to sync with VLC state."""
|
||||||
|
self._stop_sync = False
|
||||||
|
self._sync_thread = threading.Thread(
|
||||||
|
target=self._sync_loop, daemon=True
|
||||||
|
)
|
||||||
|
self._sync_thread.start()
|
||||||
|
|
||||||
|
def _sync_loop(self):
|
||||||
|
"""Background loop to sync player state with VLC."""
|
||||||
|
while not self._stop_sync:
|
||||||
|
try:
|
||||||
|
current_time = time.time()
|
||||||
|
if current_time - self.last_sync_time >= self.sync_interval:
|
||||||
|
self._sync_with_vlc()
|
||||||
|
self.last_sync_time = current_time
|
||||||
|
|
||||||
|
time.sleep(0.1) # Small sleep to prevent busy waiting
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error in sync loop: {e}")
|
||||||
|
time.sleep(1) # Longer sleep on error
|
||||||
|
|
||||||
|
def _sync_with_vlc(self):
|
||||||
|
"""Sync internal state with VLC."""
|
||||||
|
try:
|
||||||
|
if not self.player:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Update playback state
|
||||||
|
old_playing = self.is_playing
|
||||||
|
old_time = self.current_time
|
||||||
|
|
||||||
|
# Get current state from VLC
|
||||||
|
state = self.player.get_state()
|
||||||
|
self.is_playing = state == vlc.State.Playing
|
||||||
|
|
||||||
|
# Get time and duration (in milliseconds)
|
||||||
|
self.current_time = self.player.get_time()
|
||||||
|
self.duration = self.player.get_length()
|
||||||
|
|
||||||
|
# Get volume
|
||||||
|
self.volume = self.player.audio_get_volume()
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
> STATE_CHANGE_THRESHOLD_MS # More than 1 second difference
|
||||||
|
)
|
||||||
|
|
||||||
|
# Always emit if playing to keep frontend updated
|
||||||
|
if state_changed or self.is_playing:
|
||||||
|
self._emit_player_state()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error syncing with VLC: {e}")
|
||||||
|
|
||||||
|
def _emit_player_state(self):
|
||||||
|
"""Emit current player state via SocketIO."""
|
||||||
|
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')}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Fallback when no Flask context - emit basic state without database queries
|
||||||
|
basic_state = {
|
||||||
|
"is_playing": self.is_playing,
|
||||||
|
"current_time": self.current_time,
|
||||||
|
"duration": self.duration,
|
||||||
|
"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']}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error emitting player state: {e}")
|
||||||
|
|
||||||
|
def _sync_vlc_state_only(self):
|
||||||
|
"""Sync only the VLC state without auto-advance logic."""
|
||||||
|
try:
|
||||||
|
if not self.player:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get current state from VLC
|
||||||
|
state = self.player.get_state()
|
||||||
|
self.is_playing = state == vlc.State.Playing
|
||||||
|
|
||||||
|
# Get time and duration (in milliseconds)
|
||||||
|
self.current_time = self.player.get_time()
|
||||||
|
self.duration = self.player.get_length()
|
||||||
|
|
||||||
|
# Get volume
|
||||||
|
self.volume = self.player.audio_get_volume()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error syncing VLC state: {e}")
|
||||||
|
|
||||||
|
def _load_current_playlist_on_startup(self):
|
||||||
|
"""Load the current playlist automatically on startup."""
|
||||||
|
try:
|
||||||
|
if not self.app:
|
||||||
|
logger.warning(
|
||||||
|
"No Flask app context available, skipping current playlist load"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
# Find the current playlist
|
||||||
|
current_playlist = Playlist.find_current_playlist()
|
||||||
|
|
||||||
|
if current_playlist:
|
||||||
|
success = self.load_playlist(current_playlist.id)
|
||||||
|
if success:
|
||||||
|
logger.info(
|
||||||
|
f"Automatically loaded current playlist '{current_playlist.name}' with {len(self.playlist_files)} tracks"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to load current playlist on startup"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("No current playlist found to load on startup")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading current playlist on startup: {e}")
|
||||||
|
|
||||||
|
def reload_current_playlist_if_modified(
|
||||||
|
self, modified_playlist_id: int
|
||||||
|
) -> bool:
|
||||||
|
"""Reload the current playlist if it's the one that was modified."""
|
||||||
|
try:
|
||||||
|
if not self.app:
|
||||||
|
logger.warning(
|
||||||
|
"No Flask app context available, skipping playlist reload"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
# Find the current playlist
|
||||||
|
current_playlist = Playlist.find_current_playlist()
|
||||||
|
|
||||||
|
if (
|
||||||
|
current_playlist
|
||||||
|
and current_playlist.id == modified_playlist_id
|
||||||
|
):
|
||||||
|
# Reload the playlist
|
||||||
|
success = self.load_playlist(current_playlist.id, True)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(
|
||||||
|
f"Reloaded current playlist '{current_playlist.name}' after modification"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to reload current playlist after modification"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
# Not the current playlist, no need to reload
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error reloading current playlist: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Global music player service instance
|
||||||
|
music_player_service = MusicPlayerService()
|
||||||
@@ -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__)
|
||||||
|
|
||||||
@@ -25,6 +26,17 @@ class SocketIOService:
|
|||||||
socketio.emit(event, data, room=room)
|
socketio.emit(event, data, room=room)
|
||||||
logger.debug(f"Emitted {event} to user {user_id} in room {room}")
|
logger.debug(f"Emitted {event} to user {user_id} in room {room}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def emit_to_all(event: str, data: dict) -> None:
|
||||||
|
"""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())}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to emit {event}: {e}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def emit_credits_changed(user_id: int, new_credits: int) -> None:
|
def emit_credits_changed(user_id: int, new_credits: int) -> None:
|
||||||
"""Emit credits_changed event to a user."""
|
"""Emit credits_changed event to a user."""
|
||||||
@@ -34,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."""
|
||||||
@@ -43,7 +72,12 @@ 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}..."
|
||||||
|
)
|
||||||
|
|
||||||
if not access_token:
|
if not access_token:
|
||||||
|
logger.debug("No access token found in cookies")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Decode the JWT token manually
|
# Decode the JWT token manually
|
||||||
@@ -51,9 +85,13 @@ class SocketIOService:
|
|||||||
try:
|
try:
|
||||||
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}")
|
||||||
|
|
||||||
if not current_user_id:
|
if not current_user_id:
|
||||||
|
logger.debug("No user ID in token")
|
||||||
return None
|
return None
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.debug(f"Token decode error: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Query database for user data
|
# Query database for user data
|
||||||
@@ -61,8 +99,12 @@ 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}"
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
logger.debug(f"Successfully found user: {user.email}")
|
||||||
return {
|
return {
|
||||||
"id": str(user.id),
|
"id": str(user.id),
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
@@ -70,21 +112,23 @@ class SocketIOService:
|
|||||||
"role": user.role,
|
"role": user.role,
|
||||||
"credits": user.credits,
|
"credits": user.credits,
|
||||||
}
|
}
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.debug(f"Exception in get_user_from_socketio: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@socketio.on("connect")
|
@socketio.on("connect")
|
||||||
def handle_connect(auth=None) -> bool:
|
def handle_connect(auth=None):
|
||||||
"""Handle client connection."""
|
"""Handle client connection."""
|
||||||
try:
|
try:
|
||||||
logger.info(f"SocketIO connection from {request.remote_addr}")
|
logger.info(
|
||||||
return True
|
f"SocketIO connection established from {request.remote_addr}"
|
||||||
|
)
|
||||||
|
logger.info(f"Session ID: {request.sid}")
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Error handling SocketIO connection")
|
logger.exception("Error handling SocketIO connection")
|
||||||
disconnect()
|
disconnect()
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
@socketio.on("authenticate")
|
@socketio.on("authenticate")
|
||||||
@@ -92,8 +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:
|
||||||
emit("auth_error", {"error": "Authentication failed"})
|
logger.warning("SocketIO authentication failed - no user found")
|
||||||
|
# emit("auth_error", {"error": "Authentication failed"})
|
||||||
disconnect()
|
disconnect()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -106,17 +152,60 @@ 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("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")
|
@socketio.on("disconnect")
|
||||||
def handle_disconnect() -> None:
|
def handle_disconnect():
|
||||||
"""Handle client disconnection."""
|
"""Handle client disconnection."""
|
||||||
try:
|
try:
|
||||||
user = SocketIOService.get_user_from_socketio()
|
user = SocketIOService.get_user_from_socketio()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -27,8 +26,17 @@ class SoundNormalizerService:
|
|||||||
".aac",
|
".aac",
|
||||||
".opus",
|
".opus",
|
||||||
}
|
}
|
||||||
SOUNDS_DIR = "sounds/soundboard"
|
# Sound directories by type
|
||||||
NORMALIZED_DIR = "sounds/normalized/soundboard"
|
SOUND_DIRS = {
|
||||||
|
"SDB": "sounds/soundboard",
|
||||||
|
"SAY": "sounds/say",
|
||||||
|
"STR": "sounds/stream"
|
||||||
|
}
|
||||||
|
NORMALIZED_DIRS = {
|
||||||
|
"SDB": "sounds/normalized/soundboard",
|
||||||
|
"SAY": "sounds/normalized/say",
|
||||||
|
"STR": "sounds/normalized/stream"
|
||||||
|
}
|
||||||
|
|
||||||
LOUDNORM_PARAMS = {
|
LOUDNORM_PARAMS = {
|
||||||
"integrated": -16,
|
"integrated": -16,
|
||||||
@@ -62,9 +70,17 @@ class SoundNormalizerService:
|
|||||||
"error": f"Sound with ID {sound_id} not found",
|
"error": f"Sound with ID {sound_id} not found",
|
||||||
}
|
}
|
||||||
|
|
||||||
source_path = (
|
# Get directories based on sound type
|
||||||
Path(SoundNormalizerService.SOUNDS_DIR) / sound.filename
|
sound_dir = SoundNormalizerService.SOUND_DIRS.get(sound.type)
|
||||||
)
|
normalized_dir = SoundNormalizerService.NORMALIZED_DIRS.get(sound.type)
|
||||||
|
|
||||||
|
if not sound_dir or not normalized_dir:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Unsupported sound type: {sound.type}",
|
||||||
|
}
|
||||||
|
|
||||||
|
source_path = Path(sound_dir) / sound.filename
|
||||||
if not source_path.exists():
|
if not source_path.exists():
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
@@ -74,10 +90,7 @@ class SoundNormalizerService:
|
|||||||
# Always output as WAV regardless of input format
|
# Always output as WAV regardless of input format
|
||||||
filename_without_ext = Path(sound.filename).stem
|
filename_without_ext = Path(sound.filename).stem
|
||||||
normalized_filename = f"{filename_without_ext}.wav"
|
normalized_filename = f"{filename_without_ext}.wav"
|
||||||
normalized_path = (
|
normalized_path = Path(normalized_dir) / normalized_filename
|
||||||
Path(SoundNormalizerService.NORMALIZED_DIR)
|
|
||||||
/ normalized_filename
|
|
||||||
)
|
|
||||||
|
|
||||||
normalized_path.parent.mkdir(parents=True, exist_ok=True)
|
normalized_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
@@ -618,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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|
||||||
# Extract basic metadata from AudioSegment
|
if not audio_stream:
|
||||||
duration = len(audio)
|
raise ValueError("No audio stream found in file")
|
||||||
channels = audio.channels
|
|
||||||
sample_rate = audio.frame_rate
|
|
||||||
|
|
||||||
# Use mediainfo for more accurate bitrate information
|
# Extract metadata from ffmpeg probe
|
||||||
bitrate = None
|
duration = int(float(audio_stream.get('duration', 0)) * 1000) # Convert to milliseconds
|
||||||
try:
|
channels = int(audio_stream.get('channels', 0))
|
||||||
info = mediainfo(file_path)
|
sample_rate = int(audio_stream.get('sample_rate', 0))
|
||||||
if info and "bit_rate" in info:
|
bitrate = int(audio_stream.get('bit_rate', 0)) if audio_stream.get('bit_rate') else None
|
||||||
bitrate = int(info["bit_rate"])
|
|
||||||
elif info and "bitrate" in info:
|
# Fallback bitrate calculation if not available
|
||||||
bitrate = int(info["bitrate"])
|
if not bitrate and duration > 0:
|
||||||
except (ValueError, KeyError, TypeError):
|
|
||||||
# Fallback to calculated bitrate if mediainfo fails
|
|
||||||
if duration > 0:
|
|
||||||
file_size_bits = file_size * 8
|
file_size_bits = file_size * 8
|
||||||
bitrate = int(file_size_bits / duration / 1000)
|
bitrate = int(file_size_bits / (duration / 1000))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"duration": duration,
|
"duration": duration,
|
||||||
|
|||||||
590
app/services/stream_processing_service.py
Normal file
590
app/services/stream_processing_service.py
Normal file
@@ -0,0 +1,590 @@
|
|||||||
|
"""Stream processing service with queue management and yt-dlp integration."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from queue import Empty, Queue
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
from app.database import db
|
||||||
|
from app.models.sound import Sound
|
||||||
|
from app.models.stream import Stream
|
||||||
|
from app.services.logging_service import LoggingService
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logger = LoggingService.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class StreamProcessingService:
|
||||||
|
"""Service for processing streaming URLs with yt-dlp."""
|
||||||
|
|
||||||
|
# Class variables for queue management
|
||||||
|
_processing_queue: Queue = Queue()
|
||||||
|
_processing_threads: List[threading.Thread] = []
|
||||||
|
_is_running: bool = False
|
||||||
|
_max_concurrent_downloads: int = int(
|
||||||
|
os.getenv("STREAM_MAX_CONCURRENT", "2")
|
||||||
|
)
|
||||||
|
_downloads_dir: str = "sounds/temp"
|
||||||
|
_app_instance = None # Store the Flask app instance
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def initialize(cls, app=None) -> None:
|
||||||
|
"""Initialize the stream processing service."""
|
||||||
|
if cls._is_running:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Store the Flask app instance if provided
|
||||||
|
if app:
|
||||||
|
cls._app_instance = app
|
||||||
|
|
||||||
|
# Create necessary directories
|
||||||
|
os.makedirs(cls._downloads_dir, exist_ok=True)
|
||||||
|
os.makedirs("sounds/stream", exist_ok=True)
|
||||||
|
os.makedirs("sounds/stream/thumbnails", exist_ok=True)
|
||||||
|
|
||||||
|
# Start processing threads
|
||||||
|
for i in range(cls._max_concurrent_downloads):
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=cls._worker_thread,
|
||||||
|
name=f"StreamProcessor-{i + 1}",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
cls._processing_threads.append(thread)
|
||||||
|
|
||||||
|
cls._is_running = True
|
||||||
|
logger.info(
|
||||||
|
f"StreamProcessingService initialized with {cls._max_concurrent_downloads} workers"
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add_to_queue(cls, stream_id: int) -> None:
|
||||||
|
"""Add a stream to the processing queue."""
|
||||||
|
if not cls._is_running:
|
||||||
|
cls.initialize()
|
||||||
|
|
||||||
|
cls._processing_queue.put(stream_id)
|
||||||
|
logger.info(f"Added stream {stream_id} to processing queue")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_queue_status(cls) -> Dict:
|
||||||
|
"""Get the current queue status."""
|
||||||
|
pending_count = Stream.query.filter_by(status="pending").count()
|
||||||
|
processing_count = Stream.query.filter_by(status="processing").count()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"queue_size": cls._processing_queue.qsize(),
|
||||||
|
"pending_streams": pending_count,
|
||||||
|
"processing_streams": processing_count,
|
||||||
|
"max_concurrent": cls._max_concurrent_downloads,
|
||||||
|
"is_running": cls._is_running,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _worker_thread(cls) -> None:
|
||||||
|
"""Worker thread for processing streams."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# Get stream ID from queue with timeout
|
||||||
|
stream_id = cls._processing_queue.get(timeout=1)
|
||||||
|
|
||||||
|
# Use the stored app instance for database operations
|
||||||
|
if cls._app_instance:
|
||||||
|
with cls._app_instance.app_context():
|
||||||
|
cls._process_stream(stream_id)
|
||||||
|
else:
|
||||||
|
# Fallback: import create_app if no app instance stored
|
||||||
|
from app import create_app
|
||||||
|
app = create_app()
|
||||||
|
with app.app_context():
|
||||||
|
cls._process_stream(stream_id)
|
||||||
|
|
||||||
|
cls._processing_queue.task_done()
|
||||||
|
|
||||||
|
except Empty:
|
||||||
|
# No items in queue, continue
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in worker thread: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _process_stream(cls, stream_id: int) -> None:
|
||||||
|
"""Process a single stream."""
|
||||||
|
try:
|
||||||
|
stream = Stream.query.get(stream_id)
|
||||||
|
if not stream:
|
||||||
|
logger.error(f"Stream {stream_id} not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
if stream.status == "cancelled":
|
||||||
|
logger.info(f"Stream {stream_id} was cancelled")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Update status to processing
|
||||||
|
stream.status = "processing"
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Starting processing of stream {stream_id}: {stream.url}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract metadata and download audio
|
||||||
|
metadata, error_msg = cls._extract_metadata(stream.url)
|
||||||
|
if not metadata:
|
||||||
|
if not error_msg:
|
||||||
|
error_msg = "Failed to extract metadata from URL"
|
||||||
|
stream.status = "failed"
|
||||||
|
stream.error = error_msg
|
||||||
|
db.session.commit()
|
||||||
|
logger.error(
|
||||||
|
f"Failed to extract metadata for stream {stream_id}: {error_msg}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check for duplicate streams based on service and service_id
|
||||||
|
service = metadata.get("service")
|
||||||
|
service_id = metadata.get("service_id")
|
||||||
|
|
||||||
|
if service and service_id:
|
||||||
|
existing_stream = (
|
||||||
|
Stream.query.filter_by(
|
||||||
|
service=service, service_id=service_id
|
||||||
|
)
|
||||||
|
.filter(Stream.id != stream.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing_stream:
|
||||||
|
error_msg = f"Stream already exists with {service} ID: {service_id} (stream #{existing_stream.id})"
|
||||||
|
stream.status = "failed"
|
||||||
|
stream.error = error_msg
|
||||||
|
db.session.commit()
|
||||||
|
logger.error(
|
||||||
|
f"Duplicate stream detected for {stream_id}: {error_msg}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Update stream with metadata
|
||||||
|
cls._update_stream_metadata(stream, metadata)
|
||||||
|
|
||||||
|
# Download audio
|
||||||
|
audio_path, error_msg = cls._download_audio(stream.url, metadata)
|
||||||
|
if not audio_path:
|
||||||
|
if not error_msg:
|
||||||
|
error_msg = "Failed to download audio from URL"
|
||||||
|
stream.status = "failed"
|
||||||
|
stream.error = error_msg
|
||||||
|
db.session.commit()
|
||||||
|
logger.error(
|
||||||
|
f"Failed to download audio for stream {stream_id}: {error_msg}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Move files to final locations
|
||||||
|
final_audio_path, thumbnail_path, error_msg = (
|
||||||
|
cls._move_files_to_final_location(audio_path, metadata)
|
||||||
|
)
|
||||||
|
if not final_audio_path:
|
||||||
|
if not error_msg:
|
||||||
|
error_msg = "Failed to move files to final location"
|
||||||
|
stream.status = "failed"
|
||||||
|
stream.error = error_msg
|
||||||
|
db.session.commit()
|
||||||
|
logger.error(
|
||||||
|
f"Failed to move files for stream {stream_id}: {error_msg}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create sound entry with final path
|
||||||
|
sound, error_msg = cls._create_sound_entry(
|
||||||
|
final_audio_path, metadata, thumbnail_path
|
||||||
|
)
|
||||||
|
if not sound:
|
||||||
|
if not error_msg:
|
||||||
|
error_msg = "Failed to create sound entry in database"
|
||||||
|
stream.status = "failed"
|
||||||
|
stream.error = error_msg
|
||||||
|
db.session.commit()
|
||||||
|
logger.error(
|
||||||
|
f"Failed to create sound entry for stream {stream_id}: {error_msg}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Update stream with sound_id and mark as completed
|
||||||
|
stream.sound_id = sound.id
|
||||||
|
stream.status = "completed"
|
||||||
|
stream.error = None # Clear any previous errors
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Successfully processed stream {stream_id} -> sound {sound.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Unexpected error during processing: {str(e)}"
|
||||||
|
logger.error(f"Error processing stream {stream_id}: {error_msg}")
|
||||||
|
try:
|
||||||
|
stream = Stream.query.get(stream_id)
|
||||||
|
if stream:
|
||||||
|
stream.status = "failed"
|
||||||
|
stream.error = error_msg
|
||||||
|
db.session.commit()
|
||||||
|
except Exception as db_error:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to update stream error in database: {db_error}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _extract_metadata(
|
||||||
|
cls, url: str
|
||||||
|
) -> tuple[Optional[Dict], Optional[str]]:
|
||||||
|
"""Extract metadata from URL using yt-dlp."""
|
||||||
|
try:
|
||||||
|
import yt_dlp
|
||||||
|
|
||||||
|
ydl_opts = {
|
||||||
|
"quiet": True,
|
||||||
|
"no_warnings": True,
|
||||||
|
"extract_flat": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
info = ydl.extract_info(url, download=False)
|
||||||
|
|
||||||
|
# Extract service information
|
||||||
|
service = cls._detect_service(url)
|
||||||
|
service_id = cls._extract_service_id(url, info)
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"service": info.get("extractor", service),
|
||||||
|
"service_id": info.get("id", service_id),
|
||||||
|
"title": info.get("title", ""),
|
||||||
|
"track": info.get("track", ""),
|
||||||
|
"artist": info.get("artist", "")
|
||||||
|
or info.get("uploader", ""),
|
||||||
|
"album": info.get("album", ""),
|
||||||
|
"genre": info.get("genre", ""),
|
||||||
|
"duration": info.get("duration", 0),
|
||||||
|
"description": info.get("description", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"yt-dlp extraction failed: {str(e)}"
|
||||||
|
logger.error(f"Error extracting metadata from {url}: {error_msg}")
|
||||||
|
return None, error_msg
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _download_audio(
|
||||||
|
cls, url: str, metadata: Dict
|
||||||
|
) -> tuple[Optional[str], Optional[str]]:
|
||||||
|
"""Download audio from URL using yt-dlp."""
|
||||||
|
try:
|
||||||
|
import yt_dlp
|
||||||
|
|
||||||
|
# Generate filename
|
||||||
|
title = metadata.get("title", "unknown")
|
||||||
|
safe_title = re.sub(r"[^\w\s-]", "", title)[:50]
|
||||||
|
filename = f"{safe_title}_{metadata.get('service_id', 'unknown')}"
|
||||||
|
|
||||||
|
output_path = os.path.join(
|
||||||
|
cls._downloads_dir, f"{filename}.%(ext)s"
|
||||||
|
)
|
||||||
|
|
||||||
|
ydl_opts = {
|
||||||
|
"format": "bestaudio/best",
|
||||||
|
"outtmpl": output_path,
|
||||||
|
"extractaudio": True,
|
||||||
|
"audioformat": "opus",
|
||||||
|
"audioquality": "192",
|
||||||
|
"postprocessors": [
|
||||||
|
{
|
||||||
|
"key": "FFmpegExtractAudio",
|
||||||
|
"preferredcodec": "opus",
|
||||||
|
"preferredquality": "192",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"writethumbnail": True,
|
||||||
|
"quiet": True,
|
||||||
|
"no_warnings": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
ydl.download([url])
|
||||||
|
|
||||||
|
# Find the downloaded file
|
||||||
|
final_path = os.path.join(cls._downloads_dir, f"{filename}.opus")
|
||||||
|
if os.path.exists(final_path):
|
||||||
|
return final_path, None
|
||||||
|
|
||||||
|
# If opus doesn't exist, look for other formats
|
||||||
|
for ext in ["mp3", "wav", "m4a", "webm", "ogg"]:
|
||||||
|
alt_path = os.path.join(cls._downloads_dir, f"{filename}.{ext}")
|
||||||
|
if os.path.exists(alt_path):
|
||||||
|
return alt_path, None
|
||||||
|
|
||||||
|
error_msg = f"Downloaded file not found after yt-dlp processing"
|
||||||
|
logger.error(f"Downloaded file not found for {url}")
|
||||||
|
return None, error_msg
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"yt-dlp download failed: {str(e)}"
|
||||||
|
logger.error(f"Error downloading audio from {url}: {error_msg}")
|
||||||
|
return None, error_msg
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _move_files_to_final_location(
|
||||||
|
cls, audio_path: str, metadata: dict
|
||||||
|
) -> tuple[Optional[str], Optional[str], Optional[str]]:
|
||||||
|
"""Move downloaded files to their final locations.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (final_audio_path, thumbnail_path, error_message)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Create target directories
|
||||||
|
stream_dir = "sounds/stream"
|
||||||
|
thumbnail_dir = "sounds/stream/thumbnails"
|
||||||
|
|
||||||
|
os.makedirs(stream_dir, exist_ok=True)
|
||||||
|
os.makedirs(thumbnail_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Generate safe filename
|
||||||
|
title = metadata.get("title", "unknown")
|
||||||
|
safe_title = re.sub(r"[^\w\s-]", "", title)[:50]
|
||||||
|
service_id = metadata.get("service_id", "unknown")
|
||||||
|
base_filename = f"{safe_title}_{service_id}"
|
||||||
|
|
||||||
|
# Move audio file
|
||||||
|
audio_extension = os.path.splitext(audio_path)[1]
|
||||||
|
final_audio_filename = f"{base_filename}{audio_extension}"
|
||||||
|
final_audio_path = os.path.join(stream_dir, final_audio_filename)
|
||||||
|
|
||||||
|
# If file already exists, add a counter
|
||||||
|
counter = 1
|
||||||
|
while os.path.exists(final_audio_path):
|
||||||
|
final_audio_filename = (
|
||||||
|
f"{base_filename}_{counter}{audio_extension}"
|
||||||
|
)
|
||||||
|
final_audio_path = os.path.join(
|
||||||
|
stream_dir, final_audio_filename
|
||||||
|
)
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
shutil.move(audio_path, final_audio_path)
|
||||||
|
logger.info(f"Moved audio file to: {final_audio_path}")
|
||||||
|
|
||||||
|
# Look for and move thumbnail
|
||||||
|
thumbnail_path = None
|
||||||
|
temp_dir = os.path.dirname(audio_path)
|
||||||
|
|
||||||
|
# Common thumbnail extensions
|
||||||
|
for thumb_ext in [".jpg", ".jpeg", ".png", ".webp", ".gif"]:
|
||||||
|
temp_thumb_path = os.path.join(
|
||||||
|
temp_dir,
|
||||||
|
f"{os.path.splitext(os.path.basename(audio_path))[0]}{thumb_ext}",
|
||||||
|
)
|
||||||
|
if os.path.exists(temp_thumb_path):
|
||||||
|
final_thumb_filename = f"{base_filename}{thumb_ext}"
|
||||||
|
final_thumb_path = os.path.join(
|
||||||
|
thumbnail_dir, final_thumb_filename
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle duplicate thumbnail names
|
||||||
|
thumb_counter = 1
|
||||||
|
while os.path.exists(final_thumb_path):
|
||||||
|
final_thumb_filename = (
|
||||||
|
f"{base_filename}_{thumb_counter}{thumb_ext}"
|
||||||
|
)
|
||||||
|
final_thumb_path = os.path.join(
|
||||||
|
thumbnail_dir, final_thumb_filename
|
||||||
|
)
|
||||||
|
thumb_counter += 1
|
||||||
|
|
||||||
|
shutil.move(temp_thumb_path, final_thumb_path)
|
||||||
|
thumbnail_path = final_thumb_path
|
||||||
|
logger.info(f"Moved thumbnail to: {final_thumb_path}")
|
||||||
|
break
|
||||||
|
|
||||||
|
return final_audio_path, thumbnail_path, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"File move operation failed: {str(e)}"
|
||||||
|
logger.error(f"Error moving files: {error_msg}")
|
||||||
|
return None, None, error_msg
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _create_sound_entry(
|
||||||
|
cls,
|
||||||
|
audio_path: str,
|
||||||
|
metadata: dict,
|
||||||
|
thumbnail_path: str | None = None,
|
||||||
|
) -> tuple[Sound | None, str | None]:
|
||||||
|
"""Create a sound entry from the downloaded audio."""
|
||||||
|
try:
|
||||||
|
# Get file info
|
||||||
|
file_size = os.path.getsize(audio_path)
|
||||||
|
|
||||||
|
# Generate hash
|
||||||
|
file_hash = cls._calculate_file_hash(audio_path)
|
||||||
|
|
||||||
|
# Get duration (use metadata duration or calculate from file)
|
||||||
|
duration_ms = int((metadata.get("duration", 0) or 0) * 1000)
|
||||||
|
|
||||||
|
# Get thumbnail filename if available
|
||||||
|
thumbnail_filename = None
|
||||||
|
if thumbnail_path:
|
||||||
|
thumbnail_filename = os.path.basename(thumbnail_path)
|
||||||
|
|
||||||
|
# Create sound entry
|
||||||
|
sound = Sound(
|
||||||
|
type="STR", # Stream type
|
||||||
|
name=metadata.get("title", "Unknown Title"),
|
||||||
|
filename=os.path.basename(audio_path),
|
||||||
|
thumbnail=thumbnail_filename,
|
||||||
|
duration=duration_ms,
|
||||||
|
size=file_size,
|
||||||
|
hash=file_hash,
|
||||||
|
is_music=True, # Streams are typically music
|
||||||
|
is_deletable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(sound)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Add sound to main playlist
|
||||||
|
cls._add_sound_to_main_playlist(sound)
|
||||||
|
|
||||||
|
# Normalize the sound
|
||||||
|
cls._normalize_sound(sound)
|
||||||
|
|
||||||
|
return sound, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Database error while creating sound entry: {str(e)}"
|
||||||
|
logger.error(
|
||||||
|
f"Error creating sound entry for {audio_path}: {error_msg}"
|
||||||
|
)
|
||||||
|
return None, error_msg
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _update_stream_metadata(cls, stream: Stream, metadata: Dict) -> None:
|
||||||
|
"""Update stream with extracted metadata."""
|
||||||
|
stream.service = metadata.get("service")
|
||||||
|
stream.service_id = metadata.get("service_id")
|
||||||
|
stream.title = metadata.get("title")
|
||||||
|
stream.track = metadata.get("track")
|
||||||
|
stream.artist = metadata.get("artist")
|
||||||
|
stream.album = metadata.get("album")
|
||||||
|
stream.genre = metadata.get("genre")
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _detect_service(cls, url: str) -> str:
|
||||||
|
"""Detect the streaming service from URL."""
|
||||||
|
domain = urlparse(url).netloc.lower()
|
||||||
|
|
||||||
|
if "youtube.com" in domain or "youtu.be" in domain:
|
||||||
|
return "youtube"
|
||||||
|
elif "soundcloud.com" in domain:
|
||||||
|
return "soundcloud"
|
||||||
|
elif "dailymotion.com" in domain:
|
||||||
|
return "dailymotion"
|
||||||
|
elif "spotify.com" in domain:
|
||||||
|
return "spotify"
|
||||||
|
elif "vimeo.com" in domain:
|
||||||
|
return "vimeo"
|
||||||
|
elif "twitch.tv" in domain:
|
||||||
|
return "twitch"
|
||||||
|
else:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _extract_service_id(cls, url: str, info: Dict) -> str:
|
||||||
|
"""Extract service-specific ID from URL or info."""
|
||||||
|
service = cls._detect_service(url)
|
||||||
|
|
||||||
|
if service == "youtube":
|
||||||
|
# Try to get from info first
|
||||||
|
if "id" in info:
|
||||||
|
return info["id"]
|
||||||
|
# Parse from URL
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if "youtu.be" in parsed.netloc:
|
||||||
|
return parsed.path[1:] # Remove leading slash
|
||||||
|
elif "youtube.com" in parsed.netloc:
|
||||||
|
query_params = parse_qs(parsed.query)
|
||||||
|
return query_params.get("v", [""])[0]
|
||||||
|
|
||||||
|
elif service == "soundcloud":
|
||||||
|
if "id" in info:
|
||||||
|
return str(info["id"])
|
||||||
|
|
||||||
|
# Fallback to using info ID or last part of URL
|
||||||
|
if "id" in info:
|
||||||
|
return str(info["id"])
|
||||||
|
|
||||||
|
return urlparse(url).path.split("/")[-1] or "unknown"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _add_sound_to_main_playlist(cls, sound: Sound) -> None:
|
||||||
|
"""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()
|
||||||
|
|
||||||
|
if main_playlist:
|
||||||
|
# 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")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to add sound {sound.id} to main playlist: {e}")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _normalize_sound(cls, sound: Sound) -> None:
|
||||||
|
"""Normalize a stream sound using the sound normalizer service."""
|
||||||
|
try:
|
||||||
|
from app.services.sound_normalizer_service import SoundNormalizerService
|
||||||
|
|
||||||
|
logger.info(f"Starting normalization of stream sound {sound.id}: {sound.name}")
|
||||||
|
|
||||||
|
# Normalize the sound (overwrite=True since it's a new sound)
|
||||||
|
result = SoundNormalizerService.normalize_sound(
|
||||||
|
sound.id,
|
||||||
|
overwrite=True,
|
||||||
|
two_pass=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.get("success"):
|
||||||
|
logger.info(f"Successfully normalized stream sound {sound.id}")
|
||||||
|
else:
|
||||||
|
error_msg = result.get("error", "Unknown normalization error")
|
||||||
|
logger.warning(f"Failed to normalize stream sound {sound.id}: {error_msg}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error normalizing stream sound {sound.id}: {e}")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _calculate_file_hash(cls, file_path: str) -> str:
|
||||||
|
"""Calculate SHA256 hash of file."""
|
||||||
|
sha256_hash = hashlib.sha256()
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(4096), b""):
|
||||||
|
sha256_hash.update(chunk)
|
||||||
|
return sha256_hash.hexdigest()
|
||||||
82
backend.log
Normal file
82
backend.log
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
19:40:31 - apscheduler.scheduler - INFO - Adding job tentatively -- it will be properly scheduled when the scheduler starts
|
||||||
|
19:40:31 - app.services.scheduler_service - INFO - Daily credit refill job scheduled for 00:00 UTC
|
||||||
|
19:40:31 - apscheduler.scheduler - INFO - Adding job tentatively -- it will be properly scheduled when the scheduler starts
|
||||||
|
19:40:31 - app.services.scheduler_service - INFO - Sound scanning job scheduled every 5 minutes
|
||||||
|
19:40:31 - apscheduler.scheduler - INFO - Added job "Daily Credit Refill" to job store "default"
|
||||||
|
19:40:31 - apscheduler.scheduler - INFO - Added job "Sound Directory Scan" to job store "default"
|
||||||
|
19:40:31 - apscheduler.scheduler - INFO - Scheduler started
|
||||||
|
19:40:31 - app.services.scheduler_service - INFO - Scheduler started successfully
|
||||||
|
19:40:31 - app.services.stream_processing_service - INFO - StreamProcessingService initialized with 2 workers
|
||||||
|
19:40:31 - app.services.scheduler_service - WARNING - Scheduler is already running
|
||||||
|
19:40:31 - app.services.scheduler_service - WARNING - Scheduler is already running
|
||||||
|
19:40:31 - app.services.music_player_service - INFO - VLC music player started successfully
|
||||||
|
19:40:31 - app.services.music_player_service - INFO - VLC music player started successfully
|
||||||
|
19:40:31 - app.services.music_player_service - INFO - VLC music player started successfully
|
||||||
|
19:40:31 - werkzeug - WARNING - Werkzeug appears to be used in a production deployment. Consider switching to a production web server instead.
|
||||||
|
🔧 SocketIO Service: Module loaded, logger level: 10
|
||||||
|
🔧 SocketIO Service: Effective logger level: 10
|
||||||
|
🔧 SocketIO Service: Parent logger handlers: [<StreamHandler <stderr> (NOTSET)>]
|
||||||
|
🔧 SocketIO Service: Logger handlers: []
|
||||||
|
🔧 SocketIO Service: Registered event handlers: ['/']
|
||||||
|
* Serving Flask app 'app'
|
||||||
|
* Debug mode: on
|
||||||
|
19:40:31 - werkzeug - INFO - [31m[1mWARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.[0m
|
||||||
|
* Running on all addresses (0.0.0.0)
|
||||||
|
* Running on http://127.0.0.1:5000
|
||||||
|
* Running on http://10.8.0.2:5000
|
||||||
|
19:40:31 - werkzeug - INFO - [33mPress CTRL+C to quit[0m
|
||||||
|
19:40:31 - werkzeug - INFO - * Restarting with stat
|
||||||
|
19:40:32 - apscheduler.scheduler - INFO - Adding job tentatively -- it will be properly scheduled when the scheduler starts
|
||||||
|
19:40:32 - app.services.scheduler_service - INFO - Daily credit refill job scheduled for 00:00 UTC
|
||||||
|
19:40:32 - apscheduler.scheduler - INFO - Adding job tentatively -- it will be properly scheduled when the scheduler starts
|
||||||
|
19:40:32 - app.services.scheduler_service - INFO - Sound scanning job scheduled every 5 minutes
|
||||||
|
19:40:32 - apscheduler.scheduler - INFO - Added job "Daily Credit Refill" to job store "default"
|
||||||
|
19:40:32 - apscheduler.scheduler - INFO - Added job "Sound Directory Scan" to job store "default"
|
||||||
|
19:40:32 - apscheduler.scheduler - INFO - Scheduler started
|
||||||
|
19:40:32 - app.services.scheduler_service - INFO - Scheduler started successfully
|
||||||
|
19:40:32 - app.services.stream_processing_service - INFO - StreamProcessingService initialized with 2 workers
|
||||||
|
19:40:32 - app.services.scheduler_service - WARNING - Scheduler is already running
|
||||||
|
19:40:32 - app.services.scheduler_service - WARNING - Scheduler is already running
|
||||||
|
19:40:32 - app.services.music_player_service - INFO - VLC music player started successfully
|
||||||
|
19:40:32 - app.services.music_player_service - INFO - VLC music player started successfully
|
||||||
|
19:40:32 - app.services.music_player_service - INFO - VLC music player started successfully
|
||||||
|
19:40:32 - werkzeug - WARNING - Werkzeug appears to be used in a production deployment. Consider switching to a production web server instead.
|
||||||
|
19:40:32 - werkzeug - WARNING - * Debugger is active!
|
||||||
|
19:40:32 - werkzeug - INFO - * Debugger PIN: 138-440-685
|
||||||
|
19:40:32 - werkzeug - INFO - 127.0.0.1 - - [07/Jul/2025 19:40:32] "GET /socket.io/?EIO=4&transport=polling&t=e00ab8wz HTTP/1.1" 200 -
|
||||||
|
19:40:32 - werkzeug - INFO - 127.0.0.1 - - [07/Jul/2025 19:40:32] "POST /socket.io/?EIO=4&transport=polling&t=e00dbx25&sid=3ANQFsbixyerJ988AAAA HTTP/1.1" 200 -
|
||||||
|
19:40:32 - werkzeug - INFO - 127.0.0.1 - - [07/Jul/2025 19:40:32] "GET /socket.io/?EIO=4&transport=polling&t=e00dc4kv&sid=3ANQFsbixyerJ988AAAA HTTP/1.1" 200 -
|
||||||
|
19:40:32 - werkzeug - INFO - 127.0.0.1 - - [07/Jul/2025 19:40:32] "POST /socket.io/?EIO=4&transport=polling&t=e00dltvr&sid=3ANQFsbixyerJ988AAAA HTTP/1.1" 200 -
|
||||||
|
19:40:32 - werkzeug - INFO - 127.0.0.1 - - [07/Jul/2025 19:40:32] "POST /socket.io/?EIO=4&transport=polling&t=e00dryur&sid=3ANQFsbixyerJ988AAAA HTTP/1.1" 200 -
|
||||||
|
19:40:49 - werkzeug - INFO - * Detected change in '/home/jschoisy/Dev/perso/sdb-claude/backend/app/services/socketio_service.py', reloading
|
||||||
|
🔧 SocketIO Service: Module loaded, logger level: 10
|
||||||
|
🔧 SocketIO Service: Effective logger level: 10
|
||||||
|
🔧 SocketIO Service: Parent logger handlers: [<StreamHandler <stderr> (NOTSET)>]
|
||||||
|
🔧 SocketIO Service: Logger handlers: []
|
||||||
|
🔧 SocketIO Service: Registered event handlers: ['/']
|
||||||
|
19:40:49 - werkzeug - INFO - * Restarting with stat
|
||||||
|
19:40:49 - apscheduler.scheduler - INFO - Adding job tentatively -- it will be properly scheduled when the scheduler starts
|
||||||
|
19:40:49 - app.services.scheduler_service - INFO - Daily credit refill job scheduled for 00:00 UTC
|
||||||
|
19:40:49 - apscheduler.scheduler - INFO - Adding job tentatively -- it will be properly scheduled when the scheduler starts
|
||||||
|
19:40:49 - app.services.scheduler_service - INFO - Sound scanning job scheduled every 5 minutes
|
||||||
|
19:40:49 - apscheduler.scheduler - INFO - Added job "Daily Credit Refill" to job store "default"
|
||||||
|
19:40:49 - apscheduler.scheduler - INFO - Added job "Sound Directory Scan" to job store "default"
|
||||||
|
19:40:49 - apscheduler.scheduler - INFO - Scheduler started
|
||||||
|
19:40:49 - app.services.scheduler_service - INFO - Scheduler started successfully
|
||||||
|
19:40:50 - app.services.stream_processing_service - INFO - StreamProcessingService initialized with 2 workers
|
||||||
|
19:40:50 - app.services.scheduler_service - WARNING - Scheduler is already running
|
||||||
|
19:40:50 - app.services.scheduler_service - WARNING - Scheduler is already running
|
||||||
|
19:40:50 - app.services.music_player_service - INFO - VLC music player started successfully
|
||||||
|
19:40:50 - werkzeug - WARNING - Werkzeug appears to be used in a production deployment. Consider switching to a production web server instead.
|
||||||
|
19:40:50 - app.services.music_player_service - INFO - VLC music player started successfully
|
||||||
|
19:40:50 - app.services.music_player_service - INFO - VLC music player started successfully
|
||||||
|
19:40:50 - werkzeug - WARNING - * Debugger is active!
|
||||||
|
19:40:50 - werkzeug - INFO - * Debugger PIN: 220-239-682
|
||||||
|
Invalid session 3ANQFsbixyerJ988AAAA (further occurrences of this error will be logged with level INFO)
|
||||||
|
19:40:50 - engineio.server - ERROR - Invalid session 3ANQFsbixyerJ988AAAA (further occurrences of this error will be logged with level INFO)
|
||||||
|
19:40:50 - werkzeug - INFO - 127.0.0.1 - - [07/Jul/2025 19:40:50] "[31m[1mPOST /socket.io/?EIO=4&transport=polling&t=e0do0065&sid=3ANQFsbixyerJ988AAAA HTTP/1.1[0m" 400 -
|
||||||
|
19:40:51 - werkzeug - INFO - 127.0.0.1 - - [07/Jul/2025 19:40:51] "GET /socket.io/?EIO=4&transport=polling&t=e0esh2w2 HTTP/1.1" 200 -
|
||||||
|
19:40:51 - werkzeug - INFO - 127.0.0.1 - - [07/Jul/2025 19:40:51] "POST /socket.io/?EIO=4&transport=polling&t=e0esr2m9&sid=C18CrSifHGP8BpkeAAAE HTTP/1.1" 200 -
|
||||||
|
19:40:51 - werkzeug - INFO - 127.0.0.1 - - [07/Jul/2025 19:40:51] "GET /socket.io/?EIO=4&transport=polling&t=e0ess9wp&sid=C18CrSifHGP8BpkeAAAE HTTP/1.1" 200 -
|
||||||
|
19:40:51 - werkzeug - INFO - 127.0.0.1 - - [07/Jul/2025 19:40:51] "POST /socket.io/?EIO=4&transport=polling&t=e0et0qa7&sid=C18CrSifHGP8BpkeAAAE HTTP/1.1" 200 -
|
||||||
|
19:40:51 - werkzeug - INFO - 127.0.0.1 - - [07/Jul/2025 19:40:51] "POST /socket.io/?EIO=4&transport=polling&t=e0et3xhl&sid=C18CrSifHGP8BpkeAAAE HTTP/1.1" 200 -
|
||||||
13
main.py
13
main.py
@@ -1,3 +1,5 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from app import create_app, socketio
|
from app import create_app, socketio
|
||||||
@@ -5,11 +7,20 @@ from app import create_app, socketio
|
|||||||
# Load environment variables from .env file
|
# Load environment variables from .env file
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""Run the Flask application with SocketIO."""
|
"""Run the Flask application with SocketIO."""
|
||||||
app = create_app()
|
app = create_app()
|
||||||
socketio.run(app, debug=True, host="0.0.0.0", port=5000)
|
socketio.run(
|
||||||
|
app, debug=True, host="127.0.0.1", port=5000, allow_unsafe_werkzeug=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ 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",
|
||||||
"requests==2.32.4",
|
"requests==2.32.4",
|
||||||
"werkzeug==3.1.3",
|
"werkzeug==3.1.3",
|
||||||
|
"yt-dlp>=2025.6.30",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
33
uv.lock
generated
33
uv.lock
generated
@@ -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"
|
||||||
@@ -582,6 +573,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/3c/32/b4fb8585d1be0f68bde7e110dffbcf354915f77ad8c778563f0ad9655c02/python_socketio-5.13.0-py3-none-any.whl", hash = "sha256:51f68d6499f2df8524668c24bcec13ba1414117cfb3a90115c559b601ab10caf", size = 77800 },
|
{ url = "https://files.pythonhosted.org/packages/3c/32/b4fb8585d1be0f68bde7e110dffbcf354915f77ad8c778563f0ad9655c02/python_socketio-5.13.0-py3-none-any.whl", hash = "sha256:51f68d6499f2df8524668c24bcec13ba1414117cfb3a90115c559b601ab10caf", size = 77800 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-vlc"
|
||||||
|
version = "3.0.21203"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4b/5b/f9ce6f0c9877b6fe5eafbade55e0dcb6b2b30f1c2c95837aef40e390d63b/python_vlc-3.0.21203.tar.gz", hash = "sha256:52d0544b276b11e58b6c0b748c3e0518f94f74b1b4cd328c83a59eacabead1ec", size = 162211 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/ee/7d76eb3b50ccb1397621f32ede0fb4d17aa55a9aa2251bc34e6b9929fdce/python_vlc-3.0.21203-py3-none-any.whl", hash = "sha256:1613451a31b692ec276296ceeae0c0ba82bfc2d094dabf9aceb70f58944a6320", size = 87651 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "requests"
|
name = "requests"
|
||||||
version = "2.32.4"
|
version = "2.32.4"
|
||||||
@@ -636,10 +636,11 @@ 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 = "requests" },
|
{ name = "requests" },
|
||||||
{ name = "werkzeug" },
|
{ name = "werkzeug" },
|
||||||
|
{ name = "yt-dlp" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
@@ -660,10 +661,11 @@ 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 = "requests", specifier = "==2.32.4" },
|
{ name = "requests", specifier = "==2.32.4" },
|
||||||
{ name = "werkzeug", specifier = "==3.1.3" },
|
{ name = "werkzeug", specifier = "==3.1.3" },
|
||||||
|
{ name = "yt-dlp", specifier = ">=2025.6.30" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
@@ -776,3 +778,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/c9/4a/44d3c295350d77642
|
|||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/78/58/e860788190eba3bcce367f74d29c4675466ce8dddfba85f7827588416f01/wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736", size = 24226 },
|
{ url = "https://files.pythonhosted.org/packages/78/58/e860788190eba3bcce367f74d29c4675466ce8dddfba85f7827588416f01/wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736", size = 24226 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yt-dlp"
|
||||||
|
version = "2025.6.30"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/23/9c/ff64c2fed7909f43a9a0aedb7395c65404e71c2439198764685a6e3b3059/yt_dlp-2025.6.30.tar.gz", hash = "sha256:6d0ae855c0a55bfcc28dffba804ec8525b9b955d34a41191a1561a4cec03d8bd", size = 3034364 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/41/2f048ae3f6d0fa2e59223f08ba5049dbcdac628b0a9f9deac722dd9260a5/yt_dlp-2025.6.30-py3-none-any.whl", hash = "sha256:541becc29ed7b7b3a08751c0a66da4b7f8ee95cb81066221c78e83598bc3d1f3", size = 3279333 },
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user