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:
JSC
2025-07-02 17:09:43 +02:00
parent 1b597f4047
commit 7128ca727b
181 changed files with 1278 additions and 62 deletions

View File

@@ -61,6 +61,9 @@ def create_app():
# Initialize authentication service with app
auth_service.init_app(app)
# Initialize scheduler service with app
scheduler_service.app = app
# Start scheduler for background tasks
scheduler_service.start()

View File

@@ -1,7 +1,8 @@
"""Database models."""
from .plan import Plan
from .sound import Sound
from .user import User
from .user_oauth import UserOAuth
__all__ = ["Plan", "User", "UserOAuth"]
__all__ = ["Plan", "Sound", "User", "UserOAuth"]

225
app/models/sound.py Normal file
View 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

View File

@@ -1,6 +1,6 @@
"""Main routes for the application."""
from flask import Blueprint
from flask import Blueprint, request
from app.services.decorators import (
get_current_user,
@@ -9,6 +9,8 @@ from app.services.decorators import (
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__)
@@ -101,3 +103,55 @@ def scheduler_status() -> dict:
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()

View File

@@ -14,8 +14,7 @@ class CreditService:
@staticmethod
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:
1. Gets all active users
@@ -25,100 +24,101 @@ class CreditService:
Returns:
dict: Summary of the refill operation
"""
try:
# Get all active users with their plans
users = User.query.filter_by(is_active=True).all()
if not users:
logger.info("No active users found for credit refill")
return {
"success": True,
"users_processed": 0,
"credits_added": 0,
"message": "No active users found"
"message": "No active users found",
}
users_processed = 0
total_credits_added = 0
for user in users:
if not user.plan:
logger.warning(f"User {user.email} has no plan assigned, skipping")
continue
# Calculate new credit amount, capped at plan max
current_credits = user.credits or 0
plan_daily_credits = user.plan.credits
max_credits = user.plan.max_credits
# Add daily credits but don't exceed maximum
new_credits = min(current_credits + plan_daily_credits, max_credits)
credits_added = new_credits - current_credits
if credits_added > 0:
user.credits = new_credits
user.updated_at = datetime.utcnow()
total_credits_added += credits_added
logger.debug(
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:
logger.debug(
f"User {user.email}: Already at max credits "
f"({current_credits}/{max_credits})"
f"({current_credits}/{max_credits})",
)
users_processed += 1
# Commit all changes in a single transaction
db.session.commit()
logger.info(
f"Daily credit refill completed: {users_processed} users processed, "
f"{total_credits_added} total credits added"
f"{total_credits_added} total credits added",
)
return {
"success": True,
"users_processed": users_processed,
"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:
# Rollback transaction on error
db.session.rollback()
logger.error(f"Error during daily credit refill: {str(e)}")
logger.error(f"Error during daily credit refill: {e!s}")
return {
"success": False,
"users_processed": 0,
"credits_added": 0,
"error": str(e),
"message": "Credit refill failed"
"message": "Credit refill failed",
}
@staticmethod
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:
user_id: The user's ID
Returns:
dict: User's credit information
"""
user = User.query.get(user_id)
if not user:
return {"error": "User not found"}
if not user.plan:
return {"error": "User has no plan assigned"}
return {
"user_id": user.id,
"email": user.email,
@@ -127,7 +127,7 @@ class CreditService:
"code": user.plan.code,
"name": user.plan.name,
"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,
}

View File

@@ -1,12 +1,13 @@
"""Scheduler service for managing background tasks with APScheduler."""
import logging
from typing import Optional
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from flask import current_app
from app.services.credit_service import CreditService
from app.services.sound_scanner_service import SoundScannerService
logger = logging.getLogger(__name__)
@@ -14,9 +15,10 @@ logger = logging.getLogger(__name__)
class SchedulerService:
"""Service for managing scheduled background tasks."""
def __init__(self) -> None:
def __init__(self, app=None) -> None:
"""Initialize the scheduler service."""
self.scheduler: Optional[BackgroundScheduler] = None
self.scheduler: BackgroundScheduler | None = None
self.app = app
def start(self) -> None:
"""Start the scheduler and add all scheduled jobs."""
@@ -25,10 +27,13 @@ class SchedulerService:
return
self.scheduler = BackgroundScheduler()
# Add daily credit refill job
self._add_daily_credit_refill_job()
# Add sound scanning job
self._add_sound_scanning_job()
# Start the scheduler
self.scheduler.start()
logger.info("Scheduler started successfully")
@@ -47,7 +52,7 @@ class SchedulerService:
# Schedule daily at 00:00 UTC
trigger = CronTrigger(hour=0, minute=0)
self.scheduler.add_job(
func=self._run_daily_credit_refill,
trigger=trigger,
@@ -55,47 +60,99 @@ class SchedulerService:
name="Daily Credit Refill",
replace_existing=True,
)
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:
"""Execute the daily credit refill task."""
logger.info("Starting daily credit refill task")
try:
result = CreditService.refill_all_users_credits()
if result["success"]:
logger.info(
f"Daily credit refill completed successfully: "
f"{result['users_processed']} users processed, "
f"{result['credits_added']} credits added"
)
else:
logger.error(f"Daily credit refill failed: {result['message']}")
except Exception as e:
logger.exception(f"Error during daily credit refill: {e}")
app = self.app or current_app
with app.app_context():
try:
result = CreditService.refill_all_users_credits()
if result["success"]:
logger.info(
f"Daily credit refill completed successfully: "
f"{result['users_processed']} users processed, "
f"{result['credits_added']} credits added",
)
else:
logger.error(f"Daily credit refill failed: {result['message']}")
except Exception as 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:
"""Manually trigger credit refill for testing purposes."""
logger.info("Manually triggering credit refill")
return CreditService.refill_all_users_credits()
app = self.app or current_app
with app.app_context():
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:
"""Get the current status of the scheduler."""
if self.scheduler is None:
return {"running": False, "jobs": []}
jobs = []
for job in self.scheduler.get_jobs():
jobs.append({
jobs = [
{
"id": job.id,
"name": job.name,
"next_run": job.next_run_time.isoformat()
"next_run": job.next_run_time.isoformat()
if job.next_run_time else None,
"trigger": str(job.trigger),
})
}
for job in self.scheduler.get_jobs()
]
return {
"running": self.scheduler.running,
@@ -104,4 +161,4 @@ class SchedulerService:
# Global scheduler instance
scheduler_service = SchedulerService()
scheduler_service = SchedulerService()

View 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}",
}

View 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)
],
}

View File

@@ -8,11 +8,13 @@ requires-python = ">=3.12"
dependencies = [
"apscheduler==3.11.0",
"authlib==1.6.0",
"ffmpeg-python>=0.2.0",
"flask==3.1.1",
"flask-cors==6.0.1",
"flask-jwt-extended==4.7.1",
"flask-migrate==4.1.0",
"flask-sqlalchemy==3.1.1",
"pydub==0.25.1",
"python-dotenv==1.1.1",
"requests==2.32.4",
"werkzeug==3.1.3",

View File

@@ -1,5 +1,17 @@
#!/bin/bash
shopt -s extglob
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

5
sounds/normalized/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
*
!.gitignore
!say
!soundboard
!stream

2
sounds/normalized/say/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -0,0 +1,2 @@
*
!.gitignore

3
sounds/normalized/stream/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
*
!.gitignore
!thumbnails

2
sounds/say/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
sounds/soundboard/allez.wav Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
sounds/soundboard/awwww.mp3 Normal file

Binary file not shown.

BIN
sounds/soundboard/bebou.mp3 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
sounds/soundboard/bonk.mp3 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
sounds/soundboard/cou.wav Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
sounds/soundboard/fdp.mp3 Normal file

Binary file not shown.

BIN
sounds/soundboard/flute.wav Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More