Compare commits

...
Author SHA1 Message Date
Josh HawkinsandGitHub cb745d0a50 Miscellaneous fixes (0.18) (#24567)
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
* run recording snapshot ffmpeg off the event loop

The recording snapshot and Frigate+ recording submit endpoints called get_image_from_recording inline, which is a blocking ffmpeg subprocess with no timeout, so every other request stalled until the frame was decoded. Both calls now run in asyncio.to_thread.

* fix camera info dialog overflowing on mobile

* adjust test
2026-10-05 11:26:08 -06:00
Josh HawkinsandGitHub e912d0852b Fix deleting the only camera when the cameras key has a trailing comment (#24542)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
2026-10-02 06:58:57 -06:00
5 changed files with 132 additions and 7 deletions
+6 -1
View File
@@ -35,7 +35,11 @@ from frigate.config.camera.updater import (
)
from frigate.config.env import substitute_frigate_vars
from frigate.models import User
from frigate.util.builtin import clean_camera_user_pass, get_record_segment_time
from frigate.util.builtin import (
clean_camera_user_pass,
clear_orphaned_comments,
get_record_segment_time,
)
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
from frigate.util.config import find_config_file
from frigate.util.image import run_ffmpeg_snapshot
@@ -1201,6 +1205,7 @@ async def delete_camera(
# Remove camera from config
if "cameras" in data and camera_name in data["cameras"]:
del data["cameras"][camera_name]
clear_orphaned_comments(data["cameras"], data, "cameras")
# Remove camera from auth roles
auth = data.get("auth", {})
+13 -4
View File
@@ -351,8 +351,13 @@ async def get_snapshot_from_recording(
mime_type = "png" if format == "png" else "jpeg"
config: FrigateConfig = request.app.frigate_config
image_data = get_image_from_recording(
config.ffmpeg, recording.path, time_in_segment, codec, height
image_data = await asyncio.to_thread(
get_image_from_recording,
config.ffmpeg,
recording.path,
time_in_segment,
codec,
height,
)
if not image_data:
@@ -406,8 +411,12 @@ async def submit_recording_snapshot_to_plus(
config: FrigateConfig = request.app.frigate_config
recording: Recordings = recording_query.get()
time_in_segment = frame_time - recording.start_time
image_data = get_image_from_recording(
config.ffmpeg, recording.path, time_in_segment, "png"
image_data = await asyncio.to_thread(
get_image_from_recording,
config.ffmpeg,
recording.path,
time_in_segment,
"png",
)
if not image_data:
+90
View File
@@ -1,6 +1,9 @@
"""Unit tests for recordings/media API endpoints."""
import threading
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, datetime
from unittest.mock import patch
import pytz
from fastapi import Request
@@ -527,3 +530,90 @@ class TestHttpMedia(BaseTestHttp):
assert response.status_code == 200
assert response.json() == [{"start_time": 1010, "end_time": 1030}]
def _insert_recording(self, id: str, start_time: float, end_time: float) -> None:
Recordings.insert(
id=id,
path=f"/media/recordings/{id}.mp4",
camera="front_door",
start_time=start_time,
end_time=end_time,
duration=end_time - start_time,
motion=0,
).execute()
def test_recording_snapshot_does_not_block_event_loop(self):
self._insert_recording("snapshot", 1000, 1010)
started = threading.Event()
release = threading.Event()
finished = threading.Event()
def slow_image(*args):
started.set()
release.wait(timeout=5)
finished.set()
return b"jpeg"
with (
AuthTestClient(self.app) as client,
patch("frigate.api.media.get_image_from_recording", slow_image),
ThreadPoolExecutor(max_workers=1) as executor,
):
snapshot = executor.submit(
client.get, "/front_door/recordings/1005/snapshot.jpg"
)
self.assertTrue(started.wait(timeout=2))
version = client.get("/version")
blocked = finished.is_set()
release.set()
self.assertEqual(version.status_code, 200)
self.assertFalse(blocked)
self.assertEqual(snapshot.result().status_code, 200)
def test_recording_snapshot_hit_returns_image(self):
self._insert_recording("snapshot", 1000, 1010)
with (
AuthTestClient(self.app) as client,
patch("frigate.api.media.get_image_from_recording", return_value=b"jpeg"),
):
response = client.get("/front_door/recordings/1005/snapshot.jpg")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], "image/jpeg")
self.assertEqual(response.content, b"jpeg")
def test_recording_snapshot_misses_return_404(self):
self._insert_recording("snapshot", 1000, 1010)
with AuthTestClient(self.app) as client:
absent = client.get("/front_door/recordings/2000/snapshot.jpg")
rounded_miss = client.get("/front_door/recordings/1010.5/snapshot.jpg")
self.assertEqual(absent.status_code, 404)
self.assertEqual(rounded_miss.status_code, 404)
def test_plus_snapshot_miss_returns_404_without_image_lookup(self):
with (
AuthTestClient(self.app) as client,
patch("frigate.api.media.get_image_from_recording") as image,
):
response = client.post("/front_door/plus/1005")
self.assertEqual(response.status_code, 404)
image.assert_not_called()
def test_plus_snapshot_hit_uploads_image(self):
self._insert_recording("snapshot", 1000, 1010)
with (
AuthTestClient(self.app) as client,
patch("frigate.api.media.get_image_from_recording", return_value=b"png"),
patch("frigate.api.media.cv2.imdecode", return_value="frame"),
patch.object(self.app.frigate_config.plus_api, "upload_image") as upload,
):
response = client.post("/front_door/plus/1005")
self.assertEqual(response.status_code, 200)
upload.assert_called_once_with("frame", "front_door")
+22 -1
View File
@@ -6,7 +6,7 @@ import unittest
from ruamel.yaml import YAML
from frigate.util.builtin import update_yaml_file_bulk
from frigate.util.builtin import clear_orphaned_comments, update_yaml_file_bulk
class TestUpdateYaml(unittest.TestCase):
@@ -178,6 +178,27 @@ class TestUpdateYaml(unittest.TestCase):
assert data["cameras"]["cam1"]["detect"]["fps"] == 5
assert "# tuned for the pi" in self._read()
def test_delete_only_camera_with_comment_on_cameras_key(self):
"""Deleting the only camera stays parseable when the cameras key
carries the default config's trailing comment."""
self._write(
"cameras: # No cameras defined, UI wizard should be used\n"
" cam1:\n"
" enabled: true\n"
"version: 0.18-0\n"
)
yaml = YAML()
with open(self.config_path) as f:
data = yaml.load(f)
del data["cameras"]["cam1"]
clear_orphaned_comments(data["cameras"], data, "cameras")
with open(self.config_path, "w") as f:
yaml.dump(data, f)
data = self._load()
assert data["cameras"] == {}
assert data["version"] == "0.18-0"
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -91,7 +91,7 @@ export default function CameraInfoDialog({
open={showCameraInfoDialog}
onOpenChange={setShowCameraInfoDialog}
>
<DialogContent>
<DialogContent className="scrollbar-container max-h-[90dvh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="smart-capitalize">
{t("cameras.info.cameraProbeInfo", {