feat(vlc_service): refactor VLC service to use subprocess for sound playback and management; update process tracking

This commit is contained in:
JSC
2025-07-03 21:36:42 +02:00
parent 7455811860
commit 97b998fd9e
4 changed files with 153 additions and 135 deletions

View File

@@ -100,21 +100,21 @@ def get_status():
try: try:
playing_count = vlc_service.get_playing_count() playing_count = vlc_service.get_playing_count()
# Get detailed instance information # Get detailed process information
with vlc_service.lock: with vlc_service.lock:
instances = [] processes = []
for instance_id, instance_data in vlc_service.instances.items(): for process_id, process in vlc_service.processes.items():
instances.append({ processes.append({
"id": instance_id, "id": process_id,
"sound_id": instance_data.get("sound_id"), "pid": process.pid,
"created_at": instance_data.get("created_at"), "running": process.poll() is None,
}) })
return jsonify( return jsonify(
{ {
"playing_count": playing_count, "playing_count": playing_count,
"is_playing": playing_count > 0, "is_playing": playing_count > 0,
"instances": instances, "processes": processes,
} }
) )
except Exception as e: except Exception as e:

View File

@@ -1,28 +1,27 @@
"""VLC service for playing sounds.""" """VLC service for playing sounds using subprocess."""
import os import os
import signal
import subprocess
import threading import threading
import time import time
import uuid from typing import Dict, List, Optional
from typing import Any, Dict, Optional
import vlc
from app.database import db from app.database import db
from app.models.sound import Sound from app.models.sound import Sound
class VLCService: class VLCService:
"""Service for playing sounds using VLC.""" """Service for playing sounds using VLC subprocess."""
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize VLC service.""" """Initialize VLC service."""
self.instances: Dict[str, Dict[str, Any]] = {} self.processes: Dict[str, subprocess.Popen] = {}
self.lock = threading.Lock() self.lock = threading.Lock()
def play_sound(self, sound_id: int) -> bool: def play_sound(self, sound_id: int) -> bool:
"""Play a sound by ID using VLC.""" """Play a sound by ID using VLC subprocess."""
with self.lock: try:
# Get sound from database # Get sound from database
sound = Sound.query.get(sound_id) sound = Sound.query.get(sound_id)
if not sound: if not sound:
@@ -34,7 +33,6 @@ class VLCService:
"sounds", "sounds",
"normalized", "normalized",
"soundboard", "soundboard",
# sound.type.lower(),
sound.normalized_filename, sound.normalized_filename,
) )
else: else:
@@ -46,147 +44,179 @@ class VLCService:
if not os.path.exists(sound_path): if not os.path.exists(sound_path):
return False return False
# Create VLC instance # Convert to absolute path
instance = vlc.Instance() sound_path = os.path.abspath(sound_path)
player = instance.media_player_new()
# Load and play media # Create unique process ID
media = instance.media_new(sound_path) process_id = f"sound_{sound_id}_{int(time.time() * 1000000)}"
player.set_media(media)
# Start playback # Start VLC process
player.play() vlc_cmd = [
"vlc",
sound_path,
"--intf",
"dummy", # No interface
"--play-and-exit", # Exit after playing
"--no-video", # Audio only
"--quiet", # Reduce output
]
# Store instance for cleanup with unique ID process = subprocess.Popen(
instance_id = f"sound_{sound_id}_{uuid.uuid4().hex[:8]}_{int(time.time())}" vlc_cmd,
self.instances[instance_id] = { stdout=subprocess.DEVNULL,
"instance": instance, stderr=subprocess.DEVNULL,
"player": player, preexec_fn=os.setsid, # Create new process group
"sound_id": sound_id, )
"created_at": time.time(),
}
print(f"Created instance {instance_id} for sound {sound.name}. Total instances: {len(self.instances)}") # Store process for tracking
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)}"
)
# Increment play count # Increment play count
sound.increment_play_count() sound.increment_play_count()
# Schedule cleanup # Schedule cleanup after sound duration
threading.Thread( threading.Thread(
target=self._cleanup_after_playback, target=self._cleanup_after_playback,
args=(instance_id, sound.duration if sound.duration else 10), args=(process_id, sound.duration if sound.duration else 10000),
daemon=True, daemon=True,
).start() ).start()
return True return True
def _cleanup_after_playback(self, instance_id: str, duration: int) -> None: except Exception as e:
"""Clean up VLC instance after playback.""" print(f"Error starting VLC process for sound {sound_id}: {e}")
return False
def _cleanup_after_playback(self, process_id: str, duration: int) -> None:
"""Clean up VLC process after playback."""
# Wait for playback to finish (duration + 1 second buffer) # Wait for playback to finish (duration + 1 second buffer)
time.sleep(duration / 1000 + 1) # Convert ms to seconds time.sleep(duration / 1000 + 1) # Convert ms to seconds
with self.lock: with self.lock:
if instance_id in self.instances: if process_id in self.processes:
print(f"Cleaning up instance {instance_id} after playback") print(f"Cleaning up process {process_id} after playback")
instance_data = self.instances[instance_id] process = self.processes[process_id]
player = instance_data["player"]
instance = instance_data["instance"]
try: try:
# Stop player if still playing # Check if process is still running
if player.is_playing(): if process.poll() is None:
player.stop() print(
f"Process {process.pid} still running, terminating"
)
process.terminate()
# Give it a moment to terminate gracefully
try:
process.wait(timeout=2)
except subprocess.TimeoutExpired:
print(
f"Process {process.pid} didn't terminate, killing"
)
process.kill()
# Release resources print(f"Successfully cleaned up process {process_id}")
player.release()
instance.release()
print(f"Successfully cleaned up instance {instance_id}")
except Exception as e: except Exception as e:
print(f"Error during cleanup of {instance_id}: {e}") print(f"Error during cleanup of {process_id}: {e}")
finally: finally:
# Always remove from tracking # Always remove from tracking
del self.instances[instance_id] del self.processes[process_id]
print(f"Removed instance {instance_id}. Remaining instances: {len(self.instances)}") print(
f"Removed process {process_id}. Remaining processes: {len(self.processes)}"
)
else: else:
print(f"Instance {instance_id} not found during cleanup") print(f"Process {process_id} not found during cleanup")
def stop_all(self) -> None: def stop_all(self) -> None:
"""Stop all playing sounds.""" """Stop all playing sounds by killing VLC processes."""
with self.lock: with self.lock:
# Create a copy of the instances to avoid race conditions processes_copy = dict(self.processes)
instances_copy = dict(self.instances) print(
print(f"Stopping {len(instances_copy)} instances: {list(instances_copy.keys())}") f"Stopping {len(processes_copy)} VLC processes: {list(processes_copy.keys())}"
)
for instance_id, instance_data in instances_copy.items(): for process_id, process in processes_copy.items():
try: try:
player = instance_data["player"] if process.poll() is None: # Process is still running
instance = instance_data["instance"] print(
f"Terminating process {process.pid} ({process_id})"
)
process.terminate()
print(f"Stopping instance {instance_id}") # Give it a moment to terminate gracefully
try:
# Force stop the player regardless of state process.wait(timeout=1)
player.stop() print(
f"Process {process.pid} terminated gracefully"
# Give VLC a moment to process the stop command )
time.sleep(0.1) except subprocess.TimeoutExpired:
print(
# Release the media player and instance f"Process {process.pid} didn't terminate, killing forcefully"
player.release() )
instance.release() process.kill()
process.wait() # Wait for it to be killed
print(f"Successfully stopped instance {instance_id}") else:
print(
f"Process {process.pid} ({process_id}) already finished"
)
except Exception as e: except Exception as e:
# Log the error but continue stopping other instances print(f"Error stopping process {process_id}: {e}")
print(f"Error stopping instance {instance_id}: {e}")
# Clear all instances # Clear all processes
self.instances.clear() self.processes.clear()
print(f"Cleared all instances. Remaining: {len(self.instances)}") print(f"Cleared all processes. Remaining: {len(self.processes)}")
def get_playing_count(self) -> int: def get_playing_count(self) -> int:
"""Get number of currently playing sounds.""" """Get number of currently playing sounds."""
with self.lock: with self.lock:
return len(self.instances) # Clean up finished processes and return count
finished_processes = []
for process_id, process in self.processes.items():
if process.poll() is not None: # Process has finished
finished_processes.append(process_id)
# Remove finished processes
for process_id in finished_processes:
del self.processes[process_id]
return len(self.processes)
def force_stop_all(self) -> int: def force_stop_all(self) -> int:
"""Force stop all sounds and clean up resources. Returns count of stopped instances.""" """Force stop all sounds by killing VLC processes aggressively."""
with self.lock: with self.lock:
stopped_count = len(self.instances) stopped_count = len(self.processes)
print(f"Force stopping {stopped_count} instances: {list(self.instances.keys())}") print(f"Force stopping {stopped_count} VLC processes")
# More aggressive cleanup # # Kill all VLC processes aggressively
for instance_id, instance_data in list(self.instances.items()): # 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}")
# Also try to kill any remaining VLC processes system-wide
try: try:
player = instance_data["player"] subprocess.run(["pkill", "-f", "vlc"], check=False)
instance = instance_data["instance"] print("Killed any remaining VLC processes system-wide")
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: except Exception as e:
print(f"Error force-stopping instance {instance_id}: {e}") print(f"Error killing system VLC processes: {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)}") # Clear all processes
self.processes.clear()
print(
f"Force stop completed. Processes remaining: {len(self.processes)}"
)
return stopped_count return stopped_count

View File

@@ -16,7 +16,6 @@ dependencies = [
"flask-sqlalchemy==3.1.1", "flask-sqlalchemy==3.1.1",
"pydub==0.25.1", "pydub==0.25.1",
"python-dotenv==1.1.1", "python-dotenv==1.1.1",
"python-vlc>=3.0.0",
"requests==2.32.4", "requests==2.32.4",
"werkzeug==3.1.3", "werkzeug==3.1.3",
] ]

11
uv.lock generated
View File

@@ -526,15 +526,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556 }, { 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]] [[package]]
name = "requests" name = "requests"
version = "2.32.4" version = "2.32.4"
@@ -590,7 +581,6 @@ dependencies = [
{ name = "flask-sqlalchemy" }, { name = "flask-sqlalchemy" },
{ name = "pydub" }, { name = "pydub" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "python-vlc" },
{ name = "requests" }, { name = "requests" },
{ name = "werkzeug" }, { name = "werkzeug" },
] ]
@@ -614,7 +604,6 @@ requires-dist = [
{ name = "flask-sqlalchemy", specifier = "==3.1.1" }, { name = "flask-sqlalchemy", specifier = "==3.1.1" },
{ name = "pydub", specifier = "==0.25.1" }, { name = "pydub", specifier = "==0.25.1" },
{ name = "python-dotenv", specifier = "==1.1.1" }, { name = "python-dotenv", specifier = "==1.1.1" },
{ name = "python-vlc", specifier = ">=3.0.0" },
{ name = "requests", specifier = "==2.32.4" }, { name = "requests", specifier = "==2.32.4" },
{ name = "werkzeug", specifier = "==3.1.3" }, { name = "werkzeug", specifier = "==3.1.3" },
] ]