From 0928324840f6c56e1b42f1d256f90c7e6bcbb940 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:04:40 -0500 Subject: [PATCH] plan exports from the same coverage the vod route serves The vod manifest nulls video-only glitch rows on audio-bearing streams, but exports planned their stream runs from the raw coverage, so a glitch row could produce a mixed-stream file or a 404 that failed the export. `null_audio_glitches` now works out each stream's audio composition itself, and exports go through it like the manifest and its realized timelines do. An unstaged auto export also paged its playlist and chapters over main whenever main had any rows in range, even when the manifest served the range from sub and main only contributed glitches or slivers at the edges. It now reads the rows of the stream its single run actually uses. --- frigate/api/media.py | 8 +------- frigate/record/export.py | 29 ++++++++++++++++++++--------- frigate/test/test_export.py | 16 ++++++++++++++++ frigate/util/recording_coverage.py | 14 +++++++------- 4 files changed, 44 insertions(+), 23 deletions(-) diff --git a/frigate/api/media.py b/frigate/api/media.py index f94886b48e..2305f06eb6 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -63,7 +63,6 @@ from frigate.util.recording_coverage import ( null_audio_glitches, plan_clip, resolve_coverage, - stream_has_audio, ) logger = logging.getLogger(__name__) @@ -681,15 +680,10 @@ async def _vod_response( end_ts, force_discontinuity, ) - intervals = resolve_coverage(camera_name, start_ts, end_ts) - # rows contradicting their stream's audio composition are # truncated-shutdown glitches - main_audio = stream_has_audio(intervals, main=True) - sub_audio = stream_has_audio(intervals, main=False) - spans = build_spans( - null_audio_glitches(intervals, main_audio, sub_audio), + null_audio_glitches(resolve_coverage(camera_name, start_ts, end_ts)), stream_preference, ) diff --git a/frigate/record/export.py b/frigate/record/export.py index 7c87c5fd83..64d19b1ef2 100644 --- a/frigate/record/export.py +++ b/frigate/record/export.py @@ -42,6 +42,7 @@ from frigate.util.ownership import chown_to_runtime from frigate.util.recording_coverage import ( build_spans, known_video_codecs, + null_audio_glitches, resolve_coverage, stream_media_summary, ) @@ -376,15 +377,18 @@ class RecordingExporter(threading.Thread): def _resolve_coverage(self) -> tuple[list[list[Any]], set[str], bool]: """Resolve the export range into the spans the VOD manifest will serve. - Delegates to the same coverage resolution the manifest builder - uses, so what we plan around and what nginx-vod emits agree by - construction. Returns the spans (each [row, start, end, is_main]), - the known video codecs, and whether audio survives the range. + Delegates to the same coverage resolution and glitch nulling the + manifest builder uses, so what we plan around and what nginx-vod + emits agree by construction. Returns the spans (each [row, start, + end, is_main]), the known video codecs, and whether audio survives + the range. Memoized: several stages of the export ask the same question, and the recordings backing a finished range do not change under us. """ if self._coverage is None: - intervals = resolve_coverage(self.camera, self.start_time, self.end_time) + intervals = null_audio_glitches( + resolve_coverage(self.camera, self.start_time, self.end_time) + ) self._coverage = ( build_spans(intervals, self.pinned_stream), known_video_codecs(intervals), @@ -1149,11 +1153,18 @@ class RecordingExporter(threading.Thread): # its own rows are the ones the chapters describe recordings = self._get_recordings_for_range(pin) else: - # never mix streams in one playlist; use main when available - # and fall back to sub for expired-main history - recordings = self._get_recordings_for_range(STREAM_TYPE_MAIN) + # an unstaged auto range resolves to at most one stream run, and + # its rows are the ones the chapters describe. Main rows the + # manifest drops (glitches, slivers at the edges of a sub range) + # must not stand in for it. + runs = self._stream_runs(self._merged_spans()) + recordings = self._get_recordings_for_range( + runs[0].stream_type if runs else STREAM_TYPE_MAIN + ) - if not recordings: + # never mix streams in one playlist; fall back to sub for + # expired-main history + if not recordings and not runs: recordings = self._get_recordings_for_range(STREAM_TYPE_SUB) playlist_lines = [] diff --git a/frigate/test/test_export.py b/frigate/test/test_export.py index 4aee38fb6c..624bf885d4 100644 --- a/frigate/test/test_export.py +++ b/frigate/test/test_export.py @@ -543,6 +543,22 @@ class TestPinnedStream(unittest.TestCase): self.assertFalse(any("/vod/front/main/" in token for token in cmd)) +class TestExportTimelineAlignment(unittest.TestCase): + def test_unstaged_auto_reads_the_stream_it_serves(self) -> None: + # a sub-only range whose main rows are glitches the manifest drops + exporter = _make_exporter([_span("/s1.mp4", 1_000, 1_040, False)], {"h264"}) + streams: list[str] = [] + + def rows(stream: str) -> list: + streams.append(stream) + return [_FakeRow(f"/{stream}.mp4")] + + exporter._get_recordings_for_range = rows # type: ignore[method-assign] + exporter.get_record_export_command("/exports/out.mp4") + + self.assertEqual(streams, ["sub"]) + + class TestStagedFileCleanup(unittest.TestCase): """A staged path must be tracked before ffmpeg can write to it.""" diff --git a/frigate/util/recording_coverage.py b/frigate/util/recording_coverage.py index d8bbd55ad6..96a3ec5292 100644 --- a/frigate/util/recording_coverage.py +++ b/frigate/util/recording_coverage.py @@ -213,17 +213,19 @@ def stream_has_audio(intervals: list[CoverageInterval], main: bool) -> bool: ) -def null_audio_glitches( - intervals: list[CoverageInterval], main_audio: bool, sub_audio: bool -) -> list[CoverageInterval]: +def null_audio_glitches(intervals: list[CoverageInterval]) -> list[CoverageInterval]: """Treat video-only glitch rows on audio-bearing streams as no recording. nginx-vod requires every clip in a sequence to carry the same track count, so a truncated video-only segment (a backend restart can flush a sub-second file before any audio packet landed) poisons every manifest that includes it. Nulling the row turns the glitch into a - hole the span builder skips like any recording gap. + hole the span builder skips like any recording gap. Every consumer of + a window's coverage (the vod manifest, its realized timelines, and + exports) goes through here, so they all agree on which rows exist. """ + main_audio = stream_has_audio(intervals, main=True) + sub_audio = stream_has_audio(intervals, main=False) result: list[CoverageInterval] = [] for interval in intervals: main = interval.main @@ -449,9 +451,7 @@ def realized_timelines( assembles each variant's realized spans. Keyframe snapping reads the per-row index stored at record time, so no file is touched. """ - main_audio = stream_has_audio(intervals, main=True) - sub_audio = stream_has_audio(intervals, main=False) - nulled = null_audio_glitches(intervals, main_audio, sub_audio) + nulled = null_audio_glitches(intervals) return { "auto": realized_timeline(nulled, None),