feat: Add VLC service for sound playback and management

- Implemented VLCService to handle sound playback using VLC.
- Added routes for soundboard management including play, stop, and status.
- Introduced admin routes for sound normalization and scanning.
- Updated user model and services to accommodate new functionalities.
- Enhanced error handling and logging throughout the application.
- Updated dependencies to include python-vlc for sound playback capabilities.
This commit is contained in:
JSC
2025-07-03 21:25:50 +02:00
parent 8f17dd730a
commit 7455811860
20 changed files with 760 additions and 91 deletions

View File

@@ -149,7 +149,10 @@ class AuthService:
return None
def register_with_password(
self, email: str, password: str, name: str,
self,
email: str,
password: str,
name: str,
) -> Any:
"""Register new user with email and password."""
try:

View File

@@ -15,13 +15,13 @@ class CreditService:
@staticmethod
def refill_all_users_credits() -> dict:
"""Refill credits for all active users based on their plan.
This function:
1. Gets all active users
2. For each user, adds their plan's daily credit amount
3. Ensures credits never exceed the plan's max_credits limit
4. Updates all users in a single database transaction
Returns:
dict: Summary of the refill operation
@@ -44,7 +44,9 @@ class CreditService:
for user in users:
if not user.plan:
logger.warning(f"User {user.email} has no plan assigned, skipping")
logger.warning(
f"User {user.email} has no plan assigned, skipping"
)
continue
# Calculate new credit amount, capped at plan max
@@ -53,7 +55,9 @@ class CreditService:
max_credits = user.plan.max_credits
# Add daily credits but don't exceed maximum
new_credits = min(current_credits + plan_daily_credits, max_credits)
new_credits = min(
current_credits + plan_daily_credits, max_credits
)
credits_added = new_credits - current_credits
if credits_added > 0:
@@ -104,10 +108,10 @@ class CreditService:
@staticmethod
def get_user_credit_info(user_id: int) -> dict:
"""Get detailed credit information for a specific user.
Args:
user_id: The user's ID
Returns:
dict: User's credit information

View File

@@ -146,6 +146,26 @@ def require_role(required_role: str):
return decorator
def require_admin(f):
"""Decorator to require admin role for routes."""
@wraps(f)
def wrapper(*args, **kwargs):
user = get_current_user()
if not user:
return jsonify({"error": "Authentication required"}), 401
if user.get("role") != "admin":
return (
jsonify({"error": "Access denied. Admin role required"}),
403,
)
return f(*args, **kwargs)
return wrapper
def require_credits(credits_needed: int):
"""Decorator to require and deduct credits for routes."""

View File

@@ -49,7 +49,9 @@ class OAuthProvider(ABC):
return client.authorize_redirect(redirect_uri).location
def exchange_code_for_token(
self, code: str = None, redirect_uri: str = None,
self,
code: str = None,
redirect_uri: str = None,
) -> dict[str, Any]:
"""Exchange authorization code for access token."""
client = self.get_client()

View File

@@ -22,7 +22,9 @@ class OAuthProviderRegistry:
google_client_secret = os.getenv("GOOGLE_CLIENT_SECRET")
if google_client_id and google_client_secret:
self._providers["google"] = GoogleOAuthProvider(
self.oauth, google_client_id, google_client_secret,
self.oauth,
google_client_id,
google_client_secret,
)
# GitHub OAuth
@@ -30,7 +32,9 @@ class OAuthProviderRegistry:
github_client_secret = os.getenv("GITHUB_CLIENT_SECRET")
if github_client_id and github_client_secret:
self._providers["github"] = GitHubOAuthProvider(
self.oauth, github_client_id, github_client_secret,
self.oauth,
github_client_id,
github_client_secret,
)
def get_provider(self, name: str) -> OAuthProvider | None:

View File

@@ -97,7 +97,9 @@ class SchedulerService:
f"{result['credits_added']} credits added",
)
else:
logger.error(f"Daily credit refill failed: {result['message']}")
logger.error(
f"Daily credit refill failed: {result['message']}"
)
except Exception as e:
logger.exception(f"Error during daily credit refill: {e}")
@@ -119,7 +121,9 @@ class SchedulerService:
else:
logger.debug("Sound scan completed: no new files found")
else:
logger.error(f"Sound scan failed: {result.get('error', 'Unknown error')}")
logger.error(
f"Sound scan failed: {result.get('error', 'Unknown error')}"
)
except Exception as e:
logger.exception(f"Error during sound scan: {e}")
@@ -148,7 +152,8 @@ class SchedulerService:
"id": job.id,
"name": job.name,
"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),
}
for job in self.scheduler.get_jobs()

View File

@@ -38,7 +38,9 @@ class SoundNormalizerService:
}
@staticmethod
def normalize_sound(sound_id: int, overwrite: bool = False, two_pass: bool = True) -> dict:
def normalize_sound(
sound_id: int, overwrite: bool = False, two_pass: bool = True
) -> dict:
"""Normalize a specific sound file using ffmpeg loudnorm.
Args:
@@ -58,7 +60,9 @@ class SoundNormalizerService:
"error": f"Sound with ID {sound_id} not found",
}
source_path = Path(SoundNormalizerService.SOUNDS_DIR) / sound.filename
source_path = (
Path(SoundNormalizerService.SOUNDS_DIR) / sound.filename
)
if not source_path.exists():
return {
"success": False,
@@ -68,7 +72,10 @@ class SoundNormalizerService:
# 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 = (
Path(SoundNormalizerService.NORMALIZED_DIR)
/ normalized_filename
)
normalized_path.parent.mkdir(parents=True, exist_ok=True)
@@ -84,11 +91,15 @@ class SoundNormalizerService:
if two_pass:
result = SoundNormalizerService._normalize_with_ffmpeg(
str(source_path), str(normalized_path),
str(source_path),
str(normalized_path),
)
else:
result = SoundNormalizerService._normalize_with_ffmpeg_single_pass(
str(source_path), str(normalized_path),
result = (
SoundNormalizerService._normalize_with_ffmpeg_single_pass(
str(source_path),
str(normalized_path),
)
)
if result["success"]:
@@ -131,7 +142,9 @@ class SoundNormalizerService:
@staticmethod
def normalize_all_sounds(
overwrite: bool = False, limit: int = None, two_pass: bool = True,
overwrite: bool = False,
limit: int = None,
two_pass: bool = True,
) -> dict:
"""Normalize all soundboard files.
@@ -171,7 +184,9 @@ class SoundNormalizerService:
for sound in sounds:
result = SoundNormalizerService.normalize_sound(
sound.id, overwrite, two_pass,
sound.id,
overwrite,
two_pass,
)
processed += 1
@@ -233,19 +248,19 @@ class SoundNormalizerService:
# FIRST PASS: Analyze the audio to get optimal parameters
logger.debug("Starting first pass (analysis)")
first_pass_result = SoundNormalizerService._run_first_pass(
source_path, params
)
if not first_pass_result["success"]:
return first_pass_result
measured_params = first_pass_result["measured_params"]
# SECOND PASS: Apply normalization using measured parameters
logger.debug("Starting second pass (normalization)")
second_pass_result = SoundNormalizerService._run_second_pass(
source_path, output_path, params, measured_params
)
@@ -281,9 +296,11 @@ class SoundNormalizerService:
return {"success": False, "error": str(e)}
@staticmethod
def _normalize_with_ffmpeg_single_pass(source_path: str, output_path: str) -> dict:
def _normalize_with_ffmpeg_single_pass(
source_path: str, output_path: str
) -> dict:
"""Run ffmpeg loudnorm on a single file using single-pass normalization.
This is the legacy single-pass method for backward compatibility.
Args:
@@ -319,7 +336,9 @@ class SoundNormalizerService:
# Run the ffmpeg process
out, err = ffmpeg.run(
output_stream, capture_stdout=True, capture_stderr=True,
output_stream,
capture_stdout=True,
capture_stderr=True,
)
# Parse loudnorm statistics from stderr
@@ -348,11 +367,11 @@ class SoundNormalizerService:
@staticmethod
def _run_first_pass(source_path: str, params: dict) -> dict:
"""Run first pass of loudnorm to analyze audio characteristics.
Args:
source_path: Path to source audio file
params: Loudnorm target parameters
Returns:
dict: Result with measured parameters and analysis stats
"""
@@ -370,35 +389,36 @@ class SoundNormalizerService:
# Output to null device for analysis
output_stream = ffmpeg.output(
input_stream,
"/dev/null",
af=loudnorm_filter,
f="null"
input_stream, "/dev/null", af=loudnorm_filter, f="null"
)
# Run the first pass
out, err = ffmpeg.run(
output_stream, capture_stdout=True, capture_stderr=True,
output_stream,
capture_stdout=True,
capture_stderr=True,
)
stderr_text = err.decode() if err else ""
# Parse measured parameters from JSON output
measured_params = SoundNormalizerService._parse_measured_params(stderr_text)
measured_params = SoundNormalizerService._parse_measured_params(
stderr_text
)
if not measured_params:
return {
"success": False,
"error": "Failed to parse measured parameters from first pass"
"error": "Failed to parse measured parameters from first pass",
}
# Parse basic stats
stats = SoundNormalizerService._parse_loudnorm_stats(stderr_text)
return {
"success": True,
"measured_params": measured_params,
"stats": stats
"stats": stats,
}
except ffmpeg.Error as e:
@@ -410,15 +430,20 @@ class SoundNormalizerService:
return {"success": False, "error": str(e)}
@staticmethod
def _run_second_pass(source_path: str, output_path: str, target_params: dict, measured_params: dict) -> dict:
def _run_second_pass(
source_path: str,
output_path: str,
target_params: dict,
measured_params: dict,
) -> dict:
"""Run second pass of loudnorm using measured parameters.
Args:
source_path: Path to source audio file
output_path: Path for normalized output file
target_params: Target loudnorm parameters
measured_params: Parameters measured from first pass
Returns:
dict: Result with normalization stats
"""
@@ -452,18 +477,17 @@ class SoundNormalizerService:
# Run the second pass
out, err = ffmpeg.run(
output_stream, capture_stdout=True, capture_stderr=True,
output_stream,
capture_stdout=True,
capture_stderr=True,
)
stderr_text = err.decode() if err else ""
# Parse final statistics
stats = SoundNormalizerService._parse_loudnorm_stats(stderr_text)
return {
"success": True,
"stats": stats
}
return {"success": True, "stats": stats}
except ffmpeg.Error as e:
error_msg = f"Second pass FFmpeg error: {e.stderr.decode() if e.stderr else str(e)}"
@@ -476,23 +500,25 @@ class SoundNormalizerService:
@staticmethod
def _parse_measured_params(stderr_output: str) -> dict:
"""Parse measured parameters from first pass JSON output.
Args:
stderr_output: ffmpeg stderr output containing JSON data
Returns:
dict: Parsed measured parameters, empty if parsing fails
"""
try:
# Find JSON block in stderr output
json_match = re.search(r'\{[^}]*"input_i"[^}]*\}', stderr_output, re.DOTALL)
json_match = re.search(
r'\{[^}]*"input_i"[^}]*\}', stderr_output, re.DOTALL
)
if not json_match:
logger.warning("No JSON block found in first pass output")
return {}
json_str = json_match.group(0)
measured_data = json.loads(json_str)
# Extract required parameters
return {
"input_i": measured_data.get("input_i", 0),
@@ -501,7 +527,7 @@ class SoundNormalizerService:
"input_thresh": measured_data.get("input_thresh", 0),
"target_offset": measured_data.get("target_offset", 0),
}
except (json.JSONDecodeError, KeyError, AttributeError) as e:
logger.warning(f"Failed to parse measured parameters: {e}")
return {}
@@ -625,7 +651,9 @@ class SoundNormalizerService:
sounds = Sound.query.filter_by(type="SDB").all()
for sound in sounds:
original_path = Path(SoundNormalizerService.SOUNDS_DIR) / sound.filename
original_path = (
Path(SoundNormalizerService.SOUNDS_DIR) / sound.filename
)
if original_path.exists():
total_original_size += original_path.stat().st_size
@@ -633,7 +661,10 @@ class SoundNormalizerService:
# 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
normalized_path = (
Path(SoundNormalizerService.NORMALIZED_DIR)
/ sound.normalized_filename
)
if normalized_path.exists():
total_normalized_size += normalized_path.stat().st_size
@@ -676,7 +707,8 @@ class SoundNormalizerService:
import tempfile
with tempfile.NamedTemporaryFile(
suffix=".wav", delete=False,
suffix=".wav",
delete=False,
) as temp_file:
temp_path = temp_file.name

View File

@@ -83,7 +83,9 @@ class SoundScannerService:
files_added += 1
logger.debug(f"Added sound: {filename}")
elif result.get("updated"):
files_added += 1 # Count updates as additions for reporting
files_added += (
1 # Count updates as additions for reporting
)
logger.debug(f"Updated sound: {filename}")
else:
files_skipped += 1
@@ -171,7 +173,7 @@ class SoundScannerService:
# 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),
@@ -179,7 +181,7 @@ class SoundScannerService:
size=metadata["size"],
hash_value=file_hash,
)
return {
"added": False,
"updated": True,
@@ -233,15 +235,22 @@ class SoundScannerService:
"""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
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}")
logger.warning(
f"Could not remove normalized file {normalized_path}: {e}"
)
@staticmethod
def _extract_audio_metadata(file_path: str) -> dict:

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

@@ -0,0 +1,194 @@
"""VLC service for playing sounds."""
import os
import threading
import time
import uuid
from typing import Any, Dict, Optional
import vlc
from app.database import db
from app.models.sound import Sound
class VLCService:
"""Service for playing sounds using VLC."""
def __init__(self) -> None:
"""Initialize VLC service."""
self.instances: Dict[str, Dict[str, Any]] = {}
self.lock = threading.Lock()
def play_sound(self, sound_id: int) -> bool:
"""Play a sound by ID using VLC."""
with self.lock:
# Get sound from database
sound = Sound.query.get(sound_id)
if not sound:
return False
# Use normalized file if available, otherwise use original
if sound.is_normalized and sound.normalized_filename:
sound_path = os.path.join(
"sounds",
"normalized",
"soundboard",
# sound.type.lower(),
sound.normalized_filename,
)
else:
sound_path = os.path.join(
"sounds", "soundboard", sound.filename
)
# Check if file exists
if not os.path.exists(sound_path):
return False
# Create VLC instance
instance = vlc.Instance()
player = instance.media_player_new()
# Load and play media
media = instance.media_new(sound_path)
player.set_media(media)
# Start playback
player.play()
# Store instance for cleanup with unique ID
instance_id = f"sound_{sound_id}_{uuid.uuid4().hex[:8]}_{int(time.time())}"
self.instances[instance_id] = {
"instance": instance,
"player": player,
"sound_id": sound_id,
"created_at": time.time(),
}
print(f"Created instance {instance_id} for sound {sound.name}. Total instances: {len(self.instances)}")
# Increment play count
sound.increment_play_count()
# Schedule cleanup
threading.Thread(
target=self._cleanup_after_playback,
args=(instance_id, sound.duration if sound.duration else 10),
daemon=True,
).start()
return True
def _cleanup_after_playback(self, instance_id: str, duration: int) -> None:
"""Clean up VLC instance after playback."""
# Wait for playback to finish (duration + 1 second buffer)
time.sleep(duration / 1000 + 1) # Convert ms to seconds
with self.lock:
if instance_id in self.instances:
print(f"Cleaning up instance {instance_id} after playback")
instance_data = self.instances[instance_id]
player = instance_data["player"]
instance = instance_data["instance"]
try:
# Stop player if still playing
if player.is_playing():
player.stop()
# Release resources
player.release()
instance.release()
print(f"Successfully cleaned up instance {instance_id}")
except Exception as e:
print(f"Error during cleanup of {instance_id}: {e}")
finally:
# Always remove from tracking
del self.instances[instance_id]
print(f"Removed instance {instance_id}. Remaining instances: {len(self.instances)}")
else:
print(f"Instance {instance_id} not found during cleanup")
def stop_all(self) -> None:
"""Stop all playing sounds."""
with self.lock:
# Create a copy of the instances to avoid race conditions
instances_copy = dict(self.instances)
print(f"Stopping {len(instances_copy)} instances: {list(instances_copy.keys())}")
for instance_id, instance_data in instances_copy.items():
try:
player = instance_data["player"]
instance = instance_data["instance"]
print(f"Stopping instance {instance_id}")
# Force stop the player regardless of state
player.stop()
# Give VLC a moment to process the stop command
time.sleep(0.1)
# Release the media player and instance
player.release()
instance.release()
print(f"Successfully stopped instance {instance_id}")
except Exception as e:
# Log the error but continue stopping other instances
print(f"Error stopping instance {instance_id}: {e}")
# Clear all instances
self.instances.clear()
print(f"Cleared all instances. Remaining: {len(self.instances)}")
def get_playing_count(self) -> int:
"""Get number of currently playing sounds."""
with self.lock:
return len(self.instances)
def force_stop_all(self) -> int:
"""Force stop all sounds and clean up resources. Returns count of stopped instances."""
with self.lock:
stopped_count = len(self.instances)
print(f"Force stopping {stopped_count} instances: {list(self.instances.keys())}")
# More aggressive cleanup
for instance_id, instance_data in list(self.instances.items()):
try:
player = instance_data["player"]
instance = instance_data["instance"]
print(f"Force stopping instance {instance_id}")
# Multiple stop attempts
for attempt in range(3):
if hasattr(player, 'stop'):
player.stop()
print(f"Stop attempt {attempt + 1} for {instance_id}")
time.sleep(0.05) # Short delay between attempts
# Force release
if hasattr(player, 'release'):
player.release()
print(f"Released player for {instance_id}")
if hasattr(instance, 'release'):
instance.release()
print(f"Released instance for {instance_id}")
except Exception as e:
print(f"Error force-stopping instance {instance_id}: {e}")
finally:
# Always remove from tracking
if instance_id in self.instances:
del self.instances[instance_id]
print(f"Removed {instance_id} from tracking")
print(f"Force stop completed. Instances remaining: {len(self.instances)}")
return stopped_count
# Global VLC service instance
vlc_service = VLCService()