feat: coordinate authoritative playback and audio routing
This commit is contained in:
+109
-3
@@ -4,9 +4,11 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
@@ -32,6 +34,10 @@ class DownloadEvent:
|
||||
message: str | None = None
|
||||
job_id: str | None = None
|
||||
generation: int | None = None
|
||||
url: str | None = None
|
||||
submitter: str | None = None
|
||||
note: str | None = None
|
||||
reused_slot: bool = False
|
||||
|
||||
|
||||
EventCallback = Callable[[DownloadEvent], None]
|
||||
@@ -66,7 +72,7 @@ class DownloadManager:
|
||||
store: ControllerStore,
|
||||
*,
|
||||
on_event: EventCallback | None = None,
|
||||
runner: Runner = subprocess.run,
|
||||
runner: Runner | None = None,
|
||||
executor: ThreadPoolExecutor | None = None,
|
||||
):
|
||||
self.config = config
|
||||
@@ -80,6 +86,7 @@ class DownloadManager:
|
||||
self._owns_executor = executor is None
|
||||
self._lock = threading.Lock()
|
||||
self._futures: set[Future[Any]] = set()
|
||||
self._processes: set[subprocess.Popen[str]] = set()
|
||||
self._stopping = False
|
||||
|
||||
def enqueue(
|
||||
@@ -106,6 +113,10 @@ class DownloadManager:
|
||||
state="queued",
|
||||
job_id=reservation.job_id,
|
||||
generation=reservation.generation,
|
||||
url=url,
|
||||
submitter=submitter,
|
||||
note=note,
|
||||
reused_slot=reservation.reused_slot,
|
||||
)
|
||||
)
|
||||
try:
|
||||
@@ -184,7 +195,7 @@ class DownloadManager:
|
||||
url,
|
||||
]
|
||||
try:
|
||||
result = self.runner(
|
||||
result = self._run_command(
|
||||
arguments,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
@@ -246,7 +257,7 @@ class DownloadManager:
|
||||
),
|
||||
url,
|
||||
]
|
||||
self.runner(
|
||||
self._run_command(
|
||||
arguments,
|
||||
check=True,
|
||||
text=True,
|
||||
@@ -322,6 +333,90 @@ class DownloadManager:
|
||||
return f"yt-dlp failed with exit code {exc.returncode}"
|
||||
return f"download failed: {exc}"
|
||||
|
||||
def _run_command(
|
||||
self,
|
||||
arguments: list[str],
|
||||
*,
|
||||
check: bool,
|
||||
text: bool,
|
||||
timeout: float,
|
||||
capture_output: bool = False,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
if self.runner is not None:
|
||||
return self.runner(
|
||||
arguments,
|
||||
check=check,
|
||||
text=text,
|
||||
timeout=timeout,
|
||||
capture_output=capture_output,
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
if self._stopping:
|
||||
raise RuntimeError("Controller is shutting down")
|
||||
process = subprocess.Popen(
|
||||
arguments,
|
||||
stdout=subprocess.PIPE if capture_output else None,
|
||||
stderr=subprocess.PIPE if capture_output else None,
|
||||
text=text,
|
||||
start_new_session=True,
|
||||
)
|
||||
self._processes.add(process)
|
||||
try:
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._terminate_process(process, force=False)
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=2.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._terminate_process(process, force=True)
|
||||
stdout, stderr = process.communicate()
|
||||
raise subprocess.TimeoutExpired(
|
||||
arguments,
|
||||
timeout,
|
||||
output=stdout,
|
||||
stderr=stderr,
|
||||
)
|
||||
completed = subprocess.CompletedProcess(
|
||||
arguments,
|
||||
process.returncode,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
)
|
||||
if check and process.returncode:
|
||||
raise subprocess.CalledProcessError(
|
||||
process.returncode,
|
||||
arguments,
|
||||
output=stdout,
|
||||
stderr=stderr,
|
||||
)
|
||||
return completed
|
||||
finally:
|
||||
with self._lock:
|
||||
self._processes.discard(process)
|
||||
|
||||
@staticmethod
|
||||
def _terminate_process(
|
||||
process: subprocess.Popen[str],
|
||||
*,
|
||||
force: bool,
|
||||
) -> None:
|
||||
if process.poll() is not None:
|
||||
return
|
||||
try:
|
||||
os.killpg(
|
||||
process.pid,
|
||||
signal.SIGKILL if force else signal.SIGTERM,
|
||||
)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
except OSError:
|
||||
try:
|
||||
process.kill() if force else process.terminate()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
@@ -387,6 +482,17 @@ class DownloadManager:
|
||||
def shutdown(self, *, wait: bool = True) -> None:
|
||||
with self._lock:
|
||||
self._stopping = True
|
||||
processes = tuple(self._processes)
|
||||
for process in processes:
|
||||
self._terminate_process(process, force=False)
|
||||
deadline = time.monotonic() + 2.0
|
||||
for process in processes:
|
||||
if process.poll() is not None:
|
||||
continue
|
||||
try:
|
||||
process.wait(timeout=max(0.0, deadline - time.monotonic()))
|
||||
except subprocess.TimeoutExpired:
|
||||
self._terminate_process(process, force=True)
|
||||
if self._owns_executor:
|
||||
self._executor.shutdown(wait=wait, cancel_futures=True)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user