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),