diff --git a/app/__init__.py b/app/__init__.py index 511cce3..cceec84 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -27,7 +27,8 @@ def create_app(): # Configure Flask-JWT-Extended app.config["JWT_SECRET_KEY"] = os.environ.get( - "JWT_SECRET_KEY", "jwt-secret-key", + "JWT_SECRET_KEY", + "jwt-secret-key", ) app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(minutes=15) app.config["JWT_REFRESH_TOKEN_EXPIRES"] = timedelta(days=7) @@ -68,11 +69,13 @@ def create_app(): scheduler_service.start() # Register blueprints - from app.routes import admin, auth, main + from app.routes import admin, admin_sounds, auth, main, soundboard app.register_blueprint(main.bp, url_prefix="/api") app.register_blueprint(auth.bp, url_prefix="/api/auth") app.register_blueprint(admin.bp, url_prefix="/api/admin") + app.register_blueprint(admin_sounds.bp) + app.register_blueprint(soundboard.bp) # Shutdown scheduler when app is torn down @app.teardown_appcontext diff --git a/app/database_init.py b/app/database_init.py index 2b3c7b7..e11cfbb 100644 --- a/app/database_init.py +++ b/app/database_init.py @@ -68,7 +68,8 @@ def migrate_users_to_plans(): # 0 credits means they spent them, NULL means they never got assigned try: users_without_credits = User.query.filter( - User.plan_id.isnot(None), User.credits.is_(None), + User.plan_id.isnot(None), + User.credits.is_(None), ).all() except Exception: # Credits column doesn't exist yet, will be handled by create_all diff --git a/app/models/user.py b/app/models/user.py index 9b84405..53a3d74 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -29,12 +29,15 @@ class User(db.Model): # Password authentication (optional - users can use OAuth instead) password_hash: Mapped[str | None] = mapped_column( - String(255), nullable=True, + String(255), + nullable=True, ) # Role-based access control role: Mapped[str] = mapped_column( - String(50), nullable=False, default="user", + String(50), + nullable=False, + default="user", ) # User status @@ -42,7 +45,9 @@ class User(db.Model): # Plan relationship plan_id: Mapped[int] = mapped_column( - Integer, ForeignKey("plans.id"), nullable=False, + Integer, + ForeignKey("plans.id"), + nullable=False, ) # User credits (populated from plan credits on creation) @@ -51,12 +56,15 @@ class User(db.Model): # API token for programmatic access api_token: Mapped[str | None] = mapped_column(String(255), nullable=True) api_token_expires_at: Mapped[datetime | None] = mapped_column( - DateTime, nullable=True, + DateTime, + nullable=True, ) # Timestamps created_at: Mapped[datetime] = mapped_column( - DateTime, default=datetime.utcnow, nullable=False, + DateTime, + default=datetime.utcnow, + nullable=False, ) updated_at: Mapped[datetime] = mapped_column( DateTime, @@ -67,7 +75,9 @@ class User(db.Model): # Relationships oauth_providers: Mapped[list["UserOAuth"]] = relationship( - "UserOAuth", back_populates="user", cascade="all, delete-orphan", + "UserOAuth", + back_populates="user", + cascade="all, delete-orphan", ) plan: Mapped["Plan"] = relationship("Plan", back_populates="users") @@ -198,7 +208,8 @@ class User(db.Model): # First, try to find existing OAuth provider oauth_provider = UserOAuth.find_by_provider_and_id( - provider, provider_id, + provider, + provider_id, ) if oauth_provider: @@ -256,7 +267,10 @@ class User(db.Model): @classmethod def create_with_password( - cls, email: str, password: str, name: str, + cls, + email: str, + password: str, + name: str, ) -> "User": """Create new user with email and password.""" from app.models.plan import Plan @@ -293,7 +307,9 @@ class User(db.Model): @classmethod def authenticate_with_password( - cls, email: str, password: str, + cls, + email: str, + password: str, ) -> Optional["User"]: """Authenticate user with email and password.""" user = cls.find_by_email(email) diff --git a/app/models/user_oauth.py b/app/models/user_oauth.py index 78e4703..fedeb13 100644 --- a/app/models/user_oauth.py +++ b/app/models/user_oauth.py @@ -33,7 +33,9 @@ class UserOAuth(db.Model): # Timestamps created_at: Mapped[datetime] = mapped_column( - DateTime, default=datetime.utcnow, nullable=False, + DateTime, + default=datetime.utcnow, + nullable=False, ) updated_at: Mapped[datetime] = mapped_column( DateTime, @@ -45,13 +47,16 @@ class UserOAuth(db.Model): # Unique constraint on provider + provider_id combination __table_args__ = ( db.UniqueConstraint( - "provider", "provider_id", name="unique_provider_user", + "provider", + "provider_id", + name="unique_provider_user", ), ) # Relationships user: Mapped["User"] = relationship( - "User", back_populates="oauth_providers", + "User", + back_populates="oauth_providers", ) def __repr__(self) -> str: @@ -73,11 +78,14 @@ class UserOAuth(db.Model): @classmethod def find_by_provider_and_id( - cls, provider: str, provider_id: str, + cls, + provider: str, + provider_id: str, ) -> Optional["UserOAuth"]: """Find OAuth provider by provider name and provider ID.""" return cls.query.filter_by( - provider=provider, provider_id=provider_id, + provider=provider, + provider_id=provider_id, ).first() @classmethod diff --git a/app/routes/admin.py b/app/routes/admin.py index 85751b9..2919ceb 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -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() \ No newline at end of file + return SoundNormalizerService.check_ffmpeg_availability() diff --git a/app/routes/admin_sounds.py b/app/routes/admin_sounds.py new file mode 100644 index 0000000..be81c17 --- /dev/null +++ b/app/routes/admin_sounds.py @@ -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("/", 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("//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 diff --git a/app/routes/auth.py b/app/routes/auth.py index 23496d9..cd01500 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -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: diff --git a/app/routes/main.py b/app/routes/main.py index 20d503a..85e57f9 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -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, } - - diff --git a/app/routes/soundboard.py b/app/routes/soundboard.py new file mode 100644 index 0000000..a80e56e --- /dev/null +++ b/app/routes/soundboard.py @@ -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//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 diff --git a/app/services/auth_service.py b/app/services/auth_service.py index 40f202c..895308f 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -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: diff --git a/app/services/credit_service.py b/app/services/credit_service.py index 5299709..d065cca 100644 --- a/app/services/credit_service.py +++ b/app/services/credit_service.py @@ -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 diff --git a/app/services/decorators.py b/app/services/decorators.py index f5dc582..3d91d79 100644 --- a/app/services/decorators.py +++ b/app/services/decorators.py @@ -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.""" diff --git a/app/services/oauth_providers/base.py b/app/services/oauth_providers/base.py index 725b8d0..009d5a4 100644 --- a/app/services/oauth_providers/base.py +++ b/app/services/oauth_providers/base.py @@ -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() diff --git a/app/services/oauth_providers/registry.py b/app/services/oauth_providers/registry.py index 851746c..8e860f2 100644 --- a/app/services/oauth_providers/registry.py +++ b/app/services/oauth_providers/registry.py @@ -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: diff --git a/app/services/scheduler_service.py b/app/services/scheduler_service.py index e207728..2e34ecf 100644 --- a/app/services/scheduler_service.py +++ b/app/services/scheduler_service.py @@ -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() diff --git a/app/services/sound_normalizer_service.py b/app/services/sound_normalizer_service.py index 185bf87..bebefb9 100644 --- a/app/services/sound_normalizer_service.py +++ b/app/services/sound_normalizer_service.py @@ -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 diff --git a/app/services/sound_scanner_service.py b/app/services/sound_scanner_service.py index 5441d34..4c26c22 100644 --- a/app/services/sound_scanner_service.py +++ b/app/services/sound_scanner_service.py @@ -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: diff --git a/app/services/vlc_service.py b/app/services/vlc_service.py new file mode 100644 index 0000000..9fa0454 --- /dev/null +++ b/app/services/vlc_service.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml index 1c41ed2..769269f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "flask-sqlalchemy==3.1.1", "pydub==0.25.1", "python-dotenv==1.1.1", + "python-vlc>=3.0.0", "requests==2.32.4", "werkzeug==3.1.3", ] diff --git a/uv.lock b/uv.lock index 9a1860e..d74dd83 100644 --- a/uv.lock +++ b/uv.lock @@ -526,6 +526,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556 }, ] +[[package]] +name = "python-vlc" +version = "3.0.21203" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/5b/f9ce6f0c9877b6fe5eafbade55e0dcb6b2b30f1c2c95837aef40e390d63b/python_vlc-3.0.21203.tar.gz", hash = "sha256:52d0544b276b11e58b6c0b748c3e0518f94f74b1b4cd328c83a59eacabead1ec", size = 162211 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/ee/7d76eb3b50ccb1397621f32ede0fb4d17aa55a9aa2251bc34e6b9929fdce/python_vlc-3.0.21203-py3-none-any.whl", hash = "sha256:1613451a31b692ec276296ceeae0c0ba82bfc2d094dabf9aceb70f58944a6320", size = 87651 }, +] + [[package]] name = "requests" version = "2.32.4" @@ -581,6 +590,7 @@ dependencies = [ { name = "flask-sqlalchemy" }, { name = "pydub" }, { name = "python-dotenv" }, + { name = "python-vlc" }, { name = "requests" }, { name = "werkzeug" }, ] @@ -604,6 +614,7 @@ requires-dist = [ { name = "flask-sqlalchemy", specifier = "==3.1.1" }, { name = "pydub", specifier = "==0.25.1" }, { name = "python-dotenv", specifier = "==1.1.1" }, + { name = "python-vlc", specifier = ">=3.0.0" }, { name = "requests", specifier = "==2.32.4" }, { name = "werkzeug", specifier = "==3.1.3" }, ]