mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-03 17:43:59 +03:00
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:
@@ -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()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user