diff --git a/frigate/jobs/debug_replay.py b/frigate/jobs/debug_replay.py index f6430ca345..be0f9ac040 100644 --- a/frigate/jobs/debug_replay.py +++ b/frigate/jobs/debug_replay.py @@ -16,8 +16,6 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Optional, cast -from peewee import ModelSelect - from frigate.config import FrigateConfig from frigate.config.camera.updater import CameraConfigUpdatePublisher from frigate.const import ( @@ -29,10 +27,10 @@ from frigate.const import ( from frigate.jobs.export import JobStatePublisher from frigate.jobs.job import Job from frigate.jobs.manager import job_is_running, set_current_job -from frigate.models import Export, Recordings +from frigate.models import Export from frigate.types import JobStatusTypesEnum from frigate.util.ffmpeg import run_ffmpeg_with_progress -from frigate.util.recording_coverage import recording_overlap +from frigate.util.recording_coverage import resolve_coverage if TYPE_CHECKING: from frigate.debug_replay import DebugReplayManager @@ -99,27 +97,6 @@ class DebugReplayJob(Job): } -def query_recordings( - source_camera: str, start_ts: float, end_ts: float, stream_type: str -) -> ModelSelect: - """Return the Recordings query for the time range. - - Module-level so tests can patch it without instantiating a runner. - """ - query = ( - Recordings.select( - Recordings.path, - Recordings.start_time, - Recordings.end_time, - ) - .where(recording_overlap(start_ts, end_ts)) - .where(Recordings.camera == source_camera) - .where(Recordings.stream_type == stream_type) - .order_by(Recordings.start_time.asc()) - ) - return cast(ModelSelect, query) - - class NoRecordingsError(ValueError): """Raised when no recordings exist in the requested time range.""" @@ -197,12 +174,18 @@ class RecordingDebugReplaySource(DebugReplaySource): if self._end_ts <= self._start_ts: raise ValueError("End time must be after start time") - for stream_type in (STREAM_TYPE_MAIN, STREAM_TYPE_SUB): - if query_recordings( - self._camera, self._start_ts, self._end_ts, stream_type - ).count(): - self._stream_type = stream_type - return + # the VOD resolver decides what the pinned playlist serves, so it + # also decides the stream; a row merely touching the range has no + # interval and must not win + intervals = resolve_coverage(self._camera, self._start_ts, self._end_ts) + + if any(interval.main for interval in intervals): + self._stream_type = STREAM_TYPE_MAIN + return + + if any(interval.sub for interval in intervals): + self._stream_type = STREAM_TYPE_SUB + return raise NoRecordingsError( f"No recordings found for camera '{self._camera}' in the specified time range" diff --git a/frigate/test/test_debug_replay_job.py b/frigate/test/test_debug_replay_job.py index 9ea98d20d2..99bf4fbc04 100644 --- a/frigate/test/test_debug_replay_job.py +++ b/frigate/test/test_debug_replay_job.py @@ -18,6 +18,7 @@ from frigate.jobs.debug_replay import ( from frigate.jobs.export import JobStatePublisher from frigate.jobs.manager import _completed_jobs, _current_jobs from frigate.types import JobStatusTypesEnum +from frigate.util.recording_coverage import CoverageInterval def _reset_job_manager() -> None: @@ -78,25 +79,27 @@ class TestDebugReplayJob(unittest.TestCase): class TestRecordingDebugReplaySource(unittest.TestCase): - def _source_url(self, counts: dict[str, int]) -> str: - def query(camera, start_ts, end_ts, stream_type): - qs = MagicMock() - qs.count.return_value = counts.get(stream_type, 0) - return qs - + def _source_url(self, intervals: list[CoverageInterval]) -> str: source = RecordingDebugReplaySource( source_camera="front", start_ts=100.0, end_ts=200.0, internal_port=5000 ) - with patch("frigate.jobs.debug_replay.query_recordings", side_effect=query): + with patch( + "frigate.jobs.debug_replay.resolve_coverage", return_value=intervals + ): source.validate() return source.ffmpeg_input_args("/tmp")[-1] def test_pins_main_when_main_has_recordings(self) -> None: - url = self._source_url({"main": 3, "sub": 5}) + url = self._source_url( + [ + CoverageInterval(100.0, 150.0, None, MagicMock()), + CoverageInterval(150.0, 200.0, MagicMock(), MagicMock()), + ] + ) self.assertIn("/vod/front/main/start/100.0/end/200.0/", url) def test_falls_back_to_sub_without_main_recordings(self) -> None: - url = self._source_url({"sub": 5}) + url = self._source_url([CoverageInterval(100.0, 200.0, None, MagicMock())]) self.assertIn("/vod/front/sub/start/100.0/end/200.0/", url) @@ -110,9 +113,7 @@ class TestStartDebugReplayJob(unittest.TestCase): self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true" self.publisher = MagicMock() - self.recordings_qs = MagicMock() - self.recordings_qs.count.return_value = 1 - self.recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")]) + self.coverage = [CoverageInterval(100.0, 200.0, MagicMock(), None)] def tearDown(self) -> None: runner = get_active_runner() @@ -150,9 +151,7 @@ class TestStartDebugReplayJob(unittest.TestCase): ) def test_rejects_when_no_recordings(self) -> None: - empty_qs = MagicMock() - empty_qs.count.return_value = 0 - with patch("frigate.jobs.debug_replay.query_recordings", return_value=empty_qs): + with patch("frigate.jobs.debug_replay.resolve_coverage", return_value=[]): with self.assertRaises(NoRecordingsError): start_debug_replay_job( source=RecordingDebugReplaySource( @@ -175,8 +174,8 @@ class TestStartDebugReplayJob(unittest.TestCase): with ( patch( - "frigate.jobs.debug_replay.query_recordings", - return_value=self.recordings_qs, + "frigate.jobs.debug_replay.resolve_coverage", + return_value=self.coverage, ), patch( "frigate.jobs.debug_replay.run_ffmpeg_with_progress", @@ -215,8 +214,8 @@ class TestStartDebugReplayJob(unittest.TestCase): with ( patch( - "frigate.jobs.debug_replay.query_recordings", - return_value=self.recordings_qs, + "frigate.jobs.debug_replay.resolve_coverage", + return_value=self.coverage, ), patch( "frigate.jobs.debug_replay.run_ffmpeg_with_progress", @@ -265,9 +264,7 @@ class TestRunnerHappyPath(unittest.TestCase): self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true" self.publisher = MagicMock() - self.recordings_qs = MagicMock() - self.recordings_qs.count.return_value = 1 - self.recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")]) + self.coverage = [CoverageInterval(100.0, 200.0, MagicMock(), None)] def tearDown(self) -> None: runner = get_active_runner() @@ -295,8 +292,8 @@ class TestRunnerHappyPath(unittest.TestCase): with ( patch( - "frigate.jobs.debug_replay.query_recordings", - return_value=self.recordings_qs, + "frigate.jobs.debug_replay.resolve_coverage", + return_value=self.coverage, ), patch( "frigate.jobs.debug_replay.run_ffmpeg_with_progress", @@ -348,9 +345,7 @@ class TestRunnerFailurePath(unittest.TestCase): self.frigate_config.cameras = {"front": MagicMock()} self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true" self.publisher = MagicMock() - self.recordings_qs = MagicMock() - self.recordings_qs.count.return_value = 1 - self.recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")]) + self.coverage = [CoverageInterval(100.0, 200.0, MagicMock(), None)] def tearDown(self) -> None: runner = get_active_runner() @@ -373,8 +368,8 @@ class TestRunnerFailurePath(unittest.TestCase): with ( patch( - "frigate.jobs.debug_replay.query_recordings", - return_value=self.recordings_qs, + "frigate.jobs.debug_replay.resolve_coverage", + return_value=self.coverage, ), patch( "frigate.jobs.debug_replay.run_ffmpeg_with_progress", @@ -422,9 +417,7 @@ class TestRunnerCancellation(unittest.TestCase): self.frigate_config.cameras = {"front": MagicMock()} self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true" self.publisher = MagicMock() - self.recordings_qs = MagicMock() - self.recordings_qs.count.return_value = 1 - self.recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")]) + self.coverage = [CoverageInterval(100.0, 200.0, MagicMock(), None)] def tearDown(self) -> None: runner = get_active_runner() @@ -454,8 +447,8 @@ class TestRunnerCancellation(unittest.TestCase): with ( patch( - "frigate.jobs.debug_replay.query_recordings", - return_value=self.recordings_qs, + "frigate.jobs.debug_replay.resolve_coverage", + return_value=self.coverage, ), patch( "frigate.jobs.debug_replay.run_ffmpeg_with_progress",