mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-13 00:53:02 +03:00
choose the replay stream with the vod coverage resolver
Stream selection counted a main row that only touched the range, while the pinned playlist served nothing for it, so replay failed instead of using the sub footage. Selection now uses resolve_coverage, the same resolver the pinned vod and export use.
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user