Miscellaneous fixes (#24704)

* remove unreachable FileNotFoundError catches around frame_manager.get

* clear the end time cache entry when a segment is moved

The successful move returned before the end_time_cache pop at the bottom of move_segment, so every kept segment left its entry, keyframes list included, in the cache for the life of the process. Removing the cache file through drop_segment clears the entry with it.

* drop the cache file when the recording destination already exists

When the destination mp4 already existed, move_segment returned without removing the cache file, so the next pass probed and resolved the same segment again, forever. The leftover file is now dropped along with its end time cache entry, the same as the exception path.

* pace restarts of exited record and audio ffmpeg processes

The watchdog loop runs every second, and an exited record or audio ffmpeg was restarted on every pass with no backoff. A camera that refuses connections, such as one rebooting or rejecting credentials, got a reconnect attempt and a dump of ffmpeg's logs every second until it came back. The restart is now gated on can_restart like the stale segment and detect paths.

* don't send a stray newline in the certsync tls probe

The probe piped echo into openssl s_client, which sent the newline to nginx as a request after the handshake, so nginx logged an empty "" 400 line every 60 seconds on any install with TLS enabled. Reading stdin from /dev/null closes the connection right after the handshake. The fingerprint and the failure logging are unchanged.

* require a record expire_interval of at least 1

An expire_interval of 0 or less made the cleanup loop iterate over an empty range, so the cleanup thread exited on startup and expired recordings, temp clips and previews were never removed.

* remove the transcription temp wav when whisper raises

The temp wav was only removed after a successful transcribe call, so every failed transcription left a file in the cache directory.

* don't kill the ipc responder when a described event is gone

handle_update_event_description used Event.get, so a GenAI description that finished after its event was deleted raised DoesNotExist on the inter-process REP thread. That thread died with a reply still owed, and every process waiting on the requestor hung until restart. A missing event is now skipped.

* size the birdseye frame from the snapped canvas shape

The birdseye writer sizes its shared memory with get_canvas_shape, which snaps an unsupported aspect ratio such as 1920x1200 to a supported one, but the tracked object processor read it with the raw config width and height. The sizes never matched, so get returned None on every call and /api/birdseye/latest.jpg returned a 500, which broke the Home Assistant birdseye entity's still image. The reader now uses the same canvas shape, and a missing frame during startup returns an error response instead of raising in cvtColor.

* remove dead disconnect timer from the mse player

The effect that scheduled a delayed disconnect when playback was disabled never ran its body. The connect effect's cleanup already calls onDisconnect, which clears wsRef before the later effect checks it.

* remove partial output when a recording move fails

A remux that failed partway, such as on a full disk, left a partial mp4 at the destination. The next pass saw the destination already existed and dropped the cache file instead of retrying, so the segment was never saved. The failed output is now removed so the next pass retries the move. Tests cover the success, existing destination and failed move paths of move_segment.
This commit is contained in:
Josh Hawkins
2026-10-18 04:50:25 -10:00
committed by GitHub
parent 1a8644fed5
commit 275e18403e
13 changed files with 142 additions and 60 deletions
@@ -61,7 +61,7 @@ do
;;
esac
liveprint=`echo | openssl s_client -showcerts -connect 127.0.0.1:$listen_external_port 2>&1 | openssl x509 -fingerprint 2>&1 | grep -i fingerprint || echo 'failed'`
liveprint=`openssl s_client -showcerts -connect 127.0.0.1:$listen_external_port </dev/null 2>&1 | openssl x509 -fingerprint 2>&1 | grep -i fingerprint || echo 'failed'`
case "$liveprint" in
*Fingerprint*)
+9 -4
View File
@@ -297,10 +297,15 @@ async def latest_frame(
and request.app.frigate_config.birdseye.enabled
and request.app.frigate_config.birdseye.restream
):
frame = cv2.cvtColor(
frame_processor.get_current_frame(camera_name),
cv2.COLOR_YUV2BGR_I420,
)
frame = frame_processor.get_current_frame(camera_name)
if frame is None:
return JSONResponse(
content={"success": False, "message": "Unable to get valid frame"},
status_code=500,
)
frame = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420)
height = int(params.height or str(frame.shape[0]))
width = int(height * frame.shape[1] / frame.shape[0])
+5 -1
View File
@@ -322,7 +322,11 @@ class Dispatcher:
self.audio_activity.expire_all(payload)
def handle_update_event_description() -> None:
event: Event = Event.get(Event.id == payload["id"])
event: Event | None = Event.get_or_none(Event.id == payload["id"])
if event is None:
return
cast(dict, event.data)["description"] = payload["description"]
event.save()
self.publish(
+1
View File
@@ -147,6 +147,7 @@ class RecordConfig(FrigateBaseModel):
)
expire_interval: int = Field(
default=60,
ge=1,
title="Record cleanup interval",
description="Minutes between cleanup passes that remove expired recording segments.",
)
@@ -4,6 +4,7 @@ import logging
import os
import threading
import time
from pathlib import Path
from typing import Any
from peewee import DoesNotExist
@@ -193,9 +194,10 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
logger.debug("Recognizer not initialized")
return None
try: # type: ignore[unreachable]
# Save audio data to a temporary wav (faster-whisper expects a file)
temp_wav = os.path.join(CACHE_DIR, f"temp_audio_{int(time.time())}.wav")
# Save audio data to a temporary wav (faster-whisper expects a file)
temp_wav = os.path.join(CACHE_DIR, f"temp_audio_{int(time.time())}.wav") # type: ignore[unreachable]
try:
with open(temp_wav, "wb") as f:
f.write(audio_data)
@@ -205,8 +207,6 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
beam_size=5,
)
os.remove(temp_wav)
# Combine all segment texts
text = " ".join(segment.text.strip() for segment in segments)
if not text:
@@ -222,6 +222,8 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
except Exception as e:
logger.error(f"Error transcribing audio: {e}")
return None
finally:
Path(temp_wav).unlink(missing_ok=True)
def _transcription_wrapper(self, event: dict[str, Any]) -> None:
"""Wrapper to run transcription and reset running flag when done."""
+6 -13
View File
@@ -547,13 +547,9 @@ class EmbeddingMaintainer(threading.Thread):
return
# Create our own thumbnail based on the bounding box and the frame time
try:
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv, frame_time
)
except FileNotFoundError:
logger.debug(f"Frame {frame_name} not found for camera {camera}")
pass
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv, frame_time
)
if yuv_frame is None:
logger.debug(
@@ -816,12 +812,9 @@ class EmbeddingMaintainer(threading.Thread):
# no active features that use this data
return
try:
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv, frame_time
)
except FileNotFoundError:
pass
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv, frame_time
)
if yuv_frame is None:
logger.debug(
+3 -4
View File
@@ -1000,6 +1000,7 @@ class RecordingMaintainer(threading.Thread):
logger.error(f"Unable to convert {cache_path} to {file_path}")
if p.stderr:
logger.error((await p.stderr.read()).decode("ascii"))
Path(file_path).unlink(missing_ok=True)
return None
else:
logger.debug(
@@ -1017,7 +1018,7 @@ class RecordingMaintainer(threading.Thread):
except OSError:
segment_size = 0
os.remove(cache_path)
self.drop_segment(cache_path)
rand_id = "".join(
random.choices(string.ascii_lowercase + string.digits, k=6)
@@ -1046,10 +1047,8 @@ class RecordingMaintainer(threading.Thread):
}
except Exception:
logger.exception(f"Unable to store recording segment {cache_path}")
Path(cache_path).unlink(missing_ok=True)
# clear end_time cache
self.end_time_cache.pop(cache_path, None)
self.drop_segment(cache_path)
return None
def run(self) -> None:
+3 -6
View File
@@ -543,12 +543,9 @@ class ReviewSegmentMaintainer(threading.Thread):
if len(objects) <= pending.frame_active_count:
return
try:
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv, frame_time
)
except FileNotFoundError:
return
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv, frame_time
)
if yuv_frame is None:
logger.debug(f"Failed to get frame {frame_name} from SHM")
+85 -1
View File
@@ -1,6 +1,9 @@
import datetime
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
# Mock complex imports before importing maintainer, saving originals so we can
@@ -17,7 +20,7 @@ for name in _MOCKED_MODULES:
# Now import the class under test
from frigate.config import FrigateConfig, RetainModeEnum # noqa: E402
from frigate.record.maintainer import RecordingMaintainer # noqa: E402
from frigate.record.maintainer import RecordingMaintainer, SegmentInfo # noqa: E402
# Restore original modules (or remove mock if there was no original)
for name, orig in _originals.items():
@@ -240,5 +243,86 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase):
)
class TestMoveSegment(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.record_dir = os.path.join(self.tmp.name, "recordings")
self.cache_path = os.path.join(
self.tmp.name, "test_cam@20260417150000+0000.mp4"
)
self.dest_path = os.path.join(
self.record_dir, "2026-04-17", "15", "test_cam", "00.00.mp4"
)
Path(self.cache_path).write_bytes(b"segment")
config = MagicMock(spec=FrigateConfig)
config.cameras = {}
config.ffmpeg = MagicMock(ffmpeg_path="ffmpeg")
self.maintainer = RecordingMaintainer(config, MagicMock())
self.maintainer.end_time_cache = {self.cache_path: MagicMock()}
def tearDown(self):
self.tmp.cleanup()
async def _move(self, returncode: int = 0) -> tuple[dict | None, AsyncMock]:
def fake_exec(*args, **kwargs):
# ffmpeg leaves its output behind even when it fails partway
Path(args[-1]).write_bytes(b"remuxed")
process = MagicMock()
process.wait = AsyncMock()
process.returncode = returncode
process.stderr = None
return process
start = datetime.datetime(2026, 4, 17, 15, 0, 0, tzinfo=datetime.UTC)
exec_mock = AsyncMock(side_effect=fake_exec)
with (
patch("frigate.record.maintainer.RECORD_DIR", self.record_dir),
patch("frigate.record.maintainer.chown_to_runtime"),
patch(
"frigate.record.maintainer.asyncio.create_subprocess_exec", exec_mock
),
):
result = await self.maintainer.move_segment(
"test_cam",
"main",
start,
start + datetime.timedelta(seconds=10),
10.0,
self.cache_path,
SegmentInfo(0, 0, 0, 0),
)
return result, exec_mock
async def test_successful_move_clears_cache_entry(self):
result, _ = await self._move()
self.assertIsNotNone(result)
self.assertTrue(os.path.exists(self.dest_path))
self.assertFalse(os.path.exists(self.cache_path))
self.assertNotIn(self.cache_path, self.maintainer.end_time_cache)
async def test_existing_destination_drops_cache_file(self):
os.makedirs(os.path.dirname(self.dest_path))
Path(self.dest_path).write_bytes(b"existing")
result, exec_mock = await self._move()
self.assertIsNone(result)
exec_mock.assert_not_awaited()
self.assertFalse(os.path.exists(self.cache_path))
self.assertNotIn(self.cache_path, self.maintainer.end_time_cache)
async def test_failed_move_removes_partial_output_for_retry(self):
result, _ = await self._move(returncode=1)
self.assertIsNone(result)
self.assertFalse(os.path.exists(self.dest_path))
self.assertTrue(os.path.exists(self.cache_path))
self.assertIn(self.cache_path, self.maintainer.end_time_cache)
if __name__ == "__main__":
unittest.main()
+13 -4
View File
@@ -41,6 +41,7 @@ from frigate.const import (
)
from frigate.events.types import EventStateEnum, EventTypeEnum
from frigate.models import Event, ReviewSegment, Timeline
from frigate.output.birdseye import get_canvas_shape
from frigate.ptz.autotrack import PtzAutoTracker
from frigate.track.tracked_object import TrackedObject
from frigate.util.image import SharedMemoryFrameManager
@@ -71,6 +72,14 @@ class TrackedObjectProcessor(threading.Thread):
self.camera_states: dict[str, CameraState] = {}
self.camera_states_lock = threading.Lock()
self.frame_manager = SharedMemoryFrameManager()
self.birdseye_yuv_shape: tuple[int, int] | None = None
if config.birdseye.enabled and config.birdseye.restream:
birdseye_width, birdseye_height = get_canvas_shape(
config.birdseye.width, config.birdseye.height
)
self.birdseye_yuv_shape = (birdseye_height * 3 // 2, birdseye_width)
self.last_motion_detected: dict[str, float] = {}
self.ptz_autotracker_thread = ptz_autotracker_thread
@@ -387,10 +396,10 @@ class TrackedObjectProcessor(threading.Thread):
self, camera: str, draw_options: dict[str, Any] = {}
) -> np.ndarray | None:
if camera == "birdseye":
return self.frame_manager.get(
"birdseye",
(self.config.birdseye.height * 3 // 2, self.config.birdseye.width),
)
if self.birdseye_yuv_shape is None:
return None
return self.frame_manager.get("birdseye", self.birdseye_yuv_shape)
camera_state = self.get_camera_state(camera)
+8 -3
View File
@@ -559,9 +559,14 @@ class CameraWatchdog(threading.Thread):
self._send_roles_offline(p["roles"], now)
p["process"] = start_or_restart_ffmpeg(
p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"]
)
if can_restart:
p["process"] = start_or_restart_ffmpeg(
p["cmd"],
self.logger,
p["logpipe"],
ffmpeg_process=p["process"],
)
last_restart_time = now
if self.detect_process_records_sub and self.config.record.stream_enabled(
STREAM_TYPE_SUB
File diff suppressed because one or more lines are too long
-17
View File
@@ -753,23 +753,6 @@ function MSEPlayer({
videoRef.current.volume = volume;
}, [volume, videoRef]);
// ensure we disconnect for slower connections
useEffect(() => {
if (wsRef.current?.readyState === WebSocket.OPEN && !playbackEnabled) {
if (bufferTimeout) {
clearTimeout(bufferTimeout);
setBufferTimeout(undefined);
}
setTimeout(() => {
if (!playbackEnabled) onDisconnect();
}, 10000);
}
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playbackEnabled]);
// stats and health samples
const onHealthSampleRef = useRef(onHealthSample);