Compare commits
4 Commits
c241a72c60
...
c3b8205f83
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3b8205f83 | ||
|
|
97b998fd9e | ||
|
|
7455811860 | ||
|
|
8f17dd730a |
@@ -27,7 +27,8 @@ def create_app():
|
||||
|
||||
# Configure Flask-JWT-Extended
|
||||
app.config["JWT_SECRET_KEY"] = os.environ.get(
|
||||
"JWT_SECRET_KEY", "jwt-secret-key",
|
||||
"JWT_SECRET_KEY",
|
||||
"jwt-secret-key",
|
||||
)
|
||||
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(minutes=15)
|
||||
app.config["JWT_REFRESH_TOKEN_EXPIRES"] = timedelta(days=7)
|
||||
@@ -68,10 +69,13 @@ def create_app():
|
||||
scheduler_service.start()
|
||||
|
||||
# Register blueprints
|
||||
from app.routes import auth, main
|
||||
from app.routes import admin, admin_sounds, auth, main, soundboard
|
||||
|
||||
app.register_blueprint(main.bp, url_prefix="/api")
|
||||
app.register_blueprint(auth.bp, url_prefix="/api/auth")
|
||||
app.register_blueprint(admin.bp, url_prefix="/api/admin")
|
||||
app.register_blueprint(admin_sounds.bp)
|
||||
app.register_blueprint(soundboard.bp)
|
||||
|
||||
# Shutdown scheduler when app is torn down
|
||||
@app.teardown_appcontext
|
||||
|
||||
@@ -13,6 +13,6 @@ def init_db(app):
|
||||
migrate.init_app(app, db)
|
||||
|
||||
# Import models here to ensure they are registered with SQLAlchemy
|
||||
from app.models import user, user_oauth # noqa: F401
|
||||
from app.models import user, user_oauth, sound_played # noqa: F401
|
||||
|
||||
return db
|
||||
|
||||
@@ -68,7 +68,8 @@ def migrate_users_to_plans():
|
||||
# 0 credits means they spent them, NULL means they never got assigned
|
||||
try:
|
||||
users_without_credits = User.query.filter(
|
||||
User.plan_id.isnot(None), User.credits.is_(None),
|
||||
User.plan_id.isnot(None),
|
||||
User.credits.is_(None),
|
||||
).all()
|
||||
except Exception:
|
||||
# Credits column doesn't exist yet, will be handled by create_all
|
||||
|
||||
238
app/models/sound_played.py
Normal file
238
app/models/sound_played.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""Sound played tracking model."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from app.database import db
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
|
||||
class SoundPlayed(db.Model):
|
||||
"""Model to track when users play sounds."""
|
||||
|
||||
__tablename__ = "sound_played"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
|
||||
# Foreign keys
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
sound_id: Mapped[int] = mapped_column(
|
||||
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
|
||||
)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship("User", backref="sounds_played")
|
||||
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}>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert sound played record to dictionary."""
|
||||
return {
|
||||
"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": {
|
||||
"id": self.user.id,
|
||||
"name": self.user.name,
|
||||
"email": self.user.email,
|
||||
} 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,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def create_play_record(
|
||||
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)
|
||||
if commit:
|
||||
db.session.commit()
|
||||
return play_record
|
||||
|
||||
@classmethod
|
||||
def get_user_plays(
|
||||
cls, user_id: int, limit: int = 50, offset: int = 0
|
||||
) -> List["SoundPlayed"]:
|
||||
"""Get recent plays for a specific user."""
|
||||
return (
|
||||
cls.query.filter_by(user_id=user_id)
|
||||
.order_by(cls.played_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_sound_plays(
|
||||
cls, sound_id: int, limit: int = 50, offset: int = 0
|
||||
) -> List["SoundPlayed"]:
|
||||
"""Get recent plays for a specific sound."""
|
||||
return (
|
||||
cls.query.filter_by(sound_id=sound_id)
|
||||
.order_by(cls.played_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_recent_plays(
|
||||
cls, limit: int = 100, offset: int = 0
|
||||
) -> List["SoundPlayed"]:
|
||||
"""Get recent plays across all users and sounds."""
|
||||
return (
|
||||
cls.query.order_by(cls.played_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_user_play_count(cls, user_id: int) -> int:
|
||||
"""Get total play count for a user."""
|
||||
return cls.query.filter_by(user_id=user_id).count()
|
||||
|
||||
@classmethod
|
||||
def get_sound_play_count(cls, sound_id: int) -> int:
|
||||
"""Get total play count for a sound."""
|
||||
return cls.query.filter_by(sound_id=sound_id).count()
|
||||
|
||||
@classmethod
|
||||
def get_popular_sounds(
|
||||
cls, limit: int = 10, days: int | None = None
|
||||
) -> List[dict]:
|
||||
"""Get most popular sounds with play counts."""
|
||||
from sqlalchemy import func, text
|
||||
|
||||
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:
|
||||
from app.models.sound import Sound
|
||||
|
||||
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 sqlalchemy import func
|
||||
|
||||
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:
|
||||
from app.models.sound import Sound
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -29,12 +29,15 @@ class User(db.Model):
|
||||
|
||||
# Password authentication (optional - users can use OAuth instead)
|
||||
password_hash: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True,
|
||||
String(255),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Role-based access control
|
||||
role: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, default="user",
|
||||
String(50),
|
||||
nullable=False,
|
||||
default="user",
|
||||
)
|
||||
|
||||
# User status
|
||||
@@ -42,7 +45,9 @@ class User(db.Model):
|
||||
|
||||
# Plan relationship
|
||||
plan_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("plans.id"), nullable=False,
|
||||
Integer,
|
||||
ForeignKey("plans.id"),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# User credits (populated from plan credits on creation)
|
||||
@@ -51,12 +56,15 @@ class User(db.Model):
|
||||
# API token for programmatic access
|
||||
api_token: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
api_token_expires_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, nullable=True,
|
||||
DateTime,
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, nullable=False,
|
||||
DateTime,
|
||||
default=datetime.utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
@@ -67,7 +75,9 @@ class User(db.Model):
|
||||
|
||||
# Relationships
|
||||
oauth_providers: Mapped[list["UserOAuth"]] = relationship(
|
||||
"UserOAuth", back_populates="user", cascade="all, delete-orphan",
|
||||
"UserOAuth",
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
plan: Mapped["Plan"] = relationship("Plan", back_populates="users")
|
||||
|
||||
@@ -198,7 +208,8 @@ class User(db.Model):
|
||||
|
||||
# First, try to find existing OAuth provider
|
||||
oauth_provider = UserOAuth.find_by_provider_and_id(
|
||||
provider, provider_id,
|
||||
provider,
|
||||
provider_id,
|
||||
)
|
||||
|
||||
if oauth_provider:
|
||||
@@ -256,7 +267,10 @@ class User(db.Model):
|
||||
|
||||
@classmethod
|
||||
def create_with_password(
|
||||
cls, email: str, password: str, name: str,
|
||||
cls,
|
||||
email: str,
|
||||
password: str,
|
||||
name: str,
|
||||
) -> "User":
|
||||
"""Create new user with email and password."""
|
||||
from app.models.plan import Plan
|
||||
@@ -293,7 +307,9 @@ class User(db.Model):
|
||||
|
||||
@classmethod
|
||||
def authenticate_with_password(
|
||||
cls, email: str, password: str,
|
||||
cls,
|
||||
email: str,
|
||||
password: str,
|
||||
) -> Optional["User"]:
|
||||
"""Authenticate user with email and password."""
|
||||
user = cls.find_by_email(email)
|
||||
|
||||
@@ -33,7 +33,9 @@ class UserOAuth(db.Model):
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, nullable=False,
|
||||
DateTime,
|
||||
default=datetime.utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
@@ -45,13 +47,16 @@ class UserOAuth(db.Model):
|
||||
# Unique constraint on provider + provider_id combination
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint(
|
||||
"provider", "provider_id", name="unique_provider_user",
|
||||
"provider",
|
||||
"provider_id",
|
||||
name="unique_provider_user",
|
||||
),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship(
|
||||
"User", back_populates="oauth_providers",
|
||||
"User",
|
||||
back_populates="oauth_providers",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
@@ -73,11 +78,14 @@ class UserOAuth(db.Model):
|
||||
|
||||
@classmethod
|
||||
def find_by_provider_and_id(
|
||||
cls, provider: str, provider_id: str,
|
||||
cls,
|
||||
provider: str,
|
||||
provider_id: str,
|
||||
) -> Optional["UserOAuth"]:
|
||||
"""Find OAuth provider by provider name and provider ID."""
|
||||
return cls.query.filter_by(
|
||||
provider=provider, provider_id=provider_id,
|
||||
provider=provider,
|
||||
provider_id=provider_id,
|
||||
).first()
|
||||
|
||||
@classmethod
|
||||
|
||||
95
app/routes/admin.py
Normal file
95
app/routes/admin.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Admin routes for the application."""
|
||||
|
||||
from flask import Blueprint, request
|
||||
|
||||
from app.services.decorators import (
|
||||
get_current_user,
|
||||
require_auth,
|
||||
require_role,
|
||||
)
|
||||
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.route("/")
|
||||
@require_auth
|
||||
@require_role("admin")
|
||||
def admin_only() -> dict[str, str]:
|
||||
"""Admin-only endpoint to demonstrate role-based access."""
|
||||
user = get_current_user()
|
||||
return {
|
||||
"message": f"Hello admin {user['name']}, you have admin access!",
|
||||
"user": user,
|
||||
"admin_info": "This endpoint is only accessible to admin users",
|
||||
}
|
||||
|
||||
|
||||
@bp.route("/scheduler/status")
|
||||
@require_auth
|
||||
@require_role("admin")
|
||||
def scheduler_status() -> dict:
|
||||
"""Get scheduler status (admin only)."""
|
||||
return scheduler_service.get_scheduler_status()
|
||||
|
||||
|
||||
@bp.route("/credits/refill", methods=["POST"])
|
||||
@require_auth
|
||||
@require_role("admin")
|
||||
def manual_credit_refill() -> dict:
|
||||
"""Manually trigger credit refill for all users (admin only)."""
|
||||
return scheduler_service.trigger_credit_refill_now()
|
||||
|
||||
|
||||
@bp.route("/sounds/scan", methods=["POST"])
|
||||
@require_auth
|
||||
@require_role("admin")
|
||||
def manual_sound_scan() -> dict:
|
||||
"""Manually trigger sound directory scan (admin only)."""
|
||||
return scheduler_service.trigger_sound_scan_now()
|
||||
|
||||
|
||||
@bp.route("/sounds/stats")
|
||||
@require_auth
|
||||
@require_role("admin")
|
||||
def sound_statistics() -> dict:
|
||||
"""Get sound database statistics (admin only)."""
|
||||
return SoundScannerService.get_scan_statistics()
|
||||
|
||||
|
||||
@bp.route("/sounds/normalize/<int:sound_id>", methods=["POST"])
|
||||
@require_auth
|
||||
@require_role("admin")
|
||||
def normalize_sound(sound_id: int) -> dict:
|
||||
"""Normalize a specific sound file (admin only)."""
|
||||
overwrite = request.args.get("overwrite", "false").lower() == "true"
|
||||
return SoundNormalizerService.normalize_sound(sound_id, overwrite)
|
||||
|
||||
|
||||
@bp.route("/sounds/normalize-all", methods=["POST"])
|
||||
@require_auth
|
||||
@require_role("admin")
|
||||
def normalize_all_sounds() -> dict:
|
||||
"""Normalize all soundboard files (admin only)."""
|
||||
overwrite = request.args.get("overwrite", "false").lower() == "true"
|
||||
limit_str = request.args.get("limit")
|
||||
limit = int(limit_str) if limit_str else None
|
||||
return SoundNormalizerService.normalize_all_sounds(overwrite, limit)
|
||||
|
||||
|
||||
@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()
|
||||
236
app/routes/admin_sounds.py
Normal file
236
app/routes/admin_sounds.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""Admin sound management routes."""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from app.models.sound import Sound
|
||||
from app.services.sound_scanner_service import SoundScannerService
|
||||
from app.services.sound_normalizer_service import SoundNormalizerService
|
||||
from app.services.decorators import require_admin
|
||||
|
||||
bp = Blueprint("admin_sounds", __name__, url_prefix="/api/admin/sounds")
|
||||
|
||||
|
||||
@bp.route("/scan", methods=["POST"])
|
||||
@require_admin
|
||||
def scan_sounds():
|
||||
"""Manually trigger sound scanning."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
directory = data.get("directory")
|
||||
|
||||
result = SoundScannerService.scan_soundboard_directory(directory)
|
||||
|
||||
if result["success"]:
|
||||
return jsonify(result), 200
|
||||
else:
|
||||
return jsonify(result), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route("/scan/status", methods=["GET"])
|
||||
@require_admin
|
||||
def get_scan_status():
|
||||
"""Get current scan statistics and status."""
|
||||
try:
|
||||
stats = SoundScannerService.get_scan_statistics()
|
||||
return jsonify(stats), 200
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route("/normalize", methods=["POST"])
|
||||
@require_admin
|
||||
def normalize_sounds():
|
||||
"""Normalize sounds (all or specific)."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
sound_id = data.get("sound_id")
|
||||
overwrite = data.get("overwrite", False)
|
||||
two_pass = data.get("two_pass", True)
|
||||
limit = data.get("limit")
|
||||
|
||||
if sound_id:
|
||||
# Normalize specific sound
|
||||
result = SoundNormalizerService.normalize_sound(
|
||||
sound_id, overwrite, two_pass
|
||||
)
|
||||
else:
|
||||
# Normalize all sounds
|
||||
result = SoundNormalizerService.normalize_all_sounds(
|
||||
overwrite, limit, two_pass
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
return jsonify(result), 200
|
||||
else:
|
||||
return jsonify(result), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route("/normalize/status", methods=["GET"])
|
||||
@require_admin
|
||||
def get_normalization_status():
|
||||
"""Get normalization statistics and status."""
|
||||
try:
|
||||
status = SoundNormalizerService.get_normalization_status()
|
||||
return jsonify(status), 200
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route("/ffmpeg/check", methods=["GET"])
|
||||
@require_admin
|
||||
def check_ffmpeg():
|
||||
"""Check ffmpeg availability and capabilities."""
|
||||
try:
|
||||
ffmpeg_status = SoundNormalizerService.check_ffmpeg_availability()
|
||||
return jsonify(ffmpeg_status), 200
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route("/list", methods=["GET"])
|
||||
@require_admin
|
||||
def list_sounds():
|
||||
"""Get detailed list of all sounds with normalization status."""
|
||||
try:
|
||||
sound_type = request.args.get("type", "SDB")
|
||||
page = int(request.args.get("page", 1))
|
||||
per_page = int(request.args.get("per_page", 50))
|
||||
|
||||
# Validate sound type
|
||||
if sound_type not in ["SDB", "SAY", "STR"]:
|
||||
return jsonify({"error": "Invalid sound type"}), 400
|
||||
|
||||
# Get paginated results
|
||||
sounds_query = Sound.query.filter_by(type=sound_type)
|
||||
total = sounds_query.count()
|
||||
|
||||
sounds = (
|
||||
sounds_query.offset((page - 1) * per_page).limit(per_page).all()
|
||||
)
|
||||
|
||||
# Convert to detailed dict format
|
||||
sounds_data = []
|
||||
for sound in sounds:
|
||||
sound_dict = sound.to_dict()
|
||||
# Add file existence status
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
original_path = os.path.join(
|
||||
"sounds", sound.type.lower(), sound.filename
|
||||
)
|
||||
sound_dict["original_exists"] = os.path.exists(original_path)
|
||||
|
||||
if sound.is_normalized and sound.normalized_filename:
|
||||
normalized_path = os.path.join(
|
||||
"sounds",
|
||||
"normalized",
|
||||
sound.type.lower(),
|
||||
sound.normalized_filename,
|
||||
)
|
||||
sound_dict["normalized_exists"] = os.path.exists(
|
||||
normalized_path
|
||||
)
|
||||
else:
|
||||
sound_dict["normalized_exists"] = False
|
||||
|
||||
sounds_data.append(sound_dict)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"sounds": sounds_data,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total": total,
|
||||
"pages": (total + per_page - 1) // per_page,
|
||||
},
|
||||
"type": sound_type,
|
||||
}
|
||||
), 200
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route("/<int:sound_id>", methods=["DELETE"])
|
||||
@require_admin
|
||||
def delete_sound(sound_id: int):
|
||||
"""Delete a sound and its files."""
|
||||
try:
|
||||
sound = Sound.query.get(sound_id)
|
||||
if not sound:
|
||||
return jsonify({"error": "Sound not found"}), 404
|
||||
|
||||
if not sound.is_deletable:
|
||||
return jsonify({"error": "Sound is not deletable"}), 403
|
||||
|
||||
# Delete normalized file if exists
|
||||
if sound.is_normalized and sound.normalized_filename:
|
||||
import os
|
||||
|
||||
normalized_path = os.path.join(
|
||||
"sounds",
|
||||
"normalized",
|
||||
sound.type.lower(),
|
||||
sound.normalized_filename,
|
||||
)
|
||||
if os.path.exists(normalized_path):
|
||||
try:
|
||||
os.remove(normalized_path)
|
||||
except Exception as e:
|
||||
return jsonify(
|
||||
{"error": f"Failed to delete normalized file: {e}"}
|
||||
), 500
|
||||
|
||||
# Delete original file
|
||||
import os
|
||||
|
||||
original_path = os.path.join(
|
||||
"sounds", sound.type.lower(), sound.filename
|
||||
)
|
||||
if os.path.exists(original_path):
|
||||
try:
|
||||
os.remove(original_path)
|
||||
except Exception as e:
|
||||
return jsonify(
|
||||
{"error": f"Failed to delete original file: {e}"}
|
||||
), 500
|
||||
|
||||
# Delete database record
|
||||
from app.database import db
|
||||
|
||||
db.session.delete(sound)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"message": f"Sound '{sound.name}' deleted successfully",
|
||||
"sound_id": sound_id,
|
||||
}
|
||||
), 200
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route("/<int:sound_id>/normalize", methods=["POST"])
|
||||
@require_admin
|
||||
def normalize_single_sound(sound_id: int):
|
||||
"""Normalize a specific sound."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
overwrite = data.get("overwrite", False)
|
||||
two_pass = data.get("two_pass", True)
|
||||
|
||||
result = SoundNormalizerService.normalize_sound(
|
||||
sound_id, overwrite, two_pass
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
return jsonify(result), 200
|
||||
else:
|
||||
return jsonify(result), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
@@ -128,7 +128,9 @@ def refresh():
|
||||
def link_provider(provider):
|
||||
"""Link a new OAuth provider to current user account."""
|
||||
redirect_uri = url_for(
|
||||
"auth.link_callback", provider=provider, _external=True,
|
||||
"auth.link_callback",
|
||||
provider=provider,
|
||||
_external=True,
|
||||
)
|
||||
return auth_service.redirect_to_login(provider, redirect_uri)
|
||||
|
||||
@@ -174,7 +176,8 @@ def link_callback(provider):
|
||||
from app.models.user_oauth import UserOAuth
|
||||
|
||||
existing_provider = UserOAuth.find_by_provider_and_id(
|
||||
provider, provider_data["id"],
|
||||
provider,
|
||||
provider_data["id"],
|
||||
)
|
||||
|
||||
if existing_provider and existing_provider.user_id != user.id:
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
"""Main routes for the application."""
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask import Blueprint
|
||||
|
||||
from app.services.decorators import (
|
||||
get_current_user,
|
||||
require_auth,
|
||||
require_credits,
|
||||
require_role,
|
||||
)
|
||||
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("main", __name__)
|
||||
|
||||
@@ -43,19 +39,6 @@ def api_protected() -> dict[str, str]:
|
||||
}
|
||||
|
||||
|
||||
@bp.route("/admin")
|
||||
@require_auth
|
||||
@require_role("admin")
|
||||
def admin_only() -> dict[str, str]:
|
||||
"""Admin-only endpoint to demonstrate role-based access."""
|
||||
user = get_current_user()
|
||||
return {
|
||||
"message": f"Hello admin {user['name']}, you have admin access!",
|
||||
"user": user,
|
||||
"admin_info": "This endpoint is only accessible to admin users",
|
||||
}
|
||||
|
||||
|
||||
@bp.route("/health")
|
||||
def health() -> dict[str, str]:
|
||||
"""Health check endpoint."""
|
||||
@@ -87,71 +70,3 @@ def expensive_operation() -> dict[str, str]:
|
||||
"user": user["email"],
|
||||
"operation_cost": 10,
|
||||
}
|
||||
|
||||
|
||||
@bp.route("/admin/scheduler/status")
|
||||
@require_auth
|
||||
@require_role("admin")
|
||||
def scheduler_status() -> dict:
|
||||
"""Get scheduler status (admin only)."""
|
||||
return scheduler_service.get_scheduler_status()
|
||||
|
||||
|
||||
@bp.route("/admin/credits/refill", methods=["POST"])
|
||||
@require_auth
|
||||
@require_role("admin")
|
||||
def manual_credit_refill() -> dict:
|
||||
"""Manually trigger credit refill for all users (admin only)."""
|
||||
return scheduler_service.trigger_credit_refill_now()
|
||||
|
||||
|
||||
@bp.route("/admin/sounds/scan", methods=["POST"])
|
||||
@require_auth
|
||||
@require_role("admin")
|
||||
def manual_sound_scan() -> dict:
|
||||
"""Manually trigger sound directory scan (admin only)."""
|
||||
return scheduler_service.trigger_sound_scan_now()
|
||||
|
||||
|
||||
@bp.route("/admin/sounds/stats")
|
||||
@require_auth
|
||||
@require_role("admin")
|
||||
def sound_statistics() -> dict:
|
||||
"""Get sound database statistics (admin only)."""
|
||||
return SoundScannerService.get_scan_statistics()
|
||||
|
||||
|
||||
@bp.route("/admin/sounds/normalize/<int:sound_id>", methods=["POST"])
|
||||
@require_auth
|
||||
@require_role("admin")
|
||||
def normalize_sound(sound_id: int) -> dict:
|
||||
"""Normalize a specific sound file (admin only)."""
|
||||
overwrite = request.args.get("overwrite", "false").lower() == "true"
|
||||
return SoundNormalizerService.normalize_sound(sound_id, overwrite)
|
||||
|
||||
|
||||
@bp.route("/admin/sounds/normalize-all", methods=["POST"])
|
||||
@require_auth
|
||||
@require_role("admin")
|
||||
def normalize_all_sounds() -> dict:
|
||||
"""Normalize all soundboard files (admin only)."""
|
||||
overwrite = request.args.get("overwrite", "false").lower() == "true"
|
||||
limit_str = request.args.get("limit")
|
||||
limit = int(limit_str) if limit_str else None
|
||||
return SoundNormalizerService.normalize_all_sounds(overwrite, limit)
|
||||
|
||||
|
||||
@bp.route("/admin/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("/admin/sounds/ffmpeg-check")
|
||||
@require_auth
|
||||
@require_role("admin")
|
||||
def ffmpeg_check() -> dict:
|
||||
"""Check ffmpeg availability and capabilities (admin only)."""
|
||||
return SoundNormalizerService.check_ffmpeg_availability()
|
||||
|
||||
218
app/routes/soundboard.py
Normal file
218
app/routes/soundboard.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""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.vlc_service import vlc_service
|
||||
from app.services.decorators import require_auth, get_current_user
|
||||
|
||||
bp = Blueprint("soundboard", __name__, url_prefix="/api/soundboard")
|
||||
|
||||
|
||||
@bp.route("/sounds", methods=["GET"])
|
||||
@require_auth
|
||||
def get_sounds():
|
||||
"""Get all soundboard sounds."""
|
||||
try:
|
||||
# Get filter parameters
|
||||
sound_type = request.args.get("type", "SDB")
|
||||
|
||||
# Validate sound type
|
||||
if sound_type not in [t.value for t in SoundType]:
|
||||
return jsonify({"error": "Invalid sound type"}), 400
|
||||
|
||||
# Get sounds from database
|
||||
sounds = Sound.find_by_type(sound_type)
|
||||
|
||||
# Convert to dict format
|
||||
sounds_data = [sound.to_dict() for sound in sounds]
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"sounds": sounds_data,
|
||||
"total": len(sounds_data),
|
||||
"type": sound_type,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route("/sounds/<int:sound_id>/play", methods=["POST"])
|
||||
@require_auth
|
||||
def play_sound(sound_id: int):
|
||||
"""Play a specific sound."""
|
||||
try:
|
||||
# Get current user for tracking
|
||||
user = get_current_user()
|
||||
user_id = int(user["id"]) if user else None
|
||||
|
||||
# Get client information
|
||||
ip_address = request.remote_addr
|
||||
user_agent = request.headers.get("User-Agent")
|
||||
|
||||
success = vlc_service.play_sound(
|
||||
sound_id=sound_id,
|
||||
user_id=user_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
|
||||
if success:
|
||||
return jsonify({"message": "Sound playing", "sound_id": sound_id})
|
||||
else:
|
||||
return jsonify(
|
||||
{"error": "Sound not found or cannot be played"}
|
||||
), 404
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route("/stop-all", methods=["POST"])
|
||||
@require_auth
|
||||
def stop_all_sounds():
|
||||
"""Stop all currently playing sounds."""
|
||||
try:
|
||||
# Try normal stop first
|
||||
vlc_service.stop_all()
|
||||
|
||||
# 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({
|
||||
"message": f"Force stopped {stopped_count} sounds",
|
||||
"forced": True
|
||||
})
|
||||
|
||||
return jsonify({"message": "All sounds stopped"})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route("/force-stop", methods=["POST"])
|
||||
@require_auth
|
||||
def force_stop_all_sounds():
|
||||
"""Force stop all sounds with aggressive cleanup."""
|
||||
try:
|
||||
stopped_count = vlc_service.force_stop_all()
|
||||
return jsonify({
|
||||
"message": f"Force stopped {stopped_count} sound instances",
|
||||
"stopped_count": stopped_count
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route("/status", methods=["GET"])
|
||||
@require_auth
|
||||
def get_status():
|
||||
"""Get current playback status."""
|
||||
try:
|
||||
playing_count = vlc_service.get_playing_count()
|
||||
|
||||
# Get detailed process information
|
||||
with vlc_service.lock:
|
||||
processes = []
|
||||
for process_id, process in vlc_service.processes.items():
|
||||
processes.append({
|
||||
"id": process_id,
|
||||
"pid": process.pid,
|
||||
"running": process.poll() is None,
|
||||
})
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"playing_count": playing_count,
|
||||
"is_playing": playing_count > 0,
|
||||
"processes": processes,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route("/history", methods=["GET"])
|
||||
@require_auth
|
||||
def get_play_history():
|
||||
"""Get recent play history."""
|
||||
try:
|
||||
page = int(request.args.get("page", 1))
|
||||
per_page = min(int(request.args.get("per_page", 50)), 100)
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
recent_plays = SoundPlayed.get_recent_plays(limit=per_page, offset=offset)
|
||||
|
||||
return jsonify({
|
||||
"plays": [play.to_dict() for play in recent_plays],
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route("/my-history", methods=["GET"])
|
||||
@require_auth
|
||||
def get_my_play_history():
|
||||
"""Get current user's play history."""
|
||||
try:
|
||||
user = get_current_user()
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
user_id = int(user["id"])
|
||||
page = int(request.args.get("page", 1))
|
||||
per_page = min(int(request.args.get("per_page", 50)), 100)
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
user_plays = SoundPlayed.get_user_plays(user_id=user_id, limit=per_page, offset=offset)
|
||||
|
||||
return jsonify({
|
||||
"plays": [play.to_dict() for play in user_plays],
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"user_id": user_id,
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route("/my-stats", methods=["GET"])
|
||||
@require_auth
|
||||
def get_my_stats():
|
||||
"""Get current user's play statistics."""
|
||||
try:
|
||||
user = get_current_user()
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
user_id = int(user["id"])
|
||||
stats = SoundPlayed.get_user_stats(user_id)
|
||||
|
||||
return jsonify(stats)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route("/popular", methods=["GET"])
|
||||
@require_auth
|
||||
def get_popular_sounds():
|
||||
"""Get most popular sounds."""
|
||||
try:
|
||||
limit = min(int(request.args.get("limit", 10)), 50)
|
||||
days = request.args.get("days")
|
||||
days = int(days) if days and days.isdigit() else None
|
||||
|
||||
popular_sounds = SoundPlayed.get_popular_sounds(limit=limit, days=days)
|
||||
|
||||
return jsonify({
|
||||
"popular_sounds": popular_sounds,
|
||||
"limit": limit,
|
||||
"days": days,
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
@@ -149,7 +149,10 @@ class AuthService:
|
||||
return None
|
||||
|
||||
def register_with_password(
|
||||
self, email: str, password: str, name: str,
|
||||
self,
|
||||
email: str,
|
||||
password: str,
|
||||
name: str,
|
||||
) -> Any:
|
||||
"""Register new user with email and password."""
|
||||
try:
|
||||
|
||||
@@ -44,7 +44,9 @@ class CreditService:
|
||||
|
||||
for user in users:
|
||||
if not user.plan:
|
||||
logger.warning(f"User {user.email} has no plan assigned, skipping")
|
||||
logger.warning(
|
||||
f"User {user.email} has no plan assigned, skipping"
|
||||
)
|
||||
continue
|
||||
|
||||
# Calculate new credit amount, capped at plan max
|
||||
@@ -53,7 +55,9 @@ class CreditService:
|
||||
max_credits = user.plan.max_credits
|
||||
|
||||
# Add daily credits but don't exceed maximum
|
||||
new_credits = min(current_credits + plan_daily_credits, max_credits)
|
||||
new_credits = min(
|
||||
current_credits + plan_daily_credits, max_credits
|
||||
)
|
||||
credits_added = new_credits - current_credits
|
||||
|
||||
if credits_added > 0:
|
||||
|
||||
@@ -146,6 +146,26 @@ def require_role(required_role: str):
|
||||
return decorator
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def require_credits(credits_needed: int):
|
||||
"""Decorator to require and deduct credits for routes."""
|
||||
|
||||
|
||||
@@ -49,7 +49,9 @@ class OAuthProvider(ABC):
|
||||
return client.authorize_redirect(redirect_uri).location
|
||||
|
||||
def exchange_code_for_token(
|
||||
self, code: str = None, redirect_uri: str = None,
|
||||
self,
|
||||
code: str = None,
|
||||
redirect_uri: str = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Exchange authorization code for access token."""
|
||||
client = self.get_client()
|
||||
|
||||
@@ -22,7 +22,9 @@ class OAuthProviderRegistry:
|
||||
google_client_secret = os.getenv("GOOGLE_CLIENT_SECRET")
|
||||
if google_client_id and google_client_secret:
|
||||
self._providers["google"] = GoogleOAuthProvider(
|
||||
self.oauth, google_client_id, google_client_secret,
|
||||
self.oauth,
|
||||
google_client_id,
|
||||
google_client_secret,
|
||||
)
|
||||
|
||||
# GitHub OAuth
|
||||
@@ -30,7 +32,9 @@ class OAuthProviderRegistry:
|
||||
github_client_secret = os.getenv("GITHUB_CLIENT_SECRET")
|
||||
if github_client_id and github_client_secret:
|
||||
self._providers["github"] = GitHubOAuthProvider(
|
||||
self.oauth, github_client_id, github_client_secret,
|
||||
self.oauth,
|
||||
github_client_id,
|
||||
github_client_secret,
|
||||
)
|
||||
|
||||
def get_provider(self, name: str) -> OAuthProvider | None:
|
||||
|
||||
@@ -97,7 +97,9 @@ class SchedulerService:
|
||||
f"{result['credits_added']} credits added",
|
||||
)
|
||||
else:
|
||||
logger.error(f"Daily credit refill failed: {result['message']}")
|
||||
logger.error(
|
||||
f"Daily credit refill failed: {result['message']}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error during daily credit refill: {e}")
|
||||
@@ -119,7 +121,9 @@ class SchedulerService:
|
||||
else:
|
||||
logger.debug("Sound scan completed: no new files found")
|
||||
else:
|
||||
logger.error(f"Sound scan failed: {result.get('error', 'Unknown error')}")
|
||||
logger.error(
|
||||
f"Sound scan failed: {result.get('error', 'Unknown error')}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error during sound scan: {e}")
|
||||
@@ -148,7 +152,8 @@ class SchedulerService:
|
||||
"id": job.id,
|
||||
"name": job.name,
|
||||
"next_run": job.next_run_time.isoformat()
|
||||
if job.next_run_time else None,
|
||||
if job.next_run_time
|
||||
else None,
|
||||
"trigger": str(job.trigger),
|
||||
}
|
||||
for job in self.scheduler.get_jobs()
|
||||
|
||||
@@ -38,7 +38,9 @@ class SoundNormalizerService:
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def normalize_sound(sound_id: int, overwrite: bool = False, two_pass: bool = True) -> dict:
|
||||
def normalize_sound(
|
||||
sound_id: int, overwrite: bool = False, two_pass: bool = True
|
||||
) -> dict:
|
||||
"""Normalize a specific sound file using ffmpeg loudnorm.
|
||||
|
||||
Args:
|
||||
@@ -58,7 +60,9 @@ class SoundNormalizerService:
|
||||
"error": f"Sound with ID {sound_id} not found",
|
||||
}
|
||||
|
||||
source_path = Path(SoundNormalizerService.SOUNDS_DIR) / sound.filename
|
||||
source_path = (
|
||||
Path(SoundNormalizerService.SOUNDS_DIR) / sound.filename
|
||||
)
|
||||
if not source_path.exists():
|
||||
return {
|
||||
"success": False,
|
||||
@@ -68,7 +72,10 @@ class SoundNormalizerService:
|
||||
# Always output as WAV regardless of input format
|
||||
filename_without_ext = Path(sound.filename).stem
|
||||
normalized_filename = f"{filename_without_ext}.wav"
|
||||
normalized_path = Path(SoundNormalizerService.NORMALIZED_DIR) / normalized_filename
|
||||
normalized_path = (
|
||||
Path(SoundNormalizerService.NORMALIZED_DIR)
|
||||
/ normalized_filename
|
||||
)
|
||||
|
||||
normalized_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -84,11 +91,15 @@ class SoundNormalizerService:
|
||||
|
||||
if two_pass:
|
||||
result = SoundNormalizerService._normalize_with_ffmpeg(
|
||||
str(source_path), str(normalized_path),
|
||||
str(source_path),
|
||||
str(normalized_path),
|
||||
)
|
||||
else:
|
||||
result = SoundNormalizerService._normalize_with_ffmpeg_single_pass(
|
||||
str(source_path), str(normalized_path),
|
||||
result = (
|
||||
SoundNormalizerService._normalize_with_ffmpeg_single_pass(
|
||||
str(source_path),
|
||||
str(normalized_path),
|
||||
)
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
@@ -131,7 +142,9 @@ class SoundNormalizerService:
|
||||
|
||||
@staticmethod
|
||||
def normalize_all_sounds(
|
||||
overwrite: bool = False, limit: int = None, two_pass: bool = True,
|
||||
overwrite: bool = False,
|
||||
limit: int = None,
|
||||
two_pass: bool = True,
|
||||
) -> dict:
|
||||
"""Normalize all soundboard files.
|
||||
|
||||
@@ -171,7 +184,9 @@ class SoundNormalizerService:
|
||||
|
||||
for sound in sounds:
|
||||
result = SoundNormalizerService.normalize_sound(
|
||||
sound.id, overwrite, two_pass,
|
||||
sound.id,
|
||||
overwrite,
|
||||
two_pass,
|
||||
)
|
||||
processed += 1
|
||||
|
||||
@@ -281,7 +296,9 @@ class SoundNormalizerService:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_with_ffmpeg_single_pass(source_path: str, output_path: str) -> dict:
|
||||
def _normalize_with_ffmpeg_single_pass(
|
||||
source_path: str, output_path: str
|
||||
) -> dict:
|
||||
"""Run ffmpeg loudnorm on a single file using single-pass normalization.
|
||||
|
||||
This is the legacy single-pass method for backward compatibility.
|
||||
@@ -319,7 +336,9 @@ class SoundNormalizerService:
|
||||
|
||||
# Run the ffmpeg process
|
||||
out, err = ffmpeg.run(
|
||||
output_stream, capture_stdout=True, capture_stderr=True,
|
||||
output_stream,
|
||||
capture_stdout=True,
|
||||
capture_stderr=True,
|
||||
)
|
||||
|
||||
# Parse loudnorm statistics from stderr
|
||||
@@ -370,26 +389,27 @@ class SoundNormalizerService:
|
||||
|
||||
# Output to null device for analysis
|
||||
output_stream = ffmpeg.output(
|
||||
input_stream,
|
||||
"/dev/null",
|
||||
af=loudnorm_filter,
|
||||
f="null"
|
||||
input_stream, "/dev/null", af=loudnorm_filter, f="null"
|
||||
)
|
||||
|
||||
# Run the first pass
|
||||
out, err = ffmpeg.run(
|
||||
output_stream, capture_stdout=True, capture_stderr=True,
|
||||
output_stream,
|
||||
capture_stdout=True,
|
||||
capture_stderr=True,
|
||||
)
|
||||
|
||||
stderr_text = err.decode() if err else ""
|
||||
|
||||
# Parse measured parameters from JSON output
|
||||
measured_params = SoundNormalizerService._parse_measured_params(stderr_text)
|
||||
measured_params = SoundNormalizerService._parse_measured_params(
|
||||
stderr_text
|
||||
)
|
||||
|
||||
if not measured_params:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Failed to parse measured parameters from first pass"
|
||||
"error": "Failed to parse measured parameters from first pass",
|
||||
}
|
||||
|
||||
# Parse basic stats
|
||||
@@ -398,7 +418,7 @@ class SoundNormalizerService:
|
||||
return {
|
||||
"success": True,
|
||||
"measured_params": measured_params,
|
||||
"stats": stats
|
||||
"stats": stats,
|
||||
}
|
||||
|
||||
except ffmpeg.Error as e:
|
||||
@@ -410,7 +430,12 @@ class SoundNormalizerService:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
@staticmethod
|
||||
def _run_second_pass(source_path: str, output_path: str, target_params: dict, measured_params: dict) -> dict:
|
||||
def _run_second_pass(
|
||||
source_path: str,
|
||||
output_path: str,
|
||||
target_params: dict,
|
||||
measured_params: dict,
|
||||
) -> dict:
|
||||
"""Run second pass of loudnorm using measured parameters.
|
||||
|
||||
Args:
|
||||
@@ -452,7 +477,9 @@ class SoundNormalizerService:
|
||||
|
||||
# Run the second pass
|
||||
out, err = ffmpeg.run(
|
||||
output_stream, capture_stdout=True, capture_stderr=True,
|
||||
output_stream,
|
||||
capture_stdout=True,
|
||||
capture_stderr=True,
|
||||
)
|
||||
|
||||
stderr_text = err.decode() if err else ""
|
||||
@@ -460,10 +487,7 @@ class SoundNormalizerService:
|
||||
# Parse final statistics
|
||||
stats = SoundNormalizerService._parse_loudnorm_stats(stderr_text)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"stats": stats
|
||||
}
|
||||
return {"success": True, "stats": stats}
|
||||
|
||||
except ffmpeg.Error as e:
|
||||
error_msg = f"Second pass FFmpeg error: {e.stderr.decode() if e.stderr else str(e)}"
|
||||
@@ -485,7 +509,9 @@ class SoundNormalizerService:
|
||||
"""
|
||||
try:
|
||||
# Find JSON block in stderr output
|
||||
json_match = re.search(r'\{[^}]*"input_i"[^}]*\}', stderr_output, re.DOTALL)
|
||||
json_match = re.search(
|
||||
r'\{[^}]*"input_i"[^}]*\}', stderr_output, re.DOTALL
|
||||
)
|
||||
if not json_match:
|
||||
logger.warning("No JSON block found in first pass output")
|
||||
return {}
|
||||
@@ -625,7 +651,9 @@ class SoundNormalizerService:
|
||||
sounds = Sound.query.filter_by(type="SDB").all()
|
||||
|
||||
for sound in sounds:
|
||||
original_path = Path(SoundNormalizerService.SOUNDS_DIR) / sound.filename
|
||||
original_path = (
|
||||
Path(SoundNormalizerService.SOUNDS_DIR) / sound.filename
|
||||
)
|
||||
|
||||
if original_path.exists():
|
||||
total_original_size += original_path.stat().st_size
|
||||
@@ -633,7 +661,10 @@ class SoundNormalizerService:
|
||||
# Use database field to check if normalized, not file existence
|
||||
if sound.is_normalized and sound.normalized_filename:
|
||||
normalized_count += 1
|
||||
normalized_path = Path(SoundNormalizerService.NORMALIZED_DIR) / sound.normalized_filename
|
||||
normalized_path = (
|
||||
Path(SoundNormalizerService.NORMALIZED_DIR)
|
||||
/ sound.normalized_filename
|
||||
)
|
||||
if normalized_path.exists():
|
||||
total_normalized_size += normalized_path.stat().st_size
|
||||
|
||||
@@ -676,7 +707,8 @@ class SoundNormalizerService:
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=".wav", delete=False,
|
||||
suffix=".wav",
|
||||
delete=False,
|
||||
) as temp_file:
|
||||
temp_path = temp_file.name
|
||||
|
||||
|
||||
@@ -83,7 +83,9 @@ class SoundScannerService:
|
||||
files_added += 1
|
||||
logger.debug(f"Added sound: {filename}")
|
||||
elif result.get("updated"):
|
||||
files_added += 1 # Count updates as additions for reporting
|
||||
files_added += (
|
||||
1 # Count updates as additions for reporting
|
||||
)
|
||||
logger.debug(f"Updated sound: {filename}")
|
||||
else:
|
||||
files_skipped += 1
|
||||
@@ -233,15 +235,22 @@ class SoundScannerService:
|
||||
"""Remove normalized files for a sound if they exist."""
|
||||
if sound.is_normalized and sound.normalized_filename:
|
||||
# Import here to avoid circular imports
|
||||
from app.services.sound_normalizer_service import SoundNormalizerService
|
||||
from app.services.sound_normalizer_service import (
|
||||
SoundNormalizerService,
|
||||
)
|
||||
|
||||
normalized_path = Path(SoundNormalizerService.NORMALIZED_DIR) / sound.normalized_filename
|
||||
normalized_path = (
|
||||
Path(SoundNormalizerService.NORMALIZED_DIR)
|
||||
/ sound.normalized_filename
|
||||
)
|
||||
if normalized_path.exists():
|
||||
try:
|
||||
normalized_path.unlink()
|
||||
logger.info(f"Removed normalized file: {normalized_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not remove normalized file {normalized_path}: {e}")
|
||||
logger.warning(
|
||||
f"Could not remove normalized file {normalized_path}: {e}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_audio_metadata(file_path: str) -> dict:
|
||||
|
||||
238
app/services/vlc_service.py
Normal file
238
app/services/vlc_service.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""VLC service for playing sounds using subprocess."""
|
||||
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from app.database import db
|
||||
from app.models.sound import Sound
|
||||
from app.models.sound_played import SoundPlayed
|
||||
|
||||
|
||||
class VLCService:
|
||||
"""Service for playing sounds using VLC subprocess."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize VLC service."""
|
||||
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:
|
||||
"""Play a sound by ID using VLC subprocess."""
|
||||
try:
|
||||
# Get sound from database
|
||||
sound = Sound.query.get(sound_id)
|
||||
if not sound:
|
||||
return False
|
||||
|
||||
# Use normalized file if available, otherwise use original
|
||||
if sound.is_normalized and sound.normalized_filename:
|
||||
sound_path = os.path.join(
|
||||
"sounds",
|
||||
"normalized",
|
||||
"soundboard",
|
||||
sound.normalized_filename,
|
||||
)
|
||||
else:
|
||||
sound_path = os.path.join(
|
||||
"sounds", "soundboard", sound.filename
|
||||
)
|
||||
|
||||
# Check if file exists
|
||||
if not os.path.exists(sound_path):
|
||||
return False
|
||||
|
||||
# Convert to absolute path
|
||||
sound_path = os.path.abspath(sound_path)
|
||||
|
||||
# Create unique process ID
|
||||
process_id = f"sound_{sound_id}_{int(time.time() * 1000000)}"
|
||||
|
||||
# Start VLC process
|
||||
vlc_cmd = [
|
||||
"vlc",
|
||||
sound_path,
|
||||
"--intf",
|
||||
"dummy", # No interface
|
||||
"--play-and-exit", # Exit after playing
|
||||
"--no-video", # Audio only
|
||||
"--quiet", # Reduce output
|
||||
]
|
||||
|
||||
process = subprocess.Popen(
|
||||
vlc_cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
preexec_fn=os.setsid, # Create new process group
|
||||
)
|
||||
|
||||
# Store process for tracking
|
||||
with self.lock:
|
||||
self.processes[process_id] = process
|
||||
|
||||
print(
|
||||
f"Started VLC process {process.pid} ({process_id}) for sound {sound.name}. Total processes: {len(self.processes)}"
|
||||
)
|
||||
|
||||
# Increment play count
|
||||
sound.increment_play_count()
|
||||
|
||||
# Record play event if user is provided
|
||||
if user_id:
|
||||
try:
|
||||
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:
|
||||
print(f"Error recording play event: {e}")
|
||||
|
||||
# Schedule cleanup after sound duration
|
||||
threading.Thread(
|
||||
target=self._cleanup_after_playback,
|
||||
args=(process_id, sound.duration if sound.duration else 10000),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error starting VLC process for sound {sound_id}: {e}")
|
||||
return False
|
||||
|
||||
def _cleanup_after_playback(self, process_id: str, duration: int) -> None:
|
||||
"""Clean up VLC process after playback."""
|
||||
# Wait for playback to finish (duration + 1 second buffer)
|
||||
time.sleep(duration / 1000 + 1) # Convert ms to seconds
|
||||
|
||||
with self.lock:
|
||||
if process_id in self.processes:
|
||||
print(f"Cleaning up process {process_id} after playback")
|
||||
process = self.processes[process_id]
|
||||
|
||||
try:
|
||||
# Check if process is still running
|
||||
if process.poll() is None:
|
||||
print(
|
||||
f"Process {process.pid} still running, terminating"
|
||||
)
|
||||
process.terminate()
|
||||
# Give it a moment to terminate gracefully
|
||||
try:
|
||||
process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
print(
|
||||
f"Process {process.pid} didn't terminate, killing"
|
||||
)
|
||||
process.kill()
|
||||
|
||||
print(f"Successfully cleaned up process {process_id}")
|
||||
except Exception as e:
|
||||
print(f"Error during cleanup of {process_id}: {e}")
|
||||
finally:
|
||||
# Always remove from tracking
|
||||
del self.processes[process_id]
|
||||
print(
|
||||
f"Removed process {process_id}. Remaining processes: {len(self.processes)}"
|
||||
)
|
||||
else:
|
||||
print(f"Process {process_id} not found during cleanup")
|
||||
|
||||
def stop_all(self) -> None:
|
||||
"""Stop all playing sounds by killing VLC processes."""
|
||||
with self.lock:
|
||||
processes_copy = dict(self.processes)
|
||||
print(
|
||||
f"Stopping {len(processes_copy)} VLC processes: {list(processes_copy.keys())}"
|
||||
)
|
||||
|
||||
for process_id, process in processes_copy.items():
|
||||
try:
|
||||
if process.poll() is None: # Process is still running
|
||||
print(
|
||||
f"Terminating process {process.pid} ({process_id})"
|
||||
)
|
||||
process.terminate()
|
||||
|
||||
# Give it a moment to terminate gracefully
|
||||
try:
|
||||
process.wait(timeout=1)
|
||||
print(
|
||||
f"Process {process.pid} terminated gracefully"
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
print(
|
||||
f"Process {process.pid} didn't terminate, killing forcefully"
|
||||
)
|
||||
process.kill()
|
||||
process.wait() # Wait for it to be killed
|
||||
else:
|
||||
print(
|
||||
f"Process {process.pid} ({process_id}) already finished"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error stopping process {process_id}: {e}")
|
||||
|
||||
# Clear all processes
|
||||
self.processes.clear()
|
||||
print(f"Cleared all processes. Remaining: {len(self.processes)}")
|
||||
|
||||
def get_playing_count(self) -> int:
|
||||
"""Get number of currently playing sounds."""
|
||||
with self.lock:
|
||||
# Clean up finished processes and return count
|
||||
finished_processes = []
|
||||
for process_id, process in self.processes.items():
|
||||
if process.poll() is not None: # Process has finished
|
||||
finished_processes.append(process_id)
|
||||
|
||||
# Remove finished processes
|
||||
for process_id in finished_processes:
|
||||
del self.processes[process_id]
|
||||
|
||||
return len(self.processes)
|
||||
|
||||
def force_stop_all(self) -> int:
|
||||
"""Force stop all sounds by killing VLC processes aggressively."""
|
||||
with self.lock:
|
||||
stopped_count = len(self.processes)
|
||||
print(f"Force stopping {stopped_count} VLC processes")
|
||||
|
||||
# # Kill all VLC processes aggressively
|
||||
# for process_id, process in list(self.processes.items()):
|
||||
# try:
|
||||
# if process.poll() is None: # Process is still running
|
||||
# print(f"Force killing process {process.pid} ({process_id})")
|
||||
# process.kill()
|
||||
# process.wait() # Wait for it to be killed
|
||||
# print(f"Process {process.pid} killed")
|
||||
# else:
|
||||
# print(f"Process {process.pid} ({process_id}) already finished")
|
||||
|
||||
# except Exception as e:
|
||||
# print(f"Error force-stopping process {process_id}: {e}")
|
||||
|
||||
# Also try to kill any remaining VLC processes system-wide
|
||||
try:
|
||||
subprocess.run(["pkill", "-f", "vlc"], check=False)
|
||||
print("Killed any remaining VLC processes system-wide")
|
||||
except Exception as e:
|
||||
print(f"Error killing system VLC processes: {e}")
|
||||
|
||||
# Clear all processes
|
||||
self.processes.clear()
|
||||
print(
|
||||
f"Force stop completed. Processes remaining: {len(self.processes)}"
|
||||
)
|
||||
return stopped_count
|
||||
|
||||
|
||||
# Global VLC service instance
|
||||
vlc_service = VLCService()
|
||||
Reference in New Issue
Block a user