fix frozen time bounds on the recordings API (#24121)

`after` and `before` defaulted to `datetime.now()` in the function signature, so they were evaluated once at import. Requests that omitted them got a window ending at process start. They now resolve in the handler.
This commit is contained in:
Josh Hawkins
2026-08-31 11:56:05 -06:00
committed by GitHub
parent 9876052cb4
commit 036786cb30
4 changed files with 38 additions and 10 deletions
+6 -2
View File
@@ -7197,13 +7197,17 @@ paths:
in: query in: query
required: false required: false
schema: schema:
type: number anyOf:
- type: number
- type: 'null'
title: After title: After
- name: before - name: before
in: query in: query
required: false required: false
schema: schema:
type: number anyOf:
- type: number
- type: 'null'
title: Before title: Before
responses: responses:
'200': '200':
+5 -2
View File
@@ -358,10 +358,13 @@ async def recordings_coverage(
@router.get("/{camera_name}/recordings", dependencies=[Depends(require_camera_access)]) @router.get("/{camera_name}/recordings", dependencies=[Depends(require_camera_access)])
async def recordings( async def recordings(
camera_name: str, camera_name: str,
after: float = (datetime.now() - timedelta(hours=1)).timestamp(), after: float | None = None,
before: float = datetime.now().timestamp(), before: float | None = None,
): ):
"""Return specific camera recordings between the given 'after'/'end' times. If not provided the last hour will be used""" """Return specific camera recordings between the given 'after'/'end' times. If not provided the last hour will be used"""
now = datetime.now()
after = after if after is not None else (now - timedelta(hours=1)).timestamp()
before = before if before is not None else now.timestamp()
recordings = ( recordings = (
Recordings.select( Recordings.select(
Recordings.id, Recordings.id,
+23 -1
View File
@@ -1,7 +1,8 @@
"""Unit tests for recordings/media API endpoints.""" """Unit tests for recordings/media API endpoints."""
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime, timedelta
from unittest.mock import patch
import pytz import pytz
from fastapi import Request from fastapi import Request
@@ -1838,6 +1839,27 @@ class TestHttpMedia(BaseTestHttp):
recording["id"] for recording in response.json() recording["id"] for recording in response.json()
] == expected_ids ] == expected_ids
def test_recordings_default_range_follows_the_request_clock(self):
"""Omitted bounds resolve at request time, not at module import time."""
uptime = timedelta(hours=2)
recorded_at = (datetime.now() + uptime).timestamp()
class UptimeDatetime(datetime):
"""Stands in for a process that booted `uptime` ago."""
@classmethod
def now(cls, tz=None):
return datetime.now(tz) + uptime
with AuthTestClient(self.app) as client:
self._insert_recording("after_boot", recorded_at - 20, recorded_at - 10)
with patch("frigate.api.record.datetime", UptimeDatetime):
response = client.get("/front_door/recordings")
assert response.status_code == 200
assert [recording["id"] for recording in response.json()] == ["after_boot"]
def test_vod_handles_all_range_relations(self): def test_vod_handles_all_range_relations(self):
"""VOD clips every interval relation with positive playback duration.""" """VOD clips every interval relation with positive playback duration."""
with AuthTestClient(self.app) as client: with AuthTestClient(self.app) as client:
+4 -5
View File
@@ -410,11 +410,10 @@ def enrich(spec: dict, access_map: dict) -> tuple[dict, list, list]:
# Numeric defaults at or above this magnitude are treated as live Unix # Numeric defaults at or above this magnitude are treated as live Unix
# timestamps baked into the schema at import time (e.g. the /{camera_name} # timestamps baked into the schema at import time. They make the export
# /recordings after/before params default to datetime.now()). They make the # non-deterministic and document a meaningless frozen epoch, so they are
# export non-deterministic and document a meaningless frozen epoch, so they are # stripped. No route defaults this way today, and route params that need "now"
# stripped. The proper fix is to default those route params to None and resolve # resolve it inside the handler. Kept as a guard against the pattern returning.
# "now" inside the handler.
VOLATILE_DEFAULT_THRESHOLD = 1_000_000_000 VOLATILE_DEFAULT_THRESHOLD = 1_000_000_000