Compare commits

...

6 Commits

13 changed files with 456 additions and 180 deletions

View File

@@ -4,13 +4,15 @@ from datetime import timedelta
from flask import Flask from flask import Flask
from flask_cors import CORS from flask_cors import CORS
from flask_jwt_extended import JWTManager from flask_jwt_extended import JWTManager
from flask_socketio import SocketIO
from app.database import init_db from app.database import init_db
from app.services.auth_service import AuthService from app.services.auth_service import AuthService
from app.services.scheduler_service import scheduler_service from app.services.scheduler_service import scheduler_service
# Global auth service instance # Global service instances
auth_service = AuthService() auth_service = AuthService()
socketio = SocketIO()
def create_app(): def create_app():
@@ -35,7 +37,7 @@ 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"] = "/api/" app.config["JWT_ACCESS_COOKIE_PATH"] = "/" # Allow access to all paths including SocketIO
app.config["JWT_REFRESH_COOKIE_PATH"] = "/api/auth/refresh" app.config["JWT_REFRESH_COOKIE_PATH"] = "/api/auth/refresh"
# Initialize CORS # Initialize CORS
@@ -47,6 +49,13 @@ def create_app():
methods=["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"], methods=["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"],
) )
# Initialize SocketIO
socketio.init_app(
app,
cors_allowed_origins="http://localhost:3000",
cors_credentials=True,
)
# Initialize JWT manager # Initialize JWT manager
jwt = JWTManager(app) jwt = JWTManager(app)
@@ -62,6 +71,9 @@ def create_app():
# Initialize authentication service with app # Initialize authentication service with app
auth_service.init_app(app) auth_service.init_app(app)
# Initialize SocketIO service (import after socketio is initialized)
from app.services.socketio_service import socketio_service # noqa: F401
# Initialize scheduler service with app # Initialize scheduler service with app
scheduler_service.app = app scheduler_service.app = app

View File

@@ -3,11 +3,13 @@
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from typing import Optional from typing import Optional
from zoneinfo import ZoneInfo
from app.database import db
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
from app.database import db
class SoundType(Enum): class SoundType(Enum):
"""Sound type enumeration.""" """Sound type enumeration."""
@@ -75,13 +77,13 @@ class Sound(db.Model):
# Timestamps # Timestamps
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
default=datetime.utcnow, default=lambda: datetime.now(tz=ZoneInfo("UTC")),
nullable=False, nullable=False,
) )
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
default=datetime.utcnow, default=lambda: datetime.now(tz=ZoneInfo("UTC")),
onupdate=datetime.utcnow, onupdate=lambda: datetime.now(tz=ZoneInfo("UTC")),
nullable=False, nullable=False,
) )
@@ -114,7 +116,7 @@ class Sound(db.Model):
def increment_play_count(self) -> None: def increment_play_count(self) -> None:
"""Increment the play count for this sound.""" """Increment the play count for this sound."""
self.play_count += 1 self.play_count += 1
self.updated_at = datetime.utcnow() self.updated_at = datetime.now(tz=ZoneInfo("UTC"))
db.session.commit() db.session.commit()
def set_normalized_info( def set_normalized_info(
@@ -130,7 +132,7 @@ class Sound(db.Model):
self.normalized_size = normalized_size self.normalized_size = normalized_size
self.normalized_hash = normalized_hash self.normalized_hash = normalized_hash
self.is_normalized = True self.is_normalized = True
self.updated_at = datetime.utcnow() self.updated_at = datetime.now(tz=ZoneInfo("UTC"))
def clear_normalized_info(self) -> None: def clear_normalized_info(self) -> None:
"""Clear normalized sound information.""" """Clear normalized sound information."""
@@ -139,7 +141,7 @@ class Sound(db.Model):
self.normalized_hash = None self.normalized_hash = None
self.normalized_size = None self.normalized_size = None
self.is_normalized = False self.is_normalized = False
self.updated_at = datetime.utcnow() self.updated_at = datetime.now(tz=ZoneInfo("UTC"))
def update_file_info( def update_file_info(
self, self,
@@ -153,7 +155,7 @@ class Sound(db.Model):
self.duration = duration self.duration = duration
self.size = size self.size = size
self.hash = hash_value self.hash = hash_value
self.updated_at = datetime.utcnow() self.updated_at = datetime.now(tz=ZoneInfo("UTC"))
@classmethod @classmethod
def find_by_hash(cls, hash_value: str) -> Optional["Sound"]: def find_by_hash(cls, hash_value: str) -> Optional["Sound"]:

View File

@@ -1,11 +1,12 @@
"""Sound played tracking model.""" """Sound played tracking model."""
from datetime import datetime from datetime import datetime
from typing import List, Optional from zoneinfo import ZoneInfo
from sqlalchemy import DateTime, ForeignKey, Integer, func, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import db from app.database import db
from sqlalchemy import DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
class SoundPlayed(db.Model): class SoundPlayed(db.Model):
@@ -17,19 +18,21 @@ class SoundPlayed(db.Model):
# Foreign keys # Foreign keys
user_id: Mapped[int] = mapped_column( user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id"), nullable=False Integer,
ForeignKey("users.id"),
nullable=False,
) )
sound_id: Mapped[int] = mapped_column( sound_id: Mapped[int] = mapped_column(
Integer, ForeignKey("sounds.id"), nullable=False Integer,
ForeignKey("sounds.id"),
nullable=False,
) )
# Additional context
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
user_agent: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Timestamp # Timestamp
played_at: Mapped[datetime] = mapped_column( played_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, nullable=False DateTime,
default=lambda: datetime.now(tz=ZoneInfo("UTC")),
nullable=False,
) )
# Relationships # Relationships
@@ -37,8 +40,11 @@ class SoundPlayed(db.Model):
sound: Mapped["Sound"] = relationship("Sound", backref="play_history") sound: Mapped["Sound"] = relationship("Sound", backref="play_history")
def __repr__(self) -> str: def __repr__(self) -> str:
"""String representation of SoundPlayed.""" """Return string representation of SoundPlayed."""
return f"<SoundPlayed user_id={self.user_id} sound_id={self.sound_id} at={self.played_at}>" return (
f"<SoundPlayed user_id={self.user_id} sound_id={self.sound_id} "
f"at={self.played_at}>"
)
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Convert sound played record to dictionary.""" """Convert sound played record to dictionary."""
@@ -46,20 +52,26 @@ class SoundPlayed(db.Model):
"id": self.id, "id": self.id,
"user_id": self.user_id, "user_id": self.user_id,
"sound_id": self.sound_id, "sound_id": self.sound_id,
"ip_address": self.ip_address,
"user_agent": self.user_agent,
"played_at": self.played_at.isoformat(), "played_at": self.played_at.isoformat(),
"user": { "user": (
{
"id": self.user.id, "id": self.user.id,
"name": self.user.name, "name": self.user.name,
"email": self.user.email, "email": self.user.email,
} if self.user else None, }
"sound": { if self.user
else None
),
"sound": (
{
"id": self.sound.id, "id": self.sound.id,
"name": self.sound.name, "name": self.sound.name,
"filename": self.sound.filename, "filename": self.sound.filename,
"type": self.sound.type, "type": self.sound.type,
} if self.sound else None, }
if self.sound
else None
),
} }
@classmethod @classmethod
@@ -67,16 +79,13 @@ class SoundPlayed(db.Model):
cls, cls,
user_id: int, user_id: int,
sound_id: int, sound_id: int,
ip_address: str | None = None, *,
user_agent: str | None = None,
commit: bool = True, commit: bool = True,
) -> "SoundPlayed": ) -> "SoundPlayed":
"""Create a new sound played record.""" """Create a new sound played record."""
play_record = cls( play_record = cls(
user_id=user_id, user_id=user_id,
sound_id=sound_id, sound_id=sound_id,
ip_address=ip_address,
user_agent=user_agent,
) )
db.session.add(play_record) db.session.add(play_record)
@@ -86,8 +95,11 @@ class SoundPlayed(db.Model):
@classmethod @classmethod
def get_user_plays( def get_user_plays(
cls, user_id: int, limit: int = 50, offset: int = 0 cls,
) -> List["SoundPlayed"]: user_id: int,
limit: int = 50,
offset: int = 0,
) -> list["SoundPlayed"]:
"""Get recent plays for a specific user.""" """Get recent plays for a specific user."""
return ( return (
cls.query.filter_by(user_id=user_id) cls.query.filter_by(user_id=user_id)
@@ -99,8 +111,11 @@ class SoundPlayed(db.Model):
@classmethod @classmethod
def get_sound_plays( def get_sound_plays(
cls, sound_id: int, limit: int = 50, offset: int = 0 cls,
) -> List["SoundPlayed"]: sound_id: int,
limit: int = 50,
offset: int = 0,
) -> list["SoundPlayed"]:
"""Get recent plays for a specific sound.""" """Get recent plays for a specific sound."""
return ( return (
cls.query.filter_by(sound_id=sound_id) cls.query.filter_by(sound_id=sound_id)
@@ -112,8 +127,10 @@ class SoundPlayed(db.Model):
@classmethod @classmethod
def get_recent_plays( def get_recent_plays(
cls, limit: int = 100, offset: int = 0 cls,
) -> List["SoundPlayed"]: limit: int = 100,
offset: int = 0,
) -> list["SoundPlayed"]:
"""Get recent plays across all users and sounds.""" """Get recent plays across all users and sounds."""
return ( return (
cls.query.order_by(cls.played_at.desc()) cls.query.order_by(cls.played_at.desc())
@@ -134,10 +151,12 @@ class SoundPlayed(db.Model):
@classmethod @classmethod
def get_popular_sounds( def get_popular_sounds(
cls, limit: int = 10, days: int | None = None cls,
) -> List[dict]: limit: int = 10,
days: int | None = None,
) -> list[dict]:
"""Get most popular sounds with play counts.""" """Get most popular sounds with play counts."""
from sqlalchemy import func, text from app.models.sound import Sound
query = ( query = (
db.session.query( db.session.query(
@@ -151,7 +170,7 @@ class SoundPlayed(db.Model):
if days: if days:
query = query.filter( query = query.filter(
cls.played_at >= text(f"datetime('now', '-{days} days')") cls.played_at >= text(f"datetime('now', '-{days} days')"),
) )
results = query.limit(limit).all() results = query.limit(limit).all()
@@ -159,22 +178,26 @@ class SoundPlayed(db.Model):
# Get sound details # Get sound details
popular_sounds = [] popular_sounds = []
for result in results: for result in results:
from app.models.sound import Sound
sound = Sound.query.get(result.sound_id) sound = Sound.query.get(result.sound_id)
if sound: if sound:
popular_sounds.append({ popular_sounds.append(
{
"sound": sound.to_dict(), "sound": sound.to_dict(),
"play_count": result.play_count, "play_count": result.play_count,
"last_played": result.last_played.isoformat() if result.last_played else None, "last_played": (
}) result.last_played.isoformat()
if result.last_played
else None
),
}
)
return popular_sounds return popular_sounds
@classmethod @classmethod
def get_user_stats(cls, user_id: int) -> dict: def get_user_stats(cls, user_id: int) -> dict:
"""Get comprehensive stats for a user.""" """Get comprehensive stats for a user."""
from sqlalchemy import func from app.models.sound import Sound
total_plays = cls.query.filter_by(user_id=user_id).count() total_plays = cls.query.filter_by(user_id=user_id).count()
@@ -198,7 +221,8 @@ class SoundPlayed(db.Model):
# Get favorite sound # Get favorite sound
favorite_query = ( favorite_query = (
db.session.query( db.session.query(
cls.sound_id, func.count(cls.id).label("play_count") cls.sound_id,
func.count(cls.id).label("play_count"),
) )
.filter_by(user_id=user_id) .filter_by(user_id=user_id)
.group_by(cls.sound_id) .group_by(cls.sound_id)
@@ -208,8 +232,6 @@ class SoundPlayed(db.Model):
favorite_sound = None favorite_sound = None
if favorite_query: if favorite_query:
from app.models.sound import Sound
sound = Sound.query.get(favorite_query.sound_id) sound = Sound.query.get(favorite_query.sound_id)
if sound: if sound:
favorite_sound = { favorite_sound = {
@@ -233,6 +255,10 @@ class SoundPlayed(db.Model):
"total_plays": total_plays, "total_plays": total_plays,
"unique_sounds": unique_sounds, "unique_sounds": unique_sounds,
"favorite_sound": favorite_sound, "favorite_sound": favorite_sound,
"first_play": first_play.played_at.isoformat() if first_play else None, "first_play": (
"last_play": last_play.played_at.isoformat() if last_play else None, first_play.played_at.isoformat() if first_play else None
),
"last_play": (
last_play.played_at.isoformat() if last_play else None
),
} }

View File

@@ -3,6 +3,7 @@
import secrets import secrets
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
from zoneinfo import ZoneInfo
from sqlalchemy import DateTime, ForeignKey, Integer, String from sqlalchemy import DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -63,13 +64,13 @@ class User(db.Model):
# Timestamps # Timestamps
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
default=datetime.utcnow, default=lambda: datetime.now(tz=ZoneInfo("UTC")),
nullable=False, nullable=False,
) )
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
default=datetime.utcnow, default=lambda: datetime.now(tz=ZoneInfo("UTC")),
onupdate=datetime.utcnow, onupdate=lambda: datetime.now(tz=ZoneInfo("UTC")),
nullable=False, nullable=False,
) )
@@ -103,9 +104,11 @@ class User(db.Model):
"role": self.role, "role": self.role,
"is_active": self.is_active, "is_active": self.is_active,
"api_token": self.api_token, "api_token": self.api_token,
"api_token_expires_at": self.api_token_expires_at.isoformat() "api_token_expires_at": (
self.api_token_expires_at.isoformat()
if self.api_token_expires_at if self.api_token_expires_at
else None, else None
),
"providers": providers, "providers": providers,
"plan": self.plan.to_dict() if self.plan else None, "plan": self.plan.to_dict() if self.plan else None,
"credits": self.credits, "credits": self.credits,
@@ -129,13 +132,13 @@ class User(db.Model):
self.email = provider_data.get("email", self.email) self.email = provider_data.get("email", self.email)
self.name = provider_data.get("name", self.name) self.name = provider_data.get("name", self.name)
self.picture = provider_data.get("picture", self.picture) self.picture = provider_data.get("picture", self.picture)
self.updated_at = datetime.utcnow() self.updated_at = datetime.now(tz=ZoneInfo("UTC"))
db.session.commit() db.session.commit()
def set_password(self, password: str) -> None: def set_password(self, password: str) -> None:
"""Hash and set user password.""" """Hash and set user password."""
self.password_hash = generate_password_hash(password) self.password_hash = generate_password_hash(password)
self.updated_at = datetime.utcnow() self.updated_at = datetime.now(tz=ZoneInfo("UTC"))
def check_password(self, password: str) -> bool: def check_password(self, password: str) -> bool:
"""Check if provided password matches user's password.""" """Check if provided password matches user's password."""
@@ -151,7 +154,7 @@ class User(db.Model):
"""Generate a new API token for the user.""" """Generate a new API token for the user."""
self.api_token = secrets.token_urlsafe(32) self.api_token = secrets.token_urlsafe(32)
self.api_token_expires_at = None # No expiration by default self.api_token_expires_at = None # No expiration by default
self.updated_at = datetime.utcnow() self.updated_at = datetime.now(tz=ZoneInfo("UTC"))
return self.api_token return self.api_token
def is_api_token_valid(self) -> bool: def is_api_token_valid(self) -> bool:
@@ -162,23 +165,23 @@ class User(db.Model):
if self.api_token_expires_at is None: if self.api_token_expires_at is None:
return True # No expiration return True # No expiration
return datetime.utcnow() < self.api_token_expires_at return datetime.now(tz=ZoneInfo("UTC")) < self.api_token_expires_at
def revoke_api_token(self) -> None: def revoke_api_token(self) -> None:
"""Revoke the user's API token.""" """Revoke the user's API token."""
self.api_token = None self.api_token = None
self.api_token_expires_at = None self.api_token_expires_at = None
self.updated_at = datetime.utcnow() self.updated_at = datetime.now(tz=ZoneInfo("UTC"))
def activate(self) -> None: def activate(self) -> None:
"""Activate the user account.""" """Activate the user account."""
self.is_active = True self.is_active = True
self.updated_at = datetime.utcnow() self.updated_at = datetime.now(tz=ZoneInfo("UTC"))
def deactivate(self) -> None: def deactivate(self) -> None:
"""Deactivate the user account.""" """Deactivate the user account."""
self.is_active = False self.is_active = False
self.updated_at = datetime.utcnow() self.updated_at = datetime.now(tz=ZoneInfo("UTC"))
@classmethod @classmethod
def find_by_email(cls, email: str) -> Optional["User"]: def find_by_email(cls, email: str) -> Optional["User"]:
@@ -218,7 +221,7 @@ class User(db.Model):
oauth_provider.email = email oauth_provider.email = email
oauth_provider.name = name oauth_provider.name = name
oauth_provider.picture = picture oauth_provider.picture = picture
oauth_provider.updated_at = datetime.utcnow() oauth_provider.updated_at = datetime.now(tz=ZoneInfo("UTC"))
# Update user info with latest data # Update user info with latest data
user.update_from_provider( user.update_from_provider(

View File

@@ -2,6 +2,7 @@
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
from zoneinfo import ZoneInfo
from sqlalchemy import DateTime, ForeignKey, String, Text from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -34,13 +35,13 @@ class UserOAuth(db.Model):
# Timestamps # Timestamps
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
default=datetime.utcnow, default=lambda: datetime.now(tz=ZoneInfo("UTC")),
nullable=False, nullable=False,
) )
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
default=datetime.utcnow, default=lambda: datetime.now(tz=ZoneInfo("UTC")),
onupdate=datetime.utcnow, onupdate=lambda: datetime.now(tz=ZoneInfo("UTC")),
nullable=False, nullable=False,
) )
@@ -107,7 +108,7 @@ class UserOAuth(db.Model):
oauth_provider.email = email oauth_provider.email = email
oauth_provider.name = name oauth_provider.name = name
oauth_provider.picture = picture oauth_provider.picture = picture
oauth_provider.updated_at = datetime.utcnow() oauth_provider.updated_at = datetime.now(tz=ZoneInfo("UTC"))
else: else:
# Create new provider # Create new provider
oauth_provider = cls( oauth_provider = cls(

View File

@@ -1,10 +1,15 @@
"""Soundboard routes.""" """Soundboard routes."""
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify, request
from app.models.sound import Sound, SoundType from app.models.sound import Sound, SoundType
from app.models.sound_played import SoundPlayed from app.models.sound_played import SoundPlayed
from app.services.decorators import (
get_current_user,
require_auth,
require_credits,
)
from app.services.vlc_service import vlc_service from app.services.vlc_service import vlc_service
from app.services.decorators import require_auth, get_current_user
bp = Blueprint("soundboard", __name__, url_prefix="/api/soundboard") bp = Blueprint("soundboard", __name__, url_prefix="/api/soundboard")
@@ -40,6 +45,7 @@ def get_sounds():
@bp.route("/sounds/<int:sound_id>/play", methods=["POST"]) @bp.route("/sounds/<int:sound_id>/play", methods=["POST"])
@require_auth @require_auth
@require_credits(1)
def play_sound(sound_id: int): def play_sound(sound_id: int):
"""Play a specific sound.""" """Play a specific sound."""
try: try:
@@ -61,9 +67,10 @@ def play_sound(sound_id: int):
if success: if success:
return jsonify({"message": "Sound playing", "sound_id": sound_id}) return jsonify({"message": "Sound playing", "sound_id": sound_id})
else: else:
return jsonify( return (
{"error": "Sound not found or cannot be played"} jsonify({"error": "Sound not found or cannot be played"}),
), 404 404,
)
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@@ -78,15 +85,18 @@ def stop_all_sounds():
# Wait a moment and check if any are still playing # Wait a moment and check if any are still playing
import time import time
time.sleep(0.2) time.sleep(0.2)
# If there are still instances, force stop them # If there are still instances, force stop them
if vlc_service.get_playing_count() > 0: if vlc_service.get_playing_count() > 0:
stopped_count = vlc_service.force_stop_all() stopped_count = vlc_service.force_stop_all()
return jsonify({ return jsonify(
{
"message": f"Force stopped {stopped_count} sounds", "message": f"Force stopped {stopped_count} sounds",
"forced": True "forced": True,
}) }
)
return jsonify({"message": "All sounds stopped"}) return jsonify({"message": "All sounds stopped"})
except Exception as e: except Exception as e:
@@ -99,10 +109,12 @@ def force_stop_all_sounds():
"""Force stop all sounds with aggressive cleanup.""" """Force stop all sounds with aggressive cleanup."""
try: try:
stopped_count = vlc_service.force_stop_all() stopped_count = vlc_service.force_stop_all()
return jsonify({ return jsonify(
{
"message": f"Force stopped {stopped_count} sound instances", "message": f"Force stopped {stopped_count} sound instances",
"stopped_count": stopped_count "stopped_count": stopped_count,
}) }
)
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@@ -118,11 +130,13 @@ def get_status():
with vlc_service.lock: with vlc_service.lock:
processes = [] processes = []
for process_id, process in vlc_service.processes.items(): for process_id, process in vlc_service.processes.items():
processes.append({ processes.append(
{
"id": process_id, "id": process_id,
"pid": process.pid, "pid": process.pid,
"running": process.poll() is None, "running": process.poll() is None,
}) }
)
return jsonify( return jsonify(
{ {
@@ -144,13 +158,17 @@ def get_play_history():
per_page = min(int(request.args.get("per_page", 50)), 100) per_page = min(int(request.args.get("per_page", 50)), 100)
offset = (page - 1) * per_page offset = (page - 1) * per_page
recent_plays = SoundPlayed.get_recent_plays(limit=per_page, offset=offset) recent_plays = SoundPlayed.get_recent_plays(
limit=per_page, offset=offset
)
return jsonify({ return jsonify(
{
"plays": [play.to_dict() for play in recent_plays], "plays": [play.to_dict() for play in recent_plays],
"page": page, "page": page,
"per_page": per_page, "per_page": per_page,
}) }
)
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@@ -169,14 +187,18 @@ def get_my_play_history():
per_page = min(int(request.args.get("per_page", 50)), 100) per_page = min(int(request.args.get("per_page", 50)), 100)
offset = (page - 1) * per_page offset = (page - 1) * per_page
user_plays = SoundPlayed.get_user_plays(user_id=user_id, limit=per_page, offset=offset) user_plays = SoundPlayed.get_user_plays(
user_id=user_id, limit=per_page, offset=offset
)
return jsonify({ return jsonify(
{
"plays": [play.to_dict() for play in user_plays], "plays": [play.to_dict() for play in user_plays],
"page": page, "page": page,
"per_page": per_page, "per_page": per_page,
"user_id": user_id, "user_id": user_id,
}) }
)
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@@ -209,10 +231,12 @@ def get_popular_sounds():
popular_sounds = SoundPlayed.get_popular_sounds(limit=limit, days=days) popular_sounds = SoundPlayed.get_popular_sounds(limit=limit, days=days)
return jsonify({ return jsonify(
{
"popular_sounds": popular_sounds, "popular_sounds": popular_sounds,
"limit": limit, "limit": limit,
"days": days, "days": days,
}) }
)
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500

View File

@@ -2,6 +2,7 @@
import logging import logging
from datetime import datetime from datetime import datetime
from zoneinfo import ZoneInfo
from app.database import db from app.database import db
from app.models.user import User from app.models.user import User
@@ -62,7 +63,7 @@ class CreditService:
if credits_added > 0: if credits_added > 0:
user.credits = new_credits user.credits = new_credits
user.updated_at = datetime.utcnow() user.updated_at = datetime.now(tz=ZoneInfo("UTC"))
total_credits_added += credits_added total_credits_added += credits_added
logger.debug( logger.debug(

View File

@@ -148,22 +148,7 @@ def require_role(required_role: str):
def require_admin(f): def require_admin(f):
"""Decorator to require admin role for routes.""" """Decorator to require admin role for routes."""
return require_role("admin")(f)
@wraps(f)
def wrapper(*args, **kwargs):
user = get_current_user()
if not user:
return jsonify({"error": "Authentication required"}), 401
if user.get("role") != "admin":
return (
jsonify({"error": "Access denied. Admin role required"}),
403,
)
return f(*args, **kwargs)
return wrapper
def require_credits(credits_needed: int): def require_credits(credits_needed: int):
@@ -200,6 +185,16 @@ def require_credits(credits_needed: int):
user.credits -= credits_needed user.credits -= credits_needed
db.session.commit() db.session.commit()
# Emit credits changed event via SocketIO
try:
from app.services.socketio_service import socketio_service
socketio_service.emit_credits_changed(user.id, user.credits)
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_changed event: {e}")
# Execute the function # Execute the function
result = f(*args, **kwargs) result = f(*args, **kwargs)

View File

@@ -0,0 +1,134 @@
"""SocketIO service for real-time communication."""
import logging
from flask import request
from flask_jwt_extended import decode_token
from flask_socketio import disconnect, emit, join_room, leave_room
from app import socketio
logger = logging.getLogger(__name__)
class SocketIOService:
"""Service for managing SocketIO connections and user rooms."""
@staticmethod
def get_user_room(user_id: int) -> str:
"""Get the room name for a specific user."""
return f"user_{user_id}"
@staticmethod
def emit_to_user(user_id: int, event: str, data: dict) -> None:
"""Emit an event to a specific user's room."""
room = SocketIOService.get_user_room(user_id)
socketio.emit(event, data, room=room)
logger.debug(f"Emitted {event} to user {user_id} in room {room}")
@staticmethod
def emit_credits_changed(user_id: int, new_credits: int) -> None:
"""Emit credits_changed event to a user."""
SocketIOService.emit_to_user(
user_id,
"credits_changed",
{"credits": new_credits},
)
@staticmethod
def get_user_from_socketio() -> dict | None:
"""Get user from SocketIO connection using cookies."""
try:
from flask import current_app
from flask_jwt_extended import decode_token
# Check if we have the access_token cookie
access_token = request.cookies.get("access_token_cookie")
if not access_token:
return None
# Decode the JWT token manually
with current_app.app_context():
try:
decoded_token = decode_token(access_token)
current_user_id = decoded_token["sub"]
if not current_user_id:
return None
except Exception:
return None
# Query database for user data
from app.models.user import User
user = User.query.get(int(current_user_id))
if not user or not user.is_active:
return None
return {
"id": str(user.id),
"email": user.email,
"name": user.name,
"role": user.role,
"credits": user.credits,
}
except Exception:
return None
@socketio.on("connect")
def handle_connect(auth=None) -> bool:
"""Handle client connection."""
try:
logger.info(f"SocketIO connection from {request.remote_addr}")
return True
except Exception:
logger.exception("Error handling SocketIO connection")
disconnect()
return False
@socketio.on("authenticate")
def handle_authenticate(data):
"""Handle authentication after connection."""
try:
user = SocketIOService.get_user_from_socketio()
if not user:
emit("auth_error", {"error": "Authentication failed"})
disconnect()
return
user_id = int(user["id"])
user_room = SocketIOService.get_user_room(user_id)
# Join user-specific room
join_room(user_room)
logger.info(f"User {user_id} authenticated and joined room {user_room}")
# Send current credits on authentication
emit("auth_success", {"user": user})
emit("credits_changed", {"credits": user["credits"]})
except Exception:
logger.exception("Error handling SocketIO authentication")
emit("auth_error", {"error": "Authentication failed"})
disconnect()
@socketio.on("disconnect")
def handle_disconnect() -> None:
"""Handle client disconnection."""
try:
user = SocketIOService.get_user_from_socketio()
if user:
user_id = int(user["id"])
user_room = SocketIOService.get_user_room(user_id)
leave_room(user_room)
logger.info(f"User {user_id} disconnected from SocketIO")
except Exception:
logger.exception("Error handling SocketIO disconnection")
# Export the service instance
socketio_service = SocketIOService()

View File

@@ -20,7 +20,7 @@ class VLCService:
self.processes: Dict[str, subprocess.Popen] = {} self.processes: Dict[str, subprocess.Popen] = {}
self.lock = threading.Lock() self.lock = threading.Lock()
def play_sound(self, sound_id: int, user_id: int | None = None, ip_address: str | None = None, user_agent: str | None = None) -> bool: def play_sound(self, sound_id: int, user_id: int | None = None) -> bool:
"""Play a sound by ID using VLC subprocess.""" """Play a sound by ID using VLC subprocess."""
try: try:
# Get sound from database # Get sound from database
@@ -86,8 +86,6 @@ class VLCService:
SoundPlayed.create_play_record( SoundPlayed.create_play_record(
user_id=user_id, user_id=user_id,
sound_id=sound_id, sound_id=sound_id,
ip_address=ip_address,
user_agent=user_agent,
commit=True, commit=True,
) )
except Exception as e: except Exception as e:

View File

@@ -1,15 +1,15 @@
from dotenv import load_dotenv from dotenv import load_dotenv
from app import create_app from app import create_app, socketio
# Load environment variables from .env file # Load environment variables from .env file
load_dotenv() load_dotenv()
def main() -> None: def main() -> None:
"""Run the Flask application.""" """Run the Flask application with SocketIO."""
app = create_app() app = create_app()
app.run(debug=True, host="0.0.0.0", port=5000) socketio.run(app, debug=True, host="0.0.0.0", port=5000)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -13,6 +13,7 @@ dependencies = [
"flask-cors==6.0.1", "flask-cors==6.0.1",
"flask-jwt-extended==4.7.1", "flask-jwt-extended==4.7.1",
"flask-migrate==4.1.0", "flask-migrate==4.1.0",
"flask-socketio==5.5.1",
"flask-sqlalchemy==3.1.1", "flask-sqlalchemy==3.1.1",
"pydub==0.25.1", "pydub==0.25.1",
"python-dotenv==1.1.1", "python-dotenv==1.1.1",
@@ -21,7 +22,7 @@ dependencies = [
] ]
[dependency-groups] [dependency-groups]
dev = ["black==25.1.0", "pytest==8.4.1", "ruff==0.12.1"] dev = ["black==25.1.0", "pytest==8.4.1", "ruff==0.12.2"]
[tool.black] [tool.black]
line-length = 80 line-length = 80
@@ -29,7 +30,4 @@ line-length = 80
[tool.ruff] [tool.ruff]
line-length = 80 line-length = 80
lint.select = ["ALL"] lint.select = ["ALL"]
lint.ignore = [ lint.ignore = ["D100", "D104"]
"D100", # Missing docstring in public module
"D104", # Missing docstring in public package
]

122
uv.lock generated
View File

@@ -40,6 +40,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/84/29/587c189bbab1ccc8c86a03a5d0e13873df916380ef1be461ebe6acebf48d/authlib-1.6.0-py2.py3-none-any.whl", hash = "sha256:91685589498f79e8655e8a8947431ad6288831d643f11c55c2143ffcc738048d", size = 239981 }, { url = "https://files.pythonhosted.org/packages/84/29/587c189bbab1ccc8c86a03a5d0e13873df916380ef1be461ebe6acebf48d/authlib-1.6.0-py2.py3-none-any.whl", hash = "sha256:91685589498f79e8655e8a8947431ad6288831d643f11c55c2143ffcc738048d", size = 239981 },
] ]
[[package]]
name = "bidict"
version = "0.23.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/026678aa5a830e07cd9498a05d3e7e650a4f56a42f267a53d22bcda1bdc9/bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71", size = 29093 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764 },
]
[[package]] [[package]]
name = "black" name = "black"
version = "25.1.0" version = "25.1.0"
@@ -276,6 +285,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/c4/3f329b23d769fe7628a5fc57ad36956f1fb7132cf8837be6da762b197327/Flask_Migrate-4.1.0-py3-none-any.whl", hash = "sha256:24d8051af161782e0743af1b04a152d007bad9772b2bca67b7ec1e8ceeb3910d", size = 21237 }, { url = "https://files.pythonhosted.org/packages/d2/c4/3f329b23d769fe7628a5fc57ad36956f1fb7132cf8837be6da762b197327/Flask_Migrate-4.1.0-py3-none-any.whl", hash = "sha256:24d8051af161782e0743af1b04a152d007bad9772b2bca67b7ec1e8ceeb3910d", size = 21237 },
] ]
[[package]]
name = "flask-socketio"
version = "5.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
{ name = "python-socketio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/1f/54d3de4982df695682af99c65d4b89f8a46fe6739780c5a68690195835a0/flask_socketio-5.5.1.tar.gz", hash = "sha256:d946c944a1074ccad8e99485a6f5c79bc5789e3ea4df0bb9c864939586c51ec4", size = 37401 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/38/1b75b3ba3452860211ec87710f9854112911a436ee4d155533e0b83b5cd9/Flask_SocketIO-5.5.1-py3-none-any.whl", hash = "sha256:35a50166db44d055f68021d6ec32cb96f1f925cd82de4504314be79139ea846f", size = 18259 },
]
[[package]] [[package]]
name = "flask-sqlalchemy" name = "flask-sqlalchemy"
version = "3.1.1" version = "3.1.1"
@@ -331,6 +353,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/4f/aab73ecaa6b3086a4c89863d94cf26fa84cbff63f52ce9bc4342b3087a06/greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a", size = 301236 }, { url = "https://files.pythonhosted.org/packages/5c/4f/aab73ecaa6b3086a4c89863d94cf26fa84cbff63f52ce9bc4342b3087a06/greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a", size = 301236 },
] ]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 },
]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.10" version = "3.10"
@@ -526,6 +557,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556 }, { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556 },
] ]
[[package]]
name = "python-engineio"
version = "4.12.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "simple-websocket" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ba/0b/67295279b66835f9fa7a491650efcd78b20321c127036eef62c11a31e028/python_engineio-4.12.2.tar.gz", hash = "sha256:e7e712ffe1be1f6a05ee5f951e72d434854a32fcfc7f6e4d9d3cae24ec70defa", size = 91677 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/fa/df59acedf7bbb937f69174d00f921a7b93aa5a5f5c17d05296c814fff6fc/python_engineio-4.12.2-py3-none-any.whl", hash = "sha256:8218ab66950e179dfec4b4bbb30aecf3f5d86f5e58e6fc1aa7fde2c698b2804f", size = 59536 },
]
[[package]]
name = "python-socketio"
version = "5.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bidict" },
{ name = "python-engineio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/21/1a/396d50ccf06ee539fa758ce5623b59a9cb27637fc4b2dc07ed08bf495e77/python_socketio-5.13.0.tar.gz", hash = "sha256:ac4e19a0302ae812e23b712ec8b6427ca0521f7c582d6abb096e36e24a263029", size = 121125 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/32/b4fb8585d1be0f68bde7e110dffbcf354915f77ad8c778563f0ad9655c02/python_socketio-5.13.0-py3-none-any.whl", hash = "sha256:51f68d6499f2df8524668c24bcec13ba1414117cfb3a90115c559b601ab10caf", size = 77800 },
]
[[package]] [[package]]
name = "requests" name = "requests"
version = "2.32.4" version = "2.32.4"
@@ -543,27 +599,27 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.12.1" version = "0.12.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/97/38/796a101608a90494440856ccfb52b1edae90de0b817e76bfade66b12d320/ruff-0.12.1.tar.gz", hash = "sha256:806bbc17f1104fd57451a98a58df35388ee3ab422e029e8f5cf30aa4af2c138c", size = 4413426 } sdist = { url = "https://files.pythonhosted.org/packages/6c/3d/d9a195676f25d00dbfcf3cf95fdd4c685c497fcfa7e862a44ac5e4e96480/ruff-0.12.2.tar.gz", hash = "sha256:d7b4f55cd6f325cb7621244f19c873c565a08aff5a4ba9c69aa7355f3f7afd3e", size = 4432239 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/06/bf/3dba52c1d12ab5e78d75bd78ad52fb85a6a1f29cc447c2423037b82bed0d/ruff-0.12.1-py3-none-linux_armv6l.whl", hash = "sha256:6013a46d865111e2edb71ad692fbb8262e6c172587a57c0669332a449384a36b", size = 10305649 }, { url = "https://files.pythonhosted.org/packages/74/b6/2098d0126d2d3318fd5bec3ad40d06c25d377d95749f7a0c5af17129b3b1/ruff-0.12.2-py3-none-linux_armv6l.whl", hash = "sha256:093ea2b221df1d2b8e7ad92fc6ffdca40a2cb10d8564477a987b44fd4008a7be", size = 10369761 },
{ url = "https://files.pythonhosted.org/packages/8c/65/dab1ba90269bc8c81ce1d499a6517e28fe6f87b2119ec449257d0983cceb/ruff-0.12.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b3f75a19e03a4b0757d1412edb7f27cffb0c700365e9d6b60bc1b68d35bc89e0", size = 11120201 }, { url = "https://files.pythonhosted.org/packages/b1/4b/5da0142033dbe155dc598cfb99262d8ee2449d76920ea92c4eeb9547c208/ruff-0.12.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:09e4cf27cc10f96b1708100fa851e0daf21767e9709e1649175355280e0d950e", size = 11155659 },
{ url = "https://files.pythonhosted.org/packages/3f/3e/2d819ffda01defe857fa2dd4cba4d19109713df4034cc36f06bbf582d62a/ruff-0.12.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9a256522893cb7e92bb1e1153283927f842dea2e48619c803243dccc8437b8be", size = 10466769 }, { url = "https://files.pythonhosted.org/packages/3e/21/967b82550a503d7c5c5c127d11c935344b35e8c521f52915fc858fb3e473/ruff-0.12.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8ae64755b22f4ff85e9c52d1f82644abd0b6b6b6deedceb74bd71f35c24044cc", size = 10537769 },
{ url = "https://files.pythonhosted.org/packages/63/37/bde4cf84dbd7821c8de56ec4ccc2816bce8125684f7b9e22fe4ad92364de/ruff-0.12.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:069052605fe74c765a5b4272eb89880e0ff7a31e6c0dbf8767203c1fbd31c7ff", size = 10660902 }, { url = "https://files.pythonhosted.org/packages/33/91/00cff7102e2ec71a4890fb7ba1803f2cdb122d82787c7d7cf8041fe8cbc1/ruff-0.12.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3eb3a6b2db4d6e2c77e682f0b988d4d61aff06860158fdb413118ca133d57922", size = 10717602 },
{ url = "https://files.pythonhosted.org/packages/0e/3a/390782a9ed1358c95e78ccc745eed1a9d657a537e5c4c4812fce06c8d1a0/ruff-0.12.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a684f125a4fec2d5a6501a466be3841113ba6847827be4573fddf8308b83477d", size = 10167002 }, { url = "https://files.pythonhosted.org/packages/9b/eb/928814daec4e1ba9115858adcda44a637fb9010618721937491e4e2283b8/ruff-0.12.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:73448de992d05517170fc37169cbca857dfeaeaa8c2b9be494d7bcb0d36c8f4b", size = 10198772 },
{ url = "https://files.pythonhosted.org/packages/6d/05/f2d4c965009634830e97ffe733201ec59e4addc5b1c0efa035645baa9e5f/ruff-0.12.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdecdef753bf1e95797593007569d8e1697a54fca843d78f6862f7dc279e23bd", size = 11751522 }, { url = "https://files.pythonhosted.org/packages/50/fa/f15089bc20c40f4f72334f9145dde55ab2b680e51afb3b55422effbf2fb6/ruff-0.12.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b8b94317cbc2ae4a2771af641739f933934b03555e51515e6e021c64441532d", size = 11845173 },
{ url = "https://files.pythonhosted.org/packages/35/4e/4bfc519b5fcd462233f82fc20ef8b1e5ecce476c283b355af92c0935d5d9/ruff-0.12.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:70d52a058c0e7b88b602f575d23596e89bd7d8196437a4148381a3f73fcd5010", size = 12520264 }, { url = "https://files.pythonhosted.org/packages/43/9f/1f6f98f39f2b9302acc161a4a2187b1e3a97634fe918a8e731e591841cf4/ruff-0.12.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:45fc42c3bf1d30d2008023a0a9a0cfb06bf9835b147f11fe0679f21ae86d34b1", size = 12553002 },
{ url = "https://files.pythonhosted.org/packages/85/b2/7756a6925da236b3a31f234b4167397c3e5f91edb861028a631546bad719/ruff-0.12.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84d0a69d1e8d716dfeab22d8d5e7c786b73f2106429a933cee51d7b09f861d4e", size = 12133882 }, { url = "https://files.pythonhosted.org/packages/d8/70/08991ac46e38ddd231c8f4fd05ef189b1b94be8883e8c0c146a025c20a19/ruff-0.12.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce48f675c394c37e958bf229fb5c1e843e20945a6d962cf3ea20b7a107dcd9f4", size = 12171330 },
{ url = "https://files.pythonhosted.org/packages/dd/00/40da9c66d4a4d51291e619be6757fa65c91b92456ff4f01101593f3a1170/ruff-0.12.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6cc32e863adcf9e71690248607ccdf25252eeeab5193768e6873b901fd441fed", size = 11608941 }, { url = "https://files.pythonhosted.org/packages/88/a9/5a55266fec474acfd0a1c73285f19dd22461d95a538f29bba02edd07a5d9/ruff-0.12.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:793d8859445ea47591272021a81391350205a4af65a9392401f418a95dfb75c9", size = 11774717 },
{ url = "https://files.pythonhosted.org/packages/91/e7/f898391cc026a77fbe68dfea5940f8213622474cb848eb30215538a2dadf/ruff-0.12.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fd49a4619f90d5afc65cf42e07b6ae98bb454fd5029d03b306bd9e2273d44cc", size = 11602887 }, { url = "https://files.pythonhosted.org/packages/87/e5/0c270e458fc73c46c0d0f7cf970bb14786e5fdb88c87b5e423a4bd65232b/ruff-0.12.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6932323db80484dda89153da3d8e58164d01d6da86857c79f1961934354992da", size = 11646659 },
{ url = "https://files.pythonhosted.org/packages/f6/02/0891872fc6aab8678084f4cf8826f85c5d2d24aa9114092139a38123f94b/ruff-0.12.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ed5af6aaaea20710e77698e2055b9ff9b3494891e1b24d26c07055459bb717e9", size = 10521742 }, { url = "https://files.pythonhosted.org/packages/b7/b6/45ab96070c9752af37f0be364d849ed70e9ccede07675b0ec4e3ef76b63b/ruff-0.12.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6aa7e623a3a11538108f61e859ebf016c4f14a7e6e4eba1980190cacb57714ce", size = 10604012 },
{ url = "https://files.pythonhosted.org/packages/2a/98/d6534322c74a7d47b0f33b036b2498ccac99d8d8c40edadb552c038cecf1/ruff-0.12.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:801d626de15e6bf988fbe7ce59b303a914ff9c616d5866f8c79eb5012720ae13", size = 10149909 }, { url = "https://files.pythonhosted.org/packages/86/91/26a6e6a424eb147cc7627eebae095cfa0b4b337a7c1c413c447c9ebb72fd/ruff-0.12.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2a4a20aeed74671b2def096bdf2eac610c7d8ffcbf4fb0e627c06947a1d7078d", size = 10176799 },
{ url = "https://files.pythonhosted.org/packages/34/5c/9b7ba8c19a31e2b6bd5e31aa1e65b533208a30512f118805371dbbbdf6a9/ruff-0.12.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2be9d32a147f98a1972c1e4df9a6956d612ca5f5578536814372113d09a27a6c", size = 11136005 }, { url = "https://files.pythonhosted.org/packages/f5/0c/9f344583465a61c8918a7cda604226e77b2c548daf8ef7c2bfccf2b37200/ruff-0.12.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:71a4c550195612f486c9d1f2b045a600aeba851b298c667807ae933478fcef04", size = 11241507 },
{ url = "https://files.pythonhosted.org/packages/dc/34/9bbefa4d0ff2c000e4e533f591499f6b834346025e11da97f4ded21cb23e/ruff-0.12.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:49b7ce354eed2a322fbaea80168c902de9504e6e174fd501e9447cad0232f9e6", size = 11648579 }, { url = "https://files.pythonhosted.org/packages/1c/b7/99c34ded8fb5f86c0280278fa89a0066c3760edc326e935ce0b1550d315d/ruff-0.12.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:4987b8f4ceadf597c927beee65a5eaf994c6e2b631df963f86d8ad1bdea99342", size = 11717609 },
{ url = "https://files.pythonhosted.org/packages/6f/1c/20cdb593783f8f411839ce749ec9ae9e4298c2b2079b40295c3e6e2089e1/ruff-0.12.1-py3-none-win32.whl", hash = "sha256:d973fa626d4c8267848755bd0414211a456e99e125dcab147f24daa9e991a245", size = 10519495 }, { url = "https://files.pythonhosted.org/packages/51/de/8589fa724590faa057e5a6d171e7f2f6cffe3287406ef40e49c682c07d89/ruff-0.12.2-py3-none-win32.whl", hash = "sha256:369ffb69b70cd55b6c3fc453b9492d98aed98062db9fec828cdfd069555f5f1a", size = 10523823 },
{ url = "https://files.pythonhosted.org/packages/cf/56/7158bd8d3cf16394928f47c637d39a7d532268cd45220bdb6cd622985760/ruff-0.12.1-py3-none-win_amd64.whl", hash = "sha256:9e1123b1c033f77bd2590e4c1fe7e8ea72ef990a85d2484351d408224d603013", size = 11547485 }, { url = "https://files.pythonhosted.org/packages/94/47/8abf129102ae4c90cba0c2199a1a9b0fa896f6f806238d6f8c14448cc748/ruff-0.12.2-py3-none-win_amd64.whl", hash = "sha256:dca8a3b6d6dc9810ed8f328d406516bf4d660c00caeaef36eb831cf4871b0639", size = 11629831 },
{ url = "https://files.pythonhosted.org/packages/91/d0/6902c0d017259439d6fd2fd9393cea1cfe30169940118b007d5e0ea7e954/ruff-0.12.1-py3-none-win_arm64.whl", hash = "sha256:78ad09a022c64c13cc6077707f036bab0fac8cd7088772dcd1e5be21c5002efc", size = 10691209 }, { url = "https://files.pythonhosted.org/packages/e2/1f/72d2946e3cc7456bb837e88000eb3437e55f80db339c840c04015a11115d/ruff-0.12.2-py3-none-win_arm64.whl", hash = "sha256:48d6c6bfb4761df68bc05ae630e24f506755e702d4fb08f08460be778c7ccb12", size = 10735334 },
] ]
[[package]] [[package]]
@@ -578,6 +634,7 @@ dependencies = [
{ name = "flask-cors" }, { name = "flask-cors" },
{ name = "flask-jwt-extended" }, { name = "flask-jwt-extended" },
{ name = "flask-migrate" }, { name = "flask-migrate" },
{ name = "flask-socketio" },
{ name = "flask-sqlalchemy" }, { name = "flask-sqlalchemy" },
{ name = "pydub" }, { name = "pydub" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
@@ -601,6 +658,7 @@ requires-dist = [
{ name = "flask-cors", specifier = "==6.0.1" }, { name = "flask-cors", specifier = "==6.0.1" },
{ name = "flask-jwt-extended", specifier = "==4.7.1" }, { name = "flask-jwt-extended", specifier = "==4.7.1" },
{ name = "flask-migrate", specifier = "==4.1.0" }, { name = "flask-migrate", specifier = "==4.1.0" },
{ 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 = "pydub", specifier = "==0.25.1" },
{ name = "python-dotenv", specifier = "==1.1.1" }, { name = "python-dotenv", specifier = "==1.1.1" },
@@ -612,7 +670,19 @@ requires-dist = [
dev = [ dev = [
{ name = "black", specifier = "==25.1.0" }, { name = "black", specifier = "==25.1.0" },
{ name = "pytest", specifier = "==8.4.1" }, { name = "pytest", specifier = "==8.4.1" },
{ name = "ruff", specifier = "==0.12.1" }, { name = "ruff", specifier = "==0.12.2" },
]
[[package]]
name = "simple-websocket"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wsproto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b0/d4/bfa032f961103eba93de583b161f0e6a5b63cebb8f2c7d0c6e6efe1e3d2e/simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4", size = 17300 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842 },
] ]
[[package]] [[package]]
@@ -694,3 +764,15 @@ sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498 }, { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498 },
] ]
[[package]]
name = "wsproto"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/4a/44d3c295350d776427904d73c189e10aeae66d7f555bb2feee16d1e4ba5a/wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065", size = 53425 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/58/e860788190eba3bcce367f74d29c4675466ce8dddfba85f7827588416f01/wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736", size = 24226 },
]