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:
JSC
2025-07-03 21:25:50 +02:00
parent 8f17dd730a
commit 7455811860
20 changed files with 760 additions and 91 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

236
app/routes/admin_sounds.py Normal file
View 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

View File

@@ -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:

View File

@@ -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
View 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

View File

@@ -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:

View File

@@ -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:

View File

@@ -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."""

View File

@@ -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()

View File

@@ -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:

View File

@@ -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()

View File

@@ -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
@@ -281,7 +296,9 @@ 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.
@@ -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
@@ -370,26 +389,27 @@ 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
@@ -398,7 +418,7 @@ class SoundNormalizerService:
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,7 +430,12 @@ 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:
@@ -452,7 +477,9 @@ 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 ""
@@ -460,10 +487,7 @@ class SoundNormalizerService:
# 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)}"
@@ -485,7 +509,9 @@ class SoundNormalizerService:
""" """
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 {}
@@ -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

View File

@@ -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
@@ -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
View 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()

View File

@@ -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
View File

@@ -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" },
] ]