Compare commits
2 Commits
41fc197f4c
...
f68d046653
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f68d046653 | ||
|
|
e2fe451e5a |
@@ -37,7 +37,9 @@ def create_app():
|
||||
app.config["JWT_TOKEN_LOCATION"] = ["cookies"]
|
||||
app.config["JWT_COOKIE_SECURE"] = False # Set to True in production
|
||||
app.config["JWT_COOKIE_CSRF_PROTECT"] = False
|
||||
app.config["JWT_ACCESS_COOKIE_PATH"] = "/" # Allow access to all paths including SocketIO
|
||||
app.config["JWT_ACCESS_COOKIE_PATH"] = (
|
||||
"/" # Allow access to all paths including SocketIO
|
||||
)
|
||||
app.config["JWT_REFRESH_COOKIE_PATH"] = "/api/auth/refresh"
|
||||
|
||||
# Initialize CORS
|
||||
|
||||
@@ -13,6 +13,6 @@ def init_db(app):
|
||||
migrate.init_app(app, db)
|
||||
|
||||
# Import models here to ensure they are registered with SQLAlchemy
|
||||
from app.models import user, user_oauth, sound_played # noqa: F401
|
||||
from app.models import sound_played, user, user_oauth # noqa: F401
|
||||
|
||||
return db
|
||||
|
||||
@@ -189,7 +189,7 @@ class SoundPlayed(db.Model):
|
||||
if result.last_played
|
||||
else None
|
||||
),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return popular_sounds
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""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
|
||||
from app.services.error_handling_service import ErrorHandlingService
|
||||
from app.services.sound_normalizer_service import SoundNormalizerService
|
||||
from app.services.sound_scanner_service import SoundScannerService
|
||||
|
||||
bp = Blueprint("admin_sounds", __name__, url_prefix="/api/admin/sounds")
|
||||
|
||||
@@ -13,29 +14,19 @@ bp = Blueprint("admin_sounds", __name__, url_prefix="/api/admin/sounds")
|
||||
@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
|
||||
return ErrorHandlingService.wrap_service_call(
|
||||
SoundScannerService.scan_soundboard_directory,
|
||||
request.get_json().get("directory") if request.get_json() else None,
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
return ErrorHandlingService.wrap_service_call(
|
||||
SoundScannerService.get_scan_statistics,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/normalize", methods=["POST"])
|
||||
@@ -52,18 +43,21 @@ def normalize_sounds():
|
||||
if sound_id:
|
||||
# Normalize specific sound
|
||||
result = SoundNormalizerService.normalize_sound(
|
||||
sound_id, overwrite, two_pass
|
||||
sound_id,
|
||||
overwrite,
|
||||
two_pass,
|
||||
)
|
||||
else:
|
||||
# Normalize all sounds
|
||||
result = SoundNormalizerService.normalize_all_sounds(
|
||||
overwrite, limit, two_pass
|
||||
overwrite,
|
||||
limit,
|
||||
two_pass,
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
return jsonify(result), 200
|
||||
else:
|
||||
return jsonify(result), 400
|
||||
return jsonify(result), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@@ -94,125 +88,26 @@ def check_ffmpeg():
|
||||
@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))
|
||||
from app.services.sound_management_service import SoundManagementService
|
||||
|
||||
# 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
|
||||
return ErrorHandlingService.wrap_service_call(
|
||||
SoundManagementService.get_sounds_with_file_status,
|
||||
request.args.get("type", "SDB"),
|
||||
int(request.args.get("page", 1)),
|
||||
int(request.args.get("per_page", 50)),
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
from app.services.sound_management_service import SoundManagementService
|
||||
|
||||
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
|
||||
return ErrorHandlingService.wrap_service_call(
|
||||
SoundManagementService.delete_sound_with_files,
|
||||
sound_id,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/<int:sound_id>/normalize", methods=["POST"])
|
||||
@@ -220,17 +115,20 @@ def delete_sound(sound_id: int):
|
||||
def normalize_single_sound(sound_id: int):
|
||||
"""Normalize a specific sound."""
|
||||
try:
|
||||
from app.services.sound_management_service import SoundManagementService
|
||||
|
||||
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
|
||||
result = SoundManagementService.normalize_sound(
|
||||
sound_id,
|
||||
overwrite,
|
||||
two_pass,
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
return jsonify(result), 200
|
||||
else:
|
||||
return jsonify(result), 400
|
||||
return jsonify(result), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@@ -140,63 +140,27 @@ def link_provider(provider):
|
||||
def link_callback(provider):
|
||||
"""Handle OAuth callback for linking new provider."""
|
||||
try:
|
||||
from app.services.oauth_linking_service import OAuthLinkingService
|
||||
|
||||
current_user_id = get_jwt_identity()
|
||||
if not current_user_id:
|
||||
return {"error": "User not authenticated"}, 401
|
||||
|
||||
# Get current user from database
|
||||
from app.models.user import User
|
||||
|
||||
user = User.query.get(current_user_id)
|
||||
if not user:
|
||||
return {"error": "User not found"}, 404
|
||||
|
||||
# Process OAuth callback but link to existing user
|
||||
from authlib.integrations.flask_client import OAuth
|
||||
|
||||
from app.services.oauth_providers.registry import OAuthProviderRegistry
|
||||
|
||||
oauth = OAuth()
|
||||
registry = OAuthProviderRegistry(oauth)
|
||||
oauth_provider = registry.get_provider(provider)
|
||||
|
||||
if not oauth_provider:
|
||||
return {"error": f"OAuth provider '{provider}' not configured"}, 400
|
||||
|
||||
token = oauth_provider.exchange_code_for_token(None, None)
|
||||
raw_user_info = oauth_provider.get_user_info(token)
|
||||
provider_data = oauth_provider.normalize_user_data(raw_user_info)
|
||||
|
||||
if not provider_data.get("id"):
|
||||
return {
|
||||
"error": "Failed to get user information from provider",
|
||||
}, 400
|
||||
|
||||
# Check if this provider is already linked to another user
|
||||
from app.models.user_oauth import UserOAuth
|
||||
|
||||
existing_provider = UserOAuth.find_by_provider_and_id(
|
||||
result = OAuthLinkingService.link_provider_to_user(
|
||||
provider,
|
||||
provider_data["id"],
|
||||
current_user_id,
|
||||
)
|
||||
return result
|
||||
|
||||
if existing_provider and existing_provider.user_id != user.id:
|
||||
return {
|
||||
"error": "This provider account is already linked to another user",
|
||||
}, 409
|
||||
|
||||
# Link the provider to current user
|
||||
UserOAuth.create_or_update(
|
||||
user_id=user.id,
|
||||
provider=provider,
|
||||
provider_id=provider_data["id"],
|
||||
email=provider_data["email"],
|
||||
name=provider_data["name"],
|
||||
picture=provider_data.get("picture"),
|
||||
)
|
||||
|
||||
return {"message": f"{provider.title()} account linked successfully"}
|
||||
|
||||
except ValueError as e:
|
||||
error_str = str(e)
|
||||
if "not found" in error_str:
|
||||
return {"error": error_str}, 404
|
||||
if "not configured" in error_str:
|
||||
return {"error": error_str}, 400
|
||||
if "already linked" in error_str:
|
||||
return {"error": error_str}, 409
|
||||
return {"error": error_str}, 400
|
||||
except Exception as e:
|
||||
return {"error": str(e)}, 400
|
||||
|
||||
@@ -206,33 +170,27 @@ def link_callback(provider):
|
||||
def unlink_provider(provider):
|
||||
"""Unlink an OAuth provider from current user account."""
|
||||
try:
|
||||
from app.services.oauth_linking_service import OAuthLinkingService
|
||||
|
||||
current_user_id = get_jwt_identity()
|
||||
if not current_user_id:
|
||||
return {"error": "User not authenticated"}, 401
|
||||
|
||||
from app.database import db
|
||||
from app.models.user import User
|
||||
|
||||
user = User.query.get(current_user_id)
|
||||
if not user:
|
||||
return {"error": "User not found"}, 404
|
||||
|
||||
# Check if user has more than one provider (prevent locking out)
|
||||
if len(user.oauth_providers) <= 1:
|
||||
return {"error": "Cannot unlink last authentication provider"}, 400
|
||||
|
||||
# Find and remove the provider
|
||||
oauth_provider = user.get_provider(provider)
|
||||
if not oauth_provider:
|
||||
return {
|
||||
"error": f"Provider '{provider}' not linked to this account",
|
||||
}, 404
|
||||
|
||||
db.session.delete(oauth_provider)
|
||||
db.session.commit()
|
||||
|
||||
return {"message": f"{provider.title()} account unlinked successfully"}
|
||||
result = OAuthLinkingService.unlink_provider_from_user(
|
||||
provider,
|
||||
current_user_id,
|
||||
)
|
||||
return result
|
||||
|
||||
except ValueError as e:
|
||||
error_str = str(e)
|
||||
if "not found" in error_str:
|
||||
return {"error": error_str}, 404
|
||||
if "Cannot unlink" in error_str:
|
||||
return {"error": error_str}, 400
|
||||
if "not linked" in error_str:
|
||||
return {"error": error_str}, 404
|
||||
return {"error": error_str}, 400
|
||||
except Exception as e:
|
||||
return {"error": str(e)}, 400
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ def get_sounds():
|
||||
"sounds": sounds_data,
|
||||
"total": len(sounds_data),
|
||||
"type": sound_type,
|
||||
}
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
@@ -60,11 +60,10 @@ def play_sound(sound_id: int):
|
||||
|
||||
if success:
|
||||
return jsonify({"message": "Sound playing", "sound_id": sound_id})
|
||||
else:
|
||||
return (
|
||||
jsonify({"error": "Sound not found or cannot be played"}),
|
||||
404,
|
||||
)
|
||||
return (
|
||||
jsonify({"error": "Sound not found or cannot be played"}),
|
||||
404,
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@@ -89,7 +88,7 @@ def stop_all_sounds():
|
||||
{
|
||||
"message": f"Force stopped {stopped_count} sounds",
|
||||
"forced": True,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return jsonify({"message": "All sounds stopped"})
|
||||
@@ -107,7 +106,7 @@ def force_stop_all_sounds():
|
||||
{
|
||||
"message": f"Force stopped {stopped_count} sound instances",
|
||||
"stopped_count": stopped_count,
|
||||
}
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
@@ -129,7 +128,7 @@ def get_status():
|
||||
"id": process_id,
|
||||
"pid": process.pid,
|
||||
"running": process.poll() is None,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
@@ -137,7 +136,7 @@ def get_status():
|
||||
"playing_count": playing_count,
|
||||
"is_playing": playing_count > 0,
|
||||
"processes": processes,
|
||||
}
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
@@ -153,7 +152,8 @@ def get_play_history():
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
recent_plays = SoundPlayed.get_recent_plays(
|
||||
limit=per_page, offset=offset
|
||||
limit=per_page,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
@@ -161,7 +161,7 @@ def get_play_history():
|
||||
"plays": [play.to_dict() for play in recent_plays],
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
}
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
@@ -182,7 +182,9 @@ def get_my_play_history():
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
user_plays = SoundPlayed.get_user_plays(
|
||||
user_id=user_id, limit=per_page, offset=offset
|
||||
user_id=user_id,
|
||||
limit=per_page,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
@@ -191,7 +193,7 @@ def get_my_play_history():
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"user_id": user_id,
|
||||
}
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
@@ -230,7 +232,7 @@ def get_popular_sounds():
|
||||
"popular_sounds": popular_sounds,
|
||||
"limit": limit,
|
||||
"days": days,
|
||||
}
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@@ -46,7 +46,7 @@ class CreditService:
|
||||
for user in users:
|
||||
if not user.plan:
|
||||
logger.warning(
|
||||
f"User {user.email} has no plan assigned, skipping"
|
||||
f"User {user.email} has no plan assigned, skipping",
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -57,7 +57,8 @@ class CreditService:
|
||||
|
||||
# Add daily credits but don't exceed maximum
|
||||
new_credits = min(
|
||||
current_credits + plan_daily_credits, max_credits
|
||||
current_credits + plan_daily_credits,
|
||||
max_credits,
|
||||
)
|
||||
credits_added = new_credits - current_credits
|
||||
|
||||
|
||||
@@ -188,10 +188,12 @@ def require_credits(credits_needed: int):
|
||||
# Emit credits changed event via SocketIO
|
||||
try:
|
||||
from app.services.socketio_service import socketio_service
|
||||
|
||||
socketio_service.emit_credits_changed(user.id, user.credits)
|
||||
except Exception as e:
|
||||
# Don't fail the request if SocketIO emission fails
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f"Failed to emit credits_changed event: {e}")
|
||||
|
||||
|
||||
133
app/services/error_handling_service.py
Normal file
133
app/services/error_handling_service.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Centralized error handling service for consistent API responses."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flask import jsonify
|
||||
|
||||
|
||||
class ErrorHandlingService:
|
||||
"""Service for standardized error handling and responses."""
|
||||
|
||||
@staticmethod
|
||||
def handle_validation_error(error: ValueError) -> tuple[Any, int]:
|
||||
"""Handle validation errors consistently."""
|
||||
error_str = str(error)
|
||||
|
||||
# Map common validation errors to appropriate HTTP status codes
|
||||
status_code = 400
|
||||
if "not found" in error_str.lower():
|
||||
status_code = 404
|
||||
elif (
|
||||
"not authorized" in error_str.lower()
|
||||
or "permission" in error_str.lower()
|
||||
):
|
||||
status_code = 403
|
||||
elif (
|
||||
"already exists" in error_str.lower()
|
||||
or "already linked" in error_str.lower()
|
||||
):
|
||||
status_code = 409
|
||||
elif (
|
||||
"not configured" in error_str.lower()
|
||||
or "cannot unlink" in error_str.lower()
|
||||
):
|
||||
status_code = 400
|
||||
elif "not deletable" in error_str.lower():
|
||||
status_code = 403
|
||||
|
||||
return jsonify({"error": error_str}), status_code
|
||||
|
||||
@staticmethod
|
||||
def handle_generic_error(error: Exception) -> tuple[Any, int]:
|
||||
"""Handle generic exceptions with 500 status."""
|
||||
return jsonify({"error": str(error)}), 500
|
||||
|
||||
@staticmethod
|
||||
def handle_service_result(result: dict) -> tuple[Any, int]:
|
||||
"""Handle service method results that return success/error dictionaries."""
|
||||
if result.get("success"):
|
||||
return jsonify(result), 200
|
||||
return jsonify(result), 400
|
||||
|
||||
@staticmethod
|
||||
def create_success_response(
|
||||
message: str,
|
||||
data: dict = None,
|
||||
status_code: int = 200,
|
||||
) -> tuple[Any, int]:
|
||||
"""Create a standardized success response."""
|
||||
response = {"message": message}
|
||||
if data:
|
||||
response.update(data)
|
||||
return jsonify(response), status_code
|
||||
|
||||
@staticmethod
|
||||
def create_error_response(
|
||||
message: str,
|
||||
status_code: int = 400,
|
||||
details: dict = None,
|
||||
) -> tuple[Any, int]:
|
||||
"""Create a standardized error response."""
|
||||
response = {"error": message}
|
||||
if details:
|
||||
response.update(details)
|
||||
return jsonify(response), status_code
|
||||
|
||||
@staticmethod
|
||||
def handle_auth_error(error_type: str) -> tuple[Any, int]:
|
||||
"""Handle common authentication errors."""
|
||||
auth_errors = {
|
||||
"user_not_authenticated": ("User not authenticated", 401),
|
||||
"user_not_found": ("User not found", 404),
|
||||
"invalid_credentials": ("Invalid credentials", 401),
|
||||
"account_disabled": ("Account is disabled", 401),
|
||||
"insufficient_credits": ("Insufficient credits", 402),
|
||||
"admin_required": ("Admin privileges required", 403),
|
||||
}
|
||||
|
||||
if error_type in auth_errors:
|
||||
message, status = auth_errors[error_type]
|
||||
return jsonify({"error": message}), status
|
||||
|
||||
return jsonify({"error": "Authentication error"}), 401
|
||||
|
||||
@staticmethod
|
||||
def handle_file_operation_error(
|
||||
operation: str, error: Exception
|
||||
) -> tuple[Any, int]:
|
||||
"""Handle file operation errors consistently."""
|
||||
error_message = f"Failed to {operation}: {error!s}"
|
||||
|
||||
# Check for specific file operation errors
|
||||
if (
|
||||
"not found" in str(error).lower()
|
||||
or "no such file" in str(error).lower()
|
||||
):
|
||||
return jsonify({"error": f"File not found during {operation}"}), 404
|
||||
if "permission" in str(error).lower():
|
||||
return jsonify(
|
||||
{"error": f"Permission denied during {operation}"}
|
||||
), 403
|
||||
return jsonify({"error": error_message}), 500
|
||||
|
||||
@staticmethod
|
||||
def wrap_service_call(service_func, *args, **kwargs) -> tuple[Any, int]:
|
||||
"""Wrap service calls with standardized error handling."""
|
||||
try:
|
||||
result = service_func(*args, **kwargs)
|
||||
|
||||
# If result is a dictionary with success/error structure
|
||||
if isinstance(result, dict) and "success" in result:
|
||||
return ErrorHandlingService.handle_service_result(result)
|
||||
|
||||
# If result is a simple dictionary (like user data)
|
||||
if isinstance(result, dict):
|
||||
return jsonify(result), 200
|
||||
|
||||
# For other types, assume success
|
||||
return jsonify({"result": result}), 200
|
||||
|
||||
except ValueError as e:
|
||||
return ErrorHandlingService.handle_validation_error(e)
|
||||
except Exception as e:
|
||||
return ErrorHandlingService.handle_generic_error(e)
|
||||
136
app/services/logging_service.py
Normal file
136
app/services/logging_service.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""Centralized logging service for the application."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
|
||||
class LoggingService:
|
||||
"""Service for configuring and managing application logging."""
|
||||
|
||||
@staticmethod
|
||||
def setup_logging(
|
||||
level: str = "INFO",
|
||||
format_string: str | None = None,
|
||||
) -> None:
|
||||
"""Setup application-wide logging configuration."""
|
||||
if format_string is None:
|
||||
format_string = (
|
||||
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
|
||||
# Configure root logger
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, level.upper()),
|
||||
format=format_string,
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
],
|
||||
)
|
||||
|
||||
# Set specific logger levels for third-party libraries
|
||||
logging.getLogger("werkzeug").setLevel(logging.WARNING)
|
||||
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
||||
logging.getLogger("requests").setLevel(logging.WARNING)
|
||||
|
||||
@staticmethod
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""Get a logger instance for a specific module."""
|
||||
return logging.getLogger(name)
|
||||
|
||||
@staticmethod
|
||||
def log_operation_start(logger: logging.Logger, operation: str) -> None:
|
||||
"""Log the start of an operation."""
|
||||
logger.info(f"Starting {operation}")
|
||||
|
||||
@staticmethod
|
||||
def log_operation_success(
|
||||
logger: logging.Logger,
|
||||
operation: str,
|
||||
details: str | None = None,
|
||||
) -> None:
|
||||
"""Log successful completion of an operation."""
|
||||
message = f"Successfully completed {operation}"
|
||||
if details:
|
||||
message += f" - {details}"
|
||||
logger.info(message)
|
||||
|
||||
@staticmethod
|
||||
def log_operation_error(
|
||||
logger: logging.Logger,
|
||||
operation: str,
|
||||
error: Exception,
|
||||
) -> None:
|
||||
"""Log an error during an operation."""
|
||||
logger.error(f"Error during {operation}: {error}")
|
||||
|
||||
@staticmethod
|
||||
def log_validation_error(
|
||||
logger: logging.Logger,
|
||||
field: str,
|
||||
value: str,
|
||||
reason: str,
|
||||
) -> None:
|
||||
"""Log validation errors consistently."""
|
||||
logger.warning(f"Validation failed for {field}='{value}': {reason}")
|
||||
|
||||
@staticmethod
|
||||
def log_resource_not_found(
|
||||
logger: logging.Logger,
|
||||
resource_type: str,
|
||||
identifier: str,
|
||||
) -> None:
|
||||
"""Log when a resource is not found."""
|
||||
logger.warning(f"{resource_type} not found: {identifier}")
|
||||
|
||||
@staticmethod
|
||||
def log_resource_created(
|
||||
logger: logging.Logger,
|
||||
resource_type: str,
|
||||
identifier: str,
|
||||
) -> None:
|
||||
"""Log when a resource is created."""
|
||||
logger.info(f"Created {resource_type}: {identifier}")
|
||||
|
||||
@staticmethod
|
||||
def log_resource_updated(
|
||||
logger: logging.Logger,
|
||||
resource_type: str,
|
||||
identifier: str,
|
||||
) -> None:
|
||||
"""Log when a resource is updated."""
|
||||
logger.info(f"Updated {resource_type}: {identifier}")
|
||||
|
||||
@staticmethod
|
||||
def log_resource_deleted(
|
||||
logger: logging.Logger,
|
||||
resource_type: str,
|
||||
identifier: str,
|
||||
) -> None:
|
||||
"""Log when a resource is deleted."""
|
||||
logger.info(f"Deleted {resource_type}: {identifier}")
|
||||
|
||||
@staticmethod
|
||||
def log_user_action(
|
||||
logger: logging.Logger,
|
||||
user_id: str,
|
||||
action: str,
|
||||
resource: str | None = None,
|
||||
) -> None:
|
||||
"""Log user actions for auditing."""
|
||||
message = f"User {user_id} performed action: {action}"
|
||||
if resource:
|
||||
message += f" on {resource}"
|
||||
logger.info(message)
|
||||
|
||||
@staticmethod
|
||||
def log_security_event(
|
||||
logger: logging.Logger,
|
||||
event_type: str,
|
||||
details: str,
|
||||
user_id: str | None = None,
|
||||
) -> None:
|
||||
"""Log security-related events."""
|
||||
message = f"Security event [{event_type}]: {details}"
|
||||
if user_id:
|
||||
message += f" (User: {user_id})"
|
||||
logger.warning(message)
|
||||
108
app/services/oauth_linking_service.py
Normal file
108
app/services/oauth_linking_service.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""OAuth provider linking service."""
|
||||
|
||||
from authlib.integrations.flask_client import OAuth
|
||||
|
||||
from app.models.user import User
|
||||
from app.models.user_oauth import UserOAuth
|
||||
from app.services.oauth_providers.registry import OAuthProviderRegistry
|
||||
|
||||
|
||||
class OAuthLinkingService:
|
||||
"""Service for linking and unlinking OAuth providers."""
|
||||
|
||||
@staticmethod
|
||||
def link_provider_to_user(
|
||||
provider: str,
|
||||
current_user_id: int,
|
||||
) -> dict:
|
||||
"""Link a new OAuth provider to existing user account."""
|
||||
# Get current user from database
|
||||
user = User.query.get(current_user_id)
|
||||
if not user:
|
||||
raise ValueError("User not found")
|
||||
|
||||
# Get OAuth provider and process callback
|
||||
oauth = OAuth()
|
||||
registry = OAuthProviderRegistry(oauth)
|
||||
oauth_provider = registry.get_provider(provider)
|
||||
|
||||
if not oauth_provider:
|
||||
raise ValueError(f"OAuth provider '{provider}' not configured")
|
||||
|
||||
# Exchange code for token and get user info
|
||||
token = oauth_provider.exchange_code_for_token(None, None)
|
||||
raw_user_info = oauth_provider.get_user_info(token)
|
||||
provider_data = oauth_provider.normalize_user_data(raw_user_info)
|
||||
|
||||
if not provider_data.get("id"):
|
||||
raise ValueError("Failed to get user information from provider")
|
||||
|
||||
# Check if this provider is already linked to another user
|
||||
existing_provider = UserOAuth.find_by_provider_and_id(
|
||||
provider,
|
||||
provider_data["id"],
|
||||
)
|
||||
|
||||
if existing_provider and existing_provider.user_id != user.id:
|
||||
raise ValueError(
|
||||
"This provider account is already linked to another user",
|
||||
)
|
||||
|
||||
# Link the provider to current user
|
||||
UserOAuth.create_or_update(
|
||||
user_id=user.id,
|
||||
provider=provider,
|
||||
provider_id=provider_data["id"],
|
||||
email=provider_data["email"],
|
||||
name=provider_data["name"],
|
||||
picture=provider_data.get("picture"),
|
||||
)
|
||||
|
||||
return {"message": f"{provider.title()} account linked successfully"}
|
||||
|
||||
@staticmethod
|
||||
def unlink_provider_from_user(
|
||||
provider: str,
|
||||
current_user_id: int,
|
||||
) -> dict:
|
||||
"""Unlink an OAuth provider from user account."""
|
||||
from app.database import db
|
||||
|
||||
user = User.query.get(current_user_id)
|
||||
if not user:
|
||||
raise ValueError("User not found")
|
||||
|
||||
# Check if user has more than one provider (prevent locking out)
|
||||
if len(user.oauth_providers) <= 1:
|
||||
raise ValueError("Cannot unlink last authentication provider")
|
||||
|
||||
# Find and remove the provider
|
||||
oauth_provider = user.get_provider(provider)
|
||||
if not oauth_provider:
|
||||
raise ValueError(
|
||||
f"Provider '{provider}' not linked to this account",
|
||||
)
|
||||
|
||||
db.session.delete(oauth_provider)
|
||||
db.session.commit()
|
||||
|
||||
return {"message": f"{provider.title()} account unlinked successfully"}
|
||||
|
||||
@staticmethod
|
||||
def get_user_providers(user_id: int) -> dict:
|
||||
"""Get all OAuth providers linked to a user."""
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
raise ValueError("User not found")
|
||||
|
||||
return {
|
||||
"providers": [
|
||||
{
|
||||
"provider": oauth.provider,
|
||||
"email": oauth.email,
|
||||
"name": oauth.name,
|
||||
"picture": oauth.picture,
|
||||
}
|
||||
for oauth in user.oauth_providers
|
||||
],
|
||||
}
|
||||
@@ -98,7 +98,7 @@ class SchedulerService:
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f"Daily credit refill failed: {result['message']}"
|
||||
f"Daily credit refill failed: {result['message']}",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -122,7 +122,7 @@ class SchedulerService:
|
||||
logger.debug("Sound scan completed: no new files found")
|
||||
else:
|
||||
logger.error(
|
||||
f"Sound scan failed: {result.get('error', 'Unknown error')}"
|
||||
f"Sound scan failed: {result.get('error', 'Unknown error')}",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import logging
|
||||
|
||||
from flask import request
|
||||
from flask_jwt_extended import decode_token
|
||||
from flask_socketio import disconnect, emit, join_room, leave_room
|
||||
|
||||
from app import socketio
|
||||
@@ -41,12 +40,12 @@ class SocketIOService:
|
||||
try:
|
||||
from flask import current_app
|
||||
from flask_jwt_extended import decode_token
|
||||
|
||||
|
||||
# Check if we have the access_token cookie
|
||||
access_token = request.cookies.get("access_token_cookie")
|
||||
if not access_token:
|
||||
return None
|
||||
|
||||
|
||||
# Decode the JWT token manually
|
||||
with current_app.app_context():
|
||||
try:
|
||||
@@ -59,6 +58,7 @@ class SocketIOService:
|
||||
|
||||
# Query database for user data
|
||||
from app.models.user import User
|
||||
|
||||
user = User.query.get(int(current_user_id))
|
||||
if not user or not user.is_active:
|
||||
return None
|
||||
@@ -131,4 +131,4 @@ def handle_disconnect() -> None:
|
||||
|
||||
|
||||
# Export the service instance
|
||||
socketio_service = SocketIOService()
|
||||
socketio_service = SocketIOService()
|
||||
|
||||
137
app/services/sound_management_service.py
Normal file
137
app/services/sound_management_service.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""Sound management service for admin operations."""
|
||||
|
||||
import os
|
||||
|
||||
from app.database import db
|
||||
from app.models.sound import Sound
|
||||
from app.services.sound_normalizer_service import SoundNormalizerService
|
||||
|
||||
|
||||
class SoundManagementService:
|
||||
"""Service for managing sound files and database operations."""
|
||||
|
||||
@staticmethod
|
||||
def get_sounds_with_file_status(
|
||||
sound_type: str = "SDB",
|
||||
page: int = 1,
|
||||
per_page: int = 50,
|
||||
) -> dict:
|
||||
"""Get paginated sounds with file existence status."""
|
||||
# Validate sound type
|
||||
if sound_type not in ["SDB", "SAY", "STR"]:
|
||||
raise ValueError("Invalid sound type")
|
||||
|
||||
# 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 with file status
|
||||
sounds_data = []
|
||||
for sound in sounds:
|
||||
sound_dict = sound.to_dict()
|
||||
sound_dict.update(
|
||||
SoundManagementService._get_file_status(sound),
|
||||
)
|
||||
sounds_data.append(sound_dict)
|
||||
|
||||
return {
|
||||
"sounds": sounds_data,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total": total,
|
||||
"pages": (total + per_page - 1) // per_page,
|
||||
},
|
||||
"type": sound_type,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _get_file_status(sound: Sound) -> dict:
|
||||
"""Get file existence status for a sound."""
|
||||
original_path = os.path.join(
|
||||
"sounds",
|
||||
sound.type.lower(),
|
||||
sound.filename,
|
||||
)
|
||||
status = {"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,
|
||||
)
|
||||
status["normalized_exists"] = os.path.exists(normalized_path)
|
||||
else:
|
||||
status["normalized_exists"] = False
|
||||
|
||||
return status
|
||||
|
||||
@staticmethod
|
||||
def delete_sound_with_files(sound_id: int) -> dict:
|
||||
"""Delete a sound and its associated files."""
|
||||
sound = Sound.query.get(sound_id)
|
||||
if not sound:
|
||||
raise ValueError("Sound not found")
|
||||
|
||||
if not sound.is_deletable:
|
||||
raise ValueError("Sound is not deletable")
|
||||
|
||||
errors = []
|
||||
|
||||
# Delete normalized file if exists
|
||||
if sound.is_normalized and sound.normalized_filename:
|
||||
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:
|
||||
errors.append(f"Failed to delete normalized file: {e}")
|
||||
|
||||
# Delete original file
|
||||
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:
|
||||
errors.append(f"Failed to delete original file: {e}")
|
||||
|
||||
if errors:
|
||||
raise Exception("; ".join(errors))
|
||||
|
||||
# Delete database record
|
||||
sound_name = sound.name
|
||||
db.session.delete(sound)
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
"message": f"Sound '{sound_name}' deleted successfully",
|
||||
"sound_id": sound_id,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def normalize_sound(
|
||||
sound_id: int,
|
||||
overwrite: bool = False,
|
||||
two_pass: bool = True,
|
||||
) -> dict:
|
||||
"""Normalize a specific sound."""
|
||||
return SoundNormalizerService.normalize_sound(
|
||||
sound_id,
|
||||
overwrite,
|
||||
two_pass,
|
||||
)
|
||||
@@ -39,7 +39,9 @@ class SoundNormalizerService:
|
||||
|
||||
@staticmethod
|
||||
def normalize_sound(
|
||||
sound_id: int, overwrite: bool = False, two_pass: bool = True
|
||||
sound_id: int,
|
||||
overwrite: bool = False,
|
||||
two_pass: bool = True,
|
||||
) -> dict:
|
||||
"""Normalize a specific sound file using ffmpeg loudnorm.
|
||||
|
||||
@@ -250,7 +252,8 @@ class SoundNormalizerService:
|
||||
logger.debug("Starting first pass (analysis)")
|
||||
|
||||
first_pass_result = SoundNormalizerService._run_first_pass(
|
||||
source_path, params
|
||||
source_path,
|
||||
params,
|
||||
)
|
||||
|
||||
if not first_pass_result["success"]:
|
||||
@@ -262,7 +265,10 @@ class SoundNormalizerService:
|
||||
logger.debug("Starting second pass (normalization)")
|
||||
|
||||
second_pass_result = SoundNormalizerService._run_second_pass(
|
||||
source_path, output_path, params, measured_params
|
||||
source_path,
|
||||
output_path,
|
||||
params,
|
||||
measured_params,
|
||||
)
|
||||
|
||||
if not second_pass_result["success"]:
|
||||
@@ -297,7 +303,8 @@ class SoundNormalizerService:
|
||||
|
||||
@staticmethod
|
||||
def _normalize_with_ffmpeg_single_pass(
|
||||
source_path: str, output_path: str
|
||||
source_path: str,
|
||||
output_path: str,
|
||||
) -> dict:
|
||||
"""Run ffmpeg loudnorm on a single file using single-pass normalization.
|
||||
|
||||
@@ -374,6 +381,7 @@ class SoundNormalizerService:
|
||||
|
||||
Returns:
|
||||
dict: Result with measured parameters and analysis stats
|
||||
|
||||
"""
|
||||
try:
|
||||
# Create ffmpeg input stream
|
||||
@@ -389,7 +397,10 @@ 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
|
||||
@@ -403,7 +414,7 @@ class SoundNormalizerService:
|
||||
|
||||
# Parse measured parameters from JSON output
|
||||
measured_params = SoundNormalizerService._parse_measured_params(
|
||||
stderr_text
|
||||
stderr_text,
|
||||
)
|
||||
|
||||
if not measured_params:
|
||||
@@ -446,6 +457,7 @@ class SoundNormalizerService:
|
||||
|
||||
Returns:
|
||||
dict: Result with normalization stats
|
||||
|
||||
"""
|
||||
try:
|
||||
# Create ffmpeg input stream
|
||||
@@ -506,11 +518,14 @@ class SoundNormalizerService:
|
||||
|
||||
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
|
||||
r'\{[^}]*"input_i"[^}]*\}',
|
||||
stderr_output,
|
||||
re.DOTALL,
|
||||
)
|
||||
if not json_match:
|
||||
logger.warning("No JSON block found in first pass output")
|
||||
|
||||
@@ -140,76 +140,91 @@ class SoundScannerService:
|
||||
|
||||
@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
|
||||
"""Process a single audio file and add it to database if new."""
|
||||
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 for existing file by hash (duplicate content)
|
||||
if existing_sound := Sound.find_by_hash(file_hash):
|
||||
return SoundScannerService._handle_duplicate_file(existing_sound)
|
||||
|
||||
# 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,
|
||||
# Check for existing filename (file replacement)
|
||||
if existing_filename_sound := Sound.find_by_filename(
|
||||
str(relative_path)
|
||||
):
|
||||
return SoundScannerService._handle_file_replacement(
|
||||
existing_filename_sound,
|
||||
str(relative_path),
|
||||
metadata,
|
||||
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
|
||||
return SoundScannerService._create_new_sound(
|
||||
file_path,
|
||||
str(relative_path),
|
||||
metadata,
|
||||
file_hash,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _handle_duplicate_file(existing_sound: Sound) -> dict:
|
||||
"""Handle case where file content already exists in database."""
|
||||
return {
|
||||
"added": False,
|
||||
"reason": f"File already exists as '{existing_sound.name}'",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _handle_file_replacement(
|
||||
existing_sound: Sound,
|
||||
relative_path: str,
|
||||
metadata: dict,
|
||||
file_hash: str,
|
||||
) -> dict:
|
||||
"""Handle case where filename exists but content may be different."""
|
||||
# Remove normalized files and clear normalized info
|
||||
SoundScannerService._clear_normalized_files(existing_sound)
|
||||
existing_sound.clear_normalized_info()
|
||||
|
||||
# Update existing sound with new file information
|
||||
existing_sound.update_file_info(
|
||||
filename=relative_path,
|
||||
duration=metadata["duration"],
|
||||
size=metadata["size"],
|
||||
hash_value=file_hash,
|
||||
)
|
||||
|
||||
return {
|
||||
"added": False,
|
||||
"updated": True,
|
||||
"sound_id": existing_sound.id,
|
||||
"reason": f"Updated existing sound '{existing_sound.name}' with new file data",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _create_new_sound(
|
||||
file_path: str,
|
||||
relative_path: str,
|
||||
metadata: dict,
|
||||
file_hash: str,
|
||||
) -> dict:
|
||||
"""Create a new sound record in the database."""
|
||||
sound_name = SoundScannerService._generate_unique_sound_name(
|
||||
Path(file_path).stem,
|
||||
)
|
||||
|
||||
sound = Sound.create_sound(
|
||||
sound_type="SDB", # Soundboard type
|
||||
sound_type="SDB",
|
||||
name=sound_name,
|
||||
filename=str(relative_path),
|
||||
filename=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
|
||||
commit=False,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -218,6 +233,18 @@ class SoundScannerService:
|
||||
"reason": "New file added successfully",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _generate_unique_sound_name(base_name: str) -> str:
|
||||
"""Generate a unique sound name by appending numbers if needed."""
|
||||
sound_name = base_name
|
||||
counter = 1
|
||||
|
||||
while Sound.find_by_name(sound_name):
|
||||
sound_name = f"{base_name}_{counter}"
|
||||
counter += 1
|
||||
|
||||
return sound_name
|
||||
|
||||
@staticmethod
|
||||
def _calculate_file_hash(file_path: str) -> str:
|
||||
"""Calculate SHA256 hash of file contents."""
|
||||
@@ -249,7 +276,7 @@ class SoundScannerService:
|
||||
logger.info(f"Removed normalized file: {normalized_path}")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Could not remove normalized file {normalized_path}: {e}"
|
||||
f"Could not remove normalized file {normalized_path}: {e}",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"""VLC service for playing sounds using subprocess."""
|
||||
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from app.database import db
|
||||
from app.models.sound import Sound
|
||||
from app.models.sound_played import SoundPlayed
|
||||
from app.services.logging_service import LoggingService
|
||||
|
||||
logger = LoggingService.get_logger(__name__)
|
||||
|
||||
|
||||
class VLCService:
|
||||
@@ -17,7 +17,7 @@ class VLCService:
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize VLC service."""
|
||||
self.processes: Dict[str, subprocess.Popen] = {}
|
||||
self.processes: dict[str, subprocess.Popen] = {}
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def play_sound(self, sound_id: int, user_id: int | None = None) -> bool:
|
||||
@@ -38,7 +38,9 @@ class VLCService:
|
||||
)
|
||||
else:
|
||||
sound_path = os.path.join(
|
||||
"sounds", "soundboard", sound.filename
|
||||
"sounds",
|
||||
"soundboard",
|
||||
sound.filename,
|
||||
)
|
||||
|
||||
# Check if file exists
|
||||
@@ -73,8 +75,9 @@ class VLCService:
|
||||
with self.lock:
|
||||
self.processes[process_id] = process
|
||||
|
||||
print(
|
||||
f"Started VLC process {process.pid} ({process_id}) for sound {sound.name}. Total processes: {len(self.processes)}"
|
||||
logger.info(
|
||||
f"Started VLC process {process.pid} for sound '{sound.name}'. "
|
||||
f"Total active processes: {len(self.processes)}",
|
||||
)
|
||||
|
||||
# Increment play count
|
||||
@@ -89,7 +92,7 @@ class VLCService:
|
||||
commit=True,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error recording play event: {e}")
|
||||
logger.error(f"Error recording play event: {e}")
|
||||
|
||||
# Schedule cleanup after sound duration
|
||||
threading.Thread(
|
||||
@@ -101,7 +104,9 @@ class VLCService:
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error starting VLC process for sound {sound_id}: {e}")
|
||||
logger.error(
|
||||
f"Error starting VLC process for sound {sound_id}: {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
def _cleanup_after_playback(self, process_id: str, duration: int) -> None:
|
||||
@@ -111,13 +116,13 @@ class VLCService:
|
||||
|
||||
with self.lock:
|
||||
if process_id in self.processes:
|
||||
print(f"Cleaning up process {process_id} after playback")
|
||||
logger.debug(f"Cleaning up process {process_id} after playback")
|
||||
process = self.processes[process_id]
|
||||
|
||||
try:
|
||||
# Check if process is still running
|
||||
if process.poll() is None:
|
||||
print(
|
||||
logger.debug(
|
||||
f"Process {process.pid} still running, terminating"
|
||||
)
|
||||
process.terminate()
|
||||
@@ -125,62 +130,58 @@ class VLCService:
|
||||
try:
|
||||
process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
print(
|
||||
logger.debug(
|
||||
f"Process {process.pid} didn't terminate, killing"
|
||||
)
|
||||
process.kill()
|
||||
|
||||
print(f"Successfully cleaned up process {process_id}")
|
||||
logger.debug(
|
||||
f"Successfully cleaned up process {process_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error during cleanup of {process_id}: {e}")
|
||||
logger.warning(f"Error during cleanup of {process_id}: {e}")
|
||||
finally:
|
||||
# Always remove from tracking
|
||||
del self.processes[process_id]
|
||||
print(
|
||||
f"Removed process {process_id}. Remaining processes: {len(self.processes)}"
|
||||
logger.debug(
|
||||
f"Removed process {process_id}. Remaining processes: {len(self.processes)}",
|
||||
)
|
||||
else:
|
||||
print(f"Process {process_id} not found during cleanup")
|
||||
|
||||
def stop_all(self) -> None:
|
||||
"""Stop all playing sounds by killing VLC processes."""
|
||||
with self.lock:
|
||||
processes_copy = dict(self.processes)
|
||||
print(
|
||||
f"Stopping {len(processes_copy)} VLC processes: {list(processes_copy.keys())}"
|
||||
)
|
||||
if processes_copy:
|
||||
logger.info(f"Stopping {len(processes_copy)} VLC processes")
|
||||
|
||||
for process_id, process in processes_copy.items():
|
||||
try:
|
||||
if process.poll() is None: # Process is still running
|
||||
print(
|
||||
f"Terminating process {process.pid} ({process_id})"
|
||||
)
|
||||
logger.debug(f"Terminating process {process.pid}")
|
||||
process.terminate()
|
||||
|
||||
# Give it a moment to terminate gracefully
|
||||
try:
|
||||
process.wait(timeout=1)
|
||||
print(
|
||||
logger.debug(
|
||||
f"Process {process.pid} terminated gracefully"
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
print(
|
||||
logger.debug(
|
||||
f"Process {process.pid} didn't terminate, killing forcefully"
|
||||
)
|
||||
process.kill()
|
||||
process.wait() # Wait for it to be killed
|
||||
else:
|
||||
print(
|
||||
f"Process {process.pid} ({process_id}) already finished"
|
||||
)
|
||||
logger.debug(f"Process {process.pid} already finished")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error stopping process {process_id}: {e}")
|
||||
logger.warning(f"Error stopping process {process_id}: {e}")
|
||||
|
||||
# Clear all processes
|
||||
self.processes.clear()
|
||||
print(f"Cleared all processes. Remaining: {len(self.processes)}")
|
||||
if processes_copy:
|
||||
logger.info("All VLC processes stopped")
|
||||
|
||||
def get_playing_count(self) -> int:
|
||||
"""Get number of currently playing sounds."""
|
||||
@@ -201,34 +202,20 @@ class VLCService:
|
||||
"""Force stop all sounds by killing VLC processes aggressively."""
|
||||
with self.lock:
|
||||
stopped_count = len(self.processes)
|
||||
print(f"Force stopping {stopped_count} VLC processes")
|
||||
|
||||
# # Kill all VLC processes aggressively
|
||||
# for process_id, process in list(self.processes.items()):
|
||||
# try:
|
||||
# if process.poll() is None: # Process is still running
|
||||
# print(f"Force killing process {process.pid} ({process_id})")
|
||||
# process.kill()
|
||||
# process.wait() # Wait for it to be killed
|
||||
# print(f"Process {process.pid} killed")
|
||||
# else:
|
||||
# print(f"Process {process.pid} ({process_id}) already finished")
|
||||
|
||||
# except Exception as e:
|
||||
# print(f"Error force-stopping process {process_id}: {e}")
|
||||
if stopped_count > 0:
|
||||
logger.warning(f"Force stopping {stopped_count} VLC processes")
|
||||
|
||||
# Also try to kill any remaining VLC processes system-wide
|
||||
try:
|
||||
subprocess.run(["pkill", "-f", "vlc"], check=False)
|
||||
print("Killed any remaining VLC processes system-wide")
|
||||
logger.info("Killed any remaining VLC processes system-wide")
|
||||
except Exception as e:
|
||||
print(f"Error killing system VLC processes: {e}")
|
||||
logger.error(f"Error killing system VLC processes: {e}")
|
||||
|
||||
# Clear all processes
|
||||
self.processes.clear()
|
||||
print(
|
||||
f"Force stop completed. Processes remaining: {len(self.processes)}"
|
||||
)
|
||||
if stopped_count > 0:
|
||||
logger.info("Force stop completed")
|
||||
return stopped_count
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user