feat(vlc_service): refactor VLC service to use subprocess for sound playback and management; update process tracking
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
# try:
|
||||||
player = instance_data["player"]
|
# if process.poll() is None: # Process is still running
|
||||||
instance = instance_data["instance"]
|
# 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")
|
||||||
|
|
||||||
print(f"Force stopping instance {instance_id}")
|
# except Exception as e:
|
||||||
|
# print(f"Error force-stopping process {process_id}: {e}")
|
||||||
|
|
||||||
# Multiple stop attempts
|
# Also try to kill any remaining VLC processes system-wide
|
||||||
for attempt in range(3):
|
try:
|
||||||
if hasattr(player, 'stop'):
|
subprocess.run(["pkill", "-f", "vlc"], check=False)
|
||||||
player.stop()
|
print("Killed any remaining VLC processes system-wide")
|
||||||
print(f"Stop attempt {attempt + 1} for {instance_id}")
|
except Exception as e:
|
||||||
time.sleep(0.05) # Short delay between attempts
|
print(f"Error killing system VLC processes: {e}")
|
||||||
|
|
||||||
# Force release
|
# Clear all processes
|
||||||
if hasattr(player, 'release'):
|
self.processes.clear()
|
||||||
player.release()
|
print(
|
||||||
print(f"Released player for {instance_id}")
|
f"Force stop completed. Processes remaining: {len(self.processes)}"
|
||||||
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
|
return stopped_count
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
11
uv.lock
generated
@@ -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" },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user