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 }} 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: 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(