mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-18 20:52:48 +03:00
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:
@@ -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*)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user