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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -83,7 +83,9 @@ class SoundScannerService:
|
||||
files_added += 1
|
||||
logger.debug(f"Added sound: {filename}")
|
||||
elif result.get("updated"):
|
||||
files_added += 1 # Count updates as additions for reporting
|
||||
files_added += (
|
||||
1 # Count updates as additions for reporting
|
||||
)
|
||||
logger.debug(f"Updated sound: {filename}")
|
||||
else:
|
||||
files_skipped += 1
|
||||
@@ -171,7 +173,7 @@ class SoundScannerService:
|
||||
# Remove normalized files and clear normalized info
|
||||
SoundScannerService._clear_normalized_files(existing_filename_sound)
|
||||
existing_filename_sound.clear_normalized_info()
|
||||
|
||||
|
||||
# Update existing sound with new file information
|
||||
existing_filename_sound.update_file_info(
|
||||
filename=str(relative_path),
|
||||
@@ -179,7 +181,7 @@ class SoundScannerService:
|
||||
size=metadata["size"],
|
||||
hash_value=file_hash,
|
||||
)
|
||||
|
||||
|
||||
return {
|
||||
"added": False,
|
||||
"updated": True,
|
||||
@@ -233,15 +235,22 @@ class SoundScannerService:
|
||||
"""Remove normalized files for a sound if they exist."""
|
||||
if sound.is_normalized and sound.normalized_filename:
|
||||
# Import here to avoid circular imports
|
||||
from app.services.sound_normalizer_service import SoundNormalizerService
|
||||
|
||||
normalized_path = Path(SoundNormalizerService.NORMALIZED_DIR) / sound.normalized_filename
|
||||
from app.services.sound_normalizer_service import (
|
||||
SoundNormalizerService,
|
||||
)
|
||||
|
||||
normalized_path = (
|
||||
Path(SoundNormalizerService.NORMALIZED_DIR)
|
||||
/ sound.normalized_filename
|
||||
)
|
||||
if normalized_path.exists():
|
||||
try:
|
||||
normalized_path.unlink()
|
||||
logger.info(f"Removed normalized file: {normalized_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not remove normalized file {normalized_path}: {e}")
|
||||
logger.warning(
|
||||
f"Could not remove normalized file {normalized_path}: {e}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_audio_metadata(file_path: str) -> dict:
|
||||
|
||||
194
app/services/vlc_service.py
Normal file
194
app/services/vlc_service.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""VLC service for playing sounds."""
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import vlc
|
||||
|
||||
from app.database import db
|
||||
from app.models.sound import Sound
|
||||
|
||||
|
||||
class VLCService:
|
||||
"""Service for playing sounds using VLC."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize VLC service."""
|
||||
self.instances: Dict[str, Dict[str, Any]] = {}
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def play_sound(self, sound_id: int) -> bool:
|
||||
"""Play a sound by ID using VLC."""
|
||||
with self.lock:
|
||||
# Get sound from database
|
||||
sound = Sound.query.get(sound_id)
|
||||
if not sound:
|
||||
return False
|
||||
|
||||
# Use normalized file if available, otherwise use original
|
||||
if sound.is_normalized and sound.normalized_filename:
|
||||
sound_path = os.path.join(
|
||||
"sounds",
|
||||
"normalized",
|
||||
"soundboard",
|
||||
# sound.type.lower(),
|
||||
sound.normalized_filename,
|
||||
)
|
||||
else:
|
||||
sound_path = os.path.join(
|
||||
"sounds", "soundboard", sound.filename
|
||||
)
|
||||
|
||||
# Check if file exists
|
||||
if not os.path.exists(sound_path):
|
||||
return False
|
||||
|
||||
# Create VLC instance
|
||||
instance = vlc.Instance()
|
||||
player = instance.media_player_new()
|
||||
|
||||
# Load and play media
|
||||
media = instance.media_new(sound_path)
|
||||
player.set_media(media)
|
||||
|
||||
# Start playback
|
||||
player.play()
|
||||
|
||||
# Store instance for cleanup with unique ID
|
||||
instance_id = f"sound_{sound_id}_{uuid.uuid4().hex[:8]}_{int(time.time())}"
|
||||
self.instances[instance_id] = {
|
||||
"instance": instance,
|
||||
"player": player,
|
||||
"sound_id": sound_id,
|
||||
"created_at": time.time(),
|
||||
}
|
||||
|
||||
print(f"Created instance {instance_id} for sound {sound.name}. Total instances: {len(self.instances)}")
|
||||
|
||||
# Increment play count
|
||||
sound.increment_play_count()
|
||||
|
||||
# Schedule cleanup
|
||||
threading.Thread(
|
||||
target=self._cleanup_after_playback,
|
||||
args=(instance_id, sound.duration if sound.duration else 10),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
return True
|
||||
|
||||
def _cleanup_after_playback(self, instance_id: str, duration: int) -> None:
|
||||
"""Clean up VLC instance after playback."""
|
||||
# Wait for playback to finish (duration + 1 second buffer)
|
||||
time.sleep(duration / 1000 + 1) # Convert ms to seconds
|
||||
|
||||
with self.lock:
|
||||
if instance_id in self.instances:
|
||||
print(f"Cleaning up instance {instance_id} after playback")
|
||||
instance_data = self.instances[instance_id]
|
||||
player = instance_data["player"]
|
||||
instance = instance_data["instance"]
|
||||
|
||||
try:
|
||||
# Stop player if still playing
|
||||
if player.is_playing():
|
||||
player.stop()
|
||||
|
||||
# Release resources
|
||||
player.release()
|
||||
instance.release()
|
||||
print(f"Successfully cleaned up instance {instance_id}")
|
||||
except Exception as e:
|
||||
print(f"Error during cleanup of {instance_id}: {e}")
|
||||
finally:
|
||||
# Always remove from tracking
|
||||
del self.instances[instance_id]
|
||||
print(f"Removed instance {instance_id}. Remaining instances: {len(self.instances)}")
|
||||
else:
|
||||
print(f"Instance {instance_id} not found during cleanup")
|
||||
|
||||
def stop_all(self) -> None:
|
||||
"""Stop all playing sounds."""
|
||||
with self.lock:
|
||||
# Create a copy of the instances to avoid race conditions
|
||||
instances_copy = dict(self.instances)
|
||||
print(f"Stopping {len(instances_copy)} instances: {list(instances_copy.keys())}")
|
||||
|
||||
for instance_id, instance_data in instances_copy.items():
|
||||
try:
|
||||
player = instance_data["player"]
|
||||
instance = instance_data["instance"]
|
||||
|
||||
print(f"Stopping instance {instance_id}")
|
||||
|
||||
# Force stop the player regardless of state
|
||||
player.stop()
|
||||
|
||||
# Give VLC a moment to process the stop command
|
||||
time.sleep(0.1)
|
||||
|
||||
# Release the media player and instance
|
||||
player.release()
|
||||
instance.release()
|
||||
|
||||
print(f"Successfully stopped instance {instance_id}")
|
||||
|
||||
except Exception as e:
|
||||
# Log the error but continue stopping other instances
|
||||
print(f"Error stopping instance {instance_id}: {e}")
|
||||
|
||||
# Clear all instances
|
||||
self.instances.clear()
|
||||
print(f"Cleared all instances. Remaining: {len(self.instances)}")
|
||||
|
||||
def get_playing_count(self) -> int:
|
||||
"""Get number of currently playing sounds."""
|
||||
with self.lock:
|
||||
return len(self.instances)
|
||||
|
||||
def force_stop_all(self) -> int:
|
||||
"""Force stop all sounds and clean up resources. Returns count of stopped instances."""
|
||||
with self.lock:
|
||||
stopped_count = len(self.instances)
|
||||
print(f"Force stopping {stopped_count} instances: {list(self.instances.keys())}")
|
||||
|
||||
# More aggressive cleanup
|
||||
for instance_id, instance_data in list(self.instances.items()):
|
||||
try:
|
||||
player = instance_data["player"]
|
||||
instance = instance_data["instance"]
|
||||
|
||||
print(f"Force stopping instance {instance_id}")
|
||||
|
||||
# Multiple stop attempts
|
||||
for attempt in range(3):
|
||||
if hasattr(player, 'stop'):
|
||||
player.stop()
|
||||
print(f"Stop attempt {attempt + 1} for {instance_id}")
|
||||
time.sleep(0.05) # Short delay between attempts
|
||||
|
||||
# Force release
|
||||
if hasattr(player, 'release'):
|
||||
player.release()
|
||||
print(f"Released player for {instance_id}")
|
||||
if hasattr(instance, 'release'):
|
||||
instance.release()
|
||||
print(f"Released instance for {instance_id}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error force-stopping instance {instance_id}: {e}")
|
||||
finally:
|
||||
# Always remove from tracking
|
||||
if instance_id in self.instances:
|
||||
del self.instances[instance_id]
|
||||
print(f"Removed {instance_id} from tracking")
|
||||
|
||||
print(f"Force stop completed. Instances remaining: {len(self.instances)}")
|
||||
return stopped_count
|
||||
|
||||
|
||||
# Global VLC service instance
|
||||
vlc_service = VLCService()
|
||||
Reference in New Issue
Block a user