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:
@@ -92,4 +92,4 @@ def normalization_status() -> dict:
|
||||
@require_role("admin")
|
||||
def ffmpeg_check() -> dict:
|
||||
"""Check ffmpeg availability and capabilities (admin only)."""
|
||||
return SoundNormalizerService.check_ffmpeg_availability()
|
||||
return SoundNormalizerService.check_ffmpeg_availability()
|
||||
|
||||
236
app/routes/admin_sounds.py
Normal file
236
app/routes/admin_sounds.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""Admin sound management routes."""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from app.models.sound import Sound
|
||||
from app.services.sound_scanner_service import SoundScannerService
|
||||
from app.services.sound_normalizer_service import SoundNormalizerService
|
||||
from app.services.decorators import require_admin
|
||||
|
||||
bp = Blueprint("admin_sounds", __name__, url_prefix="/api/admin/sounds")
|
||||
|
||||
|
||||
@bp.route("/scan", methods=["POST"])
|
||||
@require_admin
|
||||
def scan_sounds():
|
||||
"""Manually trigger sound scanning."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
directory = data.get("directory")
|
||||
|
||||
result = SoundScannerService.scan_soundboard_directory(directory)
|
||||
|
||||
if result["success"]:
|
||||
return jsonify(result), 200
|
||||
else:
|
||||
return jsonify(result), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route("/scan/status", methods=["GET"])
|
||||
@require_admin
|
||||
def get_scan_status():
|
||||
"""Get current scan statistics and status."""
|
||||
try:
|
||||
stats = SoundScannerService.get_scan_statistics()
|
||||
return jsonify(stats), 200
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route("/normalize", methods=["POST"])
|
||||
@require_admin
|
||||
def normalize_sounds():
|
||||
"""Normalize sounds (all or specific)."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
sound_id = data.get("sound_id")
|
||||
overwrite = data.get("overwrite", False)
|
||||
two_pass = data.get("two_pass", True)
|
||||
limit = data.get("limit")
|
||||
|
||||
if sound_id:
|
||||
# Normalize specific sound
|
||||
result = SoundNormalizerService.normalize_sound(
|
||||
sound_id, overwrite, two_pass
|
||||
)
|
||||
else:
|
||||
# Normalize all sounds
|
||||
result = SoundNormalizerService.normalize_all_sounds(
|
||||
overwrite, limit, two_pass
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
return jsonify(result), 200
|
||||
else:
|
||||
return jsonify(result), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route("/normalize/status", methods=["GET"])
|
||||
@require_admin
|
||||
def get_normalization_status():
|
||||
"""Get normalization statistics and status."""
|
||||
try:
|
||||
status = SoundNormalizerService.get_normalization_status()
|
||||
return jsonify(status), 200
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route("/ffmpeg/check", methods=["GET"])
|
||||
@require_admin
|
||||
def check_ffmpeg():
|
||||
"""Check ffmpeg availability and capabilities."""
|
||||
try:
|
||||
ffmpeg_status = SoundNormalizerService.check_ffmpeg_availability()
|
||||
return jsonify(ffmpeg_status), 200
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route("/list", methods=["GET"])
|
||||
@require_admin
|
||||
def list_sounds():
|
||||
"""Get detailed list of all sounds with normalization status."""
|
||||
try:
|
||||
sound_type = request.args.get("type", "SDB")
|
||||
page = int(request.args.get("page", 1))
|
||||
per_page = int(request.args.get("per_page", 50))
|
||||
|
||||
# Validate sound type
|
||||
if sound_type not in ["SDB", "SAY", "STR"]:
|
||||
return jsonify({"error": "Invalid sound type"}), 400
|
||||
|
||||
# Get paginated results
|
||||
sounds_query = Sound.query.filter_by(type=sound_type)
|
||||
total = sounds_query.count()
|
||||
|
||||
sounds = (
|
||||
sounds_query.offset((page - 1) * per_page).limit(per_page).all()
|
||||
)
|
||||
|
||||
# Convert to detailed dict format
|
||||
sounds_data = []
|
||||
for sound in sounds:
|
||||
sound_dict = sound.to_dict()
|
||||
# Add file existence status
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
original_path = os.path.join(
|
||||
"sounds", sound.type.lower(), sound.filename
|
||||
)
|
||||
sound_dict["original_exists"] = os.path.exists(original_path)
|
||||
|
||||
if sound.is_normalized and sound.normalized_filename:
|
||||
normalized_path = os.path.join(
|
||||
"sounds",
|
||||
"normalized",
|
||||
sound.type.lower(),
|
||||
sound.normalized_filename,
|
||||
)
|
||||
sound_dict["normalized_exists"] = os.path.exists(
|
||||
normalized_path
|
||||
)
|
||||
else:
|
||||
sound_dict["normalized_exists"] = False
|
||||
|
||||
sounds_data.append(sound_dict)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"sounds": sounds_data,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total": total,
|
||||
"pages": (total + per_page - 1) // per_page,
|
||||
},
|
||||
"type": sound_type,
|
||||
}
|
||||
), 200
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route("/<int:sound_id>", methods=["DELETE"])
|
||||
@require_admin
|
||||
def delete_sound(sound_id: int):
|
||||
"""Delete a sound and its files."""
|
||||
try:
|
||||
sound = Sound.query.get(sound_id)
|
||||
if not sound:
|
||||
return jsonify({"error": "Sound not found"}), 404
|
||||
|
||||
if not sound.is_deletable:
|
||||
return jsonify({"error": "Sound is not deletable"}), 403
|
||||
|
||||
# Delete normalized file if exists
|
||||
if sound.is_normalized and sound.normalized_filename:
|
||||
import os
|
||||
|
||||
normalized_path = os.path.join(
|
||||
"sounds",
|
||||
"normalized",
|
||||
sound.type.lower(),
|
||||
sound.normalized_filename,
|
||||
)
|
||||
if os.path.exists(normalized_path):
|
||||
try:
|
||||
os.remove(normalized_path)
|
||||
except Exception as e:
|
||||
return jsonify(
|
||||
{"error": f"Failed to delete normalized file: {e}"}
|
||||
), 500
|
||||
|
||||
# Delete original file
|
||||
import os
|
||||
|
||||
original_path = os.path.join(
|
||||
"sounds", sound.type.lower(), sound.filename
|
||||
)
|
||||
if os.path.exists(original_path):
|
||||
try:
|
||||
os.remove(original_path)
|
||||
except Exception as e:
|
||||
return jsonify(
|
||||
{"error": f"Failed to delete original file: {e}"}
|
||||
), 500
|
||||
|
||||
# Delete database record
|
||||
from app.database import db
|
||||
|
||||
db.session.delete(sound)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"message": f"Sound '{sound.name}' deleted successfully",
|
||||
"sound_id": sound_id,
|
||||
}
|
||||
), 200
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route("/<int:sound_id>/normalize", methods=["POST"])
|
||||
@require_admin
|
||||
def normalize_single_sound(sound_id: int):
|
||||
"""Normalize a specific sound."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
overwrite = data.get("overwrite", False)
|
||||
two_pass = data.get("two_pass", True)
|
||||
|
||||
result = SoundNormalizerService.normalize_sound(
|
||||
sound_id, overwrite, two_pass
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
return jsonify(result), 200
|
||||
else:
|
||||
return jsonify(result), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
@@ -128,7 +128,9 @@ def refresh():
|
||||
def link_provider(provider):
|
||||
"""Link a new OAuth provider to current user account."""
|
||||
redirect_uri = url_for(
|
||||
"auth.link_callback", provider=provider, _external=True,
|
||||
"auth.link_callback",
|
||||
provider=provider,
|
||||
_external=True,
|
||||
)
|
||||
return auth_service.redirect_to_login(provider, redirect_uri)
|
||||
|
||||
@@ -174,7 +176,8 @@ def link_callback(provider):
|
||||
from app.models.user_oauth import UserOAuth
|
||||
|
||||
existing_provider = UserOAuth.find_by_provider_and_id(
|
||||
provider, provider_data["id"],
|
||||
provider,
|
||||
provider_data["id"],
|
||||
)
|
||||
|
||||
if existing_provider and existing_provider.user_id != user.id:
|
||||
|
||||
@@ -39,8 +39,6 @@ def api_protected() -> dict[str, str]:
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@bp.route("/health")
|
||||
def health() -> dict[str, str]:
|
||||
"""Health check endpoint."""
|
||||
@@ -72,5 +70,3 @@ def expensive_operation() -> dict[str, str]:
|
||||
"user": user["email"],
|
||||
"operation_cost": 10,
|
||||
}
|
||||
|
||||
|
||||
|
||||
121
app/routes/soundboard.py
Normal file
121
app/routes/soundboard.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Soundboard routes."""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from app.models.sound import Sound, SoundType
|
||||
from app.services.vlc_service import vlc_service
|
||||
from app.services.decorators import require_auth
|
||||
|
||||
bp = Blueprint("soundboard", __name__, url_prefix="/api/soundboard")
|
||||
|
||||
|
||||
@bp.route("/sounds", methods=["GET"])
|
||||
@require_auth
|
||||
def get_sounds():
|
||||
"""Get all soundboard sounds."""
|
||||
try:
|
||||
# Get filter parameters
|
||||
sound_type = request.args.get("type", "SDB")
|
||||
|
||||
# Validate sound type
|
||||
if sound_type not in [t.value for t in SoundType]:
|
||||
return jsonify({"error": "Invalid sound type"}), 400
|
||||
|
||||
# Get sounds from database
|
||||
sounds = Sound.find_by_type(sound_type)
|
||||
|
||||
# Convert to dict format
|
||||
sounds_data = [sound.to_dict() for sound in sounds]
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"sounds": sounds_data,
|
||||
"total": len(sounds_data),
|
||||
"type": sound_type,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route("/sounds/<int:sound_id>/play", methods=["POST"])
|
||||
@require_auth
|
||||
def play_sound(sound_id: int):
|
||||
"""Play a specific sound."""
|
||||
try:
|
||||
success = vlc_service.play_sound(sound_id)
|
||||
|
||||
if success:
|
||||
return jsonify({"message": "Sound playing", "sound_id": sound_id})
|
||||
else:
|
||||
return jsonify(
|
||||
{"error": "Sound not found or cannot be played"}
|
||||
), 404
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route("/stop-all", methods=["POST"])
|
||||
@require_auth
|
||||
def stop_all_sounds():
|
||||
"""Stop all currently playing sounds."""
|
||||
try:
|
||||
# Try normal stop first
|
||||
vlc_service.stop_all()
|
||||
|
||||
# Wait a moment and check if any are still playing
|
||||
import time
|
||||
time.sleep(0.2)
|
||||
|
||||
# If there are still instances, force stop them
|
||||
if vlc_service.get_playing_count() > 0:
|
||||
stopped_count = vlc_service.force_stop_all()
|
||||
return jsonify({
|
||||
"message": f"Force stopped {stopped_count} sounds",
|
||||
"forced": True
|
||||
})
|
||||
|
||||
return jsonify({"message": "All sounds stopped"})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route("/force-stop", methods=["POST"])
|
||||
@require_auth
|
||||
def force_stop_all_sounds():
|
||||
"""Force stop all sounds with aggressive cleanup."""
|
||||
try:
|
||||
stopped_count = vlc_service.force_stop_all()
|
||||
return jsonify({
|
||||
"message": f"Force stopped {stopped_count} sound instances",
|
||||
"stopped_count": stopped_count
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route("/status", methods=["GET"])
|
||||
@require_auth
|
||||
def get_status():
|
||||
"""Get current playback status."""
|
||||
try:
|
||||
playing_count = vlc_service.get_playing_count()
|
||||
|
||||
# Get detailed instance information
|
||||
with vlc_service.lock:
|
||||
instances = []
|
||||
for instance_id, instance_data in vlc_service.instances.items():
|
||||
instances.append({
|
||||
"id": instance_id,
|
||||
"sound_id": instance_data.get("sound_id"),
|
||||
"created_at": instance_data.get("created_at"),
|
||||
})
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"playing_count": playing_count,
|
||||
"is_playing": playing_count > 0,
|
||||
"instances": instances,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
Reference in New Issue
Block a user