mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-07 05:55:27 +03:00
make start call non-blocking with worker thread
This commit is contained in:
parent
5fe58d9aa6
commit
e187446d04
@ -25,6 +25,7 @@ from frigate.const import (
|
|||||||
from frigate.models import Recordings
|
from frigate.models import Recordings
|
||||||
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
|
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
|
||||||
from frigate.util.config import find_config_file
|
from frigate.util.config import find_config_file
|
||||||
|
from frigate.util.ffmpeg import run_ffmpeg_with_progress
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -58,6 +59,9 @@ class DebugReplayManager:
|
|||||||
self.clip_path: str | None = None
|
self.clip_path: str | None = None
|
||||||
self.start_ts: float | None = None
|
self.start_ts: float | None = None
|
||||||
self.end_ts: float | None = None
|
self.end_ts: float | None = None
|
||||||
|
self._active_process: sp.Popen | None = None
|
||||||
|
self._worker_thread: threading.Thread | None = None
|
||||||
|
self.progress_percent: float | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def state(self) -> ReplayState:
|
def state(self) -> ReplayState:
|
||||||
@ -78,6 +82,8 @@ class DebugReplayManager:
|
|||||||
"""Internal state transition helper. Always pair `error` with an error_message."""
|
"""Internal state transition helper. Always pair `error` with an error_message."""
|
||||||
self._state = state
|
self._state = state
|
||||||
self.error_message = error_message if state == ReplayState.error else None
|
self.error_message = error_message if state == ReplayState.error else None
|
||||||
|
if state in (ReplayState.idle, ReplayState.error):
|
||||||
|
self.progress_percent = None
|
||||||
|
|
||||||
def start(
|
def start(
|
||||||
self,
|
self,
|
||||||
@ -87,7 +93,10 @@ class DebugReplayManager:
|
|||||||
frigate_config: FrigateConfig,
|
frigate_config: FrigateConfig,
|
||||||
config_publisher: CameraConfigUpdatePublisher,
|
config_publisher: CameraConfigUpdatePublisher,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Start a debug replay session.
|
"""Validate inputs and kick off async startup. Returns immediately.
|
||||||
|
|
||||||
|
The clip generation, config build, and camera publish run on a worker
|
||||||
|
thread. Poll `state` / `error_message` to track progress.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
source_camera: Name of the source camera to replay
|
source_camera: Name of the source camera to replay
|
||||||
@ -97,36 +106,58 @@ class DebugReplayManager:
|
|||||||
config_publisher: Publisher for camera config updates
|
config_publisher: Publisher for camera config updates
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The replay camera name
|
The replay camera name (deterministic from source_camera)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If a session is already active or parameters are invalid
|
ValueError: If a session is already active or parameters are invalid
|
||||||
RuntimeError: If clip generation fails
|
|
||||||
"""
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return self._start_locked(
|
if self.active:
|
||||||
source_camera, start_ts, end_ts, frigate_config, config_publisher
|
raise ValueError("A replay session is already active")
|
||||||
)
|
|
||||||
|
|
||||||
def _start_locked(
|
if source_camera not in frigate_config.cameras:
|
||||||
self,
|
raise ValueError(f"Camera '{source_camera}' not found")
|
||||||
source_camera: str,
|
|
||||||
start_ts: float,
|
|
||||||
end_ts: float,
|
|
||||||
frigate_config: FrigateConfig,
|
|
||||||
config_publisher: CameraConfigUpdatePublisher,
|
|
||||||
) -> str:
|
|
||||||
if self.active:
|
|
||||||
raise ValueError("A replay session is already active")
|
|
||||||
|
|
||||||
if source_camera not in frigate_config.cameras:
|
if end_ts <= start_ts:
|
||||||
raise ValueError(f"Camera '{source_camera}' not found")
|
raise ValueError("End time must be after start time")
|
||||||
|
|
||||||
if end_ts <= start_ts:
|
recordings = self._query_recordings(source_camera, start_ts, end_ts)
|
||||||
raise ValueError("End time must be after start time")
|
if not recordings.count():
|
||||||
|
raise ValueError(
|
||||||
|
f"No recordings found for camera '{source_camera}' in the specified time range"
|
||||||
|
)
|
||||||
|
|
||||||
# Query recordings for the source camera in the time range
|
replay_name = f"{REPLAY_CAMERA_PREFIX}{source_camera}"
|
||||||
recordings = (
|
self.replay_camera_name = replay_name
|
||||||
|
self.source_camera = source_camera
|
||||||
|
self.start_ts = start_ts
|
||||||
|
self.end_ts = end_ts
|
||||||
|
self.progress_percent = None
|
||||||
|
self._set_state(ReplayState.preparing_clip)
|
||||||
|
|
||||||
|
worker = threading.Thread(
|
||||||
|
target=self._run_start_worker,
|
||||||
|
name=f"debug-replay-start-{replay_name}",
|
||||||
|
args=(source_camera, start_ts, end_ts, frigate_config, config_publisher),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self._worker_thread = worker
|
||||||
|
worker.start()
|
||||||
|
|
||||||
|
return replay_name
|
||||||
|
|
||||||
|
def _query_recordings(self, source_camera: str, start_ts: float, end_ts: float):
|
||||||
|
"""Return the Recordings query for the time range. Extracted so tests can patch.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_camera: Name of the source camera
|
||||||
|
start_ts: Start timestamp
|
||||||
|
end_ts: End timestamp
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Peewee query for recordings in the time range
|
||||||
|
"""
|
||||||
|
return (
|
||||||
Recordings.select(
|
Recordings.select(
|
||||||
Recordings.path,
|
Recordings.path,
|
||||||
Recordings.start_time,
|
Recordings.start_time,
|
||||||
@ -141,79 +172,148 @@ class DebugReplayManager:
|
|||||||
.order_by(Recordings.start_time.asc())
|
.order_by(Recordings.start_time.asc())
|
||||||
)
|
)
|
||||||
|
|
||||||
if not recordings.count():
|
def _run_start_worker(
|
||||||
raise ValueError(
|
self,
|
||||||
f"No recordings found for camera '{source_camera}' in the specified time range"
|
source_camera: str,
|
||||||
)
|
start_ts: float,
|
||||||
|
end_ts: float,
|
||||||
|
frigate_config: FrigateConfig,
|
||||||
|
config_publisher: CameraConfigUpdatePublisher,
|
||||||
|
) -> None:
|
||||||
|
"""Worker thread body — runs ffmpeg and publishes the camera config.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_camera: Name of the source camera to replay
|
||||||
|
start_ts: Start timestamp
|
||||||
|
end_ts: End timestamp
|
||||||
|
frigate_config: Current Frigate configuration
|
||||||
|
config_publisher: Publisher for camera config updates
|
||||||
|
"""
|
||||||
|
replay_name = self.replay_camera_name
|
||||||
|
if replay_name is None:
|
||||||
|
return
|
||||||
|
|
||||||
# Create replay directory
|
|
||||||
os.makedirs(REPLAY_DIR, exist_ok=True)
|
os.makedirs(REPLAY_DIR, exist_ok=True)
|
||||||
|
|
||||||
# Generate replay camera name
|
|
||||||
replay_name = f"{REPLAY_CAMERA_PREFIX}{source_camera}"
|
|
||||||
|
|
||||||
# Build concat file for ffmpeg
|
|
||||||
concat_file = os.path.join(REPLAY_DIR, f"{replay_name}_concat.txt")
|
concat_file = os.path.join(REPLAY_DIR, f"{replay_name}_concat.txt")
|
||||||
clip_path = os.path.join(REPLAY_DIR, f"{replay_name}.mp4")
|
clip_path = os.path.join(REPLAY_DIR, f"{replay_name}.mp4")
|
||||||
|
|
||||||
with open(concat_file, "w") as f:
|
|
||||||
for recording in recordings:
|
|
||||||
f.write(f"file '{recording.path}'\n")
|
|
||||||
|
|
||||||
# Concatenate recordings into a single clip with -c copy (fast)
|
|
||||||
ffmpeg_cmd = [
|
|
||||||
frigate_config.ffmpeg.ffmpeg_path,
|
|
||||||
"-hide_banner",
|
|
||||||
"-y",
|
|
||||||
"-f",
|
|
||||||
"concat",
|
|
||||||
"-safe",
|
|
||||||
"0",
|
|
||||||
"-i",
|
|
||||||
concat_file,
|
|
||||||
"-c",
|
|
||||||
"copy",
|
|
||||||
"-movflags",
|
|
||||||
"+faststart",
|
|
||||||
clip_path,
|
|
||||||
]
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Generating replay clip for %s (%.1f - %.1f)",
|
|
||||||
source_camera,
|
|
||||||
start_ts,
|
|
||||||
end_ts,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = sp.run(
|
recordings = self._query_recordings(source_camera, start_ts, end_ts)
|
||||||
ffmpeg_cmd,
|
with open(concat_file, "w") as f:
|
||||||
capture_output=True,
|
for recording in recordings:
|
||||||
text=True,
|
f.write(f"file '{recording.path}'\n")
|
||||||
timeout=120,
|
|
||||||
|
ffmpeg_cmd = [
|
||||||
|
frigate_config.ffmpeg.ffmpeg_path,
|
||||||
|
"-hide_banner",
|
||||||
|
"-y",
|
||||||
|
"-f",
|
||||||
|
"concat",
|
||||||
|
"-safe",
|
||||||
|
"0",
|
||||||
|
"-i",
|
||||||
|
concat_file,
|
||||||
|
"-c",
|
||||||
|
"copy",
|
||||||
|
"-movflags",
|
||||||
|
"+faststart",
|
||||||
|
clip_path,
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Generating replay clip for %s (%.1f - %.1f)",
|
||||||
|
source_camera,
|
||||||
|
start_ts,
|
||||||
|
end_ts,
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
|
||||||
logger.error("FFmpeg error: %s", result.stderr)
|
def _record_proc(p: sp.Popen) -> None:
|
||||||
raise RuntimeError(
|
self._active_process = p
|
||||||
f"Failed to generate replay clip: {result.stderr[-500:]}"
|
|
||||||
|
def _on_progress(percent: float) -> None:
|
||||||
|
self.progress_percent = percent
|
||||||
|
|
||||||
|
try:
|
||||||
|
returncode, stderr = run_ffmpeg_with_progress(
|
||||||
|
ffmpeg_cmd,
|
||||||
|
expected_duration_seconds=max(0.0, end_ts - start_ts),
|
||||||
|
on_progress=_on_progress,
|
||||||
|
process_started=_record_proc,
|
||||||
|
use_low_priority=True,
|
||||||
)
|
)
|
||||||
except sp.TimeoutExpired:
|
finally:
|
||||||
raise RuntimeError("Clip generation timed out")
|
self._active_process = None
|
||||||
|
|
||||||
|
if returncode != 0:
|
||||||
|
raise RuntimeError(f"FFmpeg failed: {stderr[-500:]}")
|
||||||
|
|
||||||
|
if not os.path.exists(clip_path):
|
||||||
|
raise RuntimeError("Clip file was not created")
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
# If stop() ran while we were preparing, bail out cleanly.
|
||||||
|
if self._state != ReplayState.preparing_clip:
|
||||||
|
logger.info(
|
||||||
|
"Replay startup aborted (state=%s); discarding clip",
|
||||||
|
self._state,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self._set_state(ReplayState.starting_camera)
|
||||||
|
|
||||||
|
self._publish_replay_camera(
|
||||||
|
source_camera, replay_name, clip_path, frigate_config, config_publisher
|
||||||
|
)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self.clip_path = clip_path
|
||||||
|
self._set_state(ReplayState.active)
|
||||||
|
|
||||||
|
logger.info("Debug replay started: %s -> %s", source_camera, replay_name)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Debug replay startup failed")
|
||||||
|
with self._lock:
|
||||||
|
self._set_state(ReplayState.error, error_message=str(exc))
|
||||||
|
# Drop session pointers so the next /start is allowed.
|
||||||
|
self.replay_camera_name = None
|
||||||
|
self.source_camera = None
|
||||||
|
self.clip_path = None
|
||||||
|
self.start_ts = None
|
||||||
|
self.end_ts = None
|
||||||
|
# Best-effort cleanup of any partial clip on disk.
|
||||||
|
try:
|
||||||
|
if os.path.exists(clip_path):
|
||||||
|
os.remove(clip_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
finally:
|
finally:
|
||||||
# Clean up concat file
|
try:
|
||||||
if os.path.exists(concat_file):
|
if os.path.exists(concat_file):
|
||||||
os.remove(concat_file)
|
os.remove(concat_file)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
if not os.path.exists(clip_path):
|
def _publish_replay_camera(
|
||||||
raise RuntimeError("Clip file was not created")
|
self,
|
||||||
|
source_camera: str,
|
||||||
|
replay_name: str,
|
||||||
|
clip_path: str,
|
||||||
|
frigate_config: FrigateConfig,
|
||||||
|
config_publisher: CameraConfigUpdatePublisher,
|
||||||
|
) -> None:
|
||||||
|
"""Build the in-memory camera config and publish the add event.
|
||||||
|
|
||||||
# Build camera config dict for the replay camera
|
Args:
|
||||||
|
source_camera: Name of the source camera
|
||||||
|
replay_name: Name for the replay camera
|
||||||
|
clip_path: Path to the replay clip file
|
||||||
|
frigate_config: Current Frigate configuration
|
||||||
|
config_publisher: Publisher for camera config updates
|
||||||
|
"""
|
||||||
source_config = frigate_config.cameras[source_camera]
|
source_config = frigate_config.cameras[source_camera]
|
||||||
camera_dict = self._build_camera_config_dict(
|
camera_dict = self._build_camera_config_dict(
|
||||||
source_config, replay_name, clip_path
|
source_config, replay_name, clip_path
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build an in-memory config with the replay camera added
|
|
||||||
config_file = find_config_file()
|
config_file = find_config_file()
|
||||||
yaml_parser = YAML()
|
yaml_parser = YAML()
|
||||||
with open(config_file, "r") as f:
|
with open(config_file, "r") as f:
|
||||||
@ -223,31 +323,14 @@ class DebugReplayManager:
|
|||||||
config_data["cameras"] = {}
|
config_data["cameras"] = {}
|
||||||
config_data["cameras"][replay_name] = camera_dict
|
config_data["cameras"][replay_name] = camera_dict
|
||||||
|
|
||||||
try:
|
new_config = FrigateConfig.parse_object(config_data)
|
||||||
new_config = FrigateConfig.parse_object(config_data)
|
|
||||||
except Exception as e:
|
|
||||||
raise RuntimeError(f"Failed to validate replay camera config: {e}")
|
|
||||||
|
|
||||||
# Update the running config
|
|
||||||
frigate_config.cameras[replay_name] = new_config.cameras[replay_name]
|
frigate_config.cameras[replay_name] = new_config.cameras[replay_name]
|
||||||
|
|
||||||
# Publish the add event
|
|
||||||
config_publisher.publish_update(
|
config_publisher.publish_update(
|
||||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.add, replay_name),
|
CameraConfigUpdateTopic(CameraConfigUpdateEnum.add, replay_name),
|
||||||
new_config.cameras[replay_name],
|
new_config.cameras[replay_name],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Store session state
|
|
||||||
self.replay_camera_name = replay_name
|
|
||||||
self.source_camera = source_camera
|
|
||||||
self.clip_path = clip_path
|
|
||||||
self.start_ts = start_ts
|
|
||||||
self.end_ts = end_ts
|
|
||||||
self._set_state(ReplayState.active)
|
|
||||||
|
|
||||||
logger.info("Debug replay started: %s -> %s", source_camera, replay_name)
|
|
||||||
return replay_name
|
|
||||||
|
|
||||||
def stop(
|
def stop(
|
||||||
self,
|
self,
|
||||||
frigate_config: FrigateConfig,
|
frigate_config: FrigateConfig,
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
"""Tests for DebugReplayManager state machine and async startup."""
|
"""Tests for DebugReplayManager state machine and async startup."""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
|
import unittest.mock
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from frigate.debug_replay import DebugReplayManager, ReplayState
|
from frigate.debug_replay import DebugReplayManager, ReplayState
|
||||||
|
|
||||||
@ -34,3 +38,163 @@ class TestDebugReplayManagerState(unittest.TestCase):
|
|||||||
manager._set_state(ReplayState.error, error_message="boom")
|
manager._set_state(ReplayState.error, error_message="boom")
|
||||||
self.assertFalse(manager.active)
|
self.assertFalse(manager.active)
|
||||||
self.assertEqual(manager.error_message, "boom")
|
self.assertEqual(manager.error_message, "boom")
|
||||||
|
|
||||||
|
|
||||||
|
class TestDebugReplayManagerAsyncStart(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.manager = DebugReplayManager()
|
||||||
|
self.frigate_config = MagicMock()
|
||||||
|
self.frigate_config.cameras = {"front": MagicMock()}
|
||||||
|
self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true"
|
||||||
|
self.publisher = MagicMock()
|
||||||
|
|
||||||
|
def test_progress_percent_tracks_helper_callbacks(self):
|
||||||
|
recordings_qs = MagicMock()
|
||||||
|
recordings_qs.count.return_value = 1
|
||||||
|
recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")])
|
||||||
|
|
||||||
|
def fake_helper(cmd, *, expected_duration_seconds, on_progress, **kwargs):
|
||||||
|
on_progress(0.0)
|
||||||
|
on_progress(42.5)
|
||||||
|
on_progress(100.0)
|
||||||
|
return 0, ""
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(self.manager, "_query_recordings", return_value=recordings_qs),
|
||||||
|
patch("frigate.debug_replay.run_ffmpeg_with_progress", side_effect=fake_helper),
|
||||||
|
patch.object(self.manager, "_publish_replay_camera"),
|
||||||
|
patch("os.path.exists", return_value=True),
|
||||||
|
patch("os.makedirs"),
|
||||||
|
patch("builtins.open", unittest.mock.mock_open()),
|
||||||
|
):
|
||||||
|
self.manager.start(
|
||||||
|
source_camera="front",
|
||||||
|
start_ts=100.0,
|
||||||
|
end_ts=200.0,
|
||||||
|
frigate_config=self.frigate_config,
|
||||||
|
config_publisher=self.publisher,
|
||||||
|
)
|
||||||
|
for _ in range(50):
|
||||||
|
if self.manager.state == ReplayState.active:
|
||||||
|
break
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
# Progress should have advanced through the callback values.
|
||||||
|
self.assertEqual(self.manager.state, ReplayState.active)
|
||||||
|
self.assertEqual(self.manager.progress_percent, 100.0)
|
||||||
|
|
||||||
|
def test_start_returns_immediately_with_preparing_state(self):
|
||||||
|
recordings_qs = MagicMock()
|
||||||
|
recordings_qs.count.return_value = 1
|
||||||
|
recordings_qs.__iter__.return_value = iter(
|
||||||
|
[MagicMock(path="/tmp/r1.mp4")]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Block the worker thread before it transitions out of preparing_clip.
|
||||||
|
worker_can_proceed = threading.Event()
|
||||||
|
|
||||||
|
def fake_helper(cmd, *, expected_duration_seconds, on_progress, **kwargs):
|
||||||
|
worker_can_proceed.wait(timeout=5)
|
||||||
|
return 0, ""
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
self.manager,
|
||||||
|
"_query_recordings",
|
||||||
|
return_value=recordings_qs,
|
||||||
|
),
|
||||||
|
patch("frigate.debug_replay.run_ffmpeg_with_progress", side_effect=fake_helper),
|
||||||
|
patch.object(self.manager, "_publish_replay_camera"),
|
||||||
|
patch("os.path.exists", return_value=True),
|
||||||
|
patch("os.makedirs"),
|
||||||
|
patch("builtins.open", unittest.mock.mock_open()),
|
||||||
|
):
|
||||||
|
replay_name = self.manager.start(
|
||||||
|
source_camera="front",
|
||||||
|
start_ts=100.0,
|
||||||
|
end_ts=200.0,
|
||||||
|
frigate_config=self.frigate_config,
|
||||||
|
config_publisher=self.publisher,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Returned synchronously
|
||||||
|
self.assertTrue(replay_name.startswith("_replay_"))
|
||||||
|
self.assertEqual(self.manager.state, ReplayState.preparing_clip)
|
||||||
|
|
||||||
|
worker_can_proceed.set()
|
||||||
|
|
||||||
|
# Wait for worker to finish
|
||||||
|
for _ in range(50):
|
||||||
|
if self.manager.state == ReplayState.active:
|
||||||
|
break
|
||||||
|
time.sleep(0.05)
|
||||||
|
self.assertEqual(self.manager.state, ReplayState.active)
|
||||||
|
|
||||||
|
def test_start_rejects_concurrent_calls_with_value_error(self):
|
||||||
|
recordings_qs = MagicMock()
|
||||||
|
recordings_qs.count.return_value = 1
|
||||||
|
recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")])
|
||||||
|
|
||||||
|
block = threading.Event()
|
||||||
|
|
||||||
|
def slow_helper(cmd, *, expected_duration_seconds, on_progress, **kwargs):
|
||||||
|
block.wait(timeout=5)
|
||||||
|
return 0, ""
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(self.manager, "_query_recordings", return_value=recordings_qs),
|
||||||
|
patch("frigate.debug_replay.run_ffmpeg_with_progress", side_effect=slow_helper),
|
||||||
|
patch.object(self.manager, "_publish_replay_camera"),
|
||||||
|
patch("os.path.exists", return_value=True),
|
||||||
|
patch("os.makedirs"),
|
||||||
|
patch("builtins.open", unittest.mock.mock_open()),
|
||||||
|
):
|
||||||
|
self.manager.start(
|
||||||
|
source_camera="front",
|
||||||
|
start_ts=100.0,
|
||||||
|
end_ts=200.0,
|
||||||
|
frigate_config=self.frigate_config,
|
||||||
|
config_publisher=self.publisher,
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.manager.start(
|
||||||
|
source_camera="front",
|
||||||
|
start_ts=100.0,
|
||||||
|
end_ts=200.0,
|
||||||
|
frigate_config=self.frigate_config,
|
||||||
|
config_publisher=self.publisher,
|
||||||
|
)
|
||||||
|
|
||||||
|
block.set()
|
||||||
|
|
||||||
|
def test_start_transitions_to_error_state_when_ffmpeg_fails(self):
|
||||||
|
recordings_qs = MagicMock()
|
||||||
|
recordings_qs.count.return_value = 1
|
||||||
|
recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")])
|
||||||
|
|
||||||
|
def failing_helper(cmd, *, expected_duration_seconds, on_progress, **kwargs):
|
||||||
|
return 1, "ffmpeg exploded"
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(self.manager, "_query_recordings", return_value=recordings_qs),
|
||||||
|
patch("frigate.debug_replay.run_ffmpeg_with_progress", side_effect=failing_helper),
|
||||||
|
patch("os.path.exists", return_value=True),
|
||||||
|
patch("os.makedirs"),
|
||||||
|
patch("builtins.open", unittest.mock.mock_open()),
|
||||||
|
):
|
||||||
|
self.manager.start(
|
||||||
|
source_camera="front",
|
||||||
|
start_ts=100.0,
|
||||||
|
end_ts=200.0,
|
||||||
|
frigate_config=self.frigate_config,
|
||||||
|
config_publisher=self.publisher,
|
||||||
|
)
|
||||||
|
|
||||||
|
for _ in range(50):
|
||||||
|
if self.manager.state == ReplayState.error:
|
||||||
|
break
|
||||||
|
time.sleep(0.05)
|
||||||
|
self.assertEqual(self.manager.state, ReplayState.error)
|
||||||
|
self.assertIsNotNone(self.manager.error_message)
|
||||||
|
self.assertIn("ffmpeg", self.manager.error_message.lower())
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user