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_cors import CORS
from flask_jwt_extended import JWTManager
from flask_socketio import SocketIO
from app.database import init_db
from app.services.auth_service import AuthService
from app.services.scheduler_service import scheduler_service
# Global auth service instance
# Global service instances
auth_service = AuthService()
socketio = SocketIO()
def create_app():
@@ -35,7 +37,7 @@ def create_app():
app.config["JWT_TOKEN_LOCATION"] = ["cookies"]
app.config["JWT_COOKIE_SECURE"] = False # Set to True in production
app.config["JWT_COOKIE_CSRF_PROTECT"] = False
app.config["JWT_ACCESS_COOKIE_PATH"] = "/api/"
app.config["JWT_ACCESS_COOKIE_PATH"] = "/" # Allow access to all paths including SocketIO
app.config["JWT_REFRESH_COOKIE_PATH"] = "/api/auth/refresh"
# Initialize CORS
@@ -47,6 +49,13 @@ def create_app():
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
jwt = JWTManager(app)
@@ -62,6 +71,9 @@ def create_app():
# Initialize authentication service with 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
scheduler_service.app = app

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -148,22 +148,7 @@ def require_role(required_role: str):
def require_admin(f):
"""Decorator to require admin role for routes."""
@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
return require_role("admin")(f)
def require_credits(credits_needed: int):
@@ -200,6 +185,16 @@ def require_credits(credits_needed: int):
user.credits -= credits_needed
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
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.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."""
try:
# Get sound from database
@@ -86,8 +86,6 @@ class VLCService:
SoundPlayed.create_play_record(
user_id=user_id,
sound_id=sound_id,
ip_address=ip_address,
user_agent=user_agent,
commit=True,
)
except Exception as e:

View File

@@ -1,15 +1,15 @@
from dotenv import load_dotenv
from app import create_app
from app import create_app, socketio
# Load environment variables from .env file
load_dotenv()
def main() -> None:
"""Run the Flask application."""
"""Run the Flask application with SocketIO."""
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__":

View File

@@ -13,6 +13,7 @@ dependencies = [
"flask-cors==6.0.1",
"flask-jwt-extended==4.7.1",
"flask-migrate==4.1.0",
"flask-socketio==5.5.1",
"flask-sqlalchemy==3.1.1",
"pydub==0.25.1",
"python-dotenv==1.1.1",
@@ -21,7 +22,7 @@ dependencies = [
]
[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]
line-length = 80
@@ -29,7 +30,4 @@ line-length = 80
[tool.ruff]
line-length = 80
lint.select = ["ALL"]
lint.ignore = [
"D100", # Missing docstring in public module
"D104", # Missing docstring in public package
]
lint.ignore = ["D100", "D104"]

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 },
]
[[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]]
name = "black"
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 },
]
[[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]]
name = "flask-sqlalchemy"
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 },
]
[[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]]
name = "idna"
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 },
]
[[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]]
name = "requests"
version = "2.32.4"
@@ -543,27 +599,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.12.1"
version = "0.12.2"
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 = [
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/6f/1c/20cdb593783f8f411839ce749ec9ae9e4298c2b2079b40295c3e6e2089e1/ruff-0.12.1-py3-none-win32.whl", hash = "sha256:d973fa626d4c8267848755bd0414211a456e99e125dcab147f24daa9e991a245", size = 10519495 },
{ 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/91/d0/6902c0d017259439d6fd2fd9393cea1cfe30169940118b007d5e0ea7e954/ruff-0.12.1-py3-none-win_arm64.whl", hash = "sha256:78ad09a022c64c13cc6077707f036bab0fac8cd7088772dcd1e5be21c5002efc", size = 10691209 },
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/51/de/8589fa724590faa057e5a6d171e7f2f6cffe3287406ef40e49c682c07d89/ruff-0.12.2-py3-none-win32.whl", hash = "sha256:369ffb69b70cd55b6c3fc453b9492d98aed98062db9fec828cdfd069555f5f1a", size = 10523823 },
{ 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/e2/1f/72d2946e3cc7456bb837e88000eb3437e55f80db339c840c04015a11115d/ruff-0.12.2-py3-none-win_arm64.whl", hash = "sha256:48d6c6bfb4761df68bc05ae630e24f506755e702d4fb08f08460be778c7ccb12", size = 10735334 },
]
[[package]]
@@ -578,6 +634,7 @@ dependencies = [
{ name = "flask-cors" },
{ name = "flask-jwt-extended" },
{ name = "flask-migrate" },
{ name = "flask-socketio" },
{ name = "flask-sqlalchemy" },
{ name = "pydub" },
{ name = "python-dotenv" },
@@ -601,6 +658,7 @@ requires-dist = [
{ name = "flask-cors", specifier = "==6.0.1" },
{ name = "flask-jwt-extended", specifier = "==4.7.1" },
{ name = "flask-migrate", specifier = "==4.1.0" },
{ name = "flask-socketio", specifier = "==5.5.1" },
{ name = "flask-sqlalchemy", specifier = "==3.1.1" },
{ name = "pydub", specifier = "==0.25.1" },
{ name = "python-dotenv", specifier = "==1.1.1" },
@@ -612,7 +670,19 @@ requires-dist = [
dev = [
{ name = "black", specifier = "==25.1.0" },
{ 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]]
@@ -694,3 +764,15 @@ sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2
wheels = [
{ 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 },
]