Compare commits

...

4 Commits

Author SHA1 Message Date
JSC
c3b8205f83 feat(sound_played): add sound play tracking and user statistics endpoints; enhance VLC service to record play events 2025-07-03 21:50:17 +02:00
JSC
97b998fd9e feat(vlc_service): refactor VLC service to use subprocess for sound playback and management; update process tracking 2025-07-03 21:36:42 +02:00
JSC
7455811860 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.
2025-07-03 21:25:50 +02:00
JSC
8f17dd730a feat(admin_routes): add admin routes for scheduler and sound management; refactor main routes 2025-07-03 20:24:13 +02:00
20 changed files with 1224 additions and 173 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,10 +69,13 @@ def create_app():
scheduler_service.start() scheduler_service.start()
# Register blueprints # Register blueprints
from app.routes import auth, main from app.routes import admin, admin_sounds, auth, main, soundboard
app.register_blueprint(main.bp, url_prefix="/api") app.register_blueprint(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_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

@@ -13,6 +13,6 @@ def init_db(app):
migrate.init_app(app, db) migrate.init_app(app, db)
# Import models here to ensure they are registered with SQLAlchemy # Import models here to ensure they are registered with SQLAlchemy
from app.models import user, user_oauth # noqa: F401 from app.models import user, user_oauth, sound_played # noqa: F401
return db return db

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

238
app/models/sound_played.py Normal file
View File

@@ -0,0 +1,238 @@
"""Sound played tracking model."""
from datetime import datetime
from typing import List, Optional
from app.database import db
from sqlalchemy import DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
class SoundPlayed(db.Model):
"""Model to track when users play sounds."""
__tablename__ = "sound_played"
id: Mapped[int] = mapped_column(primary_key=True)
# Foreign keys
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id"), nullable=False
)
sound_id: Mapped[int] = mapped_column(
Integer, ForeignKey("sounds.id"), nullable=False
)
# Additional context
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
user_agent: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Timestamp
played_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, nullable=False
)
# Relationships
user: Mapped["User"] = relationship("User", backref="sounds_played")
sound: Mapped["Sound"] = relationship("Sound", backref="play_history")
def __repr__(self) -> str:
"""String representation of SoundPlayed."""
return f"<SoundPlayed user_id={self.user_id} sound_id={self.sound_id} at={self.played_at}>"
def to_dict(self) -> dict:
"""Convert sound played record to dictionary."""
return {
"id": self.id,
"user_id": self.user_id,
"sound_id": self.sound_id,
"ip_address": self.ip_address,
"user_agent": self.user_agent,
"played_at": self.played_at.isoformat(),
"user": {
"id": self.user.id,
"name": self.user.name,
"email": self.user.email,
} if self.user else None,
"sound": {
"id": self.sound.id,
"name": self.sound.name,
"filename": self.sound.filename,
"type": self.sound.type,
} if self.sound else None,
}
@classmethod
def create_play_record(
cls,
user_id: int,
sound_id: int,
ip_address: str | None = None,
user_agent: str | None = None,
commit: bool = True,
) -> "SoundPlayed":
"""Create a new sound played record."""
play_record = cls(
user_id=user_id,
sound_id=sound_id,
ip_address=ip_address,
user_agent=user_agent,
)
db.session.add(play_record)
if commit:
db.session.commit()
return play_record
@classmethod
def get_user_plays(
cls, user_id: int, limit: int = 50, offset: int = 0
) -> List["SoundPlayed"]:
"""Get recent plays for a specific user."""
return (
cls.query.filter_by(user_id=user_id)
.order_by(cls.played_at.desc())
.offset(offset)
.limit(limit)
.all()
)
@classmethod
def get_sound_plays(
cls, sound_id: int, limit: int = 50, offset: int = 0
) -> List["SoundPlayed"]:
"""Get recent plays for a specific sound."""
return (
cls.query.filter_by(sound_id=sound_id)
.order_by(cls.played_at.desc())
.offset(offset)
.limit(limit)
.all()
)
@classmethod
def get_recent_plays(
cls, limit: int = 100, offset: int = 0
) -> List["SoundPlayed"]:
"""Get recent plays across all users and sounds."""
return (
cls.query.order_by(cls.played_at.desc())
.offset(offset)
.limit(limit)
.all()
)
@classmethod
def get_user_play_count(cls, user_id: int) -> int:
"""Get total play count for a user."""
return cls.query.filter_by(user_id=user_id).count()
@classmethod
def get_sound_play_count(cls, sound_id: int) -> int:
"""Get total play count for a sound."""
return cls.query.filter_by(sound_id=sound_id).count()
@classmethod
def get_popular_sounds(
cls, limit: int = 10, days: int | None = None
) -> List[dict]:
"""Get most popular sounds with play counts."""
from sqlalchemy import func, text
query = (
db.session.query(
cls.sound_id,
func.count(cls.id).label("play_count"),
func.max(cls.played_at).label("last_played"),
)
.group_by(cls.sound_id)
.order_by(func.count(cls.id).desc())
)
if days:
query = query.filter(
cls.played_at >= text(f"datetime('now', '-{days} days')")
)
results = query.limit(limit).all()
# Get sound details
popular_sounds = []
for result in results:
from app.models.sound import Sound
sound = Sound.query.get(result.sound_id)
if sound:
popular_sounds.append({
"sound": sound.to_dict(),
"play_count": result.play_count,
"last_played": result.last_played.isoformat() if result.last_played else None,
})
return popular_sounds
@classmethod
def get_user_stats(cls, user_id: int) -> dict:
"""Get comprehensive stats for a user."""
from sqlalchemy import func
total_plays = cls.query.filter_by(user_id=user_id).count()
if total_plays == 0:
return {
"total_plays": 0,
"unique_sounds": 0,
"favorite_sound": None,
"first_play": None,
"last_play": None,
}
# Get unique sounds count
unique_sounds = (
db.session.query(cls.sound_id)
.filter_by(user_id=user_id)
.distinct()
.count()
)
# Get favorite sound
favorite_query = (
db.session.query(
cls.sound_id, func.count(cls.id).label("play_count")
)
.filter_by(user_id=user_id)
.group_by(cls.sound_id)
.order_by(func.count(cls.id).desc())
.first()
)
favorite_sound = None
if favorite_query:
from app.models.sound import Sound
sound = Sound.query.get(favorite_query.sound_id)
if sound:
favorite_sound = {
"sound": sound.to_dict(),
"play_count": favorite_query.play_count,
}
# Get first and last play dates
first_play = (
cls.query.filter_by(user_id=user_id)
.order_by(cls.played_at.asc())
.first()
)
last_play = (
cls.query.filter_by(user_id=user_id)
.order_by(cls.played_at.desc())
.first()
)
return {
"total_plays": total_plays,
"unique_sounds": unique_sounds,
"favorite_sound": favorite_sound,
"first_play": first_play.played_at.isoformat() if first_play else None,
"last_play": last_play.played_at.isoformat() if last_play else None,
}

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

95
app/routes/admin.py Normal file
View File

@@ -0,0 +1,95 @@
"""Admin routes for the application."""
from flask import Blueprint, request
from app.services.decorators import (
get_current_user,
require_auth,
require_role,
)
from app.services.scheduler_service import scheduler_service
from app.services.sound_normalizer_service import SoundNormalizerService
from app.services.sound_scanner_service import SoundScannerService
bp = Blueprint("admin", __name__)
@bp.route("/")
@require_auth
@require_role("admin")
def admin_only() -> dict[str, str]:
"""Admin-only endpoint to demonstrate role-based access."""
user = get_current_user()
return {
"message": f"Hello admin {user['name']}, you have admin access!",
"user": user,
"admin_info": "This endpoint is only accessible to admin users",
}
@bp.route("/scheduler/status")
@require_auth
@require_role("admin")
def scheduler_status() -> dict:
"""Get scheduler status (admin only)."""
return scheduler_service.get_scheduler_status()
@bp.route("/credits/refill", methods=["POST"])
@require_auth
@require_role("admin")
def manual_credit_refill() -> dict:
"""Manually trigger credit refill for all users (admin only)."""
return scheduler_service.trigger_credit_refill_now()
@bp.route("/sounds/scan", methods=["POST"])
@require_auth
@require_role("admin")
def manual_sound_scan() -> dict:
"""Manually trigger sound directory scan (admin only)."""
return scheduler_service.trigger_sound_scan_now()
@bp.route("/sounds/stats")
@require_auth
@require_role("admin")
def sound_statistics() -> dict:
"""Get sound database statistics (admin only)."""
return SoundScannerService.get_scan_statistics()
@bp.route("/sounds/normalize/<int:sound_id>", methods=["POST"])
@require_auth
@require_role("admin")
def normalize_sound(sound_id: int) -> dict:
"""Normalize a specific sound file (admin only)."""
overwrite = request.args.get("overwrite", "false").lower() == "true"
return SoundNormalizerService.normalize_sound(sound_id, overwrite)
@bp.route("/sounds/normalize-all", methods=["POST"])
@require_auth
@require_role("admin")
def normalize_all_sounds() -> dict:
"""Normalize all soundboard files (admin only)."""
overwrite = request.args.get("overwrite", "false").lower() == "true"
limit_str = request.args.get("limit")
limit = int(limit_str) if limit_str else None
return SoundNormalizerService.normalize_all_sounds(overwrite, limit)
@bp.route("/sounds/normalization-status")
@require_auth
@require_role("admin")
def normalization_status() -> dict:
"""Get normalization status statistics (admin only)."""
return SoundNormalizerService.get_normalization_status()
@bp.route("/sounds/ffmpeg-check")
@require_auth
@require_role("admin")
def ffmpeg_check() -> dict:
"""Check ffmpeg availability and capabilities (admin only)."""
return SoundNormalizerService.check_ffmpeg_availability()

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

@@ -1,16 +1,12 @@
"""Main routes for the application.""" """Main routes for the application."""
from flask import Blueprint, request from flask import Blueprint
from app.services.decorators import ( from app.services.decorators import (
get_current_user, get_current_user,
require_auth, require_auth,
require_credits, require_credits,
require_role,
) )
from app.services.scheduler_service import scheduler_service
from app.services.sound_normalizer_service import SoundNormalizerService
from app.services.sound_scanner_service import SoundScannerService
bp = Blueprint("main", __name__) bp = Blueprint("main", __name__)
@@ -43,19 +39,6 @@ def api_protected() -> dict[str, str]:
} }
@bp.route("/admin")
@require_auth
@require_role("admin")
def admin_only() -> dict[str, str]:
"""Admin-only endpoint to demonstrate role-based access."""
user = get_current_user()
return {
"message": f"Hello admin {user['name']}, you have admin access!",
"user": user,
"admin_info": "This endpoint is only accessible to admin users",
}
@bp.route("/health") @bp.route("/health")
def health() -> dict[str, str]: def health() -> dict[str, str]:
"""Health check endpoint.""" """Health check endpoint."""
@@ -87,71 +70,3 @@ def expensive_operation() -> dict[str, str]:
"user": user["email"], "user": user["email"],
"operation_cost": 10, "operation_cost": 10,
} }
@bp.route("/admin/scheduler/status")
@require_auth
@require_role("admin")
def scheduler_status() -> dict:
"""Get scheduler status (admin only)."""
return scheduler_service.get_scheduler_status()
@bp.route("/admin/credits/refill", methods=["POST"])
@require_auth
@require_role("admin")
def manual_credit_refill() -> dict:
"""Manually trigger credit refill for all users (admin only)."""
return scheduler_service.trigger_credit_refill_now()
@bp.route("/admin/sounds/scan", methods=["POST"])
@require_auth
@require_role("admin")
def manual_sound_scan() -> dict:
"""Manually trigger sound directory scan (admin only)."""
return scheduler_service.trigger_sound_scan_now()
@bp.route("/admin/sounds/stats")
@require_auth
@require_role("admin")
def sound_statistics() -> dict:
"""Get sound database statistics (admin only)."""
return SoundScannerService.get_scan_statistics()
@bp.route("/admin/sounds/normalize/<int:sound_id>", methods=["POST"])
@require_auth
@require_role("admin")
def normalize_sound(sound_id: int) -> dict:
"""Normalize a specific sound file (admin only)."""
overwrite = request.args.get("overwrite", "false").lower() == "true"
return SoundNormalizerService.normalize_sound(sound_id, overwrite)
@bp.route("/admin/sounds/normalize-all", methods=["POST"])
@require_auth
@require_role("admin")
def normalize_all_sounds() -> dict:
"""Normalize all soundboard files (admin only)."""
overwrite = request.args.get("overwrite", "false").lower() == "true"
limit_str = request.args.get("limit")
limit = int(limit_str) if limit_str else None
return SoundNormalizerService.normalize_all_sounds(overwrite, limit)
@bp.route("/admin/sounds/normalization-status")
@require_auth
@require_role("admin")
def normalization_status() -> dict:
"""Get normalization status statistics (admin only)."""
return SoundNormalizerService.get_normalization_status()
@bp.route("/admin/sounds/ffmpeg-check")
@require_auth
@require_role("admin")
def ffmpeg_check() -> dict:
"""Check ffmpeg availability and capabilities (admin only)."""
return SoundNormalizerService.check_ffmpeg_availability()

218
app/routes/soundboard.py Normal file
View File

@@ -0,0 +1,218 @@
"""Soundboard routes."""
from flask import Blueprint, jsonify, request
from app.models.sound import Sound, SoundType
from app.models.sound_played import SoundPlayed
from app.services.vlc_service import vlc_service
from app.services.decorators import require_auth, get_current_user
bp = Blueprint("soundboard", __name__, url_prefix="/api/soundboard")
@bp.route("/sounds", methods=["GET"])
@require_auth
def get_sounds():
"""Get all soundboard sounds."""
try:
# Get filter parameters
sound_type = request.args.get("type", "SDB")
# Validate sound type
if sound_type not in [t.value for t in SoundType]:
return jsonify({"error": "Invalid sound type"}), 400
# Get sounds from database
sounds = Sound.find_by_type(sound_type)
# Convert to dict format
sounds_data = [sound.to_dict() for sound in sounds]
return jsonify(
{
"sounds": sounds_data,
"total": len(sounds_data),
"type": sound_type,
}
)
except Exception as e:
return jsonify({"error": str(e)}), 500
@bp.route("/sounds/<int:sound_id>/play", methods=["POST"])
@require_auth
def play_sound(sound_id: int):
"""Play a specific sound."""
try:
# Get current user for tracking
user = get_current_user()
user_id = int(user["id"]) if user else None
# Get client information
ip_address = request.remote_addr
user_agent = request.headers.get("User-Agent")
success = vlc_service.play_sound(
sound_id=sound_id,
user_id=user_id,
ip_address=ip_address,
user_agent=user_agent,
)
if success:
return jsonify({"message": "Sound playing", "sound_id": sound_id})
else:
return jsonify(
{"error": "Sound not found or cannot be played"}
), 404
except Exception as e:
return jsonify({"error": str(e)}), 500
@bp.route("/stop-all", methods=["POST"])
@require_auth
def stop_all_sounds():
"""Stop all currently playing sounds."""
try:
# Try normal stop first
vlc_service.stop_all()
# Wait a moment and check if any are still playing
import time
time.sleep(0.2)
# If there are still instances, force stop them
if vlc_service.get_playing_count() > 0:
stopped_count = vlc_service.force_stop_all()
return jsonify({
"message": f"Force stopped {stopped_count} sounds",
"forced": True
})
return jsonify({"message": "All sounds stopped"})
except Exception as e:
return jsonify({"error": str(e)}), 500
@bp.route("/force-stop", methods=["POST"])
@require_auth
def force_stop_all_sounds():
"""Force stop all sounds with aggressive cleanup."""
try:
stopped_count = vlc_service.force_stop_all()
return jsonify({
"message": f"Force stopped {stopped_count} sound instances",
"stopped_count": stopped_count
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@bp.route("/status", methods=["GET"])
@require_auth
def get_status():
"""Get current playback status."""
try:
playing_count = vlc_service.get_playing_count()
# Get detailed process information
with vlc_service.lock:
processes = []
for process_id, process in vlc_service.processes.items():
processes.append({
"id": process_id,
"pid": process.pid,
"running": process.poll() is None,
})
return jsonify(
{
"playing_count": playing_count,
"is_playing": playing_count > 0,
"processes": processes,
}
)
except Exception as e:
return jsonify({"error": str(e)}), 500
@bp.route("/history", methods=["GET"])
@require_auth
def get_play_history():
"""Get recent play history."""
try:
page = int(request.args.get("page", 1))
per_page = min(int(request.args.get("per_page", 50)), 100)
offset = (page - 1) * per_page
recent_plays = SoundPlayed.get_recent_plays(limit=per_page, offset=offset)
return jsonify({
"plays": [play.to_dict() for play in recent_plays],
"page": page,
"per_page": per_page,
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@bp.route("/my-history", methods=["GET"])
@require_auth
def get_my_play_history():
"""Get current user's play history."""
try:
user = get_current_user()
if not user:
return jsonify({"error": "User not found"}), 404
user_id = int(user["id"])
page = int(request.args.get("page", 1))
per_page = min(int(request.args.get("per_page", 50)), 100)
offset = (page - 1) * per_page
user_plays = SoundPlayed.get_user_plays(user_id=user_id, limit=per_page, offset=offset)
return jsonify({
"plays": [play.to_dict() for play in user_plays],
"page": page,
"per_page": per_page,
"user_id": user_id,
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@bp.route("/my-stats", methods=["GET"])
@require_auth
def get_my_stats():
"""Get current user's play statistics."""
try:
user = get_current_user()
if not user:
return jsonify({"error": "User not found"}), 404
user_id = int(user["id"])
stats = SoundPlayed.get_user_stats(user_id)
return jsonify(stats)
except Exception as e:
return jsonify({"error": str(e)}), 500
@bp.route("/popular", methods=["GET"])
@require_auth
def get_popular_sounds():
"""Get most popular sounds."""
try:
limit = min(int(request.args.get("limit", 10)), 50)
days = request.args.get("days")
days = int(days) if days and days.isdigit() else None
popular_sounds = SoundPlayed.get_popular_sounds(limit=limit, days=days)
return jsonify({
"popular_sounds": popular_sounds,
"limit": limit,
"days": days,
})
except Exception as e:
return jsonify({"error": str(e)}), 500

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:

238
app/services/vlc_service.py Normal file
View File

@@ -0,0 +1,238 @@
"""VLC service for playing sounds using subprocess."""
import os
import signal
import subprocess
import threading
import time
from typing import Dict, List, Optional
from app.database import db
from app.models.sound import Sound
from app.models.sound_played import SoundPlayed
class VLCService:
"""Service for playing sounds using VLC subprocess."""
def __init__(self) -> None:
"""Initialize VLC service."""
self.processes: Dict[str, subprocess.Popen] = {}
self.lock = threading.Lock()
def play_sound(self, sound_id: int, user_id: int | None = None, ip_address: str | None = None, user_agent: str | None = None) -> bool:
"""Play a sound by ID using VLC subprocess."""
try:
# Get sound from database
sound = Sound.query.get(sound_id)
if not sound:
return False
# Use normalized file if available, otherwise use original
if sound.is_normalized and sound.normalized_filename:
sound_path = os.path.join(
"sounds",
"normalized",
"soundboard",
sound.normalized_filename,
)
else:
sound_path = os.path.join(
"sounds", "soundboard", sound.filename
)
# Check if file exists
if not os.path.exists(sound_path):
return False
# Convert to absolute path
sound_path = os.path.abspath(sound_path)
# Create unique process ID
process_id = f"sound_{sound_id}_{int(time.time() * 1000000)}"
# Start VLC process
vlc_cmd = [
"vlc",
sound_path,
"--intf",
"dummy", # No interface
"--play-and-exit", # Exit after playing
"--no-video", # Audio only
"--quiet", # Reduce output
]
process = subprocess.Popen(
vlc_cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
preexec_fn=os.setsid, # Create new process group
)
# Store process for tracking
with self.lock:
self.processes[process_id] = process
print(
f"Started VLC process {process.pid} ({process_id}) for sound {sound.name}. Total processes: {len(self.processes)}"
)
# Increment play count
sound.increment_play_count()
# Record play event if user is provided
if user_id:
try:
SoundPlayed.create_play_record(
user_id=user_id,
sound_id=sound_id,
ip_address=ip_address,
user_agent=user_agent,
commit=True,
)
except Exception as e:
print(f"Error recording play event: {e}")
# Schedule cleanup after sound duration
threading.Thread(
target=self._cleanup_after_playback,
args=(process_id, sound.duration if sound.duration else 10000),
daemon=True,
).start()
return True
except Exception as e:
print(f"Error starting VLC process for sound {sound_id}: {e}")
return False
def _cleanup_after_playback(self, process_id: str, duration: int) -> None:
"""Clean up VLC process after playback."""
# Wait for playback to finish (duration + 1 second buffer)
time.sleep(duration / 1000 + 1) # Convert ms to seconds
with self.lock:
if process_id in self.processes:
print(f"Cleaning up process {process_id} after playback")
process = self.processes[process_id]
try:
# Check if process is still running
if process.poll() is None:
print(
f"Process {process.pid} still running, terminating"
)
process.terminate()
# Give it a moment to terminate gracefully
try:
process.wait(timeout=2)
except subprocess.TimeoutExpired:
print(
f"Process {process.pid} didn't terminate, killing"
)
process.kill()
print(f"Successfully cleaned up process {process_id}")
except Exception as e:
print(f"Error during cleanup of {process_id}: {e}")
finally:
# Always remove from tracking
del self.processes[process_id]
print(
f"Removed process {process_id}. Remaining processes: {len(self.processes)}"
)
else:
print(f"Process {process_id} not found during cleanup")
def stop_all(self) -> None:
"""Stop all playing sounds by killing VLC processes."""
with self.lock:
processes_copy = dict(self.processes)
print(
f"Stopping {len(processes_copy)} VLC processes: {list(processes_copy.keys())}"
)
for process_id, process in processes_copy.items():
try:
if process.poll() is None: # Process is still running
print(
f"Terminating process {process.pid} ({process_id})"
)
process.terminate()
# Give it a moment to terminate gracefully
try:
process.wait(timeout=1)
print(
f"Process {process.pid} terminated gracefully"
)
except subprocess.TimeoutExpired:
print(
f"Process {process.pid} didn't terminate, killing forcefully"
)
process.kill()
process.wait() # Wait for it to be killed
else:
print(
f"Process {process.pid} ({process_id}) already finished"
)
except Exception as e:
print(f"Error stopping process {process_id}: {e}")
# Clear all processes
self.processes.clear()
print(f"Cleared all processes. Remaining: {len(self.processes)}")
def get_playing_count(self) -> int:
"""Get number of currently playing sounds."""
with self.lock:
# Clean up finished processes and return count
finished_processes = []
for process_id, process in self.processes.items():
if process.poll() is not None: # Process has finished
finished_processes.append(process_id)
# Remove finished processes
for process_id in finished_processes:
del self.processes[process_id]
return len(self.processes)
def force_stop_all(self) -> int:
"""Force stop all sounds by killing VLC processes aggressively."""
with self.lock:
stopped_count = len(self.processes)
print(f"Force stopping {stopped_count} VLC processes")
# # Kill all VLC processes aggressively
# for process_id, process in list(self.processes.items()):
# try:
# if process.poll() is None: # Process is still running
# print(f"Force killing process {process.pid} ({process_id})")
# process.kill()
# process.wait() # Wait for it to be killed
# print(f"Process {process.pid} killed")
# else:
# print(f"Process {process.pid} ({process_id}) already finished")
# except Exception as e:
# print(f"Error force-stopping process {process_id}: {e}")
# Also try to kill any remaining VLC processes system-wide
try:
subprocess.run(["pkill", "-f", "vlc"], check=False)
print("Killed any remaining VLC processes system-wide")
except Exception as e:
print(f"Error killing system VLC processes: {e}")
# Clear all processes
self.processes.clear()
print(
f"Force stop completed. Processes remaining: {len(self.processes)}"
)
return stopped_count
# Global VLC service instance
vlc_service = VLCService()