Improve recording timeline and VOD query performance (#23862)

* Improve recording timeline and VOD query performance

* Add recording query boundary tests
This commit is contained in:
DoFabien
2026-07-29 16:03:39 -06:00
committed by GitHub
parent 6cd33fcde1
commit 0f636a17f3
3 changed files with 180 additions and 5 deletions
+4 -4
View File
@@ -574,11 +574,11 @@ async def vod_ts(
Recordings.start_time, Recordings.start_time,
) )
.where( .where(
Recordings.start_time.between(start_ts, end_ts) Recordings.camera == camera_name,
| Recordings.end_time.between(start_ts, end_ts) Recordings.start_time >= start_ts - MAX_SEGMENT_DURATION,
| ((start_ts > Recordings.start_time) & (end_ts < Recordings.end_time)) Recordings.start_time <= end_ts,
Recordings.end_time >= start_ts,
) )
.where(Recordings.camera == camera_name)
.order_by(Recordings.start_time.asc()) .order_by(Recordings.start_time.asc())
.iterator() .iterator()
) )
+2 -1
View File
@@ -25,7 +25,7 @@ from frigate.api.defs.query.recordings_query_parameters import (
) )
from frigate.api.defs.response.generic_response import GenericResponse from frigate.api.defs.response.generic_response import GenericResponse
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
from frigate.const import RECORD_DIR from frigate.const import MAX_SEGMENT_DURATION, RECORD_DIR
from frigate.models import Event, Recordings from frigate.models import Event, Recordings
from frigate.util.time import get_dst_transitions from frigate.util.time import get_dst_transitions
@@ -243,6 +243,7 @@ async def recordings(
) )
.where( .where(
Recordings.camera == camera_name, Recordings.camera == camera_name,
Recordings.start_time >= after - MAX_SEGMENT_DURATION,
Recordings.end_time >= after, Recordings.end_time >= after,
Recordings.start_time <= before, Recordings.start_time <= before,
) )
+174
View File
@@ -1,15 +1,73 @@
"""Unit tests for recordings/media API endpoints.""" """Unit tests for recordings/media API endpoints."""
from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from unittest.mock import patch
import pytz import pytz
from fastapi import Request from fastapi import Request
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
from frigate.const import MAX_SEGMENT_DURATION
from frigate.models import Recordings from frigate.models import Recordings
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
@dataclass(frozen=True)
class RangeCase:
"""Expected behavior for one segment relative to the requested range.
Offsets are seconds from REQUEST_START; the request ends at +100 seconds.
"""
name: str
start_offset: float
end_offset: float
included_in_recordings: bool
vod_clip_from_ms: int | None = None
vod_duration_ms: int | None = None
REQUEST_START = 1000
REQUEST_END = 1100
RANGE_CASES = (
RangeCase("before", -MAX_SEGMENT_DURATION + 1, -1, False),
RangeCase("meets_start", -10, 0, True),
RangeCase(
"overlaps_start",
-MAX_SEGMENT_DURATION + 0.5,
0.25,
True,
vod_clip_from_ms=599500,
vod_duration_ms=250,
),
RangeCase("starts_at_start", 0, 10, True, vod_duration_ms=10000),
RangeCase("inside", 20, 80, True, vod_duration_ms=60000),
RangeCase("ends_at_end", 90, 100, True, vod_duration_ms=10000),
RangeCase("matches_range", 0, 100, True, vod_duration_ms=100000),
RangeCase("starts_with_range", 0, 110, True, vod_duration_ms=100000),
RangeCase(
"covers_range",
-20,
120,
True,
vod_clip_from_ms=20000,
vod_duration_ms=100000,
),
RangeCase(
"ends_with_range",
-10,
100,
True,
vod_clip_from_ms=10000,
vod_duration_ms=100000,
),
RangeCase("overlaps_end", 95, 105, True, vod_duration_ms=5000),
RangeCase("starts_at_end", 100, 110, True),
RangeCase("after", 101, 110, False),
)
class TestHttpMedia(BaseTestHttp): class TestHttpMedia(BaseTestHttp):
"""Test media API endpoints, particularly recordings with DST handling.""" """Test media API endpoints, particularly recordings with DST handling."""
@@ -44,6 +102,26 @@ class TestHttpMedia(BaseTestHttp):
self.app.dependency_overrides.clear() self.app.dependency_overrides.clear()
super().tearDown() super().tearDown()
def _assert_vod_response(
self,
response,
expected_clips: list[tuple[str, int | None, int]],
) -> None:
"""Assert VOD clip metadata and its derived duration fields."""
assert response.status_code == 200
vod = response.json()
assert [
(
clip["path"],
clip.get("clipFrom"),
clip["keyFrameDurations"][0],
)
for clip in vod["sequences"][0]["clips"]
] == expected_clips
expected_durations = [clip[2] for clip in expected_clips]
assert vod["durations"] == expected_durations
assert vod["segment_duration"] == max(expected_durations)
def test_recordings_summary_across_dst_spring_forward(self): def test_recordings_summary_across_dst_spring_forward(self):
""" """
Test recordings summary across spring DST transition (spring forward). Test recordings summary across spring DST transition (spring forward).
@@ -404,6 +482,102 @@ class TestHttpMedia(BaseTestHttp):
assert "2024-03-10" in summary assert "2024-03-10" in summary
assert summary["2024-03-10"] is True assert summary["2024-03-10"] is True
def test_recordings_handles_all_range_relations(self):
"""Recordings return every interval relation that touches the range."""
with AuthTestClient(self.app) as client:
for case in RANGE_CASES:
with self.subTest(case=case.name):
Recordings.delete().execute()
super().insert_mock_recording(
case.name,
REQUEST_START + case.start_offset,
REQUEST_START + case.end_offset,
)
response = client.get(
"/front_door/recordings",
params={"after": REQUEST_START, "before": REQUEST_END},
)
assert response.status_code == 200
expected_ids = [case.name] if case.included_in_recordings else []
assert [
recording["id"] for recording in response.json()
] == expected_ids
def test_vod_handles_all_range_relations(self):
"""VOD clips every interval relation with positive playback duration."""
with (
AuthTestClient(self.app) as client,
patch(
"frigate.api.media.get_keyframe_before",
side_effect=lambda _path, offset: offset,
),
):
for case in RANGE_CASES:
with self.subTest(case=case.name):
Recordings.delete().execute()
super().insert_mock_recording(
case.name,
REQUEST_START + case.start_offset,
REQUEST_START + case.end_offset,
)
response = client.get(
f"/vod/front_door/start/{REQUEST_START}/end/{REQUEST_END}"
)
if case.vod_duration_ms is None:
assert response.status_code == 404
continue
self._assert_vod_response(
response,
[
(
case.name,
case.vod_clip_from_ms,
case.vod_duration_ms,
)
],
)
def test_vod_handles_segment_ending_at_start_with_keyframe_fallbacks(self):
"""VOD keeps a boundary segment when keyframe lookup extends it."""
def keyframe_before(path: str, offset: int) -> int | None:
return offset - 1000 if path == "previous_keyframe" else None
with (
AuthTestClient(self.app) as client,
patch(
"frigate.api.media.get_keyframe_before",
side_effect=keyframe_before,
),
):
super().insert_mock_recording(
"previous_keyframe",
REQUEST_START - 10,
REQUEST_START,
)
super().insert_mock_recording(
"missing_keyframe",
REQUEST_START - 5,
REQUEST_START,
)
response = client.get(
f"/vod/front_door/start/{REQUEST_START}/end/{REQUEST_END}"
)
self._assert_vod_response(
response,
[
("previous_keyframe", 9000, 1000),
("missing_keyframe", None, 5000),
],
)
def test_recordings_unavailable_reports_gap_between_recordings(self): def test_recordings_unavailable_reports_gap_between_recordings(self):
"""A gap between two recordings is reported as an unavailable segment.""" """A gap between two recordings is reported as an unavailable segment."""
with AuthTestClient(self.app) as client: with AuthTestClient(self.app) as client: