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.
This commit is contained in:
Josh Hawkins
2026-09-28 09:28:09 -05:00
parent 386b3d31aa
commit 0928324840
4 changed files with 44 additions and 23 deletions
+1 -7
View File
@@ -63,7 +63,6 @@ from frigate.util.recording_coverage import (
null_audio_glitches, null_audio_glitches,
plan_clip, plan_clip,
resolve_coverage, resolve_coverage,
stream_has_audio,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -681,15 +680,10 @@ async def _vod_response(
end_ts, end_ts,
force_discontinuity, force_discontinuity,
) )
intervals = resolve_coverage(camera_name, start_ts, end_ts)
# rows contradicting their stream's audio composition are # rows contradicting their stream's audio composition are
# truncated-shutdown glitches # truncated-shutdown glitches
main_audio = stream_has_audio(intervals, main=True)
sub_audio = stream_has_audio(intervals, main=False)
spans = build_spans( spans = build_spans(
null_audio_glitches(intervals, main_audio, sub_audio), null_audio_glitches(resolve_coverage(camera_name, start_ts, end_ts)),
stream_preference, stream_preference,
) )
+20 -9
View File
@@ -42,6 +42,7 @@ from frigate.util.ownership import chown_to_runtime
from frigate.util.recording_coverage import ( from frigate.util.recording_coverage import (
build_spans, build_spans,
known_video_codecs, known_video_codecs,
null_audio_glitches,
resolve_coverage, resolve_coverage,
stream_media_summary, stream_media_summary,
) )
@@ -376,15 +377,18 @@ class RecordingExporter(threading.Thread):
def _resolve_coverage(self) -> tuple[list[list[Any]], set[str], bool]: def _resolve_coverage(self) -> tuple[list[list[Any]], set[str], bool]:
"""Resolve the export range into the spans the VOD manifest will serve. """Resolve the export range into the spans the VOD manifest will serve.
Delegates to the same coverage resolution the manifest builder Delegates to the same coverage resolution and glitch nulling the
uses, so what we plan around and what nginx-vod emits agree by manifest builder uses, so what we plan around and what nginx-vod
construction. Returns the spans (each [row, start, end, is_main]), emits agree by construction. Returns the spans (each [row, start,
the known video codecs, and whether audio survives the range. end, is_main]), the known video codecs, and whether audio survives
the range.
Memoized: several stages of the export ask the same question, and Memoized: several stages of the export ask the same question, and
the recordings backing a finished range do not change under us. the recordings backing a finished range do not change under us.
""" """
if self._coverage is None: 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 = ( self._coverage = (
build_spans(intervals, self.pinned_stream), build_spans(intervals, self.pinned_stream),
known_video_codecs(intervals), known_video_codecs(intervals),
@@ -1149,11 +1153,18 @@ class RecordingExporter(threading.Thread):
# its own rows are the ones the chapters describe # its own rows are the ones the chapters describe
recordings = self._get_recordings_for_range(pin) recordings = self._get_recordings_for_range(pin)
else: else:
# never mix streams in one playlist; use main when available # an unstaged auto range resolves to at most one stream run, and
# and fall back to sub for expired-main history # its rows are the ones the chapters describe. Main rows the
recordings = self._get_recordings_for_range(STREAM_TYPE_MAIN) # 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) recordings = self._get_recordings_for_range(STREAM_TYPE_SUB)
playlist_lines = [] playlist_lines = []
+16
View File
@@ -543,6 +543,22 @@ class TestPinnedStream(unittest.TestCase):
self.assertFalse(any("/vod/front/main/" in token for token in cmd)) 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): class TestStagedFileCleanup(unittest.TestCase):
"""A staged path must be tracked before ffmpeg can write to it.""" """A staged path must be tracked before ffmpeg can write to it."""
+7 -7
View File
@@ -213,17 +213,19 @@ def stream_has_audio(intervals: list[CoverageInterval], main: bool) -> bool:
) )
def null_audio_glitches( def null_audio_glitches(intervals: list[CoverageInterval]) -> list[CoverageInterval]:
intervals: list[CoverageInterval], main_audio: bool, sub_audio: bool
) -> list[CoverageInterval]:
"""Treat video-only glitch rows on audio-bearing streams as no recording. """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 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 count, so a truncated video-only segment (a backend restart can flush
a sub-second file before any audio packet landed) poisons every a sub-second file before any audio packet landed) poisons every
manifest that includes it. Nulling the row turns the glitch into a 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] = [] result: list[CoverageInterval] = []
for interval in intervals: for interval in intervals:
main = interval.main main = interval.main
@@ -449,9 +451,7 @@ def realized_timelines(
assembles each variant's realized spans. Keyframe snapping reads the assembles each variant's realized spans. Keyframe snapping reads the
per-row index stored at record time, so no file is touched. per-row index stored at record time, so no file is touched.
""" """
main_audio = stream_has_audio(intervals, main=True) nulled = null_audio_glitches(intervals)
sub_audio = stream_has_audio(intervals, main=False)
nulled = null_audio_glitches(intervals, main_audio, sub_audio)
return { return {
"auto": realized_timeline(nulled, None), "auto": realized_timeline(nulled, None),