From 8433e16558fcaf5537c1bb84fe9c4cf0bf6cda6f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Mar 2026 05:24:38 +0000 Subject: [PATCH 1/3] Fix preview frame tracking and partial file cleanup - write_frame_to_cache() now returns bool; callers only append the timestamp to output_frames when cv2.imwrite() actually succeeded, preventing dangling timestamps that cause ffmpeg "Impossible to open" errors when the cache disk is full - FFMpegConverter removes the partial output mp4 on ffmpeg failure so stale partial files don't accumulate on the recording disk https://claude.ai/code/session_016bxjbVpx8DqpjysnGYmXdx --- frigate/output/preview.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/frigate/output/preview.py b/frigate/output/preview.py index f49fc61c9..e2f2aa03a 100644 --- a/frigate/output/preview.py +++ b/frigate/output/preview.py @@ -201,6 +201,7 @@ class FFMpegConverter(threading.Thread): ) else: logger.error(f"Error saving preview for {self.config.name} :: {p.stderr}") + Path(self.path).unlink(missing_ok=True) # unlink files from cache # don't delete last frame as it will be used as first frame in next segment @@ -345,7 +346,7 @@ class PreviewRecorder: return False - def write_frame_to_cache(self, frame_time: float, frame: np.ndarray) -> None: + def write_frame_to_cache(self, frame_time: float, frame: np.ndarray) -> bool: # resize yuv frame small_frame = np.zeros((self.out_height * 3 // 2, self.out_width), np.uint8) copy_yuv_to_position( @@ -360,7 +361,7 @@ class PreviewRecorder: small_frame, cv2.COLOR_YUV2BGR_I420, ) - cv2.imwrite( + result = cv2.imwrite( get_cache_image_name(self.config.name, frame_time), small_frame, [ @@ -368,6 +369,11 @@ class PreviewRecorder: PREVIEW_QUALITY_WEBP[self.config.record.preview.quality], ], ) + if not result: + logger.warning( + f"Failed to write preview frame for {self.config.name} at {frame_time}, likely no space in cache" + ) + return result def write_data( self, @@ -381,8 +387,8 @@ class PreviewRecorder: # always write the first frame if self.start_time == 0: self.start_time = frame_time - self.output_frames.append(frame_time) - self.write_frame_to_cache(frame_time, frame) + if self.write_frame_to_cache(frame_time, frame): + self.output_frames.append(frame_time) return # check if PREVIEW clip should be generated and cached frames reset @@ -390,8 +396,8 @@ class PreviewRecorder: if len(self.output_frames) > 0: # save last frame to ensure consistent duration if self.config.record: - self.output_frames.append(frame_time) - self.write_frame_to_cache(frame_time, frame) + if self.write_frame_to_cache(frame_time, frame): + self.output_frames.append(frame_time) # write the preview if any frames exist for this hour FFMpegConverter( @@ -409,13 +415,13 @@ class PreviewRecorder: # include first frame to ensure consistent duration if self.config.record.enabled: - self.output_frames.append(frame_time) - self.write_frame_to_cache(frame_time, frame) + if self.write_frame_to_cache(frame_time, frame): + self.output_frames.append(frame_time) return elif self.should_write_frame(current_tracked_objects, motion_boxes, frame_time): - self.output_frames.append(frame_time) - self.write_frame_to_cache(frame_time, frame) + if self.write_frame_to_cache(frame_time, frame): + self.output_frames.append(frame_time) return def flag_offline(self, frame_time: float) -> None: From 6248dbf12f0ad84e464aafc0473942c0f417a265 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Mar 2026 08:51:42 +0000 Subject: [PATCH 2/3] Delete preview files when emergency storage cleanup removes recordings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When reduce_storage_consumption deletes old recording segments to free disk space, it now also deletes preview files that overlap the same time range. Without this, preview mp4 files on the same disk continued to consume space, causing the storage maintainer to delete progressively newer recordings while old previews accumulated — resulting in archives where older periods had previews but no video. This is particularly impactful for multi-path setups where each camera's preview directory shares a disk with its recordings. https://claude.ai/code/session_016bxjbVpx8DqpjysnGYmXdx --- frigate/storage.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/frigate/storage.py b/frigate/storage.py index 9f92ceb7b..9bcec9c4c 100644 --- a/frigate/storage.py +++ b/frigate/storage.py @@ -10,7 +10,7 @@ from peewee import SQL, fn from frigate.config import FrigateConfig from frigate.const import RECORD_DIR, REPLAY_CAMERA_PREFIX -from frigate.models import Event, Recordings +from frigate.models import Event, Previews, Recordings from frigate.util.builtin import clear_and_unlink logger = logging.getLogger(__name__) @@ -390,6 +390,32 @@ class StorageMaintainer(threading.Thread): f"Updated has_clip to False for {len(events_to_update)} events" ) + # Also delete preview files that overlap with deleted recordings so they + # don't continue to consume space on the same disk after the recordings + # are gone (especially important for multi-path setups where preview and + # recordings share the same disk). + if deleted_recordings: + deleted_previews = [] + for camera, time_range in camera_recordings.items(): + overlapping_previews = ( + Previews.select(Previews.id, Previews.path) + .where( + Previews.camera == camera, + Previews.start_time < time_range["max_end"], + Previews.end_time > time_range["min_start"], + ) + .namedtuples() + ) + for preview in overlapping_previews: + clear_and_unlink(Path(preview.path), missing_ok=True) + deleted_previews.append(preview.id) + + logger.debug(f"Expiring {len(deleted_previews)} previews") + for i in range(0, len(deleted_previews), max_deletes): + Previews.delete().where( + Previews.id << deleted_previews[i : i + max_deletes] + ).execute() + deleted_recordings_list = [r.id for r in deleted_recordings] for i in range(0, len(deleted_recordings_list), max_deletes): Recordings.delete().where( From 5ba780231672f824bd4fc87d57ce0e9be1aaf826 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Mar 2026 09:01:56 +0000 Subject: [PATCH 3/3] Serve preview files from non-default recording roots via nginx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When cameras are configured with recording paths outside /media/frigate (e.g. /video1, /video2), preview mp4 files generated there had no corresponding nginx location block — requests returned 404. At nginx startup, get_nginx_settings.py now extracts unique recording roots outside /media/frigate from the Frigate config. The nginx run script uses a new extra_recordings.gotmpl template to generate location blocks (e.g. /video1/preview/) with alias directives for each such root, included via extra_recordings.conf. The API already returns correct src URLs for these paths (the existing replace(BASE_DIR, "") leaves non-media paths unchanged), so no API changes are needed. https://claude.ai/code/session_016bxjbVpx8DqpjysnGYmXdx --- docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run | 5 +++++ docker/main/rootfs/usr/local/nginx/conf/nginx.conf | 1 + .../rootfs/usr/local/nginx/get_nginx_settings.py | 11 +++++++++++ .../local/nginx/templates/extra_recordings.gotmpl | 12 ++++++++++++ 4 files changed, 29 insertions(+) create mode 100644 docker/main/rootfs/usr/local/nginx/templates/extra_recordings.gotmpl diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run index a3c7b3248..a92ac0d73 100755 --- a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run +++ b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run @@ -89,6 +89,11 @@ python3 /usr/local/nginx/get_nginx_settings.py | \ tempio -template /usr/local/nginx/templates/listen.gotmpl \ -out /usr/local/nginx/conf/listen.conf +# build location blocks for recording roots outside /media/frigate +python3 /usr/local/nginx/get_nginx_settings.py | \ + tempio -template /usr/local/nginx/templates/extra_recordings.gotmpl \ + -out /usr/local/nginx/conf/extra_recordings.conf + # Replace the bash process with the NGINX process, redirecting stderr to stdout exec 2>&1 exec \ diff --git a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf index 46241c5ab..e96f0e26e 100644 --- a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf +++ b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf @@ -101,6 +101,7 @@ http { include auth_location.conf; include base_path.conf; + include extra_recordings.conf; location /vod/ { include auth_request.conf; diff --git a/docker/main/rootfs/usr/local/nginx/get_nginx_settings.py b/docker/main/rootfs/usr/local/nginx/get_nginx_settings.py index 79cda3686..e2e0e4746 100644 --- a/docker/main/rootfs/usr/local/nginx/get_nginx_settings.py +++ b/docker/main/rootfs/usr/local/nginx/get_nginx_settings.py @@ -52,11 +52,22 @@ listen_config["external_port"] = external_port base_path = os.environ.get("FRIGATE_BASE_PATH", "") +# Collect recording roots that are outside the default /media/frigate tree. +# Nginx needs an explicit location block for each such root to serve preview files. +_default_recordings = "/media/frigate" +_extra_roots: set[str] = set() +for _cam_cfg in config.get("cameras", {}).values(): + if isinstance(_cam_cfg, dict): + _path = _cam_cfg.get("path", "") + if _path and not _path.startswith(_default_recordings): + _extra_roots.add(_path.rstrip("/")) + result: dict[str, Any] = { "tls": tls_config, "ipv6": ipv6_config, "listen": listen_config, "base_path": base_path, + "extra_recording_roots": sorted(_extra_roots), } print(json.dumps(result)) diff --git a/docker/main/rootfs/usr/local/nginx/templates/extra_recordings.gotmpl b/docker/main/rootfs/usr/local/nginx/templates/extra_recordings.gotmpl new file mode 100644 index 000000000..7abe93126 --- /dev/null +++ b/docker/main/rootfs/usr/local/nginx/templates/extra_recordings.gotmpl @@ -0,0 +1,12 @@ +{{ range .extra_recording_roots }} +location {{ . }}/preview/ { + include auth_request.conf; + types { + video/mp4 mp4; + } + + expires 7d; + add_header Cache-Control "public"; + alias {{ . }}/preview/; +} +{{ end }}