Add new sound files and update dependencies
- Added various sound files to the soundboard, including insults, quotes, and sound effects. - Introduced new dependencies: ffmpeg-python (version 0.2.0) and pydub (version 0.25.1) for audio processing. - Updated the lock file to reflect the new packages and their respective versions. - Added .gitignore files in the sounds/stream and sounds/temp directories to exclude unnecessary files.
This commit is contained in:
@@ -61,6 +61,9 @@ def create_app():
|
|||||||
# Initialize authentication service with app
|
# Initialize authentication service with app
|
||||||
auth_service.init_app(app)
|
auth_service.init_app(app)
|
||||||
|
|
||||||
|
# Initialize scheduler service with app
|
||||||
|
scheduler_service.app = app
|
||||||
|
|
||||||
# Start scheduler for background tasks
|
# Start scheduler for background tasks
|
||||||
scheduler_service.start()
|
scheduler_service.start()
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"""Database models."""
|
"""Database models."""
|
||||||
|
|
||||||
from .plan import Plan
|
from .plan import Plan
|
||||||
|
from .sound import Sound
|
||||||
from .user import User
|
from .user import User
|
||||||
from .user_oauth import UserOAuth
|
from .user_oauth import UserOAuth
|
||||||
|
|
||||||
__all__ = ["Plan", "User", "UserOAuth"]
|
__all__ = ["Plan", "Sound", "User", "UserOAuth"]
|
||||||
|
|||||||
225
app/models/sound.py
Normal file
225
app/models/sound.py
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
"""Sound model for storing sound file information."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.database import db
|
||||||
|
from sqlalchemy import Boolean, DateTime, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
|
||||||
|
class SoundType(Enum):
|
||||||
|
"""Sound type enumeration."""
|
||||||
|
|
||||||
|
SDB = "SDB" # Soundboard sound
|
||||||
|
SAY = "SAY" # Text-to-speech
|
||||||
|
STR = "STR" # Stream sound
|
||||||
|
|
||||||
|
|
||||||
|
class Sound(db.Model):
|
||||||
|
"""Sound model for storing sound file information."""
|
||||||
|
|
||||||
|
__tablename__ = "sounds"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
|
||||||
|
# Sound type (SDB, SAY, or STR)
|
||||||
|
type: Mapped[str] = mapped_column(String(3), nullable=False)
|
||||||
|
|
||||||
|
# Basic sound information
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
filename: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
|
duration: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
size: Mapped[int] = mapped_column(Integer, nullable=False) # Size in bytes
|
||||||
|
hash: Mapped[str] = mapped_column(String(64), nullable=False) # SHA256 hash
|
||||||
|
|
||||||
|
# Normalized sound information
|
||||||
|
normalized_filename: Mapped[str | None] = mapped_column(
|
||||||
|
String(500),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
normalized_duration: Mapped[int | None] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
normalized_size: Mapped[int | None] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
normalized_hash: Mapped[str | None] = mapped_column(
|
||||||
|
String(64),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sound properties
|
||||||
|
is_normalized: Mapped[bool] = mapped_column(
|
||||||
|
Boolean,
|
||||||
|
nullable=False,
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
is_music: Mapped[bool] = mapped_column(
|
||||||
|
Boolean,
|
||||||
|
nullable=False,
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
is_deletable: Mapped[bool] = mapped_column(
|
||||||
|
Boolean,
|
||||||
|
nullable=False,
|
||||||
|
default=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Usage tracking
|
||||||
|
play_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
default=datetime.utcnow,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
default=datetime.utcnow,
|
||||||
|
onupdate=datetime.utcnow,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
"""String representation of Sound."""
|
||||||
|
return f"<Sound {self.name} ({self.type}) - {self.play_count} plays>"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert sound to dictionary."""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"type": self.type,
|
||||||
|
"name": self.name,
|
||||||
|
"filename": self.filename,
|
||||||
|
"duration": self.duration,
|
||||||
|
"size": self.size,
|
||||||
|
"hash": self.hash,
|
||||||
|
"normalized_filename": self.normalized_filename,
|
||||||
|
"normalized_duration": self.normalized_duration,
|
||||||
|
"normalized_size": self.normalized_size,
|
||||||
|
"normalized_hash": self.normalized_hash,
|
||||||
|
"is_normalized": self.is_normalized,
|
||||||
|
"is_music": self.is_music,
|
||||||
|
"is_deletable": self.is_deletable,
|
||||||
|
"play_count": self.play_count,
|
||||||
|
"created_at": self.created_at.isoformat(),
|
||||||
|
"updated_at": self.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def increment_play_count(self) -> None:
|
||||||
|
"""Increment the play count for this sound."""
|
||||||
|
self.play_count += 1
|
||||||
|
self.updated_at = datetime.utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
def set_normalized_info(
|
||||||
|
self,
|
||||||
|
normalized_filename: str,
|
||||||
|
normalized_duration: float,
|
||||||
|
normalized_size: int,
|
||||||
|
normalized_hash: str,
|
||||||
|
) -> None:
|
||||||
|
"""Set normalized sound information."""
|
||||||
|
self.normalized_filename = normalized_filename
|
||||||
|
self.normalized_duration = normalized_duration
|
||||||
|
self.normalized_size = normalized_size
|
||||||
|
self.normalized_hash = normalized_hash
|
||||||
|
self.is_normalized = True
|
||||||
|
self.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
def clear_normalized_info(self) -> None:
|
||||||
|
"""Clear normalized sound information."""
|
||||||
|
self.normalized_filename = None
|
||||||
|
self.normalized_duration = None
|
||||||
|
self.normalized_hash = None
|
||||||
|
self.normalized_size = None
|
||||||
|
self.is_normalized = False
|
||||||
|
self.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
def update_file_info(
|
||||||
|
self,
|
||||||
|
filename: str,
|
||||||
|
duration: float,
|
||||||
|
size: int,
|
||||||
|
hash_value: str,
|
||||||
|
) -> None:
|
||||||
|
"""Update file information for existing sound."""
|
||||||
|
self.filename = filename
|
||||||
|
self.duration = duration
|
||||||
|
self.size = size
|
||||||
|
self.hash = hash_value
|
||||||
|
self.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_by_hash(cls, hash_value: str) -> Optional["Sound"]:
|
||||||
|
"""Find sound by hash."""
|
||||||
|
return cls.query.filter_by(hash=hash_value).first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_by_name(cls, name: str) -> Optional["Sound"]:
|
||||||
|
"""Find sound by name."""
|
||||||
|
return cls.query.filter_by(name=name).first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_by_filename(cls, filename: str) -> Optional["Sound"]:
|
||||||
|
"""Find sound by filename."""
|
||||||
|
return cls.query.filter_by(filename=filename).first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_by_type(cls, sound_type: str) -> list["Sound"]:
|
||||||
|
"""Find all sounds by type."""
|
||||||
|
return cls.query.filter_by(type=sound_type).all()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_most_played(cls, limit: int = 10) -> list["Sound"]:
|
||||||
|
"""Get the most played sounds."""
|
||||||
|
return cls.query.order_by(cls.play_count.desc()).limit(limit).all()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_music_sounds(cls) -> list["Sound"]:
|
||||||
|
"""Get all music sounds."""
|
||||||
|
return cls.query.filter_by(is_music=True).all()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_deletable_sounds(cls) -> list["Sound"]:
|
||||||
|
"""Get all deletable sounds."""
|
||||||
|
return cls.query.filter_by(is_deletable=True).all()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_sound(
|
||||||
|
cls,
|
||||||
|
sound_type: str,
|
||||||
|
name: str,
|
||||||
|
filename: str,
|
||||||
|
duration: float,
|
||||||
|
size: int,
|
||||||
|
hash_value: str,
|
||||||
|
is_music: bool = False,
|
||||||
|
is_deletable: bool = True,
|
||||||
|
commit: bool = True,
|
||||||
|
) -> "Sound":
|
||||||
|
"""Create a new sound."""
|
||||||
|
# Validate sound type
|
||||||
|
if sound_type not in [t.value for t in SoundType]:
|
||||||
|
raise ValueError(f"Invalid sound type: {sound_type}")
|
||||||
|
|
||||||
|
sound = cls(
|
||||||
|
type=sound_type,
|
||||||
|
name=name,
|
||||||
|
filename=filename,
|
||||||
|
duration=duration,
|
||||||
|
size=size,
|
||||||
|
hash=hash_value,
|
||||||
|
is_music=is_music,
|
||||||
|
is_deletable=is_deletable,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(sound)
|
||||||
|
if commit:
|
||||||
|
db.session.commit()
|
||||||
|
return sound
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Main routes for the application."""
|
"""Main routes for the application."""
|
||||||
|
|
||||||
from flask import Blueprint
|
from flask import Blueprint, request
|
||||||
|
|
||||||
from app.services.decorators import (
|
from app.services.decorators import (
|
||||||
get_current_user,
|
get_current_user,
|
||||||
@@ -9,6 +9,8 @@ from app.services.decorators import (
|
|||||||
require_role,
|
require_role,
|
||||||
)
|
)
|
||||||
from app.services.scheduler_service import scheduler_service
|
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__)
|
||||||
|
|
||||||
@@ -101,3 +103,55 @@ def scheduler_status() -> dict:
|
|||||||
def manual_credit_refill() -> dict:
|
def manual_credit_refill() -> dict:
|
||||||
"""Manually trigger credit refill for all users (admin only)."""
|
"""Manually trigger credit refill for all users (admin only)."""
|
||||||
return scheduler_service.trigger_credit_refill_now()
|
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()
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ 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
|
||||||
@@ -25,6 +24,7 @@ class CreditService:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Summary of the refill operation
|
dict: Summary of the refill operation
|
||||||
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get all active users with their plans
|
# Get all active users with their plans
|
||||||
@@ -36,7 +36,7 @@ class CreditService:
|
|||||||
"success": True,
|
"success": True,
|
||||||
"users_processed": 0,
|
"users_processed": 0,
|
||||||
"credits_added": 0,
|
"credits_added": 0,
|
||||||
"message": "No active users found"
|
"message": "No active users found",
|
||||||
}
|
}
|
||||||
|
|
||||||
users_processed = 0
|
users_processed = 0
|
||||||
@@ -63,12 +63,12 @@ class CreditService:
|
|||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"User {user.email}: {current_credits} -> {new_credits} "
|
f"User {user.email}: {current_credits} -> {new_credits} "
|
||||||
f"(+{credits_added} credits, plan: {user.plan.code})"
|
f"(+{credits_added} credits, plan: {user.plan.code})",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"User {user.email}: Already at max credits "
|
f"User {user.email}: Already at max credits "
|
||||||
f"({current_credits}/{max_credits})"
|
f"({current_credits}/{max_credits})",
|
||||||
)
|
)
|
||||||
|
|
||||||
users_processed += 1
|
users_processed += 1
|
||||||
@@ -78,39 +78,39 @@ class CreditService:
|
|||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Daily credit refill completed: {users_processed} users processed, "
|
f"Daily credit refill completed: {users_processed} users processed, "
|
||||||
f"{total_credits_added} total credits added"
|
f"{total_credits_added} total credits added",
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"users_processed": users_processed,
|
"users_processed": users_processed,
|
||||||
"credits_added": total_credits_added,
|
"credits_added": total_credits_added,
|
||||||
"message": f"Successfully refilled credits for {users_processed} users"
|
"message": f"Successfully refilled credits for {users_processed} users",
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Rollback transaction on error
|
# Rollback transaction on error
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
logger.error(f"Error during daily credit refill: {str(e)}")
|
logger.error(f"Error during daily credit refill: {e!s}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"users_processed": 0,
|
"users_processed": 0,
|
||||||
"credits_added": 0,
|
"credits_added": 0,
|
||||||
"error": str(e),
|
"error": str(e),
|
||||||
"message": "Credit refill failed"
|
"message": "Credit refill failed",
|
||||||
}
|
}
|
||||||
|
|
||||||
@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
|
||||||
|
|
||||||
"""
|
"""
|
||||||
user = User.query.get(user_id)
|
user = User.query.get(user_id)
|
||||||
if not user:
|
if not user:
|
||||||
@@ -127,7 +127,7 @@ class CreditService:
|
|||||||
"code": user.plan.code,
|
"code": user.plan.code,
|
||||||
"name": user.plan.name,
|
"name": user.plan.name,
|
||||||
"daily_credits": user.plan.credits,
|
"daily_credits": user.plan.credits,
|
||||||
"max_credits": user.plan.max_credits
|
"max_credits": user.plan.max_credits,
|
||||||
},
|
},
|
||||||
"is_active": user.is_active
|
"is_active": user.is_active,
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
"""Scheduler service for managing background tasks with APScheduler."""
|
"""Scheduler service for managing background tasks with APScheduler."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
from apscheduler.triggers.cron import CronTrigger
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
from app.services.credit_service import CreditService
|
from app.services.credit_service import CreditService
|
||||||
|
from app.services.sound_scanner_service import SoundScannerService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -14,9 +15,10 @@ logger = logging.getLogger(__name__)
|
|||||||
class SchedulerService:
|
class SchedulerService:
|
||||||
"""Service for managing scheduled background tasks."""
|
"""Service for managing scheduled background tasks."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self, app=None) -> None:
|
||||||
"""Initialize the scheduler service."""
|
"""Initialize the scheduler service."""
|
||||||
self.scheduler: Optional[BackgroundScheduler] = None
|
self.scheduler: BackgroundScheduler | None = None
|
||||||
|
self.app = app
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
"""Start the scheduler and add all scheduled jobs."""
|
"""Start the scheduler and add all scheduled jobs."""
|
||||||
@@ -29,6 +31,9 @@ class SchedulerService:
|
|||||||
# Add daily credit refill job
|
# Add daily credit refill job
|
||||||
self._add_daily_credit_refill_job()
|
self._add_daily_credit_refill_job()
|
||||||
|
|
||||||
|
# Add sound scanning job
|
||||||
|
self._add_sound_scanning_job()
|
||||||
|
|
||||||
# Start the scheduler
|
# Start the scheduler
|
||||||
self.scheduler.start()
|
self.scheduler.start()
|
||||||
logger.info("Scheduler started successfully")
|
logger.info("Scheduler started successfully")
|
||||||
@@ -58,10 +63,30 @@ class SchedulerService:
|
|||||||
|
|
||||||
logger.info("Daily credit refill job scheduled for 00:00 UTC")
|
logger.info("Daily credit refill job scheduled for 00:00 UTC")
|
||||||
|
|
||||||
|
def _add_sound_scanning_job(self) -> None:
|
||||||
|
"""Add the sound scanning job."""
|
||||||
|
if self.scheduler is None:
|
||||||
|
raise RuntimeError("Scheduler not initialized")
|
||||||
|
|
||||||
|
# Schedule every 5 minutes for sound scanning
|
||||||
|
trigger = CronTrigger(minute="*/5")
|
||||||
|
|
||||||
|
self.scheduler.add_job(
|
||||||
|
func=self._run_sound_scan,
|
||||||
|
trigger=trigger,
|
||||||
|
id="sound_scan",
|
||||||
|
name="Sound Directory Scan",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Sound scanning job scheduled every 5 minutes")
|
||||||
|
|
||||||
def _run_daily_credit_refill(self) -> None:
|
def _run_daily_credit_refill(self) -> None:
|
||||||
"""Execute the daily credit refill task."""
|
"""Execute the daily credit refill task."""
|
||||||
logger.info("Starting daily credit refill task")
|
logger.info("Starting daily credit refill task")
|
||||||
|
|
||||||
|
app = self.app or current_app
|
||||||
|
with app.app_context():
|
||||||
try:
|
try:
|
||||||
result = CreditService.refill_all_users_credits()
|
result = CreditService.refill_all_users_credits()
|
||||||
|
|
||||||
@@ -69,7 +94,7 @@ class SchedulerService:
|
|||||||
logger.info(
|
logger.info(
|
||||||
f"Daily credit refill completed successfully: "
|
f"Daily credit refill completed successfully: "
|
||||||
f"{result['users_processed']} users processed, "
|
f"{result['users_processed']} users processed, "
|
||||||
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']}")
|
||||||
@@ -77,25 +102,57 @@ class SchedulerService:
|
|||||||
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}")
|
||||||
|
|
||||||
|
def _run_sound_scan(self) -> None:
|
||||||
|
"""Execute the sound scanning task."""
|
||||||
|
logger.info("Starting sound directory scan")
|
||||||
|
|
||||||
|
app = self.app or current_app
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
result = SoundScannerService.scan_soundboard_directory()
|
||||||
|
|
||||||
|
if result["success"]:
|
||||||
|
if result["files_added"] > 0:
|
||||||
|
logger.info(
|
||||||
|
f"Sound scan completed: {result['files_added']} new sounds added",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug("Sound scan completed: no new files found")
|
||||||
|
else:
|
||||||
|
logger.error(f"Sound scan failed: {result.get('error', 'Unknown error')}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Error during sound scan: {e}")
|
||||||
|
|
||||||
def trigger_credit_refill_now(self) -> dict:
|
def trigger_credit_refill_now(self) -> dict:
|
||||||
"""Manually trigger credit refill for testing purposes."""
|
"""Manually trigger credit refill for testing purposes."""
|
||||||
logger.info("Manually triggering credit refill")
|
logger.info("Manually triggering credit refill")
|
||||||
|
app = self.app or current_app
|
||||||
|
with app.app_context():
|
||||||
return CreditService.refill_all_users_credits()
|
return CreditService.refill_all_users_credits()
|
||||||
|
|
||||||
|
def trigger_sound_scan_now(self) -> dict:
|
||||||
|
"""Manually trigger sound scan for testing purposes."""
|
||||||
|
logger.info("Manually triggering sound scan")
|
||||||
|
app = self.app or current_app
|
||||||
|
with app.app_context():
|
||||||
|
return SoundScannerService.scan_soundboard_directory()
|
||||||
|
|
||||||
def get_scheduler_status(self) -> dict:
|
def get_scheduler_status(self) -> dict:
|
||||||
"""Get the current status of the scheduler."""
|
"""Get the current status of the scheduler."""
|
||||||
if self.scheduler is None:
|
if self.scheduler is None:
|
||||||
return {"running": False, "jobs": []}
|
return {"running": False, "jobs": []}
|
||||||
|
|
||||||
jobs = []
|
jobs = [
|
||||||
for job in self.scheduler.get_jobs():
|
{
|
||||||
jobs.append({
|
|
||||||
"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()
|
||||||
|
]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"running": self.scheduler.running,
|
"running": self.scheduler.running,
|
||||||
|
|||||||
491
app/services/sound_normalizer_service.py
Normal file
491
app/services/sound_normalizer_service.py
Normal file
@@ -0,0 +1,491 @@
|
|||||||
|
"""Sound normalization service using ffmpeg loudnorm filter."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import ffmpeg
|
||||||
|
from pydub import AudioSegment
|
||||||
|
|
||||||
|
from app.database import db
|
||||||
|
from app.models.sound import Sound
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SoundNormalizerService:
|
||||||
|
"""Service for normalizing sound files using ffmpeg loudnorm."""
|
||||||
|
|
||||||
|
SUPPORTED_EXTENSIONS = {
|
||||||
|
".mp3",
|
||||||
|
".wav",
|
||||||
|
".ogg",
|
||||||
|
".flac",
|
||||||
|
".m4a",
|
||||||
|
".aac",
|
||||||
|
".opus",
|
||||||
|
}
|
||||||
|
SOUNDS_DIR = "sounds/soundboard"
|
||||||
|
NORMALIZED_DIR = "sounds/normalized/soundboard"
|
||||||
|
|
||||||
|
LOUDNORM_PARAMS = {
|
||||||
|
"integrated": -16,
|
||||||
|
"true_peak": -1.5,
|
||||||
|
"lra": 11.0,
|
||||||
|
"print_format": "summary",
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def normalize_sound(sound_id: int, overwrite: bool = False) -> dict:
|
||||||
|
"""Normalize a specific sound file using ffmpeg loudnorm.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sound_id: ID of the sound to normalize
|
||||||
|
overwrite: Whether to overwrite existing normalized file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Result of the normalization operation
|
||||||
|
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
sound = Sound.query.get(sound_id)
|
||||||
|
if not sound:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Sound with ID {sound_id} not found",
|
||||||
|
}
|
||||||
|
|
||||||
|
source_path = Path(SoundNormalizerService.SOUNDS_DIR) / sound.filename
|
||||||
|
if not source_path.exists():
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Source file not found: {source_path}",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Always output as WAV regardless of input format
|
||||||
|
filename_without_ext = Path(sound.filename).stem
|
||||||
|
normalized_filename = f"{filename_without_ext}.wav"
|
||||||
|
normalized_path = Path(SoundNormalizerService.NORMALIZED_DIR) / normalized_filename
|
||||||
|
|
||||||
|
normalized_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if normalized_path.exists() and not overwrite:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Normalized file already exists: {normalized_path}. Use overwrite=True to replace it.",
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Starting normalization of {sound.name} ({sound.filename})",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = SoundNormalizerService._normalize_with_ffmpeg(
|
||||||
|
str(source_path), str(normalized_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
if result["success"]:
|
||||||
|
# Calculate normalized file metadata
|
||||||
|
normalized_metadata = (
|
||||||
|
SoundNormalizerService._get_normalized_metadata(
|
||||||
|
str(normalized_path),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update sound record with normalized information
|
||||||
|
sound.set_normalized_info(
|
||||||
|
normalized_filename=normalized_filename,
|
||||||
|
normalized_duration=normalized_metadata["duration"],
|
||||||
|
normalized_size=normalized_metadata["size"],
|
||||||
|
normalized_hash=normalized_metadata["hash"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Commit the database changes
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Successfully normalized {sound.name}")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"sound_id": sound_id,
|
||||||
|
"sound_name": sound.name,
|
||||||
|
"source_path": str(source_path),
|
||||||
|
"normalized_path": str(normalized_path),
|
||||||
|
"normalized_filename": normalized_filename,
|
||||||
|
"normalized_duration": normalized_metadata["duration"],
|
||||||
|
"normalized_size": normalized_metadata["size"],
|
||||||
|
"normalized_hash": normalized_metadata["hash"],
|
||||||
|
"loudnorm_stats": result.get("stats", {}),
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error normalizing sound {sound_id}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def normalize_all_sounds(
|
||||||
|
overwrite: bool = False, limit: int = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Normalize all soundboard files.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
overwrite: Whether to overwrite existing normalized files
|
||||||
|
limit: Maximum number of files to process (None for all)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Summary of the normalization operation
|
||||||
|
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
query = Sound.query.filter_by(type="SDB")
|
||||||
|
if limit:
|
||||||
|
query = query.limit(limit)
|
||||||
|
|
||||||
|
sounds = query.all()
|
||||||
|
|
||||||
|
if not sounds:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "No soundboard files found to normalize",
|
||||||
|
"processed": 0,
|
||||||
|
"successful": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"skipped": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"Starting bulk normalization of {len(sounds)} sounds")
|
||||||
|
|
||||||
|
processed = 0
|
||||||
|
successful = 0
|
||||||
|
failed = 0
|
||||||
|
skipped = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for sound in sounds:
|
||||||
|
result = SoundNormalizerService.normalize_sound(
|
||||||
|
sound.id, overwrite,
|
||||||
|
)
|
||||||
|
processed += 1
|
||||||
|
|
||||||
|
if result["success"]:
|
||||||
|
successful += 1
|
||||||
|
elif "already exists" in result.get("error", ""):
|
||||||
|
skipped += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
errors.append(f"{sound.name}: {result['error']}")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Bulk normalization completed: {successful} successful, {failed} failed, {skipped} skipped",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Processed {processed} sounds: {successful} successful, {failed} failed, {skipped} skipped",
|
||||||
|
"processed": processed,
|
||||||
|
"successful": successful,
|
||||||
|
"failed": failed,
|
||||||
|
"skipped": skipped,
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error during bulk normalization: {e}")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"processed": 0,
|
||||||
|
"successful": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"skipped": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_with_ffmpeg(source_path: str, output_path: str) -> dict:
|
||||||
|
"""Run ffmpeg loudnorm on a single file using python-ffmpeg.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_path: Path to source audio file
|
||||||
|
output_path: Path for normalized output file (will be WAV format)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Result with success status and loudnorm statistics
|
||||||
|
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
params = SoundNormalizerService.LOUDNORM_PARAMS
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Running ffmpeg normalization: {source_path} -> {output_path}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create ffmpeg input stream
|
||||||
|
input_stream = ffmpeg.input(source_path)
|
||||||
|
|
||||||
|
# Apply loudnorm filter
|
||||||
|
loudnorm_filter = f"loudnorm=I={params['integrated']}:TP={params['true_peak']}:LRA={params['lra']}:print_format={params['print_format']}"
|
||||||
|
|
||||||
|
# Create output stream with WAV format
|
||||||
|
output_stream = ffmpeg.output(
|
||||||
|
input_stream,
|
||||||
|
output_path,
|
||||||
|
acodec="pcm_s16le", # 16-bit PCM for WAV
|
||||||
|
ar=44100, # 44.1kHz sample rate
|
||||||
|
af=loudnorm_filter,
|
||||||
|
y=None, # Overwrite output file
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run the ffmpeg process
|
||||||
|
out, err = ffmpeg.run(
|
||||||
|
output_stream, capture_stdout=True, capture_stderr=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse loudnorm statistics from stderr
|
||||||
|
stats = SoundNormalizerService._parse_loudnorm_stats(
|
||||||
|
err.decode() if err else "",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not Path(output_path).exists():
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "Output file was not created",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"success": True, "stats": stats}
|
||||||
|
|
||||||
|
except ffmpeg.Error as e:
|
||||||
|
error_msg = (
|
||||||
|
f"FFmpeg error: {e.stderr.decode() if e.stderr else str(e)}"
|
||||||
|
)
|
||||||
|
logger.error(error_msg)
|
||||||
|
return {"success": False, "error": error_msg}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error running ffmpeg: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_loudnorm_stats(stderr_output: str) -> dict:
|
||||||
|
"""Parse loudnorm statistics from ffmpeg stderr output.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stderr_output: ffmpeg stderr output containing loudnorm stats
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Parsed loudnorm statistics
|
||||||
|
|
||||||
|
"""
|
||||||
|
stats = {}
|
||||||
|
|
||||||
|
if not stderr_output:
|
||||||
|
return stats
|
||||||
|
|
||||||
|
lines = stderr_output.split("\n")
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if "Input Integrated:" in line:
|
||||||
|
try:
|
||||||
|
stats["input_integrated"] = float(line.split()[-2])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
elif "Input True Peak:" in line:
|
||||||
|
try:
|
||||||
|
stats["input_true_peak"] = float(line.split()[-2])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
elif "Input LRA:" in line:
|
||||||
|
try:
|
||||||
|
stats["input_lra"] = float(line.split()[-1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
elif "Output Integrated:" in line:
|
||||||
|
try:
|
||||||
|
stats["output_integrated"] = float(line.split()[-2])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
elif "Output True Peak:" in line:
|
||||||
|
try:
|
||||||
|
stats["output_true_peak"] = float(line.split()[-2])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
elif "Output LRA:" in line:
|
||||||
|
try:
|
||||||
|
stats["output_lra"] = float(line.split()[-1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_normalized_metadata(file_path: str) -> dict:
|
||||||
|
"""Calculate metadata for normalized file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to the normalized audio file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Metadata including duration and hash
|
||||||
|
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get file size
|
||||||
|
file_size = Path(file_path).stat().st_size
|
||||||
|
|
||||||
|
# Calculate file hash
|
||||||
|
file_hash = SoundNormalizerService._calculate_file_hash(file_path)
|
||||||
|
|
||||||
|
# Get duration using pydub
|
||||||
|
audio = AudioSegment.from_wav(file_path)
|
||||||
|
duration = len(audio) # Duration in milliseconds
|
||||||
|
|
||||||
|
return {
|
||||||
|
"duration": duration,
|
||||||
|
"size": file_size,
|
||||||
|
"hash": file_hash,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error calculating metadata for {file_path}: {e}")
|
||||||
|
return {
|
||||||
|
"duration": 0,
|
||||||
|
"size": Path(file_path).stat().st_size,
|
||||||
|
"hash": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _calculate_file_hash(file_path: str) -> str:
|
||||||
|
"""Calculate SHA256 hash of file contents."""
|
||||||
|
sha256_hash = hashlib.sha256()
|
||||||
|
|
||||||
|
with Path(file_path).open("rb") as f:
|
||||||
|
# Read file in chunks to handle large files
|
||||||
|
for chunk in iter(lambda: f.read(4096), b""):
|
||||||
|
sha256_hash.update(chunk)
|
||||||
|
|
||||||
|
return sha256_hash.hexdigest()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_normalization_status() -> dict:
|
||||||
|
"""Get statistics about normalized vs original files.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Statistics about normalization status
|
||||||
|
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
total_sounds = Sound.query.filter_by(type="SDB").count()
|
||||||
|
|
||||||
|
normalized_count = 0
|
||||||
|
total_original_size = 0
|
||||||
|
total_normalized_size = 0
|
||||||
|
|
||||||
|
sounds = Sound.query.filter_by(type="SDB").all()
|
||||||
|
|
||||||
|
for sound in sounds:
|
||||||
|
original_path = Path(SoundNormalizerService.SOUNDS_DIR) / sound.filename
|
||||||
|
|
||||||
|
if original_path.exists():
|
||||||
|
total_original_size += original_path.stat().st_size
|
||||||
|
|
||||||
|
# Use database field to check if normalized, not file existence
|
||||||
|
if sound.is_normalized and sound.normalized_filename:
|
||||||
|
normalized_count += 1
|
||||||
|
normalized_path = Path(SoundNormalizerService.NORMALIZED_DIR) / sound.normalized_filename
|
||||||
|
if normalized_path.exists():
|
||||||
|
total_normalized_size += normalized_path.stat().st_size
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_sounds": total_sounds,
|
||||||
|
"normalized_count": normalized_count,
|
||||||
|
"normalization_percentage": (
|
||||||
|
(normalized_count / total_sounds * 100)
|
||||||
|
if total_sounds > 0
|
||||||
|
else 0
|
||||||
|
),
|
||||||
|
"total_original_size": total_original_size,
|
||||||
|
"total_normalized_size": total_normalized_size,
|
||||||
|
"size_difference": (
|
||||||
|
total_normalized_size - total_original_size
|
||||||
|
if normalized_count > 0
|
||||||
|
else 0
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting normalization status: {e}")
|
||||||
|
return {
|
||||||
|
"error": str(e),
|
||||||
|
"total_sounds": 0,
|
||||||
|
"normalized_count": 0,
|
||||||
|
"normalization_percentage": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def check_ffmpeg_availability() -> dict:
|
||||||
|
"""Check if ffmpeg is available and supports loudnorm filter.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Information about ffmpeg availability and capabilities
|
||||||
|
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Create a minimal test audio file to check ffmpeg
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
suffix=".wav", delete=False,
|
||||||
|
) as temp_file:
|
||||||
|
temp_path = temp_file.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try a simple ffmpeg operation to check availability
|
||||||
|
test_input = ffmpeg.input(
|
||||||
|
"anullsrc=channel_layout=stereo:sample_rate=44100",
|
||||||
|
f="lavfi",
|
||||||
|
t=0.1,
|
||||||
|
)
|
||||||
|
test_output = ffmpeg.output(test_input, temp_path)
|
||||||
|
ffmpeg.run(
|
||||||
|
test_output,
|
||||||
|
capture_stdout=True,
|
||||||
|
capture_stderr=True,
|
||||||
|
quiet=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# If we get here, basic ffmpeg is working
|
||||||
|
# Now test loudnorm filter
|
||||||
|
try:
|
||||||
|
norm_input = ffmpeg.input(temp_path)
|
||||||
|
norm_output = ffmpeg.output(
|
||||||
|
norm_input,
|
||||||
|
"/dev/null",
|
||||||
|
af="loudnorm=I=-16:TP=-1.5:LRA=11.0",
|
||||||
|
f="null",
|
||||||
|
)
|
||||||
|
ffmpeg.run(
|
||||||
|
norm_output,
|
||||||
|
capture_stdout=True,
|
||||||
|
capture_stderr=True,
|
||||||
|
quiet=True,
|
||||||
|
)
|
||||||
|
has_loudnorm = True
|
||||||
|
except ffmpeg.Error:
|
||||||
|
has_loudnorm = False
|
||||||
|
|
||||||
|
return {
|
||||||
|
"available": True,
|
||||||
|
"version": "ffmpeg-python wrapper available",
|
||||||
|
"has_loudnorm": has_loudnorm,
|
||||||
|
"ready": has_loudnorm,
|
||||||
|
}
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Clean up temp file
|
||||||
|
temp_file_path = Path(temp_path)
|
||||||
|
if temp_file_path.exists():
|
||||||
|
temp_file_path.unlink()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"available": False,
|
||||||
|
"error": f"ffmpeg not available via python-ffmpeg: {e!s}",
|
||||||
|
}
|
||||||
316
app/services/sound_scanner_service.py
Normal file
316
app/services/sound_scanner_service.py
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
"""Sound file scanning service for discovering and importing audio files."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydub import AudioSegment
|
||||||
|
from pydub.utils import mediainfo
|
||||||
|
|
||||||
|
from app.database import db
|
||||||
|
from app.models.sound import Sound
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SoundScannerService:
|
||||||
|
"""Service for scanning and importing sound files."""
|
||||||
|
|
||||||
|
# Supported audio file extensions
|
||||||
|
SUPPORTED_EXTENSIONS = {".mp3", ".wav", ".ogg", ".flac", ".m4a", ".aac"}
|
||||||
|
|
||||||
|
# Default soundboard directory
|
||||||
|
DEFAULT_SOUNDBOARD_DIR = "sounds/soundboard"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def scan_soundboard_directory(
|
||||||
|
directory: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Scan the soundboard directory and add new files to the database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Directory to scan (defaults to sounds/soundboard)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Summary of the scan operation
|
||||||
|
|
||||||
|
"""
|
||||||
|
scan_dir = directory or SoundScannerService.DEFAULT_SOUNDBOARD_DIR
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Ensure directory exists
|
||||||
|
scan_path = Path(scan_dir)
|
||||||
|
if not scan_path.exists():
|
||||||
|
logger.warning(
|
||||||
|
f"Soundboard directory does not exist: {scan_dir}",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Directory not found: {scan_dir}",
|
||||||
|
"files_found": 0,
|
||||||
|
"files_added": 0,
|
||||||
|
"files_skipped": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"Starting soundboard scan in: {scan_dir}")
|
||||||
|
|
||||||
|
files_found = 0
|
||||||
|
files_added = 0
|
||||||
|
files_skipped = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Walk through directory and subdirectories
|
||||||
|
for file_path in scan_path.rglob("*"):
|
||||||
|
if file_path.is_file():
|
||||||
|
filename = file_path.name
|
||||||
|
|
||||||
|
# Check if file has supported extension
|
||||||
|
if not SoundScannerService._is_supported_audio_file(
|
||||||
|
filename,
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
files_found += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Process the audio file
|
||||||
|
result = SoundScannerService._process_audio_file(
|
||||||
|
str(file_path),
|
||||||
|
scan_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result["added"]:
|
||||||
|
files_added += 1
|
||||||
|
logger.debug(f"Added sound: {filename}")
|
||||||
|
elif result.get("updated"):
|
||||||
|
files_added += 1 # Count updates as additions for reporting
|
||||||
|
logger.debug(f"Updated sound: {filename}")
|
||||||
|
else:
|
||||||
|
files_skipped += 1
|
||||||
|
logger.debug(
|
||||||
|
f"Skipped sound: {filename} ({result['reason']})",
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Error processing {filename}: {e!s}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
errors.append(error_msg)
|
||||||
|
files_skipped += 1
|
||||||
|
|
||||||
|
# Commit all changes
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Soundboard scan completed: {files_found} files found, "
|
||||||
|
f"{files_added} added, {files_skipped} skipped",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"directory": scan_dir,
|
||||||
|
"files_found": files_found,
|
||||||
|
"files_added": files_added,
|
||||||
|
"files_skipped": files_skipped,
|
||||||
|
"errors": errors,
|
||||||
|
"message": f"Scan completed: {files_added} new sounds added",
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Error during soundboard scan: {e!s}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"files_found": 0,
|
||||||
|
"files_added": 0,
|
||||||
|
"files_skipped": 0,
|
||||||
|
"message": "Soundboard scan failed",
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_supported_audio_file(filename: str) -> bool:
|
||||||
|
"""Check if file has a supported audio extension."""
|
||||||
|
return (
|
||||||
|
Path(filename).suffix.lower()
|
||||||
|
in SoundScannerService.SUPPORTED_EXTENSIONS
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _process_audio_file(file_path: str, base_dir: str) -> dict:
|
||||||
|
"""Process a single audio file and add it to database if new.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Full path to the audio file
|
||||||
|
base_dir: Base directory for relative path calculation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Processing result with added flag and reason
|
||||||
|
|
||||||
|
"""
|
||||||
|
# Calculate file hash for deduplication
|
||||||
|
file_hash = SoundScannerService._calculate_file_hash(file_path)
|
||||||
|
|
||||||
|
# Get file metadata
|
||||||
|
metadata = SoundScannerService._extract_audio_metadata(file_path)
|
||||||
|
|
||||||
|
# Calculate relative filename from base directory
|
||||||
|
relative_path = Path(file_path).relative_to(Path(base_dir))
|
||||||
|
|
||||||
|
# Check if file already exists in database by hash
|
||||||
|
existing_sound = Sound.find_by_hash(file_hash)
|
||||||
|
if existing_sound:
|
||||||
|
return {
|
||||||
|
"added": False,
|
||||||
|
"reason": f"File already exists as '{existing_sound.name}'",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if filename already exists in database
|
||||||
|
existing_filename_sound = Sound.find_by_filename(str(relative_path))
|
||||||
|
if existing_filename_sound:
|
||||||
|
# Remove normalized files and clear normalized info
|
||||||
|
SoundScannerService._clear_normalized_files(existing_filename_sound)
|
||||||
|
existing_filename_sound.clear_normalized_info()
|
||||||
|
|
||||||
|
# Update existing sound with new file information
|
||||||
|
existing_filename_sound.update_file_info(
|
||||||
|
filename=str(relative_path),
|
||||||
|
duration=metadata["duration"],
|
||||||
|
size=metadata["size"],
|
||||||
|
hash_value=file_hash,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"added": False,
|
||||||
|
"updated": True,
|
||||||
|
"sound_id": existing_filename_sound.id,
|
||||||
|
"reason": f"Updated existing sound '{existing_filename_sound.name}' with new file data",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generate sound name from filename (without extension)
|
||||||
|
sound_name = Path(file_path).stem
|
||||||
|
|
||||||
|
# Check if name already exists and make it unique if needed
|
||||||
|
counter = 1
|
||||||
|
original_name = sound_name
|
||||||
|
while Sound.find_by_name(sound_name):
|
||||||
|
sound_name = f"{original_name}_{counter}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
# Create new sound record
|
||||||
|
sound = Sound.create_sound(
|
||||||
|
sound_type="SDB", # Soundboard type
|
||||||
|
name=sound_name,
|
||||||
|
filename=str(relative_path),
|
||||||
|
duration=metadata["duration"],
|
||||||
|
size=metadata["size"],
|
||||||
|
hash_value=file_hash,
|
||||||
|
is_music=False,
|
||||||
|
is_deletable=False,
|
||||||
|
commit=False, # Don't commit individually, let scanner handle transaction
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"added": True,
|
||||||
|
"sound_id": sound.id,
|
||||||
|
"reason": "New file added successfully",
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _calculate_file_hash(file_path: str) -> str:
|
||||||
|
"""Calculate SHA256 hash of file contents."""
|
||||||
|
sha256_hash = hashlib.sha256()
|
||||||
|
|
||||||
|
with Path(file_path).open("rb") as f:
|
||||||
|
# Read file in chunks to handle large files
|
||||||
|
for chunk in iter(lambda: f.read(4096), b""):
|
||||||
|
sha256_hash.update(chunk)
|
||||||
|
|
||||||
|
return sha256_hash.hexdigest()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clear_normalized_files(sound: Sound) -> None:
|
||||||
|
"""Remove normalized files for a sound if they exist."""
|
||||||
|
if sound.is_normalized and sound.normalized_filename:
|
||||||
|
# Import here to avoid circular imports
|
||||||
|
from app.services.sound_normalizer_service import SoundNormalizerService
|
||||||
|
|
||||||
|
normalized_path = Path(SoundNormalizerService.NORMALIZED_DIR) / sound.normalized_filename
|
||||||
|
if normalized_path.exists():
|
||||||
|
try:
|
||||||
|
normalized_path.unlink()
|
||||||
|
logger.info(f"Removed normalized file: {normalized_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not remove normalized file {normalized_path}: {e}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_audio_metadata(file_path: str) -> dict:
|
||||||
|
"""Extract metadata from audio file using pydub and mediainfo."""
|
||||||
|
try:
|
||||||
|
# Get file size
|
||||||
|
file_size = Path(file_path).stat().st_size
|
||||||
|
|
||||||
|
# Load audio file with pydub for basic info
|
||||||
|
audio = AudioSegment.from_file(file_path)
|
||||||
|
|
||||||
|
# Extract basic metadata from AudioSegment
|
||||||
|
duration = len(audio)
|
||||||
|
channels = audio.channels
|
||||||
|
sample_rate = audio.frame_rate
|
||||||
|
|
||||||
|
# Use mediainfo for more accurate bitrate information
|
||||||
|
bitrate = None
|
||||||
|
try:
|
||||||
|
info = mediainfo(file_path)
|
||||||
|
if info and "bit_rate" in info:
|
||||||
|
bitrate = int(info["bit_rate"])
|
||||||
|
elif info and "bitrate" in info:
|
||||||
|
bitrate = int(info["bitrate"])
|
||||||
|
except (ValueError, KeyError, TypeError):
|
||||||
|
# Fallback to calculated bitrate if mediainfo fails
|
||||||
|
if duration > 0:
|
||||||
|
file_size_bits = file_size * 8
|
||||||
|
bitrate = int(file_size_bits / duration / 1000)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"duration": duration,
|
||||||
|
"size": file_size,
|
||||||
|
"bitrate": bitrate,
|
||||||
|
"channels": channels,
|
||||||
|
"sample_rate": sample_rate,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not extract metadata from {file_path}: {e}")
|
||||||
|
return {
|
||||||
|
"duration": 0,
|
||||||
|
"size": Path(file_path).stat().st_size,
|
||||||
|
"bitrate": None,
|
||||||
|
"channels": None,
|
||||||
|
"sample_rate": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_scan_statistics() -> dict:
|
||||||
|
"""Get statistics about sounds in the database."""
|
||||||
|
total_sounds = Sound.query.count()
|
||||||
|
sdb_sounds = Sound.query.filter_by(type="SDB").count()
|
||||||
|
music_sounds = Sound.query.filter_by(is_music=True).count()
|
||||||
|
|
||||||
|
# Calculate total size and duration
|
||||||
|
sounds = Sound.query.all()
|
||||||
|
total_size = sum(sound.size for sound in sounds)
|
||||||
|
total_duration = sum(sound.duration for sound in sounds)
|
||||||
|
total_plays = sum(sound.play_count for sound in sounds)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_sounds": total_sounds,
|
||||||
|
"soundboard_sounds": sdb_sounds,
|
||||||
|
"music_sounds": music_sounds,
|
||||||
|
"total_size_bytes": total_size,
|
||||||
|
"total_duration": total_duration,
|
||||||
|
"total_plays": total_plays,
|
||||||
|
"most_played": [
|
||||||
|
sound.to_dict() for sound in Sound.get_most_played(5)
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -8,11 +8,13 @@ requires-python = ">=3.12"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"apscheduler==3.11.0",
|
"apscheduler==3.11.0",
|
||||||
"authlib==1.6.0",
|
"authlib==1.6.0",
|
||||||
|
"ffmpeg-python>=0.2.0",
|
||||||
"flask==3.1.1",
|
"flask==3.1.1",
|
||||||
"flask-cors==6.0.1",
|
"flask-cors==6.0.1",
|
||||||
"flask-jwt-extended==4.7.1",
|
"flask-jwt-extended==4.7.1",
|
||||||
"flask-migrate==4.1.0",
|
"flask-migrate==4.1.0",
|
||||||
"flask-sqlalchemy==3.1.1",
|
"flask-sqlalchemy==3.1.1",
|
||||||
|
"pydub==0.25.1",
|
||||||
"python-dotenv==1.1.1",
|
"python-dotenv==1.1.1",
|
||||||
"requests==2.32.4",
|
"requests==2.32.4",
|
||||||
"werkzeug==3.1.3",
|
"werkzeug==3.1.3",
|
||||||
|
|||||||
14
reset.sh
14
reset.sh
@@ -1,5 +1,17 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
|
shopt -s extglob
|
||||||
|
|
||||||
rm instance/soundboard.db
|
rm instance/soundboard.db
|
||||||
uv run migrate_db.py init-db
|
|
||||||
|
rm -rf alembic/versions/!(.gitignore)
|
||||||
|
rm -rf sounds/say/!(.gitignore)
|
||||||
|
rm -rf sounds/stream/!(.gitignore|thumbnails)
|
||||||
|
rm -rf sounds/stream/thumbnails/!(.gitignore)
|
||||||
|
rm -rf sounds/temp/!(.gitignore)
|
||||||
|
rm -rf sounds/normalized/say/!(.gitignore)
|
||||||
|
rm -rf sounds/normalized/soundboard/!(.gitignore)
|
||||||
|
rm -rf sounds/normalized/stream/!(.gitignore)
|
||||||
|
|
||||||
|
# uv run migrate_db.py init-db
|
||||||
uv run main.py
|
uv run main.py
|
||||||
5
sounds/normalized/.gitignore
vendored
Normal file
5
sounds/normalized/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
|
!say
|
||||||
|
!soundboard
|
||||||
|
!stream
|
||||||
2
sounds/normalized/say/.gitignore
vendored
Normal file
2
sounds/normalized/say/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
2
sounds/normalized/soundboard/.gitignore
vendored
Normal file
2
sounds/normalized/soundboard/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
3
sounds/normalized/stream/.gitignore
vendored
Normal file
3
sounds/normalized/stream/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
|
!thumbnails
|
||||||
2
sounds/say/.gitignore
vendored
Normal file
2
sounds/say/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
BIN
sounds/soundboard/20th_century_fox.mp3
Normal file
BIN
sounds/soundboard/20th_century_fox.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/3corde.wav
Normal file
BIN
sounds/soundboard/3corde.wav
Normal file
Binary file not shown.
BIN
sounds/soundboard/a_few_moments_later.mp3
Normal file
BIN
sounds/soundboard/a_few_moments_later.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/aallez.wav
Normal file
BIN
sounds/soundboard/aallez.wav
Normal file
Binary file not shown.
BIN
sounds/soundboard/ah_denis_brogniart.mp3
Normal file
BIN
sounds/soundboard/ah_denis_brogniart.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/alerte_gogole.mp3
Normal file
BIN
sounds/soundboard/alerte_gogole.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/allez.wav
Normal file
BIN
sounds/soundboard/allez.wav
Normal file
Binary file not shown.
BIN
sounds/soundboard/among_us.mp3
Normal file
BIN
sounds/soundboard/among_us.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/and_his_name_is_john_cena.mp3
Normal file
BIN
sounds/soundboard/and_his_name_is_john_cena.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/animal_crossing_bla.mp3
Normal file
BIN
sounds/soundboard/animal_crossing_bla.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/another_one.mp3
Normal file
BIN
sounds/soundboard/another_one.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/as_tu_vu_les_quenouilles.mp3
Normal file
BIN
sounds/soundboard/as_tu_vu_les_quenouilles.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/as_tu_vu_les_quenouilles_long.mp3
Normal file
BIN
sounds/soundboard/as_tu_vu_les_quenouilles_long.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/aughhhhh_aughhhhh.mp3
Normal file
BIN
sounds/soundboard/aughhhhh_aughhhhh.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/awwww.mp3
Normal file
BIN
sounds/soundboard/awwww.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/bebou.mp3
Normal file
BIN
sounds/soundboard/bebou.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/bebou_long.mp3
Normal file
BIN
sounds/soundboard/bebou_long.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/bizarre.opus
Normal file
BIN
sounds/soundboard/bizarre.opus
Normal file
Binary file not shown.
BIN
sounds/soundboard/bonk.mp3
Normal file
BIN
sounds/soundboard/bonk.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/brother_ewwwwwww.mp3
Normal file
BIN
sounds/soundboard/brother_ewwwwwww.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/c_est_honteux.mp3
Normal file
BIN
sounds/soundboard/c_est_honteux.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/c_est_l_heure_de_manger.mp3
Normal file
BIN
sounds/soundboard/c_est_l_heure_de_manger.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/c_est_la_mer_noir.mp3
Normal file
BIN
sounds/soundboard/c_est_la_mer_noir.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/c_t_sur_sard.mp3
Normal file
BIN
sounds/soundboard/c_t_sur_sard.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/ca_va_peter.mp3
Normal file
BIN
sounds/soundboard/ca_va_peter.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/careless_whisper_short.mp3
Normal file
BIN
sounds/soundboard/careless_whisper_short.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/carrefour.mp3
Normal file
BIN
sounds/soundboard/carrefour.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/cest_moi.wav
Normal file
BIN
sounds/soundboard/cest_moi.wav
Normal file
Binary file not shown.
BIN
sounds/soundboard/cloche_de_boxe.mp3
Normal file
BIN
sounds/soundboard/cloche_de_boxe.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/combien.mp3
Normal file
BIN
sounds/soundboard/combien.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/comment_ca_mon_reuf_sans_le_quoi.mp3
Normal file
BIN
sounds/soundboard/comment_ca_mon_reuf_sans_le_quoi.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/community_chang_gay.mp3
Normal file
BIN
sounds/soundboard/community_chang_gay.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/cou.wav
Normal file
BIN
sounds/soundboard/cou.wav
Normal file
Binary file not shown.
BIN
sounds/soundboard/coucou.mp3
Normal file
BIN
sounds/soundboard/coucou.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/dancehall_horn.mp3
Normal file
BIN
sounds/soundboard/dancehall_horn.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/decathlon.mp3
Normal file
BIN
sounds/soundboard/decathlon.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/dikkenek_ou_tu_sors_ou_j_te_sors.mp3
Normal file
BIN
sounds/soundboard/dikkenek_ou_tu_sors_ou_j_te_sors.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/directed_by_robert_b_weide.mp3
Normal file
BIN
sounds/soundboard/directed_by_robert_b_weide.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/downer_noise.mp3
Normal file
BIN
sounds/soundboard/downer_noise.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/dry_fart.mp3
Normal file
BIN
sounds/soundboard/dry_fart.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/emotional_damage.mp3
Normal file
BIN
sounds/soundboard/emotional_damage.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/epic_sax_guy.mp3
Normal file
BIN
sounds/soundboard/epic_sax_guy.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/etchebest_c_est_con_ça.mp3
Normal file
BIN
sounds/soundboard/etchebest_c_est_con_ça.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/excuse_moiiii.mp3
Normal file
BIN
sounds/soundboard/excuse_moiiii.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/expecto_patronum.mp3
Normal file
BIN
sounds/soundboard/expecto_patronum.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/fart_with_extra_reverb.mp3
Normal file
BIN
sounds/soundboard/fart_with_extra_reverb.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/fbi_open_up.mp3
Normal file
BIN
sounds/soundboard/fbi_open_up.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/fdp.mp3
Normal file
BIN
sounds/soundboard/fdp.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/flute.wav
Normal file
BIN
sounds/soundboard/flute.wav
Normal file
Binary file not shown.
BIN
sounds/soundboard/flute_anniv_LIP.mp3
Normal file
BIN
sounds/soundboard/flute_anniv_LIP.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/fonctionnaire.mp3
Normal file
BIN
sounds/soundboard/fonctionnaire.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/gay_echo.mp3
Normal file
BIN
sounds/soundboard/gay_echo.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/goku_drip.mp3
Normal file
BIN
sounds/soundboard/goku_drip.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/gta_mission_complete.mp3
Normal file
BIN
sounds/soundboard/gta_mission_complete.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/gtav_wasted.mp3
Normal file
BIN
sounds/soundboard/gtav_wasted.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/happy_happy_happy.mp3
Normal file
BIN
sounds/soundboard/happy_happy_happy.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/hugooo.mp3
Normal file
BIN
sounds/soundboard/hugooo.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/i_will_be_back.mp3
Normal file
BIN
sounds/soundboard/i_will_be_back.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/initial_d_deja_vu.mp3
Normal file
BIN
sounds/soundboard/initial_d_deja_vu.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/initial_d_gas_gas_gas.mp3
Normal file
BIN
sounds/soundboard/initial_d_gas_gas_gas.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/insult.wav
Normal file
BIN
sounds/soundboard/insult.wav
Normal file
Binary file not shown.
BIN
sounds/soundboard/je_suis_pas_venue_ici_pour_souffrir_ok.mp3
Normal file
BIN
sounds/soundboard/je_suis_pas_venue_ici_pour_souffrir_ok.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/je_te_demande_pardon.mp3
Normal file
BIN
sounds/soundboard/je_te_demande_pardon.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/je_vous_demande_de_vous_arreter.mp3
Normal file
BIN
sounds/soundboard/je_vous_demande_de_vous_arreter.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/julien_lepers_Ah_ouai_ouai_ouai_question.mp3
Normal file
BIN
sounds/soundboard/julien_lepers_Ah_ouai_ouai_ouai_question.mp3
Normal file
Binary file not shown.
Binary file not shown.
BIN
sounds/soundboard/kabuki.mp3
Normal file
BIN
sounds/soundboard/kabuki.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/karime_cuisiniere.mp3
Normal file
BIN
sounds/soundboard/karime_cuisiniere.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/karime_enfant_gache_court.mp3
Normal file
BIN
sounds/soundboard/karime_enfant_gache_court.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/karime_enfant_gache_long.mp3
Normal file
BIN
sounds/soundboard/karime_enfant_gache_long.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/karime_enfant_gache_medium.mp3
Normal file
BIN
sounds/soundboard/karime_enfant_gache_medium.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/kendrick_mustard.mp3
Normal file
BIN
sounds/soundboard/kendrick_mustard.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/la_place_de_la_femme.mp3
Normal file
BIN
sounds/soundboard/la_place_de_la_femme.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/leroy_merlin.mp3
Normal file
BIN
sounds/soundboard/leroy_merlin.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/les_cons_sur_orbite.mp3
Normal file
BIN
sounds/soundboard/les_cons_sur_orbite.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/les_trois_freres_c_est_la_ca_ta_strophe.mp3
Normal file
BIN
sounds/soundboard/les_trois_freres_c_est_la_ca_ta_strophe.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/lets_go.wav
Normal file
BIN
sounds/soundboard/lets_go.wav
Normal file
Binary file not shown.
BIN
sounds/soundboard/loading.mp3
Normal file
BIN
sounds/soundboard/loading.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/mac_quack.mp3
Normal file
BIN
sounds/soundboard/mac_quack.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/magneto_serge.mp3
Normal file
BIN
sounds/soundboard/magneto_serge.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/maman_va_me_niquer_long.mp3
Normal file
BIN
sounds/soundboard/maman_va_me_niquer_long.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/maman_va_me_niquer_medium.mp3
Normal file
BIN
sounds/soundboard/maman_va_me_niquer_medium.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/mans_not_hot_1.mp3
Normal file
BIN
sounds/soundboard/mans_not_hot_1.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/mans_not_hot_2.mp3
Normal file
BIN
sounds/soundboard/mans_not_hot_2.mp3
Normal file
Binary file not shown.
BIN
sounds/soundboard/mans_not_hot_3.mp3
Normal file
BIN
sounds/soundboard/mans_not_hot_3.mp3
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user