diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f053abe3f..0af9c249f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -324,6 +324,12 @@ try: value = await sensor.read() except Exception: # ❌ Too broad logger.error("Failed") + +# Returning exceptions in JSON responses +except ValueError as e: + return JSONResponse( + content={"success": False, "message": str(e)}, + ) ``` ### ✅ Use These Instead @@ -353,6 +359,16 @@ try: value = await sensor.read() except SensorException as err: # ✅ Specific logger.exception("Failed to read sensor") + +# Safe error responses +except ValueError: + logger.exception("Invalid parameters for API request") + return JSONResponse( + content={ + "success": False, + "message": "Invalid request parameters", + }, + ) ``` ## Project-Specific Conventions diff --git a/docs/docs/configuration/genai/objects.md b/docs/docs/configuration/genai/objects.md index c878f5ec8..3ed826d21 100644 --- a/docs/docs/configuration/genai/objects.md +++ b/docs/docs/configuration/genai/objects.md @@ -75,4 +75,4 @@ Many providers also have a public facing chat interface for their models. Downlo - OpenAI - [ChatGPT](https://chatgpt.com) - Gemini - [Google AI Studio](https://aistudio.google.com) -- Ollama - [Open WebUI](https://docs.openwebui.com/) \ No newline at end of file +- Ollama - [Open WebUI](https://docs.openwebui.com/) diff --git a/frigate/api/app.py b/frigate/api/app.py index 04d1c2238..a28f174de 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -49,12 +49,13 @@ from frigate.stats.prometheus import get_metrics, update_metrics from frigate.types import JobStatusTypesEnum from frigate.util.builtin import ( clean_camera_user_pass, + deep_merge, flatten_config_data, load_labels, process_config_query_string, update_yaml_file_bulk, ) -from frigate.util.config import find_config_file +from frigate.util.config import apply_section_update, find_config_file from frigate.util.schema import get_config_schema from frigate.util.services import ( get_nvidia_driver_info, @@ -422,9 +423,100 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")): ) +def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONResponse: + """Apply config changes in-memory only, without writing to YAML. + + Used for temporary config changes like debug replay camera tuning. + Updates the in-memory Pydantic config and publishes ZMQ updates, + bypassing YAML parsing entirely. + """ + try: + updates = {} + if body.config_data: + updates = flatten_config_data(body.config_data) + updates = {k: ("" if v is None else v) for k, v in updates.items()} + + if not updates: + return JSONResponse( + content={"success": False, "message": "No configuration data provided"}, + status_code=400, + ) + + config: FrigateConfig = request.app.frigate_config + + # Group flat key paths into nested per-camera, per-section dicts + grouped: dict[str, dict[str, dict]] = {} + for key_path, value in updates.items(): + parts = key_path.split(".") + if len(parts) < 3 or parts[0] != "cameras": + continue + + cam, section = parts[1], parts[2] + grouped.setdefault(cam, {}).setdefault(section, {}) + + # Build nested dict from remaining path (e.g. "filters.person.threshold") + target = grouped[cam][section] + for part in parts[3:-1]: + target = target.setdefault(part, {}) + if len(parts) > 3: + target[parts[-1]] = value + elif isinstance(value, dict): + grouped[cam][section] = deep_merge( + grouped[cam][section], value, override=True + ) + else: + grouped[cam][section] = value + + # Apply each section update + for cam_name, sections in grouped.items(): + camera_config = config.cameras.get(cam_name) + if not camera_config: + return JSONResponse( + content={ + "success": False, + "message": f"Camera '{cam_name}' not found", + }, + status_code=400, + ) + + for section_name, update in sections.items(): + err = apply_section_update(camera_config, section_name, update) + if err is not None: + return JSONResponse( + content={"success": False, "message": err}, + status_code=400, + ) + + # Publish ZMQ updates so processing threads pick up changes + if body.update_topic and body.update_topic.startswith("config/cameras/"): + _, _, camera, field = body.update_topic.split("/") + settings = getattr(config.cameras.get(camera, None), field, None) + + if settings is not None: + request.app.config_publisher.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum[field], camera), + settings, + ) + + return JSONResponse( + content={"success": True, "message": "Config applied in-memory"}, + status_code=200, + ) + except Exception as e: + logger.error(f"Error applying config in-memory: {e}") + return JSONResponse( + content={"success": False, "message": "Error applying config"}, + status_code=500, + ) + + @router.put("/config/set", dependencies=[Depends(require_role(["admin"]))]) def config_set(request: Request, body: AppConfigSetBody): config_file = find_config_file() + + if body.skip_save: + return _config_set_in_memory(request, body) + lock = FileLock(f"{config_file}.lock", timeout=5) try: diff --git a/frigate/api/debug_replay.py b/frigate/api/debug_replay.py new file mode 100644 index 000000000..027d4e50c --- /dev/null +++ b/frigate/api/debug_replay.py @@ -0,0 +1,176 @@ +"""Debug replay API endpoints.""" + +import asyncio +import logging +from datetime import datetime + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from frigate.api.auth import require_role +from frigate.api.defs.tags import Tags + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=[Tags.app]) + + +class DebugReplayStartBody(BaseModel): + """Request body for starting a debug replay session.""" + + camera: str = Field(title="Source camera name") + start_time: float = Field(title="Start timestamp") + end_time: float = Field(title="End timestamp") + + +class DebugReplayStartResponse(BaseModel): + """Response for starting a debug replay session.""" + + success: bool + replay_camera: str + + +class DebugReplayStatusResponse(BaseModel): + """Response for debug replay status.""" + + active: bool + replay_camera: str | None = None + source_camera: str | None = None + start_time: float | None = None + end_time: float | None = None + live_ready: bool = False + + +class DebugReplayStopResponse(BaseModel): + """Response for stopping a debug replay session.""" + + success: bool + + +@router.post( + "/debug_replay/start", + response_model=DebugReplayStartResponse, + dependencies=[Depends(require_role(["admin"]))], + summary="Start debug replay", + description="Start a debug replay session from camera recordings.", +) +async def start_debug_replay(request: Request, body: DebugReplayStartBody): + """Start a debug replay session.""" + replay_manager = request.app.replay_manager + + if replay_manager.active: + return JSONResponse( + content={ + "success": False, + "message": "A replay session is already active", + }, + status_code=409, + ) + + try: + replay_camera = await asyncio.to_thread( + replay_manager.start, + source_camera=body.camera, + start_ts=body.start_time, + end_ts=body.end_time, + frigate_config=request.app.frigate_config, + config_publisher=request.app.config_publisher, + ) + except ValueError: + logger.exception("Invalid parameters for debug replay start request") + return JSONResponse( + content={ + "success": False, + "message": "Invalid debug replay request parameters", + }, + status_code=400, + ) + except RuntimeError: + logger.exception("Error while starting debug replay session") + return JSONResponse( + content={ + "success": False, + "message": "An internal error occurred while starting debug replay", + }, + status_code=500, + ) + + return DebugReplayStartResponse( + success=True, + replay_camera=replay_camera, + ) + + +@router.get( + "/debug_replay/status", + response_model=DebugReplayStatusResponse, + dependencies=[Depends(require_role(["admin"]))], + summary="Get debug replay status", + description="Get the status of the current debug replay session.", +) +def get_debug_replay_status(request: Request): + """Get the current replay session status.""" + replay_manager = request.app.replay_manager + + live_ready = False + replay_camera = replay_manager.replay_camera_name + + if replay_manager.active and replay_camera: + frame_processor = request.app.detected_frames_processor + frame = frame_processor.get_current_frame(replay_camera) + + if frame is not None: + frame_time = frame_processor.get_current_frame_time(replay_camera) + camera_config = request.app.frigate_config.cameras.get(replay_camera) + retry_interval = 10 + + if camera_config is not None: + retry_interval = float(camera_config.ffmpeg.retry_interval or 10) + + live_ready = datetime.now().timestamp() <= frame_time + retry_interval + + return DebugReplayStatusResponse( + active=replay_manager.active, + replay_camera=replay_camera, + source_camera=replay_manager.source_camera, + start_time=replay_manager.start_ts, + end_time=replay_manager.end_ts, + live_ready=live_ready, + ) + + +@router.post( + "/debug_replay/stop", + response_model=DebugReplayStopResponse, + dependencies=[Depends(require_role(["admin"]))], + summary="Stop debug replay", + description="Stop the active debug replay session and clean up all artifacts.", +) +async def stop_debug_replay(request: Request): + """Stop the active replay session.""" + replay_manager = request.app.replay_manager + + if not replay_manager.active: + return JSONResponse( + content={"success": False, "message": "No active replay session"}, + status_code=400, + ) + + try: + await asyncio.to_thread( + replay_manager.stop, + frigate_config=request.app.frigate_config, + config_publisher=request.app.config_publisher, + ) + except (ValueError, RuntimeError, OSError) as e: + logger.error("Error stopping replay: %s", e) + return JSONResponse( + content={ + "success": False, + "message": "Failed to stop replay session due to an internal error.", + }, + status_code=500, + ) + + return DebugReplayStopResponse(success=True) diff --git a/frigate/api/defs/request/app_body.py b/frigate/api/defs/request/app_body.py index 6059daf6e..3d2ab5961 100644 --- a/frigate/api/defs/request/app_body.py +++ b/frigate/api/defs/request/app_body.py @@ -7,6 +7,7 @@ class AppConfigSetBody(BaseModel): requires_restart: int = 1 update_topic: str | None = None config_data: Optional[Dict[str, Any]] = None + skip_save: bool = False class AppPutPasswordBody(BaseModel): diff --git a/frigate/api/fastapi_app.py b/frigate/api/fastapi_app.py index 3206c7b4a..1e8c408e6 100644 --- a/frigate/api/fastapi_app.py +++ b/frigate/api/fastapi_app.py @@ -18,6 +18,7 @@ from frigate.api import ( camera, chat, classification, + debug_replay, event, export, media, @@ -32,6 +33,7 @@ from frigate.comms.event_metadata_updater import ( ) from frigate.config import FrigateConfig from frigate.config.camera.updater import CameraConfigUpdatePublisher +from frigate.debug_replay import DebugReplayManager from frigate.embeddings import EmbeddingsContext from frigate.genai import GenAIClientManager from frigate.ptz.onvif import OnvifController @@ -65,6 +67,7 @@ def create_fastapi_app( stats_emitter: StatsEmitter, event_metadata_updater: EventMetadataPublisher, config_publisher: CameraConfigUpdatePublisher, + replay_manager: DebugReplayManager, enforce_default_admin: bool = True, ): logger.info("Starting FastAPI app") @@ -133,6 +136,7 @@ def create_fastapi_app( app.include_router(event.router) app.include_router(media.router) app.include_router(record.router) + app.include_router(debug_replay.router) # App Properties app.frigate_config = frigate_config app.genai_manager = GenAIClientManager(frigate_config) @@ -144,6 +148,7 @@ def create_fastapi_app( app.stats_emitter = stats_emitter app.event_metadata_updater = event_metadata_updater app.config_publisher = config_publisher + app.replay_manager = replay_manager if frigate_config.auth.enabled: secret = get_jwt_secret() diff --git a/frigate/app.py b/frigate/app.py index fac7a08d9..7c8ac47e3 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -43,6 +43,10 @@ from frigate.const import ( ) from frigate.data_processing.types import DataProcessorMetrics from frigate.db.sqlitevecq import SqliteVecQueueDatabase +from frigate.debug_replay import ( + DebugReplayManager, + cleanup_replay_cameras, +) from frigate.embeddings import EmbeddingProcess, EmbeddingsContext from frigate.events.audio import AudioProcessor from frigate.events.cleanup import EventCleanup @@ -139,6 +143,9 @@ class FrigateApp: else: logger.debug(f"Skipping directory: {d}") + def init_debug_replay_manager(self) -> None: + self.replay_manager = DebugReplayManager() + def init_camera_metrics(self) -> None: # create camera_metrics for camera_name in self.config.cameras.keys(): @@ -531,6 +538,7 @@ class FrigateApp: set_file_limit() # Start frigate services. + self.init_debug_replay_manager() self.init_camera_metrics() self.init_queues() self.init_database() @@ -541,6 +549,10 @@ class FrigateApp: self.init_embeddings_manager() self.bind_database() self.check_db_data_migrations() + + # Clean up any stale replay camera artifacts (filesystem + DB) + cleanup_replay_cameras() + self.init_inter_process_communicator() self.start_detectors() self.init_dispatcher() @@ -572,6 +584,7 @@ class FrigateApp: self.stats_emitter, self.event_metadata_updater, self.inter_config_updater, + self.replay_manager, ), host="127.0.0.1", port=5001, @@ -637,6 +650,7 @@ class FrigateApp: self.record_cleanup.join() self.stats_emitter.join() self.frigate_watchdog.join() + self.camera_maintainer.join() self.db.stop() # Save embeddings stats to disk diff --git a/frigate/camera/activity_manager.py b/frigate/camera/activity_manager.py index c2dfa891d..70c867073 100644 --- a/frigate/camera/activity_manager.py +++ b/frigate/camera/activity_manager.py @@ -57,6 +57,9 @@ class CameraActivityManager: all_objects: list[dict[str, Any]] = [] for camera in new_activity.keys(): + if camera not in self.config.cameras: + continue + # handle cameras that were added dynamically if camera not in self.camera_all_object_counts: self.__init_camera(self.config.cameras[camera]) @@ -124,7 +127,11 @@ class CameraActivityManager: any_changed = False # run through each object and check what topics need to be updated - for label in self.config.cameras[camera].objects.track: + camera_config = self.config.cameras.get(camera) + if camera_config is None: + return + + for label in camera_config.objects.track: if label in self.config.model.non_logo_attributes: continue @@ -174,6 +181,9 @@ class AudioActivityManager: now = datetime.datetime.now().timestamp() for camera in new_activity.keys(): + if camera not in self.config.cameras: + continue + # handle cameras that were added dynamically if camera not in self.current_audio_detections: self.__init_camera(self.config.cameras[camera]) @@ -193,7 +203,11 @@ class AudioActivityManager: def compare_audio_activity( self, camera: str, new_detections: list[tuple[str, float]], now: float ) -> None: - max_not_heard = self.config.cameras[camera].audio.max_not_heard + camera_config = self.config.cameras.get(camera) + if camera_config is None: + return False + + max_not_heard = camera_config.audio.max_not_heard current = self.current_audio_detections[camera] any_changed = False diff --git a/frigate/camera/maintainer.py b/frigate/camera/maintainer.py index 815e650e9..9cfdcc7f3 100644 --- a/frigate/camera/maintainer.py +++ b/frigate/camera/maintainer.py @@ -55,8 +55,20 @@ class CameraMaintainer(threading.Thread): self.shm_count = self.__calculate_shm_frame_count() self.camera_processes: dict[str, mp.Process] = {} self.capture_processes: dict[str, mp.Process] = {} + self.camera_stop_events: dict[str, MpEvent] = {} self.metrics_manager = metrics_manager + def __ensure_camera_stop_event(self, camera: str) -> MpEvent: + camera_stop_event = self.camera_stop_events.get(camera) + + if camera_stop_event is None: + camera_stop_event = mp.Event() + self.camera_stop_events[camera] = camera_stop_event + else: + camera_stop_event.clear() + + return camera_stop_event + def __init_historical_regions(self) -> None: # delete region grids for removed or renamed cameras cameras = list(self.config.cameras.keys()) @@ -99,6 +111,8 @@ class CameraMaintainer(threading.Thread): logger.info(f"Camera processor not started for disabled camera {name}") return + camera_stop_event = self.__ensure_camera_stop_event(name) + if runtime: self.camera_metrics[name] = CameraMetrics(self.metrics_manager) self.ptz_metrics[name] = PTZMetrics(autotracker_enabled=False) @@ -135,7 +149,7 @@ class CameraMaintainer(threading.Thread): self.camera_metrics[name], self.ptz_metrics[name], self.region_grids[name], - self.stop_event, + camera_stop_event, self.config.logger, ) self.camera_processes[config.name] = camera_process @@ -150,6 +164,8 @@ class CameraMaintainer(threading.Thread): logger.info(f"Capture process not started for disabled camera {name}") return + camera_stop_event = self.__ensure_camera_stop_event(name) + # pre-create shms count = 10 if runtime else self.shm_count for i in range(count): @@ -160,7 +176,7 @@ class CameraMaintainer(threading.Thread): config, count, self.camera_metrics[name], - self.stop_event, + camera_stop_event, self.config.logger, ) capture_process.daemon = True @@ -170,18 +186,36 @@ class CameraMaintainer(threading.Thread): logger.info(f"Capture process started for {name}: {capture_process.pid}") def __stop_camera_capture_process(self, camera: str) -> None: - capture_process = self.capture_processes[camera] + capture_process = self.capture_processes.get(camera) if capture_process is not None: logger.info(f"Waiting for capture process for {camera} to stop") - capture_process.terminate() - capture_process.join() + camera_stop_event = self.camera_stop_events.get(camera) + + if camera_stop_event is not None: + camera_stop_event.set() + + capture_process.join(timeout=10) + if capture_process.is_alive(): + logger.warning( + f"Capture process for {camera} didn't exit, forcing termination" + ) + capture_process.terminate() + capture_process.join() def __stop_camera_process(self, camera: str) -> None: - camera_process = self.camera_processes[camera] + camera_process = self.camera_processes.get(camera) if camera_process is not None: logger.info(f"Waiting for process for {camera} to stop") - camera_process.terminate() - camera_process.join() + camera_stop_event = self.camera_stop_events.get(camera) + + if camera_stop_event is not None: + camera_stop_event.set() + + camera_process.join(timeout=10) + if camera_process.is_alive(): + logger.warning(f"Process for {camera} didn't exit, forcing termination") + camera_process.terminate() + camera_process.join() logger.info(f"Closing frame queue for {camera}") empty_and_close_queue(self.camera_metrics[camera].frame_queue) @@ -199,6 +233,12 @@ class CameraMaintainer(threading.Thread): for update_type, updated_cameras in updates.items(): if update_type == CameraConfigUpdateEnum.add.name: for camera in updated_cameras: + if ( + camera in self.camera_processes + or camera in self.capture_processes + ): + continue + self.__start_camera_processor( camera, self.update_subscriber.camera_configs[camera], @@ -210,15 +250,22 @@ class CameraMaintainer(threading.Thread): runtime=True, ) elif update_type == CameraConfigUpdateEnum.remove.name: - self.__stop_camera_capture_process(camera) - self.__stop_camera_process(camera) + for camera in updated_cameras: + self.__stop_camera_capture_process(camera) + self.__stop_camera_process(camera) + self.capture_processes.pop(camera, None) + self.camera_processes.pop(camera, None) + self.camera_stop_events.pop(camera, None) + self.region_grids.pop(camera, None) + self.camera_metrics.pop(camera, None) + self.ptz_metrics.pop(camera, None) # ensure the capture processes are done - for camera in self.camera_processes.keys(): + for camera in self.capture_processes.keys(): self.__stop_camera_capture_process(camera) # ensure the camera processors are done - for camera in self.capture_processes.keys(): + for camera in self.camera_processes.keys(): self.__stop_camera_process(camera) self.update_subscriber.stop() diff --git a/frigate/comms/config_updater.py b/frigate/comms/config_updater.py index 447089a94..4552abc11 100644 --- a/frigate/comms/config_updater.py +++ b/frigate/comms/config_updater.py @@ -26,8 +26,8 @@ class ConfigPublisher: def stop(self) -> None: self.stop_event.set() - self.socket.close() - self.context.destroy() + self.socket.close(linger=0) + self.context.destroy(linger=0) class ConfigSubscriber: @@ -55,5 +55,5 @@ class ConfigSubscriber: return (None, None) def stop(self) -> None: - self.socket.close() - self.context.destroy() + self.socket.close(linger=0) + self.context.destroy(linger=0) diff --git a/frigate/comms/dispatcher.py b/frigate/comms/dispatcher.py index 6da154814..490a829dc 100644 --- a/frigate/comms/dispatcher.py +++ b/frigate/comms/dispatcher.py @@ -110,6 +110,9 @@ class Dispatcher: payload: str, sub_command: str | None = None, ) -> None: + if camera_name not in self.config.cameras: + return + try: if command_type == "set": if sub_command: @@ -131,6 +134,9 @@ class Dispatcher: def handle_request_region_grid() -> Any: camera = payload + if camera not in self.config.cameras: + return None + grid = get_camera_regions_grid( camera, self.config.cameras[camera].detect, @@ -243,7 +249,11 @@ class Dispatcher: self.publish("birdseye_layout", json.dumps(self.birdseye_layout.copy())) def handle_on_connect() -> None: - camera_status = self.camera_activity.last_camera_activity.copy() + camera_status = { + camera: status + for camera, status in self.camera_activity.last_camera_activity.copy().items() + if camera in self.config.cameras + } audio_detections = self.audio_activity.current_audio_detections.copy() cameras_with_status = camera_status.keys() @@ -346,7 +356,8 @@ class Dispatcher: # example /cam_name/notifications/suspend payload=duration camera_name = parts[-3] command = parts[-2] - self._on_camera_notification_suspend(camera_name, payload) + if camera_name in self.config.cameras: + self._on_camera_notification_suspend(camera_name, payload) except IndexError: logger.error( f"Received invalid {topic.split('/')[-1]} command: {topic}" diff --git a/frigate/comms/inter_process.py b/frigate/comms/inter_process.py index e4aad9107..5e76da5eb 100644 --- a/frigate/comms/inter_process.py +++ b/frigate/comms/inter_process.py @@ -61,8 +61,8 @@ class InterProcessCommunicator(Communicator): def stop(self) -> None: self.stop_event.set() self.reader_thread.join() - self.socket.close() - self.context.destroy() + self.socket.close(linger=0) + self.context.destroy(linger=0) class InterProcessRequestor: @@ -82,5 +82,5 @@ class InterProcessRequestor: return "" def stop(self) -> None: - self.socket.close() - self.context.destroy() + self.socket.close(linger=0) + self.context.destroy(linger=0) diff --git a/frigate/comms/zmq_proxy.py b/frigate/comms/zmq_proxy.py index 29329ec59..4a4a0492a 100644 --- a/frigate/comms/zmq_proxy.py +++ b/frigate/comms/zmq_proxy.py @@ -43,7 +43,7 @@ class ZmqProxy: def stop(self) -> None: # destroying the context will tell the proxy to stop - self.context.destroy() + self.context.destroy(linger=0) self.runner.join() @@ -66,8 +66,8 @@ class Publisher(Generic[T]): self.socket.send_string(f"{self.topic}{sub_topic} {json.dumps(payload)}") def stop(self) -> None: - self.socket.close() - self.context.destroy() + self.socket.close(linger=0) + self.context.destroy(linger=0) class Subscriber(Generic[T]): @@ -96,8 +96,8 @@ class Subscriber(Generic[T]): return self._return_object("", None) def stop(self) -> None: - self.socket.close() - self.context.destroy() + self.socket.close(linger=0) + self.context.destroy(linger=0) def _return_object(self, topic: str, payload: T | None) -> T | None: return payload diff --git a/frigate/config/camera/updater.py b/frigate/config/camera/updater.py index 125094f10..44aea527d 100644 --- a/frigate/config/camera/updater.py +++ b/frigate/config/camera/updater.py @@ -80,8 +80,8 @@ class CameraConfigUpdateSubscriber: self.camera_configs[camera] = updated_config return elif update_type == CameraConfigUpdateEnum.remove: - self.config.cameras.pop(camera) - self.camera_configs.pop(camera) + self.config.cameras.pop(camera, None) + self.camera_configs.pop(camera, None) return config = self.camera_configs.get(camera) diff --git a/frigate/const.py b/frigate/const.py index 87fdb8e70..6b1e227d5 100644 --- a/frigate/const.py +++ b/frigate/const.py @@ -14,6 +14,8 @@ RECORD_DIR = f"{BASE_DIR}/recordings" TRIGGER_DIR = f"{CLIPS_DIR}/triggers" BIRDSEYE_PIPE = "/tmp/cache/birdseye" CACHE_DIR = "/tmp/cache" +REPLAY_CAMERA_PREFIX = "_replay_" +REPLAY_DIR = os.path.join(CACHE_DIR, "replay") PLUS_ENV_VAR = "PLUS_API_KEY" PLUS_API_HOST = "https://api.frigate.video" diff --git a/frigate/debug_replay.py b/frigate/debug_replay.py new file mode 100644 index 000000000..504184667 --- /dev/null +++ b/frigate/debug_replay.py @@ -0,0 +1,443 @@ +"""Debug replay camera management for replaying recordings with detection overlays.""" + +import logging +import os +import shutil +import subprocess as sp +import threading + +from ruamel.yaml import YAML + +from frigate.config import FrigateConfig +from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdatePublisher, + CameraConfigUpdateTopic, +) +from frigate.const import ( + CLIPS_DIR, + RECORD_DIR, + REPLAY_CAMERA_PREFIX, + REPLAY_DIR, + THUMB_DIR, +) +from frigate.models import Event, Recordings, ReviewSegment, Timeline +from frigate.util.config import find_config_file + +logger = logging.getLogger(__name__) + + +class DebugReplayManager: + """Manages a single debug replay session.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self.replay_camera_name: str | None = None + self.source_camera: str | None = None + self.clip_path: str | None = None + self.start_ts: float | None = None + self.end_ts: float | None = None + + @property + def active(self) -> bool: + """Whether a replay session is currently active.""" + return self.replay_camera_name is not None + + def start( + self, + source_camera: str, + start_ts: float, + end_ts: float, + frigate_config: FrigateConfig, + config_publisher: CameraConfigUpdatePublisher, + ) -> str: + """Start a debug replay session. + + Args: + source_camera: Name of the source camera to replay + start_ts: Start timestamp + end_ts: End timestamp + frigate_config: Current Frigate configuration + config_publisher: Publisher for camera config updates + + Returns: + The replay camera name + + Raises: + ValueError: If a session is already active or parameters are invalid + RuntimeError: If clip generation fails + """ + with self._lock: + return self._start_locked( + source_camera, start_ts, end_ts, frigate_config, config_publisher + ) + + def _start_locked( + self, + source_camera: str, + start_ts: float, + end_ts: float, + frigate_config: FrigateConfig, + config_publisher: CameraConfigUpdatePublisher, + ) -> str: + if self.active: + raise ValueError("A replay session is already active") + + if source_camera not in frigate_config.cameras: + raise ValueError(f"Camera '{source_camera}' not found") + + if end_ts <= start_ts: + raise ValueError("End time must be after start time") + + # Query recordings for the source camera in the time range + recordings = ( + Recordings.select( + Recordings.path, + Recordings.start_time, + Recordings.end_time, + ) + .where( + Recordings.start_time.between(start_ts, end_ts) + | Recordings.end_time.between(start_ts, end_ts) + | ((start_ts > Recordings.start_time) & (end_ts < Recordings.end_time)) + ) + .where(Recordings.camera == source_camera) + .order_by(Recordings.start_time.asc()) + ) + + if not recordings.count(): + raise ValueError( + f"No recordings found for camera '{source_camera}' in the specified time range" + ) + + # Create replay directory + os.makedirs(REPLAY_DIR, exist_ok=True) + + # Generate replay camera name + replay_name = f"{REPLAY_CAMERA_PREFIX}{source_camera}" + + # Build concat file for ffmpeg + concat_file = os.path.join(REPLAY_DIR, f"{replay_name}_concat.txt") + clip_path = os.path.join(REPLAY_DIR, f"{replay_name}.mp4") + + with open(concat_file, "w") as f: + for recording in recordings: + f.write(f"file '{recording.path}'\n") + + # Concatenate recordings into a single clip with -c copy (fast) + ffmpeg_cmd = [ + frigate_config.ffmpeg.ffmpeg_path, + "-hide_banner", + "-y", + "-f", + "concat", + "-safe", + "0", + "-i", + concat_file, + "-c", + "copy", + "-movflags", + "+faststart", + clip_path, + ] + + logger.info( + "Generating replay clip for %s (%.1f - %.1f)", + source_camera, + start_ts, + end_ts, + ) + + try: + result = sp.run( + ffmpeg_cmd, + capture_output=True, + text=True, + timeout=120, + ) + if result.returncode != 0: + logger.error("FFmpeg error: %s", result.stderr) + raise RuntimeError( + f"Failed to generate replay clip: {result.stderr[-500:]}" + ) + except sp.TimeoutExpired: + raise RuntimeError("Clip generation timed out") + finally: + # Clean up concat file + if os.path.exists(concat_file): + os.remove(concat_file) + + if not os.path.exists(clip_path): + raise RuntimeError("Clip file was not created") + + # Build camera config dict for the replay camera + source_config = frigate_config.cameras[source_camera] + camera_dict = self._build_camera_config_dict( + source_config, replay_name, clip_path + ) + + # Build an in-memory config with the replay camera added + config_file = find_config_file() + yaml_parser = YAML() + with open(config_file, "r") as f: + config_data = yaml_parser.load(f) + + if "cameras" not in config_data or config_data["cameras"] is None: + config_data["cameras"] = {} + config_data["cameras"][replay_name] = camera_dict + + try: + new_config = FrigateConfig.parse_object(config_data) + except Exception as e: + raise RuntimeError(f"Failed to validate replay camera config: {e}") + + # Update the running config + frigate_config.cameras[replay_name] = new_config.cameras[replay_name] + + # Publish the add event + config_publisher.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum.add, replay_name), + new_config.cameras[replay_name], + ) + + # Store session state + self.replay_camera_name = replay_name + self.source_camera = source_camera + self.clip_path = clip_path + self.start_ts = start_ts + self.end_ts = end_ts + + logger.info("Debug replay started: %s -> %s", source_camera, replay_name) + return replay_name + + def stop( + self, + frigate_config: FrigateConfig, + config_publisher: CameraConfigUpdatePublisher, + ) -> None: + """Stop the active replay session and clean up all artifacts. + + Args: + frigate_config: Current Frigate configuration + config_publisher: Publisher for camera config updates + """ + with self._lock: + self._stop_locked(frigate_config, config_publisher) + + def _stop_locked( + self, + frigate_config: FrigateConfig, + config_publisher: CameraConfigUpdatePublisher, + ) -> None: + if not self.active: + logger.warning("No active replay session to stop") + return + + replay_name = self.replay_camera_name + + # Publish remove event so subscribers stop and remove from their config + if replay_name in frigate_config.cameras: + config_publisher.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum.remove, replay_name), + frigate_config.cameras[replay_name], + ) + # Do NOT pop here — let subscribers handle removal from the shared + # config dict when they process the ZMQ message to avoid race conditions + + # Defensive DB cleanup + self._cleanup_db(replay_name) + + # Remove filesystem artifacts + self._cleanup_files(replay_name) + + # Reset state + self.replay_camera_name = None + self.source_camera = None + self.clip_path = None + self.start_ts = None + self.end_ts = None + + logger.info("Debug replay stopped and cleaned up: %s", replay_name) + + def _build_camera_config_dict( + self, + source_config, + replay_name: str, + clip_path: str, + ) -> dict: + """Build a camera config dictionary for the replay camera. + + Args: + source_config: Source camera's CameraConfig + replay_name: Name for the replay camera + clip_path: Path to the replay clip file + + Returns: + Camera config as a dictionary + """ + # Extract detect config (exclude computed fields) + detect_dict = source_config.detect.model_dump( + exclude={"min_initialized", "max_disappeared", "enabled_in_config"} + ) + + # Extract objects config, using .dict() on filters to convert + # RuntimeFilterConfig ndarray masks back to string coordinates + objects_dict = { + "track": source_config.objects.track, + "mask": { + mask_id: ( + mask_cfg.model_dump( + exclude={"raw_coordinates", "enabled_in_config"} + ) + if mask_cfg is not None + else None + ) + for mask_id, mask_cfg in source_config.objects.mask.items() + } + if source_config.objects.mask + else {}, + "filters": { + name: filt.dict() if hasattr(filt, "dict") else filt.model_dump() + for name, filt in source_config.objects.filters.items() + }, + } + + # Extract zones (exclude_defaults avoids serializing empty defaults + # like distances=[] that fail validation on re-parse) + zones_dict = {} + for zone_name, zone_config in source_config.zones.items(): + zone_dump = zone_config.model_dump( + exclude={"contour", "color"}, exclude_defaults=True + ) + # Always include required fields + zone_dump.setdefault("coordinates", zone_config.coordinates) + zones_dict[zone_name] = zone_dump + + # Extract motion config (exclude runtime fields) + motion_dict = {} + if source_config.motion is not None: + motion_dict = source_config.motion.model_dump( + exclude={ + "frame_shape", + "raw_mask", + "mask", + "improved_contrast_enabled", + "rasterized_mask", + } + ) + + return { + "enabled": True, + "ffmpeg": { + "inputs": [ + { + "path": clip_path, + "roles": ["detect"], + "input_args": "-re -stream_loop -1 -fflags +genpts", + } + ], + "hwaccel_args": [], + }, + "detect": detect_dict, + "objects": objects_dict, + "zones": zones_dict, + "motion": motion_dict, + "record": {"enabled": False}, + "snapshots": {"enabled": False}, + "review": { + "alerts": {"enabled": False}, + "detections": {"enabled": False}, + }, + "birdseye": {"enabled": False}, + "audio": {"enabled": False}, + "lpr": {"enabled": False}, + "face_recognition": {"enabled": False}, + } + + def _cleanup_db(self, camera_name: str) -> None: + """Defensively remove any database rows for the replay camera.""" + try: + Event.delete().where(Event.camera == camera_name).execute() + except Exception as e: + logger.error("Failed to delete replay events: %s", e) + + try: + Timeline.delete().where(Timeline.camera == camera_name).execute() + except Exception as e: + logger.error("Failed to delete replay timeline: %s", e) + + try: + Recordings.delete().where(Recordings.camera == camera_name).execute() + except Exception as e: + logger.error("Failed to delete replay recordings: %s", e) + + try: + ReviewSegment.delete().where(ReviewSegment.camera == camera_name).execute() + except Exception as e: + logger.error("Failed to delete replay review segments: %s", e) + + def _cleanup_files(self, camera_name: str) -> None: + """Remove filesystem artifacts for the replay camera.""" + dirs_to_clean = [ + os.path.join(RECORD_DIR, camera_name), + os.path.join(CLIPS_DIR, camera_name), + os.path.join(THUMB_DIR, camera_name), + ] + + for dir_path in dirs_to_clean: + if os.path.exists(dir_path): + try: + shutil.rmtree(dir_path) + logger.debug("Removed replay directory: %s", dir_path) + except Exception as e: + logger.error("Failed to remove %s: %s", dir_path, e) + + # Remove replay clip and any related files + if os.path.exists(REPLAY_DIR): + try: + shutil.rmtree(REPLAY_DIR) + logger.debug("Removed replay cache directory") + except Exception as e: + logger.error("Failed to remove replay cache: %s", e) + + +def cleanup_replay_cameras() -> None: + """Remove any stale replay camera artifacts on startup. + + Since replay cameras are memory-only and never written to YAML, they + won't appear in the config after a restart. This function cleans up + filesystem and database artifacts from any replay that was running when + the process stopped. + + Must be called AFTER the database is bound. + """ + stale_cameras: set[str] = set() + + # Scan filesystem for leftover replay artifacts to derive camera names + for dir_path in [RECORD_DIR, CLIPS_DIR, THUMB_DIR]: + if os.path.isdir(dir_path): + for entry in os.listdir(dir_path): + if entry.startswith(REPLAY_CAMERA_PREFIX): + stale_cameras.add(entry) + + if os.path.isdir(REPLAY_DIR): + for entry in os.listdir(REPLAY_DIR): + if entry.startswith(REPLAY_CAMERA_PREFIX) and entry.endswith(".mp4"): + stale_cameras.add(entry.removesuffix(".mp4")) + + if not stale_cameras: + return + + logger.info("Cleaning up stale replay camera artifacts: %s", list(stale_cameras)) + + manager = DebugReplayManager() + for camera_name in stale_cameras: + manager._cleanup_db(camera_name) + manager._cleanup_files(camera_name) + + if os.path.exists(REPLAY_DIR): + try: + shutil.rmtree(REPLAY_DIR) + except Exception as e: + logger.error("Failed to remove replay cache directory: %s", e) diff --git a/frigate/embeddings/maintainer.py b/frigate/embeddings/maintainer.py index 54831942a..8e45af498 100644 --- a/frigate/embeddings/maintainer.py +++ b/frigate/embeddings/maintainer.py @@ -421,7 +421,9 @@ class EmbeddingMaintainer(threading.Thread): if self.config.semantic_search.enabled: self.embeddings.update_stats() - camera_config = self.config.cameras[camera] + camera_config = self.config.cameras.get(camera) + if camera_config is None: + return # no need to process updated objects if no processors are active if len(self.realtime_processors) == 0 and len(self.post_processors) == 0: @@ -639,7 +641,10 @@ class EmbeddingMaintainer(threading.Thread): if not camera or camera not in self.config.cameras: return - camera_config = self.config.cameras[camera] + camera_config = self.config.cameras.get(camera) + if camera_config is None: + return + dedicated_lpr_enabled = ( camera_config.type == CameraTypeEnum.lpr and "license_plate" not in camera_config.objects.track diff --git a/frigate/events/maintainer.py b/frigate/events/maintainer.py index f6ab777c1..77f6eee5f 100644 --- a/frigate/events/maintainer.py +++ b/frigate/events/maintainer.py @@ -7,6 +7,7 @@ from typing import Dict from frigate.comms.events_updater import EventEndPublisher, EventUpdateSubscriber from frigate.config import FrigateConfig from frigate.config.classification import ObjectClassificationType +from frigate.const import REPLAY_CAMERA_PREFIX from frigate.events.types import EventStateEnum, EventTypeEnum from frigate.models import Event from frigate.util.builtin import to_relative_box @@ -146,7 +147,9 @@ class EventProcessor(threading.Thread): if should_update_db(self.events_in_process[event_data["id"]], event_data): updated_db = True - camera_config = self.config.cameras[camera] + camera_config = self.config.cameras.get(camera) + if camera_config is None: + return width = camera_config.detect.width height = camera_config.detect.height first_detector = list(self.config.detectors.values())[0] @@ -283,6 +286,10 @@ class EventProcessor(threading.Thread): def handle_external_detection( self, event_type: EventStateEnum, event_data: Event ) -> None: + # Skip replay cameras + if event_data.get("camera", "").startswith(REPLAY_CAMERA_PREFIX): + return + if event_type == EventStateEnum.start: event = { Event.id: event_data["id"], diff --git a/frigate/output/birdseye.py b/frigate/output/birdseye.py index eb23c2573..d3717d281 100644 --- a/frigate/output/birdseye.py +++ b/frigate/output/birdseye.py @@ -420,7 +420,8 @@ class BirdsEyeFrameManager: [ cam for cam, cam_data in self.cameras.items() - if self.config.cameras[cam].birdseye.enabled + if cam in self.config.cameras + and self.config.cameras[cam].birdseye.enabled and self.config.cameras[cam].enabled_in_config and self.config.cameras[cam].enabled and cam_data["last_active_frame"] > 0 @@ -723,8 +724,11 @@ class BirdsEyeFrameManager: Update birdseye for a specific camera with new frame data. Returns (frame_changed, layout_changed) to indicate if the frame or layout changed. """ - # don't process if birdseye is disabled for this camera - camera_config = self.config.cameras[camera] + # don't process if camera was removed or birdseye is disabled + camera_config = self.config.cameras.get(camera) + if camera_config is None: + return False, False + force_update = False # disabling birdseye is a little tricky diff --git a/frigate/output/output.py b/frigate/output/output.py index a44415000..38b1ddc52 100644 --- a/frigate/output/output.py +++ b/frigate/output/output.py @@ -22,7 +22,12 @@ from frigate.config.camera.updater import ( CameraConfigUpdateEnum, CameraConfigUpdateSubscriber, ) -from frigate.const import CACHE_DIR, CLIPS_DIR, PROCESS_PRIORITY_MED +from frigate.const import ( + CACHE_DIR, + CLIPS_DIR, + PROCESS_PRIORITY_MED, + REPLAY_CAMERA_PREFIX, +) from frigate.output.birdseye import Birdseye from frigate.output.camera import JsmpegCamera from frigate.output.preview import PreviewRecorder @@ -79,6 +84,32 @@ class OutputProcess(FrigateProcess): ) self.config = config + def is_debug_replay_camera(self, camera: str) -> bool: + return camera.startswith(REPLAY_CAMERA_PREFIX) + + def add_camera( + self, + camera: str, + websocket_server: WSGIServer, + jsmpeg_cameras: dict[str, JsmpegCamera], + preview_recorders: dict[str, PreviewRecorder], + preview_write_times: dict[str, float], + birdseye: Birdseye | None, + ) -> None: + camera_config = self.config.cameras[camera] + jsmpeg_cameras[camera] = JsmpegCamera( + camera_config, self.stop_event, websocket_server + ) + preview_recorders[camera] = PreviewRecorder(camera_config) + preview_write_times[camera] = 0 + + if ( + birdseye is not None + and self.config.birdseye.enabled + and camera_config.birdseye.enabled + ): + birdseye.add_camera(camera) + def run(self) -> None: self.pre_run_setup(self.config.logger) @@ -118,14 +149,17 @@ class OutputProcess(FrigateProcess): move_preview_frames("cache") for camera, cam_config in self.config.cameras.items(): - if not cam_config.enabled_in_config: + if not cam_config.enabled_in_config or self.is_debug_replay_camera(camera): continue - jsmpeg_cameras[camera] = JsmpegCamera( - cam_config, self.stop_event, websocket_server + self.add_camera( + camera, + websocket_server, + jsmpeg_cameras, + preview_recorders, + preview_write_times, + birdseye, ) - preview_recorders[camera] = PreviewRecorder(cam_config) - preview_write_times[camera] = 0 if self.config.birdseye.enabled: birdseye = Birdseye(self.config, self.stop_event, websocket_server) @@ -138,19 +172,15 @@ class OutputProcess(FrigateProcess): if CameraConfigUpdateEnum.add in updates: for camera in updates["add"]: - jsmpeg_cameras[camera] = JsmpegCamera( - self.config.cameras[camera], self.stop_event, websocket_server - ) - preview_recorders[camera] = PreviewRecorder( - self.config.cameras[camera] - ) - preview_write_times[camera] = 0 - - if ( - self.config.birdseye.enabled - and self.config.cameras[camera].birdseye.enabled - ): - birdseye.add_camera(camera) + if not self.is_debug_replay_camera(camera): + self.add_camera( + camera, + websocket_server, + jsmpeg_cameras, + preview_recorders, + preview_write_times, + birdseye, + ) (topic, data) = detection_subscriber.check_for_update(timeout=1) now = datetime.datetime.now().timestamp() @@ -174,7 +204,11 @@ class OutputProcess(FrigateProcess): _, ) = data - if not self.config.cameras[camera].enabled: + if ( + camera not in self.config.cameras + or not self.config.cameras[camera].enabled + or self.is_debug_replay_camera(camera) + ): continue frame = frame_manager.get( diff --git a/frigate/record/maintainer.py b/frigate/record/maintainer.py index a90d1edc1..7b54d6bd1 100644 --- a/frigate/record/maintainer.py +++ b/frigate/record/maintainer.py @@ -287,11 +287,12 @@ class RecordingMaintainer(threading.Thread): ) # publish most recently available recording time and None if disabled + camera_cfg = self.config.cameras.get(camera) self.recordings_publisher.publish( ( camera, recordings[0]["start_time"].timestamp() - if self.config.cameras[camera].record.enabled + if camera_cfg and camera_cfg.record.enabled else None, None, ), @@ -315,9 +316,8 @@ class RecordingMaintainer(threading.Thread): ) -> Optional[Recordings]: cache_path: str = recording["cache_path"] start_time: datetime.datetime = recording["start_time"] - record_config = self.config.cameras[camera].record - # Just delete files if recordings are turned off + # Just delete files if camera removed or recordings are turned off if ( camera not in self.config.cameras or not self.config.cameras[camera].record.enabled diff --git a/frigate/review/maintainer.py b/frigate/review/maintainer.py index 6afdc8de9..a51c73f88 100644 --- a/frigate/review/maintainer.py +++ b/frigate/review/maintainer.py @@ -652,6 +652,9 @@ class ReviewSegmentMaintainer(threading.Thread): if camera not in self.indefinite_events: self.indefinite_events[camera] = {} + if camera not in self.config.cameras: + continue + if ( not self.config.cameras[camera].enabled or not self.config.cameras[camera].record.enabled diff --git a/frigate/stats/util.py b/frigate/stats/util.py index f4f91f83f..40337268e 100644 --- a/frigate/stats/util.py +++ b/frigate/stats/util.py @@ -340,6 +340,9 @@ def stats_snapshot( stats["cameras"] = {} for name, camera_stats in camera_metrics.items(): + if name not in config.cameras: + continue + total_camera_fps += camera_stats.camera_fps.value total_process_fps += camera_stats.process_fps.value total_skipped_fps += camera_stats.skipped_fps.value diff --git a/frigate/storage.py b/frigate/storage.py index feabe06ff..93463c542 100644 --- a/frigate/storage.py +++ b/frigate/storage.py @@ -8,7 +8,7 @@ from pathlib import Path from peewee import SQL, fn from frigate.config import FrigateConfig -from frigate.const import RECORD_DIR +from frigate.const import RECORD_DIR, REPLAY_CAMERA_PREFIX from frigate.models import Event, Recordings from frigate.util.builtin import clear_and_unlink @@ -32,6 +32,10 @@ class StorageMaintainer(threading.Thread): def calculate_camera_bandwidth(self) -> None: """Calculate an average MB/hr for each camera.""" for camera in self.config.cameras.keys(): + # Skip replay cameras + if camera.startswith(REPLAY_CAMERA_PREFIX): + continue + # cameras with < 50 segments should be refreshed to keep size accurate # when few segments are available if self.camera_storage_stats.get(camera, {}).get("needs_refresh", True): @@ -77,6 +81,10 @@ class StorageMaintainer(threading.Thread): usages: dict[str, dict] = {} for camera in self.config.cameras.keys(): + # Skip replay cameras + if camera.startswith(REPLAY_CAMERA_PREFIX): + continue + camera_storage = ( Recordings.select(fn.SUM(Recordings.segment_size)) .where(Recordings.camera == camera, Recordings.segment_size != 0) diff --git a/frigate/test/http_api/base_http_test.py b/frigate/test/http_api/base_http_test.py index 16ded63f8..2ca4aafd0 100644 --- a/frigate/test/http_api/base_http_test.py +++ b/frigate/test/http_api/base_http_test.py @@ -13,6 +13,7 @@ from pydantic import Json from frigate.api.fastapi_app import create_fastapi_app from frigate.config import FrigateConfig from frigate.const import BASE_DIR, CACHE_DIR +from frigate.debug_replay import DebugReplayManager from frigate.models import Event, Recordings, ReviewSegment from frigate.review.types import SeverityEnum from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS @@ -141,6 +142,7 @@ class BaseTestHttp(unittest.TestCase): stats, event_metadata_publisher, None, + DebugReplayManager(), enforce_default_admin=False, ) diff --git a/frigate/test/http_api/test_http_app.py b/frigate/test/http_api/test_http_app.py index b04b1cf55..bf8e9c72a 100644 --- a/frigate/test/http_api/test_http_app.py +++ b/frigate/test/http_api/test_http_app.py @@ -22,3 +22,32 @@ class TestHttpApp(BaseTestHttp): response = client.get("/stats") response_json = response.json() assert response_json == self.test_stats + + def test_config_set_in_memory_replaces_objects_track_list(self): + self.minimal_config["cameras"]["front_door"]["objects"] = { + "track": ["person", "car"], + } + app = super().create_app() + app.config_publisher = Mock() + + with AuthTestClient(app) as client: + response = client.put( + "/config/set", + json={ + "requires_restart": 0, + "skip_save": True, + "update_topic": "config/cameras/front_door/objects", + "config_data": { + "cameras": { + "front_door": { + "objects": { + "track": ["person"], + } + } + } + }, + }, + ) + + assert response.status_code == 200 + assert app.frigate_config.cameras["front_door"].objects.track == ["person"] diff --git a/frigate/test/test_config.py b/frigate/test/test_config.py index 98799fcf0..e903c2ac3 100644 --- a/frigate/test/test_config.py +++ b/frigate/test/test_config.py @@ -151,6 +151,22 @@ class TestConfig(unittest.TestCase): frigate_config = FrigateConfig(**config) assert "dog" in frigate_config.cameras["back"].objects.track + def test_deep_merge_override_replaces_list_values(self): + base = {"objects": {"track": ["person", "face"]}} + update = {"objects": {"track": ["person"]}} + + merged = deep_merge(base, update, override=True) + + assert merged["objects"]["track"] == ["person"] + + def test_deep_merge_merge_lists_still_appends(self): + base = {"track": ["person"]} + update = {"track": ["face"]} + + merged = deep_merge(base, update, override=True, merge_lists=True) + + assert merged["track"] == ["person", "face"] + def test_override_birdseye(self): config = { "mqtt": {"host": "mqtt"}, diff --git a/frigate/timeline.py b/frigate/timeline.py index cf2f5e8c7..3ec866176 100644 --- a/frigate/timeline.py +++ b/frigate/timeline.py @@ -86,7 +86,9 @@ class TimelineProcessor(threading.Thread): event_data: dict[Any, Any], ) -> bool: """Handle object detection.""" - camera_config = self.config.cameras[camera] + camera_config = self.config.cameras.get(camera) + if camera_config is None: + return False event_id = event_data["id"] # Base timeline entry data that all entries will share diff --git a/frigate/track/object_processing.py b/frigate/track/object_processing.py index 9ac04b42a..a699fab23 100644 --- a/frigate/track/object_processing.py +++ b/frigate/track/object_processing.py @@ -690,9 +690,13 @@ class TrackedObjectProcessor(threading.Thread): self.create_camera_state(camera) elif "remove" in updated_topics: for camera in updated_topics["remove"]: - camera_state = self.camera_states[camera] - camera_state.shutdown() + removed_camera_state = self.camera_states[camera] + removed_camera_state.shutdown() self.camera_states.pop(camera) + self.camera_activity.pop(camera, None) + self.last_motion_detected.pop(camera, None) + + self.requestor.send_data(UPDATE_CAMERA_ACTIVITY, self.camera_activity) # manage camera disabled state for camera, config in self.config.cameras.items(): @@ -700,6 +704,10 @@ class TrackedObjectProcessor(threading.Thread): continue current_enabled = config.enabled + camera_state = self.camera_states.get(camera) + if camera_state is None: + continue + camera_state = self.camera_states[camera] if camera_state.prev_enabled and not current_enabled: @@ -752,7 +760,11 @@ class TrackedObjectProcessor(threading.Thread): except queue.Empty: continue - if not self.config.cameras[camera].enabled: + camera_config = self.config.cameras.get(camera) + if camera_config is None: + continue + + if not camera_config.enabled: logger.debug(f"Camera {camera} disabled, skipping update") continue diff --git a/frigate/track/tracked_object.py b/frigate/track/tracked_object.py index c4398dec6..4eb600fb8 100644 --- a/frigate/track/tracked_object.py +++ b/frigate/track/tracked_object.py @@ -16,7 +16,7 @@ from frigate.config import ( SnapshotsConfig, UIConfig, ) -from frigate.const import CLIPS_DIR, THUMB_DIR +from frigate.const import CLIPS_DIR, REPLAY_CAMERA_PREFIX, THUMB_DIR from frigate.detectors.detector_config import ModelConfig from frigate.review.types import SeverityEnum from frigate.util.builtin import sanitize_float @@ -621,6 +621,9 @@ class TrackedObject: if not self.camera_config.name: return + if self.camera_config.name.startswith(REPLAY_CAMERA_PREFIX): + return + directory = os.path.join(THUMB_DIR, self.camera_config.name) if not os.path.exists(directory): diff --git a/frigate/util/builtin.py b/frigate/util/builtin.py index bcdc2feda..aa2417a5c 100644 --- a/frigate/util/builtin.py +++ b/frigate/util/builtin.py @@ -84,7 +84,8 @@ def deep_merge(dct1: dict, dct2: dict, override=False, merge_lists=False) -> dic """ :param dct1: First dict to merge :param dct2: Second dict to merge - :param override: if same key exists in both dictionaries, should override? otherwise ignore. (default=True) + :param override: if same key exists in both dictionaries, should override? otherwise ignore. + :param merge_lists: if True, lists will be merged. :return: The merge dictionary """ merged = copy.deepcopy(dct1) @@ -96,6 +97,8 @@ def deep_merge(dct1: dict, dct2: dict, override=False, merge_lists=False) -> dic elif isinstance(v1, list) and isinstance(v2, list): if merge_lists: merged[k] = v1 + v2 + elif override: + merged[k] = copy.deepcopy(v2) else: if override: merged[k] = copy.deepcopy(v2) diff --git a/frigate/util/config.py b/frigate/util/config.py index c689d16e4..238671563 100644 --- a/frigate/util/config.py +++ b/frigate/util/config.py @@ -9,6 +9,7 @@ from typing import Any, Optional, Union from ruamel.yaml import YAML from frigate.const import CONFIG_DIR, EXPORT_DIR +from frigate.util.builtin import deep_merge from frigate.util.services import get_video_properties logger = logging.getLogger(__name__) @@ -688,3 +689,78 @@ class StreamInfoRetriever: info = asyncio.run(get_video_properties(ffmpeg, path)) self.stream_cache[path] = info return info + + +def apply_section_update(camera_config, section: str, update: dict) -> Optional[str]: + """Merge an update dict into a camera config section and rebuild runtime variants. + + For motion and object filter sections, the plain Pydantic models are rebuilt + as RuntimeMotionConfig / RuntimeFilterConfig so that rasterized numpy masks + are recomputed. This mirrors the logic in FrigateConfig.post_validation. + + Args: + camera_config: The CameraConfig instance to update. + section: Config section name (e.g. "motion", "objects"). + update: Nested dict of field updates to merge. + + Returns: + None on success, or an error message string on failure. + """ + from frigate.config.config import RuntimeFilterConfig, RuntimeMotionConfig + + current = getattr(camera_config, section, None) + if current is None: + return f"Section '{section}' not found on camera '{camera_config.name}'" + + try: + frame_shape = camera_config.frame_shape + + if section == "motion": + merged = deep_merge( + current.model_dump(exclude_unset=True, exclude={"rasterized_mask"}), + update, + override=True, + ) + camera_config.motion = RuntimeMotionConfig( + frame_shape=frame_shape, **merged + ) + + elif section == "objects": + merged = deep_merge( + current.model_dump( + exclude={"filters": {"__all__": {"rasterized_mask"}}} + ), + update, + override=True, + ) + new_objects = current.__class__.model_validate(merged) + + # Preserve private _all_objects from original config + try: + new_objects._all_objects = current._all_objects + except AttributeError: + pass + + # Rebuild RuntimeFilterConfig with merged global + per-object masks + for obj_name, filt in new_objects.filters.items(): + merged_mask = dict(filt.mask) + if new_objects.mask: + for gid, gmask in new_objects.mask.items(): + merged_mask[f"global_{gid}"] = gmask + + new_objects.filters[obj_name] = RuntimeFilterConfig( + frame_shape=frame_shape, + mask=merged_mask, + **filt.model_dump(exclude_unset=True, exclude={"mask", "raw_mask"}), + ) + camera_config.objects = new_objects + + else: + merged = deep_merge(current.model_dump(), update, override=True) + setattr(camera_config, section, current.__class__.model_validate(merged)) + + except Exception: + logger.exception("Config validation error") + return "Validation error. Check logs for details." + + return None diff --git a/web/public/locales/en/common.json b/web/public/locales/en/common.json index 1181554af..37566117a 100644 --- a/web/public/locales/en/common.json +++ b/web/public/locales/en/common.json @@ -117,6 +117,7 @@ "button": { "add": "Add", "apply": "Apply", + "applying": "Applying…", "reset": "Reset", "undo": "Undo", "done": "Done", @@ -252,6 +253,7 @@ "review": "Review", "explore": "Explore", "export": "Export", + "actions": "Actions", "uiPlayground": "UI Playground", "faceLibrary": "Face Library", "classification": "Classification", diff --git a/web/public/locales/en/views/explore.json b/web/public/locales/en/views/explore.json index 53b04e6c4..661a9a5e9 100644 --- a/web/public/locales/en/views/explore.json +++ b/web/public/locales/en/views/explore.json @@ -216,6 +216,10 @@ }, "hideObjectDetails": { "label": "Hide object path" + }, + "debugReplay": { + "label": "Debug replay", + "aria": "View this tracked object in the debug replay view" } }, "dialog": { diff --git a/web/public/locales/en/views/replay.json b/web/public/locales/en/views/replay.json new file mode 100644 index 000000000..a966626f5 --- /dev/null +++ b/web/public/locales/en/views/replay.json @@ -0,0 +1,54 @@ +{ + "title": "Debug Replay", + "description": "Replay camera recordings for debugging. The object list shows a time-delayed summary of detected objects and the Messages tab shows a stream of Frigate's internal messages from the replay footage.", + "websocket_messages": "Messages", + "dialog": { + "title": "Start Debug Replay", + "description": "Create a temporary replay camera that loops historical footage for debugging object detection and tracking issues. The replay camera will have the same detection configuration as the source camera. Choose a time range to begin.", + "camera": "Source Camera", + "timeRange": "Time Range", + "preset": { + "1m": "Last 1 Minute", + "5m": "Last 5 Minutes", + "timeline": "From Timeline", + "custom": "Custom" + }, + "startButton": "Start Replay", + "selectFromTimeline": "Select", + "starting": "Starting replay...", + "startLabel": "Start", + "endLabel": "End", + "toast": { + "success": "Debug replay started successfully", + "error": "Failed to start debug replay: {{error}}", + "alreadyActive": "A replay session is already active", + "stopped": "Debug replay stopped", + "stopError": "Failed to stop debug replay: {{error}}", + "goToReplay": "Go to Replay" + } + }, + "page": { + "noSession": "No Active Replay Session", + "noSessionDesc": "Start a debug replay from the History view by clicking the Debug Replay button in the toolbar.", + "goToRecordings": "Go to History", + "sourceCamera": "Source Camera", + "replayCamera": "Replay Camera", + "initializingReplay": "Initializing replay...", + "stoppingReplay": "Stopping replay...", + "stopReplay": "Stop Replay", + "confirmStop": { + "title": "Stop Debug Replay?", + "description": "This will stop the replay session and clean up all temporary data. Are you sure?", + "confirm": "Stop Replay", + "cancel": "Cancel" + }, + "activity": "Activity", + "objects": "Object List", + "audioDetections": "Audio Detections", + "noActivity": "No activity detected", + "activeTracking": "Active tracking", + "noActiveTracking": "No active tracking", + "configuration": "Configuration", + "configurationDesc": "Fine tune motion detection and object tracking settings for the debug replay camera. No changes are saved to your Frigate configuration file." + } +} diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index ca772377b..0b61b278b 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1392,6 +1392,7 @@ }, "toast": { "success": "Settings saved successfully", + "applied": "Settings applied successfully", "successRestartRequired": "Settings saved successfully. Restart Frigate to apply your changes.", "error": "Failed to save settings", "validationError": "Validation failed: {{message}}", diff --git a/web/public/locales/en/views/system.json b/web/public/locales/en/views/system.json index 8ddbc03e1..faaff31c9 100644 --- a/web/public/locales/en/views/system.json +++ b/web/public/locales/en/views/system.json @@ -7,12 +7,39 @@ "logs": { "frigate": "Frigate Logs - Frigate", "go2rtc": "Go2RTC Logs - Frigate", - "nginx": "Nginx Logs - Frigate" + "nginx": "Nginx Logs - Frigate", + "websocket": "Messages Logs - Frigate" } }, "title": "System", "metrics": "System metrics", "logs": { + "websocket": { + "label": "Messages", + "pause": "Pause", + "resume": "Resume", + "clear": "Clear", + "filter": { + "all": "All topics", + "topics": "Topics", + "events": "Events", + "reviews": "Reviews", + "classification": "Classification", + "face_recognition": "Face Recognition", + "lpr": "LPR", + "camera_activity": "Camera activity", + "system": "System", + "camera": "Camera", + "all_cameras": "All cameras", + "cameras_count_one": "{{count}} Camera", + "cameras_count_other": "{{count}} Cameras" + }, + "empty": "No messages captured yet", + "count": "{{count}} messages", + "expanded": { + "payload": "Payload" + } + }, "download": { "label": "Download Logs" }, @@ -189,7 +216,8 @@ "cameraIsOffline": "{{camera}} is offline", "detectIsSlow": "{{detect}} is slow ({{speed}} ms)", "detectIsVerySlow": "{{detect}} is very slow ({{speed}} ms)", - "shmTooLow": "/dev/shm allocation ({{total}} MB) should be increased to at least {{min}} MB." + "shmTooLow": "/dev/shm allocation ({{total}} MB) should be increased to at least {{min}} MB.", + "debugReplayActive": "Debug replay session is active" }, "enrichments": { "title": "Enrichments", diff --git a/web/src/App.tsx b/web/src/App.tsx index 82ca2b1e0..21babc2b9 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -30,6 +30,7 @@ const Classification = lazy(() => import("@/pages/ClassificationModel")); const Chat = lazy(() => import("@/pages/Chat")); const Logs = lazy(() => import("@/pages/Logs")); const AccessDenied = lazy(() => import("@/pages/AccessDenied")); +const Replay = lazy(() => import("@/pages/Replay")); function App() { const { data: config } = useSWR("config", { @@ -108,7 +109,8 @@ function DefaultAppView() { } /> } /> } /> - } /> + } />{" "} + } />{" "} } /> } /> diff --git a/web/src/api/ws.tsx b/web/src/api/ws.tsx index f5b7b57a3..07d44d67a 100644 --- a/web/src/api/ws.tsx +++ b/web/src/api/ws.tsx @@ -1,5 +1,5 @@ import { baseUrl } from "./baseUrl"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import useWebSocket, { ReadyState } from "react-use-websocket"; import { EmbeddingsReindexProgressType, @@ -17,6 +17,13 @@ import { FrigateStats } from "@/types/stats"; import { createContainer } from "react-tracked"; import useDeepMemo from "@/hooks/use-deep-memo"; +export type WsFeedMessage = { + topic: string; + payload: unknown; + timestamp: number; + id: string; +}; + type Update = { topic: string; payload: unknown; @@ -29,6 +36,9 @@ type WsState = { type useValueReturn = [WsState, (update: Update) => void]; +const wsMessageSubscribers = new Set<(msg: WsFeedMessage) => void>(); +let wsMessageIdCounter = 0; + function useValue(): useValueReturn { const wsUrl = `${baseUrl.replace(/^http/, "ws")}ws`; @@ -43,8 +53,13 @@ function useValue(): useValueReturn { return; } - const cameraActivity: { [key: string]: FrigateCameraState } = - JSON.parse(activityValue); + let cameraActivity: { [key: string]: Partial }; + + try { + cameraActivity = JSON.parse(activityValue); + } catch { + return; + } if (Object.keys(cameraActivity).length === 0) { return; @@ -53,6 +68,12 @@ function useValue(): useValueReturn { const cameraStates: WsState = {}; Object.entries(cameraActivity).forEach(([name, state]) => { + const cameraConfig = state?.config; + + if (!cameraConfig) { + return; + } + const { record, detect, @@ -67,7 +88,7 @@ function useValue(): useValueReturn { detections, object_descriptions, review_descriptions, - } = state["config"]; + } = cameraConfig; cameraStates[`${name}/recordings/state`] = record ? "ON" : "OFF"; cameraStates[`${name}/enabled/state`] = enabled ? "ON" : "OFF"; cameraStates[`${name}/detect/state`] = detect ? "ON" : "OFF"; @@ -115,6 +136,17 @@ function useValue(): useValueReturn { ...prevState, [data.topic]: data.payload, })); + + // Notify feed subscribers + if (wsMessageSubscribers.size > 0) { + const feedMsg: WsFeedMessage = { + topic: data.topic, + payload: data.payload, + timestamp: Date.now(), + id: String(wsMessageIdCounter++), + }; + wsMessageSubscribers.forEach((cb) => cb(feedMsg)); + } } }, onOpen: () => { @@ -740,3 +772,16 @@ export function useJobStatus( return { payload: currentJob as Job | null }; } + +export function useWsMessageSubscribe(callback: (msg: WsFeedMessage) => void) { + const callbackRef = useRef(callback); + callbackRef.current = callback; + + useEffect(() => { + const handler = (msg: WsFeedMessage) => callbackRef.current(msg); + wsMessageSubscribers.add(handler); + return () => { + wsMessageSubscribers.delete(handler); + }; + }, []); +} diff --git a/web/src/components/camera/CameraImage.tsx b/web/src/components/camera/CameraImage.tsx index 716e63f57..f0c05995e 100644 --- a/web/src/components/camera/CameraImage.tsx +++ b/web/src/components/camera/CameraImage.tsx @@ -26,7 +26,8 @@ export default function CameraImage({ const containerRef = useRef(null); const imgRef = useRef(null); - const { name } = config ? config.cameras[camera] : ""; + const cameraConfig = config?.cameras?.[camera]; + const { name } = cameraConfig ?? { name: camera }; const { payload: enabledState } = useEnabledState(camera); const enabled = enabledState ? enabledState === "ON" : true; @@ -34,15 +35,15 @@ export default function CameraImage({ useResizeObserver(containerRef); const requestHeight = useMemo(() => { - if (!config || containerHeight == 0) { + if (!cameraConfig || containerHeight == 0) { return 360; } return Math.min( - config.cameras[camera].detect.height, + cameraConfig.detect.height, Math.round(containerHeight * (isDesktop ? 1.1 : 1.25)), ); - }, [config, camera, containerHeight]); + }, [cameraConfig, containerHeight]); const [isPortraitImage, setIsPortraitImage] = useState(false); diff --git a/web/src/components/config-form/section-configs/motion.ts b/web/src/components/config-form/section-configs/motion.ts index 0acdc0d99..41b5738d3 100644 --- a/web/src/components/config-form/section-configs/motion.ts +++ b/web/src/components/config-form/section-configs/motion.ts @@ -44,6 +44,30 @@ const motion: SectionConfigOverrides = { camera: { restartRequired: ["frame_height"], }, + replay: { + restartRequired: [], + fieldOrder: [ + "threshold", + "contour_area", + "lightning_threshold", + "improve_contrast", + ], + fieldGroups: { + sensitivity: ["threshold", "contour_area"], + algorithm: ["improve_contrast"], + }, + hiddenFields: [ + "enabled", + "enabled_in_config", + "mask", + "raw_mask", + "mqtt_off_delay", + "delta_alpha", + "frame_alpha", + "frame_height", + ], + advancedFields: ["lightning_threshold"], + }, }; export default motion; diff --git a/web/src/components/config-form/section-configs/objects.ts b/web/src/components/config-form/section-configs/objects.ts index 1dfb31053..a70746c49 100644 --- a/web/src/components/config-form/section-configs/objects.ts +++ b/web/src/components/config-form/section-configs/objects.ts @@ -99,6 +99,28 @@ const objects: SectionConfigOverrides = { camera: { restartRequired: [], }, + replay: { + restartRequired: [], + fieldOrder: ["track", "filters"], + fieldGroups: { + tracking: ["track"], + filtering: ["filters"], + }, + hiddenFields: [ + "enabled_in_config", + "alert", + "detect", + "mask", + "raw_mask", + "genai", + "genai.enabled_in_config", + "filters.*.mask", + "filters.*.raw_mask", + "filters.mask", + "filters.raw_mask", + ], + advancedFields: [], + }, }; export default objects; diff --git a/web/src/components/config-form/section-configs/types.ts b/web/src/components/config-form/section-configs/types.ts index 600a3ca50..e2b308e08 100644 --- a/web/src/components/config-form/section-configs/types.ts +++ b/web/src/components/config-form/section-configs/types.ts @@ -4,4 +4,5 @@ export type SectionConfigOverrides = { base?: SectionConfig; global?: Partial; camera?: Partial; + replay?: Partial; }; diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 606919dcc..821857ae6 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -95,9 +95,9 @@ export interface SectionConfig { } export interface BaseSectionProps { - /** Whether this is at global or camera level */ - level: "global" | "camera"; - /** Camera name (required if level is "camera") */ + /** Whether this is at global, camera, or replay level */ + level: "global" | "camera" | "replay"; + /** Camera name (required if level is "camera" or "replay") */ cameraName?: string; /** Whether to show override indicator badge */ showOverrideIndicator?: boolean; @@ -117,6 +117,10 @@ export interface BaseSectionProps { defaultCollapsed?: boolean; /** Whether to show the section title (default: false for global, true for camera) */ showTitle?: boolean; + /** If true, apply config in-memory only without writing to YAML */ + skipSave?: boolean; + /** If true, buttons are not sticky at the bottom */ + noStickyButtons?: boolean; /** Callback when section status changes */ onStatusChange?: (status: { hasChanges: boolean; @@ -156,12 +160,16 @@ export function ConfigSection({ collapsible = false, defaultCollapsed = true, showTitle, + skipSave = false, + noStickyButtons = false, onStatusChange, pendingDataBySection, onPendingDataChange, }: ConfigSectionProps) { + // For replay level, treat as camera-level config access + const effectiveLevel = level === "replay" ? "camera" : level; const { t, i18n } = useTranslation([ - level === "camera" ? "config/cameras" : "config/global", + effectiveLevel === "camera" ? "config/cameras" : "config/global", "config/cameras", "views/settings", "common", @@ -174,10 +182,10 @@ export function ConfigSection({ // Create a key for this section's pending data const pendingDataKey = useMemo( () => - level === "camera" && cameraName + effectiveLevel === "camera" && cameraName ? `${cameraName}::${sectionPath}` : sectionPath, - [level, cameraName, sectionPath], + [effectiveLevel, cameraName, sectionPath], ); // Use pending data from parent if available, otherwise use local state @@ -222,20 +230,20 @@ export function ConfigSection({ const lastPendingDataKeyRef = useRef(null); const updateTopic = - level === "camera" && cameraName + effectiveLevel === "camera" && cameraName ? cameraUpdateTopicMap[sectionPath] ? `config/cameras/${cameraName}/${cameraUpdateTopicMap[sectionPath]}` : undefined : `config/${sectionPath}`; // Default: show title for camera level (since it might be collapsible), hide for global - const shouldShowTitle = showTitle ?? level === "camera"; + const shouldShowTitle = showTitle ?? effectiveLevel === "camera"; // Fetch config const { data: config, mutate: refreshConfig } = useSWR("config"); // Get section schema using cached hook - const sectionSchema = useSectionSchema(sectionPath, level); + const sectionSchema = useSectionSchema(sectionPath, effectiveLevel); // Apply special case handling for sections with problematic schema defaults const modifiedSchema = useMemo( @@ -247,7 +255,7 @@ export function ConfigSection({ // Get override status const { isOverridden, globalValue, cameraValue } = useConfigOverride({ config, - cameraName: level === "camera" ? cameraName : undefined, + cameraName: effectiveLevel === "camera" ? cameraName : undefined, sectionPath, compareFields: sectionConfig.overrideFields, }); @@ -256,12 +264,12 @@ export function ConfigSection({ const rawSectionValue = useMemo(() => { if (!config) return undefined; - if (level === "camera" && cameraName) { + if (effectiveLevel === "camera" && cameraName) { return get(config.cameras?.[cameraName], sectionPath); } return get(config, sectionPath); - }, [config, level, cameraName, sectionPath]); + }, [config, cameraName, sectionPath, effectiveLevel]); const rawFormData = useMemo(() => { if (!config) return {}; @@ -328,9 +336,10 @@ export function ConfigSection({ [rawFormData, sanitizeSectionData], ); - // Clear pendingData whenever formData changes (e.g., from server refresh) - // This prevents RJSF's initial onChange call from being treated as a user edit - // Only clear if pendingData is managed locally (not by parent) + // Clear pendingData whenever the section/camera key changes (e.g., switching + // cameras) or when there is no pending data yet (initialization). + // This prevents RJSF's initial onChange call from being treated as a user edit. + // Only clear if pendingData is managed locally (not by parent). useEffect(() => { const pendingKeyChanged = lastPendingDataKeyRef.current !== pendingDataKey; @@ -339,15 +348,16 @@ export function ConfigSection({ isInitializingRef.current = true; setPendingOverrides(undefined); setDirtyOverrides(undefined); + + // Reset local pending data when switching sections/cameras + if (onPendingDataChange === undefined) { + setPendingData(null); + } } else if (!pendingData) { isInitializingRef.current = true; setPendingOverrides(undefined); setDirtyOverrides(undefined); } - - if (onPendingDataChange === undefined) { - setPendingData(null); - } }, [ onPendingDataChange, pendingData, @@ -484,7 +494,7 @@ export function ConfigSection({ setIsSaving(true); try { const basePath = - level === "camera" && cameraName + effectiveLevel === "camera" && cameraName ? `cameras.${cameraName}.${sectionPath}` : sectionPath; const rawData = sanitizeSectionData(rawFormData); @@ -495,7 +505,7 @@ export function ConfigSection({ ); const sanitizedOverrides = sanitizeOverridesForSection( sectionPath, - level, + effectiveLevel, overrides, ); @@ -508,16 +518,26 @@ export function ConfigSection({ return; } - const needsRestart = requiresRestartForOverrides(sanitizedOverrides); + const needsRestart = skipSave + ? false + : requiresRestartForOverrides(sanitizedOverrides); const configData = buildConfigDataForPath(basePath, sanitizedOverrides); await axios.put("config/set", { requires_restart: needsRestart ? 1 : 0, update_topic: updateTopic, config_data: configData, + ...(skipSave ? { skip_save: true } : {}), }); - if (needsRestart) { + if (skipSave) { + toast.success( + t("toast.applied", { + ns: "views/settings", + defaultValue: "Settings applied successfully", + }), + ); + } else if (needsRestart) { statusBar?.addMessage( "config_restart_required", t("configForm.restartRequiredFooter", { @@ -596,7 +616,7 @@ export function ConfigSection({ }, [ sectionPath, pendingData, - level, + effectiveLevel, cameraName, t, refreshConfig, @@ -608,15 +628,16 @@ export function ConfigSection({ updateTopic, setPendingData, requiresRestartForOverrides, + skipSave, ]); // Handle reset to global/defaults - removes camera-level override or resets global to defaults const handleResetToGlobal = useCallback(async () => { - if (level === "camera" && !cameraName) return; + if (effectiveLevel === "camera" && !cameraName) return; try { const basePath = - level === "camera" && cameraName + effectiveLevel === "camera" && cameraName ? `cameras.${cameraName}.${sectionPath}` : sectionPath; @@ -632,7 +653,7 @@ export function ConfigSection({ t("toast.resetSuccess", { ns: "views/settings", defaultValue: - level === "global" + effectiveLevel === "global" ? "Reset to defaults" : "Reset to global defaults", }), @@ -651,7 +672,7 @@ export function ConfigSection({ } }, [ sectionPath, - level, + effectiveLevel, cameraName, requiresRestart, t, @@ -661,8 +682,8 @@ export function ConfigSection({ ]); const sectionValidation = useMemo( - () => getSectionValidation({ sectionPath, level, t }), - [sectionPath, level, t], + () => getSectionValidation({ sectionPath, level: effectiveLevel, t }), + [sectionPath, effectiveLevel, t], ); const customValidate = useMemo(() => { @@ -733,7 +754,7 @@ export function ConfigSection({ // nested under the section name (e.g., `audio.label`). For global-level // sections, keys are nested under the section name in `config/global`. const configNamespace = - level === "camera" ? "config/cameras" : "config/global"; + effectiveLevel === "camera" ? "config/cameras" : "config/global"; const title = t(`${sectionPath}.label`, { ns: configNamespace, defaultValue: defaultTitle, @@ -769,7 +790,7 @@ export function ConfigSection({ i18nNamespace={configNamespace} customValidate={customValidate} formContext={{ - level, + level: effectiveLevel, cameraName, globalValue, cameraValue, @@ -784,7 +805,7 @@ export function ConfigSection({ onFormDataChange: (data: ConfigSectionData) => handleChange(data), // For widgets that need access to full camera config (e.g., zone names) fullCameraConfig: - level === "camera" && cameraName + effectiveLevel === "camera" && cameraName ? config?.cameras?.[cameraName] : undefined, fullConfig: config, @@ -804,7 +825,12 @@ export function ConfigSection({ }} /> -
+
)}
- {((level === "camera" && isOverridden) || level === "global") && - !hasChanges && ( + {((effectiveLevel === "camera" && isOverridden) || + effectiveLevel === "global") && + !hasChanges && + !skipSave && ( + + ), + }, + ); + } else { + toast.error(t("dialog.toast.error", { error: errorMessage }), { + position: "top-center", + }); + } + }) + .finally(() => { + setIsStarting(false); + }); + }, + [navigate, t], + ); + const MenuItem = isContextMenu ? ContextMenuItem : DropdownMenuItem; const menuItems = ( @@ -149,6 +205,20 @@ export default function SearchResultActions({ {t("itemMenu.addTrigger.label")} )} + {searchResult.has_clip && ( + { + handleDebugReplay(searchResult); + }} + > + {isStarting + ? t("dialog.starting", { ns: "views/replay" }) + : t("itemMenu.debugReplay.label")} + + )} {isAdmin && ( void; + onExportClick: () => void; +}; + +export default function ActionsDropdown({ + onDebugReplayClick, + onExportClick, +}: ActionsDropdownProps) { + const { t } = useTranslation(["components/dialog", "views/replay", "common"]); + + return ( + + + + + + + {t("menu.export", { ns: "common" })} + + + {t("title", { ns: "views/replay" })} + + + + ); +} diff --git a/web/src/components/overlay/CustomTimeSelector.tsx b/web/src/components/overlay/CustomTimeSelector.tsx new file mode 100644 index 000000000..0d9a4d052 --- /dev/null +++ b/web/src/components/overlay/CustomTimeSelector.tsx @@ -0,0 +1,240 @@ +import { useMemo, useState } from "react"; +import { Button } from "../ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; +import { SelectSeparator } from "../ui/select"; +import { TimeRange } from "@/types/timeline"; +import { useFormattedTimestamp } from "@/hooks/use-date-utils"; +import { getUTCOffset } from "@/utils/dateUtil"; +import { TimezoneAwareCalendar } from "./ReviewActivityCalendar"; +import { FaArrowRight, FaCalendarAlt } from "react-icons/fa"; +import { isDesktop, isIOS } from "react-device-detect"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { useTranslation } from "react-i18next"; + +type CustomTimeSelectorProps = { + latestTime: number; + range?: TimeRange; + setRange: (range: TimeRange | undefined) => void; + startLabel: string; + endLabel: string; +}; + +export function CustomTimeSelector({ + latestTime, + range, + setRange, + startLabel, + endLabel, +}: CustomTimeSelectorProps) { + const { t } = useTranslation(["common"]); + const { data: config } = useSWR("config"); + + // times + const timezoneOffset = useMemo( + () => + config?.ui.timezone + ? Math.round(getUTCOffset(new Date(), config.ui.timezone)) + : undefined, + [config?.ui.timezone], + ); + const localTimeOffset = useMemo( + () => + Math.round( + getUTCOffset( + new Date(), + Intl.DateTimeFormat().resolvedOptions().timeZone, + ), + ), + [], + ); + + const startTime = useMemo(() => { + let time = range?.after || latestTime - 3600; + + if (timezoneOffset) { + time = time + (timezoneOffset - localTimeOffset) * 60; + } + + return time; + }, [range, latestTime, timezoneOffset, localTimeOffset]); + + const endTime = useMemo(() => { + let time = range?.before || latestTime; + + if (timezoneOffset) { + time = time + (timezoneOffset - localTimeOffset) * 60; + } + + return time; + }, [range, latestTime, timezoneOffset, localTimeOffset]); + + const formattedStart = useFormattedTimestamp( + startTime, + config?.ui.time_format == "24hour" + ? t("time.formattedTimestamp.24hour") + : t("time.formattedTimestamp.12hour"), + ); + + const formattedEnd = useFormattedTimestamp( + endTime, + config?.ui.time_format == "24hour" + ? t("time.formattedTimestamp.24hour") + : t("time.formattedTimestamp.12hour"), + ); + + const startClock = useMemo(() => { + const date = new Date(startTime * 1000); + return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}`; + }, [startTime]); + + const endClock = useMemo(() => { + const date = new Date(endTime * 1000); + return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}`; + }, [endTime]); + + // calendars + const [startOpen, setStartOpen] = useState(false); + const [endOpen, setEndOpen] = useState(false); + + return ( +
+ +
+ { + if (!open) { + setStartOpen(false); + } + }} + > + + + + + { + if (!day) { + return; + } + + setRange({ + before: endTime, + after: day.getTime() / 1000 + 1, + }); + }} + /> + + { + const clock = e.target.value; + const [hour, minute, second] = isIOS + ? [...clock.split(":"), "00"] + : clock.split(":"); + + const start = new Date(startTime * 1000); + start.setHours( + parseInt(hour), + parseInt(minute), + parseInt(second ?? 0), + 0, + ); + setRange({ + before: endTime, + after: start.getTime() / 1000, + }); + }} + /> + + + + { + if (!open) { + setEndOpen(false); + } + }} + > + + + + + { + if (!day) { + return; + } + + setRange({ + after: startTime, + before: day.getTime() / 1000, + }); + }} + /> + + { + const clock = e.target.value; + const [hour, minute, second] = isIOS + ? [...clock.split(":"), "00"] + : clock.split(":"); + + const end = new Date(endTime * 1000); + end.setHours( + parseInt(hour), + parseInt(minute), + parseInt(second ?? 0), + 0, + ); + setRange({ + before: end.getTime() / 1000, + after: startTime, + }); + }} + /> + + +
+
+ ); +} diff --git a/web/src/components/overlay/DebugReplayDialog.tsx b/web/src/components/overlay/DebugReplayDialog.tsx new file mode 100644 index 000000000..9c3efb4f5 --- /dev/null +++ b/web/src/components/overlay/DebugReplayDialog.tsx @@ -0,0 +1,367 @@ +import { useCallback, useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../ui/dialog"; +import { Label } from "../ui/label"; +import { RadioGroup, RadioGroupItem } from "../ui/radio-group"; +import { Button } from "../ui/button"; +import axios from "axios"; +import { toast } from "sonner"; +import { isDesktop } from "react-device-detect"; +import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { SelectSeparator } from "../ui/select"; +import ActivityIndicator from "../indicators/activity-indicator"; +import { LuBug, LuPlay, LuX } from "react-icons/lu"; +import { ExportMode } from "@/types/filter"; +import { TimeRange } from "@/types/timeline"; +import { cn } from "@/lib/utils"; +import { CustomTimeSelector } from "./CustomTimeSelector"; + +const REPLAY_TIME_OPTIONS = ["1", "5", "timeline", "custom"] as const; +type ReplayTimeOption = (typeof REPLAY_TIME_OPTIONS)[number]; + +type DebugReplayContentProps = { + currentTime: number; + latestTime: number; + range?: TimeRange; + selectedOption: ReplayTimeOption; + isStarting: boolean; + onSelectedOptionChange: (option: ReplayTimeOption) => void; + onStart: () => void; + onCancel: () => void; + setRange: (range: TimeRange | undefined) => void; + setMode: (mode: ExportMode) => void; +}; + +export function DebugReplayContent({ + currentTime, + latestTime, + range, + selectedOption, + isStarting, + onSelectedOptionChange, + onStart, + onCancel, + setRange, + setMode, +}: DebugReplayContentProps) { + const { t } = useTranslation(["views/replay"]); + + return ( +
+ {isDesktop && ( + <> + + {t("dialog.title")} + {t("dialog.description")} + + + + )} + + {/* Time range */} +
+ + onSelectedOptionChange(value as ReplayTimeOption) + } + > + {REPLAY_TIME_OPTIONS.map((opt) => ( +
+ + +
+ ))} +
+
+ + {/* Custom time inputs */} + {selectedOption === "custom" && ( + + )} + + {isDesktop && } + + +
+ {t("button.cancel", { ns: "common" })} +
+ +
+
+ ); +} + +type DebugReplayDialogProps = { + camera: string; + currentTime: number; + latestTime: number; + range?: TimeRange; + mode: ExportMode; + setRange: (range: TimeRange | undefined) => void; + setMode: (mode: ExportMode) => void; +}; + +export default function DebugReplayDialog({ + camera, + currentTime, + latestTime, + range, + mode, + setRange, + setMode, +}: DebugReplayDialogProps) { + const { t } = useTranslation(["views/replay"]); + const navigate = useNavigate(); + + const [selectedOption, setSelectedOption] = useState("1"); + const [isStarting, setIsStarting] = useState(false); + + const handleTimeOptionChange = useCallback( + (option: ReplayTimeOption) => { + setSelectedOption(option); + + if (option === "custom" || option === "timeline") { + return; + } + + const minutes = parseInt(option, 10); + const end = latestTime; + setRange({ after: end - minutes * 60, before: end }); + }, + [latestTime, setRange], + ); + + const handleStart = useCallback(() => { + if (!range || range.before <= range.after) { + toast.error( + t("dialog.toast.error", { error: "End time must be after start time" }), + { position: "top-center" }, + ); + return; + } + + setIsStarting(true); + + axios + .post("debug_replay/start", { + camera: camera, + start_time: range.after, + end_time: range.before, + }) + .then((response) => { + if (response.status === 200) { + toast.success(t("dialog.toast.success"), { + position: "top-center", + }); + setMode("none"); + setRange(undefined); + navigate("/replay"); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + + if (error.response?.status === 409) { + toast.error(t("dialog.toast.alreadyActive"), { + position: "top-center", + closeButton: true, + dismissible: false, + action: ( + + + + ), + }); + } else { + toast.error(t("dialog.toast.error", { error: errorMessage }), { + position: "top-center", + }); + } + }) + .finally(() => { + setIsStarting(false); + }); + }, [camera, range, navigate, setMode, setRange, t]); + + const handleCancel = useCallback(() => { + setMode("none"); + setRange(undefined); + }, [setMode, setRange]); + + const Overlay = isDesktop ? Dialog : Drawer; + const Trigger = isDesktop ? DialogTrigger : DrawerTrigger; + const Content = isDesktop ? DialogContent : DrawerContent; + + return ( + <> + + { + if (!open) { + setMode("none"); + } + }} + > + {!isDesktop && ( + + + + )} + + + + + + ); +} + +type SaveDebugReplayOverlayProps = { + className: string; + show: boolean; + isStarting: boolean; + onSave: () => void; + onCancel: () => void; +}; + +export function SaveDebugReplayOverlay({ + className, + show, + isStarting, + onSave, + onCancel, +}: SaveDebugReplayOverlayProps) { + const { t } = useTranslation(["views/replay"]); + + return ( +
+
+ + +
+
+ ); +} diff --git a/web/src/components/overlay/ExportDialog.tsx b/web/src/components/overlay/ExportDialog.tsx index 738aa689e..6912ebf46 100644 --- a/web/src/components/overlay/ExportDialog.tsx +++ b/web/src/components/overlay/ExportDialog.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useState } from "react"; import { Dialog, DialogContent, @@ -12,16 +12,12 @@ import { Label } from "../ui/label"; import { RadioGroup, RadioGroupItem } from "../ui/radio-group"; import { Button } from "../ui/button"; import { ExportMode } from "@/types/filter"; -import { FaArrowDown, FaArrowRight, FaCalendarAlt } from "react-icons/fa"; +import { FaArrowDown } from "react-icons/fa"; import axios from "axios"; import { toast } from "sonner"; import { Input } from "../ui/input"; import { TimeRange } from "@/types/timeline"; -import { useFormattedTimestamp } from "@/hooks/use-date-utils"; import useSWR from "swr"; -import { FrigateConfig } from "@/types/frigateConfig"; -import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; -import { TimezoneAwareCalendar } from "./ReviewActivityCalendar"; import { Select, SelectContent, @@ -30,15 +26,15 @@ import { SelectTrigger, SelectValue, } from "../ui/select"; -import { isDesktop, isIOS, isMobile } from "react-device-detect"; +import { isDesktop, isMobile } from "react-device-detect"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import SaveExportOverlay from "./SaveExportOverlay"; -import { getUTCOffset } from "@/utils/dateUtil"; import { baseUrl } from "@/api/baseUrl"; import { cn } from "@/lib/utils"; import { GenericVideoPlayer } from "../player/GenericVideoPlayer"; import { useTranslation } from "react-i18next"; import { ExportCase } from "@/types/export"; +import { CustomTimeSelector } from "./CustomTimeSelector"; const EXPORT_OPTIONS = [ "1", @@ -167,31 +163,33 @@ export default function ExportDialog({ } }} > - - - + {!isDesktop && ( + + + + )} )} void; -}; -function CustomTimeSelector({ - latestTime, - range, - setRange, -}: CustomTimeSelectorProps) { - const { t } = useTranslation(["components/dialog"]); - const { data: config } = useSWR("config"); - - // times - - const timezoneOffset = useMemo( - () => - config?.ui.timezone - ? Math.round(getUTCOffset(new Date(), config.ui.timezone)) - : undefined, - [config?.ui.timezone], - ); - const localTimeOffset = useMemo( - () => - Math.round( - getUTCOffset( - new Date(), - Intl.DateTimeFormat().resolvedOptions().timeZone, - ), - ), - [], - ); - - const startTime = useMemo(() => { - let time = range?.after || latestTime - 3600; - - if (timezoneOffset) { - time = time + (timezoneOffset - localTimeOffset) * 60; - } - - return time; - }, [range, latestTime, timezoneOffset, localTimeOffset]); - const endTime = useMemo(() => { - let time = range?.before || latestTime; - - if (timezoneOffset) { - time = time + (timezoneOffset - localTimeOffset) * 60; - } - - return time; - }, [range, latestTime, timezoneOffset, localTimeOffset]); - const formattedStart = useFormattedTimestamp( - startTime, - config?.ui.time_format == "24hour" - ? t("time.formattedTimestamp.24hour", { ns: "common" }) - : t("time.formattedTimestamp.12hour", { ns: "common" }), - ); - const formattedEnd = useFormattedTimestamp( - endTime, - config?.ui.time_format == "24hour" - ? t("time.formattedTimestamp.24hour", { ns: "common" }) - : t("time.formattedTimestamp.12hour", { ns: "common" }), - ); - - const startClock = useMemo(() => { - const date = new Date(startTime * 1000); - return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}`; - }, [startTime]); - const endClock = useMemo(() => { - const date = new Date(endTime * 1000); - return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}`; - }, [endTime]); - - // calendars - - const [startOpen, setStartOpen] = useState(false); - const [endOpen, setEndOpen] = useState(false); - - return ( -
- -
- { - if (!open) { - setStartOpen(false); - } - }} - > - - - - - { - if (!day) { - return; - } - - setRange({ - before: endTime, - after: day.getTime() / 1000 + 1, - }); - }} - /> - - { - const clock = e.target.value; - const [hour, minute, second] = isIOS - ? [...clock.split(":"), "00"] - : clock.split(":"); - - const start = new Date(startTime * 1000); - start.setHours( - parseInt(hour), - parseInt(minute), - parseInt(second ?? 0), - 0, - ); - setRange({ - before: endTime, - after: start.getTime() / 1000, - }); - }} - /> - - - - { - if (!open) { - setEndOpen(false); - } - }} - > - - - - - { - if (!day) { - return; - } - - setRange({ - after: startTime, - before: day.getTime() / 1000, - }); - }} - /> - - { - const clock = e.target.value; - const [hour, minute, second] = isIOS - ? [...clock.split(":"), "00"] - : clock.split(":"); - - const end = new Date(endTime * 1000); - end.setHours( - parseInt(hour), - parseInt(minute), - parseInt(second ?? 0), - 0, - ); - setRange({ - before: end.getTime() / 1000, - after: startTime, - }); - }} - /> - - -
-
- ); -} - type ExportPreviewDialogProps = { camera: string; range?: TimeRange; diff --git a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx index 84f9051ea..77cb8e3f4 100644 --- a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx +++ b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx @@ -2,8 +2,13 @@ import { useCallback, useState } from "react"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import { Button } from "../ui/button"; import { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa"; +import { LuBug } from "react-icons/lu"; import { TimeRange } from "@/types/timeline"; import { ExportContent, ExportPreviewDialog } from "./ExportDialog"; +import { + DebugReplayContent, + SaveDebugReplayOverlay, +} from "./DebugReplayDialog"; import { ExportMode, GeneralFilter } from "@/types/filter"; import ReviewActivityCalendar from "./ReviewActivityCalendar"; import { SelectSeparator } from "../ui/select"; @@ -16,19 +21,32 @@ import { import { getEndOfDayTimestamp } from "@/utils/dateUtil"; import { GeneralFilterContent } from "../filter/ReviewFilterGroup"; import { toast } from "sonner"; -import axios from "axios"; +import axios, { AxiosError } from "axios"; import SaveExportOverlay from "./SaveExportOverlay"; import { isIOS, isMobile } from "react-device-detect"; import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; -type DrawerMode = "none" | "select" | "export" | "calendar" | "filter"; +type DrawerMode = + | "none" + | "select" + | "export" + | "calendar" + | "filter" + | "debug-replay"; -const DRAWER_FEATURES = ["export", "calendar", "filter"] as const; +const DRAWER_FEATURES = [ + "export", + "calendar", + "filter", + "debug-replay", +] as const; export type DrawerFeatures = (typeof DRAWER_FEATURES)[number]; const DEFAULT_DRAWER_FEATURES: DrawerFeatures[] = [ "export", "calendar", "filter", + "debug-replay", ]; type MobileReviewSettingsDrawerProps = { @@ -45,6 +63,10 @@ type MobileReviewSettingsDrawerProps = { recordingsSummary?: RecordingsSummary; allLabels: string[]; allZones: string[]; + debugReplayMode?: ExportMode; + debugReplayRange?: TimeRange; + setDebugReplayMode?: (mode: ExportMode) => void; + setDebugReplayRange?: (range: TimeRange | undefined) => void; onUpdateFilter: (filter: ReviewFilter) => void; setRange: (range: TimeRange | undefined) => void; setMode: (mode: ExportMode) => void; @@ -64,13 +86,26 @@ export default function MobileReviewSettingsDrawer({ recordingsSummary, allLabels, allZones, + debugReplayMode = "none", + debugReplayRange, + setDebugReplayMode = () => {}, + setDebugReplayRange = () => {}, onUpdateFilter, setRange, setMode, setShowExportPreview, }: MobileReviewSettingsDrawerProps) { - const { t } = useTranslation(["views/recording", "components/dialog"]); + const { t } = useTranslation([ + "views/recording", + "components/dialog", + "views/replay", + ]); + const navigate = useNavigate(); const [drawerMode, setDrawerMode] = useState("none"); + const [selectedReplayOption, setSelectedReplayOption] = useState< + "1" | "5" | "custom" | "timeline" + >("1"); + const [isDebugReplayStarting, setIsDebugReplayStarting] = useState(false); // exports @@ -140,6 +175,76 @@ export default function MobileReviewSettingsDrawer({ }); }, [camera, name, range, selectedCaseId, setRange, setName, setMode, t]); + const onStartDebugReplay = useCallback(async () => { + if ( + !debugReplayRange || + debugReplayRange.before <= debugReplayRange.after + ) { + toast.error( + t("dialog.toast.error", { + error: "End time must be after start time", + ns: "views/replay", + }), + { position: "top-center" }, + ); + return; + } + + setIsDebugReplayStarting(true); + + try { + const response = await axios.post("debug_replay/start", { + camera: camera, + start_time: debugReplayRange.after, + end_time: debugReplayRange.before, + }); + + if (response.status === 200) { + toast.success(t("dialog.toast.success", { ns: "views/replay" }), { + position: "top-center", + }); + setDebugReplayMode("none"); + setDebugReplayRange(undefined); + setDrawerMode("none"); + navigate("/replay"); + } + } catch (error) { + const axiosError = error as AxiosError<{ + message?: string; + detail?: string; + }>; + const errorMessage = + axiosError.response?.data?.message || + axiosError.response?.data?.detail || + "Unknown error"; + + if (axiosError.response?.status === 409) { + toast.error(t("dialog.toast.alreadyActive", { ns: "views/replay" }), { + position: "top-center", + }); + } else { + toast.error( + t("dialog.toast.error", { + error: errorMessage, + ns: "views/replay", + }), + { + position: "top-center", + }, + ); + } + } finally { + setIsDebugReplayStarting(false); + } + }, [ + camera, + debugReplayRange, + navigate, + setDebugReplayMode, + setDebugReplayRange, + t, + ]); + // filters const [currentFilter, setCurrentFilter] = useState({ @@ -196,6 +301,26 @@ export default function MobileReviewSettingsDrawer({ {t("filter")} )} + {features.includes("debug-replay") && ( + + )}
); } else if (drawerMode == "export") { @@ -311,6 +436,47 @@ export default function MobileReviewSettingsDrawer({ />
); + } else if (drawerMode == "debug-replay") { + const handleTimeOptionChange = ( + option: "1" | "5" | "custom" | "timeline", + ) => { + setSelectedReplayOption(option); + + if (option === "custom" || option === "timeline") { + return; + } + + const hours = parseInt(option); + const end = latestTime; + const now = new Date(end * 1000); + now.setHours(now.getHours() - hours); + setDebugReplayRange({ after: now.getTime() / 1000, before: end }); + }; + + content = ( + { + setDebugReplayMode("none"); + setDebugReplayRange(undefined); + setDrawerMode("select"); + }} + setRange={setDebugReplayRange} + setMode={(mode) => { + setDebugReplayMode(mode); + + if (mode == "timeline") { + setDrawerMode("none"); + } + }} + /> + ); } return ( @@ -322,6 +488,16 @@ export default function MobileReviewSettingsDrawer({ onCancel={() => setMode("none")} onPreview={() => setShowExportPreview(true)} /> + { + setDebugReplayMode("none"); + setDebugReplayRange(undefined); + }} + /> - + {content} diff --git a/web/src/components/timeline/EventMenu.tsx b/web/src/components/timeline/EventMenu.tsx index e6ad8eba4..98c514945 100644 --- a/web/src/components/timeline/EventMenu.tsx +++ b/web/src/components/timeline/EventMenu.tsx @@ -12,8 +12,11 @@ import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Event } from "@/types/event"; import { FrigateConfig } from "@/types/frigateConfig"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import { useIsAdmin } from "@/hooks/use-is-admin"; +import axios from "axios"; +import { toast } from "sonner"; +import { Button } from "../ui/button"; type EventMenuProps = { event: Event; @@ -34,9 +37,10 @@ export default function EventMenu({ }: EventMenuProps) { const apiHost = useApiHost(); const navigate = useNavigate(); - const { t } = useTranslation("views/explore"); + const { t } = useTranslation(["views/explore", "views/replay"]); const [isOpen, setIsOpen] = useState(false); const isAdmin = useIsAdmin(); + const [isStarting, setIsStarting] = useState(false); const handleObjectSelect = () => { if (isSelected) { @@ -46,6 +50,59 @@ export default function EventMenu({ } }; + const handleDebugReplay = useCallback( + (event: Event) => { + setIsStarting(true); + + axios + .post("debug_replay/start", { + camera: event.camera, + start_time: event.start_time, + end_time: event.end_time, + }) + .then((response) => { + if (response.status === 200) { + toast.success(t("dialog.toast.success", { ns: "views/replay" }), { + position: "top-center", + }); + navigate("/replay"); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + + if (error.response?.status === 409) { + toast.error( + t("dialog.toast.alreadyActive", { ns: "views/replay" }), + { + position: "top-center", + closeButton: true, + dismissible: false, + action: ( + + + + ), + }, + ); + } else { + toast.error(t("dialog.toast.error", { error: errorMessage }), { + position: "top-center", + }); + } + }) + .finally(() => { + setIsStarting(false); + }); + }, + [navigate, t], + ); + return ( <> @@ -117,6 +174,19 @@ export default function EventMenu({ {t("itemMenu.findSimilar.label")} )} + {event.has_clip && ( + { + handleDebugReplay(event); + }} + > + {isStarting + ? t("dialog.starting", { ns: "views/replay" }) + : t("itemMenu.debugReplay.label")} + + )} diff --git a/web/src/components/ws/WsMessageFeed.tsx b/web/src/components/ws/WsMessageFeed.tsx new file mode 100644 index 000000000..0da86a108 --- /dev/null +++ b/web/src/components/ws/WsMessageFeed.tsx @@ -0,0 +1,608 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import useSWR from "swr"; +import { WsFeedMessage } from "@/api/ws"; +import { useWsMessageBuffer } from "@/hooks/use-ws-message-buffer"; +import WsMessageRow from "./WsMessageRow"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { FaEraser, FaFilter, FaPause, FaPlay, FaVideo } from "react-icons/fa"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"; +import FilterSwitch from "@/components/filter/FilterSwitch"; +import { isMobile } from "react-device-detect"; +import { isReplayCamera } from "@/utils/cameraUtil"; + +type TopicCategory = + | "events" + | "camera_activity" + | "system" + | "reviews" + | "classification" + | "face_recognition" + | "lpr"; + +const ALL_TOPIC_CATEGORIES: TopicCategory[] = [ + "events", + "reviews", + "classification", + "face_recognition", + "lpr", + "camera_activity", + "system", +]; + +const PRESET_TOPICS: Record> = { + events: new Set(["events", "triggers"]), + reviews: new Set(["reviews"]), + classification: new Set(["tracked_object_update"]), + face_recognition: new Set(["tracked_object_update"]), + lpr: new Set(["tracked_object_update"]), + camera_activity: new Set(["camera_activity", "audio_detections"]), + system: new Set([ + "stats", + "model_state", + "job_state", + "embeddings_reindex_progress", + "audio_transcription_state", + "birdseye_layout", + ]), +}; + +// Maps tracked_object_update payload type to TopicCategory +const TRACKED_UPDATE_TYPE_MAP: Record = { + classification: "classification", + face: "face_recognition", + lpr: "lpr", +}; + +// camera_activity preset also matches topics with camera prefix patterns +const CAMERA_ACTIVITY_TOPIC_PATTERNS = [ + "/motion", + "/audio", + "/detect", + "/recordings", + "/enabled", + "/snapshots", + "/ptz", +]; + +function matchesCategories( + msg: WsFeedMessage, + categories: TopicCategory[] | undefined, +): boolean { + // undefined means all topics + if (!categories) return true; + + const { topic, payload } = msg; + + // Handle tracked_object_update with payload-based sub-categories + if (topic === "tracked_object_update") { + // payload might be a JSON string or a parsed object + let data: unknown = payload; + if (typeof data === "string") { + try { + data = JSON.parse(data); + } catch { + // not valid JSON, fall through + } + } + + const updateType = + data && typeof data === "object" && "type" in data + ? (data as { type: string }).type + : undefined; + + if (updateType && updateType in TRACKED_UPDATE_TYPE_MAP) { + const mappedCategory = TRACKED_UPDATE_TYPE_MAP[updateType]; + return categories.includes(mappedCategory); + } + + // tracked_object_update with other types (e.g. "description") falls under "events" + return categories.includes("events"); + } + + for (const cat of categories) { + const topicSet = PRESET_TOPICS[cat]; + if (topicSet.has(topic)) return true; + + if (cat === "camera_activity") { + if ( + CAMERA_ACTIVITY_TOPIC_PATTERNS.some((pattern) => + topic.includes(pattern), + ) + ) { + return true; + } + } + } + + return false; +} + +type WsMessageFeedProps = { + maxSize?: number; + defaultCamera?: string; + lockedCamera?: string; + showCameraBadge?: boolean; +}; + +export default function WsMessageFeed({ + maxSize = 500, + defaultCamera, + lockedCamera, + showCameraBadge = true, +}: WsMessageFeedProps) { + const { t } = useTranslation(["views/system"]); + const [paused, setPaused] = useState(false); + // undefined = all topics + const [selectedTopics, setSelectedTopics] = useState< + TopicCategory[] | undefined + >(undefined); + // undefined = all cameras + const [selectedCameras, setSelectedCameras] = useState( + () => { + if (lockedCamera) return [lockedCamera]; + if (defaultCamera) return [defaultCamera]; + return undefined; + }, + ); + + const { messages, clear } = useWsMessageBuffer(maxSize, paused, { + cameraFilter: selectedCameras, + }); + + const { data: config } = useSWR("config", { + revalidateOnFocus: false, + }); + + const availableCameras = useMemo(() => { + if (!config?.cameras) return []; + return Object.keys(config.cameras) + .filter((name) => { + const cam = config.cameras[name]; + return !isReplayCamera(name) && cam.enabled_in_config; + }) + .sort(); + }, [config]); + + const filteredMessages = useMemo(() => { + return messages.filter((msg: WsFeedMessage) => { + if (!matchesCategories(msg, selectedTopics)) return false; + return true; + }); + }, [messages, selectedTopics]); + + // Auto-scroll logic + const scrollContainerRef = useRef(null); + const autoScrollRef = useRef(true); + + const handleScroll = useCallback(() => { + const el = scrollContainerRef.current; + if (!el) return; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40; + autoScrollRef.current = atBottom; + }, []); + + useEffect(() => { + const el = scrollContainerRef.current; + if (!el || !autoScrollRef.current) return; + el.scrollTop = el.scrollHeight; + }, [filteredMessages.length]); + + return ( +
+ {/* Toolbar */} +
+
+ + + {!lockedCamera && ( + + )} +
+ +
+ + {t("logs.websocket.count", { + count: filteredMessages.length, + })} + + +
+ + + +
+
+
+ + {/* Feed area */} +
+ {filteredMessages.length === 0 ? ( +
+ {t("logs.websocket.empty")} +
+ ) : ( + filteredMessages.map((msg: WsFeedMessage) => ( + + )) + )} +
+
+ ); +} + +// Topic Filter Button + +type TopicFilterButtonProps = { + selectedTopics: TopicCategory[] | undefined; + updateTopicFilter: (topics: TopicCategory[] | undefined) => void; +}; + +function TopicFilterButton({ + selectedTopics, + updateTopicFilter, +}: TopicFilterButtonProps) { + const { t } = useTranslation(["views/system"]); + const [open, setOpen] = useState(false); + const [currentTopics, setCurrentTopics] = useState< + TopicCategory[] | undefined + >(selectedTopics); + + useEffect(() => { + setCurrentTopics(selectedTopics); + }, [selectedTopics]); + + const isFiltered = selectedTopics !== undefined; + + const trigger = ( + + ); + + const content = ( + { + updateTopicFilter(currentTopics); + setOpen(false); + }} + onReset={() => { + setCurrentTopics(undefined); + updateTopicFilter(undefined); + }} + /> + ); + + if (isMobile) { + return ( + { + if (!open) setCurrentTopics(selectedTopics); + setOpen(open); + }} + > + {trigger} + + {content} + + + ); + } + + return ( + { + if (!open) setCurrentTopics(selectedTopics); + setOpen(open); + }} + > + {trigger} + {content} + + ); +} + +type TopicFilterContentProps = { + currentTopics: TopicCategory[] | undefined; + setCurrentTopics: (topics: TopicCategory[] | undefined) => void; + onApply: () => void; + onReset: () => void; +}; + +function TopicFilterContent({ + currentTopics, + setCurrentTopics, + onApply, + onReset, +}: TopicFilterContentProps) { + const { t } = useTranslation(["views/system", "common"]); + + return ( + <> +
+ { + if (isChecked) { + setCurrentTopics(undefined); + } + }} + /> + + {ALL_TOPIC_CATEGORIES.map((cat) => ( + { + if (isChecked) { + const updated = currentTopics ? [...currentTopics, cat] : [cat]; + setCurrentTopics(updated); + } else { + const updated = currentTopics + ? currentTopics.filter((c) => c !== cat) + : []; + if (updated.length === 0) { + setCurrentTopics(undefined); + } else { + setCurrentTopics(updated); + } + } + }} + /> + ))} +
+ +
+ + +
+ + ); +} + +// Camera Filter Button + +type WsCamerasFilterButtonProps = { + allCameras: string[]; + selectedCameras: string[] | undefined; + updateCameraFilter: (cameras: string[] | undefined) => void; +}; + +function WsCamerasFilterButton({ + allCameras, + selectedCameras, + updateCameraFilter, +}: WsCamerasFilterButtonProps) { + const { t } = useTranslation(["views/system", "common"]); + const [open, setOpen] = useState(false); + const [currentCameras, setCurrentCameras] = useState( + selectedCameras, + ); + + useEffect(() => { + setCurrentCameras(selectedCameras); + }, [selectedCameras]); + + const isFiltered = selectedCameras !== undefined; + + const trigger = ( + + ); + + const content = ( + { + updateCameraFilter(currentCameras); + setOpen(false); + }} + onReset={() => { + setCurrentCameras(undefined); + updateCameraFilter(undefined); + }} + /> + ); + + if (isMobile) { + return ( + { + if (!open) setCurrentCameras(selectedCameras); + setOpen(open); + }} + > + {trigger} + + {content} + + + ); + } + + return ( + { + if (!open) setCurrentCameras(selectedCameras); + setOpen(open); + }} + > + {trigger} + {content} + + ); +} + +type WsCamerasFilterContentProps = { + allCameras: string[]; + currentCameras: string[] | undefined; + setCurrentCameras: (cameras: string[] | undefined) => void; + onApply: () => void; + onReset: () => void; +}; + +function WsCamerasFilterContent({ + allCameras, + currentCameras, + setCurrentCameras, + onApply, + onReset, +}: WsCamerasFilterContentProps) { + const { t } = useTranslation(["views/system", "common"]); + + return ( + <> +
+ { + if (isChecked) { + setCurrentCameras(undefined); + } + }} + /> + + {allCameras.map((cam) => ( + { + if (isChecked) { + const updated = currentCameras ? [...currentCameras] : []; + if (!updated.includes(cam)) { + updated.push(cam); + } + setCurrentCameras(updated); + } else { + const updated = currentCameras ? [...currentCameras] : []; + if (updated.length > 1) { + updated.splice(updated.indexOf(cam), 1); + setCurrentCameras(updated); + } + } + }} + /> + ))} +
+ +
+ + +
+ + ); +} diff --git a/web/src/components/ws/WsMessageRow.tsx b/web/src/components/ws/WsMessageRow.tsx new file mode 100644 index 000000000..aa7c89522 --- /dev/null +++ b/web/src/components/ws/WsMessageRow.tsx @@ -0,0 +1,433 @@ +import { memo, useCallback, useState } from "react"; +import { WsFeedMessage } from "@/api/ws"; +import { cn } from "@/lib/utils"; +import { ChevronRight } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { extractCameraName } from "@/utils/wsUtil"; +import { getIconForLabel } from "@/utils/iconUtil"; +import { LuCheck, LuCopy } from "react-icons/lu"; + +type TopicCategory = "events" | "camera_activity" | "system" | "other"; + +const TOPIC_CATEGORY_COLORS: Record = { + events: "bg-blue-500/20 text-blue-700 dark:text-blue-300 border-blue-500/30", + camera_activity: + "bg-green-500/20 text-green-700 dark:text-green-300 border-green-500/30", + system: + "bg-purple-500/20 text-purple-700 dark:text-purple-300 border-purple-500/30", + other: "bg-gray-500/20 text-gray-700 dark:text-gray-300 border-gray-500/30", +}; + +const EVENT_TYPE_COLORS: Record = { + start: + "bg-green-500/20 text-green-700 dark:text-green-300 border-green-500/30", + update: "bg-cyan-500/20 text-cyan-700 dark:text-cyan-300 border-cyan-500/30", + end: "bg-red-500/20 text-red-700 dark:text-red-300 border-red-500/30", +}; + +const TRACKED_OBJECT_UPDATE_COLORS: Record = { + description: + "bg-amber-500/20 text-amber-700 dark:text-amber-300 border-amber-500/30", + face: "bg-pink-500/20 text-pink-700 dark:text-pink-300 border-pink-500/30", + lpr: "bg-yellow-500/20 text-yellow-700 dark:text-yellow-300 border-yellow-500/30", + classification: + "bg-violet-500/20 text-violet-700 dark:text-violet-300 border-violet-500/30", +}; + +function getEventTypeColor(eventType: string): string { + return ( + EVENT_TYPE_COLORS[eventType] || + "bg-orange-500/20 text-orange-700 dark:text-orange-300 border-orange-500/30" + ); +} + +function getTrackedObjectTypeColor(objectType: string): string { + return ( + TRACKED_OBJECT_UPDATE_COLORS[objectType] || + "bg-orange-500/20 text-orange-700 dark:text-orange-300 border-orange-500/30" + ); +} + +const EVENT_TOPICS = new Set([ + "events", + "reviews", + "tracked_object_update", + "triggers", +]); + +const SYSTEM_TOPICS = new Set([ + "stats", + "model_state", + "job_state", + "embeddings_reindex_progress", + "audio_transcription_state", + "birdseye_layout", +]); + +function getTopicCategory(topic: string): TopicCategory { + if (EVENT_TOPICS.has(topic)) return "events"; + if (SYSTEM_TOPICS.has(topic)) return "system"; + if ( + topic === "camera_activity" || + topic === "audio_detections" || + topic.includes("/motion") || + topic.includes("/audio") || + topic.includes("/detect") || + topic.includes("/recordings") || + topic.includes("/enabled") || + topic.includes("/snapshots") || + topic.includes("/ptz") + ) { + return "camera_activity"; + } + return "other"; +} + +function formatTimestamp(ts: number): string { + const d = new Date(ts); + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + const ss = String(d.getSeconds()).padStart(2, "0"); + const ms = String(d.getMilliseconds()).padStart(3, "0"); + return `${hh}:${mm}:${ss}.${ms}`; +} + +function getPayloadSummary( + topic: string, + payload: unknown, + hideType: boolean = false, +): string { + if (payload === null || payload === undefined) return ""; + + try { + const data = typeof payload === "string" ? JSON.parse(payload) : payload; + + if (typeof data === "object" && data !== null) { + // Topic-specific summary handlers + if (topic === "tracked_object_update") { + return getTrackedObjectUpdateSummary(data); + } + + if ("type" in data && "label" in (data.after || data)) { + const after = data.after || data; + const parts: string[] = []; + + if (!hideType) { + parts.push(`type: ${data.type}`); + } + parts.push(`label: ${after.label || "?"}`); + + // Add sub_label for events topic if present + if (topic === "events" && after.sub_label) { + parts.push(`sub_label: ${after.sub_label}`); + } + + return parts.join(", "); + } + if ("type" in data && "camera" in data) { + if (hideType) { + return `camera: ${data.camera}`; + } + return `type: ${data.type}, camera: ${data.camera}`; + } + const keys = Object.keys(data); + if (keys.length <= 3) { + return keys + .map((k) => { + const v = data[k]; + if (typeof v === "string" || typeof v === "number") { + return `${k}: ${v}`; + } + return k; + }) + .join(", "); + } + return `{${keys.length} keys}`; + } + + const str = String(data); + return str.length > 80 ? str.slice(0, 80) + "…" : str; + } catch { + const str = String(payload); + return str.length > 80 ? str.slice(0, 80) + "…" : str; + } +} + +function getTrackedObjectUpdateSummary(data: unknown): string { + if (typeof data !== "object" || data === null) return ""; + + const obj = data as Record; + const type = obj.type as string; + + switch (type) { + case "description": + return obj.description ? `${obj.description}` : "no description"; + + case "face": { + const name = obj.name as string | undefined; + return name || "unknown"; + } + + case "lpr": { + const name = obj.name as string | undefined; + const plate = obj.plate as string | undefined; + return name || plate || "unknown"; + } + + case "classification": { + const parts: string[] = []; + const model = obj.model as string | undefined; + const subLabel = obj.sub_label as string | undefined; + const attribute = obj.attribute as string | undefined; + + if (model) parts.push(`model: ${model}`); + if (subLabel) parts.push(`sub_label: ${subLabel}`); + if (attribute) parts.push(`attribute: ${attribute}`); + + return parts.length > 0 ? parts.join(", ") : "classification"; + } + + default: + return type || "unknown"; + } +} + +function extractTypeForBadge(payload: unknown): string | null { + if (payload === null || payload === undefined) return null; + + try { + const data = typeof payload === "string" ? JSON.parse(payload) : payload; + + if (typeof data === "object" && data !== null && "type" in data) { + return data.type as string; + } + } catch { + // ignore + } + return null; +} + +function shouldShowTypeBadge(type: string | null): boolean { + if (!type) return false; + return true; +} + +function shouldShowSummary(topic: string): boolean { + // Hide summary for reviews topic + return topic !== "reviews"; +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); +} + +function highlightJson(value: unknown): string { + // Try to auto-parse JSON strings + if (typeof value === "string") { + try { + const parsed = JSON.parse(value); + if (typeof parsed === "object" && parsed !== null) { + value = parsed; + } + } catch { + // not JSON + } + } + + const raw = JSON.stringify(value, null, 2) ?? String(value); + + // Single regex pass to colorize JSON tokens + return raw.replace( + /("(?:[^"\\]|\\.)*")\s*:|("(?:[^"\\]|\\.)*")|(true|false|null)|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g, + (match, key: string, str: string, keyword: string, num: string) => { + if (key) { + return `${escapeHtml(key)}:`; + } + if (str) { + const content = escapeHtml(str); + return `${content}`; + } + if (keyword) { + return `${keyword}`; + } + if (num) { + return `${num}`; + } + return match; + }, + ); +} + +function CopyJsonButton({ payload }: { payload: unknown }) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + const text = + typeof payload === "string" + ? payload + : JSON.stringify(payload, null, 2); + navigator.clipboard.writeText(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }, + [payload], + ); + + return ( + + ); +} + +type WsMessageRowProps = { + message: WsFeedMessage; + showCameraBadge?: boolean; +}; + +const WsMessageRow = memo(function WsMessageRow({ + message, + showCameraBadge = true, +}: WsMessageRowProps) { + const { t } = useTranslation(["views/system"]); + const [expanded, setExpanded] = useState(false); + const category = getTopicCategory(message.topic); + + const cameraName = extractCameraName(message); + + const messageType = extractTypeForBadge(message.payload); + const showTypeBadge = shouldShowTypeBadge(messageType); + + const summary = getPayloadSummary(message.topic, message.payload); + + const eventLabel = (() => { + try { + const data = + typeof message.payload === "string" + ? JSON.parse(message.payload) + : message.payload; + if (typeof data === "object" && data !== null) { + return (data.after?.label as string) || (data.label as string) || null; + } + } catch { + // ignore + } + return null; + })(); + + const parsedPayload = (() => { + try { + return typeof message.payload === "string" + ? JSON.parse(message.payload) + : message.payload; + } catch { + return message.payload; + } + })(); + + const handleToggle = useCallback(() => { + setExpanded((prev) => !prev); + }, []); + + // Determine which color function to use based on topic + const getTypeBadgeColor = (type: string | null) => { + if (!type) return ""; + if (message.topic === "tracked_object_update") { + return getTrackedObjectTypeColor(type); + } + return getEventTypeColor(type); + }; + + return ( +
+
+ + + + {formatTimestamp(message.timestamp)} + + + + {message.topic} + + + {showTypeBadge && messageType && ( + + {messageType} + + )} + + {showCameraBadge && cameraName && ( + + {cameraName} + + )} + + {eventLabel && ( + + {getIconForLabel( + eventLabel, + "object", + "size-3.5 text-primary-variant", + )} + + )} + + {shouldShowSummary(message.topic) && ( + + {summary} + + )} +
+ + {expanded && ( +
+
+ + {t("logs.websocket.expanded.payload")} + + +
+
+        
+ )} +
+ ); +}); + +export default WsMessageRow; diff --git a/web/src/hooks/use-allowed-cameras.ts b/web/src/hooks/use-allowed-cameras.ts index 05941922a..add52fed7 100644 --- a/web/src/hooks/use-allowed-cameras.ts +++ b/web/src/hooks/use-allowed-cameras.ts @@ -2,6 +2,7 @@ import { useContext } from "react"; import { AuthContext } from "@/context/auth-context"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; +import { isReplayCamera } from "@/utils/cameraUtil"; export function useAllowedCameras() { const { auth } = useContext(AuthContext); @@ -14,9 +15,11 @@ export function useAllowedCameras() { auth.user?.role === "admin" || !auth.isAuthenticated // anonymous internal port ) { - // return all cameras - return config?.cameras ? Object.keys(config.cameras) : []; + // return all cameras, excluding replay cameras + return config?.cameras + ? Object.keys(config.cameras).filter((name) => !isReplayCamera(name)) + : []; } - return auth.allowedCameras || []; + return (auth.allowedCameras || []).filter((name) => !isReplayCamera(name)); } diff --git a/web/src/hooks/use-camera-activity.ts b/web/src/hooks/use-camera-activity.ts index 328811a9d..cf5bf4653 100644 --- a/web/src/hooks/use-camera-activity.ts +++ b/web/src/hooks/use-camera-activity.ts @@ -26,7 +26,7 @@ type useCameraActivityReturn = { }; export function useCameraActivity( - camera: CameraConfig, + camera: CameraConfig | undefined, revalidateOnFocus: boolean = true, ): useCameraActivityReturn { const { data: config } = useSWR("config", { @@ -47,7 +47,7 @@ export function useCameraActivity( // init camera activity const { payload: updatedCameraState } = useInitialCameraState( - camera.name, + camera?.name ?? "", revalidateOnFocus, ); useEffect(() => { @@ -60,7 +60,7 @@ export function useCameraActivity( const memoizedAudioState = useDeepMemo(updatedAudioState); useEffect(() => { - if (memoizedAudioState) { + if (memoizedAudioState && camera?.name) { setAudioDetections(memoizedAudioState[camera.name]); } }, [memoizedAudioState, camera]); @@ -72,8 +72,8 @@ export function useCameraActivity( [objects], ); - const { payload: cameraEnabled } = useEnabledState(camera.name); - const { payload: detectingMotion } = useMotionActivity(camera.name); + const { payload: cameraEnabled } = useEnabledState(camera?.name ?? ""); + const { payload: detectingMotion } = useMotionActivity(camera?.name ?? ""); const { payload: event } = useFrigateEvents(); const updatedEvent = useDeepMemo(event); @@ -91,7 +91,7 @@ export function useCameraActivity( return; } - if (updatedEvent.after.camera !== camera.name) { + if (!camera?.name || updatedEvent.after.camera !== camera.name) { return; } @@ -158,6 +158,10 @@ export function useCameraActivity( return false; } + if (!camera?.name) { + return false; + } + return ( cameras[camera.name]?.camera_fps == 0 && stats["service"].uptime > 60 ); diff --git a/web/src/hooks/use-stats.ts b/web/src/hooks/use-stats.ts index 040dd7b1a..5bddb75ac 100644 --- a/web/src/hooks/use-stats.ts +++ b/web/src/hooks/use-stats.ts @@ -9,6 +9,7 @@ import { useMemo } from "react"; import useSWR from "swr"; import useDeepMemo from "./use-deep-memo"; import { capitalizeAll, capitalizeFirstLetter } from "@/utils/stringUtil"; +import { isReplayCamera } from "@/utils/cameraUtil"; import { useFrigateStats } from "@/api/ws"; import { useTranslation } from "react-i18next"; @@ -16,6 +17,9 @@ import { useTranslation } from "react-i18next"; export default function useStats(stats: FrigateStats | undefined) { const { t } = useTranslation(["views/system"]); const { data: config } = useSWR("config"); + const { data: debugReplayStatus } = useSWR("debug_replay/status", { + revalidateOnFocus: false, + }); const memoizedStats = useDeepMemo(stats); @@ -74,6 +78,11 @@ export default function useStats(stats: FrigateStats | undefined) { return; } + // Skip replay cameras + if (isReplayCamera(name)) { + return; + } + const cameraName = config.cameras?.[name]?.friendly_name ?? name; if (config.cameras[name].enabled && cam["camera_fps"] == 0) { problems.push({ @@ -96,7 +105,15 @@ export default function useStats(stats: FrigateStats | undefined) { ); const cameraName = config?.cameras?.[name]?.friendly_name ?? name; - if (!isNaN(ffmpegAvg) && ffmpegAvg >= CameraFfmpegThreshold.error) { + + // Skip ffmpeg warnings for replay cameras when debug replay is active + if ( + !isNaN(ffmpegAvg) && + ffmpegAvg >= CameraFfmpegThreshold.error && + !( + debugReplayStatus?.active && debugReplayStatus?.replay_camera === name + ) + ) { problems.push({ text: t("stats.ffmpegHighCpuUsage", { camera: capitalizeFirstLetter(capitalizeAll(cameraName)), @@ -119,8 +136,19 @@ export default function useStats(stats: FrigateStats | undefined) { } }); + // Add message if debug replay is active + if (debugReplayStatus?.active) { + problems.push({ + text: t("stats.debugReplayActive", { + defaultValue: "Debug replay session is active", + }), + color: "text-selected", + relevantLink: "/replay", + }); + } + return problems; - }, [config, memoizedStats, t]); + }, [config, memoizedStats, t, debugReplayStatus]); return { potentialProblems }; } diff --git a/web/src/hooks/use-ws-message-buffer.ts b/web/src/hooks/use-ws-message-buffer.ts new file mode 100644 index 000000000..6f7f06662 --- /dev/null +++ b/web/src/hooks/use-ws-message-buffer.ts @@ -0,0 +1,99 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useWsMessageSubscribe, WsFeedMessage } from "@/api/ws"; +import { extractCameraName } from "@/utils/wsUtil"; + +type UseWsMessageBufferReturn = { + messages: WsFeedMessage[]; + clear: () => void; +}; + +type MessageFilter = { + cameraFilter?: string | string[]; // "all", specific camera name, or array of camera names (undefined in array = all) +}; + +export function useWsMessageBuffer( + maxSize: number = 2000, + paused: boolean = false, + filter?: MessageFilter, +): UseWsMessageBufferReturn { + const bufferRef = useRef([]); + const [version, setVersion] = useState(0); + const pausedRef = useRef(paused); + const filterRef = useRef(filter); + + pausedRef.current = paused; + filterRef.current = filter; + + const batchTimerRef = useRef | null>(null); + const dirtyRef = useRef(false); + + useEffect(() => { + batchTimerRef.current = setInterval(() => { + if (dirtyRef.current) { + dirtyRef.current = false; + setVersion((v) => v + 1); + } + }, 200); + + return () => { + if (batchTimerRef.current) { + clearInterval(batchTimerRef.current); + } + }; + }, []); + + const shouldIncludeMessage = useCallback((msg: WsFeedMessage): boolean => { + const currentFilter = filterRef.current; + if (!currentFilter) return true; + + // Check camera filter + const cf = currentFilter.cameraFilter; + if (cf !== undefined) { + if (Array.isArray(cf)) { + // Array of cameras: include messages matching any camera in the list + const msgCamera = extractCameraName(msg); + if (msgCamera && !cf.includes(msgCamera)) { + return false; + } + } else if (cf !== "all") { + // Single string camera filter + const msgCamera = extractCameraName(msg); + if (msgCamera !== cf) { + return false; + } + } + } + + return true; + }, []); + + useWsMessageSubscribe( + useCallback( + (msg: WsFeedMessage) => { + if (pausedRef.current) return; + if (!shouldIncludeMessage(msg)) return; + + const buf = bufferRef.current; + buf.push(msg); + if (buf.length > maxSize) { + buf.splice(0, buf.length - maxSize); + } + dirtyRef.current = true; + }, + [shouldIncludeMessage, maxSize], + ), + ); + + const clear = useCallback(() => { + bufferRef.current = []; + setVersion((v) => v + 1); + }, []); + + // version is used to trigger re-renders; we spread the buffer + // into a new array so that downstream useMemo dependencies + // see a new reference and recompute. + // eslint-disable-next-line react-hooks/exhaustive-deps + const messages = useMemo(() => [...bufferRef.current], [version]); + + return { messages, clear }; +} diff --git a/web/src/pages/Exports.tsx b/web/src/pages/Exports.tsx index 5b05439c6..a1b5953c6 100644 --- a/web/src/pages/Exports.tsx +++ b/web/src/pages/Exports.tsx @@ -642,4 +642,4 @@ function CaseAssignmentDialog({ ); } -export default Exports; \ No newline at end of file +export default Exports; diff --git a/web/src/pages/Logs.tsx b/web/src/pages/Logs.tsx index 03b52acc9..b01b4c712 100644 --- a/web/src/pages/Logs.tsx +++ b/web/src/pages/Logs.tsx @@ -35,10 +35,12 @@ import { isIOS, isMobile } from "react-device-detect"; import { isPWA } from "@/utils/isPWA"; import { isInIframe } from "@/utils/isIFrame"; import { useTranslation } from "react-i18next"; +import WsMessageFeed from "@/components/ws/WsMessageFeed"; function Logs() { const { t } = useTranslation(["views/system"]); const [logService, setLogService] = useState("frigate"); + const isWebsocket = logService === "websocket"; const tabsRef = useRef(null); const lazyLogWrapperRef = useRef(null); const [logs, setLogs] = useState([]); @@ -216,6 +218,12 @@ function Logs() { }, [logService, filterSeverity, t]); useEffect(() => { + if (isWebsocket) { + setIsLoading(false); + setLogs([]); + return; + } + setIsLoading(true); setLogs([]); lastFetchedIndexRef.current = -1; @@ -494,116 +502,128 @@ function Logs() { data-nav-item={item} aria-label={`Select ${item}`} > -
{item}
+
+ {item === "websocket" ? t("logs.websocket.label") : item} +
))}
-
- - - -
-
- -
-
-
-
-
- {t("logs.type.label")} + {!isWebsocket && ( +
+ + + +
+ )} +
+ + {isWebsocket ? ( +
+ +
+ ) : ( +
+
+
+
+
+ {t("logs.type.label")} +
+
{t("logs.type.timestamp")}
+
+
+
+ {t("logs.type.tag")} +
+
+
{t("logs.type.message")}
-
- {t("logs.type.tag")} -
-
-
{t("logs.type.message")}
-
-
-
- {isLoading ? ( - - ) : ( - ( - <> - {follow && !logSettings.disableStreaming && ( -
- - - - - {t("logs.tips")} - -
- )} - - } - loading={isLoading} - /> - - )} - /> - )} +
+ {isLoading ? ( + + ) : ( + ( + <> + {follow && !logSettings.disableStreaming && ( +
+ + + + + {t("logs.tips")} + +
+ )} + + } + loading={isLoading} + /> + + )} + /> + )} +
-
+ )}
); } diff --git a/web/src/pages/Replay.tsx b/web/src/pages/Replay.tsx new file mode 100644 index 000000000..187a9a76b --- /dev/null +++ b/web/src/pages/Replay.tsx @@ -0,0 +1,725 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { Trans, useTranslation } from "react-i18next"; +import useSWR from "swr"; +import axios from "axios"; +import { toast } from "sonner"; +import AutoUpdatingCameraImage from "@/components/camera/AutoUpdatingCameraImage"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { useCameraActivity } from "@/hooks/use-camera-activity"; +import { cn } from "@/lib/utils"; +import Heading from "@/components/ui/heading"; +import { Toaster } from "@/components/ui/sonner"; +import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; +import { getIconForLabel } from "@/utils/iconUtil"; +import { getTranslatedLabel } from "@/utils/i18n"; +import { ObjectType } from "@/types/ws"; +import WsMessageFeed from "@/components/ws/WsMessageFeed"; +import { ConfigSectionTemplate } from "@/components/config-form/sections/ConfigSectionTemplate"; + +import { LuExternalLink, LuInfo, LuSettings } from "react-icons/lu"; +import { LuSquare } from "react-icons/lu"; +import { MdReplay } from "react-icons/md"; +import { isDesktop, isMobile } from "react-device-detect"; +import Logo from "@/components/Logo"; +import { Separator } from "@/components/ui/separator"; +import { useDocDomain } from "@/hooks/use-doc-domain"; +import DebugDrawingLayer from "@/components/overlay/DebugDrawingLayer"; +import { IoMdArrowRoundBack } from "react-icons/io"; + +type DebugReplayStatus = { + active: boolean; + replay_camera: string | null; + source_camera: string | null; + start_time: number | null; + end_time: number | null; + live_ready: boolean; +}; + +type DebugOptions = { + bbox: boolean; + timestamp: boolean; + zones: boolean; + mask: boolean; + motion: boolean; + regions: boolean; + paths: boolean; +}; + +const DEFAULT_OPTIONS: DebugOptions = { + bbox: true, + timestamp: false, + zones: false, + mask: false, + motion: true, + regions: false, + paths: false, +}; + +const DEBUG_OPTION_KEYS: (keyof DebugOptions)[] = [ + "bbox", + "timestamp", + "zones", + "mask", + "motion", + "regions", + "paths", +]; + +const DEBUG_OPTION_I18N_KEY: Record = { + bbox: "boundingBoxes", + timestamp: "timestamp", + zones: "zones", + mask: "mask", + motion: "motion", + regions: "regions", + paths: "paths", +}; + +const REPLAY_INIT_SKELETON_TIMEOUT_MS = 8000; + +export default function Replay() { + const { t } = useTranslation(["views/replay", "views/settings", "common"]); + const navigate = useNavigate(); + const { getLocaleDocUrl } = useDocDomain(); + + const { + data: status, + mutate: refreshStatus, + isLoading, + } = useSWR("debug_replay/status", { + refreshInterval: 1000, + }); + const [isInitializing, setIsInitializing] = useState(true); + + // Refresh status immediately on mount to avoid showing "no session" briefly + useEffect(() => { + const initializeStatus = async () => { + await refreshStatus(); + setIsInitializing(false); + }; + initializeStatus(); + }, [refreshStatus]); + + useEffect(() => { + if (status?.live_ready) { + setShowReplayInitSkeleton(false); + } + }, [status?.live_ready]); + + const [options, setOptions] = useState(DEFAULT_OPTIONS); + const [isStopping, setIsStopping] = useState(false); + const [configDialogOpen, setConfigDialogOpen] = useState(false); + + const searchParams = useMemo(() => { + const params = new URLSearchParams(); + for (const key of DEBUG_OPTION_KEYS) { + params.set(key, options[key] ? "1" : "0"); + } + return params; + }, [options]); + + const handleSetOption = useCallback( + (key: keyof DebugOptions, value: boolean) => { + setOptions((prev) => ({ ...prev, [key]: value })); + }, + [], + ); + + const handleStop = useCallback(() => { + setIsStopping(true); + axios + .post("debug_replay/stop") + .then(() => { + toast.success(t("dialog.toast.stopped"), { + position: "top-center", + }); + refreshStatus(); + navigate("/review"); + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(t("dialog.toast.stopError", { error: errorMessage }), { + position: "top-center", + }); + }) + .finally(() => { + setIsStopping(false); + }); + }, [navigate, refreshStatus, t]); + + // Camera activity for the replay camera + const { data: config } = useSWR("config", { + revalidateOnFocus: false, + }); + const replayCameraName = status?.replay_camera ?? ""; + const replayCameraConfig = replayCameraName + ? config?.cameras?.[replayCameraName] + : undefined; + + const { objects } = useCameraActivity(replayCameraConfig); + + const [showReplayInitSkeleton, setShowReplayInitSkeleton] = useState(false); + + // debug draw + const containerRef = useRef(null); + const [debugDraw, setDebugDraw] = useState(false); + + useEffect(() => { + if (!status?.active || !status.replay_camera) { + setShowReplayInitSkeleton(false); + return; + } + + setShowReplayInitSkeleton(true); + + const timeout = window.setTimeout(() => { + setShowReplayInitSkeleton(false); + }, REPLAY_INIT_SKELETON_TIMEOUT_MS); + + return () => { + window.clearTimeout(timeout); + }; + }, [status?.active, status?.replay_camera]); + + useEffect(() => { + if (status?.live_ready) { + setShowReplayInitSkeleton(false); + } + }, [status?.live_ready]); + + // Format time range for display + const timeRangeDisplay = useMemo(() => { + if (!status?.start_time || !status?.end_time) return ""; + const start = new Date(status.start_time * 1000).toLocaleString(); + const end = new Date(status.end_time * 1000).toLocaleString(); + return `${start} — ${end}`; + }, [status]); + + // Show loading state + if (isInitializing || (isLoading && !status?.active)) { + return ( +
+ +
+ ); + } + + // No active session + if (!status?.active) { + return ( +
+ + + {t("page.noSession")} + +

+ {t("page.noSessionDesc")} +

+ +
+ ); + } + + return ( +
+ + + {/* Top bar */} +
+ {isMobile && ( + + )} + +
+ + + + + + + + + + {t("page.confirmStop.title")} + + + {t("page.confirmStop.description")} + + + + + {t("page.confirmStop.cancel")} + + + {t("page.confirmStop.confirm")} + + + + +
+
+ + {/* Main content */} +
+ {/* Camera feed */} +
+ {isStopping ? ( +
+
+ +
+ {t("page.stoppingReplay")} +
+
+
+ ) : ( + status.replay_camera && ( +
+ + {debugDraw && ( + + )} + {showReplayInitSkeleton && ( +
+ +
+ +
+ {t("page.initializingReplay")} +
+
+
+ )} +
+ ) + )} +
+ + {/* Side panel */} +
+
+ + {t("title")} + +
+ {status.source_camera} + {timeRangeDisplay && ( + <> + + {timeRangeDisplay} + + )} +
+
+

{t("description")}

+
+
+ + + + {t("debug.debugging", { ns: "views/settings" })} + + {t("page.objects")} + + {t("websocket_messages")} + + + +
+
+ {DEBUG_OPTION_KEYS.map((key) => { + const i18nKey = DEBUG_OPTION_I18N_KEY[key]; + return ( +
+
+
+ + {(key === "bbox" || + key === "motion" || + key === "regions" || + key === "paths") && ( + + +
+ + + {t("button.info", { ns: "common" })} + +
+
+ + {key === "bbox" ? ( + <> +

+ + {t( + "debug.boundingBoxes.colors.label", + { + ns: "views/settings", + }, + )} + +

+
    + + debug.boundingBoxes.colors.info + +
+ + ) : ( + + {`debug.${i18nKey}.tips`} + + )} +
+
+ )} +
+
+ {t(`debug.${i18nKey}.desc`, { + ns: "views/settings", + })} +
+
+ + handleSetOption(key, checked) + } + /> +
+ ); + })} + {isDesktop && ( + <> + +
+
+
+ + + + +
+ + + {t("button.info", { ns: "common" })} + +
+
+ + {t("debug.objectShapeFilterDrawing.tips", { + ns: "views/settings", + })} +
+ + {t("readTheDocumentation", { + ns: "common", + })} + + +
+
+
+
+
+ {t("debug.objectShapeFilterDrawing.desc", { + ns: "views/settings", + })} +
+
+ { + setDebugDraw(isChecked); + }} + /> +
+ + )} +
+
+
+ + + + +
+ +
+
+
+
+
+ + + + + {t("page.configuration")} + + {t("page.configurationDesc")} + + +
+ + +
+
+
+
+ ); +} + +type ObjectListProps = { + cameraConfig?: CameraConfig; + objects?: ObjectType[]; + config?: FrigateConfig; +}; + +function ObjectList({ cameraConfig, objects, config }: ObjectListProps) { + const { t } = useTranslation(["views/settings"]); + + const colormap = useMemo(() => { + if (!config) { + return; + } + return config.model?.colormap; + }, [config]); + + const getColorForObjectName = useCallback( + (objectName: string) => { + return colormap && colormap[objectName] + ? `rgb(${colormap[objectName][2]}, ${colormap[objectName][1]}, ${colormap[objectName][0]})` + : "rgb(128, 128, 128)"; + }, + [colormap], + ); + + if (!objects || objects.length === 0) { + return ( +
+ {t("debug.noObjects", { ns: "views/settings" })} +
+ ); + } + + return ( +
+ {objects.map((obj: ObjectType) => { + return ( +
+
+
+ {getIconForLabel(obj.label, "object", "size-4 text-white")} +
+
+ {getTranslatedLabel(obj.label)} +
+
+
+
+ + {t("debug.objectShapeFilterDrawing.score", { + ns: "views/settings", + })} + : + + + {obj.score ? (obj.score * 100).toFixed(1) : "-"}% + +
+ {obj.ratio && ( +
+ + {t("debug.objectShapeFilterDrawing.ratio", { + ns: "views/settings", + })} + : + + {obj.ratio.toFixed(2)} +
+ )} + {obj.area && cameraConfig && ( +
+ + {t("debug.objectShapeFilterDrawing.area", { + ns: "views/settings", + })} + : + + + {obj.area} px ( + {( + (obj.area / + (cameraConfig.detect.width * + cameraConfig.detect.height)) * + 100 + ).toFixed(2)} + %) + +
+ )} +
+
+ ); + })} +
+ ); +} diff --git a/web/src/types/log.ts b/web/src/types/log.ts index 2e856f574..bc0555e16 100644 --- a/web/src/types/log.ts +++ b/web/src/types/log.ts @@ -12,7 +12,7 @@ export type LogLine = { content: string; }; -export const logTypes = ["frigate", "go2rtc", "nginx"] as const; +export const logTypes = ["frigate", "go2rtc", "nginx", "websocket"] as const; export type LogType = (typeof logTypes)[number]; export type LogSettingsType = { diff --git a/web/src/utils/cameraUtil.ts b/web/src/utils/cameraUtil.ts index 543605ad0..c47c46f8f 100644 --- a/web/src/utils/cameraUtil.ts +++ b/web/src/utils/cameraUtil.ts @@ -148,3 +148,15 @@ export function detectCameraAudioFeatures( audioOutput: !!audioOutput, }; } + +const REPLAY_CAMERA_PREFIX = "_replay_"; + +/** + * Check if a camera name is a debug replay camera. + * + * @param name - The camera name to check + * @returns true if the camera is a replay camera + */ +export function isReplayCamera(name: string): boolean { + return name.startsWith(REPLAY_CAMERA_PREFIX); +} diff --git a/web/src/utils/configUtil.ts b/web/src/utils/configUtil.ts index c4c5afad1..9d2327cb3 100644 --- a/web/src/utils/configUtil.ts +++ b/web/src/utils/configUtil.ts @@ -514,13 +514,18 @@ const mergeSectionConfig = ( export function getSectionConfig( sectionKey: string, - level: "global" | "camera", + level: "global" | "camera" | "replay", ): SectionConfig { const entry = sectionConfigs[sectionKey]; if (!entry) { return {}; } - const overrides = level === "global" ? entry.global : entry.camera; + const overrides = + level === "global" + ? entry.global + : level === "replay" + ? entry.replay + : entry.camera; return mergeSectionConfig(entry.base, overrides); } diff --git a/web/src/utils/wsUtil.ts b/web/src/utils/wsUtil.ts new file mode 100644 index 000000000..411ff1645 --- /dev/null +++ b/web/src/utils/wsUtil.ts @@ -0,0 +1,53 @@ +import { WsFeedMessage } from "@/api/ws"; + +const EVENT_TOPICS = new Set([ + "events", + "reviews", + "tracked_object_update", + "triggers", +]); + +const SYSTEM_TOPICS = new Set([ + "stats", + "model_state", + "job_state", + "embeddings_reindex_progress", + "audio_transcription_state", + "birdseye_layout", +]); + +export function extractCameraName(message: WsFeedMessage): string | null { + // Try extracting from topic pattern: {camera}/motion, {camera}/audio/rms, etc. + const topicParts = message.topic.split("/"); + if ( + topicParts.length >= 2 && + !EVENT_TOPICS.has(message.topic) && + !SYSTEM_TOPICS.has(message.topic) && + message.topic !== "camera_activity" && + message.topic !== "audio_detections" && + message.topic !== "restart" && + message.topic !== "notification_test" + ) { + return topicParts[0]; + } + + // Try extracting from payload + try { + const data = + typeof message.payload === "string" + ? JSON.parse(message.payload) + : message.payload; + + if (typeof data === "object" && data !== null) { + if ("camera" in data) return data.camera as string; + if ("after" in data && data.after?.camera) + return data.after.camera as string; + if ("before" in data && data.before?.camera) + return data.before.camera as string; + } + } catch { + // ignore parse errors + } + + return null; +} diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index 75463b1fd..5758728dc 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -1,6 +1,8 @@ import ReviewCard from "@/components/card/ReviewCard"; import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup"; +import DebugReplayDialog from "@/components/overlay/DebugReplayDialog"; import ExportDialog from "@/components/overlay/ExportDialog"; +import ActionsDropdown from "@/components/overlay/ActionsDropdown"; import PreviewPlayer, { PreviewController, } from "@/components/player/PreviewPlayer"; @@ -199,6 +201,11 @@ export function RecordingView({ const [exportRange, setExportRange] = useState(); const [showExportPreview, setShowExportPreview] = useState(false); + // debug replay + + const [debugReplayMode, setDebugReplayMode] = useState("none"); + const [debugReplayRange, setDebugReplayRange] = useState(); + // move to next clip const onClipEnded = useCallback(() => { @@ -269,7 +276,7 @@ export function RecordingView({ ); useEffect(() => { - if (scrubbing || exportRange) { + if (scrubbing || exportRange || debugReplayRange) { if ( currentTime > currentTimeRange.before + 60 || currentTime < currentTimeRange.after - 60 @@ -591,6 +598,23 @@ export function RecordingView({ selected={mainCamera} onSelectCamera={onSelectCamera} /> + {isDesktop && ( + { + setDebugReplayRange(range); + + if (range != undefined) { + mainControllerRef.current?.pause(); + } + }} + setMode={setDebugReplayMode} + /> + )} {isDesktop && ( {}} /> )} + {isDesktop && ( + { + const now = new Date(timeRange.before * 1000); + now.setHours(now.getHours() - 1); + setDebugReplayRange({ + after: now.getTime() / 1000, + before: timeRange.before, + }); + setDebugReplayMode("select"); + }} + onExportClick={() => { + const now = new Date(timeRange.before * 1000); + now.setHours(now.getHours() - 1); + setExportRange({ + before: timeRange.before, + after: now.getTime() / 1000, + }); + setExportMode("select"); + }} + /> + )} {isDesktop ? ( { + setDebugReplayRange(range); + + if (range != undefined) { + mainControllerRef.current?.pause(); + } + }} onUpdateFilter={updateFilter} setRange={setExportRange} setMode={setExportMode} @@ -758,7 +814,9 @@ export function RecordingView({ timeRange={currentTimeRange} cameraPreviews={allPreviews ?? []} startTimestamp={playbackStart} - hotKeys={exportMode != "select"} + hotKeys={ + exportMode != "select" && debugReplayMode != "select" + } fullscreen={fullscreen} onTimestampUpdate={(timestamp) => { setPlayerTime(timestamp); @@ -772,7 +830,11 @@ export function RecordingView({ onControllerReady={(controller) => { mainControllerRef.current = controller; }} - isScrubbing={scrubbing || exportMode == "timeline"} + isScrubbing={ + scrubbing || + exportMode == "timeline" || + debugReplayMode == "timeline" + } supportsFullscreen={supportsFullScreen} setFullResolution={setFullResolution} toggleFullscreen={toggleFullscreen} @@ -840,18 +902,29 @@ export function RecordingView({ contentRef={contentRef} mainCamera={mainCamera} timelineType={ - (exportRange == undefined ? timelineType : "timeline") ?? - "timeline" + (exportRange == undefined && debugReplayRange == undefined + ? timelineType + : "timeline") ?? "timeline" } timeRange={timeRange} mainCameraReviewItems={mainCameraReviewItems} activeReviewItem={activeReviewItem} currentTime={currentTime} - exportRange={exportMode == "timeline" ? exportRange : undefined} + exportRange={ + exportMode == "timeline" + ? exportRange + : debugReplayMode == "timeline" + ? debugReplayRange + : undefined + } setCurrentTime={setCurrentTime} manuallySetCurrentTime={manuallySetCurrentTime} setScrubbing={setScrubbing} - setExportRange={setExportRange} + setExportRange={ + debugReplayMode == "timeline" + ? setDebugReplayRange + : setExportRange + } onAnalysisOpen={onAnalysisOpen} isPlaying={mainControllerRef?.current?.isPlaying() ?? false} />