feat: Add VLC service for sound playback and management
- Implemented VLCService to handle sound playback using VLC. - Added routes for soundboard management including play, stop, and status. - Introduced admin routes for sound normalization and scanning. - Updated user model and services to accommodate new functionalities. - Enhanced error handling and logging throughout the application. - Updated dependencies to include python-vlc for sound playback capabilities.
This commit is contained in:
@@ -27,7 +27,8 @@ def create_app():
|
|||||||
|
|
||||||
# Configure Flask-JWT-Extended
|
# Configure Flask-JWT-Extended
|
||||||
app.config["JWT_SECRET_KEY"] = os.environ.get(
|
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_ACCESS_TOKEN_EXPIRES"] = timedelta(minutes=15)
|
||||||
app.config["JWT_REFRESH_TOKEN_EXPIRES"] = timedelta(days=7)
|
app.config["JWT_REFRESH_TOKEN_EXPIRES"] = timedelta(days=7)
|
||||||
@@ -68,11 +69,13 @@ def create_app():
|
|||||||
scheduler_service.start()
|
scheduler_service.start()
|
||||||
|
|
||||||
# Register blueprints
|
# Register blueprints
|
||||||
from app.routes import admin, auth, main
|
from app.routes import admin, admin_sounds, auth, main, soundboard
|
||||||
|
|
||||||
app.register_blueprint(main.bp, url_prefix="/api")
|
app.register_blueprint(main.bp, url_prefix="/api")
|
||||||
app.register_blueprint(auth.bp, url_prefix="/api/auth")
|
app.register_blueprint(auth.bp, url_prefix="/api/auth")
|
||||||
app.register_blueprint(admin.bp, url_prefix="/api/admin")
|
app.register_blueprint(admin.bp, url_prefix="/api/admin")
|
||||||
|
app.register_blueprint(admin_sounds.bp)
|
||||||
|
app.register_blueprint(soundboard.bp)
|
||||||
|
|
||||||
# Shutdown scheduler when app is torn down
|
# Shutdown scheduler when app is torn down
|
||||||
@app.teardown_appcontext
|
@app.teardown_appcontext
|
||||||
|
|||||||
@@ -68,7 +68,8 @@ def migrate_users_to_plans():
|
|||||||
# 0 credits means they spent them, NULL means they never got assigned
|
# 0 credits means they spent them, NULL means they never got assigned
|
||||||
try:
|
try:
|
||||||
users_without_credits = User.query.filter(
|
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()
|
).all()
|
||||||
except Exception:
|
except Exception:
|
||||||
# Credits column doesn't exist yet, will be handled by create_all
|
# Credits column doesn't exist yet, will be handled by create_all
|
||||||
|
|||||||
@@ -29,12 +29,15 @@ class User(db.Model):
|
|||||||
|
|
||||||
# Password authentication (optional - users can use OAuth instead)
|
# Password authentication (optional - users can use OAuth instead)
|
||||||
password_hash: Mapped[str | None] = mapped_column(
|
password_hash: Mapped[str | None] = mapped_column(
|
||||||
String(255), nullable=True,
|
String(255),
|
||||||
|
nullable=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Role-based access control
|
# Role-based access control
|
||||||
role: Mapped[str] = mapped_column(
|
role: Mapped[str] = mapped_column(
|
||||||
String(50), nullable=False, default="user",
|
String(50),
|
||||||
|
nullable=False,
|
||||||
|
default="user",
|
||||||
)
|
)
|
||||||
|
|
||||||
# User status
|
# User status
|
||||||
@@ -42,7 +45,9 @@ class User(db.Model):
|
|||||||
|
|
||||||
# Plan relationship
|
# Plan relationship
|
||||||
plan_id: Mapped[int] = mapped_column(
|
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)
|
# User credits (populated from plan credits on creation)
|
||||||
@@ -51,12 +56,15 @@ class User(db.Model):
|
|||||||
# API token for programmatic access
|
# API token for programmatic access
|
||||||
api_token: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
api_token: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
api_token_expires_at: Mapped[datetime | None] = mapped_column(
|
api_token_expires_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime, nullable=True,
|
DateTime,
|
||||||
|
nullable=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, default=datetime.utcnow, nullable=False,
|
DateTime,
|
||||||
|
default=datetime.utcnow,
|
||||||
|
nullable=False,
|
||||||
)
|
)
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime,
|
DateTime,
|
||||||
@@ -67,7 +75,9 @@ class User(db.Model):
|
|||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
oauth_providers: Mapped[list["UserOAuth"]] = relationship(
|
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")
|
plan: Mapped["Plan"] = relationship("Plan", back_populates="users")
|
||||||
|
|
||||||
@@ -198,7 +208,8 @@ class User(db.Model):
|
|||||||
|
|
||||||
# First, try to find existing OAuth provider
|
# First, try to find existing OAuth provider
|
||||||
oauth_provider = UserOAuth.find_by_provider_and_id(
|
oauth_provider = UserOAuth.find_by_provider_and_id(
|
||||||
provider, provider_id,
|
provider,
|
||||||
|
provider_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if oauth_provider:
|
if oauth_provider:
|
||||||
@@ -256,7 +267,10 @@ class User(db.Model):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_with_password(
|
def create_with_password(
|
||||||
cls, email: str, password: str, name: str,
|
cls,
|
||||||
|
email: str,
|
||||||
|
password: str,
|
||||||
|
name: str,
|
||||||
) -> "User":
|
) -> "User":
|
||||||
"""Create new user with email and password."""
|
"""Create new user with email and password."""
|
||||||
from app.models.plan import Plan
|
from app.models.plan import Plan
|
||||||
@@ -293,7 +307,9 @@ class User(db.Model):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def authenticate_with_password(
|
def authenticate_with_password(
|
||||||
cls, email: str, password: str,
|
cls,
|
||||||
|
email: str,
|
||||||
|
password: str,
|
||||||
) -> Optional["User"]:
|
) -> Optional["User"]:
|
||||||
"""Authenticate user with email and password."""
|
"""Authenticate user with email and password."""
|
||||||
user = cls.find_by_email(email)
|
user = cls.find_by_email(email)
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ class UserOAuth(db.Model):
|
|||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, default=datetime.utcnow, nullable=False,
|
DateTime,
|
||||||
|
default=datetime.utcnow,
|
||||||
|
nullable=False,
|
||||||
)
|
)
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime,
|
DateTime,
|
||||||
@@ -45,13 +47,16 @@ class UserOAuth(db.Model):
|
|||||||
# Unique constraint on provider + provider_id combination
|
# Unique constraint on provider + provider_id combination
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
db.UniqueConstraint(
|
db.UniqueConstraint(
|
||||||
"provider", "provider_id", name="unique_provider_user",
|
"provider",
|
||||||
|
"provider_id",
|
||||||
|
name="unique_provider_user",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
user: Mapped["User"] = relationship(
|
user: Mapped["User"] = relationship(
|
||||||
"User", back_populates="oauth_providers",
|
"User",
|
||||||
|
back_populates="oauth_providers",
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
@@ -73,11 +78,14 @@ class UserOAuth(db.Model):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_by_provider_and_id(
|
def find_by_provider_and_id(
|
||||||
cls, provider: str, provider_id: str,
|
cls,
|
||||||
|
provider: str,
|
||||||
|
provider_id: str,
|
||||||
) -> Optional["UserOAuth"]:
|
) -> Optional["UserOAuth"]:
|
||||||
"""Find OAuth provider by provider name and provider ID."""
|
"""Find OAuth provider by provider name and provider ID."""
|
||||||
return cls.query.filter_by(
|
return cls.query.filter_by(
|
||||||
provider=provider, provider_id=provider_id,
|
provider=provider,
|
||||||
|
provider_id=provider_id,
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -92,4 +92,4 @@ def normalization_status() -> dict:
|
|||||||
@require_role("admin")
|
@require_role("admin")
|
||||||
def ffmpeg_check() -> dict:
|
def ffmpeg_check() -> dict:
|
||||||
"""Check ffmpeg availability and capabilities (admin only)."""
|
"""Check ffmpeg availability and capabilities (admin only)."""
|
||||||
return SoundNormalizerService.check_ffmpeg_availability()
|
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):
|
def link_provider(provider):
|
||||||
"""Link a new OAuth provider to current user account."""
|
"""Link a new OAuth provider to current user account."""
|
||||||
redirect_uri = url_for(
|
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)
|
return auth_service.redirect_to_login(provider, redirect_uri)
|
||||||
|
|
||||||
@@ -174,7 +176,8 @@ def link_callback(provider):
|
|||||||
from app.models.user_oauth import UserOAuth
|
from app.models.user_oauth import UserOAuth
|
||||||
|
|
||||||
existing_provider = UserOAuth.find_by_provider_and_id(
|
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:
|
if existing_provider and existing_provider.user_id != user.id:
|
||||||
|
|||||||
@@ -39,8 +39,6 @@ def api_protected() -> dict[str, str]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/health")
|
@bp.route("/health")
|
||||||
def health() -> dict[str, str]:
|
def health() -> dict[str, str]:
|
||||||
"""Health check endpoint."""
|
"""Health check endpoint."""
|
||||||
@@ -72,5 +70,3 @@ def expensive_operation() -> dict[str, str]:
|
|||||||
"user": user["email"],
|
"user": user["email"],
|
||||||
"operation_cost": 10,
|
"operation_cost": 10,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
121
app/routes/soundboard.py
Normal file
121
app/routes/soundboard.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""Soundboard routes."""
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
from app.models.sound import Sound, SoundType
|
||||||
|
from app.services.vlc_service import vlc_service
|
||||||
|
from app.services.decorators import require_auth
|
||||||
|
|
||||||
|
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:
|
||||||
|
success = vlc_service.play_sound(sound_id)
|
||||||
|
|
||||||
|
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 instance information
|
||||||
|
with vlc_service.lock:
|
||||||
|
instances = []
|
||||||
|
for instance_id, instance_data in vlc_service.instances.items():
|
||||||
|
instances.append({
|
||||||
|
"id": instance_id,
|
||||||
|
"sound_id": instance_data.get("sound_id"),
|
||||||
|
"created_at": instance_data.get("created_at"),
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"playing_count": playing_count,
|
||||||
|
"is_playing": playing_count > 0,
|
||||||
|
"instances": instances,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
@@ -149,7 +149,10 @@ class AuthService:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def register_with_password(
|
def register_with_password(
|
||||||
self, email: str, password: str, name: str,
|
self,
|
||||||
|
email: str,
|
||||||
|
password: str,
|
||||||
|
name: str,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Register new user with email and password."""
|
"""Register new user with email and password."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ class CreditService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def refill_all_users_credits() -> dict:
|
def refill_all_users_credits() -> dict:
|
||||||
"""Refill credits for all active users based on their plan.
|
"""Refill credits for all active users based on their plan.
|
||||||
|
|
||||||
This function:
|
This function:
|
||||||
1. Gets all active users
|
1. Gets all active users
|
||||||
2. For each user, adds their plan's daily credit amount
|
2. For each user, adds their plan's daily credit amount
|
||||||
3. Ensures credits never exceed the plan's max_credits limit
|
3. Ensures credits never exceed the plan's max_credits limit
|
||||||
4. Updates all users in a single database transaction
|
4. Updates all users in a single database transaction
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Summary of the refill operation
|
dict: Summary of the refill operation
|
||||||
|
|
||||||
@@ -44,7 +44,9 @@ class CreditService:
|
|||||||
|
|
||||||
for user in users:
|
for user in users:
|
||||||
if not user.plan:
|
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
|
continue
|
||||||
|
|
||||||
# Calculate new credit amount, capped at plan max
|
# Calculate new credit amount, capped at plan max
|
||||||
@@ -53,7 +55,9 @@ class CreditService:
|
|||||||
max_credits = user.plan.max_credits
|
max_credits = user.plan.max_credits
|
||||||
|
|
||||||
# Add daily credits but don't exceed maximum
|
# 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
|
credits_added = new_credits - current_credits
|
||||||
|
|
||||||
if credits_added > 0:
|
if credits_added > 0:
|
||||||
@@ -104,10 +108,10 @@ class CreditService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def get_user_credit_info(user_id: int) -> dict:
|
def get_user_credit_info(user_id: int) -> dict:
|
||||||
"""Get detailed credit information for a specific user.
|
"""Get detailed credit information for a specific user.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_id: The user's ID
|
user_id: The user's ID
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: User's credit information
|
dict: User's credit information
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,26 @@ def require_role(required_role: str):
|
|||||||
return decorator
|
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):
|
def require_credits(credits_needed: int):
|
||||||
"""Decorator to require and deduct credits for routes."""
|
"""Decorator to require and deduct credits for routes."""
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ class OAuthProvider(ABC):
|
|||||||
return client.authorize_redirect(redirect_uri).location
|
return client.authorize_redirect(redirect_uri).location
|
||||||
|
|
||||||
def exchange_code_for_token(
|
def exchange_code_for_token(
|
||||||
self, code: str = None, redirect_uri: str = None,
|
self,
|
||||||
|
code: str = None,
|
||||||
|
redirect_uri: str = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Exchange authorization code for access token."""
|
"""Exchange authorization code for access token."""
|
||||||
client = self.get_client()
|
client = self.get_client()
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ class OAuthProviderRegistry:
|
|||||||
google_client_secret = os.getenv("GOOGLE_CLIENT_SECRET")
|
google_client_secret = os.getenv("GOOGLE_CLIENT_SECRET")
|
||||||
if google_client_id and google_client_secret:
|
if google_client_id and google_client_secret:
|
||||||
self._providers["google"] = GoogleOAuthProvider(
|
self._providers["google"] = GoogleOAuthProvider(
|
||||||
self.oauth, google_client_id, google_client_secret,
|
self.oauth,
|
||||||
|
google_client_id,
|
||||||
|
google_client_secret,
|
||||||
)
|
)
|
||||||
|
|
||||||
# GitHub OAuth
|
# GitHub OAuth
|
||||||
@@ -30,7 +32,9 @@ class OAuthProviderRegistry:
|
|||||||
github_client_secret = os.getenv("GITHUB_CLIENT_SECRET")
|
github_client_secret = os.getenv("GITHUB_CLIENT_SECRET")
|
||||||
if github_client_id and github_client_secret:
|
if github_client_id and github_client_secret:
|
||||||
self._providers["github"] = GitHubOAuthProvider(
|
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:
|
def get_provider(self, name: str) -> OAuthProvider | None:
|
||||||
|
|||||||
@@ -97,7 +97,9 @@ class SchedulerService:
|
|||||||
f"{result['credits_added']} credits added",
|
f"{result['credits_added']} credits added",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.error(f"Daily credit refill failed: {result['message']}")
|
logger.error(
|
||||||
|
f"Daily credit refill failed: {result['message']}"
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Error during daily credit refill: {e}")
|
logger.exception(f"Error during daily credit refill: {e}")
|
||||||
@@ -119,7 +121,9 @@ class SchedulerService:
|
|||||||
else:
|
else:
|
||||||
logger.debug("Sound scan completed: no new files found")
|
logger.debug("Sound scan completed: no new files found")
|
||||||
else:
|
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:
|
except Exception as e:
|
||||||
logger.exception(f"Error during sound scan: {e}")
|
logger.exception(f"Error during sound scan: {e}")
|
||||||
@@ -148,7 +152,8 @@ class SchedulerService:
|
|||||||
"id": job.id,
|
"id": job.id,
|
||||||
"name": job.name,
|
"name": job.name,
|
||||||
"next_run": job.next_run_time.isoformat()
|
"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),
|
"trigger": str(job.trigger),
|
||||||
}
|
}
|
||||||
for job in self.scheduler.get_jobs()
|
for job in self.scheduler.get_jobs()
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ class SoundNormalizerService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@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.
|
"""Normalize a specific sound file using ffmpeg loudnorm.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -58,7 +60,9 @@ class SoundNormalizerService:
|
|||||||
"error": f"Sound with ID {sound_id} not found",
|
"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():
|
if not source_path.exists():
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
@@ -68,7 +72,10 @@ class SoundNormalizerService:
|
|||||||
# Always output as WAV regardless of input format
|
# Always output as WAV regardless of input format
|
||||||
filename_without_ext = Path(sound.filename).stem
|
filename_without_ext = Path(sound.filename).stem
|
||||||
normalized_filename = f"{filename_without_ext}.wav"
|
normalized_filename = f"{filename_without_ext}.wav"
|
||||||
normalized_path = Path(SoundNormalizerService.NORMALIZED_DIR) / normalized_filename
|
normalized_path = (
|
||||||
|
Path(SoundNormalizerService.NORMALIZED_DIR)
|
||||||
|
/ normalized_filename
|
||||||
|
)
|
||||||
|
|
||||||
normalized_path.parent.mkdir(parents=True, exist_ok=True)
|
normalized_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
@@ -84,11 +91,15 @@ class SoundNormalizerService:
|
|||||||
|
|
||||||
if two_pass:
|
if two_pass:
|
||||||
result = SoundNormalizerService._normalize_with_ffmpeg(
|
result = SoundNormalizerService._normalize_with_ffmpeg(
|
||||||
str(source_path), str(normalized_path),
|
str(source_path),
|
||||||
|
str(normalized_path),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
result = SoundNormalizerService._normalize_with_ffmpeg_single_pass(
|
result = (
|
||||||
str(source_path), str(normalized_path),
|
SoundNormalizerService._normalize_with_ffmpeg_single_pass(
|
||||||
|
str(source_path),
|
||||||
|
str(normalized_path),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if result["success"]:
|
if result["success"]:
|
||||||
@@ -131,7 +142,9 @@ class SoundNormalizerService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def normalize_all_sounds(
|
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:
|
) -> dict:
|
||||||
"""Normalize all soundboard files.
|
"""Normalize all soundboard files.
|
||||||
|
|
||||||
@@ -171,7 +184,9 @@ class SoundNormalizerService:
|
|||||||
|
|
||||||
for sound in sounds:
|
for sound in sounds:
|
||||||
result = SoundNormalizerService.normalize_sound(
|
result = SoundNormalizerService.normalize_sound(
|
||||||
sound.id, overwrite, two_pass,
|
sound.id,
|
||||||
|
overwrite,
|
||||||
|
two_pass,
|
||||||
)
|
)
|
||||||
processed += 1
|
processed += 1
|
||||||
|
|
||||||
@@ -233,19 +248,19 @@ class SoundNormalizerService:
|
|||||||
|
|
||||||
# FIRST PASS: Analyze the audio to get optimal parameters
|
# FIRST PASS: Analyze the audio to get optimal parameters
|
||||||
logger.debug("Starting first pass (analysis)")
|
logger.debug("Starting first pass (analysis)")
|
||||||
|
|
||||||
first_pass_result = SoundNormalizerService._run_first_pass(
|
first_pass_result = SoundNormalizerService._run_first_pass(
|
||||||
source_path, params
|
source_path, params
|
||||||
)
|
)
|
||||||
|
|
||||||
if not first_pass_result["success"]:
|
if not first_pass_result["success"]:
|
||||||
return first_pass_result
|
return first_pass_result
|
||||||
|
|
||||||
measured_params = first_pass_result["measured_params"]
|
measured_params = first_pass_result["measured_params"]
|
||||||
|
|
||||||
# SECOND PASS: Apply normalization using measured parameters
|
# SECOND PASS: Apply normalization using measured parameters
|
||||||
logger.debug("Starting second pass (normalization)")
|
logger.debug("Starting second pass (normalization)")
|
||||||
|
|
||||||
second_pass_result = SoundNormalizerService._run_second_pass(
|
second_pass_result = SoundNormalizerService._run_second_pass(
|
||||||
source_path, output_path, params, measured_params
|
source_path, output_path, params, measured_params
|
||||||
)
|
)
|
||||||
@@ -281,9 +296,11 @@ class SoundNormalizerService:
|
|||||||
return {"success": False, "error": str(e)}
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
@staticmethod
|
@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.
|
"""Run ffmpeg loudnorm on a single file using single-pass normalization.
|
||||||
|
|
||||||
This is the legacy single-pass method for backward compatibility.
|
This is the legacy single-pass method for backward compatibility.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -319,7 +336,9 @@ class SoundNormalizerService:
|
|||||||
|
|
||||||
# Run the ffmpeg process
|
# Run the ffmpeg process
|
||||||
out, err = ffmpeg.run(
|
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
|
# Parse loudnorm statistics from stderr
|
||||||
@@ -348,11 +367,11 @@ class SoundNormalizerService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _run_first_pass(source_path: str, params: dict) -> dict:
|
def _run_first_pass(source_path: str, params: dict) -> dict:
|
||||||
"""Run first pass of loudnorm to analyze audio characteristics.
|
"""Run first pass of loudnorm to analyze audio characteristics.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
source_path: Path to source audio file
|
source_path: Path to source audio file
|
||||||
params: Loudnorm target parameters
|
params: Loudnorm target parameters
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Result with measured parameters and analysis stats
|
dict: Result with measured parameters and analysis stats
|
||||||
"""
|
"""
|
||||||
@@ -370,35 +389,36 @@ class SoundNormalizerService:
|
|||||||
|
|
||||||
# Output to null device for analysis
|
# Output to null device for analysis
|
||||||
output_stream = ffmpeg.output(
|
output_stream = ffmpeg.output(
|
||||||
input_stream,
|
input_stream, "/dev/null", af=loudnorm_filter, f="null"
|
||||||
"/dev/null",
|
|
||||||
af=loudnorm_filter,
|
|
||||||
f="null"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Run the first pass
|
# Run the first pass
|
||||||
out, err = ffmpeg.run(
|
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 ""
|
stderr_text = err.decode() if err else ""
|
||||||
|
|
||||||
# Parse measured parameters from JSON output
|
# 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:
|
if not measured_params:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"error": "Failed to parse measured parameters from first pass"
|
"error": "Failed to parse measured parameters from first pass",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse basic stats
|
# Parse basic stats
|
||||||
stats = SoundNormalizerService._parse_loudnorm_stats(stderr_text)
|
stats = SoundNormalizerService._parse_loudnorm_stats(stderr_text)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"measured_params": measured_params,
|
"measured_params": measured_params,
|
||||||
"stats": stats
|
"stats": stats,
|
||||||
}
|
}
|
||||||
|
|
||||||
except ffmpeg.Error as e:
|
except ffmpeg.Error as e:
|
||||||
@@ -410,15 +430,20 @@ class SoundNormalizerService:
|
|||||||
return {"success": False, "error": str(e)}
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
@staticmethod
|
@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.
|
"""Run second pass of loudnorm using measured parameters.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
source_path: Path to source audio file
|
source_path: Path to source audio file
|
||||||
output_path: Path for normalized output file
|
output_path: Path for normalized output file
|
||||||
target_params: Target loudnorm parameters
|
target_params: Target loudnorm parameters
|
||||||
measured_params: Parameters measured from first pass
|
measured_params: Parameters measured from first pass
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Result with normalization stats
|
dict: Result with normalization stats
|
||||||
"""
|
"""
|
||||||
@@ -452,18 +477,17 @@ class SoundNormalizerService:
|
|||||||
|
|
||||||
# Run the second pass
|
# Run the second pass
|
||||||
out, err = ffmpeg.run(
|
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 ""
|
stderr_text = err.decode() if err else ""
|
||||||
|
|
||||||
# Parse final statistics
|
# Parse final statistics
|
||||||
stats = SoundNormalizerService._parse_loudnorm_stats(stderr_text)
|
stats = SoundNormalizerService._parse_loudnorm_stats(stderr_text)
|
||||||
|
|
||||||
return {
|
return {"success": True, "stats": stats}
|
||||||
"success": True,
|
|
||||||
"stats": stats
|
|
||||||
}
|
|
||||||
|
|
||||||
except ffmpeg.Error as e:
|
except ffmpeg.Error as e:
|
||||||
error_msg = f"Second pass FFmpeg error: {e.stderr.decode() if e.stderr else str(e)}"
|
error_msg = f"Second pass FFmpeg error: {e.stderr.decode() if e.stderr else str(e)}"
|
||||||
@@ -476,23 +500,25 @@ class SoundNormalizerService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_measured_params(stderr_output: str) -> dict:
|
def _parse_measured_params(stderr_output: str) -> dict:
|
||||||
"""Parse measured parameters from first pass JSON output.
|
"""Parse measured parameters from first pass JSON output.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
stderr_output: ffmpeg stderr output containing JSON data
|
stderr_output: ffmpeg stderr output containing JSON data
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Parsed measured parameters, empty if parsing fails
|
dict: Parsed measured parameters, empty if parsing fails
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Find JSON block in stderr output
|
# 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:
|
if not json_match:
|
||||||
logger.warning("No JSON block found in first pass output")
|
logger.warning("No JSON block found in first pass output")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
json_str = json_match.group(0)
|
json_str = json_match.group(0)
|
||||||
measured_data = json.loads(json_str)
|
measured_data = json.loads(json_str)
|
||||||
|
|
||||||
# Extract required parameters
|
# Extract required parameters
|
||||||
return {
|
return {
|
||||||
"input_i": measured_data.get("input_i", 0),
|
"input_i": measured_data.get("input_i", 0),
|
||||||
@@ -501,7 +527,7 @@ class SoundNormalizerService:
|
|||||||
"input_thresh": measured_data.get("input_thresh", 0),
|
"input_thresh": measured_data.get("input_thresh", 0),
|
||||||
"target_offset": measured_data.get("target_offset", 0),
|
"target_offset": measured_data.get("target_offset", 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
except (json.JSONDecodeError, KeyError, AttributeError) as e:
|
except (json.JSONDecodeError, KeyError, AttributeError) as e:
|
||||||
logger.warning(f"Failed to parse measured parameters: {e}")
|
logger.warning(f"Failed to parse measured parameters: {e}")
|
||||||
return {}
|
return {}
|
||||||
@@ -625,7 +651,9 @@ class SoundNormalizerService:
|
|||||||
sounds = Sound.query.filter_by(type="SDB").all()
|
sounds = Sound.query.filter_by(type="SDB").all()
|
||||||
|
|
||||||
for sound in sounds:
|
for sound in sounds:
|
||||||
original_path = Path(SoundNormalizerService.SOUNDS_DIR) / sound.filename
|
original_path = (
|
||||||
|
Path(SoundNormalizerService.SOUNDS_DIR) / sound.filename
|
||||||
|
)
|
||||||
|
|
||||||
if original_path.exists():
|
if original_path.exists():
|
||||||
total_original_size += original_path.stat().st_size
|
total_original_size += original_path.stat().st_size
|
||||||
@@ -633,7 +661,10 @@ class SoundNormalizerService:
|
|||||||
# Use database field to check if normalized, not file existence
|
# Use database field to check if normalized, not file existence
|
||||||
if sound.is_normalized and sound.normalized_filename:
|
if sound.is_normalized and sound.normalized_filename:
|
||||||
normalized_count += 1
|
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():
|
if normalized_path.exists():
|
||||||
total_normalized_size += normalized_path.stat().st_size
|
total_normalized_size += normalized_path.stat().st_size
|
||||||
|
|
||||||
@@ -676,7 +707,8 @@ class SoundNormalizerService:
|
|||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(
|
with tempfile.NamedTemporaryFile(
|
||||||
suffix=".wav", delete=False,
|
suffix=".wav",
|
||||||
|
delete=False,
|
||||||
) as temp_file:
|
) as temp_file:
|
||||||
temp_path = temp_file.name
|
temp_path = temp_file.name
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,9 @@ class SoundScannerService:
|
|||||||
files_added += 1
|
files_added += 1
|
||||||
logger.debug(f"Added sound: {filename}")
|
logger.debug(f"Added sound: {filename}")
|
||||||
elif result.get("updated"):
|
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}")
|
logger.debug(f"Updated sound: {filename}")
|
||||||
else:
|
else:
|
||||||
files_skipped += 1
|
files_skipped += 1
|
||||||
@@ -171,7 +173,7 @@ class SoundScannerService:
|
|||||||
# Remove normalized files and clear normalized info
|
# Remove normalized files and clear normalized info
|
||||||
SoundScannerService._clear_normalized_files(existing_filename_sound)
|
SoundScannerService._clear_normalized_files(existing_filename_sound)
|
||||||
existing_filename_sound.clear_normalized_info()
|
existing_filename_sound.clear_normalized_info()
|
||||||
|
|
||||||
# Update existing sound with new file information
|
# Update existing sound with new file information
|
||||||
existing_filename_sound.update_file_info(
|
existing_filename_sound.update_file_info(
|
||||||
filename=str(relative_path),
|
filename=str(relative_path),
|
||||||
@@ -179,7 +181,7 @@ class SoundScannerService:
|
|||||||
size=metadata["size"],
|
size=metadata["size"],
|
||||||
hash_value=file_hash,
|
hash_value=file_hash,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"added": False,
|
"added": False,
|
||||||
"updated": True,
|
"updated": True,
|
||||||
@@ -233,15 +235,22 @@ class SoundScannerService:
|
|||||||
"""Remove normalized files for a sound if they exist."""
|
"""Remove normalized files for a sound if they exist."""
|
||||||
if sound.is_normalized and sound.normalized_filename:
|
if sound.is_normalized and sound.normalized_filename:
|
||||||
# Import here to avoid circular imports
|
# 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():
|
if normalized_path.exists():
|
||||||
try:
|
try:
|
||||||
normalized_path.unlink()
|
normalized_path.unlink()
|
||||||
logger.info(f"Removed normalized file: {normalized_path}")
|
logger.info(f"Removed normalized file: {normalized_path}")
|
||||||
except Exception as e:
|
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
|
@staticmethod
|
||||||
def _extract_audio_metadata(file_path: str) -> dict:
|
def _extract_audio_metadata(file_path: str) -> dict:
|
||||||
|
|||||||
194
app/services/vlc_service.py
Normal file
194
app/services/vlc_service.py
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
"""VLC service for playing sounds."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
import vlc
|
||||||
|
|
||||||
|
from app.database import db
|
||||||
|
from app.models.sound import Sound
|
||||||
|
|
||||||
|
|
||||||
|
class VLCService:
|
||||||
|
"""Service for playing sounds using VLC."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialize VLC service."""
|
||||||
|
self.instances: Dict[str, Dict[str, Any]] = {}
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
|
||||||
|
def play_sound(self, sound_id: int) -> bool:
|
||||||
|
"""Play a sound by ID using VLC."""
|
||||||
|
with self.lock:
|
||||||
|
# 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.type.lower(),
|
||||||
|
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
|
||||||
|
|
||||||
|
# Create VLC instance
|
||||||
|
instance = vlc.Instance()
|
||||||
|
player = instance.media_player_new()
|
||||||
|
|
||||||
|
# Load and play media
|
||||||
|
media = instance.media_new(sound_path)
|
||||||
|
player.set_media(media)
|
||||||
|
|
||||||
|
# Start playback
|
||||||
|
player.play()
|
||||||
|
|
||||||
|
# Store instance for cleanup with unique ID
|
||||||
|
instance_id = f"sound_{sound_id}_{uuid.uuid4().hex[:8]}_{int(time.time())}"
|
||||||
|
self.instances[instance_id] = {
|
||||||
|
"instance": instance,
|
||||||
|
"player": player,
|
||||||
|
"sound_id": sound_id,
|
||||||
|
"created_at": time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"Created instance {instance_id} for sound {sound.name}. Total instances: {len(self.instances)}")
|
||||||
|
|
||||||
|
# Increment play count
|
||||||
|
sound.increment_play_count()
|
||||||
|
|
||||||
|
# Schedule cleanup
|
||||||
|
threading.Thread(
|
||||||
|
target=self._cleanup_after_playback,
|
||||||
|
args=(instance_id, sound.duration if sound.duration else 10),
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _cleanup_after_playback(self, instance_id: str, duration: int) -> None:
|
||||||
|
"""Clean up VLC instance after playback."""
|
||||||
|
# Wait for playback to finish (duration + 1 second buffer)
|
||||||
|
time.sleep(duration / 1000 + 1) # Convert ms to seconds
|
||||||
|
|
||||||
|
with self.lock:
|
||||||
|
if instance_id in self.instances:
|
||||||
|
print(f"Cleaning up instance {instance_id} after playback")
|
||||||
|
instance_data = self.instances[instance_id]
|
||||||
|
player = instance_data["player"]
|
||||||
|
instance = instance_data["instance"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Stop player if still playing
|
||||||
|
if player.is_playing():
|
||||||
|
player.stop()
|
||||||
|
|
||||||
|
# Release resources
|
||||||
|
player.release()
|
||||||
|
instance.release()
|
||||||
|
print(f"Successfully cleaned up instance {instance_id}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error during cleanup of {instance_id}: {e}")
|
||||||
|
finally:
|
||||||
|
# Always remove from tracking
|
||||||
|
del self.instances[instance_id]
|
||||||
|
print(f"Removed instance {instance_id}. Remaining instances: {len(self.instances)}")
|
||||||
|
else:
|
||||||
|
print(f"Instance {instance_id} not found during cleanup")
|
||||||
|
|
||||||
|
def stop_all(self) -> None:
|
||||||
|
"""Stop all playing sounds."""
|
||||||
|
with self.lock:
|
||||||
|
# Create a copy of the instances to avoid race conditions
|
||||||
|
instances_copy = dict(self.instances)
|
||||||
|
print(f"Stopping {len(instances_copy)} instances: {list(instances_copy.keys())}")
|
||||||
|
|
||||||
|
for instance_id, instance_data in instances_copy.items():
|
||||||
|
try:
|
||||||
|
player = instance_data["player"]
|
||||||
|
instance = instance_data["instance"]
|
||||||
|
|
||||||
|
print(f"Stopping instance {instance_id}")
|
||||||
|
|
||||||
|
# Force stop the player regardless of state
|
||||||
|
player.stop()
|
||||||
|
|
||||||
|
# Give VLC a moment to process the stop command
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
# Release the media player and instance
|
||||||
|
player.release()
|
||||||
|
instance.release()
|
||||||
|
|
||||||
|
print(f"Successfully stopped instance {instance_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Log the error but continue stopping other instances
|
||||||
|
print(f"Error stopping instance {instance_id}: {e}")
|
||||||
|
|
||||||
|
# Clear all instances
|
||||||
|
self.instances.clear()
|
||||||
|
print(f"Cleared all instances. Remaining: {len(self.instances)}")
|
||||||
|
|
||||||
|
def get_playing_count(self) -> int:
|
||||||
|
"""Get number of currently playing sounds."""
|
||||||
|
with self.lock:
|
||||||
|
return len(self.instances)
|
||||||
|
|
||||||
|
def force_stop_all(self) -> int:
|
||||||
|
"""Force stop all sounds and clean up resources. Returns count of stopped instances."""
|
||||||
|
with self.lock:
|
||||||
|
stopped_count = len(self.instances)
|
||||||
|
print(f"Force stopping {stopped_count} instances: {list(self.instances.keys())}")
|
||||||
|
|
||||||
|
# More aggressive cleanup
|
||||||
|
for instance_id, instance_data in list(self.instances.items()):
|
||||||
|
try:
|
||||||
|
player = instance_data["player"]
|
||||||
|
instance = instance_data["instance"]
|
||||||
|
|
||||||
|
print(f"Force stopping instance {instance_id}")
|
||||||
|
|
||||||
|
# Multiple stop attempts
|
||||||
|
for attempt in range(3):
|
||||||
|
if hasattr(player, 'stop'):
|
||||||
|
player.stop()
|
||||||
|
print(f"Stop attempt {attempt + 1} for {instance_id}")
|
||||||
|
time.sleep(0.05) # Short delay between attempts
|
||||||
|
|
||||||
|
# Force release
|
||||||
|
if hasattr(player, 'release'):
|
||||||
|
player.release()
|
||||||
|
print(f"Released player for {instance_id}")
|
||||||
|
if hasattr(instance, 'release'):
|
||||||
|
instance.release()
|
||||||
|
print(f"Released instance for {instance_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error force-stopping instance {instance_id}: {e}")
|
||||||
|
finally:
|
||||||
|
# Always remove from tracking
|
||||||
|
if instance_id in self.instances:
|
||||||
|
del self.instances[instance_id]
|
||||||
|
print(f"Removed {instance_id} from tracking")
|
||||||
|
|
||||||
|
print(f"Force stop completed. Instances remaining: {len(self.instances)}")
|
||||||
|
return stopped_count
|
||||||
|
|
||||||
|
|
||||||
|
# Global VLC service instance
|
||||||
|
vlc_service = VLCService()
|
||||||
@@ -16,6 +16,7 @@ dependencies = [
|
|||||||
"flask-sqlalchemy==3.1.1",
|
"flask-sqlalchemy==3.1.1",
|
||||||
"pydub==0.25.1",
|
"pydub==0.25.1",
|
||||||
"python-dotenv==1.1.1",
|
"python-dotenv==1.1.1",
|
||||||
|
"python-vlc>=3.0.0",
|
||||||
"requests==2.32.4",
|
"requests==2.32.4",
|
||||||
"werkzeug==3.1.3",
|
"werkzeug==3.1.3",
|
||||||
]
|
]
|
||||||
|
|||||||
11
uv.lock
generated
11
uv.lock
generated
@@ -526,6 +526,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556 },
|
{ url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-vlc"
|
||||||
|
version = "3.0.21203"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4b/5b/f9ce6f0c9877b6fe5eafbade55e0dcb6b2b30f1c2c95837aef40e390d63b/python_vlc-3.0.21203.tar.gz", hash = "sha256:52d0544b276b11e58b6c0b748c3e0518f94f74b1b4cd328c83a59eacabead1ec", size = 162211 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/ee/7d76eb3b50ccb1397621f32ede0fb4d17aa55a9aa2251bc34e6b9929fdce/python_vlc-3.0.21203-py3-none-any.whl", hash = "sha256:1613451a31b692ec276296ceeae0c0ba82bfc2d094dabf9aceb70f58944a6320", size = 87651 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "requests"
|
name = "requests"
|
||||||
version = "2.32.4"
|
version = "2.32.4"
|
||||||
@@ -581,6 +590,7 @@ dependencies = [
|
|||||||
{ name = "flask-sqlalchemy" },
|
{ name = "flask-sqlalchemy" },
|
||||||
{ name = "pydub" },
|
{ name = "pydub" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "python-vlc" },
|
||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
{ name = "werkzeug" },
|
{ name = "werkzeug" },
|
||||||
]
|
]
|
||||||
@@ -604,6 +614,7 @@ requires-dist = [
|
|||||||
{ name = "flask-sqlalchemy", specifier = "==3.1.1" },
|
{ name = "flask-sqlalchemy", specifier = "==3.1.1" },
|
||||||
{ name = "pydub", specifier = "==0.25.1" },
|
{ name = "pydub", specifier = "==0.25.1" },
|
||||||
{ name = "python-dotenv", specifier = "==1.1.1" },
|
{ name = "python-dotenv", specifier = "==1.1.1" },
|
||||||
|
{ name = "python-vlc", specifier = ">=3.0.0" },
|
||||||
{ name = "requests", specifier = "==2.32.4" },
|
{ name = "requests", specifier = "==2.32.4" },
|
||||||
{ name = "werkzeug", specifier = "==3.1.3" },
|
{ name = "werkzeug", specifier = "==3.1.3" },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user