diff --git a/docs/docs/configuration/record.md b/docs/docs/configuration/record.md index 4d97b94330..ed4e2d0aec 100644 --- a/docs/docs/configuration/record.md +++ b/docs/docs/configuration/record.md @@ -413,17 +413,18 @@ Avoid removing audio from only one of the two streams (for example with `-an`). As a general rule, features that read recordings prefer the main stream and fall back to the sub stream for time ranges where the main recordings have expired. Analytics features use only the main stream. -| Feature | Stream used | -| ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -| Recording playback (History and Review) | Both (main preferred with sub fallback by default), or exactly one stream when a quality is selected manually | -| Tracking details and Explore clip playback | Main, falling back to sub where the main recordings have expired | +| Feature | Stream used | +| ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| Recording playback (History and Review) | Both (main preferred with sub fallback by default), or exactly one stream when a quality is selected manually | +| Tracking details and Explore clip playback | Main, falling back to sub where the main recordings have expired | | Exports | Both (main preferred with sub fallback by default), or exactly one stream when a quality is selected in the export dialog | -| Clip downloads | Main; sub is used when no main recordings remain in the range (streams are never mixed in one file) | -| Frames grabbed from a recording in History (download snapshot, submit frame to Frigate+) | Main preferred, sub fallback | -| Audio extraction (e.g., transcription) | Main preferred, sub fallback | -| Motion search | Main only | -| Review timeline motion data | Main only | -| Storage usage statistics | Both streams counted, and listed separately per camera | +| Clip downloads | Main; sub is used when no main recordings remain in the range (streams are never mixed in one file) | +| Debug replay | Main; sub is used when no main recordings remain in the range (streams are never mixed in one file) | +| Frames grabbed from a recording in History (download snapshot, submit frame to Frigate+) | Main preferred, sub fallback | +| Audio extraction (e.g., transcription) | Main preferred, sub fallback | +| Motion search | Main only | +| Review timeline motion data | Main only | +| Storage usage statistics | Both streams counted, and listed separately per camera | This table covers only features that read recordings from disk. Tracked object snapshots and thumbnails (the images shown in Explore and sent with notifications, and the images submitted to Frigate+ from a tracked object) are captured live from the `detect` stream as the object is tracked, never from recordings, so sub stream recording does not affect them. diff --git a/frigate/jobs/debug_replay.py b/frigate/jobs/debug_replay.py index 8cfe6402b3..f6430ca345 100644 --- a/frigate/jobs/debug_replay.py +++ b/frigate/jobs/debug_replay.py @@ -20,7 +20,12 @@ from peewee import ModelSelect from frigate.config import FrigateConfig from frigate.config.camera.updater import CameraConfigUpdatePublisher -from frigate.const import REPLAY_CAMERA_PREFIX, REPLAY_DIR +from frigate.const import ( + REPLAY_CAMERA_PREFIX, + REPLAY_DIR, + STREAM_TYPE_MAIN, + STREAM_TYPE_SUB, +) from frigate.jobs.export import JobStatePublisher from frigate.jobs.job import Job from frigate.jobs.manager import job_is_running, set_current_job @@ -94,7 +99,9 @@ class DebugReplayJob(Job): } -def query_recordings(source_camera: str, start_ts: float, end_ts: float) -> ModelSelect: +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. @@ -107,6 +114,7 @@ def query_recordings(source_camera: str, start_ts: float, end_ts: float) -> Mode ) .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) @@ -156,7 +164,8 @@ class RecordingDebugReplaySource(DebugReplaySource): Feeds ffmpeg the internal VOD endpoint so segments with mismatched SPS/PPS (e.g. across day/night transitions) stitch cleanly via HLS - discontinuities. + discontinuities. The playlist is pinned to one stream because the + stream-copied clip cannot change codec or resolution mid-file. """ def __init__( @@ -170,6 +179,7 @@ class RecordingDebugReplaySource(DebugReplaySource): self._start_ts = start_ts self._end_ts = end_ts self._internal_port = internal_port + self._stream_type = STREAM_TYPE_MAIN @property def source_camera(self) -> str: @@ -187,15 +197,22 @@ class RecordingDebugReplaySource(DebugReplaySource): if self._end_ts <= self._start_ts: raise ValueError("End time must be after start time") - if not query_recordings(self._camera, self._start_ts, self._end_ts).count(): - raise NoRecordingsError( - f"No recordings found for camera '{self._camera}' in the specified time range" - ) + 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 + + raise NoRecordingsError( + f"No recordings found for camera '{self._camera}' in the specified time range" + ) def ffmpeg_input_args(self, working_dir: str) -> list[str]: playlist_url = ( f"http://127.0.0.1:{self._internal_port}/vod/{self._camera}" - f"/start/{self._start_ts}/end/{self._end_ts}/index.m3u8" + f"/{self._stream_type}/start/{self._start_ts}/end/{self._end_ts}" + "/index.m3u8" ) return [ "-protocol_whitelist", diff --git a/frigate/test/test_debug_replay_job.py b/frigate/test/test_debug_replay_job.py index e84a67e1d8..9ea98d20d2 100644 --- a/frigate/test/test_debug_replay_job.py +++ b/frigate/test/test_debug_replay_job.py @@ -77,6 +77,29 @@ class TestDebugReplayJob(unittest.TestCase): self.assertEqual(results["end_ts"], 200.0) +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 + + 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): + 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}) + 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}) + self.assertIn("/vod/front/sub/start/100.0/end/200.0/", url) + + class TestStartDebugReplayJob(unittest.TestCase): def setUp(self) -> None: _reset_job_manager()