mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-09 15:05:26 +03:00
Compare commits
No commits in common. "e7e6f87682b655f6bbf65fe9cb47a9bf6b46a71b" and "335229d0d415c0abbd06b7dd36f2dc77e5a284e7" have entirely different histories.
e7e6f87682
...
335229d0d4
@ -119,12 +119,6 @@ audio:
|
||||
|
||||
Frigate supports fully local audio transcription using either `sherpa-onnx` or OpenAI's open-source Whisper models via `faster-whisper`. The goal of this feature is to support Semantic Search for `speech` audio events. Frigate is not intended to act as a continuous, fully-automatic speech transcription service — automatically transcribing all speech (or queuing many audio events for transcription) requires substantial CPU (or GPU) resources and is impractical on most systems. For this reason, transcriptions for events are initiated manually from the UI or the API rather than being run continuously in the background.
|
||||
|
||||
:::info
|
||||
|
||||
Audio transcription requires a one-time internet connection to download the Whisper or Sherpa-ONNX model on first use. Once cached, transcription runs fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details.
|
||||
|
||||
:::
|
||||
|
||||
Transcription accuracy also depends heavily on the quality of your camera's microphone and recording conditions. Many cameras use inexpensive microphones, and distance to the speaker, low audio bitrate, or background noise can significantly reduce transcription quality. If you need higher accuracy, more robust long-running queues, or large-scale automatic transcription, consider using the HTTP API in combination with an automation platform and a cloud transcription service.
|
||||
|
||||
#### Configuration
|
||||
|
||||
@ -9,12 +9,6 @@ import NavPath from "@site/src/components/NavPath";
|
||||
|
||||
Bird classification identifies known birds using a quantized Tensorflow model. When a known bird is recognized, its common name will be added as a `sub_label`. This information is included in the UI, filters, as well as in notifications.
|
||||
|
||||
:::info
|
||||
|
||||
Bird classification requires a one-time internet connection to download the classification model and label map from GitHub. Once cached, models work fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details.
|
||||
|
||||
:::
|
||||
|
||||
## Minimum System Requirements
|
||||
|
||||
Bird classification runs a lightweight tflite model on the CPU, there are no significantly different system requirements than running Frigate itself.
|
||||
|
||||
@ -9,12 +9,6 @@ import NavPath from "@site/src/components/NavPath";
|
||||
|
||||
Object classification allows you to train a custom MobileNetV2 classification model to run on tracked objects (persons, cars, animals, etc.) to identify a finer category or attribute for that object. Classification results are visible in the Tracked Object Details pane in Explore, through the `frigate/tracked_object_details` MQTT topic, in Home Assistant sensors via the official Frigate integration, or through the event endpoints in the HTTP API.
|
||||
|
||||
:::info
|
||||
|
||||
Training a custom object classification model requires a one-time internet connection to download MobileNetV2 base weights. Once trained, the model runs fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details.
|
||||
|
||||
:::
|
||||
|
||||
## Minimum System Requirements
|
||||
|
||||
Object classification models are lightweight and run very fast on CPU.
|
||||
|
||||
@ -9,12 +9,6 @@ import NavPath from "@site/src/components/NavPath";
|
||||
|
||||
State classification allows you to train a custom MobileNetV2 classification model on a fixed region of your camera frame(s) to determine a current state. The model can be configured to run on a schedule and/or when motion is detected in that region. Classification results are available through the `frigate/<camera_name>/classification/<model_name>` MQTT topic and in Home Assistant sensors via the official Frigate integration.
|
||||
|
||||
:::info
|
||||
|
||||
Training a custom state classification model requires a one-time internet connection to download MobileNetV2 base weights. Once trained, the model runs fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details.
|
||||
|
||||
:::
|
||||
|
||||
## Minimum System Requirements
|
||||
|
||||
State classification models are lightweight and run very fast on CPU.
|
||||
|
||||
@ -9,12 +9,6 @@ import NavPath from "@site/src/components/NavPath";
|
||||
|
||||
Face recognition identifies known individuals by matching detected faces with previously learned facial data. When a known `person` is recognized, their name will be added as a `sub_label`. This information is included in the UI, filters, as well as in notifications.
|
||||
|
||||
:::info
|
||||
|
||||
Face recognition requires a one-time internet connection to download detection and embedding models from GitHub. Once cached, models work fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details.
|
||||
|
||||
:::
|
||||
|
||||
## Model Requirements
|
||||
|
||||
### Face Detection
|
||||
|
||||
@ -193,12 +193,6 @@ To use a different OpenAI-compatible API endpoint, set the `OPENAI_BASE_URL` env
|
||||
|
||||
Cloud providers run on remote infrastructure and require an API key for authentication. These services handle all model inference on their servers.
|
||||
|
||||
:::info
|
||||
|
||||
Cloud Generative AI providers require an active internet connection to send images and prompts for processing. Local providers like llama.cpp and Ollama (with local models) do not require internet. See [Network Requirements](/frigate/network_requirements#generative-ai) for details.
|
||||
|
||||
:::
|
||||
|
||||
### Ollama Cloud
|
||||
|
||||
Ollama also supports [cloud models](https://ollama.com/cloud), where your local Ollama instance handles requests from Frigate, but model inference is performed in the cloud. Set up Ollama locally, sign in with your Ollama account, and specify the cloud model name in your Frigate config. For more details, see the Ollama cloud model [docs](https://docs.ollama.com/cloud).
|
||||
|
||||
@ -11,12 +11,6 @@ Frigate can recognize license plates on vehicles and automatically add the detec
|
||||
|
||||
LPR works best when the license plate is clearly visible to the camera. For moving vehicles, Frigate continuously refines the recognition process, keeping the most confident result. When a vehicle becomes stationary, LPR continues to run for a short time after to attempt recognition.
|
||||
|
||||
:::info
|
||||
|
||||
License plate recognition requires a one-time internet connection to download OCR and detection models from GitHub. Once cached, models work fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details.
|
||||
|
||||
:::
|
||||
|
||||
When a plate is recognized, the details are:
|
||||
|
||||
- Added as a `sub_label` (if [known](#matching)) or the `recognized_license_plate` field (if unknown) to a tracked object.
|
||||
|
||||
@ -21,12 +21,6 @@ The jsmpeg live view will use more browser and client GPU resources. Using go2rt
|
||||
| mse | native | native | yes (depends on audio codec) | yes | iPhone requires iOS 17.1+, Firefox is h.264 only. This is Frigate's default when go2rtc is configured. |
|
||||
| webrtc | native | native | yes (depends on audio codec) | yes | Requires extra configuration. Frigate attempts to use WebRTC when MSE fails or when using a camera's two-way talk feature. |
|
||||
|
||||
:::info
|
||||
|
||||
WebRTC may use an external STUN server for NAT traversal. MSE and HLS streaming do not require any internet access. See [Network Requirements](/frigate/network_requirements#webrtc-stun) for details.
|
||||
|
||||
:::
|
||||
|
||||
### Camera Settings Recommendations
|
||||
|
||||
If you are using go2rtc, you should adjust the following settings in your camera's firmware for the best experience with Live view:
|
||||
|
||||
@ -11,12 +11,6 @@ import NavPath from "@site/src/components/NavPath";
|
||||
|
||||
Frigate offers native notifications using the [WebPush Protocol](https://web.dev/articles/push-notifications-web-push-protocol) which uses the [VAPID spec](https://tools.ietf.org/html/draft-thomson-webpush-vapid) to deliver notifications to web apps using encryption.
|
||||
|
||||
:::info
|
||||
|
||||
Push notifications require internet access from the Frigate server to the browser vendor's push service (e.g., Google FCM, Mozilla autopush). See [Network Requirements](/frigate/network_requirements#push-notifications) for details.
|
||||
|
||||
:::
|
||||
|
||||
## Setting up Notifications
|
||||
|
||||
In order to use notifications the following requirements must be met:
|
||||
|
||||
@ -288,12 +288,6 @@ This detector is available for use with both Hailo-8 and Hailo-8L AI Acceleratio
|
||||
|
||||
See the [installation docs](../frigate/installation.md#hailo-8) for information on configuring the Hailo hardware.
|
||||
|
||||
:::info
|
||||
|
||||
If no custom model is provided, the Hailo detector downloads a default model from the Hailo Model Zoo on first startup. Once cached, the model works fully offline. See [Network Requirements](/frigate/network_requirements#hardware-specific-detector-models) for details.
|
||||
|
||||
:::
|
||||
|
||||
### Configuration
|
||||
|
||||
When configuring the Hailo detector, you have two options to specify the model: a local **path** or a **URL**.
|
||||
@ -1799,12 +1793,6 @@ Hardware accelerated object detection is supported on the following SoCs:
|
||||
|
||||
This implementation uses the [Rockchip's RKNN-Toolkit2](https://github.com/airockchip/rknn-toolkit2/), version v2.3.2.
|
||||
|
||||
:::info
|
||||
|
||||
If no custom model is provided, the RKNN detector downloads a default model from GitHub on first startup. Once cached, the model works fully offline. See [Network Requirements](/frigate/network_requirements#hardware-specific-detector-models) for details.
|
||||
|
||||
:::
|
||||
|
||||
:::tip
|
||||
|
||||
When using many cameras one detector may not be enough to keep up. Multiple detectors can be defined assuming NPU resources are available. An example configuration would be:
|
||||
@ -2188,12 +2176,6 @@ This implementation uses the [AXera Pulsar2 Toolchain](https://huggingface.co/AX
|
||||
|
||||
See the [installation docs](../frigate/installation.md#axera) for information on configuring the AXEngine hardware.
|
||||
|
||||
:::info
|
||||
|
||||
The AXEngine detector downloads its default model from HuggingFace on first startup. Once cached, the model works fully offline. See [Network Requirements](/frigate/network_requirements#hardware-specific-detector-models) for details.
|
||||
|
||||
:::
|
||||
|
||||
### Configuration
|
||||
|
||||
When configuring the AXEngine detector, you have to specify the model name.
|
||||
|
||||
@ -13,12 +13,6 @@ Frigate uses models from [Jina AI](https://huggingface.co/jinaai) to create and
|
||||
|
||||
Semantic Search is accessed via the _Explore_ view in the Frigate UI.
|
||||
|
||||
:::info
|
||||
|
||||
Semantic search requires a one-time internet connection to download embedding models from HuggingFace. Once cached, models work fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details.
|
||||
|
||||
:::
|
||||
|
||||
## Minimum System Requirements
|
||||
|
||||
Semantic Search works by running a large AI model locally on your system. Small or underpowered systems like a Raspberry Pi will not run Semantic Search reliably or at all.
|
||||
|
||||
@ -1,155 +0,0 @@
|
||||
---
|
||||
id: network_requirements
|
||||
title: Network Requirements
|
||||
---
|
||||
|
||||
# Network Requirements
|
||||
|
||||
Frigate is designed to run locally and does not require a persistent internet connection for core functionality. However, certain features need internet access for initial setup or ongoing operation. This page describes what connects to the internet, when, and how to control it.
|
||||
|
||||
## How Frigate Uses the Internet
|
||||
|
||||
Frigate's internet usage falls into three categories:
|
||||
|
||||
1. **One-time model downloads** — ML models are downloaded the first time a feature is enabled, then cached locally. No internet is needed on subsequent startups.
|
||||
2. **Optional cloud services** — Features like Frigate+ and Generative AI connect to external APIs only when explicitly configured.
|
||||
3. **Build-time dependencies** — Components bundled into the Docker image during the build process. These require no internet at runtime.
|
||||
|
||||
:::tip
|
||||
|
||||
After initial setup, Frigate can run fully offline as long as all required models have been downloaded and no cloud-dependent features are enabled.
|
||||
|
||||
:::
|
||||
|
||||
## One-Time Model Downloads
|
||||
|
||||
The following models are downloaded automatically the first time their associated feature is enabled. Once cached in `/config/model_cache/`, they do not require internet again.
|
||||
|
||||
| Feature | Models Downloaded | Source |
|
||||
| --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -------------------- |
|
||||
| [Semantic search](/configuration/semantic_search) | Jina CLIP v1 or v2 (ONNX) + tokenizer | HuggingFace |
|
||||
| [Face recognition](/configuration/face_recognition) | FaceNet, ArcFace, face detection model | GitHub |
|
||||
| [License plate recognition](/configuration/license_plate_recognition) | PaddleOCR (detection, classification, recognition) + YOLOv9 plate detector | GitHub |
|
||||
| [Bird classification](/configuration/bird_classification) | MobileNetV2 bird model + label map | GitHub |
|
||||
| [Custom classification](/configuration/custom_classification/state_classification) (training) | MobileNetV2 ImageNet base weights (via Keras) | Google storage |
|
||||
| [Audio transcription](/configuration/advanced) | Whisper or Sherpa-ONNX streaming model | HuggingFace / OpenAI |
|
||||
|
||||
### Hardware-Specific Detector Models
|
||||
|
||||
If you are using one of the following hardware detectors and have not provided your own model file, a default model will be downloaded on first startup:
|
||||
|
||||
| Detector | Model Downloaded | Source |
|
||||
| ------------------------------------------------------------------ | -------------------- | ------------------------ |
|
||||
| [Rockchip RKNN](/configuration/object_detectors#rockchip-platform) | RKNN detection model | GitHub |
|
||||
| [Hailo 8 / 8L](/configuration/object_detectors#hailo-8) | YOLOv6n (.hef) | Hailo Model Zoo (AWS S3) |
|
||||
| [AXERA AXEngine](/configuration/object_detectors) | Detection model | HuggingFace |
|
||||
|
||||
:::note
|
||||
|
||||
The default CPU, EdgeTPU, and OpenVINO object detection models are bundled into the Docker image and do not require any download at runtime.
|
||||
|
||||
:::
|
||||
|
||||
### Preventing Model Downloads
|
||||
|
||||
If you have already downloaded all required models and want to prevent Frigate from attempting any outbound connections to HuggingFace or the Transformers library, set the following environment variables on your Frigate container:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
HF_HUB_OFFLINE: "1"
|
||||
TRANSFORMERS_OFFLINE: "1"
|
||||
```
|
||||
|
||||
:::warning
|
||||
|
||||
Setting these variables without having the correct model files already cached in `/config/model_cache/` will cause failures. Only use these after a successful initial setup with internet access.
|
||||
|
||||
:::
|
||||
|
||||
### Mirror Support
|
||||
|
||||
If your Frigate instance has restricted internet access, you can point model downloads at internal mirrors using environment variables:
|
||||
|
||||
| Environment Variable | Default | Used By |
|
||||
| ----------------------------------- | ----------------------------------- | --------------------------------------------- |
|
||||
| `HF_ENDPOINT` | `https://huggingface.co` | Semantic search, Sherpa-ONNX, AXEngine models |
|
||||
| `GITHUB_ENDPOINT` | `https://github.com` | Face recognition, LPR, RKNN models |
|
||||
| `GITHUB_RAW_ENDPOINT` | `https://raw.githubusercontent.com` | Bird classification |
|
||||
| `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` | Google storage (Keras default) | Custom classification training |
|
||||
|
||||
## Optional Cloud Services
|
||||
|
||||
These features connect to external services during normal operation and require internet whenever they are active.
|
||||
|
||||
### Frigate+
|
||||
|
||||
When a Frigate+ API key is configured, Frigate communicates with `https://api.frigate.video` to download models, upload snapshots for training, submit annotations, and report false positives. Remove the API key to disable all Frigate+ network activity.
|
||||
|
||||
See [Frigate+](/integrations/plus) for details.
|
||||
|
||||
### Generative AI
|
||||
|
||||
When a Generative AI provider is configured, Frigate sends images and prompts to the configured provider for event descriptions, chat, and camera monitoring. Available providers:
|
||||
|
||||
| Provider | Internet Required |
|
||||
| ------------- | ---------------------------------------------------------------- |
|
||||
| OpenAI | Yes — connects to OpenAI API (or custom base URL) |
|
||||
| Google Gemini | Yes — connects to Google Generative AI API |
|
||||
| Azure OpenAI | Yes — connects to your Azure endpoint |
|
||||
| Ollama | Depends — typically local (`localhost:11434`), but can be remote |
|
||||
| llama.cpp | No — runs entirely locally |
|
||||
|
||||
Disable Generative AI by removing the `genai` configuration from your cameras. See [Generative AI](/configuration/genai/genai_config) for details.
|
||||
|
||||
### Version Check
|
||||
|
||||
Frigate checks GitHub for the latest release version on startup by querying `https://api.github.com`. This can be disabled:
|
||||
|
||||
```yaml
|
||||
telemetry:
|
||||
version_check: false
|
||||
```
|
||||
|
||||
### Push Notifications
|
||||
|
||||
When [notifications](/configuration/notifications) are enabled and users have registered for push notifications in the web UI, Frigate sends push messages through the browser vendor's push service (e.g., Google FCM, Mozilla autopush). This requires internet access from the Frigate server to these push endpoints.
|
||||
|
||||
### MQTT
|
||||
|
||||
If an [MQTT broker](/integrations/mqtt) is configured, Frigate maintains a connection to the broker's host and port. This is typically a local network connection, but will require internet if you use a cloud-hosted MQTT broker.
|
||||
|
||||
### DeepStack / CodeProject.AI
|
||||
|
||||
When using the [DeepStack detector plugin](/configuration/object_detectors), Frigate sends images to the configured API endpoint for inference. This is typically local but depends on where the service is hosted.
|
||||
|
||||
## WebRTC (STUN)
|
||||
|
||||
For [WebRTC live streaming](/configuration/live), Frigate uses STUN for NAT traversal:
|
||||
|
||||
- **go2rtc** defaults to a local STUN listener (`stun:8555`) — no internet required.
|
||||
- **The web UI's WebRTC player** includes a fallback to Google's public STUN server (`stun:stun.l.google.com:19302`), which requires internet.
|
||||
|
||||
## Home Assistant Supervisor
|
||||
|
||||
When running as a Home Assistant add-on, the go2rtc startup script queries the local Supervisor API (`http://supervisor/`) to discover the host IP address and WebRTC port. This is a local network call to the Home Assistant host, not an internet connection.
|
||||
|
||||
## What Does NOT Require Internet
|
||||
|
||||
- **Object detection** — CPU, EdgeTPU, OpenVINO, and other bundled detector models are included in the Docker image.
|
||||
- **Recording and playback** — All video is stored and served locally.
|
||||
- **Live streaming** — Camera streams are pulled over your local network. MSE and HLS streaming work without any external connections.
|
||||
- **The web interface** — Fully self-contained with no external fonts, scripts, analytics, or CDN dependencies. All translations are bundled locally.
|
||||
- **Custom classification inference** — After training, custom models run entirely locally.
|
||||
- **Audio detection** — The YAMNet audio classification model is bundled in the Docker image.
|
||||
|
||||
## Running Frigate Offline
|
||||
|
||||
To run Frigate in an air-gapped or offline environment:
|
||||
|
||||
1. **Pre-download models** — Start Frigate with internet access once with all desired features enabled. Models will be cached in `/config/model_cache/`.
|
||||
2. **Disable version check** — Set `telemetry.version_check: false` in your configuration.
|
||||
3. **Block outbound model requests** — Set the `HF_HUB_OFFLINE=1` and `TRANSFORMERS_OFFLINE=1` environment variables to prevent HuggingFace and Transformers from attempting any network requests.
|
||||
4. **Avoid cloud features** — Do not configure Frigate+, Generative AI providers that require internet, or cloud MQTT brokers.
|
||||
5. **Use local model mirrors** — If limited internet is available, set the `HF_ENDPOINT`, `GITHUB_ENDPOINT`, and `GITHUB_RAW_ENDPOINT` environment variables to point to local mirrors.
|
||||
|
||||
After these steps, Frigate will operate with no outbound internet connections.
|
||||
@ -5,12 +5,6 @@ title: MQTT
|
||||
|
||||
These are the MQTT messages generated by Frigate. The default topic_prefix is `frigate`, but can be changed in the config file.
|
||||
|
||||
:::info
|
||||
|
||||
MQTT requires a network connection to your broker. This is typically local, but will require internet if using a cloud-hosted MQTT broker. See [Network Requirements](/frigate/network_requirements#mqtt) for details.
|
||||
|
||||
:::
|
||||
|
||||
## General Frigate Topics
|
||||
|
||||
### `frigate/available`
|
||||
|
||||
@ -5,12 +5,6 @@ title: Frigate+
|
||||
|
||||
For more information about how to use Frigate+ to improve your model, see the [Frigate+ docs](/plus/).
|
||||
|
||||
:::info
|
||||
|
||||
Frigate+ requires an active internet connection to communicate with `https://api.frigate.video` for model downloads, image uploads, and annotations. See [Network Requirements](/frigate/network_requirements#frigate) for details.
|
||||
|
||||
:::
|
||||
|
||||
## Setup
|
||||
|
||||
### Create an account
|
||||
|
||||
@ -12,7 +12,6 @@ const sidebars: SidebarsConfig = {
|
||||
"frigate/updating",
|
||||
"frigate/camera_setup",
|
||||
"frigate/video_pipeline",
|
||||
"frigate/network_requirements",
|
||||
"frigate/glossary",
|
||||
],
|
||||
Guides: [
|
||||
|
||||
362
docs/static/frigate-api.yaml
vendored
362
docs/static/frigate-api.yaml
vendored
@ -2724,135 +2724,6 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HTTPValidationError"
|
||||
/exports/batch:
|
||||
post:
|
||||
tags:
|
||||
- Export
|
||||
summary: Start recording export batch
|
||||
description: >-
|
||||
Starts recording exports for a batch of items, each with its own camera
|
||||
and time range. Optionally assigns them to a new or existing export case.
|
||||
When neither export_case_id nor new_case_name is provided, exports are
|
||||
added as uncategorized. Attaching to an existing case is admin-only.
|
||||
operationId: export_recordings_batch_exports_batch_post
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BatchExportBody"
|
||||
responses:
|
||||
"202":
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BatchExportResponse"
|
||||
"400":
|
||||
description: Bad Request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GenericResponse"
|
||||
"403":
|
||||
description: Forbidden
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GenericResponse"
|
||||
"404":
|
||||
description: Not Found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GenericResponse"
|
||||
"503":
|
||||
description: Service Unavailable
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GenericResponse"
|
||||
"422":
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HTTPValidationError"
|
||||
/exports/delete:
|
||||
post:
|
||||
tags:
|
||||
- Export
|
||||
summary: Bulk delete exports
|
||||
description: >-
|
||||
Deletes one or more exports by ID. All IDs must exist and none can be
|
||||
in-progress. Admin-only.
|
||||
operationId: bulk_delete_exports_exports_delete_post
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ExportBulkDeleteBody"
|
||||
responses:
|
||||
"200":
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GenericResponse"
|
||||
"400":
|
||||
description: Bad Request - one or more exports are in-progress
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GenericResponse"
|
||||
"404":
|
||||
description: Not Found - one or more export IDs do not exist
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GenericResponse"
|
||||
"422":
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HTTPValidationError"
|
||||
/exports/reassign:
|
||||
post:
|
||||
tags:
|
||||
- Export
|
||||
summary: Bulk reassign exports to a case
|
||||
description: >-
|
||||
Assigns or unassigns one or more exports to/from a case. All IDs must
|
||||
exist. Pass export_case_id as null to unassign (move to uncategorized).
|
||||
Admin-only.
|
||||
operationId: bulk_reassign_exports_exports_reassign_post
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ExportBulkReassignBody"
|
||||
responses:
|
||||
"200":
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GenericResponse"
|
||||
"404":
|
||||
description: Not Found - one or more export IDs or the target case do not exist
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GenericResponse"
|
||||
"422":
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HTTPValidationError"
|
||||
/cases:
|
||||
get:
|
||||
tags:
|
||||
@ -2982,6 +2853,39 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HTTPValidationError"
|
||||
"/export/{export_id}/case":
|
||||
patch:
|
||||
tags:
|
||||
- Export
|
||||
summary: Assign export to case
|
||||
description: "Assigns an export to a case, or unassigns it if export_case_id is null."
|
||||
operationId: assign_export_case_export__export_id__case_patch
|
||||
parameters:
|
||||
- name: export_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
title: Export Id
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ExportCaseAssignBody"
|
||||
responses:
|
||||
"200":
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GenericResponse"
|
||||
"422":
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HTTPValidationError"
|
||||
"/export/{camera_name}/start/{start_time}/end/{end_time}":
|
||||
post:
|
||||
tags:
|
||||
@ -3069,6 +2973,32 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HTTPValidationError"
|
||||
"/export/{event_id}":
|
||||
delete:
|
||||
tags:
|
||||
- Export
|
||||
summary: Delete export
|
||||
operationId: export_delete_export__event_id__delete
|
||||
parameters:
|
||||
- name: event_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
title: Event Id
|
||||
responses:
|
||||
"200":
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GenericResponse"
|
||||
"422":
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HTTPValidationError"
|
||||
"/export/custom/{camera_name}/start/{start_time}/end/{end_time}":
|
||||
post:
|
||||
tags:
|
||||
@ -6571,149 +6501,6 @@ components:
|
||||
required:
|
||||
- recognizedLicensePlate
|
||||
title: EventsLPRBody
|
||||
BatchExportBody:
|
||||
properties:
|
||||
items:
|
||||
items:
|
||||
$ref: "#/components/schemas/BatchExportItem"
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 50
|
||||
title: Items
|
||||
description: List of export items. Each item has its own camera and time range.
|
||||
export_case_id:
|
||||
anyOf:
|
||||
- type: string
|
||||
maxLength: 30
|
||||
- type: "null"
|
||||
title: Export case ID
|
||||
description: Existing export case ID to assign all exports to. Attaching to an existing case is temporarily admin-only until case-level ACLs exist.
|
||||
new_case_name:
|
||||
anyOf:
|
||||
- type: string
|
||||
maxLength: 100
|
||||
- type: "null"
|
||||
title: New case name
|
||||
description: Name of a new export case to create when export_case_id is omitted
|
||||
new_case_description:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
title: New case description
|
||||
description: Optional description for a newly created export case
|
||||
type: object
|
||||
required:
|
||||
- items
|
||||
title: BatchExportBody
|
||||
BatchExportItem:
|
||||
properties:
|
||||
camera:
|
||||
type: string
|
||||
title: Camera name
|
||||
start_time:
|
||||
type: number
|
||||
title: Start time
|
||||
end_time:
|
||||
type: number
|
||||
title: End time
|
||||
image_path:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
title: Existing thumbnail path
|
||||
description: Optional existing image to use as the export thumbnail
|
||||
friendly_name:
|
||||
anyOf:
|
||||
- type: string
|
||||
maxLength: 256
|
||||
- type: "null"
|
||||
title: Friendly name
|
||||
description: Optional friendly name for this specific export item
|
||||
client_item_id:
|
||||
anyOf:
|
||||
- type: string
|
||||
maxLength: 128
|
||||
- type: "null"
|
||||
title: Client item ID
|
||||
description: Optional opaque client identifier echoed back in results
|
||||
type: object
|
||||
required:
|
||||
- camera
|
||||
- start_time
|
||||
- end_time
|
||||
title: BatchExportItem
|
||||
BatchExportResponse:
|
||||
properties:
|
||||
export_case_id:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
title: Export Case Id
|
||||
description: Export case ID associated with the batch
|
||||
export_ids:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
title: Export Ids
|
||||
description: Export IDs successfully queued
|
||||
results:
|
||||
items:
|
||||
$ref: "#/components/schemas/BatchExportResultModel"
|
||||
type: array
|
||||
title: Results
|
||||
description: Per-item batch export results
|
||||
type: object
|
||||
required:
|
||||
- export_ids
|
||||
- results
|
||||
title: BatchExportResponse
|
||||
description: Response model for starting an export batch.
|
||||
BatchExportResultModel:
|
||||
properties:
|
||||
camera:
|
||||
type: string
|
||||
title: Camera
|
||||
description: Camera name for this export attempt
|
||||
export_id:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
title: Export Id
|
||||
description: The export ID when the export was successfully queued
|
||||
success:
|
||||
type: boolean
|
||||
title: Success
|
||||
description: Whether the export was successfully queued
|
||||
status:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
title: Status
|
||||
description: Queue status for this camera export
|
||||
error:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
title: Error
|
||||
description: Validation or queueing error for this item, if any
|
||||
item_index:
|
||||
anyOf:
|
||||
- type: integer
|
||||
- type: "null"
|
||||
title: Item Index
|
||||
description: Zero-based index of this result within the request items list
|
||||
client_item_id:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
title: Client Item Id
|
||||
description: Opaque client-supplied item identifier echoed from the request
|
||||
type: object
|
||||
required:
|
||||
- camera
|
||||
- success
|
||||
title: BatchExportResultModel
|
||||
description: Per-item result for a batch export request.
|
||||
EventsSubLabelBody:
|
||||
properties:
|
||||
subLabel:
|
||||
@ -6736,41 +6523,18 @@ components:
|
||||
required:
|
||||
- subLabel
|
||||
title: EventsSubLabelBody
|
||||
ExportBulkDeleteBody:
|
||||
ExportCaseAssignBody:
|
||||
properties:
|
||||
ids:
|
||||
items:
|
||||
type: string
|
||||
minLength: 1
|
||||
type: array
|
||||
minItems: 1
|
||||
title: Ids
|
||||
type: object
|
||||
required:
|
||||
- ids
|
||||
title: ExportBulkDeleteBody
|
||||
description: Request body for bulk deleting exports.
|
||||
ExportBulkReassignBody:
|
||||
properties:
|
||||
ids:
|
||||
items:
|
||||
type: string
|
||||
minLength: 1
|
||||
type: array
|
||||
minItems: 1
|
||||
title: Ids
|
||||
export_case_id:
|
||||
anyOf:
|
||||
- type: string
|
||||
maxLength: 30
|
||||
- type: "null"
|
||||
title: Export Case Id
|
||||
description: "Case ID to assign to, or null to unassign from current case"
|
||||
description: "Case ID to assign to the export, or null to unassign"
|
||||
type: object
|
||||
required:
|
||||
- ids
|
||||
title: ExportBulkReassignBody
|
||||
description: Request body for bulk reassigning exports to a case.
|
||||
title: ExportCaseAssignBody
|
||||
description: Request body for assigning or unassigning an export to a case.
|
||||
ExportCaseCreateBody:
|
||||
properties:
|
||||
name:
|
||||
|
||||
@ -88,9 +88,7 @@ def require_admin_by_default():
|
||||
"/go2rtc/streams",
|
||||
"/event_ids",
|
||||
"/events",
|
||||
"/cases",
|
||||
"/exports",
|
||||
"/jobs/export",
|
||||
}
|
||||
|
||||
# Path prefixes that should be exempt (for paths with parameters)
|
||||
@ -103,9 +101,7 @@ def require_admin_by_default():
|
||||
"/go2rtc/streams/", # /go2rtc/streams/{camera}
|
||||
"/users/", # /users/{username}/password (has own auth)
|
||||
"/preview/", # /preview/{file}/thumbnail.jpg
|
||||
"/cases/", # /cases/{case_id}
|
||||
"/exports/", # /exports/{export_id}
|
||||
"/jobs/export/", # /jobs/export/{export_id}
|
||||
"/vod/", # /vod/{camera_name}/...
|
||||
"/notifications/", # /notifications/pubkey, /notifications/register
|
||||
)
|
||||
|
||||
@ -1,65 +0,0 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
MAX_BATCH_EXPORT_ITEMS = 50
|
||||
|
||||
|
||||
class BatchExportItem(BaseModel):
|
||||
camera: str = Field(title="Camera name")
|
||||
start_time: float = Field(title="Start time")
|
||||
end_time: float = Field(title="End time")
|
||||
image_path: Optional[str] = Field(
|
||||
default=None,
|
||||
title="Existing thumbnail path",
|
||||
description="Optional existing image to use as the export thumbnail",
|
||||
)
|
||||
friendly_name: Optional[str] = Field(
|
||||
default=None,
|
||||
title="Friendly name",
|
||||
max_length=256,
|
||||
description="Optional friendly name for this specific export item",
|
||||
)
|
||||
client_item_id: Optional[str] = Field(
|
||||
default=None,
|
||||
title="Client item ID",
|
||||
max_length=128,
|
||||
description="Optional opaque client identifier echoed back in results",
|
||||
)
|
||||
|
||||
|
||||
class BatchExportBody(BaseModel):
|
||||
items: List[BatchExportItem] = Field(
|
||||
title="Items",
|
||||
min_length=1,
|
||||
max_length=MAX_BATCH_EXPORT_ITEMS,
|
||||
description="List of export items. Each item has its own camera and time range.",
|
||||
)
|
||||
export_case_id: Optional[str] = Field(
|
||||
default=None,
|
||||
title="Export case ID",
|
||||
max_length=30,
|
||||
description=(
|
||||
"Existing export case ID to assign all exports to. Attaching to an "
|
||||
"existing case is temporarily admin-only until case-level ACLs exist."
|
||||
),
|
||||
)
|
||||
new_case_name: Optional[str] = Field(
|
||||
default=None,
|
||||
title="New case name",
|
||||
max_length=100,
|
||||
description="Name of a new export case to create when export_case_id is omitted",
|
||||
)
|
||||
new_case_description: Optional[str] = Field(
|
||||
default=None,
|
||||
title="New case description",
|
||||
description="Optional description for a newly created export case",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_case_target(self) -> "BatchExportBody":
|
||||
for item in self.items:
|
||||
if item.end_time <= item.start_time:
|
||||
raise ValueError("end_time must be after start_time")
|
||||
|
||||
return self
|
||||
@ -1,24 +0,0 @@
|
||||
"""Request bodies for bulk export operations."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field, conlist, constr
|
||||
|
||||
|
||||
class ExportBulkDeleteBody(BaseModel):
|
||||
"""Request body for bulk deleting exports."""
|
||||
|
||||
# List of export IDs with at least one element and each element with at least one char
|
||||
ids: conlist(constr(min_length=1), min_length=1)
|
||||
|
||||
|
||||
class ExportBulkReassignBody(BaseModel):
|
||||
"""Request body for bulk reassigning exports to a case."""
|
||||
|
||||
# List of export IDs with at least one element and each element with at least one char
|
||||
ids: conlist(constr(min_length=1), min_length=1)
|
||||
export_case_id: Optional[str] = Field(
|
||||
default=None,
|
||||
max_length=30,
|
||||
description="Case ID to assign to, or null to unassign from current case",
|
||||
)
|
||||
@ -23,3 +23,13 @@ class ExportCaseUpdateBody(BaseModel):
|
||||
description: Optional[str] = Field(
|
||||
default=None, description="Updated description of the export case"
|
||||
)
|
||||
|
||||
|
||||
class ExportCaseAssignBody(BaseModel):
|
||||
"""Request body for assigning or unassigning an export to a case."""
|
||||
|
||||
export_case_id: Optional[str] = Field(
|
||||
default=None,
|
||||
max_length=30,
|
||||
description="Case ID to assign to the export, or null to unassign",
|
||||
)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from typing import Any, List, Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@ -28,88 +28,6 @@ class StartExportResponse(BaseModel):
|
||||
export_id: Optional[str] = Field(
|
||||
default=None, description="The export ID if successfully started"
|
||||
)
|
||||
status: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Queue status for the export job",
|
||||
)
|
||||
|
||||
|
||||
class BatchExportResultModel(BaseModel):
|
||||
"""Per-item result for a batch export request."""
|
||||
|
||||
camera: str = Field(description="Camera name for this export attempt")
|
||||
export_id: Optional[str] = Field(
|
||||
default=None,
|
||||
description="The export ID when the export was successfully queued",
|
||||
)
|
||||
success: bool = Field(description="Whether the export was successfully queued")
|
||||
status: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Queue status for this camera export",
|
||||
)
|
||||
error: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Validation or queueing error for this item, if any",
|
||||
)
|
||||
item_index: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Zero-based index of this result within the request items list",
|
||||
)
|
||||
client_item_id: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Opaque client-supplied item identifier echoed from the request",
|
||||
)
|
||||
|
||||
|
||||
class BatchExportResponse(BaseModel):
|
||||
"""Response model for starting an export batch."""
|
||||
|
||||
export_case_id: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Export case ID associated with the batch",
|
||||
)
|
||||
export_ids: List[str] = Field(description="Export IDs successfully queued")
|
||||
results: List[BatchExportResultModel] = Field(
|
||||
description="Per-item batch export results"
|
||||
)
|
||||
|
||||
|
||||
class ExportJobModel(BaseModel):
|
||||
"""Model representing a queued or running export job."""
|
||||
|
||||
id: str = Field(description="Unique identifier for the export job")
|
||||
job_type: str = Field(description="Job type")
|
||||
status: str = Field(description="Current job status")
|
||||
camera: str = Field(description="Camera associated with this export job")
|
||||
name: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Friendly name for the export",
|
||||
)
|
||||
export_case_id: Optional[str] = Field(
|
||||
default=None,
|
||||
description="ID of the export case this export belongs to",
|
||||
)
|
||||
request_start_time: float = Field(description="Requested export start time")
|
||||
request_end_time: float = Field(description="Requested export end time")
|
||||
start_time: Optional[float] = Field(
|
||||
default=None,
|
||||
description="Unix timestamp when execution started",
|
||||
)
|
||||
end_time: Optional[float] = Field(
|
||||
default=None,
|
||||
description="Unix timestamp when execution completed",
|
||||
)
|
||||
error_message: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Error message for failed jobs",
|
||||
)
|
||||
results: Optional[dict[str, Any]] = Field(
|
||||
default=None,
|
||||
description="Result metadata for completed jobs",
|
||||
)
|
||||
|
||||
|
||||
ExportJobsResponse = List[ExportJobModel]
|
||||
|
||||
|
||||
ExportsResponse = List[ExportModel]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -52,7 +52,6 @@ from frigate.embeddings import EmbeddingProcess, EmbeddingsContext
|
||||
from frigate.events.audio import AudioProcessor
|
||||
from frigate.events.cleanup import EventCleanup
|
||||
from frigate.events.maintainer import EventProcessor
|
||||
from frigate.jobs.export import reap_stale_exports
|
||||
from frigate.jobs.motion_search import stop_all_motion_search_jobs
|
||||
from frigate.log import _stop_logging
|
||||
from frigate.models import (
|
||||
@ -612,11 +611,6 @@ class FrigateApp:
|
||||
# Clean up any stale replay camera artifacts (filesystem + DB)
|
||||
cleanup_replay_cameras()
|
||||
|
||||
# Reap any Export rows still marked in_progress from a previous
|
||||
# session (crash, kill, broken migration). Runs synchronously before
|
||||
# uvicorn binds so no API request can observe a stale row.
|
||||
reap_stale_exports()
|
||||
|
||||
self.init_inter_process_communicator()
|
||||
self.start_detectors()
|
||||
self.init_dispatcher()
|
||||
|
||||
@ -92,12 +92,6 @@ class RecordExportConfig(FrigateBaseModel):
|
||||
title="Export hwaccel args",
|
||||
description="Hardware acceleration args to use for export/transcode operations.",
|
||||
)
|
||||
max_concurrent: int = Field(
|
||||
default=3,
|
||||
ge=1,
|
||||
title="Maximum concurrent exports",
|
||||
description="Maximum number of export jobs to process at the same time.",
|
||||
)
|
||||
|
||||
|
||||
class RecordConfig(FrigateBaseModel):
|
||||
|
||||
@ -1,387 +0,0 @@
|
||||
"""Export job management with queued background execution."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from queue import Full, Queue
|
||||
from typing import Any, Optional
|
||||
|
||||
from peewee import DoesNotExist
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.jobs.job import Job
|
||||
from frigate.models import Export
|
||||
from frigate.record.export import PlaybackSourceEnum, RecordingExporter
|
||||
from frigate.types import JobStatusTypesEnum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Maximum number of jobs that can sit in the queue waiting to run.
|
||||
# Prevents a runaway client from unbounded memory growth.
|
||||
MAX_QUEUED_EXPORT_JOBS = 100
|
||||
|
||||
|
||||
class ExportQueueFullError(RuntimeError):
|
||||
"""Raised when the export queue is at capacity."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExportJob(Job):
|
||||
"""Job state for export operations."""
|
||||
|
||||
job_type: str = "export"
|
||||
camera: str = ""
|
||||
name: Optional[str] = None
|
||||
image_path: Optional[str] = None
|
||||
export_case_id: Optional[str] = None
|
||||
request_start_time: float = 0.0
|
||||
request_end_time: float = 0.0
|
||||
playback_source: str = PlaybackSourceEnum.recordings.value
|
||||
ffmpeg_input_args: Optional[str] = None
|
||||
ffmpeg_output_args: Optional[str] = None
|
||||
cpu_fallback: bool = False
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary for API responses.
|
||||
|
||||
Only exposes fields that are part of the public ExportJobModel schema.
|
||||
Internal execution details (image_path, ffmpeg args, cpu_fallback) are
|
||||
intentionally omitted so they don't leak through the API.
|
||||
"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"job_type": self.job_type,
|
||||
"status": self.status,
|
||||
"camera": self.camera,
|
||||
"name": self.name,
|
||||
"export_case_id": self.export_case_id,
|
||||
"request_start_time": self.request_start_time,
|
||||
"request_end_time": self.request_end_time,
|
||||
"start_time": self.start_time,
|
||||
"end_time": self.end_time,
|
||||
"error_message": self.error_message,
|
||||
"results": self.results,
|
||||
}
|
||||
|
||||
|
||||
class ExportQueueWorker(threading.Thread):
|
||||
"""Worker that executes queued exports."""
|
||||
|
||||
def __init__(self, manager: "ExportJobManager", worker_index: int) -> None:
|
||||
super().__init__(
|
||||
daemon=True,
|
||||
name=f"export_queue_worker_{worker_index}",
|
||||
)
|
||||
self.manager = manager
|
||||
|
||||
def run(self) -> None:
|
||||
while True:
|
||||
job = self.manager.queue.get()
|
||||
|
||||
try:
|
||||
self.manager.run_job(job)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Export queue worker failed while processing %s", job.id
|
||||
)
|
||||
finally:
|
||||
self.manager.queue.task_done()
|
||||
|
||||
|
||||
class ExportJobManager:
|
||||
"""Concurrency-limited manager for queued export jobs."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: FrigateConfig,
|
||||
max_concurrent: int,
|
||||
max_queued: int = MAX_QUEUED_EXPORT_JOBS,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.max_concurrent = max(1, max_concurrent)
|
||||
self.queue: Queue[ExportJob] = Queue(maxsize=max(1, max_queued))
|
||||
self.jobs: dict[str, ExportJob] = {}
|
||||
self.lock = threading.Lock()
|
||||
self.workers: list[ExportQueueWorker] = []
|
||||
self.started = False
|
||||
|
||||
def ensure_started(self) -> None:
|
||||
"""Ensure worker threads are started exactly once."""
|
||||
with self.lock:
|
||||
if self.started:
|
||||
self._restart_dead_workers_locked()
|
||||
return
|
||||
|
||||
for index in range(self.max_concurrent):
|
||||
worker = ExportQueueWorker(self, index)
|
||||
worker.start()
|
||||
self.workers.append(worker)
|
||||
|
||||
self.started = True
|
||||
|
||||
def _restart_dead_workers_locked(self) -> None:
|
||||
for index, worker in enumerate(self.workers):
|
||||
if worker.is_alive():
|
||||
continue
|
||||
|
||||
logger.error(
|
||||
"Export queue worker %s died unexpectedly, restarting", worker.name
|
||||
)
|
||||
replacement = ExportQueueWorker(self, index)
|
||||
replacement.start()
|
||||
self.workers[index] = replacement
|
||||
|
||||
def enqueue(self, job: ExportJob) -> str:
|
||||
"""Queue a job for background execution.
|
||||
|
||||
Raises ExportQueueFullError if the queue is at capacity.
|
||||
"""
|
||||
self.ensure_started()
|
||||
|
||||
try:
|
||||
self.queue.put_nowait(job)
|
||||
except Full as err:
|
||||
raise ExportQueueFullError(
|
||||
"Export queue is full; try again once current exports finish"
|
||||
) from err
|
||||
|
||||
with self.lock:
|
||||
self.jobs[job.id] = job
|
||||
|
||||
return job.id
|
||||
|
||||
def get_job(self, job_id: str) -> Optional[ExportJob]:
|
||||
"""Get a job by ID."""
|
||||
with self.lock:
|
||||
return self.jobs.get(job_id)
|
||||
|
||||
def list_active_jobs(self) -> list[ExportJob]:
|
||||
"""List queued and running jobs."""
|
||||
with self.lock:
|
||||
return [
|
||||
job
|
||||
for job in self.jobs.values()
|
||||
if job.status in (JobStatusTypesEnum.queued, JobStatusTypesEnum.running)
|
||||
]
|
||||
|
||||
def cancel_queued_jobs_for_case(self, case_id: str) -> list[ExportJob]:
|
||||
"""Cancel queued export jobs assigned to a deleted case."""
|
||||
cancelled_jobs: list[ExportJob] = []
|
||||
|
||||
with self.lock:
|
||||
with self.queue.mutex:
|
||||
retained_jobs: list[ExportJob] = []
|
||||
|
||||
while self.queue.queue:
|
||||
job = self.queue.queue.popleft()
|
||||
|
||||
if (
|
||||
job.export_case_id == case_id
|
||||
and job.status == JobStatusTypesEnum.queued
|
||||
):
|
||||
job.status = JobStatusTypesEnum.cancelled
|
||||
job.end_time = time.time()
|
||||
cancelled_jobs.append(job)
|
||||
continue
|
||||
|
||||
retained_jobs.append(job)
|
||||
|
||||
self.queue.queue.extend(retained_jobs)
|
||||
|
||||
if cancelled_jobs:
|
||||
self.queue.unfinished_tasks = max(
|
||||
0,
|
||||
self.queue.unfinished_tasks - len(cancelled_jobs),
|
||||
)
|
||||
if self.queue.unfinished_tasks == 0:
|
||||
self.queue.all_tasks_done.notify_all()
|
||||
self.queue.not_full.notify_all()
|
||||
|
||||
return cancelled_jobs
|
||||
|
||||
def available_slots(self) -> int:
|
||||
"""Approximate number of additional jobs that could be queued right now.
|
||||
|
||||
Uses Queue.qsize() which is best-effort; callers should treat the
|
||||
result as advisory since another thread could enqueue between
|
||||
checking and enqueueing.
|
||||
"""
|
||||
return max(0, self.queue.maxsize - self.queue.qsize())
|
||||
|
||||
def run_job(self, job: ExportJob) -> None:
|
||||
"""Execute a queued export job."""
|
||||
job.status = JobStatusTypesEnum.running
|
||||
job.start_time = time.time()
|
||||
|
||||
exporter = RecordingExporter(
|
||||
self.config,
|
||||
job.id,
|
||||
job.camera,
|
||||
job.name,
|
||||
job.image_path,
|
||||
int(job.request_start_time),
|
||||
int(job.request_end_time),
|
||||
PlaybackSourceEnum(job.playback_source),
|
||||
job.export_case_id,
|
||||
job.ffmpeg_input_args,
|
||||
job.ffmpeg_output_args,
|
||||
job.cpu_fallback,
|
||||
)
|
||||
|
||||
try:
|
||||
exporter.run()
|
||||
export = Export.get_or_none(Export.id == job.id)
|
||||
if export is None:
|
||||
job.status = JobStatusTypesEnum.failed
|
||||
job.error_message = "Export failed"
|
||||
elif export.in_progress:
|
||||
job.status = JobStatusTypesEnum.failed
|
||||
job.error_message = "Export did not complete"
|
||||
else:
|
||||
job.status = JobStatusTypesEnum.success
|
||||
job.results = {
|
||||
"export_id": export.id,
|
||||
"export_case_id": export.export_case_id,
|
||||
"video_path": export.video_path,
|
||||
"thumb_path": export.thumb_path,
|
||||
}
|
||||
except DoesNotExist:
|
||||
job.status = JobStatusTypesEnum.failed
|
||||
job.error_message = "Export not found"
|
||||
except Exception as err:
|
||||
logger.exception("Export job %s failed: %s", job.id, err)
|
||||
job.status = JobStatusTypesEnum.failed
|
||||
job.error_message = str(err)
|
||||
finally:
|
||||
job.end_time = time.time()
|
||||
|
||||
|
||||
_job_manager: Optional[ExportJobManager] = None
|
||||
_job_manager_lock = threading.Lock()
|
||||
|
||||
|
||||
def _get_max_concurrent(config: FrigateConfig) -> int:
|
||||
return int(config.record.export.max_concurrent)
|
||||
|
||||
|
||||
def reap_stale_exports() -> None:
|
||||
"""Sweep Export rows stuck with in_progress=True from previous sessions.
|
||||
|
||||
On Frigate startup no export job is alive yet, so any in_progress=True
|
||||
row must be a leftover from a previous session that crashed, was killed
|
||||
mid-export, or returned early from RecordingExporter.run() without
|
||||
flipping the flag. For each stale row we either:
|
||||
|
||||
- delete the row (and any thumb) if the video file is missing or empty,
|
||||
since there is nothing worth recovering
|
||||
- flip in_progress to False if the video file exists on disk and is
|
||||
non-empty, treating it as a completed export the user can manage
|
||||
through the normal UI
|
||||
|
||||
Must only be called when the export job manager is certain to have no
|
||||
active jobs — i.e., at Frigate startup, before any worker runs.
|
||||
|
||||
All exceptions are caught and logged; the caller does not need to wrap
|
||||
this in a try/except. A failure on a single row will not stop the rest
|
||||
of the sweep, and a failure in the top-level query will log and return.
|
||||
"""
|
||||
try:
|
||||
stale_exports = list(Export.select().where(Export.in_progress == True)) # noqa: E712
|
||||
except Exception:
|
||||
logger.exception("Failed to query stale in-progress exports")
|
||||
return
|
||||
|
||||
if not stale_exports:
|
||||
logger.debug("No stale in-progress exports found on startup")
|
||||
return
|
||||
|
||||
flipped = 0
|
||||
deleted = 0
|
||||
errored = 0
|
||||
|
||||
for export in stale_exports:
|
||||
try:
|
||||
video_path = export.video_path
|
||||
has_usable_file = False
|
||||
|
||||
if video_path:
|
||||
try:
|
||||
has_usable_file = os.path.getsize(video_path) > 0
|
||||
except OSError:
|
||||
has_usable_file = False
|
||||
|
||||
if has_usable_file:
|
||||
# Unassign from any case on recovery: the user should
|
||||
# re-triage a recovered export rather than have it silently
|
||||
# reappear inside a case they curated.
|
||||
Export.update(
|
||||
{Export.in_progress: False, Export.export_case: None}
|
||||
).where(Export.id == export.id).execute()
|
||||
flipped += 1
|
||||
logger.info(
|
||||
"Recovered stale in-progress export %s (file intact on disk)",
|
||||
export.id,
|
||||
)
|
||||
continue
|
||||
|
||||
if export.thumb_path:
|
||||
Path(export.thumb_path).unlink(missing_ok=True)
|
||||
if video_path:
|
||||
Path(video_path).unlink(missing_ok=True)
|
||||
Export.delete().where(Export.id == export.id).execute()
|
||||
deleted += 1
|
||||
logger.info(
|
||||
"Deleted stale in-progress export %s (no usable file on disk)",
|
||||
export.id,
|
||||
)
|
||||
except Exception:
|
||||
errored += 1
|
||||
logger.exception("Failed to reap stale export %s", export.id)
|
||||
|
||||
logger.info(
|
||||
"Stale export cleanup complete: %d recovered, %d deleted, %d errored",
|
||||
flipped,
|
||||
deleted,
|
||||
errored,
|
||||
)
|
||||
|
||||
|
||||
def get_export_job_manager(config: FrigateConfig) -> ExportJobManager:
|
||||
"""Get or create the singleton export job manager."""
|
||||
global _job_manager
|
||||
|
||||
with _job_manager_lock:
|
||||
if _job_manager is None:
|
||||
_job_manager = ExportJobManager(config, _get_max_concurrent(config))
|
||||
_job_manager.ensure_started()
|
||||
return _job_manager
|
||||
|
||||
|
||||
def start_export_job(config: FrigateConfig, job: ExportJob) -> str:
|
||||
"""Queue an export job and return its ID."""
|
||||
return get_export_job_manager(config).enqueue(job)
|
||||
|
||||
|
||||
def get_export_job(config: FrigateConfig, job_id: str) -> Optional[ExportJob]:
|
||||
"""Get a queued or completed export job by ID."""
|
||||
return get_export_job_manager(config).get_job(job_id)
|
||||
|
||||
|
||||
def list_active_export_jobs(config: FrigateConfig) -> list[ExportJob]:
|
||||
"""List queued and running export jobs."""
|
||||
return get_export_job_manager(config).list_active_jobs()
|
||||
|
||||
|
||||
def cancel_queued_export_jobs_for_case(
|
||||
config: FrigateConfig, case_id: str
|
||||
) -> list[ExportJob]:
|
||||
"""Cancel queued export jobs that still point at a deleted case."""
|
||||
return get_export_job_manager(config).cancel_queued_jobs_for_case(case_id)
|
||||
|
||||
|
||||
def available_export_queue_slots(config: FrigateConfig) -> int:
|
||||
"""Approximate number of additional export jobs that could be queued now."""
|
||||
return get_export_job_manager(config).available_slots()
|
||||
File diff suppressed because it is too large
Load Diff
@ -82,26 +82,14 @@ export class ApiMocker {
|
||||
route.fulfill({ json: stats }),
|
||||
);
|
||||
|
||||
// Reviews. The real backend exposes /review (singular) for the main
|
||||
// list and /review/summary for the summary — the previous plural glob
|
||||
// (**/api/reviews**) never matched either endpoint, so review-dependent
|
||||
// tests silently ran without data. The POST mutations at /reviews/viewed
|
||||
// and /reviews/delete (plural) still fall through to the generic
|
||||
// mutation catch-all further down the file.
|
||||
await this.page.route(/\/api\/review\/summary/, (route) =>
|
||||
route.fulfill({ json: reviewSummary }),
|
||||
);
|
||||
await this.page.route(/\/api\/review(\?|$)/, (route) =>
|
||||
route.fulfill({ json: reviews }),
|
||||
);
|
||||
|
||||
// Export jobs. The Exports page polls this every 2s while any export
|
||||
// is in_progress; without a mock route it falls through to the preview
|
||||
// server which returns 500 and makes the page flap between loading and
|
||||
// rendered state, breaking tests that navigate to /export.
|
||||
await this.page.route("**/api/jobs/export", (route) =>
|
||||
route.fulfill({ json: [] }),
|
||||
);
|
||||
// Reviews
|
||||
await this.page.route("**/api/reviews**", (route) => {
|
||||
const url = route.request().url();
|
||||
if (url.includes("summary")) {
|
||||
return route.fulfill({ json: reviewSummary });
|
||||
}
|
||||
return route.fulfill({ json: reviews });
|
||||
});
|
||||
|
||||
// Recordings summary
|
||||
await this.page.route("**/api/recordings/summary**", (route) =>
|
||||
|
||||
@ -1,734 +1,74 @@
|
||||
/**
|
||||
* Export page tests -- HIGH tier.
|
||||
*
|
||||
* Tests export card rendering with mock data, search filtering,
|
||||
* and delete confirmation dialog.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
|
||||
test.describe("Export Page - Overview @high", () => {
|
||||
test("renders uncategorized exports and case cards from mock data", async ({
|
||||
test.describe("Export Page - Cards @high", () => {
|
||||
test("export page renders export cards from mock data", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/export");
|
||||
|
||||
await frigateApp.page.waitForTimeout(2000);
|
||||
// Should show export names from our mock data
|
||||
await expect(
|
||||
frigateApp.page.getByText("Front Door - Person Alert"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
frigateApp.page.getByText("Garage - In Progress"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
frigateApp.page.getByText("Package Theft Investigation"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("search filters uncategorized exports", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/export");
|
||||
|
||||
const searchInput = frigateApp.page.getByPlaceholder(/search/i).first();
|
||||
await searchInput.fill("Front Door");
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByText("Front Door - Person Alert"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
frigateApp.page.getByText("Backyard - Car Detection"),
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
frigateApp.page.getByText("Garage - In Progress"),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test("new case button opens the create case dialog", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/export");
|
||||
|
||||
await frigateApp.page.getByRole("button", { name: "New Case" }).click();
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByRole("dialog").filter({ hasText: "Create Case" }),
|
||||
).toBeVisible();
|
||||
await expect(frigateApp.page.getByPlaceholder("Case name")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Export Page - Case Detail @high", () => {
|
||||
test("opening a case shows its detail view and associated export", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/export");
|
||||
|
||||
await frigateApp.page
|
||||
.getByText("Package Theft Investigation")
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByRole("heading", {
|
||||
name: "Package Theft Investigation",
|
||||
}),
|
||||
).toBeVisible();
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await expect(
|
||||
frigateApp.page.getByText("Backyard - Car Detection"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
frigateApp.page.getByRole("button", { name: "Add Export" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
frigateApp.page.getByRole("button", { name: "Edit Case" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
frigateApp.page.getByRole("button", { name: "Delete Case" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("edit case opens a prefilled dialog", async ({ frigateApp }) => {
|
||||
test("export page shows in-progress indicator", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/export");
|
||||
|
||||
await frigateApp.page
|
||||
.getByText("Package Theft Investigation")
|
||||
.first()
|
||||
.click();
|
||||
await frigateApp.page.getByRole("button", { name: "Edit Case" }).click();
|
||||
|
||||
const dialog = frigateApp.page
|
||||
.getByRole("dialog")
|
||||
.filter({ hasText: "Edit Case" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.locator("input")).toHaveValue(
|
||||
"Package Theft Investigation",
|
||||
);
|
||||
await expect(dialog.locator("textarea")).toHaveValue(
|
||||
"Review of suspicious activity near the front porch",
|
||||
await frigateApp.page.waitForTimeout(2000);
|
||||
// "Garage - In Progress" export should be visible
|
||||
await expect(frigateApp.page.getByText("Garage - In Progress")).toBeVisible(
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
});
|
||||
|
||||
test("add export shows completed uncategorized exports for assignment", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test("export page shows case grouping", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/export");
|
||||
|
||||
await frigateApp.page
|
||||
.getByText("Package Theft Investigation")
|
||||
.first()
|
||||
.click();
|
||||
await frigateApp.page.getByRole("button", { name: "Add Export" }).click();
|
||||
|
||||
const dialog = frigateApp.page
|
||||
.getByRole("dialog")
|
||||
.filter({ hasText: "Add Export to Package Theft Investigation" });
|
||||
await expect(dialog).toBeVisible();
|
||||
// Completed, uncategorized exports are selectable
|
||||
await expect(dialog.getByText("Front Door - Person Alert")).toBeVisible();
|
||||
// In-progress exports are intentionally hidden by AssignExportDialog
|
||||
// (see Exports.tsx filteredExports) — they can't be assigned until
|
||||
// they finish, so they should not show in the picker.
|
||||
await expect(dialog.getByText("Garage - In Progress")).toBeHidden();
|
||||
});
|
||||
|
||||
test("delete case opens a confirmation dialog", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/export");
|
||||
|
||||
await frigateApp.page
|
||||
.getByText("Package Theft Investigation")
|
||||
.first()
|
||||
.click();
|
||||
await frigateApp.page.getByRole("button", { name: "Delete Case" }).click();
|
||||
|
||||
const dialog = frigateApp.page
|
||||
.getByRole("alertdialog")
|
||||
.filter({ hasText: "Delete Case" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByText(/Package Theft Investigation/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("delete case can also delete its exports", async ({ frigateApp }) => {
|
||||
let deleteRequestUrl: string | null = null;
|
||||
let deleteCaseCompleted = false;
|
||||
|
||||
const initialCases = [
|
||||
{
|
||||
id: "case-001",
|
||||
name: "Package Theft Investigation",
|
||||
description: "Review of suspicious activity near the front porch",
|
||||
created_at: 1775407931.3863528,
|
||||
updated_at: 1775483531.3863528,
|
||||
},
|
||||
];
|
||||
|
||||
const initialExports = [
|
||||
{
|
||||
id: "export-001",
|
||||
camera: "front_door",
|
||||
name: "Front Door - Person Alert",
|
||||
date: 1775490731.3863528,
|
||||
video_path: "/exports/export-001.mp4",
|
||||
thumb_path: "/exports/export-001-thumb.jpg",
|
||||
in_progress: false,
|
||||
export_case_id: null,
|
||||
},
|
||||
{
|
||||
id: "export-002",
|
||||
camera: "backyard",
|
||||
name: "Backyard - Car Detection",
|
||||
date: 1775483531.3863528,
|
||||
video_path: "/exports/export-002.mp4",
|
||||
thumb_path: "/exports/export-002-thumb.jpg",
|
||||
in_progress: false,
|
||||
export_case_id: "case-001",
|
||||
},
|
||||
{
|
||||
id: "export-003",
|
||||
camera: "garage",
|
||||
name: "Garage - In Progress",
|
||||
date: 1775492531.3863528,
|
||||
video_path: "/exports/export-003.mp4",
|
||||
thumb_path: "/exports/export-003-thumb.jpg",
|
||||
in_progress: true,
|
||||
export_case_id: null,
|
||||
},
|
||||
];
|
||||
|
||||
await frigateApp.page.route(/\/api\/cases(?:$|\?|\/)/, async (route) => {
|
||||
const request = route.request();
|
||||
|
||||
if (request.method() === "DELETE") {
|
||||
deleteRequestUrl = request.url();
|
||||
deleteCaseCompleted = true;
|
||||
return route.fulfill({ json: { success: true } });
|
||||
}
|
||||
|
||||
if (request.method() === "GET") {
|
||||
return route.fulfill({
|
||||
json: deleteCaseCompleted ? [] : initialCases,
|
||||
});
|
||||
}
|
||||
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
await frigateApp.page.route("**/api/exports**", async (route) => {
|
||||
if (route.request().method() !== "GET") {
|
||||
return route.fallback();
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
json: deleteCaseCompleted
|
||||
? initialExports.filter((exp) => exp.export_case_id !== "case-001")
|
||||
: initialExports,
|
||||
});
|
||||
});
|
||||
|
||||
await frigateApp.goto("/export");
|
||||
|
||||
await frigateApp.page
|
||||
.getByText("Package Theft Investigation")
|
||||
.first()
|
||||
.click();
|
||||
await frigateApp.page.getByRole("button", { name: "Delete Case" }).click();
|
||||
|
||||
const dialog = frigateApp.page
|
||||
.getByRole("alertdialog")
|
||||
.filter({ hasText: "Delete Case" });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
const deleteExportsSwitch = dialog.getByRole("switch", {
|
||||
name: "Also delete exports",
|
||||
});
|
||||
await expect(deleteExportsSwitch).toHaveAttribute("aria-checked", "false");
|
||||
await expect(
|
||||
dialog.getByText(
|
||||
"Exports will remain available as uncategorized exports.",
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
await deleteExportsSwitch.click();
|
||||
|
||||
await expect(deleteExportsSwitch).toHaveAttribute("aria-checked", "true");
|
||||
await expect(
|
||||
dialog.getByText("All exports in this case will be permanently deleted."),
|
||||
).toBeVisible();
|
||||
|
||||
await dialog.getByRole("button", { name: /^delete$/i }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => deleteRequestUrl)
|
||||
.toContain("/api/cases/case-001?delete_exports=true");
|
||||
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(
|
||||
frigateApp.page.getByRole("heading", {
|
||||
name: "Package Theft Investigation",
|
||||
}),
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
frigateApp.page.getByText("Backyard - Car Detection"),
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
frigateApp.page.getByText("Front Door - Person Alert"),
|
||||
).toBeVisible();
|
||||
await frigateApp.page.waitForTimeout(3000);
|
||||
// Cases may render differently depending on API response shape
|
||||
const pageText = await frigateApp.page.textContent("#pageRoot");
|
||||
expect(pageText?.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Export Page - Empty State @high", () => {
|
||||
test("renders the empty state when there are no exports or cases", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.page.route("**/api/export**", (route) =>
|
||||
route.fulfill({ json: [] }),
|
||||
);
|
||||
await frigateApp.page.route("**/api/exports**", (route) =>
|
||||
route.fulfill({ json: [] }),
|
||||
);
|
||||
await frigateApp.page.route("**/api/cases", (route) =>
|
||||
route.fulfill({ json: [] }),
|
||||
);
|
||||
await frigateApp.page.route("**/api/cases**", (route) =>
|
||||
route.fulfill({ json: [] }),
|
||||
);
|
||||
|
||||
test.describe("Export Page - Search @high", () => {
|
||||
test("search input filters export list", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/export");
|
||||
|
||||
await expect(frigateApp.page.getByText("No exports found")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Export Page - Mobile @high @mobile", () => {
|
||||
test("mobile can open an export preview dialog", async ({ frigateApp }) => {
|
||||
test.skip(!frigateApp.isMobile, "Mobile-only assertion");
|
||||
|
||||
await frigateApp.goto("/export");
|
||||
|
||||
await frigateApp.page
|
||||
.getByText("Front Door - Person Alert")
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const dialog = frigateApp.page
|
||||
.getByRole("dialog")
|
||||
.filter({ hasText: "Front Door - Person Alert" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.locator("video")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Multi-Review Export @high", () => {
|
||||
// Two alert reviews close enough to "now" to fall within the
|
||||
// default last-24-hours review window. Using numeric timestamps
|
||||
// because the TS ReviewSegment type expects numbers even though
|
||||
// the backend pydantic model serializes datetime as ISO strings —
|
||||
// the app reads these as numbers for display math.
|
||||
const now = Date.now() / 1000;
|
||||
const mockReviews = [
|
||||
{
|
||||
id: "mex-review-001",
|
||||
camera: "front_door",
|
||||
start_time: now - 600,
|
||||
end_time: now - 580,
|
||||
has_been_reviewed: false,
|
||||
severity: "alert",
|
||||
thumb_path: "/clips/front_door/mex-review-001-thumb.jpg",
|
||||
data: {
|
||||
audio: [],
|
||||
detections: ["person-001"],
|
||||
objects: ["person"],
|
||||
sub_labels: [],
|
||||
significant_motion_areas: [],
|
||||
zones: ["front_yard"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "mex-review-002",
|
||||
camera: "backyard",
|
||||
start_time: now - 1200,
|
||||
end_time: now - 1170,
|
||||
has_been_reviewed: false,
|
||||
severity: "alert",
|
||||
thumb_path: "/clips/backyard/mex-review-002-thumb.jpg",
|
||||
data: {
|
||||
audio: [],
|
||||
detections: ["car-002"],
|
||||
objects: ["car"],
|
||||
sub_labels: [],
|
||||
significant_motion_areas: [],
|
||||
zones: ["driveway"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// 51 alert reviews, all front_door, spaced 5 minutes apart. Used by the
|
||||
// over-limit test to trigger Ctrl+A select-all and verify the Export
|
||||
// button is hidden at 51 selected.
|
||||
const oversizedReviews = Array.from({ length: 51 }, (_, i) => ({
|
||||
id: `mex-oversized-${i.toString().padStart(3, "0")}`,
|
||||
camera: "front_door",
|
||||
start_time: now - 60 * 60 - i * 300,
|
||||
end_time: now - 60 * 60 - i * 300 + 20,
|
||||
has_been_reviewed: false,
|
||||
severity: "alert",
|
||||
thumb_path: `/clips/front_door/mex-oversized-${i}-thumb.jpg`,
|
||||
data: {
|
||||
audio: [],
|
||||
detections: [`person-${i}`],
|
||||
objects: ["person"],
|
||||
sub_labels: [],
|
||||
significant_motion_areas: [],
|
||||
zones: ["front_yard"],
|
||||
},
|
||||
}));
|
||||
|
||||
const mockSummary = {
|
||||
last24Hours: {
|
||||
reviewed_alert: 0,
|
||||
reviewed_detection: 0,
|
||||
total_alert: 2,
|
||||
total_detection: 0,
|
||||
},
|
||||
};
|
||||
|
||||
async function routeReviews(
|
||||
page: import("@playwright/test").Page,
|
||||
reviews: unknown[],
|
||||
await frigateApp.page.waitForTimeout(2000);
|
||||
const searchInput = frigateApp.page.locator(
|
||||
'#pageRoot input[type="text"], #pageRoot input',
|
||||
);
|
||||
if (
|
||||
(await searchInput.count()) > 0 &&
|
||||
(await searchInput.first().isVisible())
|
||||
) {
|
||||
// Intercept the actual `/api/review` endpoint (singular — the
|
||||
// default api-mocker only registers `/api/reviews**` (plural)
|
||||
// which does not match the real request URL).
|
||||
await page.route(/\/api\/review(\?|$)/, (route) =>
|
||||
route.fulfill({ json: reviews }),
|
||||
);
|
||||
await page.route(/\/api\/review\/summary/, (route) =>
|
||||
route.fulfill({ json: mockSummary }),
|
||||
);
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ frigateApp }) => {
|
||||
await routeReviews(frigateApp.page, mockReviews);
|
||||
// Empty cases list by default so the dialog defaults to "new case".
|
||||
// Individual tests override this to populate existing cases.
|
||||
await frigateApp.page.route("**/api/cases", (route) =>
|
||||
route.fulfill({ json: [] }),
|
||||
);
|
||||
});
|
||||
|
||||
async function selectTwoReviews(frigateApp: {
|
||||
page: import("@playwright/test").Page;
|
||||
}) {
|
||||
// Every review card has className `review-item` on its wrapper
|
||||
// (see EventView.tsx). Cards also have data-start attributes that
|
||||
// we can key off if needed.
|
||||
const reviewItems = frigateApp.page.locator(".review-item");
|
||||
await reviewItems.first().waitFor({ state: "visible", timeout: 10_000 });
|
||||
|
||||
// Meta-click the first two items to enter multi-select mode.
|
||||
// PreviewThumbnailPlayer reads e.metaKey to decide multi-select.
|
||||
await reviewItems.nth(0).click({ modifiers: ["Meta"] });
|
||||
await reviewItems.nth(1).click();
|
||||
}
|
||||
|
||||
test("selecting two reviews reveals the export button", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Desktop multi-select flow");
|
||||
|
||||
await frigateApp.goto("/review");
|
||||
|
||||
await selectTwoReviews(frigateApp);
|
||||
|
||||
// Action group replaces the filter bar once items are selected
|
||||
await expect(frigateApp.page.getByText(/2.*selected/i)).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
const exportButton = frigateApp.page.getByRole("button", {
|
||||
name: /export/i,
|
||||
});
|
||||
await expect(exportButton).toBeVisible();
|
||||
});
|
||||
|
||||
test("clicking export opens the multi-review dialog with correct title", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Desktop multi-select flow");
|
||||
|
||||
await frigateApp.goto("/review");
|
||||
|
||||
await selectTwoReviews(frigateApp);
|
||||
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: /export/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const dialog = frigateApp.page
|
||||
.getByRole("dialog")
|
||||
.filter({ hasText: /Export 2 reviews/i });
|
||||
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||
// The dialog uses a Select trigger for case selection (admins). The
|
||||
// default "None" value is shown on the trigger.
|
||||
await expect(dialog.locator("button[role='combobox']")).toBeVisible();
|
||||
await expect(dialog.getByText(/None/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("starting an export posts the expected payload and navigates to the case", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Desktop multi-select flow");
|
||||
|
||||
let capturedPayload: unknown = null;
|
||||
await frigateApp.page.route("**/api/exports/batch", async (route) => {
|
||||
capturedPayload = route.request().postDataJSON();
|
||||
await route.fulfill({
|
||||
status: 202,
|
||||
json: {
|
||||
export_case_id: "new-case-xyz",
|
||||
export_ids: ["front_door_a", "backyard_b"],
|
||||
results: [
|
||||
{
|
||||
camera: "front_door",
|
||||
export_id: "front_door_a",
|
||||
success: true,
|
||||
status: "queued",
|
||||
error: null,
|
||||
item_index: 0,
|
||||
},
|
||||
{
|
||||
camera: "backyard",
|
||||
export_id: "backyard_b",
|
||||
success: true,
|
||||
status: "queued",
|
||||
error: null,
|
||||
item_index: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await frigateApp.goto("/review");
|
||||
await selectTwoReviews(frigateApp);
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: /export/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const dialog = frigateApp.page
|
||||
.getByRole("dialog")
|
||||
.filter({ hasText: /Export 2 reviews/i });
|
||||
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Select "Create new case" from the case dropdown (default is "None")
|
||||
await dialog.locator("button[role='combobox']").click();
|
||||
await frigateApp.page
|
||||
.getByRole("option", { name: /Create new case/i })
|
||||
.click();
|
||||
|
||||
const nameInput = dialog.locator("input").first();
|
||||
await nameInput.fill("E2E Incident");
|
||||
|
||||
await dialog.getByRole("button", { name: /export 2 reviews/i }).click();
|
||||
|
||||
// Wait for the POST to fire
|
||||
await expect.poll(() => capturedPayload, { timeout: 5_000 }).not.toBeNull();
|
||||
|
||||
const payload = capturedPayload as {
|
||||
items: Array<{
|
||||
camera: string;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
image_path?: string;
|
||||
client_item_id?: string;
|
||||
}>;
|
||||
new_case_name?: string;
|
||||
export_case_id?: string;
|
||||
};
|
||||
expect(payload.items).toHaveLength(2);
|
||||
expect(payload.new_case_name).toBe("E2E Incident");
|
||||
// When creating a new case, we must NOT also send export_case_id —
|
||||
// the two fields are mutually exclusive on the backend.
|
||||
expect(payload.export_case_id).toBeUndefined();
|
||||
expect(payload.items.map((i) => i.camera).sort()).toEqual([
|
||||
"backyard",
|
||||
"front_door",
|
||||
]);
|
||||
// Each item must preserve REVIEW_PADDING (4s) on the edges —
|
||||
// i.e. the padded window is 8s longer than the original review.
|
||||
// The mock reviews above have 20s and 30s raw durations, so the
|
||||
// expected padded durations are 28s and 38s.
|
||||
const paddedDurations = payload.items
|
||||
.map((i) => i.end_time - i.start_time)
|
||||
.sort((a, b) => a - b);
|
||||
expect(paddedDurations).toEqual([28, 38]);
|
||||
// Thumbnails should be passed through per item
|
||||
for (const item of payload.items) {
|
||||
expect(item.image_path).toMatch(/mex-review-\d+-thumb\.jpg$/);
|
||||
}
|
||||
expect(payload.items.map((item) => item.client_item_id)).toEqual([
|
||||
"mex-review-001",
|
||||
"mex-review-002",
|
||||
]);
|
||||
|
||||
await expect(frigateApp.page).toHaveURL(/caseId=new-case-xyz/, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("mobile opens a drawer (not a dialog) for the multi-review export flow", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(!frigateApp.isMobile, "Mobile-only Drawer assertion");
|
||||
|
||||
await frigateApp.goto("/review");
|
||||
await selectTwoReviews(frigateApp);
|
||||
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: /export/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// On mobile the component renders a shadcn Drawer, which uses
|
||||
// role="dialog" but sets data-vaul-drawer. Desktop renders a
|
||||
// shadcn Dialog with role="dialog" but no data-vaul-drawer.
|
||||
// The title and submit button both contain "Export 2 reviews", so
|
||||
// assert each element distinctly: the title is a heading and the
|
||||
// submit button has role="button".
|
||||
const drawer = frigateApp.page.locator("[data-vaul-drawer]");
|
||||
await expect(drawer).toBeVisible({ timeout: 5_000 });
|
||||
// Type a search term that matches one export
|
||||
await searchInput.first().fill("Front Door");
|
||||
await frigateApp.page.waitForTimeout(500);
|
||||
// "Front Door - Person Alert" should still be visible
|
||||
await expect(
|
||||
drawer.getByRole("heading", { name: /Export 2 reviews/i }),
|
||||
frigateApp.page.getByText("Front Door - Person Alert"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
drawer.getByRole("button", { name: /export 2 reviews/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("hides export button when more than 50 reviews are selected", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Desktop select-all keyboard flow");
|
||||
|
||||
// Override the default 2-review mock with 51 reviews before
|
||||
// navigation. Playwright matches routes last-registered-first so
|
||||
// this takes precedence over the beforeEach.
|
||||
await routeReviews(frigateApp.page, oversizedReviews);
|
||||
|
||||
await frigateApp.goto("/review");
|
||||
|
||||
// Wait for any review item to render before firing the shortcut
|
||||
await frigateApp.page
|
||||
.locator(".review-item")
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout: 10_000 });
|
||||
|
||||
// Ctrl+A triggers onSelectAllReviews (see EventView.tsx useKeyboardListener)
|
||||
await frigateApp.page.keyboard.press("Control+a");
|
||||
|
||||
// The action group should show "51 selected" but no Export button.
|
||||
// Mark-as-reviewed is still there so the action bar is rendered.
|
||||
// Scope the "Mark as reviewed" lookup to its exact aria-label because
|
||||
// the page can render other "mark as reviewed" controls elsewhere
|
||||
// (e.g. on individual cards) that would trip strict-mode matching.
|
||||
await expect(frigateApp.page.getByText(/51.*selected/i)).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(
|
||||
frigateApp.page.getByRole("button", { name: "Mark as reviewed" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
frigateApp.page.getByRole("button", { name: /^export$/i }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("attaching to an existing case sends export_case_id without new_case_name", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Desktop multi-select flow");
|
||||
|
||||
// Seed one existing case so the dialog can offer the "existing" branch.
|
||||
// The fixture mocks the user as admin (adminProfile()), so useIsAdmin()
|
||||
// is true and the dialog renders the "Existing case" radio.
|
||||
await frigateApp.page.route("**/api/cases", (route) =>
|
||||
route.fulfill({
|
||||
json: [
|
||||
{
|
||||
id: "existing-case-abc",
|
||||
name: "Incident #42",
|
||||
description: "",
|
||||
created_at: now - 3600,
|
||||
updated_at: now - 3600,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
let capturedPayload: unknown = null;
|
||||
await frigateApp.page.route("**/api/exports/batch", async (route) => {
|
||||
capturedPayload = route.request().postDataJSON();
|
||||
await route.fulfill({
|
||||
status: 202,
|
||||
json: {
|
||||
export_case_id: "existing-case-abc",
|
||||
export_ids: ["front_door_a", "backyard_b"],
|
||||
results: [
|
||||
{
|
||||
camera: "front_door",
|
||||
export_id: "front_door_a",
|
||||
success: true,
|
||||
status: "queued",
|
||||
error: null,
|
||||
item_index: 0,
|
||||
},
|
||||
{
|
||||
camera: "backyard",
|
||||
export_id: "backyard_b",
|
||||
success: true,
|
||||
status: "queued",
|
||||
error: null,
|
||||
item_index: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await frigateApp.goto("/review");
|
||||
await selectTwoReviews(frigateApp);
|
||||
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: /export/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const dialog = frigateApp.page
|
||||
.getByRole("dialog")
|
||||
.filter({ hasText: /Export 2 reviews/i });
|
||||
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Open the Case Select dropdown and pick the seeded case directly.
|
||||
// The dialog now uses a single Select listing existing cases above
|
||||
// the "Create new case" option — no radio toggle needed.
|
||||
const selectTrigger = dialog.locator("button[role='combobox']").first();
|
||||
await selectTrigger.waitFor({ state: "visible", timeout: 5_000 });
|
||||
await selectTrigger.click();
|
||||
|
||||
// The dropdown portal renders outside the dialog
|
||||
await frigateApp.page.getByRole("option", { name: /Incident #42/ }).click();
|
||||
|
||||
await dialog.getByRole("button", { name: /export 2 reviews/i }).click();
|
||||
|
||||
await expect.poll(() => capturedPayload, { timeout: 5_000 }).not.toBeNull();
|
||||
|
||||
const payload = capturedPayload as {
|
||||
items: unknown[];
|
||||
new_case_name?: string;
|
||||
new_case_description?: string;
|
||||
export_case_id?: string;
|
||||
};
|
||||
expect(payload.export_case_id).toBe("existing-case-abc");
|
||||
expect(payload.new_case_name).toBeUndefined();
|
||||
expect(payload.new_case_description).toBeUndefined();
|
||||
expect(payload.items).toHaveLength(2);
|
||||
|
||||
// Navigate should hit /export. useSearchEffect consumes the caseId
|
||||
// query param and strips it once the case is found in the cases list,
|
||||
// so we assert on the path, not the query string.
|
||||
await expect(frigateApp.page).toHaveURL(/\/export(\?|$)/, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
}
|
||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Export Page - Controls @high", () => {
|
||||
test("export page filter controls are present", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/export");
|
||||
await frigateApp.page.waitForTimeout(1000);
|
||||
const buttons = frigateApp.page.locator("#pageRoot button");
|
||||
const count = await buttons.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@ -50,79 +50,24 @@
|
||||
"placeholder": "Name the Export"
|
||||
},
|
||||
"case": {
|
||||
"newCaseOption": "Create new case",
|
||||
"newCaseNamePlaceholder": "New case name",
|
||||
"newCaseDescriptionPlaceholder": "Case description",
|
||||
"label": "Case",
|
||||
"nonAdminHelp": "A new case will be created for these exports.",
|
||||
"placeholder": "Select a case"
|
||||
},
|
||||
"select": "Select",
|
||||
"export": "Export",
|
||||
"queueing": "Queueing Export...",
|
||||
"selectOrExport": "Select or Export",
|
||||
"tabs": {
|
||||
"export": "Single Camera",
|
||||
"multiCamera": "Multi-Camera"
|
||||
},
|
||||
"multiCamera": {
|
||||
"timeRange": "Time range",
|
||||
"selectFromTimeline": "Select from Timeline",
|
||||
"cameraSelection": "Cameras",
|
||||
"cameraSelectionHelp": "Cameras with tracked objects in this time range are pre-selected",
|
||||
"checkingActivity": "Checking camera activity...",
|
||||
"noCameras": "No cameras available",
|
||||
"detectionCount_one": "1 tracked object",
|
||||
"detectionCount_other": "{{count}} tracked objects",
|
||||
"nameLabel": "Export name",
|
||||
"namePlaceholder": "Optional base name for these exports",
|
||||
"queueingButton": "Queueing Exports...",
|
||||
"exportButton_one": "Export 1 Camera",
|
||||
"exportButton_other": "Export {{count}} Cameras"
|
||||
},
|
||||
"multi": {
|
||||
"title": "Export {{count}} reviews",
|
||||
"title_one": "Export 1 review",
|
||||
"title_other": "Export {{count}} reviews",
|
||||
"description": "Export each selected review. All exports will be grouped under a single case.",
|
||||
"descriptionNoCase": "Export each selected review.",
|
||||
"caseNamePlaceholder": "Review export - {{date}}",
|
||||
"exportButton": "Export {{count}} reviews",
|
||||
"exportButton_one": "Export 1 review",
|
||||
"exportButton_other": "Export {{count}} reviews",
|
||||
"exportingButton": "Exporting...",
|
||||
"toast": {
|
||||
"started_one": "Started 1 export. Opening the case now.",
|
||||
"started_other": "Started {{count}} exports. Opening the case now.",
|
||||
"startedNoCase_one": "Started 1 export.",
|
||||
"startedNoCase_other": "Started {{count}} exports.",
|
||||
"partial": "Started {{successful}} of {{total}} exports. Failed: {{failedItems}}",
|
||||
"failed": "Failed to start {{total}} exports. Failed: {{failedItems}}"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"success": "Successfully started export. View the file in the exports page.",
|
||||
"queued": "Export queued. View progress in the exports page.",
|
||||
"view": "View",
|
||||
"batchSuccess_one": "Started 1 export. Opening the case now.",
|
||||
"batchSuccess_other": "Started {{count}} exports. Opening the case now.",
|
||||
"batchPartial": "Started {{successful}} of {{total}} exports. Failed cameras: {{failedCameras}}",
|
||||
"batchFailed": "Failed to start {{total}} exports. Failed cameras: {{failedCameras}}",
|
||||
"batchQueuedSuccess_one": "Queued 1 export. Opening the case now.",
|
||||
"batchQueuedSuccess_other": "Queued {{count}} exports. Opening the case now.",
|
||||
"batchQueuedPartial": "Queued {{successful}} of {{total}} exports. Failed cameras: {{failedCameras}}",
|
||||
"batchQueueFailed": "Failed to queue {{total}} exports. Failed cameras: {{failedCameras}}",
|
||||
"error": {
|
||||
"failed": "Failed to queue export: {{error}}",
|
||||
"failed": "Failed to start export: {{error}}",
|
||||
"endTimeMustAfterStartTime": "End time must be after start time",
|
||||
"noVaildTimeSelected": "No valid time range selected"
|
||||
}
|
||||
},
|
||||
"fromTimeline": {
|
||||
"saveExport": "Save Export",
|
||||
"queueingExport": "Queueing Export...",
|
||||
"previewExport": "Preview Export",
|
||||
"useThisRange": "Use This Range"
|
||||
"previewExport": "Preview Export"
|
||||
}
|
||||
},
|
||||
"streaming": {
|
||||
|
||||
@ -20,30 +20,14 @@
|
||||
"downloadVideo": "Download video",
|
||||
"editName": "Edit name",
|
||||
"deleteExport": "Delete export",
|
||||
"assignToCase": "Add to case",
|
||||
"removeFromCase": "Remove from case"
|
||||
},
|
||||
"toolbar": {
|
||||
"newCase": "New Case",
|
||||
"addExport": "Add Export",
|
||||
"editCase": "Edit Case",
|
||||
"deleteCase": "Delete Case"
|
||||
"assignToCase": "Add to case"
|
||||
},
|
||||
"toast": {
|
||||
"error": {
|
||||
"renameExportFailed": "Failed to rename export: {{errorMessage}}",
|
||||
"assignCaseFailed": "Failed to update case assignment: {{errorMessage}}",
|
||||
"caseSaveFailed": "Failed to save case: {{errorMessage}}",
|
||||
"caseDeleteFailed": "Failed to delete case: {{errorMessage}}"
|
||||
"assignCaseFailed": "Failed to update case assignment: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"deleteCase": {
|
||||
"label": "Delete Case",
|
||||
"desc": "Are you sure you want to delete {{caseName}}?",
|
||||
"descKeepExports": "Exports will remain available as uncategorized exports.",
|
||||
"descDeleteExports": "All exports in this case will be permanently deleted.",
|
||||
"deleteExports": "Also delete exports"
|
||||
},
|
||||
"caseDialog": {
|
||||
"title": "Add to case",
|
||||
"description": "Choose an existing case or create a new one.",
|
||||
@ -51,73 +35,5 @@
|
||||
"newCaseOption": "Create new case",
|
||||
"nameLabel": "Case name",
|
||||
"descriptionLabel": "Description"
|
||||
},
|
||||
"caseCard": {
|
||||
"emptyCase": "No exports yet"
|
||||
},
|
||||
"jobCard": {
|
||||
"defaultName": "{{camera}} export",
|
||||
"queued": "Queued",
|
||||
"running": "Running"
|
||||
},
|
||||
"caseView": {
|
||||
"noDescription": "No description",
|
||||
"createdAt": "Created {{value}}",
|
||||
"exportCount_one": "1 export",
|
||||
"exportCount_other": "{{count}} exports",
|
||||
"cameraCount_one": "1 camera",
|
||||
"cameraCount_other": "{{count}} cameras",
|
||||
"showMore": "Show more",
|
||||
"showLess": "Show less",
|
||||
"emptyTitle": "This case is empty",
|
||||
"emptyDescription": "Add existing uncategorized exports to keep the case organized.",
|
||||
"emptyDescriptionNoExports": "There are no uncategorized exports available to add yet."
|
||||
},
|
||||
"caseEditor": {
|
||||
"createTitle": "Create Case",
|
||||
"editTitle": "Edit Case",
|
||||
"namePlaceholder": "Case name",
|
||||
"descriptionPlaceholder": "Add notes or context for this case"
|
||||
},
|
||||
"addExportDialog": {
|
||||
"title": "Add Export to {{caseName}}",
|
||||
"searchPlaceholder": "Search uncategorized exports",
|
||||
"empty": "No uncategorized exports match this search.",
|
||||
"addButton_one": "Add 1 Export",
|
||||
"addButton_other": "Add {{count}} Exports",
|
||||
"adding": "Adding..."
|
||||
},
|
||||
"selected_one": "{{count}} selected",
|
||||
"selected_other": "{{count}} selected",
|
||||
"bulkActions": {
|
||||
"addToCase": "Add to Case",
|
||||
"moveToCase": "Move to Case",
|
||||
"removeFromCase": "Remove from Case",
|
||||
"delete": "Delete",
|
||||
"deleteNow": "Delete Now"
|
||||
},
|
||||
"bulkDelete": {
|
||||
"title": "Delete Exports",
|
||||
"desc_one": "Are you sure you want to delete {{count}} export?",
|
||||
"desc_other": "Are you sure you want to delete {{count}} exports?"
|
||||
},
|
||||
"bulkRemoveFromCase": {
|
||||
"title": "Remove from Case",
|
||||
"desc_one": "Remove {{count}} export from this case?",
|
||||
"desc_other": "Remove {{count}} exports from this case?",
|
||||
"descKeepExports": "Exports will be moved to uncategorized.",
|
||||
"descDeleteExports": "Exports will be permanently deleted.",
|
||||
"deleteExports": "Delete exports instead"
|
||||
},
|
||||
"bulkToast": {
|
||||
"success": {
|
||||
"delete": "Successfully deleted exports",
|
||||
"reassign": "Successfully updated case assignment",
|
||||
"remove": "Successfully removed exports from case"
|
||||
},
|
||||
"error": {
|
||||
"deleteFailed": "Failed to delete exports: {{errorMessage}}",
|
||||
"reassignFailed": "Failed to update case assignment: {{errorMessage}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { Button } from "../ui/button";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { FiMoreVertical } from "react-icons/fi";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
@ -13,7 +13,7 @@ import {
|
||||
} from "../ui/dialog";
|
||||
import { Input } from "../ui/input";
|
||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||
import { DeleteClipType, Export, ExportCase, ExportJob } from "@/types/export";
|
||||
import { DeleteClipType, Export, ExportCase } from "@/types/export";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { shareOrCopy } from "@/utils/browserUtil";
|
||||
@ -27,10 +27,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import { FaFolder, FaVideo } from "react-icons/fa";
|
||||
import { HiSquare2Stack } from "react-icons/hi2";
|
||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||
import useContextMenu from "@/hooks/use-contextmenu";
|
||||
import { FaFolder } from "react-icons/fa";
|
||||
|
||||
type CaseCardProps = {
|
||||
className: string;
|
||||
@ -44,15 +41,10 @@ export function CaseCard({
|
||||
exports,
|
||||
onSelect,
|
||||
}: CaseCardProps) {
|
||||
const { t } = useTranslation(["views/exports"]);
|
||||
const firstExport = useMemo(
|
||||
() => exports.find((exp) => exp.thumb_path && exp.thumb_path.length > 0),
|
||||
[exports],
|
||||
);
|
||||
const cameraCount = useMemo(
|
||||
() => new Set(exports.map((exp) => exp.camera)).size,
|
||||
[exports],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -69,30 +61,10 @@ export function CaseCard({
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
{!firstExport && (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-secondary via-secondary/80 to-muted" />
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-16 bg-gradient-to-t from-black/60 to-transparent" />
|
||||
<div className="absolute right-1 top-1 z-40 flex items-center gap-2 rounded-lg bg-black/50 px-2 py-1 text-xs text-white">
|
||||
<div className="flex items-center gap-1">
|
||||
<HiSquare2Stack className="size-3" />
|
||||
<div>{exports.length}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<FaVideo className="size-3" />
|
||||
<div>{cameraCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-x-2 bottom-2 z-20 text-white">
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<div className="absolute bottom-2 left-2 z-20 flex items-center justify-start gap-2 text-white">
|
||||
<FaFolder />
|
||||
<div className="truncate smart-capitalize">{exportCase.name}</div>
|
||||
</div>
|
||||
{exports.length === 0 && (
|
||||
<div className="mt-1 text-xs text-white/80">
|
||||
{t("caseCard.emptyCase")}
|
||||
</div>
|
||||
)}
|
||||
<div className="capitalize">{exportCase.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -101,26 +73,18 @@ export function CaseCard({
|
||||
type ExportCardProps = {
|
||||
className: string;
|
||||
exportedRecording: Export;
|
||||
isSelected?: boolean;
|
||||
selectionMode?: boolean;
|
||||
onSelect: (selected: Export) => void;
|
||||
onContextSelect?: (selected: Export) => void;
|
||||
onRename: (original: string, update: string) => void;
|
||||
onDelete: ({ file, exportName }: DeleteClipType) => void;
|
||||
onAssignToCase?: (selected: Export) => void;
|
||||
onRemoveFromCase?: (selected: Export) => void;
|
||||
};
|
||||
export function ExportCard({
|
||||
className,
|
||||
exportedRecording,
|
||||
isSelected,
|
||||
selectionMode,
|
||||
onSelect,
|
||||
onContextSelect,
|
||||
onRename,
|
||||
onDelete,
|
||||
onAssignToCase,
|
||||
onRemoveFromCase,
|
||||
}: ExportCardProps) {
|
||||
const { t } = useTranslation(["views/exports"]);
|
||||
const isAdmin = useIsAdmin();
|
||||
@ -128,15 +92,6 @@ export function ExportCard({
|
||||
exportedRecording.thumb_path.length > 0,
|
||||
);
|
||||
|
||||
// selection
|
||||
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
useContextMenu(cardRef, () => {
|
||||
if (!exportedRecording.in_progress && onContextSelect) {
|
||||
onContextSelect(exportedRecording);
|
||||
}
|
||||
});
|
||||
|
||||
// editing name
|
||||
|
||||
const [editName, setEditName] = useState<{
|
||||
@ -225,19 +180,14 @@ export function ExportCard({
|
||||
</Dialog>
|
||||
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={cn(
|
||||
"relative flex aspect-video cursor-pointer items-center justify-center rounded-lg bg-black md:rounded-2xl",
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
onClick={() => {
|
||||
if (!exportedRecording.in_progress) {
|
||||
if ((selectionMode || e.ctrlKey || e.metaKey) && onContextSelect) {
|
||||
onContextSelect(exportedRecording);
|
||||
} else {
|
||||
onSelect(exportedRecording);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{exportedRecording.in_progress ? (
|
||||
@ -255,7 +205,7 @@ export function ExportCard({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!exportedRecording.in_progress && !selectionMode && (
|
||||
{!exportedRecording.in_progress && (
|
||||
<div className="absolute bottom-2 right-3 z-40">
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger>
|
||||
@ -304,18 +254,6 @@ export function ExportCard({
|
||||
{t("tooltip.assignToCase")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && onRemoveFromCase && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
aria-label={t("tooltip.removeFromCase")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveFromCase(exportedRecording);
|
||||
}}
|
||||
>
|
||||
{t("tooltip.removeFromCase")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
@ -354,61 +292,10 @@ export function ExportCard({
|
||||
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" />
|
||||
)}
|
||||
<ImageShadowOverlay />
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 z-10 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] md:rounded-2xl",
|
||||
isSelected
|
||||
? "shadow-selected outline-selected"
|
||||
: "outline-transparent duration-500",
|
||||
)}
|
||||
/>
|
||||
<div className="absolute bottom-2 left-3 right-12 z-30 text-white">
|
||||
<div className="truncate smart-capitalize">
|
||||
<div className="absolute bottom-2 left-3 flex items-end text-white smart-capitalize">
|
||||
{exportedRecording.name.replaceAll("_", " ")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type ActiveExportJobCardProps = {
|
||||
className?: string;
|
||||
job: ExportJob;
|
||||
};
|
||||
|
||||
export function ActiveExportJobCard({
|
||||
className = "",
|
||||
job,
|
||||
}: ActiveExportJobCardProps) {
|
||||
const { t } = useTranslation(["views/exports", "common"]);
|
||||
const cameraName = useCameraFriendlyName(job.camera);
|
||||
const displayName = useMemo(() => {
|
||||
if (job.name && job.name.length > 0) {
|
||||
return job.name.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
return t("jobCard.defaultName", {
|
||||
camera: cameraName,
|
||||
});
|
||||
}, [cameraName, job.name, t]);
|
||||
const statusLabel =
|
||||
job.status === "queued" ? t("jobCard.queued") : t("jobCard.running");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex aspect-video items-center justify-center overflow-hidden rounded-lg border border-dashed border-border bg-secondary/40 md:rounded-2xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="absolute right-3 top-3 z-30 rounded-full bg-selected/90 px-2 py-1 text-xs text-selected-foreground">
|
||||
{statusLabel}
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-3 px-6 text-center">
|
||||
<ActivityIndicator />
|
||||
<div className="text-sm font-medium text-primary">{displayName}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -81,7 +81,7 @@ export default function ReviewCard({
|
||||
|
||||
axios
|
||||
.post(
|
||||
`export/${event.camera}/start/${event.start_time - REVIEW_PADDING}/end/${endTime}`,
|
||||
`export/${event.camera}/start/${event.start_time + REVIEW_PADDING}/end/${endTime}`,
|
||||
{ playback: "realtime" },
|
||||
)
|
||||
.then((response) => {
|
||||
|
||||
@ -56,11 +56,6 @@ const record: SectionConfigOverrides = {
|
||||
},
|
||||
camera: {
|
||||
restartRequired: [],
|
||||
hiddenFields: [
|
||||
"enabled_in_config",
|
||||
"sync_recordings",
|
||||
"export.max_concurrent",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -1,384 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import axios from "axios";
|
||||
import { Button, buttonVariants } from "../ui/button";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import { HiTrash } from "react-icons/hi";
|
||||
import { LuFolderPlus, LuFolderX } from "react-icons/lu";
|
||||
import { Export, ExportCase } from "@/types/export";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "../ui/alert-dialog";
|
||||
import { Label } from "../ui/label";
|
||||
import { Switch } from "../ui/switch";
|
||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import OptionAndInputDialog from "../overlay/dialog/OptionAndInputDialog";
|
||||
|
||||
type ExportActionGroupProps = {
|
||||
selectedExports: Export[];
|
||||
setSelectedExports: (exports: Export[]) => void;
|
||||
context: "uncategorized" | "case";
|
||||
cases?: ExportCase[];
|
||||
currentCaseId?: string;
|
||||
mutate: () => void;
|
||||
};
|
||||
export default function ExportActionGroup({
|
||||
selectedExports,
|
||||
setSelectedExports,
|
||||
context,
|
||||
cases,
|
||||
currentCaseId,
|
||||
mutate,
|
||||
}: ExportActionGroupProps) {
|
||||
const { t } = useTranslation(["views/exports", "common"]);
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const onClearSelected = useCallback(() => {
|
||||
setSelectedExports([]);
|
||||
}, [setSelectedExports]);
|
||||
|
||||
// ── Delete ──────────────────────────────────────────────────────
|
||||
|
||||
const onDelete = useCallback(() => {
|
||||
const ids = selectedExports.map((e) => e.id);
|
||||
axios
|
||||
.post("exports/delete", { ids })
|
||||
.then((resp) => {
|
||||
if (resp.status === 200) {
|
||||
toast.success(t("bulkToast.success.delete"), {
|
||||
position: "top-center",
|
||||
});
|
||||
setSelectedExports([]);
|
||||
mutate();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(t("bulkToast.error.deleteFailed", { errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
}, [selectedExports, setSelectedExports, mutate, t]);
|
||||
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [bypassDialog, setBypassDialog] = useState(false);
|
||||
|
||||
useKeyboardListener(["Shift"], (_, modifiers) => {
|
||||
setBypassDialog(modifiers.shift);
|
||||
return false;
|
||||
});
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (bypassDialog) {
|
||||
onDelete();
|
||||
} else {
|
||||
setDeleteDialogOpen(true);
|
||||
}
|
||||
}, [bypassDialog, onDelete]);
|
||||
|
||||
// ── Remove from case ────────────────────────────────────────────
|
||||
|
||||
const [removeDialogOpen, setRemoveDialogOpen] = useState(false);
|
||||
const [deleteExportsOnRemove, setDeleteExportsOnRemove] = useState(false);
|
||||
|
||||
const handleRemoveFromCase = useCallback(() => {
|
||||
const ids = selectedExports.map((e) => e.id);
|
||||
|
||||
const request = deleteExportsOnRemove
|
||||
? axios.post("exports/delete", { ids })
|
||||
: axios.post("exports/reassign", { ids, export_case_id: null });
|
||||
|
||||
request
|
||||
.then((resp) => {
|
||||
if (resp.status === 200) {
|
||||
toast.success(t("bulkToast.success.remove"), {
|
||||
position: "top-center",
|
||||
});
|
||||
setSelectedExports([]);
|
||||
mutate();
|
||||
setRemoveDialogOpen(false);
|
||||
setDeleteExportsOnRemove(false);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(t("bulkToast.error.reassignFailed", { errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
}, [selectedExports, deleteExportsOnRemove, setSelectedExports, mutate, t]);
|
||||
|
||||
// ── Case picker ─────────────────────────────────────────────────
|
||||
|
||||
const [casePickerOpen, setCasePickerOpen] = useState(false);
|
||||
|
||||
const caseOptions = useMemo(
|
||||
() => [
|
||||
...(cases ?? [])
|
||||
.filter((c) => c.id !== currentCaseId)
|
||||
.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.name,
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label)),
|
||||
{
|
||||
value: "new",
|
||||
label: t("caseDialog.newCaseOption"),
|
||||
},
|
||||
],
|
||||
[cases, currentCaseId, t],
|
||||
);
|
||||
|
||||
const handleAssignToCase = useCallback(
|
||||
async (caseId: string) => {
|
||||
const ids = selectedExports.map((e) => e.id);
|
||||
try {
|
||||
await axios.post("exports/reassign", {
|
||||
ids,
|
||||
export_case_id: caseId,
|
||||
});
|
||||
toast.success(t("bulkToast.success.reassign"), {
|
||||
position: "top-center",
|
||||
});
|
||||
setSelectedExports([]);
|
||||
mutate();
|
||||
} catch (error) {
|
||||
const apiError = error as {
|
||||
response?: { data?: { message?: string; detail?: string } };
|
||||
};
|
||||
const errorMessage =
|
||||
apiError.response?.data?.message ||
|
||||
apiError.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(t("bulkToast.error.reassignFailed", { errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[selectedExports, setSelectedExports, mutate, t],
|
||||
);
|
||||
|
||||
const handleCreateNewCase = useCallback(
|
||||
async (name: string, description: string) => {
|
||||
const ids = selectedExports.map((e) => e.id);
|
||||
try {
|
||||
const createResp = await axios.post("cases", { name, description });
|
||||
const newCaseId: string | undefined = createResp.data?.id;
|
||||
|
||||
if (newCaseId) {
|
||||
await axios.post("exports/reassign", {
|
||||
ids,
|
||||
export_case_id: newCaseId,
|
||||
});
|
||||
}
|
||||
|
||||
toast.success(t("bulkToast.success.reassign"), {
|
||||
position: "top-center",
|
||||
});
|
||||
setSelectedExports([]);
|
||||
mutate();
|
||||
} catch (error) {
|
||||
const apiError = error as {
|
||||
response?: { data?: { message?: string; detail?: string } };
|
||||
};
|
||||
const errorMessage =
|
||||
apiError.response?.data?.message ||
|
||||
apiError.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(t("bulkToast.error.reassignFailed", { errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[selectedExports, setSelectedExports, mutate, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Delete confirmation dialog */}
|
||||
<AlertDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("bulkDelete.title")}</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogDescription>
|
||||
{t("bulkDelete.desc", { count: selectedExports.length })}
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={buttonVariants({ variant: "destructive" })}
|
||||
onClick={onDelete}
|
||||
>
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Remove from case dialog */}
|
||||
{context === "case" && (
|
||||
<AlertDialog
|
||||
open={removeDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setRemoveDialogOpen(false);
|
||||
setDeleteExportsOnRemove(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("bulkRemoveFromCase.title")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("bulkRemoveFromCase.desc", {
|
||||
count: selectedExports.length,
|
||||
})}{" "}
|
||||
{deleteExportsOnRemove
|
||||
? t("bulkRemoveFromCase.descDeleteExports")
|
||||
: t("bulkRemoveFromCase.descKeepExports")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="flex items-center justify-start gap-6">
|
||||
<Label
|
||||
htmlFor="bulk-delete-exports-switch"
|
||||
className="cursor-pointer text-sm"
|
||||
>
|
||||
{t("bulkRemoveFromCase.deleteExports")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="bulk-delete-exports-switch"
|
||||
checked={deleteExportsOnRemove}
|
||||
onCheckedChange={setDeleteExportsOnRemove}
|
||||
/>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={buttonVariants({ variant: "destructive" })}
|
||||
onClick={handleRemoveFromCase}
|
||||
>
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
|
||||
{/* Case picker dialog */}
|
||||
<OptionAndInputDialog
|
||||
open={casePickerOpen}
|
||||
title={t("caseDialog.title")}
|
||||
description={t("caseDialog.description")}
|
||||
setOpen={setCasePickerOpen}
|
||||
options={caseOptions}
|
||||
nameLabel={t("caseDialog.nameLabel")}
|
||||
descriptionLabel={t("caseDialog.descriptionLabel")}
|
||||
initialValue={caseOptions[0]?.value}
|
||||
newValueKey="new"
|
||||
onSave={handleAssignToCase}
|
||||
onCreateNew={handleCreateNewCase}
|
||||
/>
|
||||
|
||||
{/* Action bar */}
|
||||
<div className="flex w-full items-center justify-end gap-2">
|
||||
<div className="mx-1 flex items-center justify-center text-sm text-muted-foreground">
|
||||
<div className="p-1">
|
||||
{t("selected", { count: selectedExports.length })}
|
||||
</div>
|
||||
<div className="p-1">{"|"}</div>
|
||||
<div
|
||||
className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary"
|
||||
onClick={onClearSelected}
|
||||
>
|
||||
{t("button.unselect", { ns: "common" })}
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-1 md:gap-2">
|
||||
{/* Add to Case / Move to Case */}
|
||||
<Button
|
||||
className="flex items-center gap-2 p-2"
|
||||
aria-label={
|
||||
context === "case"
|
||||
? t("bulkActions.moveToCase")
|
||||
: t("bulkActions.addToCase")
|
||||
}
|
||||
size="sm"
|
||||
onClick={() => setCasePickerOpen(true)}
|
||||
>
|
||||
<LuFolderPlus className="text-secondary-foreground" />
|
||||
{isDesktop && (
|
||||
<div className="text-primary">
|
||||
{context === "case"
|
||||
? t("bulkActions.moveToCase")
|
||||
: t("bulkActions.addToCase")}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Remove from Case (case context only) */}
|
||||
{context === "case" && (
|
||||
<Button
|
||||
className="flex items-center gap-2 p-2"
|
||||
aria-label={t("bulkActions.removeFromCase")}
|
||||
size="sm"
|
||||
onClick={() => setRemoveDialogOpen(true)}
|
||||
>
|
||||
<LuFolderX className="text-secondary-foreground" />
|
||||
{isDesktop && (
|
||||
<div className="text-primary">
|
||||
{t("bulkActions.removeFromCase")}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Delete */}
|
||||
<Button
|
||||
className="flex items-center gap-2 p-2"
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<HiTrash className="text-secondary-foreground" />
|
||||
{isDesktop && (
|
||||
<div className="text-primary">
|
||||
{bypassDialog
|
||||
? t("bulkActions.deleteNow")
|
||||
: t("bulkActions.delete")}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -6,7 +6,6 @@ import { isDesktop } from "react-device-detect";
|
||||
import { FaCompactDisc } from "react-icons/fa";
|
||||
import { HiTrash } from "react-icons/hi";
|
||||
import { ReviewSegment } from "@/types/review";
|
||||
import { MAX_BATCH_EXPORT_ITEMS } from "@/types/export";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@ -21,7 +20,6 @@ import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import MultiExportDialog from "../overlay/MultiExportDialog";
|
||||
|
||||
type ReviewActionGroupProps = {
|
||||
selectedReviews: ReviewSegment[];
|
||||
@ -166,29 +164,6 @@ export default function ReviewActionGroup({
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{selectedReviews.length >= 2 &&
|
||||
selectedReviews.length <= MAX_BATCH_EXPORT_ITEMS && (
|
||||
<MultiExportDialog
|
||||
selectedReviews={selectedReviews}
|
||||
onStarted={() => {
|
||||
onClearSelected();
|
||||
pullLatestData();
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
className="flex items-center gap-2 p-2"
|
||||
aria-label={t("recording.button.export")}
|
||||
size="sm"
|
||||
>
|
||||
<FaCompactDisc className="text-secondary-foreground" />
|
||||
{isDesktop && (
|
||||
<div className="text-primary">
|
||||
{t("recording.button.export")}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</MultiExportDialog>
|
||||
)}
|
||||
<Button
|
||||
className="flex items-center gap-2 p-2"
|
||||
aria-label={
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -4,7 +4,7 @@ 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, ExportTab } from "./ExportDialog";
|
||||
import { ExportContent, ExportPreviewDialog } from "./ExportDialog";
|
||||
import {
|
||||
DebugReplayContent,
|
||||
SaveDebugReplayOverlay,
|
||||
@ -26,7 +26,6 @@ import SaveExportOverlay from "./SaveExportOverlay";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { StartExportResponse } from "@/types/export";
|
||||
|
||||
type DrawerMode =
|
||||
| "none"
|
||||
@ -103,7 +102,6 @@ export default function MobileReviewSettingsDrawer({
|
||||
]);
|
||||
const navigate = useNavigate();
|
||||
const [drawerMode, setDrawerMode] = useState<DrawerMode>("none");
|
||||
const [exportTab, setExportTab] = useState<ExportTab>("export");
|
||||
const [selectedReplayOption, setSelectedReplayOption] = useState<
|
||||
"1" | "5" | "custom" | "timeline"
|
||||
>("1");
|
||||
@ -115,63 +113,35 @@ export default function MobileReviewSettingsDrawer({
|
||||
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [singleNewCaseName, setSingleNewCaseName] = useState("");
|
||||
const [singleNewCaseDescription, setSingleNewCaseDescription] = useState("");
|
||||
const [isStartingExport, setIsStartingExport] = useState(false);
|
||||
const onStartExport = useCallback(async () => {
|
||||
if (isStartingExport) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const onStartExport = useCallback(() => {
|
||||
if (!range) {
|
||||
toast.error(
|
||||
t("export.toast.error.noVaildTimeSelected", {
|
||||
ns: "components/dialog",
|
||||
}),
|
||||
{
|
||||
toast.error(t("toast.error.noValidTimeSelected"), {
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
return false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (range.before < range.after) {
|
||||
toast.error(
|
||||
t("export.toast.error.endTimeMustAfterStartTime", {
|
||||
ns: "components/dialog",
|
||||
}),
|
||||
{
|
||||
toast.error(t("toast.error.endTimeMustAfterStartTime"), {
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsStartingExport(true);
|
||||
|
||||
try {
|
||||
let exportCaseId: string | undefined = selectedCaseId;
|
||||
|
||||
if (selectedCaseId === "new" && singleNewCaseName.trim().length > 0) {
|
||||
const caseResp = await axios.post("cases", {
|
||||
name: singleNewCaseName.trim(),
|
||||
description: singleNewCaseDescription.trim() || undefined,
|
||||
});
|
||||
exportCaseId = caseResp.data?.id;
|
||||
} else if (selectedCaseId === "new" || selectedCaseId === "none") {
|
||||
exportCaseId = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
await axios.post<StartExportResponse>(
|
||||
axios
|
||||
.post(
|
||||
`export/${camera}/start/${Math.round(range.after)}/end/${Math.round(range.before)}`,
|
||||
{
|
||||
source: "recordings",
|
||||
playback: "realtime",
|
||||
name,
|
||||
export_case_id: exportCaseId,
|
||||
export_case_id: selectedCaseId || undefined,
|
||||
},
|
||||
);
|
||||
|
||||
toast.success(t("export.toast.queued", { ns: "components/dialog" }), {
|
||||
)
|
||||
.then((response) => {
|
||||
if (response.status == 200) {
|
||||
toast.success(
|
||||
t("export.toast.success", { ns: "components/dialog" }),
|
||||
{
|
||||
position: "top-center",
|
||||
action: (
|
||||
<a href="/export" target="_blank" rel="noopener noreferrer">
|
||||
@ -180,47 +150,30 @@ export default function MobileReviewSettingsDrawer({
|
||||
</Button>
|
||||
</a>
|
||||
),
|
||||
});
|
||||
},
|
||||
);
|
||||
setName("");
|
||||
setSelectedCaseId(undefined);
|
||||
setSingleNewCaseName("");
|
||||
setSingleNewCaseDescription("");
|
||||
setRange(undefined);
|
||||
setMode("none");
|
||||
return true;
|
||||
} catch (error) {
|
||||
const apiError = error as {
|
||||
response?: { data?: { message?: string; detail?: string } };
|
||||
};
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
apiError.response?.data?.message ||
|
||||
apiError.response?.data?.detail ||
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(
|
||||
t("export.toast.error.failed", {
|
||||
ns: "components/dialog",
|
||||
error: errorMessage,
|
||||
errorMessage,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
setIsStartingExport(false);
|
||||
}
|
||||
}, [
|
||||
camera,
|
||||
isStartingExport,
|
||||
name,
|
||||
range,
|
||||
selectedCaseId,
|
||||
singleNewCaseDescription,
|
||||
singleNewCaseName,
|
||||
setRange,
|
||||
setMode,
|
||||
t,
|
||||
]);
|
||||
});
|
||||
}, [camera, name, range, selectedCaseId, setRange, setName, setMode, t]);
|
||||
|
||||
const onStartDebugReplay = useCallback(async () => {
|
||||
if (
|
||||
@ -314,7 +267,6 @@ export default function MobileReviewSettingsDrawer({
|
||||
className="flex w-full items-center justify-center gap-2"
|
||||
aria-label={t("export")}
|
||||
onClick={() => {
|
||||
setExportTab("export");
|
||||
setDrawerMode("export");
|
||||
setMode("select");
|
||||
}}
|
||||
@ -379,21 +331,14 @@ export default function MobileReviewSettingsDrawer({
|
||||
range={range}
|
||||
name={name}
|
||||
selectedCaseId={selectedCaseId}
|
||||
singleNewCaseName={singleNewCaseName}
|
||||
singleNewCaseDescription={singleNewCaseDescription}
|
||||
activeTab={exportTab}
|
||||
isStartingExport={isStartingExport}
|
||||
onStartExport={onStartExport}
|
||||
setActiveTab={setExportTab}
|
||||
setName={setName}
|
||||
setSelectedCaseId={setSelectedCaseId}
|
||||
setSingleNewCaseName={setSingleNewCaseName}
|
||||
setSingleNewCaseDescription={setSingleNewCaseDescription}
|
||||
setRange={setRange}
|
||||
setMode={(mode) => {
|
||||
setMode(mode);
|
||||
|
||||
if (mode == "timeline" || mode == "timeline_multi") {
|
||||
if (mode == "timeline") {
|
||||
setDrawerMode("none");
|
||||
}
|
||||
}}
|
||||
@ -401,9 +346,6 @@ export default function MobileReviewSettingsDrawer({
|
||||
setMode("none");
|
||||
setRange(undefined);
|
||||
setSelectedCaseId(undefined);
|
||||
setSingleNewCaseName("");
|
||||
setSingleNewCaseDescription("");
|
||||
setExportTab("export");
|
||||
setDrawerMode("select");
|
||||
}}
|
||||
/>
|
||||
@ -541,29 +483,9 @@ export default function MobileReviewSettingsDrawer({
|
||||
<>
|
||||
<SaveExportOverlay
|
||||
className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2"
|
||||
show={mode == "timeline" || mode == "timeline_multi"}
|
||||
hidePreview={mode == "timeline_multi"}
|
||||
isSaving={isStartingExport}
|
||||
saveLabel={
|
||||
mode == "timeline_multi"
|
||||
? t("export.fromTimeline.useThisRange", { ns: "components/dialog" })
|
||||
: undefined
|
||||
}
|
||||
onSave={() => {
|
||||
if (mode == "timeline_multi") {
|
||||
setExportTab("multi");
|
||||
setDrawerMode("export");
|
||||
setMode("select");
|
||||
return;
|
||||
}
|
||||
|
||||
void onStartExport();
|
||||
}}
|
||||
onCancel={() => {
|
||||
setExportTab("export");
|
||||
setRange(undefined);
|
||||
setMode("none");
|
||||
}}
|
||||
show={mode == "timeline"}
|
||||
onSave={() => onStartExport()}
|
||||
onCancel={() => setMode("none")}
|
||||
onPreview={() => setShowExportPreview(true)}
|
||||
/>
|
||||
<SaveDebugReplayOverlay
|
||||
|
||||
@ -1,403 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import useSWR from "swr";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../ui/dialog";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "../ui/drawer";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
|
||||
import {
|
||||
BatchExportBody,
|
||||
BatchExportResponse,
|
||||
BatchExportResult,
|
||||
ExportCase,
|
||||
} from "@/types/export";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { REVIEW_PADDING, ReviewSegment } from "@/types/review";
|
||||
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
|
||||
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
|
||||
type MultiExportDialogProps = {
|
||||
selectedReviews: ReviewSegment[];
|
||||
onStarted: () => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const NONE_CASE_OPTION = "none";
|
||||
const NEW_CASE_OPTION = "new";
|
||||
|
||||
export default function MultiExportDialog({
|
||||
selectedReviews,
|
||||
onStarted,
|
||||
children,
|
||||
}: MultiExportDialogProps) {
|
||||
const { t } = useTranslation(["components/dialog", "common"]);
|
||||
const locale = useDateLocale();
|
||||
const navigate = useNavigate();
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
// Only admins can attach exports to an existing case (enforced server-side
|
||||
// by POST /exports/batch). Skip fetching the case list entirely for
|
||||
// non-admins — they can only ever use the "Create new case" branch.
|
||||
const { data: cases } = useSWR<ExportCase[]>(isAdmin ? "cases" : null);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [caseSelection, setCaseSelection] = useState<string>(NONE_CASE_OPTION);
|
||||
const [newCaseName, setNewCaseName] = useState("");
|
||||
const [newCaseDescription, setNewCaseDescription] = useState("");
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const count = selectedReviews.length;
|
||||
|
||||
// Resolve a failed batch result back to a human-readable label via the
|
||||
// client-provided review id when available. Falls back to item_index and
|
||||
// finally camera name for defensive compatibility.
|
||||
const formatFailureLabel = useCallback(
|
||||
(result: BatchExportResult): string => {
|
||||
const cameraName = resolveCameraName(config, result.camera);
|
||||
if (result.client_item_id) {
|
||||
const review = selectedReviews.find(
|
||||
(item) => item.id === result.client_item_id,
|
||||
);
|
||||
if (review) {
|
||||
const time = formatUnixTimestampToDateTime(review.start_time, {
|
||||
date_style: "short",
|
||||
time_style: "short",
|
||||
locale,
|
||||
});
|
||||
return `${cameraName} • ${time}`;
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof result.item_index === "number" &&
|
||||
result.item_index >= 0 &&
|
||||
result.item_index < selectedReviews.length
|
||||
) {
|
||||
const review = selectedReviews[result.item_index];
|
||||
const time = formatUnixTimestampToDateTime(review.start_time, {
|
||||
date_style: "short",
|
||||
time_style: "short",
|
||||
locale,
|
||||
});
|
||||
return `${cameraName} • ${time}`;
|
||||
}
|
||||
return cameraName;
|
||||
},
|
||||
[config, locale, selectedReviews],
|
||||
);
|
||||
|
||||
const defaultCaseName = useMemo(() => {
|
||||
const formattedDate = formatUnixTimestampToDateTime(Date.now() / 1000, {
|
||||
date_style: "medium",
|
||||
time_style: "short",
|
||||
locale,
|
||||
});
|
||||
return t("export.multi.caseNamePlaceholder", {
|
||||
ns: "components/dialog",
|
||||
date: formattedDate,
|
||||
});
|
||||
}, [t, locale]);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setCaseSelection(NONE_CASE_OPTION);
|
||||
setNewCaseName("");
|
||||
setNewCaseDescription("");
|
||||
setIsExporting(false);
|
||||
}, []);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!next) {
|
||||
resetState();
|
||||
} else {
|
||||
// Freshly reset each time so the default name reflects "now"
|
||||
setCaseSelection(NONE_CASE_OPTION);
|
||||
setNewCaseName(defaultCaseName);
|
||||
setNewCaseDescription("");
|
||||
setIsExporting(false);
|
||||
}
|
||||
setOpen(next);
|
||||
},
|
||||
[defaultCaseName, resetState],
|
||||
);
|
||||
|
||||
const existingCases = useMemo(() => {
|
||||
return (cases ?? []).slice().sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [cases]);
|
||||
|
||||
const isNewCase = caseSelection === NEW_CASE_OPTION;
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
if (isExporting) return false;
|
||||
if (count === 0) return false;
|
||||
if (!isAdmin) return true;
|
||||
if (isNewCase) {
|
||||
return newCaseName.trim().length > 0;
|
||||
}
|
||||
return caseSelection.length > 0;
|
||||
}, [caseSelection, count, isAdmin, isExporting, isNewCase, newCaseName]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!canSubmit) return;
|
||||
|
||||
const items = selectedReviews.map((review) => ({
|
||||
camera: review.camera,
|
||||
start_time: review.start_time - REVIEW_PADDING,
|
||||
end_time: (review.end_time ?? Date.now() / 1000) + REVIEW_PADDING,
|
||||
image_path: review.thumb_path || undefined,
|
||||
client_item_id: review.id,
|
||||
}));
|
||||
|
||||
const payload: BatchExportBody = { items };
|
||||
|
||||
if (isAdmin && caseSelection !== NONE_CASE_OPTION) {
|
||||
if (isNewCase) {
|
||||
payload.new_case_name = newCaseName.trim();
|
||||
payload.new_case_description = newCaseDescription.trim() || undefined;
|
||||
} else {
|
||||
payload.export_case_id = caseSelection;
|
||||
}
|
||||
}
|
||||
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const response = await axios.post<BatchExportResponse>(
|
||||
"exports/batch",
|
||||
payload,
|
||||
);
|
||||
|
||||
const results = response.data.results ?? [];
|
||||
const successful = results.filter((r) => r.success);
|
||||
const failed = results.filter((r) => !r.success);
|
||||
|
||||
if (successful.length > 0 && failed.length === 0) {
|
||||
toast.success(
|
||||
t(
|
||||
isAdmin
|
||||
? "export.multi.toast.started"
|
||||
: "export.multi.toast.startedNoCase",
|
||||
{
|
||||
ns: "components/dialog",
|
||||
count: successful.length,
|
||||
},
|
||||
),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
} else if (successful.length > 0 && failed.length > 0) {
|
||||
// Resolve each failure to its review via item_index so same-camera
|
||||
// items are disambiguated by time. Falls back to camera-only if the
|
||||
// server didn't populate item_index.
|
||||
const failedLabels = failed.map(formatFailureLabel).join(", ");
|
||||
toast.success(
|
||||
t("export.multi.toast.partial", {
|
||||
ns: "components/dialog",
|
||||
successful: successful.length,
|
||||
total: results.length,
|
||||
failedItems: failedLabels,
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
} else {
|
||||
const failedLabels = failed.map(formatFailureLabel).join(", ");
|
||||
toast.error(
|
||||
t("export.multi.toast.failed", {
|
||||
ns: "components/dialog",
|
||||
total: results.length,
|
||||
failedItems: failedLabels,
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
}
|
||||
|
||||
if (successful.length > 0) {
|
||||
onStarted();
|
||||
setOpen(false);
|
||||
resetState();
|
||||
if (response.data.export_case_id) {
|
||||
navigate(`/export?caseId=${response.data.export_case_id}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const apiError = error as {
|
||||
response?: { data?: { message?: string; detail?: string } };
|
||||
};
|
||||
const errorMessage =
|
||||
apiError.response?.data?.message ||
|
||||
apiError.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(
|
||||
t("export.toast.error.failed", {
|
||||
ns: "components/dialog",
|
||||
error: errorMessage,
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}, [
|
||||
canSubmit,
|
||||
caseSelection,
|
||||
formatFailureLabel,
|
||||
isAdmin,
|
||||
isNewCase,
|
||||
navigate,
|
||||
newCaseDescription,
|
||||
newCaseName,
|
||||
onStarted,
|
||||
resetState,
|
||||
selectedReviews,
|
||||
t,
|
||||
]);
|
||||
|
||||
// New-case inputs: rendered below the Select when caseSelection === "new",
|
||||
// or rendered standalone for non-admins (who never see the Select since
|
||||
// they cannot attach to an existing case).
|
||||
const newCaseInputs = (
|
||||
<div className="space-y-2 pt-1">
|
||||
<Input
|
||||
className="text-md"
|
||||
placeholder={t("export.case.newCaseNamePlaceholder")}
|
||||
value={newCaseName}
|
||||
onChange={(event) => setNewCaseName(event.target.value)}
|
||||
maxLength={100}
|
||||
autoFocus={isDesktop}
|
||||
/>
|
||||
<Textarea
|
||||
className="text-md"
|
||||
placeholder={t("export.case.newCaseDescriptionPlaceholder")}
|
||||
value={newCaseDescription}
|
||||
onChange={(event) => setNewCaseDescription(event.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const body = (
|
||||
<div className="flex flex-col gap-4">
|
||||
{isAdmin && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-secondary-foreground">
|
||||
{t("export.case.label")}
|
||||
</Label>
|
||||
<Select
|
||||
value={caseSelection}
|
||||
onValueChange={(value) => setCaseSelection(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("export.case.placeholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE_CASE_OPTION}>
|
||||
{t("label.none", { ns: "common" })}
|
||||
</SelectItem>
|
||||
{existingCases.map((caseItem) => (
|
||||
<SelectItem key={caseItem.id} value={caseItem.id}>
|
||||
{caseItem.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectSeparator />
|
||||
<SelectItem value={NEW_CASE_OPTION}>
|
||||
{t("export.case.newCaseOption")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isNewCase && newCaseInputs}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isExporting}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
aria-label={t("export.multi.exportButton", { count })}
|
||||
>
|
||||
{isExporting
|
||||
? t("export.multi.exportingButton")
|
||||
: t("export.multi.exportButton", { count })}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isDesktop) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("export.multi.title", { count })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isAdmin
|
||||
? t("export.multi.description")
|
||||
: t("export.multi.descriptionNoCase")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{body}
|
||||
<DialogFooter className="gap-2">{footer}</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={handleOpenChange}>
|
||||
<DrawerTrigger asChild>{children}</DrawerTrigger>
|
||||
<DrawerContent className="px-4 pb-6">
|
||||
<DrawerHeader className="px-0">
|
||||
<DrawerTitle>{t("export.multi.title", { count })}</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
{isAdmin
|
||||
? t("export.multi.description")
|
||||
: t("export.multi.descriptionNoCase")}
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
{body}
|
||||
<div className="mt-4 flex flex-col-reverse gap-2">{footer}</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@ -7,9 +7,6 @@ import { useTranslation } from "react-i18next";
|
||||
type SaveExportOverlayProps = {
|
||||
className: string;
|
||||
show: boolean;
|
||||
hidePreview?: boolean;
|
||||
saveLabel?: string;
|
||||
isSaving?: boolean;
|
||||
onPreview: () => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
@ -17,9 +14,6 @@ type SaveExportOverlayProps = {
|
||||
export default function SaveExportOverlay({
|
||||
className,
|
||||
show,
|
||||
hidePreview = false,
|
||||
saveLabel,
|
||||
isSaving = false,
|
||||
onPreview,
|
||||
onSave,
|
||||
onCancel,
|
||||
@ -38,36 +32,29 @@ export default function SaveExportOverlay({
|
||||
className="flex items-center gap-1 text-primary"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
size="sm"
|
||||
disabled={isSaving}
|
||||
onClick={onCancel}
|
||||
>
|
||||
<LuX />
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
{!hidePreview && (
|
||||
<Button
|
||||
className="flex items-center gap-1"
|
||||
aria-label={t("export.fromTimeline.previewExport")}
|
||||
size="sm"
|
||||
disabled={isSaving}
|
||||
onClick={onPreview}
|
||||
>
|
||||
<LuVideo />
|
||||
{t("export.fromTimeline.previewExport")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="flex items-center gap-1"
|
||||
aria-label={saveLabel || t("export.fromTimeline.saveExport")}
|
||||
aria-label={t("export.fromTimeline.saveExport")}
|
||||
variant="select"
|
||||
size="sm"
|
||||
disabled={isSaving}
|
||||
onClick={onSave}
|
||||
>
|
||||
<FaCompactDisc />
|
||||
{isSaving
|
||||
? t("export.fromTimeline.queueingExport")
|
||||
: saveLabel || t("export.fromTimeline.saveExport")}
|
||||
{t("export.fromTimeline.saveExport")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -8,7 +8,6 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -16,10 +15,9 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type Option = {
|
||||
@ -37,8 +35,8 @@ type OptionAndInputDialogProps = {
|
||||
nameLabel: string;
|
||||
descriptionLabel: string;
|
||||
setOpen: (open: boolean) => void;
|
||||
onSave: (value: string) => Promise<void>;
|
||||
onCreateNew: (name: string, description: string) => Promise<void>;
|
||||
onSave: (value: string) => void;
|
||||
onCreateNew: (name: string, description: string) => void;
|
||||
};
|
||||
|
||||
export default function OptionAndInputDialog({
|
||||
@ -71,12 +69,10 @@ export default function OptionAndInputDialog({
|
||||
}
|
||||
}, [open, initialValue, firstOption]);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const isNew = selectedValue === newValueKey;
|
||||
const disableSave =
|
||||
!selectedValue || (isNew && name.trim().length === 0) || isLoading;
|
||||
const disableSave = !selectedValue || (isNew && name.trim().length === 0);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const handleSave = () => {
|
||||
if (!selectedValue) {
|
||||
return;
|
||||
}
|
||||
@ -84,26 +80,13 @@ export default function OptionAndInputDialog({
|
||||
const trimmedName = name.trim();
|
||||
const trimmedDescription = descriptionValue.trim();
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (isNew) {
|
||||
await onCreateNew(trimmedName, trimmedDescription);
|
||||
onCreateNew(trimmedName, trimmedDescription);
|
||||
} else {
|
||||
await onSave(selectedValue);
|
||||
onSave(selectedValue);
|
||||
}
|
||||
setOpen(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [
|
||||
selectedValue,
|
||||
name,
|
||||
descriptionValue,
|
||||
isNew,
|
||||
onCreateNew,
|
||||
onSave,
|
||||
setOpen,
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} defaultOpen={false} onOpenChange={setOpen}>
|
||||
@ -144,21 +127,15 @@ export default function OptionAndInputDialog({
|
||||
<label className="text-sm font-medium text-secondary-foreground">
|
||||
{nameLabel}
|
||||
</label>
|
||||
<Input
|
||||
className="text-md"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium text-secondary-foreground">
|
||||
{descriptionLabel}
|
||||
</label>
|
||||
<Textarea
|
||||
className="text-md"
|
||||
<Input
|
||||
value={descriptionValue}
|
||||
onChange={(e) => setDescriptionValue(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -168,7 +145,6 @@ export default function OptionAndInputDialog({
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
@ -179,13 +155,9 @@ export default function OptionAndInputDialog({
|
||||
type="button"
|
||||
variant="select"
|
||||
disabled={disableSave}
|
||||
onClick={() => void handleSave()}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator className="size-4" />
|
||||
) : (
|
||||
t("button.save")
|
||||
)}
|
||||
{t("button.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@ -19,7 +19,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
closeButton:
|
||||
"group-[.toast]:bg-secondary group-[.toast]:text-primary group-[.toast]:border-primary group-[.toast]:border-[1px]",
|
||||
"group-[.toast]:bg-secondary border-primary border-[1px]",
|
||||
success:
|
||||
"group toast group-[.toaster]:bg-success group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
error:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -6,8 +6,7 @@ export type Export = {
|
||||
video_path: string;
|
||||
thumb_path: string;
|
||||
in_progress: boolean;
|
||||
export_case?: string | null;
|
||||
export_case_id?: string | null;
|
||||
export_case?: string;
|
||||
};
|
||||
|
||||
export type ExportCase = {
|
||||
@ -18,81 +17,6 @@ export type ExportCase = {
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
export type BatchExportBody = {
|
||||
items: BatchExportItem[];
|
||||
export_case_id?: string;
|
||||
new_case_name?: string;
|
||||
new_case_description?: string;
|
||||
};
|
||||
|
||||
export const MAX_BATCH_EXPORT_ITEMS = 50;
|
||||
|
||||
export type BatchExportItem = {
|
||||
camera: string;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
image_path?: string;
|
||||
friendly_name?: string;
|
||||
client_item_id?: string;
|
||||
};
|
||||
|
||||
export type BatchExportResult = {
|
||||
camera: string;
|
||||
export_id?: string | null;
|
||||
success: boolean;
|
||||
status?: string | null;
|
||||
error?: string | null;
|
||||
item_index?: number | null;
|
||||
client_item_id?: string | null;
|
||||
};
|
||||
|
||||
export type BatchExportResponse = {
|
||||
export_case_id?: string | null;
|
||||
export_ids: string[];
|
||||
results: BatchExportResult[];
|
||||
};
|
||||
|
||||
export type StartExportResponse = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
export_id?: string | null;
|
||||
status?: string | null;
|
||||
};
|
||||
|
||||
export type ExportJob = {
|
||||
id: string;
|
||||
job_type: string;
|
||||
status: string;
|
||||
camera: string;
|
||||
name?: string | null;
|
||||
export_case_id?: string | null;
|
||||
request_start_time: number;
|
||||
request_end_time: number;
|
||||
start_time?: number | null;
|
||||
end_time?: number | null;
|
||||
error_message?: string | null;
|
||||
results?: {
|
||||
export_id?: string;
|
||||
export_case_id?: string | null;
|
||||
video_path?: string;
|
||||
thumb_path?: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type CameraActivitySegment = {
|
||||
/** Fractional start position within the time range, 0-1 inclusive. */
|
||||
start: number;
|
||||
/** Fractional end position within the time range, 0-1 inclusive. */
|
||||
end: number;
|
||||
};
|
||||
|
||||
export type CameraActivity = {
|
||||
camera: string;
|
||||
count: number;
|
||||
hasDetections: boolean;
|
||||
segments: CameraActivitySegment[];
|
||||
};
|
||||
|
||||
export type DeleteClipType = {
|
||||
file: string;
|
||||
exportName: string;
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type FilterType = { [searchKey: string]: any };
|
||||
|
||||
export type ExportMode = "select" | "timeline" | "timeline_multi" | "none";
|
||||
export type ExportMode = "select" | "timeline" | "none";
|
||||
|
||||
export type FilterList = {
|
||||
labels?: string[];
|
||||
|
||||
@ -270,10 +270,7 @@ export default function MotionSearchView({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
(exportMode !== "timeline" && exportMode !== "timeline_multi") ||
|
||||
exportRange
|
||||
) {
|
||||
if (exportMode !== "timeline" || exportRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -958,25 +955,9 @@ export default function MotionSearchView({
|
||||
|
||||
<SaveExportOverlay
|
||||
className="pointer-events-none absolute inset-x-0 top-0 z-30"
|
||||
show={
|
||||
(exportMode === "timeline" || exportMode === "timeline_multi") &&
|
||||
Boolean(exportRange)
|
||||
}
|
||||
hidePreview={exportMode === "timeline_multi"}
|
||||
saveLabel={
|
||||
exportMode === "timeline_multi"
|
||||
? t("export.fromTimeline.useThisRange", { ns: "components/dialog" })
|
||||
: undefined
|
||||
}
|
||||
show={exportMode === "timeline" && Boolean(exportRange)}
|
||||
onPreview={handleExportPreview}
|
||||
onSave={() => {
|
||||
if (exportMode === "timeline_multi") {
|
||||
setExportMode("select");
|
||||
return;
|
||||
}
|
||||
|
||||
handleExportSave();
|
||||
}}
|
||||
onSave={handleExportSave}
|
||||
onCancel={handleExportCancel}
|
||||
/>
|
||||
|
||||
@ -995,10 +976,7 @@ export default function MotionSearchView({
|
||||
noRecordingRanges={noRecordings ?? []}
|
||||
contentRef={contentRef}
|
||||
onHandlebarDraggingChange={(dragging) => setScrubbing(dragging)}
|
||||
showExportHandles={
|
||||
(exportMode === "timeline" || exportMode === "timeline_multi") &&
|
||||
Boolean(exportRange)
|
||||
}
|
||||
showExportHandles={exportMode === "timeline" && Boolean(exportRange)}
|
||||
exportStartTime={exportRange?.after}
|
||||
exportEndTime={exportRange?.before}
|
||||
setExportStartTime={setExportStartTime}
|
||||
@ -1430,11 +1408,7 @@ export default function MotionSearchView({
|
||||
onControllerReady={(controller) => {
|
||||
mainControllerRef.current = controller;
|
||||
}}
|
||||
isScrubbing={
|
||||
scrubbing ||
|
||||
exportMode == "timeline" ||
|
||||
exportMode == "timeline_multi"
|
||||
}
|
||||
isScrubbing={scrubbing || exportMode == "timeline"}
|
||||
supportsFullscreen={supportsFullScreen}
|
||||
setFullResolution={setFullResolution}
|
||||
toggleFullscreen={toggleFullscreen}
|
||||
|
||||
@ -833,7 +833,6 @@ export function RecordingView({
|
||||
isScrubbing={
|
||||
scrubbing ||
|
||||
exportMode == "timeline" ||
|
||||
exportMode == "timeline_multi" ||
|
||||
debugReplayMode == "timeline"
|
||||
}
|
||||
supportsFullscreen={supportsFullScreen}
|
||||
@ -912,7 +911,7 @@ export function RecordingView({
|
||||
activeReviewItem={activeReviewItem}
|
||||
currentTime={currentTime}
|
||||
exportRange={
|
||||
exportMode == "timeline" || exportMode == "timeline_multi"
|
||||
exportMode == "timeline"
|
||||
? exportRange
|
||||
: debugReplayMode == "timeline"
|
||||
? debugReplayRange
|
||||
|
||||
Loading…
Reference in New Issue
Block a user