forked from Github/frigate
Compare commits
30 Commits
model-fixe
...
v0.15.0-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33825f6d96 | ||
|
|
eca504cb07 | ||
|
|
4c75440af4 | ||
|
|
94f7528885 | ||
|
|
4dadf6d353 | ||
|
|
2d27e72ed9 | ||
|
|
4ff0c8a8d1 | ||
|
|
f9fba94863 | ||
|
|
f9b246dbd0 | ||
|
|
8fefded8dc | ||
|
|
18824830fd | ||
|
|
fa81d87dc0 | ||
|
|
8bc145472a | ||
|
|
7afc1e9762 | ||
|
|
fc59c83e16 | ||
|
|
e4048be088 | ||
|
|
d715a8c290 | ||
|
|
ad308252a1 | ||
|
|
c7d9f83638 | ||
|
|
828fdbfd2d | ||
|
|
40c6fda19d | ||
|
|
b69816c2f9 | ||
|
|
46f5234bd9 | ||
|
|
81b8d7a66b | ||
|
|
b1285a16c1 | ||
|
|
90140e7710 | ||
|
|
8364e68667 | ||
|
|
4bb420d049 | ||
|
|
560dc68120 | ||
|
|
8fcb8e54f7 |
@@ -42,6 +42,7 @@ codeproject
|
||||
colormap
|
||||
colorspace
|
||||
comms
|
||||
coro
|
||||
ctypeslib
|
||||
CUDA
|
||||
Cuvid
|
||||
@@ -59,6 +60,7 @@ dsize
|
||||
dtype
|
||||
ECONNRESET
|
||||
edgetpu
|
||||
fastapi
|
||||
faststart
|
||||
fflags
|
||||
ffprobe
|
||||
@@ -237,6 +239,7 @@ sleeptime
|
||||
SNDMORE
|
||||
socs
|
||||
sqliteq
|
||||
sqlitevecq
|
||||
ssdlite
|
||||
statm
|
||||
stimeout
|
||||
@@ -271,6 +274,7 @@ unraid
|
||||
unreviewed
|
||||
userdata
|
||||
usermod
|
||||
uvicorn
|
||||
vaapi
|
||||
vainfo
|
||||
variations
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -6,6 +6,8 @@ on:
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
|
||||
# only run the latest commit to avoid cache overwrites
|
||||
concurrency:
|
||||
|
||||
5
.github/workflows/pull_request.yml
vendored
5
.github/workflows/pull_request.yml
vendored
@@ -1,6 +1,9 @@
|
||||
name: On pull request
|
||||
|
||||
on: pull_request
|
||||
on:
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
|
||||
env:
|
||||
DEFAULT_PYTHON: 3.9
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -34,14 +34,14 @@ jobs:
|
||||
STABLE_TAG=${BASE}:stable
|
||||
PULL_TAG=${BASE}:${BUILD_TAG}
|
||||
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG} docker://${VERSION_TAG}
|
||||
for variant in standard-arm64 tensorrt tensorrt-jp4 tensorrt-jp5 rk; do
|
||||
for variant in standard-arm64 tensorrt tensorrt-jp4 tensorrt-jp5 rk h8l rocm; do
|
||||
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG}-${variant} docker://${VERSION_TAG}-${variant}
|
||||
done
|
||||
|
||||
# stable tag
|
||||
if [[ "${BUILD_TYPE}" == "stable" ]]; then
|
||||
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG} docker://${STABLE_TAG}
|
||||
for variant in standard-arm64 tensorrt tensorrt-jp4 tensorrt-jp5 rk; do
|
||||
for variant in standard-arm64 tensorrt tensorrt-jp4 tensorrt-jp5 rk h8l rocm; do
|
||||
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG}-${variant} docker://${STABLE_TAG}-${variant}
|
||||
done
|
||||
fi
|
||||
|
||||
@@ -38,7 +38,7 @@ cd ../../
|
||||
if [ ! -d /lib/firmware/hailo ]; then
|
||||
sudo mkdir /lib/firmware/hailo
|
||||
fi
|
||||
sudo mv hailo8_fw.4.17.0.bin /lib/firmware/hailo/hailo8_fw.bin
|
||||
sudo mv hailo8_fw.4.18.0.bin /lib/firmware/hailo/hailo8_fw.bin
|
||||
|
||||
# Install udev rules
|
||||
sudo cp ./linux/pcie/51-hailo-udev.rules /etc/udev/rules.d/
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
click == 8.1.*
|
||||
# FastAPI
|
||||
starlette-context == 0.3.6
|
||||
fastapi == 0.115.0
|
||||
fastapi == 0.115.*
|
||||
uvicorn == 0.30.*
|
||||
slowapi == 0.1.9
|
||||
slowapi == 0.1.*
|
||||
imutils == 0.5.*
|
||||
joserfc == 1.0.*
|
||||
pathvalidate == 3.2.*
|
||||
@@ -16,10 +16,10 @@ paho-mqtt == 2.1.*
|
||||
pandas == 2.2.*
|
||||
peewee == 3.17.*
|
||||
peewee_migrate == 1.13.*
|
||||
psutil == 5.9.*
|
||||
psutil == 6.1.*
|
||||
pydantic == 2.8.*
|
||||
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
|
||||
pytz == 2024.1
|
||||
pytz == 2024.*
|
||||
pyzmq == 26.2.*
|
||||
ruamel.yaml == 0.18.*
|
||||
tzlocal == 5.2
|
||||
|
||||
@@ -9,6 +9,6 @@ nvidia-cuda-runtime-cu11 == 11.8.*; platform_machine == 'x86_64'
|
||||
nvidia-cublas-cu11 == 11.11.3.6; platform_machine == 'x86_64'
|
||||
nvidia-cudnn-cu11 == 8.6.0.*; platform_machine == 'x86_64'
|
||||
nvidia-cufft-cu11==10.*; platform_machine == 'x86_64'
|
||||
onnx==1.14.0; platform_machine == 'x86_64'
|
||||
onnxruntime-gpu==1.17.*; platform_machine == 'x86_64'
|
||||
onnx==1.16.*; platform_machine == 'x86_64'
|
||||
onnxruntime-gpu==1.18.*; platform_machine == 'x86_64'
|
||||
protobuf==3.20.3; platform_machine == 'x86_64'
|
||||
|
||||
@@ -29,11 +29,21 @@ cameras:
|
||||
|
||||
## Ollama
|
||||
|
||||
[Ollama](https://ollama.com/) allows you to self-host large language models and keep everything running locally. It provides a nice API over [llama.cpp](https://github.com/ggerganov/llama.cpp). It is highly recommended to host this server on a machine with an Nvidia graphics card, or on a Apple silicon Mac for best performance. Most of the 7b parameter 4-bit vision models will fit inside 8GB of VRAM. There is also a [docker container](https://hub.docker.com/r/ollama/ollama) available.
|
||||
:::warning
|
||||
|
||||
Using Ollama on CPU is not recommended, high inference times make using generative AI impractical.
|
||||
|
||||
:::
|
||||
|
||||
[Ollama](https://ollama.com/) allows you to self-host large language models and keep everything running locally. It provides a nice API over [llama.cpp](https://github.com/ggerganov/llama.cpp). It is highly recommended to host this server on a machine with an Nvidia graphics card, or on a Apple silicon Mac for best performance.
|
||||
|
||||
Most of the 7b parameter 4-bit vision models will fit inside 8GB of VRAM. There is also a [docker container](https://hub.docker.com/r/ollama/ollama) available.
|
||||
|
||||
Parallel requests also come with some caveats. See the [Ollama documentation](https://github.com/ollama/ollama/blob/main/docs/faq.md#how-does-ollama-handle-concurrent-requests).
|
||||
|
||||
### Supported Models
|
||||
|
||||
You must use a vision capable model with Frigate. Current model variants can be found [in their model library](https://ollama.com/library). At the time of writing, this includes `llava`, `llava-llama3`, `llava-phi3`, and `moondream`.
|
||||
You must use a vision capable model with Frigate. Current model variants can be found [in their model library](https://ollama.com/library). At the time of writing, this includes `llava`, `llava-llama3`, `llava-phi3`, and `moondream`. Note that Frigate will not automatically download the model you specify in your config, you must download the model to your local instance of Ollama first i.e. by running `ollama pull llava:7b` on your Ollama server/Docker container. Note that the model specified in Frigate's config must match the downloaded model tag.
|
||||
|
||||
:::note
|
||||
|
||||
@@ -48,7 +58,7 @@ genai:
|
||||
enabled: True
|
||||
provider: ollama
|
||||
base_url: http://localhost:11434
|
||||
model: llava
|
||||
model: llava:7b
|
||||
```
|
||||
|
||||
## Google Gemini
|
||||
|
||||
@@ -92,10 +92,16 @@ motion:
|
||||
lightning_threshold: 0.8
|
||||
```
|
||||
|
||||
:::tip
|
||||
:::warning
|
||||
|
||||
Some cameras like doorbell cameras may have missed detections when someone walks directly in front of the camera and the lightning_threshold causes motion detection to be re-calibrated. In this case, it may be desirable to increase the `lightning_threshold` to ensure these objects are not missed.
|
||||
|
||||
:::
|
||||
|
||||
:::note
|
||||
|
||||
Lightning threshold does not stop motion based recordings from being saved.
|
||||
|
||||
:::
|
||||
|
||||
Large changes in motion like PTZ moves and camera switches between Color and IR mode should result in no motion detection. This is done via the `lightning_threshold` configuration. It is defined as the percentage of the image used to detect lightning or other substantial changes where motion detection needs to recalibrate. Increasing this value will make motion detection more likely to consider lightning or IR mode changes as valid motion. Decreasing this value will make motion detection more likely to ignore large amounts of motion such as a person approaching a doorbell camera.
|
||||
|
||||
428
docs/static/frigate-api.yaml
vendored
428
docs/static/frigate-api.yaml
vendored
@@ -172,76 +172,65 @@ paths:
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
type: string
|
||||
default: all
|
||||
title: Cameras
|
||||
- name: labels
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
type: string
|
||||
default: all
|
||||
title: Labels
|
||||
- name: zones
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
type: string
|
||||
default: all
|
||||
title: Zones
|
||||
- name: reviewed
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: integer
|
||||
- type: 'null'
|
||||
type: integer
|
||||
default: 0
|
||||
title: Reviewed
|
||||
- name: limit
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: integer
|
||||
- type: 'null'
|
||||
type: integer
|
||||
title: Limit
|
||||
- name: severity
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SeverityEnum'
|
||||
title: Severity
|
||||
- name: before
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: 'null'
|
||||
type: number
|
||||
title: Before
|
||||
- name: after
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: 'null'
|
||||
type: number
|
||||
title: After
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: { }
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ReviewSegmentResponse'
|
||||
title: Response Review Review Get
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
@@ -259,36 +248,28 @@ paths:
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
type: string
|
||||
default: all
|
||||
title: Cameras
|
||||
- name: labels
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
type: string
|
||||
default: all
|
||||
title: Labels
|
||||
- name: zones
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
type: string
|
||||
default: all
|
||||
title: Zones
|
||||
- name: timezone
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
type: string
|
||||
default: utc
|
||||
title: Timezone
|
||||
responses:
|
||||
@@ -296,7 +277,8 @@ paths:
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: { }
|
||||
schema:
|
||||
$ref: '#/components/schemas/ReviewSummaryResponse'
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
@@ -310,17 +292,18 @@ paths:
|
||||
summary: Set Multiple Reviewed
|
||||
operationId: set_multiple_reviewed_reviews_viewed_post
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
title: Body
|
||||
$ref: '#/components/schemas/ReviewSetMultipleReviewedBody'
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: { }
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenericResponse'
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
@@ -334,17 +317,18 @@ paths:
|
||||
summary: Delete Reviews
|
||||
operationId: delete_reviews_reviews_delete_post
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
title: Body
|
||||
$ref: '#/components/schemas/ReviewDeleteMultipleReviewsBody'
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: { }
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenericResponse'
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
@@ -363,96 +347,38 @@ paths:
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
type: string
|
||||
default: all
|
||||
title: Cameras
|
||||
- name: before
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: 'null'
|
||||
type: number
|
||||
title: Before
|
||||
- name: after
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: 'null'
|
||||
type: number
|
||||
title: After
|
||||
- name: scale
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: integer
|
||||
- type: 'null'
|
||||
type: integer
|
||||
default: 30
|
||||
title: Scale
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: { }
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
/review/activity/audio:
|
||||
get:
|
||||
tags:
|
||||
- Review
|
||||
summary: Audio Activity
|
||||
description: Get motion and audio activity.
|
||||
operationId: audio_activity_review_activity_audio_get
|
||||
parameters:
|
||||
- name: cameras
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
default: all
|
||||
title: Cameras
|
||||
- name: before
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: 'null'
|
||||
title: Before
|
||||
- name: after
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: 'null'
|
||||
title: After
|
||||
- name: scale
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: integer
|
||||
- type: 'null'
|
||||
default: 30
|
||||
title: Scale
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: { }
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ReviewActivityMotionResponse'
|
||||
title: Response Motion Activity Review Activity Motion Get
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
@@ -477,57 +403,60 @@ paths:
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: { }
|
||||
schema:
|
||||
$ref: '#/components/schemas/ReviewSegmentResponse'
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
/review/{event_id}:
|
||||
/review/{review_id}:
|
||||
get:
|
||||
tags:
|
||||
- Review
|
||||
summary: Get Review
|
||||
operationId: get_review_review__event_id__get
|
||||
operationId: get_review_review__review_id__get
|
||||
parameters:
|
||||
- name: event_id
|
||||
- name: review_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
title: Event Id
|
||||
title: Review Id
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: { }
|
||||
schema:
|
||||
$ref: '#/components/schemas/ReviewSegmentResponse'
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
/review/{event_id}/viewed:
|
||||
/review/{review_id}/viewed:
|
||||
delete:
|
||||
tags:
|
||||
- Review
|
||||
summary: Set Not Reviewed
|
||||
operationId: set_not_reviewed_review__event_id__viewed_delete
|
||||
operationId: set_not_reviewed_review__review_id__viewed_delete
|
||||
parameters:
|
||||
- name: event_id
|
||||
- name: review_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
title: Event Id
|
||||
title: Review Id
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: { }
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenericResponse'
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
@@ -763,13 +692,25 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: { }
|
||||
/nvinfo:
|
||||
get:
|
||||
tags:
|
||||
- App
|
||||
summary: Nvinfo
|
||||
operationId: nvinfo_nvinfo_get
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: { }
|
||||
/logs/{service}:
|
||||
get:
|
||||
tags:
|
||||
- App
|
||||
- Logs
|
||||
summary: Logs
|
||||
description: Get logs for the requested service (frigate/nginx/go2rtc/chroma)
|
||||
description: Get logs for the requested service (frigate/nginx/go2rtc)
|
||||
operationId: logs_logs__service__get
|
||||
parameters:
|
||||
- name: service
|
||||
@@ -781,7 +722,6 @@ paths:
|
||||
- frigate
|
||||
- nginx
|
||||
- go2rtc
|
||||
- chroma
|
||||
title: Service
|
||||
- name: download
|
||||
in: query
|
||||
@@ -1042,7 +982,8 @@ paths:
|
||||
- Preview
|
||||
summary: Preview Hour
|
||||
description: Get all mp4 previews relevant for time period given the timezone
|
||||
operationId: preview_hour_preview__year_month___day___hour___camera_name___tz_name__get
|
||||
operationId: >-
|
||||
preview_hour_preview__year_month___day___hour___camera_name___tz_name__get
|
||||
parameters:
|
||||
- name: year_month
|
||||
in: path
|
||||
@@ -1092,7 +1033,8 @@ paths:
|
||||
- Preview
|
||||
summary: Get Preview Frames From Cache
|
||||
description: Get list of cached preview frames
|
||||
operationId: get_preview_frames_from_cache_preview__camera_name__start__start_ts__end__end_ts__frames_get
|
||||
operationId: >-
|
||||
get_preview_frames_from_cache_preview__camera_name__start__start_ts__end__end_ts__frames_get
|
||||
parameters:
|
||||
- name: camera_name
|
||||
in: path
|
||||
@@ -1177,7 +1119,8 @@ paths:
|
||||
tags:
|
||||
- Export
|
||||
summary: Export Recording
|
||||
operationId: export_recording_export__camera_name__start__start_time__end__end_time__post
|
||||
operationId: >-
|
||||
export_recording_export__camera_name__start__start_time__end__end_time__post
|
||||
parameters:
|
||||
- name: camera_name
|
||||
in: path
|
||||
@@ -1656,6 +1599,30 @@ paths:
|
||||
- type: 'null'
|
||||
default: utc
|
||||
title: Timezone
|
||||
- name: min_score
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: 'null'
|
||||
title: Min Score
|
||||
- name: max_score
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: 'null'
|
||||
title: Max Score
|
||||
- name: sort
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: Sort
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
@@ -1942,6 +1909,15 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
title: Event Id
|
||||
- name: source
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- $ref: '#/components/schemas/RegenerateDescriptionEnum'
|
||||
- type: 'null'
|
||||
default: thumbnails
|
||||
title: Source
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
@@ -2029,12 +2005,12 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
'{camera_name}':
|
||||
/{camera_name}:
|
||||
get:
|
||||
tags:
|
||||
- Media
|
||||
summary: Mjpeg Feed
|
||||
operationId: mjpeg_feed_camera_name__get
|
||||
operationId: mjpeg_feed__camera_name__get
|
||||
parameters:
|
||||
- name: camera_name
|
||||
in: path
|
||||
@@ -2241,7 +2217,8 @@ paths:
|
||||
tags:
|
||||
- Media
|
||||
summary: Get Snapshot From Recording
|
||||
operationId: get_snapshot_from_recording__camera_name__recordings__frame_time__snapshot__format__get
|
||||
operationId: >-
|
||||
get_snapshot_from_recording__camera_name__recordings__frame_time__snapshot__format__get
|
||||
parameters:
|
||||
- name: camera_name
|
||||
in: path
|
||||
@@ -2363,7 +2340,9 @@ paths:
|
||||
tags:
|
||||
- Media
|
||||
summary: Recordings
|
||||
description: Return specific camera recordings between the given 'after'/'end' times. If not provided the last hour will be used
|
||||
description: >-
|
||||
Return specific camera recordings between the given 'after'/'end' times.
|
||||
If not provided the last hour will be used
|
||||
operationId: recordings__camera_name__recordings_get
|
||||
parameters:
|
||||
- name: camera_name
|
||||
@@ -2377,14 +2356,14 @@ paths:
|
||||
required: false
|
||||
schema:
|
||||
type: number
|
||||
default: 1727542549.303557
|
||||
default: 1729274204.653048
|
||||
title: After
|
||||
- name: before
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: number
|
||||
default: 1727546149.303926
|
||||
default: 1729277804.653095
|
||||
title: Before
|
||||
responses:
|
||||
'200':
|
||||
@@ -2423,13 +2402,6 @@ paths:
|
||||
schema:
|
||||
type: number
|
||||
title: End Ts
|
||||
- name: download
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
title: Download
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
@@ -2800,13 +2772,6 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
title: Event Id
|
||||
- name: download
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
title: Download
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
@@ -3121,7 +3086,9 @@ paths:
|
||||
tags:
|
||||
- Media
|
||||
summary: Label Snapshot
|
||||
description: Returns the snapshot image from the latest event for the given camera and label combo
|
||||
description: >-
|
||||
Returns the snapshot image from the latest event for the given camera
|
||||
and label combo
|
||||
operationId: label_snapshot__camera_name___label__snapshot_jpg_get
|
||||
parameters:
|
||||
- name: camera_name
|
||||
@@ -3193,6 +3160,32 @@ components:
|
||||
required:
|
||||
- password
|
||||
title: AppPutPasswordBody
|
||||
DayReview:
|
||||
properties:
|
||||
day:
|
||||
type: string
|
||||
format: date-time
|
||||
title: Day
|
||||
reviewed_alert:
|
||||
type: integer
|
||||
title: Reviewed Alert
|
||||
reviewed_detection:
|
||||
type: integer
|
||||
title: Reviewed Detection
|
||||
total_alert:
|
||||
type: integer
|
||||
title: Total Alert
|
||||
total_detection:
|
||||
type: integer
|
||||
title: Total Detection
|
||||
type: object
|
||||
required:
|
||||
- day
|
||||
- reviewed_alert
|
||||
- reviewed_detection
|
||||
- total_alert
|
||||
- total_detection
|
||||
title: DayReview
|
||||
EventsCreateBody:
|
||||
properties:
|
||||
source_type:
|
||||
@@ -3237,7 +3230,6 @@ components:
|
||||
description:
|
||||
anyOf:
|
||||
- type: string
|
||||
minLength: 1
|
||||
- type: 'null'
|
||||
title: The description of the event
|
||||
type: object
|
||||
@@ -3278,6 +3270,19 @@ components:
|
||||
- jpg
|
||||
- jpeg
|
||||
title: Extension
|
||||
GenericResponse:
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
title: Success
|
||||
message:
|
||||
type: string
|
||||
title: Message
|
||||
type: object
|
||||
required:
|
||||
- success
|
||||
- message
|
||||
title: GenericResponse
|
||||
HTTPValidationError:
|
||||
properties:
|
||||
detail:
|
||||
@@ -3287,6 +3292,133 @@ components:
|
||||
title: Detail
|
||||
type: object
|
||||
title: HTTPValidationError
|
||||
Last24HoursReview:
|
||||
properties:
|
||||
reviewed_alert:
|
||||
type: integer
|
||||
title: Reviewed Alert
|
||||
reviewed_detection:
|
||||
type: integer
|
||||
title: Reviewed Detection
|
||||
total_alert:
|
||||
type: integer
|
||||
title: Total Alert
|
||||
total_detection:
|
||||
type: integer
|
||||
title: Total Detection
|
||||
type: object
|
||||
required:
|
||||
- reviewed_alert
|
||||
- reviewed_detection
|
||||
- total_alert
|
||||
- total_detection
|
||||
title: Last24HoursReview
|
||||
RegenerateDescriptionEnum:
|
||||
type: string
|
||||
enum:
|
||||
- thumbnails
|
||||
- snapshot
|
||||
title: RegenerateDescriptionEnum
|
||||
ReviewActivityMotionResponse:
|
||||
properties:
|
||||
start_time:
|
||||
type: integer
|
||||
title: Start Time
|
||||
motion:
|
||||
type: number
|
||||
title: Motion
|
||||
camera:
|
||||
type: string
|
||||
title: Camera
|
||||
type: object
|
||||
required:
|
||||
- start_time
|
||||
- motion
|
||||
- camera
|
||||
title: ReviewActivityMotionResponse
|
||||
ReviewDeleteMultipleReviewsBody:
|
||||
properties:
|
||||
ids:
|
||||
items:
|
||||
type: string
|
||||
minLength: 1
|
||||
type: array
|
||||
minItems: 1
|
||||
title: Ids
|
||||
type: object
|
||||
required:
|
||||
- ids
|
||||
title: ReviewDeleteMultipleReviewsBody
|
||||
ReviewSegmentResponse:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
title: Id
|
||||
camera:
|
||||
type: string
|
||||
title: Camera
|
||||
start_time:
|
||||
type: string
|
||||
format: date-time
|
||||
title: Start Time
|
||||
end_time:
|
||||
type: string
|
||||
format: date-time
|
||||
title: End Time
|
||||
has_been_reviewed:
|
||||
type: boolean
|
||||
title: Has Been Reviewed
|
||||
severity:
|
||||
$ref: '#/components/schemas/SeverityEnum'
|
||||
thumb_path:
|
||||
type: string
|
||||
title: Thumb Path
|
||||
data:
|
||||
title: Data
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- camera
|
||||
- start_time
|
||||
- end_time
|
||||
- has_been_reviewed
|
||||
- severity
|
||||
- thumb_path
|
||||
- data
|
||||
title: ReviewSegmentResponse
|
||||
ReviewSetMultipleReviewedBody:
|
||||
properties:
|
||||
ids:
|
||||
items:
|
||||
type: string
|
||||
minLength: 1
|
||||
type: array
|
||||
minItems: 1
|
||||
title: Ids
|
||||
type: object
|
||||
required:
|
||||
- ids
|
||||
title: ReviewSetMultipleReviewedBody
|
||||
ReviewSummaryResponse:
|
||||
properties:
|
||||
last24Hours:
|
||||
$ref: '#/components/schemas/Last24HoursReview'
|
||||
root:
|
||||
additionalProperties:
|
||||
$ref: '#/components/schemas/DayReview'
|
||||
type: object
|
||||
title: Root
|
||||
type: object
|
||||
required:
|
||||
- last24Hours
|
||||
- root
|
||||
title: ReviewSummaryResponse
|
||||
SeverityEnum:
|
||||
type: string
|
||||
enum:
|
||||
- alert
|
||||
- detection
|
||||
title: SeverityEnum
|
||||
SubmitPlusBody:
|
||||
properties:
|
||||
include_annotation:
|
||||
|
||||
6
frigate/api/defs/generic_response.py
Normal file
6
frigate/api/defs/generic_response.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class GenericResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
6
frigate/api/defs/review_body.py
Normal file
6
frigate/api/defs/review_body.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from pydantic import BaseModel, conlist, constr
|
||||
|
||||
|
||||
class ReviewModifyMultipleBody(BaseModel):
|
||||
# List of string with at least one element and each element with at least one char
|
||||
ids: conlist(constr(min_length=1), min_length=1)
|
||||
@@ -1,28 +1,31 @@
|
||||
from typing import Optional
|
||||
from typing import Union
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic.json_schema import SkipJsonSchema
|
||||
|
||||
from frigate.review.maintainer import SeverityEnum
|
||||
|
||||
|
||||
class ReviewQueryParams(BaseModel):
|
||||
cameras: Optional[str] = "all"
|
||||
labels: Optional[str] = "all"
|
||||
zones: Optional[str] = "all"
|
||||
reviewed: Optional[int] = 0
|
||||
limit: Optional[int] = None
|
||||
severity: Optional[str] = None
|
||||
before: Optional[float] = None
|
||||
after: Optional[float] = None
|
||||
cameras: str = "all"
|
||||
labels: str = "all"
|
||||
zones: str = "all"
|
||||
reviewed: int = 0
|
||||
limit: Union[int, SkipJsonSchema[None]] = None
|
||||
severity: Union[SeverityEnum, SkipJsonSchema[None]] = None
|
||||
before: Union[float, SkipJsonSchema[None]] = None
|
||||
after: Union[float, SkipJsonSchema[None]] = None
|
||||
|
||||
|
||||
class ReviewSummaryQueryParams(BaseModel):
|
||||
cameras: Optional[str] = "all"
|
||||
labels: Optional[str] = "all"
|
||||
zones: Optional[str] = "all"
|
||||
timezone: Optional[str] = "utc"
|
||||
cameras: str = "all"
|
||||
labels: str = "all"
|
||||
zones: str = "all"
|
||||
timezone: str = "utc"
|
||||
|
||||
|
||||
class ReviewActivityMotionQueryParams(BaseModel):
|
||||
cameras: Optional[str] = "all"
|
||||
before: Optional[float] = None
|
||||
after: Optional[float] = None
|
||||
scale: Optional[int] = 30
|
||||
cameras: str = "all"
|
||||
before: Union[float, SkipJsonSchema[None]] = None
|
||||
after: Union[float, SkipJsonSchema[None]] = None
|
||||
scale: int = 30
|
||||
|
||||
43
frigate/api/defs/review_responses.py
Normal file
43
frigate/api/defs/review_responses.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from datetime import datetime
|
||||
from typing import Dict
|
||||
|
||||
from pydantic import BaseModel, Json
|
||||
|
||||
from frigate.review.maintainer import SeverityEnum
|
||||
|
||||
|
||||
class ReviewSegmentResponse(BaseModel):
|
||||
id: str
|
||||
camera: str
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
has_been_reviewed: bool
|
||||
severity: SeverityEnum
|
||||
thumb_path: str
|
||||
data: Json
|
||||
|
||||
|
||||
class Last24HoursReview(BaseModel):
|
||||
reviewed_alert: int
|
||||
reviewed_detection: int
|
||||
total_alert: int
|
||||
total_detection: int
|
||||
|
||||
|
||||
class DayReview(BaseModel):
|
||||
day: datetime
|
||||
reviewed_alert: int
|
||||
reviewed_detection: int
|
||||
total_alert: int
|
||||
total_detection: int
|
||||
|
||||
|
||||
class ReviewSummaryResponse(BaseModel):
|
||||
last24Hours: Last24HoursReview
|
||||
root: Dict[str, DayReview]
|
||||
|
||||
|
||||
class ReviewActivityMotionResponse(BaseModel):
|
||||
start_time: int
|
||||
motion: float
|
||||
camera: str
|
||||
@@ -394,6 +394,7 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
|
||||
Event.end_time,
|
||||
Event.has_clip,
|
||||
Event.has_snapshot,
|
||||
Event.top_score,
|
||||
Event.data,
|
||||
Event.plus_id,
|
||||
ReviewSegment.thumb_path,
|
||||
@@ -1014,7 +1015,7 @@ def regenerate_description(
|
||||
content=(
|
||||
{
|
||||
"success": False,
|
||||
"message": "Semantic search and generative AI are not enabled",
|
||||
"message": "Semantic Search and Generative AI must be enabled to regenerate a description",
|
||||
}
|
||||
),
|
||||
status_code=400,
|
||||
|
||||
@@ -13,8 +13,12 @@ from peewee import DoesNotExist
|
||||
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.const import EXPORT_DIR
|
||||
from frigate.models import Export, Recordings
|
||||
from frigate.record.export import PlaybackFactorEnum, RecordingExporter
|
||||
from frigate.models import Export, Previews, Recordings
|
||||
from frigate.record.export import (
|
||||
PlaybackFactorEnum,
|
||||
PlaybackSourceEnum,
|
||||
RecordingExporter,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -45,6 +49,7 @@ def export_recording(
|
||||
|
||||
json: dict[str, any] = body or {}
|
||||
playback_factor = json.get("playback", "realtime")
|
||||
playback_source = json.get("source", "recordings")
|
||||
friendly_name: Optional[str] = json.get("name")
|
||||
|
||||
if len(friendly_name or "") > 256:
|
||||
@@ -55,25 +60,48 @@ def export_recording(
|
||||
|
||||
existing_image = json.get("image_path")
|
||||
|
||||
recordings_count = (
|
||||
Recordings.select()
|
||||
.where(
|
||||
Recordings.start_time.between(start_time, end_time)
|
||||
| Recordings.end_time.between(start_time, end_time)
|
||||
| ((start_time > Recordings.start_time) & (end_time < Recordings.end_time))
|
||||
if playback_source == "recordings":
|
||||
recordings_count = (
|
||||
Recordings.select()
|
||||
.where(
|
||||
Recordings.start_time.between(start_time, end_time)
|
||||
| Recordings.end_time.between(start_time, end_time)
|
||||
| (
|
||||
(start_time > Recordings.start_time)
|
||||
& (end_time < Recordings.end_time)
|
||||
)
|
||||
)
|
||||
.where(Recordings.camera == camera_name)
|
||||
.count()
|
||||
)
|
||||
.where(Recordings.camera == camera_name)
|
||||
.count()
|
||||
)
|
||||
|
||||
if recordings_count <= 0:
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{"success": False, "message": "No recordings found for time range"}
|
||||
),
|
||||
status_code=400,
|
||||
if recordings_count <= 0:
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{"success": False, "message": "No recordings found for time range"}
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
else:
|
||||
previews_count = (
|
||||
Previews.select()
|
||||
.where(
|
||||
Previews.start_time.between(start_time, end_time)
|
||||
| Previews.end_time.between(start_time, end_time)
|
||||
| ((start_time > Previews.start_time) & (end_time < Previews.end_time))
|
||||
)
|
||||
.where(Previews.camera == camera_name)
|
||||
.count()
|
||||
)
|
||||
|
||||
if previews_count <= 0:
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{"success": False, "message": "No previews found for time range"}
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
export_id = f"{camera_name}_{''.join(random.choices(string.ascii_lowercase + string.digits, k=6))}"
|
||||
exporter = RecordingExporter(
|
||||
request.app.frigate_config,
|
||||
@@ -88,6 +116,11 @@ def export_recording(
|
||||
if playback_factor in PlaybackFactorEnum.__members__.values()
|
||||
else PlaybackFactorEnum.realtime
|
||||
),
|
||||
(
|
||||
PlaybackSourceEnum[playback_source]
|
||||
if playback_source in PlaybackSourceEnum.__members__.values()
|
||||
else PlaybackSourceEnum.recordings
|
||||
),
|
||||
)
|
||||
exporter.start()
|
||||
return JSONResponse(
|
||||
|
||||
@@ -82,6 +82,10 @@ def create_fastapi_app(
|
||||
database.close()
|
||||
return response
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
logger.info("FastAPI started")
|
||||
|
||||
# Rate limiter (used for login endpoint)
|
||||
auth.rateLimiter.set_limit(frigate_config.auth.failed_login_rate_limit or "")
|
||||
app.state.limiter = limiter
|
||||
|
||||
@@ -460,8 +460,8 @@ def recording_clip(
|
||||
text=False,
|
||||
) as ffmpeg:
|
||||
while True:
|
||||
data = ffmpeg.stdout.read(1024)
|
||||
if data is not None:
|
||||
data = ffmpeg.stdout.read(8192)
|
||||
if data is not None and len(data) > 0:
|
||||
yield data
|
||||
else:
|
||||
if ffmpeg.returncode and ffmpeg.returncode != 0:
|
||||
|
||||
@@ -12,11 +12,18 @@ from fastapi.responses import JSONResponse
|
||||
from peewee import Case, DoesNotExist, fn, operator
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
|
||||
from frigate.api.defs.generic_response import GenericResponse
|
||||
from frigate.api.defs.review_body import ReviewModifyMultipleBody
|
||||
from frigate.api.defs.review_query_parameters import (
|
||||
ReviewActivityMotionQueryParams,
|
||||
ReviewQueryParams,
|
||||
ReviewSummaryQueryParams,
|
||||
)
|
||||
from frigate.api.defs.review_responses import (
|
||||
ReviewActivityMotionResponse,
|
||||
ReviewSegmentResponse,
|
||||
ReviewSummaryResponse,
|
||||
)
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.models import Recordings, ReviewSegment
|
||||
from frigate.util.builtin import get_tz_modifiers
|
||||
@@ -26,7 +33,7 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=[Tags.review])
|
||||
|
||||
|
||||
@router.get("/review")
|
||||
@router.get("/review", response_model=list[ReviewSegmentResponse])
|
||||
def review(params: ReviewQueryParams = Depends()):
|
||||
cameras = params.cameras
|
||||
labels = params.labels
|
||||
@@ -102,7 +109,7 @@ def review(params: ReviewQueryParams = Depends()):
|
||||
return JSONResponse(content=[r for r in review])
|
||||
|
||||
|
||||
@router.get("/review/summary")
|
||||
@router.get("/review/summary", response_model=ReviewSummaryResponse)
|
||||
def review_summary(params: ReviewSummaryQueryParams = Depends()):
|
||||
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(params.timezone)
|
||||
day_ago = (datetime.datetime.now() - datetime.timedelta(hours=24)).timestamp()
|
||||
@@ -173,18 +180,6 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
|
||||
0,
|
||||
)
|
||||
).alias("reviewed_detection"),
|
||||
fn.SUM(
|
||||
Case(
|
||||
None,
|
||||
[
|
||||
(
|
||||
(ReviewSegment.severity == "significant_motion"),
|
||||
ReviewSegment.has_been_reviewed,
|
||||
)
|
||||
],
|
||||
0,
|
||||
)
|
||||
).alias("reviewed_motion"),
|
||||
fn.SUM(
|
||||
Case(
|
||||
None,
|
||||
@@ -209,18 +204,6 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
|
||||
0,
|
||||
)
|
||||
).alias("total_detection"),
|
||||
fn.SUM(
|
||||
Case(
|
||||
None,
|
||||
[
|
||||
(
|
||||
(ReviewSegment.severity == "significant_motion"),
|
||||
1,
|
||||
)
|
||||
],
|
||||
0,
|
||||
)
|
||||
).alias("total_motion"),
|
||||
)
|
||||
.where(reduce(operator.and_, clauses))
|
||||
.dicts()
|
||||
@@ -282,18 +265,6 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
|
||||
0,
|
||||
)
|
||||
).alias("reviewed_detection"),
|
||||
fn.SUM(
|
||||
Case(
|
||||
None,
|
||||
[
|
||||
(
|
||||
(ReviewSegment.severity == "significant_motion"),
|
||||
ReviewSegment.has_been_reviewed,
|
||||
)
|
||||
],
|
||||
0,
|
||||
)
|
||||
).alias("reviewed_motion"),
|
||||
fn.SUM(
|
||||
Case(
|
||||
None,
|
||||
@@ -318,18 +289,6 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
|
||||
0,
|
||||
)
|
||||
).alias("total_detection"),
|
||||
fn.SUM(
|
||||
Case(
|
||||
None,
|
||||
[
|
||||
(
|
||||
(ReviewSegment.severity == "significant_motion"),
|
||||
1,
|
||||
)
|
||||
],
|
||||
0,
|
||||
)
|
||||
).alias("total_motion"),
|
||||
)
|
||||
.where(reduce(operator.and_, clauses))
|
||||
.group_by(
|
||||
@@ -348,19 +307,10 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
|
||||
return JSONResponse(content=data)
|
||||
|
||||
|
||||
@router.post("/reviews/viewed")
|
||||
def set_multiple_reviewed(body: dict = None):
|
||||
json: dict[str, any] = body or {}
|
||||
list_of_ids = json.get("ids", "")
|
||||
|
||||
if not list_of_ids or len(list_of_ids) == 0:
|
||||
return JSONResponse(
|
||||
context=({"success": False, "message": "Not a valid list of ids"}),
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
@router.post("/reviews/viewed", response_model=GenericResponse)
|
||||
def set_multiple_reviewed(body: ReviewModifyMultipleBody):
|
||||
ReviewSegment.update(has_been_reviewed=True).where(
|
||||
ReviewSegment.id << list_of_ids
|
||||
ReviewSegment.id << body.ids
|
||||
).execute()
|
||||
|
||||
return JSONResponse(
|
||||
@@ -369,17 +319,9 @@ def set_multiple_reviewed(body: dict = None):
|
||||
)
|
||||
|
||||
|
||||
@router.post("/reviews/delete")
|
||||
def delete_reviews(body: dict = None):
|
||||
json: dict[str, any] = body or {}
|
||||
list_of_ids = json.get("ids", "")
|
||||
|
||||
if not list_of_ids or len(list_of_ids) == 0:
|
||||
return JSONResponse(
|
||||
content=({"success": False, "message": "Not a valid list of ids"}),
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
@router.post("/reviews/delete", response_model=GenericResponse)
|
||||
def delete_reviews(body: ReviewModifyMultipleBody):
|
||||
list_of_ids = body.ids
|
||||
reviews = (
|
||||
ReviewSegment.select(
|
||||
ReviewSegment.camera,
|
||||
@@ -424,7 +366,9 @@ def delete_reviews(body: dict = None):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/review/activity/motion")
|
||||
@router.get(
|
||||
"/review/activity/motion", response_model=list[ReviewActivityMotionResponse]
|
||||
)
|
||||
def motion_activity(params: ReviewActivityMotionQueryParams = Depends()):
|
||||
"""Get motion and audio activity."""
|
||||
cameras = params.cameras
|
||||
@@ -498,98 +442,44 @@ def motion_activity(params: ReviewActivityMotionQueryParams = Depends()):
|
||||
return JSONResponse(content=normalized)
|
||||
|
||||
|
||||
@router.get("/review/activity/audio")
|
||||
def audio_activity(params: ReviewActivityMotionQueryParams = Depends()):
|
||||
"""Get motion and audio activity."""
|
||||
cameras = params.cameras
|
||||
before = params.before or datetime.datetime.now().timestamp()
|
||||
after = (
|
||||
params.after
|
||||
or (datetime.datetime.now() - datetime.timedelta(hours=1)).timestamp()
|
||||
)
|
||||
# get scale in seconds
|
||||
scale = params.scale
|
||||
|
||||
clauses = [(Recordings.start_time > after) & (Recordings.end_time < before)]
|
||||
|
||||
if cameras != "all":
|
||||
camera_list = cameras.split(",")
|
||||
clauses.append((Recordings.camera << camera_list))
|
||||
|
||||
all_recordings: list[Recordings] = (
|
||||
Recordings.select(
|
||||
Recordings.start_time,
|
||||
Recordings.duration,
|
||||
Recordings.objects,
|
||||
Recordings.dBFS,
|
||||
)
|
||||
.where(reduce(operator.and_, clauses))
|
||||
.order_by(Recordings.start_time.asc())
|
||||
.iterator()
|
||||
)
|
||||
|
||||
# format is: { timestamp: segment_start_ts, motion: [0-100], audio: [0 - -100] }
|
||||
# periods where active objects / audio was detected will cause audio to be scaled down
|
||||
data: list[dict[str, float]] = []
|
||||
|
||||
for rec in all_recordings:
|
||||
data.append(
|
||||
{
|
||||
"start_time": rec.start_time,
|
||||
"audio": rec.dBFS if rec.objects == 0 else 0,
|
||||
}
|
||||
)
|
||||
|
||||
# resample data using pandas to get activity on scaled basis
|
||||
df = pd.DataFrame(data, columns=["start_time", "audio"])
|
||||
df = df.astype(dtype={"audio": "float16"})
|
||||
|
||||
# set date as datetime index
|
||||
df["start_time"] = pd.to_datetime(df["start_time"], unit="s")
|
||||
df.set_index(["start_time"], inplace=True)
|
||||
|
||||
# normalize data
|
||||
df = df.resample(f"{scale}S").mean().fillna(0.0)
|
||||
df["audio"] = (
|
||||
(df["audio"] - df["audio"].max())
|
||||
/ (df["audio"].min() - df["audio"].max())
|
||||
* -100
|
||||
)
|
||||
|
||||
# change types for output
|
||||
df.index = df.index.astype(int) // (10**9)
|
||||
normalized = df.reset_index().to_dict("records")
|
||||
return JSONResponse(content=normalized)
|
||||
|
||||
|
||||
@router.get("/review/event/{event_id}")
|
||||
@router.get("/review/event/{event_id}", response_model=ReviewSegmentResponse)
|
||||
def get_review_from_event(event_id: str):
|
||||
try:
|
||||
return model_to_dict(
|
||||
ReviewSegment.get(
|
||||
ReviewSegment.data["detections"].cast("text") % f'*"{event_id}"*'
|
||||
return JSONResponse(
|
||||
model_to_dict(
|
||||
ReviewSegment.get(
|
||||
ReviewSegment.data["detections"].cast("text") % f'*"{event_id}"*'
|
||||
)
|
||||
)
|
||||
)
|
||||
except DoesNotExist:
|
||||
return "Review item not found", 404
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Review item not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/review/{event_id}")
|
||||
def get_review(event_id: str):
|
||||
@router.get("/review/{review_id}", response_model=ReviewSegmentResponse)
|
||||
def get_review(review_id: str):
|
||||
try:
|
||||
return model_to_dict(ReviewSegment.get(ReviewSegment.id == event_id))
|
||||
return JSONResponse(
|
||||
content=model_to_dict(ReviewSegment.get(ReviewSegment.id == review_id))
|
||||
)
|
||||
except DoesNotExist:
|
||||
return "Review item not found", 404
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Review item not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/review/{event_id}/viewed")
|
||||
def set_not_reviewed(event_id: str):
|
||||
@router.delete("/review/{review_id}/viewed", response_model=GenericResponse)
|
||||
def set_not_reviewed(review_id: str):
|
||||
try:
|
||||
review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == event_id)
|
||||
review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == review_id)
|
||||
except DoesNotExist:
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{"success": False, "message": "Review " + event_id + " not found"}
|
||||
{"success": False, "message": "Review " + review_id + " not found"}
|
||||
),
|
||||
status_code=404,
|
||||
)
|
||||
@@ -598,6 +488,8 @@ def set_not_reviewed(event_id: str):
|
||||
review.save()
|
||||
|
||||
return JSONResponse(
|
||||
content=({"success": True, "message": "Reviewed " + event_id + " not viewed"}),
|
||||
content=(
|
||||
{"success": True, "message": "Set Review " + review_id + " as not viewed"}
|
||||
),
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
@@ -63,6 +63,7 @@ from frigate.record.cleanup import RecordingCleanup
|
||||
from frigate.record.export import migrate_exports
|
||||
from frigate.record.record import manage_recordings
|
||||
from frigate.review.review import manage_review_segments
|
||||
from frigate.service_manager import ServiceManager
|
||||
from frigate.stats.emitter import StatsEmitter
|
||||
from frigate.stats.util import stats_init
|
||||
from frigate.storage import StorageMaintainer
|
||||
@@ -78,7 +79,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class FrigateApp:
|
||||
def __init__(self, config: FrigateConfig) -> None:
|
||||
self.audio_process: Optional[mp.Process] = None
|
||||
self.stop_event: MpEvent = mp.Event()
|
||||
self.detection_queue: Queue = mp.Queue()
|
||||
self.detectors: dict[str, ObjectDetectProcess] = {}
|
||||
@@ -449,9 +449,8 @@ class FrigateApp:
|
||||
]
|
||||
|
||||
if audio_cameras:
|
||||
self.audio_process = AudioProcessor(audio_cameras, self.camera_metrics)
|
||||
self.audio_process.start()
|
||||
self.processes["audio_detector"] = self.audio_process.pid or 0
|
||||
proc = AudioProcessor(audio_cameras, self.camera_metrics).start(wait=True)
|
||||
self.processes["audio_detector"] = proc.pid or 0
|
||||
|
||||
def start_timeline_processor(self) -> None:
|
||||
self.timeline_processor = TimelineProcessor(
|
||||
@@ -639,11 +638,6 @@ class FrigateApp:
|
||||
ReviewSegment.end_time == None
|
||||
).execute()
|
||||
|
||||
# stop the audio process
|
||||
if self.audio_process:
|
||||
self.audio_process.terminate()
|
||||
self.audio_process.join()
|
||||
|
||||
# ensure the capture processes are done
|
||||
for camera, metrics in self.camera_metrics.items():
|
||||
capture_process = metrics.capture_process
|
||||
@@ -712,4 +706,6 @@ class FrigateApp:
|
||||
shm.close()
|
||||
shm.unlink()
|
||||
|
||||
ServiceManager.current().shutdown(wait=True)
|
||||
|
||||
os._exit(os.EX_OK)
|
||||
|
||||
@@ -17,7 +17,7 @@ class MqttClient(Communicator): # type: ignore[misc]
|
||||
def __init__(self, config: FrigateConfig) -> None:
|
||||
self.config = config
|
||||
self.mqtt_config = config.mqtt
|
||||
self.connected: bool = False
|
||||
self.connected = False
|
||||
|
||||
def subscribe(self, receiver: Callable) -> None:
|
||||
"""Wrapper for allowing dispatcher to subscribe."""
|
||||
@@ -27,7 +27,7 @@ class MqttClient(Communicator): # type: ignore[misc]
|
||||
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
|
||||
"""Wrapper for publishing when client is in valid state."""
|
||||
if not self.connected:
|
||||
logger.error(f"Unable to publish to {topic}: client is not connected")
|
||||
logger.debug(f"Unable to publish to {topic}: client is not connected")
|
||||
return
|
||||
|
||||
self.client.publish(
|
||||
@@ -173,6 +173,7 @@ class MqttClient(Communicator): # type: ignore[misc]
|
||||
client_id=self.mqtt_config.client_id,
|
||||
)
|
||||
self.client.on_connect = self._on_connect
|
||||
self.client.on_disconnect = self._on_disconnect
|
||||
self.client.will_set(
|
||||
self.mqtt_config.topic_prefix + "/available",
|
||||
payload="offline",
|
||||
@@ -197,14 +198,6 @@ class MqttClient(Communicator): # type: ignore[misc]
|
||||
|
||||
for name in self.config.cameras.keys():
|
||||
for callback in callback_types:
|
||||
# We need to pre-clear existing set topics because in previous
|
||||
# versions the webUI retained on the /set topic but this is
|
||||
# no longer the case.
|
||||
self.client.publish(
|
||||
f"{self.mqtt_config.topic_prefix}/{name}/{callback}/set",
|
||||
None,
|
||||
retain=True,
|
||||
)
|
||||
self.client.message_callback_add(
|
||||
f"{self.mqtt_config.topic_prefix}/{name}/{callback}/set",
|
||||
self.on_mqtt_command,
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
"""SQLite-vec embeddings database."""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
from numpy import ndarray
|
||||
from PIL import Image
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
@@ -22,7 +20,7 @@ from frigate.models import Event
|
||||
from frigate.types import ModelStatusTypesEnum
|
||||
from frigate.util.builtin import serialize
|
||||
|
||||
from .functions.onnx import GenericONNXEmbedding
|
||||
from .functions.onnx import GenericONNXEmbedding, ModelTypeEnum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -97,7 +95,7 @@ class Embeddings:
|
||||
"text_model_fp16.onnx": "https://huggingface.co/jinaai/jina-clip-v1/resolve/main/onnx/text_model_fp16.onnx",
|
||||
},
|
||||
model_size=config.model_size,
|
||||
model_type="text",
|
||||
model_type=ModelTypeEnum.text,
|
||||
requestor=self.requestor,
|
||||
device="CPU",
|
||||
)
|
||||
@@ -118,83 +116,102 @@ class Embeddings:
|
||||
model_file=model_file,
|
||||
download_urls=download_urls,
|
||||
model_size=config.model_size,
|
||||
model_type="vision",
|
||||
model_type=ModelTypeEnum.vision,
|
||||
requestor=self.requestor,
|
||||
device="GPU" if config.model_size == "large" else "CPU",
|
||||
)
|
||||
|
||||
def upsert_thumbnail(self, event_id: str, thumbnail: bytes) -> ndarray:
|
||||
# Convert thumbnail bytes to PIL Image
|
||||
image = Image.open(io.BytesIO(thumbnail)).convert("RGB")
|
||||
embedding = self.vision_embedding([image])[0]
|
||||
def embed_thumbnail(
|
||||
self, event_id: str, thumbnail: bytes, upsert: bool = True
|
||||
) -> ndarray:
|
||||
"""Embed thumbnail and optionally insert into DB.
|
||||
|
||||
self.db.execute_sql(
|
||||
"""
|
||||
INSERT OR REPLACE INTO vec_thumbnails(id, thumbnail_embedding)
|
||||
VALUES(?, ?)
|
||||
""",
|
||||
(event_id, serialize(embedding)),
|
||||
)
|
||||
@param: event_id in Events DB
|
||||
@param: thumbnail bytes in jpg format
|
||||
@param: upsert If embedding should be upserted into vec DB
|
||||
"""
|
||||
# Convert thumbnail bytes to PIL Image
|
||||
embedding = self.vision_embedding([thumbnail])[0]
|
||||
|
||||
if upsert:
|
||||
self.db.execute_sql(
|
||||
"""
|
||||
INSERT OR REPLACE INTO vec_thumbnails(id, thumbnail_embedding)
|
||||
VALUES(?, ?)
|
||||
""",
|
||||
(event_id, serialize(embedding)),
|
||||
)
|
||||
|
||||
return embedding
|
||||
|
||||
def batch_upsert_thumbnail(self, event_thumbs: dict[str, bytes]) -> list[ndarray]:
|
||||
images = [
|
||||
Image.open(io.BytesIO(thumb)).convert("RGB")
|
||||
for thumb in event_thumbs.values()
|
||||
]
|
||||
def batch_embed_thumbnail(
|
||||
self, event_thumbs: dict[str, bytes], upsert: bool = True
|
||||
) -> list[ndarray]:
|
||||
"""Embed thumbnails and optionally insert into DB.
|
||||
|
||||
@param: event_thumbs Map of Event IDs in DB to thumbnail bytes in jpg format
|
||||
@param: upsert If embedding should be upserted into vec DB
|
||||
"""
|
||||
ids = list(event_thumbs.keys())
|
||||
embeddings = self.vision_embedding(images)
|
||||
embeddings = self.vision_embedding(list(event_thumbs.values()))
|
||||
|
||||
items = []
|
||||
if upsert:
|
||||
items = []
|
||||
|
||||
for i in range(len(ids)):
|
||||
items.append(ids[i])
|
||||
items.append(serialize(embeddings[i]))
|
||||
for i in range(len(ids)):
|
||||
items.append(ids[i])
|
||||
items.append(serialize(embeddings[i]))
|
||||
|
||||
self.db.execute_sql(
|
||||
"""
|
||||
INSERT OR REPLACE INTO vec_thumbnails(id, thumbnail_embedding)
|
||||
VALUES {}
|
||||
""".format(", ".join(["(?, ?)"] * len(ids))),
|
||||
items,
|
||||
)
|
||||
|
||||
self.db.execute_sql(
|
||||
"""
|
||||
INSERT OR REPLACE INTO vec_thumbnails(id, thumbnail_embedding)
|
||||
VALUES {}
|
||||
""".format(", ".join(["(?, ?)"] * len(ids))),
|
||||
items,
|
||||
)
|
||||
return embeddings
|
||||
|
||||
def upsert_description(self, event_id: str, description: str) -> ndarray:
|
||||
def embed_description(
|
||||
self, event_id: str, description: str, upsert: bool = True
|
||||
) -> ndarray:
|
||||
embedding = self.text_embedding([description])[0]
|
||||
self.db.execute_sql(
|
||||
"""
|
||||
INSERT OR REPLACE INTO vec_descriptions(id, description_embedding)
|
||||
VALUES(?, ?)
|
||||
""",
|
||||
(event_id, serialize(embedding)),
|
||||
)
|
||||
|
||||
if upsert:
|
||||
self.db.execute_sql(
|
||||
"""
|
||||
INSERT OR REPLACE INTO vec_descriptions(id, description_embedding)
|
||||
VALUES(?, ?)
|
||||
""",
|
||||
(event_id, serialize(embedding)),
|
||||
)
|
||||
|
||||
return embedding
|
||||
|
||||
def batch_upsert_description(self, event_descriptions: dict[str, str]) -> ndarray:
|
||||
def batch_embed_description(
|
||||
self, event_descriptions: dict[str, str], upsert: bool = True
|
||||
) -> ndarray:
|
||||
# upsert embeddings one by one to avoid token limit
|
||||
embeddings = []
|
||||
|
||||
for desc in event_descriptions.values():
|
||||
embeddings.append(self.text_embedding([desc])[0])
|
||||
|
||||
ids = list(event_descriptions.keys())
|
||||
if upsert:
|
||||
ids = list(event_descriptions.keys())
|
||||
items = []
|
||||
|
||||
items = []
|
||||
for i in range(len(ids)):
|
||||
items.append(ids[i])
|
||||
items.append(serialize(embeddings[i]))
|
||||
|
||||
for i in range(len(ids)):
|
||||
items.append(ids[i])
|
||||
items.append(serialize(embeddings[i]))
|
||||
|
||||
self.db.execute_sql(
|
||||
"""
|
||||
INSERT OR REPLACE INTO vec_descriptions(id, description_embedding)
|
||||
VALUES {}
|
||||
""".format(", ".join(["(?, ?)"] * len(ids))),
|
||||
items,
|
||||
)
|
||||
self.db.execute_sql(
|
||||
"""
|
||||
INSERT OR REPLACE INTO vec_descriptions(id, description_embedding)
|
||||
VALUES {}
|
||||
""".format(", ".join(["(?, ?)"] * len(ids))),
|
||||
items,
|
||||
)
|
||||
|
||||
return embeddings
|
||||
|
||||
@@ -261,10 +278,10 @@ class Embeddings:
|
||||
totals["processed_objects"] += 1
|
||||
|
||||
# run batch embedding
|
||||
self.batch_upsert_thumbnail(batch_thumbs)
|
||||
self.batch_embed_thumbnail(batch_thumbs)
|
||||
|
||||
if batch_descs:
|
||||
self.batch_upsert_description(batch_descs)
|
||||
self.batch_embed_description(batch_descs)
|
||||
|
||||
# report progress every batch so we don't spam the logs
|
||||
progress = (totals["processed_objects"] / total_events) * 100
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
import os
|
||||
import warnings
|
||||
from enum import Enum
|
||||
from io import BytesIO
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
@@ -31,6 +32,12 @@ disable_progress_bar()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ModelTypeEnum(str, Enum):
|
||||
face = "face"
|
||||
vision = "vision"
|
||||
text = "text"
|
||||
|
||||
|
||||
class GenericONNXEmbedding:
|
||||
"""Generic embedding function for ONNX models (text and vision)."""
|
||||
|
||||
@@ -88,7 +95,10 @@ class GenericONNXEmbedding:
|
||||
file_name = os.path.basename(path)
|
||||
if file_name in self.download_urls:
|
||||
ModelDownloader.download_from_url(self.download_urls[file_name], path)
|
||||
elif file_name == self.tokenizer_file and self.model_type == "text":
|
||||
elif (
|
||||
file_name == self.tokenizer_file
|
||||
and self.model_type == ModelTypeEnum.text
|
||||
):
|
||||
if not os.path.exists(path + "/" + self.model_name):
|
||||
logger.info(f"Downloading {self.model_name} tokenizer")
|
||||
tokenizer = AutoTokenizer.from_pretrained(
|
||||
@@ -119,7 +129,7 @@ class GenericONNXEmbedding:
|
||||
if self.runner is None:
|
||||
if self.downloader:
|
||||
self.downloader.wait_for_download()
|
||||
if self.model_type == "text":
|
||||
if self.model_type == ModelTypeEnum.text:
|
||||
self.tokenizer = self._load_tokenizer()
|
||||
else:
|
||||
self.feature_extractor = self._load_feature_extractor()
|
||||
@@ -143,11 +153,35 @@ class GenericONNXEmbedding:
|
||||
f"{MODEL_CACHE_DIR}/{self.model_name}",
|
||||
)
|
||||
|
||||
def _preprocess_inputs(self, raw_inputs: any) -> any:
|
||||
if self.model_type == ModelTypeEnum.text:
|
||||
max_length = max(len(self.tokenizer.encode(text)) for text in raw_inputs)
|
||||
return [
|
||||
self.tokenizer(
|
||||
text,
|
||||
padding="max_length",
|
||||
truncation=True,
|
||||
max_length=max_length,
|
||||
return_tensors="np",
|
||||
)
|
||||
for text in raw_inputs
|
||||
]
|
||||
elif self.model_type == ModelTypeEnum.vision:
|
||||
processed_images = [self._process_image(img) for img in raw_inputs]
|
||||
return [
|
||||
self.feature_extractor(images=image, return_tensors="np")
|
||||
for image in processed_images
|
||||
]
|
||||
else:
|
||||
raise ValueError(f"Unable to preprocess inputs for {self.model_type}")
|
||||
|
||||
def _process_image(self, image):
|
||||
if isinstance(image, str):
|
||||
if image.startswith("http"):
|
||||
response = requests.get(image)
|
||||
image = Image.open(BytesIO(response.content)).convert("RGB")
|
||||
elif isinstance(image, bytes):
|
||||
image = Image.open(BytesIO(image)).convert("RGB")
|
||||
|
||||
return image
|
||||
|
||||
@@ -163,25 +197,7 @@ class GenericONNXEmbedding:
|
||||
)
|
||||
return []
|
||||
|
||||
if self.model_type == "text":
|
||||
max_length = max(len(self.tokenizer.encode(text)) for text in inputs)
|
||||
processed_inputs = [
|
||||
self.tokenizer(
|
||||
text,
|
||||
padding="max_length",
|
||||
truncation=True,
|
||||
max_length=max_length,
|
||||
return_tensors="np",
|
||||
)
|
||||
for text in inputs
|
||||
]
|
||||
else:
|
||||
processed_images = [self._process_image(img) for img in inputs]
|
||||
processed_inputs = [
|
||||
self.feature_extractor(images=image, return_tensors="np")
|
||||
for image in processed_images
|
||||
]
|
||||
|
||||
processed_inputs = self._preprocess_inputs(inputs)
|
||||
input_names = self.runner.get_input_names()
|
||||
onnx_inputs = {name: [] for name in input_names}
|
||||
input: dict[str, any]
|
||||
|
||||
@@ -86,7 +86,7 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
try:
|
||||
if topic == EmbeddingsRequestEnum.embed_description.value:
|
||||
return serialize(
|
||||
self.embeddings.upsert_description(
|
||||
self.embeddings.embed_description(
|
||||
data["id"], data["description"]
|
||||
),
|
||||
pack=False,
|
||||
@@ -94,7 +94,7 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
elif topic == EmbeddingsRequestEnum.embed_thumbnail.value:
|
||||
thumbnail = base64.b64decode(data["thumbnail"])
|
||||
return serialize(
|
||||
self.embeddings.upsert_thumbnail(data["id"], thumbnail),
|
||||
self.embeddings.embed_thumbnail(data["id"], thumbnail),
|
||||
pack=False,
|
||||
)
|
||||
elif topic == EmbeddingsRequestEnum.generate_search.value:
|
||||
@@ -270,7 +270,7 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
|
||||
def _embed_thumbnail(self, event_id: str, thumbnail: bytes) -> None:
|
||||
"""Embed the thumbnail for an event."""
|
||||
self.embeddings.upsert_thumbnail(event_id, thumbnail)
|
||||
self.embeddings.embed_thumbnail(event_id, thumbnail)
|
||||
|
||||
def _embed_description(self, event: Event, thumbnails: list[bytes]) -> None:
|
||||
"""Embed the description for an event."""
|
||||
@@ -290,8 +290,8 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
{"id": event.id, "description": description},
|
||||
)
|
||||
|
||||
# Encode the description
|
||||
self.embeddings.upsert_description(event.id, description)
|
||||
# Embed the description
|
||||
self.embeddings.embed_description(event.id, description)
|
||||
|
||||
logger.debug(
|
||||
"Generated description for %s (%d images): %s",
|
||||
|
||||
@@ -9,7 +9,6 @@ from typing import Tuple
|
||||
import numpy as np
|
||||
import requests
|
||||
|
||||
import frigate.util as util
|
||||
from frigate.camera import CameraMetrics
|
||||
from frigate.comms.config_updater import ConfigSubscriber
|
||||
from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum
|
||||
@@ -26,6 +25,7 @@ from frigate.const import (
|
||||
from frigate.ffmpeg_presets import parse_preset_input
|
||||
from frigate.log import LogPipe
|
||||
from frigate.object_detection import load_labels
|
||||
from frigate.service_manager import ServiceProcess
|
||||
from frigate.util.builtin import get_ffmpeg_arg_list
|
||||
from frigate.video import start_or_restart_ffmpeg, stop_ffmpeg
|
||||
|
||||
@@ -63,13 +63,15 @@ def get_ffmpeg_command(ffmpeg: FfmpegConfig) -> list[str]:
|
||||
)
|
||||
|
||||
|
||||
class AudioProcessor(util.Process):
|
||||
class AudioProcessor(ServiceProcess):
|
||||
name = "frigate.audio_manager"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cameras: list[CameraConfig],
|
||||
camera_metrics: dict[str, CameraMetrics],
|
||||
):
|
||||
super().__init__(name="frigate.audio_manager", daemon=True)
|
||||
super().__init__()
|
||||
|
||||
self.camera_metrics = camera_metrics
|
||||
self.cameras = cameras
|
||||
|
||||
@@ -21,12 +21,20 @@ class OllamaClient(GenAIClient):
|
||||
|
||||
def _init_provider(self):
|
||||
"""Initialize the client."""
|
||||
client = ApiClient(host=self.genai_config.base_url, timeout=self.timeout)
|
||||
response = client.pull(self.genai_config.model)
|
||||
if response["status"] != "success":
|
||||
logger.error("Failed to pull %s model from Ollama", self.genai_config.model)
|
||||
try:
|
||||
client = ApiClient(host=self.genai_config.base_url, timeout=self.timeout)
|
||||
# ensure the model is available locally
|
||||
response = client.show(self.genai_config.model)
|
||||
if response.get("error"):
|
||||
logger.error(
|
||||
"Ollama error: %s",
|
||||
response["error"],
|
||||
)
|
||||
return None
|
||||
return client
|
||||
except Exception as e:
|
||||
logger.warning("Error initializing Ollama: %s", str(e))
|
||||
return None
|
||||
return client
|
||||
|
||||
def _send(self, prompt: str, images: list[bytes]) -> Optional[str]:
|
||||
"""Submit a request to Ollama"""
|
||||
|
||||
@@ -93,7 +93,7 @@ class ReviewSegment(Model): # type: ignore[misc]
|
||||
start_time = DateTimeField()
|
||||
end_time = DateTimeField()
|
||||
has_been_reviewed = BooleanField(default=False)
|
||||
severity = CharField(max_length=30) # alert, detection, significant_motion
|
||||
severity = CharField(max_length=30) # alert, detection
|
||||
thumb_path = CharField(unique=True)
|
||||
data = JSONField() # additional data about detection like list of labels, zone, areas of significant motion
|
||||
|
||||
|
||||
@@ -59,3 +59,7 @@ ignore_errors = false
|
||||
[mypy-frigate.watchdog]
|
||||
ignore_errors = false
|
||||
disallow_untyped_calls = false
|
||||
|
||||
|
||||
[mypy-frigate.service_manager.*]
|
||||
ignore_errors = false
|
||||
|
||||
@@ -344,6 +344,7 @@ class CameraState:
|
||||
# if the object's thumbnail is not from the current frame, skip
|
||||
if (
|
||||
current_frame is None
|
||||
or obj.thumbnail_data is None
|
||||
or obj.false_positive
|
||||
or obj.thumbnail_data["frame_time"] != frame_time
|
||||
):
|
||||
|
||||
@@ -43,6 +43,11 @@ class PlaybackFactorEnum(str, Enum):
|
||||
timelapse_25x = "timelapse_25x"
|
||||
|
||||
|
||||
class PlaybackSourceEnum(str, Enum):
|
||||
recordings = "recordings"
|
||||
preview = "preview"
|
||||
|
||||
|
||||
class RecordingExporter(threading.Thread):
|
||||
"""Exports a specific set of recordings for a camera to storage as a single file."""
|
||||
|
||||
@@ -56,6 +61,7 @@ class RecordingExporter(threading.Thread):
|
||||
start_time: int,
|
||||
end_time: int,
|
||||
playback_factor: PlaybackFactorEnum,
|
||||
playback_source: PlaybackSourceEnum,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.config = config
|
||||
@@ -66,6 +72,7 @@ class RecordingExporter(threading.Thread):
|
||||
self.start_time = start_time
|
||||
self.end_time = end_time
|
||||
self.playback_factor = playback_factor
|
||||
self.playback_source = playback_source
|
||||
|
||||
# ensure export thumb dir
|
||||
Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True)
|
||||
@@ -170,30 +177,7 @@ class RecordingExporter(threading.Thread):
|
||||
|
||||
return thumb_path
|
||||
|
||||
def run(self) -> None:
|
||||
logger.debug(
|
||||
f"Beginning export for {self.camera} from {self.start_time} to {self.end_time}"
|
||||
)
|
||||
export_name = (
|
||||
self.user_provided_name
|
||||
or f"{self.camera.replace('_', ' ')} {self.get_datetime_from_timestamp(self.start_time)} {self.get_datetime_from_timestamp(self.end_time)}"
|
||||
)
|
||||
video_path = f"{EXPORT_DIR}/{self.export_id}.mp4"
|
||||
|
||||
thumb_path = self.save_thumbnail(self.export_id)
|
||||
|
||||
Export.insert(
|
||||
{
|
||||
Export.id: self.export_id,
|
||||
Export.camera: self.camera,
|
||||
Export.name: export_name,
|
||||
Export.date: self.start_time,
|
||||
Export.video_path: video_path,
|
||||
Export.thumb_path: thumb_path,
|
||||
Export.in_progress: True,
|
||||
}
|
||||
).execute()
|
||||
|
||||
def get_record_export_command(self, video_path: str) -> list[str]:
|
||||
if (self.end_time - self.start_time) <= MAX_PLAYLIST_SECONDS:
|
||||
playlist_lines = f"http://127.0.0.1:5000/vod/{self.camera}/start/{self.start_time}/end/{self.end_time}/index.m3u8"
|
||||
ffmpeg_input = (
|
||||
@@ -204,7 +188,10 @@ class RecordingExporter(threading.Thread):
|
||||
|
||||
# get full set of recordings
|
||||
export_recordings = (
|
||||
Recordings.select()
|
||||
Recordings.select(
|
||||
Recordings.start_time,
|
||||
Recordings.end_time,
|
||||
)
|
||||
.where(
|
||||
Recordings.start_time.between(self.start_time, self.end_time)
|
||||
| Recordings.end_time.between(self.start_time, self.end_time)
|
||||
@@ -229,6 +216,65 @@ class RecordingExporter(threading.Thread):
|
||||
|
||||
ffmpeg_input = "-y -protocol_whitelist pipe,file,http,tcp -f concat -safe 0 -i /dev/stdin"
|
||||
|
||||
if self.playback_factor == PlaybackFactorEnum.realtime:
|
||||
ffmpeg_cmd = (
|
||||
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} -c copy -movflags +faststart {video_path}"
|
||||
).split(" ")
|
||||
elif self.playback_factor == PlaybackFactorEnum.timelapse_25x:
|
||||
ffmpeg_cmd = (
|
||||
parse_preset_hardware_acceleration_encode(
|
||||
self.config.ffmpeg.ffmpeg_path,
|
||||
self.config.ffmpeg.hwaccel_args,
|
||||
f"-an {ffmpeg_input}",
|
||||
f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart {video_path}",
|
||||
EncodeTypeEnum.timelapse,
|
||||
)
|
||||
).split(" ")
|
||||
|
||||
return ffmpeg_cmd, playlist_lines
|
||||
|
||||
def get_preview_export_command(self, video_path: str) -> list[str]:
|
||||
playlist_lines = []
|
||||
|
||||
# get full set of previews
|
||||
export_previews = (
|
||||
Previews.select(
|
||||
Previews.path,
|
||||
Previews.start_time,
|
||||
Previews.end_time,
|
||||
)
|
||||
.where(
|
||||
Previews.start_time.between(self.start_time, self.end_time)
|
||||
| Previews.end_time.between(self.start_time, self.end_time)
|
||||
| (
|
||||
(self.start_time > Previews.start_time)
|
||||
& (self.end_time < Previews.end_time)
|
||||
)
|
||||
)
|
||||
.where(Previews.camera == self.camera)
|
||||
.order_by(Previews.start_time.asc())
|
||||
.namedtuples()
|
||||
.iterator()
|
||||
)
|
||||
|
||||
preview: Previews
|
||||
for preview in export_previews:
|
||||
playlist_lines.append(f"file '{preview.path}'")
|
||||
|
||||
if preview.start_time < self.start_time:
|
||||
playlist_lines.append(
|
||||
f"inpoint {int(self.start_time - preview.start_time)}"
|
||||
)
|
||||
|
||||
if preview.end_time > self.end_time:
|
||||
playlist_lines.append(
|
||||
f"outpoint {int(preview.end_time - self.end_time)}"
|
||||
)
|
||||
|
||||
ffmpeg_input = (
|
||||
"-y -protocol_whitelist pipe,file,tcp -f concat -safe 0 -i /dev/stdin"
|
||||
)
|
||||
|
||||
if self.playback_factor == PlaybackFactorEnum.realtime:
|
||||
ffmpeg_cmd = (
|
||||
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} -c copy -movflags +faststart {video_path}"
|
||||
@@ -244,6 +290,36 @@ class RecordingExporter(threading.Thread):
|
||||
)
|
||||
).split(" ")
|
||||
|
||||
return ffmpeg_cmd, playlist_lines
|
||||
|
||||
def run(self) -> None:
|
||||
logger.debug(
|
||||
f"Beginning export for {self.camera} from {self.start_time} to {self.end_time}"
|
||||
)
|
||||
export_name = (
|
||||
self.user_provided_name
|
||||
or f"{self.camera.replace('_', ' ')} {self.get_datetime_from_timestamp(self.start_time)} {self.get_datetime_from_timestamp(self.end_time)}"
|
||||
)
|
||||
video_path = f"{EXPORT_DIR}/{self.export_id}.mp4"
|
||||
thumb_path = self.save_thumbnail(self.export_id)
|
||||
|
||||
Export.insert(
|
||||
{
|
||||
Export.id: self.export_id,
|
||||
Export.camera: self.camera,
|
||||
Export.name: export_name,
|
||||
Export.date: self.start_time,
|
||||
Export.video_path: video_path,
|
||||
Export.thumb_path: thumb_path,
|
||||
Export.in_progress: True,
|
||||
}
|
||||
).execute()
|
||||
|
||||
if self.playback_source == PlaybackSourceEnum.recordings:
|
||||
ffmpeg_cmd, playlist_lines = self.get_record_export_command(video_path)
|
||||
else:
|
||||
ffmpeg_cmd, playlist_lines = self.get_preview_export_command(video_path)
|
||||
|
||||
p = sp.run(
|
||||
ffmpeg_cmd,
|
||||
input="\n".join(playlist_lines),
|
||||
@@ -254,7 +330,7 @@ class RecordingExporter(threading.Thread):
|
||||
|
||||
if p.returncode != 0:
|
||||
logger.error(
|
||||
f"Failed to export recording for command {' '.join(ffmpeg_cmd)}"
|
||||
f"Failed to export {self.playback_source.value} for command {' '.join(ffmpeg_cmd)}"
|
||||
)
|
||||
logger.error(p.stderr)
|
||||
Path(video_path).unlink(missing_ok=True)
|
||||
|
||||
@@ -51,7 +51,7 @@ class PendingReviewSegment:
|
||||
frame_time: float,
|
||||
severity: SeverityEnum,
|
||||
detections: dict[str, str],
|
||||
sub_labels: set[str],
|
||||
sub_labels: dict[str, str],
|
||||
zones: list[str],
|
||||
audio: set[str],
|
||||
):
|
||||
@@ -135,7 +135,7 @@ class PendingReviewSegment:
|
||||
ReviewSegment.data.name: {
|
||||
"detections": list(set(self.detections.keys())),
|
||||
"objects": list(set(self.detections.values())),
|
||||
"sub_labels": list(self.sub_labels),
|
||||
"sub_labels": list(self.sub_labels.values()),
|
||||
"zones": self.zones,
|
||||
"audio": list(self.audio),
|
||||
},
|
||||
@@ -261,7 +261,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
segment.detections[object["id"]] = object["sub_label"][0]
|
||||
else:
|
||||
segment.detections[object["id"]] = f'{object["label"]}-verified'
|
||||
segment.sub_labels.add(object["sub_label"][0])
|
||||
segment.sub_labels[object["id"]] = object["sub_label"][0]
|
||||
|
||||
# if object is alert label
|
||||
# and has entered required zones or required zones is not set
|
||||
@@ -347,7 +347,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
|
||||
if len(active_objects) > 0:
|
||||
detections: dict[str, str] = {}
|
||||
sub_labels = set()
|
||||
sub_labels: dict[str, str] = {}
|
||||
zones: list[str] = []
|
||||
severity = None
|
||||
|
||||
@@ -358,7 +358,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
detections[object["id"]] = object["sub_label"][0]
|
||||
else:
|
||||
detections[object["id"]] = f'{object["label"]}-verified'
|
||||
sub_labels.add(object["sub_label"][0])
|
||||
sub_labels[object["id"]] = object["sub_label"][0]
|
||||
|
||||
# if object is alert label
|
||||
# and has entered required zones or required zones is not set
|
||||
@@ -566,7 +566,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
frame_time,
|
||||
severity,
|
||||
{},
|
||||
set(),
|
||||
{},
|
||||
[],
|
||||
detections,
|
||||
)
|
||||
@@ -576,7 +576,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
frame_time,
|
||||
SeverityEnum.alert,
|
||||
{manual_info["event_id"]: manual_info["label"]},
|
||||
set(),
|
||||
{},
|
||||
[],
|
||||
set(),
|
||||
)
|
||||
|
||||
4
frigate/service_manager/__init__.py
Normal file
4
frigate/service_manager/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .multiprocessing import ServiceProcess
|
||||
from .service import Service, ServiceManager
|
||||
|
||||
__all__ = ["Service", "ServiceProcess", "ServiceManager"]
|
||||
164
frigate/service_manager/multiprocessing.py
Normal file
164
frigate/service_manager/multiprocessing.py
Normal file
@@ -0,0 +1,164 @@
|
||||
import asyncio
|
||||
import faulthandler
|
||||
import logging
|
||||
import multiprocessing as mp
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
from abc import ABC, abstractmethod
|
||||
from asyncio.exceptions import TimeoutError
|
||||
from logging.handlers import QueueHandler
|
||||
from types import FrameType
|
||||
from typing import Optional
|
||||
|
||||
import frigate.log
|
||||
|
||||
from .multiprocessing_waiter import wait as mp_wait
|
||||
from .service import Service, ServiceManager
|
||||
|
||||
DEFAULT_STOP_TIMEOUT = 10 # seconds
|
||||
|
||||
|
||||
class BaseServiceProcess(Service, ABC):
|
||||
"""A Service the manages a multiprocessing.Process."""
|
||||
|
||||
_process: Optional[mp.Process]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
manager: Optional[ServiceManager] = None,
|
||||
) -> None:
|
||||
super().__init__(name=name, manager=manager)
|
||||
|
||||
self._process = None
|
||||
|
||||
async def on_start(self) -> None:
|
||||
if self._process is not None:
|
||||
if self._process.is_alive():
|
||||
return # Already started.
|
||||
else:
|
||||
self._process.close()
|
||||
|
||||
# At this point, the process is either stopped or dead, so we can recreate it.
|
||||
self._process = mp.Process(target=self._run)
|
||||
self._process.name = self.name
|
||||
self._process.daemon = True
|
||||
self.before_start()
|
||||
self._process.start()
|
||||
self.after_start()
|
||||
|
||||
self.manager.logger.info(f"Started {self.name} (pid: {self._process.pid})")
|
||||
|
||||
async def on_stop(
|
||||
self,
|
||||
*,
|
||||
force: bool = False,
|
||||
timeout: Optional[float] = None,
|
||||
) -> None:
|
||||
if timeout is None:
|
||||
timeout = DEFAULT_STOP_TIMEOUT
|
||||
|
||||
if self._process is None:
|
||||
return # Already stopped.
|
||||
|
||||
running = True
|
||||
|
||||
if not force:
|
||||
self._process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(mp_wait(self._process), timeout)
|
||||
running = False
|
||||
except TimeoutError:
|
||||
self.manager.logger.warning(
|
||||
f"{self.name} is still running after "
|
||||
f"{timeout} seconds. Killing."
|
||||
)
|
||||
|
||||
if running:
|
||||
self._process.kill()
|
||||
await mp_wait(self._process)
|
||||
|
||||
self._process.close()
|
||||
self._process = None
|
||||
|
||||
self.manager.logger.info(f"{self.name} stopped")
|
||||
|
||||
@property
|
||||
def pid(self) -> Optional[int]:
|
||||
return self._process.pid if self._process else None
|
||||
|
||||
def _run(self) -> None:
|
||||
self.before_run()
|
||||
self.run()
|
||||
self.after_run()
|
||||
|
||||
def before_start(self) -> None:
|
||||
pass
|
||||
|
||||
def after_start(self) -> None:
|
||||
pass
|
||||
|
||||
def before_run(self) -> None:
|
||||
pass
|
||||
|
||||
def after_run(self) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def run(self) -> None:
|
||||
pass
|
||||
|
||||
def __getstate__(self) -> dict:
|
||||
return {
|
||||
k: v
|
||||
for k, v in self.__dict__.items()
|
||||
if not (k.startswith("_Service__") or k == "_process")
|
||||
}
|
||||
|
||||
|
||||
class ServiceProcess(BaseServiceProcess):
|
||||
logger: logging.Logger
|
||||
|
||||
@property
|
||||
def stop_event(self) -> threading.Event:
|
||||
# Lazily create the stop_event. This allows the signal handler to tell if anyone is
|
||||
# monitoring the stop event, and to raise a SystemExit if not.
|
||||
if "stop_event" not in self.__dict__:
|
||||
stop_event = threading.Event()
|
||||
self.__dict__["stop_event"] = stop_event
|
||||
else:
|
||||
stop_event = self.__dict__["stop_event"]
|
||||
assert isinstance(stop_event, threading.Event)
|
||||
|
||||
return stop_event
|
||||
|
||||
def before_start(self) -> None:
|
||||
if frigate.log.log_listener is None:
|
||||
raise RuntimeError("Logging has not yet been set up.")
|
||||
self.__log_queue = frigate.log.log_listener.queue
|
||||
|
||||
def before_run(self) -> None:
|
||||
super().before_run()
|
||||
|
||||
faulthandler.enable()
|
||||
|
||||
def receiveSignal(signalNumber: int, frame: Optional[FrameType]) -> None:
|
||||
# Get the stop_event through the dict to bypass lazy initialization.
|
||||
stop_event = self.__dict__.get("stop_event")
|
||||
if stop_event is not None:
|
||||
# Someone is monitoring stop_event. We should set it.
|
||||
stop_event.set()
|
||||
else:
|
||||
# Nobody is monitoring stop_event. We should raise SystemExit.
|
||||
sys.exit()
|
||||
|
||||
signal.signal(signal.SIGTERM, receiveSignal)
|
||||
signal.signal(signal.SIGINT, receiveSignal)
|
||||
|
||||
self.logger = logging.getLogger(self.name)
|
||||
|
||||
logging.basicConfig(handlers=[], force=True)
|
||||
logging.getLogger().addHandler(QueueHandler(self.__log_queue))
|
||||
del self.__log_queue
|
||||
150
frigate/service_manager/multiprocessing_waiter.py
Normal file
150
frigate/service_manager/multiprocessing_waiter.py
Normal file
@@ -0,0 +1,150 @@
|
||||
import asyncio
|
||||
import functools
|
||||
import logging
|
||||
import multiprocessing as mp
|
||||
import queue
|
||||
import threading
|
||||
from multiprocessing.connection import Connection
|
||||
from multiprocessing.connection import wait as mp_wait
|
||||
from socket import socket
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MultiprocessingWaiter(threading.Thread):
|
||||
"""A background thread that manages futures for the multiprocessing.connection.wait() method."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(daemon=True)
|
||||
|
||||
# Queue of objects to wait for and futures to set results for.
|
||||
self._queue: queue.Queue[tuple[Any, asyncio.Future[None]]] = queue.Queue()
|
||||
|
||||
# This is required to get mp_wait() to wake up when new objects to wait for are received.
|
||||
receive, send = mp.Pipe(duplex=False)
|
||||
self._receive_connection = receive
|
||||
self._send_connection = send
|
||||
|
||||
def wait_for_sentinel(self, sentinel: Any) -> asyncio.Future[None]:
|
||||
"""Create an asyncio.Future tracking a sentinel for multiprocessing.connection.wait()
|
||||
|
||||
Warning: This method is NOT thread-safe.
|
||||
"""
|
||||
# This would be incredibly stupid, but you never know.
|
||||
assert sentinel != self._receive_connection
|
||||
|
||||
# Send the future to the background thread for processing.
|
||||
future = asyncio.get_running_loop().create_future()
|
||||
self._queue.put((sentinel, future))
|
||||
|
||||
# Notify the background thread.
|
||||
#
|
||||
# This is the non-thread-safe part, but since this method is not really meant to be called
|
||||
# by users, we can get away with not adding a lock at this point (to avoid adding 2 locks).
|
||||
self._send_connection.send_bytes(b".")
|
||||
|
||||
return future
|
||||
|
||||
def run(self) -> None:
|
||||
logger.debug("Started background thread")
|
||||
|
||||
wait_dict: dict[Any, set[asyncio.Future[None]]] = {
|
||||
self._receive_connection: set()
|
||||
}
|
||||
while True:
|
||||
for ready_obj in mp_wait(wait_dict.keys()):
|
||||
# Make sure we never remove the receive connection from the wait dict
|
||||
if ready_obj is self._receive_connection:
|
||||
continue
|
||||
|
||||
logger.debug(
|
||||
f"Sentinel {ready_obj!r} is ready. "
|
||||
f"Notifying {len(wait_dict[ready_obj])} future(s)."
|
||||
)
|
||||
|
||||
# Go over all the futures attached to this object and mark them as ready.
|
||||
for fut in wait_dict.pop(ready_obj):
|
||||
if fut.cancelled():
|
||||
logger.debug(
|
||||
f"A future for sentinel {ready_obj!r} is ready, "
|
||||
"but the future is cancelled. Skipping."
|
||||
)
|
||||
else:
|
||||
fut.get_loop().call_soon_threadsafe(
|
||||
# Note: We need to check fut.cancelled() again, since it might
|
||||
# have been set before the event loop's definition of "soon".
|
||||
functools.partial(
|
||||
lambda fut: fut.cancelled() or fut.set_result(None), fut
|
||||
)
|
||||
)
|
||||
|
||||
# Check for cancellations in the remaining futures.
|
||||
done_objects = []
|
||||
for obj, fut_set in wait_dict.items():
|
||||
if obj is self._receive_connection:
|
||||
continue
|
||||
|
||||
# Find any cancelled futures and remove them.
|
||||
cancelled = [fut for fut in fut_set if fut.cancelled()]
|
||||
fut_set.difference_update(cancelled)
|
||||
logger.debug(
|
||||
f"Removing {len(cancelled)} future(s) from sentinel: {obj!r}"
|
||||
)
|
||||
|
||||
# Mark objects with no remaining futures for removal.
|
||||
if len(fut_set) == 0:
|
||||
done_objects.append(obj)
|
||||
|
||||
# Remove any objects that are done after removing cancelled futures.
|
||||
for obj in done_objects:
|
||||
logger.debug(
|
||||
f"Sentinel {obj!r} no longer has any futures waiting for it."
|
||||
)
|
||||
del wait_dict[obj]
|
||||
|
||||
# Get new objects to wait for from the queue.
|
||||
while True:
|
||||
try:
|
||||
obj, fut = self._queue.get_nowait()
|
||||
self._receive_connection.recv_bytes(maxlength=1)
|
||||
self._queue.task_done()
|
||||
|
||||
logger.debug(f"Received new sentinel: {obj!r}")
|
||||
|
||||
wait_dict.setdefault(obj, set()).add(fut)
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
|
||||
waiter_lock = threading.Lock()
|
||||
waiter_thread: Optional[MultiprocessingWaiter] = None
|
||||
|
||||
|
||||
async def wait(object: Union[mp.Process, Connection, socket]) -> None:
|
||||
"""Wait for the supplied object to be ready.
|
||||
|
||||
Under the hood, this uses multiprocessing.connection.wait() and a background thread manage the
|
||||
returned futures.
|
||||
"""
|
||||
global waiter_thread, waiter_lock
|
||||
|
||||
sentinel: Union[Connection, socket, int]
|
||||
if isinstance(object, mp.Process):
|
||||
sentinel = object.sentinel
|
||||
elif isinstance(object, Connection) or isinstance(object, socket):
|
||||
sentinel = object
|
||||
else:
|
||||
raise ValueError(f"Cannot wait for object of type {type(object).__qualname__}")
|
||||
|
||||
with waiter_lock:
|
||||
if waiter_thread is None:
|
||||
# Start a new waiter thread.
|
||||
waiter_thread = MultiprocessingWaiter()
|
||||
waiter_thread.start()
|
||||
|
||||
# Create the future while still holding the lock,
|
||||
# since wait_for_sentinel() is not thread safe.
|
||||
fut = waiter_thread.wait_for_sentinel(sentinel)
|
||||
|
||||
await fut
|
||||
446
frigate/service_manager/service.py
Normal file
446
frigate/service_manager/service.py
Normal file
@@ -0,0 +1,446 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import atexit
|
||||
import logging
|
||||
import threading
|
||||
from abc import ABC, abstractmethod
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
from typing import Coroutine, Optional, Union, cast
|
||||
|
||||
from typing_extensions import Self
|
||||
|
||||
|
||||
class Service(ABC):
|
||||
"""An abstract service instance."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
manager: Optional[ServiceManager] = None,
|
||||
):
|
||||
if name:
|
||||
self.__dict__["name"] = name
|
||||
|
||||
self.__manager = manager or ServiceManager.current()
|
||||
self.__lock = asyncio.Lock(loop=self.__manager._event_loop)
|
||||
self.__manager._register(self)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
try:
|
||||
return cast(str, self.__dict__["name"])
|
||||
except KeyError:
|
||||
return type(self).__qualname__
|
||||
|
||||
@property
|
||||
def manager(self) -> ServiceManager:
|
||||
"""The service manager this service is registered with."""
|
||||
try:
|
||||
return self.__manager
|
||||
except AttributeError:
|
||||
raise RuntimeError("Cannot access associated service manager")
|
||||
|
||||
def start(
|
||||
self,
|
||||
*,
|
||||
wait: bool = False,
|
||||
wait_timeout: Optional[float] = None,
|
||||
) -> Self:
|
||||
"""Start this service.
|
||||
|
||||
:param wait: If set, this function will block until the task is complete.
|
||||
:param wait_timeout: If set, this function will not return until the task is complete or the
|
||||
specified timeout has elapsed.
|
||||
"""
|
||||
|
||||
self.manager.run_task(
|
||||
self.on_start(),
|
||||
wait=wait,
|
||||
wait_timeout=wait_timeout,
|
||||
lock=self.__lock,
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
def stop(
|
||||
self,
|
||||
*,
|
||||
force: bool = False,
|
||||
timeout: Optional[float] = None,
|
||||
wait: bool = False,
|
||||
wait_timeout: Optional[float] = None,
|
||||
) -> Self:
|
||||
"""Stop this service.
|
||||
|
||||
:param force: If set, the service will be killed immediately.
|
||||
:param timeout: Maximum amount of time to wait before force-killing the service.
|
||||
|
||||
:param wait: If set, this function will block until the task is complete.
|
||||
:param wait_timeout: If set, this function will not return until the task is complete or the
|
||||
specified timeout has elapsed.
|
||||
"""
|
||||
|
||||
self.manager.run_task(
|
||||
self.on_stop(force=force, timeout=timeout),
|
||||
wait=wait,
|
||||
wait_timeout=wait_timeout,
|
||||
lock=self.__lock,
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
def restart(
|
||||
self,
|
||||
*,
|
||||
force: bool = False,
|
||||
stop_timeout: Optional[float] = None,
|
||||
wait: bool = False,
|
||||
wait_timeout: Optional[float] = None,
|
||||
) -> Self:
|
||||
"""Restart this service.
|
||||
|
||||
:param force: If set, the service will be killed immediately.
|
||||
:param timeout: Maximum amount of time to wait before force-killing the service.
|
||||
|
||||
:param wait: If set, this function will block until the task is complete.
|
||||
:param wait_timeout: If set, this function will not return until the task is complete or the
|
||||
specified timeout has elapsed.
|
||||
"""
|
||||
|
||||
self.manager.run_task(
|
||||
self.on_restart(force=force, stop_timeout=stop_timeout),
|
||||
wait=wait,
|
||||
wait_timeout=wait_timeout,
|
||||
lock=self.__lock,
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
@abstractmethod
|
||||
async def on_start(self) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def on_stop(
|
||||
self,
|
||||
*,
|
||||
force: bool = False,
|
||||
timeout: Optional[float] = None,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
async def on_restart(
|
||||
self,
|
||||
*,
|
||||
force: bool = False,
|
||||
stop_timeout: Optional[float] = None,
|
||||
) -> None:
|
||||
await self.on_stop(force=force, timeout=stop_timeout)
|
||||
await self.on_start()
|
||||
|
||||
|
||||
default_service_manager_lock = threading.Lock()
|
||||
default_service_manager: Optional[ServiceManager] = None
|
||||
|
||||
current_service_manager: ContextVar[ServiceManager] = ContextVar(
|
||||
"current_service_manager"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Command:
|
||||
"""A coroutine to execute in the service manager thread.
|
||||
|
||||
Attributes:
|
||||
coro: The coroutine to execute.
|
||||
lock: An async lock to acquire before calling the coroutine.
|
||||
done: If specified, the service manager will set this event after the command completes.
|
||||
"""
|
||||
|
||||
coro: Coroutine
|
||||
lock: Optional[asyncio.Lock] = None
|
||||
done: Optional[threading.Event] = None
|
||||
|
||||
|
||||
class ServiceManager:
|
||||
"""A set of services, along with the global state required to manage them efficiently.
|
||||
|
||||
Typically users of the service infrastructure will not interact with a service manager directly,
|
||||
but rather through individual Service subclasses that will automatically manage a service
|
||||
manager instance.
|
||||
|
||||
Each service manager instance has a background thread in which service lifecycle tasks are
|
||||
executed in an async executor. This is done to avoid head-of-line blocking in the business logic
|
||||
that spins up individual services. This thread is automatically started when the service manager
|
||||
is created and stopped either manually, or on application exit.
|
||||
|
||||
All (public) service manager methods are thread-safe.
|
||||
"""
|
||||
|
||||
_name: str
|
||||
_logger: logging.Logger
|
||||
|
||||
# The set of services this service manager knows about.
|
||||
_services: dict[str, Service]
|
||||
_services_lock: threading.Lock
|
||||
|
||||
# Commands will be queued with associated event loop. Queueing `None` signals shutdown.
|
||||
_command_queue: asyncio.Queue[Union[Command, None]]
|
||||
_event_loop: asyncio.AbstractEventLoop
|
||||
|
||||
# The pending command counter is used to ensure all commands have been queued before shutdown.
|
||||
_pending_commands: AtomicCounter
|
||||
|
||||
# The set of pending tasks after they have been received by the background thread and spawned.
|
||||
_tasks: set
|
||||
|
||||
# Fired after the async runtime starts. Object initialization completes after this is set.
|
||||
_setup_event: threading.Event
|
||||
|
||||
# Will be acquired to ensure the shutdown sentinel is sent only once. Never released.
|
||||
_shutdown_lock: threading.Lock
|
||||
|
||||
def __init__(self, *, name: Optional[str] = None):
|
||||
self._name = name if name is not None else (__package__ or __name__)
|
||||
self._logger = logging.getLogger(self.name)
|
||||
|
||||
self._services = dict()
|
||||
self._services_lock = threading.Lock()
|
||||
|
||||
self._pending_commands = AtomicCounter()
|
||||
self._tasks = set()
|
||||
|
||||
self._shutdown_lock = threading.Lock()
|
||||
|
||||
# --- Start the manager thread and wait for it to be ready. ---
|
||||
|
||||
self._setup_event = threading.Event()
|
||||
|
||||
async def start_manager() -> None:
|
||||
self._event_loop = asyncio.get_running_loop()
|
||||
self._command_queue = asyncio.Queue()
|
||||
|
||||
self._setup_event.set()
|
||||
await self._monitor_command_queue()
|
||||
|
||||
self._manager_thread = threading.Thread(
|
||||
name=self.name,
|
||||
target=lambda: asyncio.run(start_manager()),
|
||||
daemon=True,
|
||||
)
|
||||
|
||||
self._manager_thread.start()
|
||||
atexit.register(partial(self.shutdown, wait=True))
|
||||
|
||||
self._setup_event.wait()
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""The name of this service manager. Primarily intended for logging purposes."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
"""The logger used by this service manager."""
|
||||
return self._logger
|
||||
|
||||
@classmethod
|
||||
def current(cls) -> ServiceManager:
|
||||
"""The service manager set in the current context (async task or thread).
|
||||
|
||||
A global default service manager will be automatically created on first access."""
|
||||
|
||||
global default_service_manager
|
||||
|
||||
current = current_service_manager.get(None)
|
||||
if current is None:
|
||||
with default_service_manager_lock:
|
||||
if default_service_manager is None:
|
||||
default_service_manager = cls()
|
||||
|
||||
current = default_service_manager
|
||||
current_service_manager.set(current)
|
||||
return current
|
||||
|
||||
def make_current(self) -> None:
|
||||
"""Make this the current service manager."""
|
||||
|
||||
current_service_manager.set(self)
|
||||
|
||||
def run_task(
|
||||
self,
|
||||
coro: Coroutine,
|
||||
*,
|
||||
wait: bool = False,
|
||||
wait_timeout: Optional[float] = None,
|
||||
lock: Optional[asyncio.Lock] = None,
|
||||
) -> None:
|
||||
"""Run an async task in the service manager thread.
|
||||
|
||||
:param wait: If set, this function will block until the task is complete.
|
||||
:param wait_timeout: If set, this function will not return until the task is complete or the
|
||||
specified timeout has elapsed.
|
||||
"""
|
||||
|
||||
if not isinstance(coro, Coroutine):
|
||||
raise TypeError(f"Cannot schedule task for object of type {type(coro)}")
|
||||
|
||||
cmd = Command(coro=coro, lock=lock)
|
||||
if wait or wait_timeout is not None:
|
||||
cmd.done = threading.Event()
|
||||
|
||||
self._send_command(cmd)
|
||||
|
||||
if cmd.done is not None:
|
||||
cmd.done.wait(timeout=wait_timeout)
|
||||
|
||||
def shutdown(
|
||||
self, *, wait: bool = False, wait_timeout: Optional[float] = None
|
||||
) -> None:
|
||||
"""Shutdown the service manager thread.
|
||||
|
||||
After the shutdown process completes, any subsequent calls to the service manager will
|
||||
produce an error.
|
||||
|
||||
:param wait: If set, this function will block until the shutdown process is complete.
|
||||
:param wait_timeout: If set, this function will not return until the shutdown process is
|
||||
complete or the specified timeout has elapsed.
|
||||
"""
|
||||
|
||||
if self._shutdown_lock.acquire(blocking=False):
|
||||
self._send_command(None)
|
||||
if wait:
|
||||
self._manager_thread.join(timeout=wait_timeout)
|
||||
|
||||
def _ensure_running(self) -> None:
|
||||
self._setup_event.wait()
|
||||
if not self._manager_thread.is_alive():
|
||||
raise RuntimeError(f"ServiceManager {self.name} is not running")
|
||||
|
||||
def _send_command(self, command: Union[Command, None]) -> None:
|
||||
self._ensure_running()
|
||||
|
||||
async def queue_command() -> None:
|
||||
await self._command_queue.put(command)
|
||||
self._pending_commands.sub()
|
||||
|
||||
self._pending_commands.add()
|
||||
asyncio.run_coroutine_threadsafe(queue_command(), self._event_loop)
|
||||
|
||||
def _register(self, service: Service) -> None:
|
||||
"""Register a service with the service manager. This is done by the service constructor."""
|
||||
|
||||
self._ensure_running()
|
||||
with self._services_lock:
|
||||
name_conflict: Optional[Service] = next(
|
||||
(
|
||||
existing
|
||||
for name, existing in self._services.items()
|
||||
if name == service.name
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if name_conflict is service:
|
||||
raise RuntimeError(f"Attempt to re-register service: {service.name}")
|
||||
elif name_conflict is not None:
|
||||
raise RuntimeError(f"Duplicate service name: {service.name}")
|
||||
|
||||
self.logger.debug(f"Registering service: {service.name}")
|
||||
self._services[service.name] = service
|
||||
|
||||
def _run_command(self, command: Command) -> None:
|
||||
"""Execute a command and add it to the tasks set."""
|
||||
|
||||
def task_done(task: asyncio.Task) -> None:
|
||||
exc = task.exception()
|
||||
if exc:
|
||||
self.logger.exception("Exception in service manager task", exc_info=exc)
|
||||
self._tasks.discard(task)
|
||||
if command.done is not None:
|
||||
command.done.set()
|
||||
|
||||
async def task_harness() -> None:
|
||||
if command.lock is not None:
|
||||
async with command.lock:
|
||||
await command.coro
|
||||
else:
|
||||
await command.coro
|
||||
|
||||
task = asyncio.create_task(task_harness())
|
||||
task.add_done_callback(task_done)
|
||||
self._tasks.add(task)
|
||||
|
||||
async def _monitor_command_queue(self) -> None:
|
||||
"""The main function of the background thread."""
|
||||
|
||||
self.logger.info("Started service manager")
|
||||
|
||||
# Main command processing loop.
|
||||
while (command := await self._command_queue.get()) is not None:
|
||||
self._run_command(command)
|
||||
|
||||
# Send a stop command to all services. We don't have a status command yet, so we can just
|
||||
# stop everything and be done with it.
|
||||
with self._services_lock:
|
||||
self.logger.debug(f"Stopping {len(self._services)} services")
|
||||
for service in self._services.values():
|
||||
service.stop()
|
||||
|
||||
# Wait for all commands to finish executing.
|
||||
await self._shutdown()
|
||||
|
||||
self.logger.info("Exiting service manager")
|
||||
|
||||
async def _shutdown(self) -> None:
|
||||
"""Ensure all commands have been queued & executed."""
|
||||
|
||||
while True:
|
||||
command = None
|
||||
try:
|
||||
# Try and get a command from the queue.
|
||||
command = self._command_queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
if self._pending_commands.value > 0:
|
||||
# If there are pending commands to queue, await them.
|
||||
command = await self._command_queue.get()
|
||||
elif self._tasks:
|
||||
# If there are still pending tasks, wait for them. These tasks might queue
|
||||
# commands though, so we have to loop again.
|
||||
await asyncio.wait(self._tasks)
|
||||
else:
|
||||
# Nothing is pending at this point, so we're done here.
|
||||
break
|
||||
|
||||
# If we got a command, run it.
|
||||
if command is not None:
|
||||
self._run_command(command)
|
||||
|
||||
|
||||
class AtomicCounter:
|
||||
"""A lock-protected atomic counter."""
|
||||
|
||||
# Modern CPUs have atomics, but python doesn't seem to include them in the standard library.
|
||||
# Besides, the performance penalty is negligible compared to, well, using python.
|
||||
# So this will do just fine.
|
||||
|
||||
def __init__(self, initial: int = 0):
|
||||
self._lock = threading.Lock()
|
||||
self._value = initial
|
||||
|
||||
def add(self, value: int = 1) -> None:
|
||||
with self._lock:
|
||||
self._value += value
|
||||
|
||||
def sub(self, value: int = 1) -> None:
|
||||
with self._lock:
|
||||
self._value -= value
|
||||
|
||||
@property
|
||||
def value(self) -> int:
|
||||
with self._lock:
|
||||
return self._value
|
||||
@@ -197,8 +197,8 @@ async def set_gpu_stats(
|
||||
# intel QSV GPU
|
||||
intel_usage = get_intel_gpu_stats()
|
||||
|
||||
if intel_usage:
|
||||
stats["intel-qsv"] = intel_usage
|
||||
if intel_usage is not None:
|
||||
stats["intel-qsv"] = intel_usage or {"gpu": "", "mem": ""}
|
||||
else:
|
||||
stats["intel-qsv"] = {"gpu": "", "mem": ""}
|
||||
hwaccel_errors.append(args)
|
||||
@@ -222,8 +222,8 @@ async def set_gpu_stats(
|
||||
# intel VAAPI GPU
|
||||
intel_usage = get_intel_gpu_stats()
|
||||
|
||||
if intel_usage:
|
||||
stats["intel-vaapi"] = intel_usage
|
||||
if intel_usage is not None:
|
||||
stats["intel-vaapi"] = intel_usage or {"gpu": "", "mem": ""}
|
||||
else:
|
||||
stats["intel-vaapi"] = {"gpu": "", "mem": ""}
|
||||
hwaccel_errors.append(args)
|
||||
|
||||
@@ -20,7 +20,7 @@ def get_ort_providers(
|
||||
["CPUExecutionProvider"],
|
||||
[
|
||||
{
|
||||
"arena_extend_strategy": "kSameAsRequested",
|
||||
"enable_cpu_mem_arena": False,
|
||||
}
|
||||
],
|
||||
)
|
||||
@@ -53,7 +53,7 @@ def get_ort_providers(
|
||||
providers.append(provider)
|
||||
options.append(
|
||||
{
|
||||
"arena_extend_strategy": "kSameAsRequested",
|
||||
"enable_cpu_mem_arena": False,
|
||||
}
|
||||
)
|
||||
else:
|
||||
@@ -85,12 +85,8 @@ class ONNXModelRunner:
|
||||
else:
|
||||
# Use ONNXRuntime
|
||||
self.type = "ort"
|
||||
options = ort.SessionOptions()
|
||||
if device == "CPU":
|
||||
options.enable_cpu_mem_arena = False
|
||||
self.ort = ort.InferenceSession(
|
||||
model_path,
|
||||
sess_options=options,
|
||||
providers=providers,
|
||||
provider_options=options,
|
||||
)
|
||||
|
||||
@@ -257,6 +257,40 @@ def get_amd_gpu_stats() -> dict[str, str]:
|
||||
|
||||
def get_intel_gpu_stats() -> dict[str, str]:
|
||||
"""Get stats using intel_gpu_top."""
|
||||
|
||||
def get_stats_manually(output: str) -> dict[str, str]:
|
||||
"""Find global stats via regex when json fails to parse."""
|
||||
reading = "".join(output)
|
||||
results: dict[str, str] = {}
|
||||
|
||||
# render is used for qsv
|
||||
render = []
|
||||
for result in re.findall(r'"Render/3D/0":{[a-z":\d.,%]+}', reading):
|
||||
packet = json.loads(result[14:])
|
||||
single = packet.get("busy", 0.0)
|
||||
render.append(float(single))
|
||||
|
||||
if render:
|
||||
render_avg = sum(render) / len(render)
|
||||
else:
|
||||
render_avg = 1
|
||||
|
||||
# video is used for vaapi
|
||||
video = []
|
||||
for result in re.findall(r'"Video/\d":{[a-z":\d.,%]+}', reading):
|
||||
packet = json.loads(result[10:])
|
||||
single = packet.get("busy", 0.0)
|
||||
video.append(float(single))
|
||||
|
||||
if video:
|
||||
video_avg = sum(video) / len(video)
|
||||
else:
|
||||
video_avg = 1
|
||||
|
||||
results["gpu"] = f"{round((video_avg + render_avg) / 2, 2)}%"
|
||||
results["mem"] = "-%"
|
||||
return results
|
||||
|
||||
intel_gpu_top_command = [
|
||||
"timeout",
|
||||
"0.5s",
|
||||
@@ -279,7 +313,13 @@ def get_intel_gpu_stats() -> dict[str, str]:
|
||||
logger.error(f"Unable to poll intel GPU stats: {p.stderr}")
|
||||
return None
|
||||
else:
|
||||
data = json.loads(f'[{"".join(p.stdout.split())}]')
|
||||
output = "".join(p.stdout.split())
|
||||
|
||||
try:
|
||||
data = json.loads(f"[{output}]")
|
||||
except json.JSONDecodeError:
|
||||
return get_stats_manually(output)
|
||||
|
||||
results: dict[str, str] = {}
|
||||
render = {"global": []}
|
||||
video = {"global": []}
|
||||
@@ -328,7 +368,7 @@ def get_intel_gpu_stats() -> dict[str, str]:
|
||||
results["clients"] = {}
|
||||
|
||||
for key in render.keys():
|
||||
if key == "global":
|
||||
if key == "global" or not render[key] or not video[key]:
|
||||
continue
|
||||
|
||||
results["clients"][key] = (
|
||||
|
||||
692
web/package-lock.json
generated
692
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -16,28 +16,28 @@
|
||||
"dependencies": {
|
||||
"@cycjimmy/jsmpeg-player": "^6.1.1",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.1",
|
||||
"@radix-ui/react-context-menu": "^2.2.1",
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-hover-card": "^1.1.1",
|
||||
"@radix-ui/react-checkbox": "^1.1.2",
|
||||
"@radix-ui/react-context-menu": "^2.2.2",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-hover-card": "^1.1.2",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.1",
|
||||
"@radix-ui/react-radio-group": "^1.2.0",
|
||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-popover": "^1.1.2",
|
||||
"@radix-ui/react-radio-group": "^1.2.1",
|
||||
"@radix-ui/react-scroll-area": "^1.2.0",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slider": "^1.2.0",
|
||||
"@radix-ui/react-slider": "^1.2.1",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.3",
|
||||
"apexcharts": "^3.52.0",
|
||||
"axios": "^1.7.3",
|
||||
"axios": "^1.7.7",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
@@ -45,10 +45,10 @@
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.2.0",
|
||||
"framer-motion": "^11.5.4",
|
||||
"hls.js": "^1.5.14",
|
||||
"hls.js": "^1.5.17",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"immer": "^10.1.1",
|
||||
"konva": "^9.3.14",
|
||||
"konva": "^9.3.16",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.407.0",
|
||||
"monaco-yaml": "^5.2.2",
|
||||
@@ -65,7 +65,7 @@
|
||||
"react-konva": "^18.2.10",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"react-swipeable": "^7.0.1",
|
||||
"react-tracked": "^2.0.0",
|
||||
"react-tracked": "^2.0.1",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-use-websocket": "^4.8.1",
|
||||
"react-zoom-pan-pinch": "3.4.4",
|
||||
@@ -83,9 +83,9 @@
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@testing-library/jest-dom": "^6.4.6",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@testing-library/jest-dom": "^6.6.2",
|
||||
"@types/lodash": "^4.17.12",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/react": "^18.3.2",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
@@ -95,7 +95,7 @@
|
||||
"@types/strftime": "^0.9.8",
|
||||
"@typescript-eslint/eslint-plugin": "^7.5.0",
|
||||
"@typescript-eslint/parser": "^7.5.0",
|
||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||
"@vitejs/plugin-react-swc": "^3.7.1",
|
||||
"@vitest/coverage-v8": "^2.0.5",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.57.0",
|
||||
@@ -109,8 +109,8 @@
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"jsdom": "^24.1.1",
|
||||
"msw": "^2.3.5",
|
||||
"postcss": "^8.4.39",
|
||||
"prettier": "^3.3.2",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"tailwindcss": "^3.4.9",
|
||||
"typescript": "^5.5.4",
|
||||
|
||||
@@ -65,7 +65,10 @@ function useValue(): useValueReturn {
|
||||
: "OFF";
|
||||
});
|
||||
|
||||
setWsState({ ...wsState, ...cameraStates });
|
||||
setWsState((prevState) => ({
|
||||
...prevState,
|
||||
...cameraStates,
|
||||
}));
|
||||
setHasCameraState(true);
|
||||
// we only want this to run initially when the config is loaded
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -77,7 +80,10 @@ function useValue(): useValueReturn {
|
||||
const data: Update = JSON.parse(event.data);
|
||||
|
||||
if (data) {
|
||||
setWsState({ ...wsState, [data.topic]: data.payload });
|
||||
setWsState((prevState) => ({
|
||||
...prevState,
|
||||
[data.topic]: data.payload,
|
||||
}));
|
||||
}
|
||||
},
|
||||
onOpen: () => {
|
||||
|
||||
@@ -121,6 +121,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
variant="select"
|
||||
disabled={isLoading}
|
||||
className="flex flex-1"
|
||||
aria-label="Login"
|
||||
>
|
||||
{isLoading && <ActivityIndicator className="mr-2 h-4 w-4" />}
|
||||
Login
|
||||
|
||||
@@ -4,17 +4,20 @@ import { toast } from "sonner";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { FaDownload } from "react-icons/fa";
|
||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type DownloadVideoButtonProps = {
|
||||
source: string;
|
||||
camera: string;
|
||||
startTime: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function DownloadVideoButton({
|
||||
source,
|
||||
camera,
|
||||
startTime,
|
||||
className,
|
||||
}: DownloadVideoButtonProps) {
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
@@ -32,13 +35,6 @@ export function DownloadVideoButton({
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownloadEnd = () => {
|
||||
setIsDownloading(false);
|
||||
toast.success("Download completed successfully.", {
|
||||
position: "top-center",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
@@ -46,17 +42,15 @@ export function DownloadVideoButton({
|
||||
disabled={isDownloading}
|
||||
className="flex items-center gap-2"
|
||||
size="sm"
|
||||
aria-label="Download Video"
|
||||
>
|
||||
<a
|
||||
href={source}
|
||||
download={filename}
|
||||
onClick={handleDownloadStart}
|
||||
onBlur={handleDownloadEnd}
|
||||
>
|
||||
<a href={source} download={filename} onClick={handleDownloadStart}>
|
||||
{isDownloading ? (
|
||||
<ActivityIndicator className="size-4" />
|
||||
) : (
|
||||
<FaDownload className="size-4 text-secondary-foreground" />
|
||||
<FaDownload
|
||||
className={cn("size-4 text-secondary-foreground", className)}
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
@@ -55,7 +55,12 @@ export default function DebugCameraImage({
|
||||
searchParams={searchParams}
|
||||
cameraClasses="relative w-full h-full flex justify-center"
|
||||
/>
|
||||
<Button onClick={handleToggleSettings} variant="link" size="sm">
|
||||
<Button
|
||||
onClick={handleToggleSettings}
|
||||
variant="link"
|
||||
size="sm"
|
||||
aria-label="Settings"
|
||||
>
|
||||
<span className="h-5 w-5">
|
||||
<LuSettings />
|
||||
</span>{" "}
|
||||
|
||||
@@ -121,6 +121,7 @@ export function AnimatedEventCard({
|
||||
<Button
|
||||
className="absolute right-2 top-1 z-40 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
|
||||
size="xs"
|
||||
aria-label="Mark as Reviewed"
|
||||
onClick={async () => {
|
||||
await axios.post(`reviews/viewed`, { ids: [event.id] });
|
||||
updateEvents();
|
||||
|
||||
@@ -113,6 +113,7 @@ export default function ExportCard({
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
aria-label="Save Export"
|
||||
size="sm"
|
||||
variant="select"
|
||||
disabled={(editName?.update?.length ?? 0) == 0}
|
||||
@@ -206,6 +207,7 @@ export default function ExportCard({
|
||||
{!exportedRecording.in_progress && (
|
||||
<Button
|
||||
className="absolute left-1/2 top-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2 cursor-pointer text-white hover:bg-transparent hover:text-white"
|
||||
aria-label="Play"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
onSelect(exportedRecording);
|
||||
|
||||
@@ -1,38 +1,10 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import TimeAgo from "../dynamic/TimeAgo";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { SearchResult } from "@/types/search";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "../ui/alert-dialog";
|
||||
import { LuCamera, LuDownload, LuMoreVertical, LuTrash2 } from "react-icons/lu";
|
||||
import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon";
|
||||
import { FrigatePlusDialog } from "../overlay/dialog/FrigatePlusDialog";
|
||||
import { Event } from "@/types/event";
|
||||
import { FaArrowsRotate } from "react-icons/fa6";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { MdImageSearch } from "react-icons/md";
|
||||
import { isMobileOnly } from "react-device-detect";
|
||||
import { buttonVariants } from "../ui/button";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import SearchResultActions from "../menu/SearchResultActions";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SearchThumbnailProps = {
|
||||
@@ -52,31 +24,7 @@ export default function SearchThumbnailFooter({
|
||||
}: SearchThumbnailProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
// interactions
|
||||
|
||||
const [showFrigatePlus, setShowFrigatePlus] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
axios
|
||||
.delete(`events/${searchResult.id}`)
|
||||
.then((resp) => {
|
||||
if (resp.status == 200) {
|
||||
toast.success("Tracked object deleted successfully.", {
|
||||
position: "top-center",
|
||||
});
|
||||
refreshResults();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Failed to delete tracked object.", {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
}, [searchResult, refreshResults]);
|
||||
|
||||
// date
|
||||
|
||||
const formattedDate = useFormattedTimestamp(
|
||||
searchResult.start_time,
|
||||
config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p",
|
||||
@@ -84,146 +32,31 @@ export default function SearchThumbnailFooter({
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AlertDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirm Delete</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this tracked object?
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={buttonVariants({ variant: "destructive" })}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<FrigatePlusDialog
|
||||
upload={
|
||||
showFrigatePlus ? (searchResult as unknown as Event) : undefined
|
||||
}
|
||||
onClose={() => setShowFrigatePlus(false)}
|
||||
onEventUploaded={() => {
|
||||
searchResult.plus_id = "submitted";
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-row items-center justify-between",
|
||||
columns > 4 &&
|
||||
"items-start sm:flex-col sm:gap-2 lg:flex-row lg:items-center lg:gap-1",
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-row items-center justify-between",
|
||||
columns > 4 &&
|
||||
"items-start sm:flex-col sm:gap-2 lg:flex-row lg:items-center lg:gap-1",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-start text-xs text-primary-variant">
|
||||
{searchResult.end_time ? (
|
||||
<TimeAgo time={searchResult.start_time * 1000} dense />
|
||||
) : (
|
||||
<div>
|
||||
<ActivityIndicator size={14} />
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-start text-xs text-primary-variant">
|
||||
{searchResult.end_time ? (
|
||||
<TimeAgo time={searchResult.start_time * 1000} dense />
|
||||
) : (
|
||||
<div>
|
||||
<ActivityIndicator size={14} />
|
||||
</div>
|
||||
)}
|
||||
{formattedDate}
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-end gap-6 md:gap-4">
|
||||
{!isMobileOnly &&
|
||||
config?.plus?.enabled &&
|
||||
searchResult.has_snapshot &&
|
||||
searchResult.end_time &&
|
||||
!searchResult.plus_id && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<FrigatePlusIcon
|
||||
className="size-5 cursor-pointer text-primary-variant hover:text-primary"
|
||||
onClick={() => setShowFrigatePlus(true)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Submit to Frigate+</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{config?.semantic_search?.enabled && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<MdImageSearch
|
||||
className="size-5 cursor-pointer text-primary-variant hover:text-primary"
|
||||
onClick={findSimilar}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Find similar</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<LuMoreVertical className="size-5 cursor-pointer text-primary-variant hover:text-primary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={"end"}>
|
||||
{searchResult.has_clip && (
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
className="justify_start flex items-center"
|
||||
href={`${baseUrl}api/events/${searchResult.id}/clip.mp4`}
|
||||
download={`${searchResult.camera}_${searchResult.label}.mp4`}
|
||||
>
|
||||
<LuDownload className="mr-2 size-4" />
|
||||
<span>Download video</span>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{searchResult.has_snapshot && (
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
className="justify_start flex items-center"
|
||||
href={`${baseUrl}api/events/${searchResult.id}/snapshot.jpg`}
|
||||
download={`${searchResult.camera}_${searchResult.label}.jpg`}
|
||||
>
|
||||
<LuCamera className="mr-2 size-4" />
|
||||
<span>Download snapshot</span>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={showObjectLifecycle}
|
||||
>
|
||||
<FaArrowsRotate className="mr-2 size-4" />
|
||||
<span>View object lifecycle</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{isMobileOnly &&
|
||||
config?.plus?.enabled &&
|
||||
searchResult.has_snapshot &&
|
||||
searchResult.end_time &&
|
||||
!searchResult.plus_id && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => setShowFrigatePlus(true)}
|
||||
>
|
||||
<FrigatePlusIcon className="mr-2 size-4 cursor-pointer text-primary" />
|
||||
<span>Submit to Frigate+</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
>
|
||||
<LuTrash2 className="mr-2 size-4" />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{formattedDate}
|
||||
</div>
|
||||
</>
|
||||
<div className="flex flex-row items-center justify-end gap-6 md:gap-4">
|
||||
<SearchResultActions
|
||||
searchResult={searchResult}
|
||||
findSimilar={findSimilar}
|
||||
refreshResults={refreshResults}
|
||||
showObjectLifecycle={showObjectLifecycle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ export default function NewReviewData({
|
||||
: "invisible",
|
||||
"mx-auto mt-5 bg-gray-400 text-center text-white",
|
||||
)}
|
||||
aria-label="View new review items"
|
||||
onClick={() => {
|
||||
pullLatestData();
|
||||
if (contentRef.current) {
|
||||
|
||||
@@ -34,6 +34,7 @@ export default function CalendarFilterButton({
|
||||
const trigger = (
|
||||
<Button
|
||||
className="flex items-center gap-2"
|
||||
aria-label="Select a date to filter by"
|
||||
variant={day == undefined ? "default" : "select"}
|
||||
size="sm"
|
||||
>
|
||||
@@ -57,6 +58,7 @@ export default function CalendarFilterButton({
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex items-center justify-center p-2">
|
||||
<Button
|
||||
aria-label="Reset"
|
||||
onClick={() => {
|
||||
updateSelectedDay(undefined);
|
||||
}}
|
||||
@@ -99,6 +101,7 @@ export function CalendarRangeFilterButton({
|
||||
const trigger = (
|
||||
<Button
|
||||
className="flex items-center gap-2"
|
||||
aria-label="Select a date to filter by"
|
||||
variant={range == undefined ? "default" : "select"}
|
||||
size="sm"
|
||||
>
|
||||
|
||||
@@ -141,6 +141,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
? "bg-blue-900 bg-opacity-60 text-selected focus:bg-blue-900 focus:bg-opacity-60"
|
||||
: "bg-secondary text-secondary-foreground focus:bg-secondary focus:text-secondary-foreground"
|
||||
}
|
||||
aria-label="All Cameras"
|
||||
size="xs"
|
||||
onClick={() => (group ? setGroup("default", true) : null)}
|
||||
onMouseEnter={() => (isDesktop ? showTooltip("default") : null)}
|
||||
@@ -165,6 +166,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
? "bg-blue-900 bg-opacity-60 text-selected focus:bg-blue-900 focus:bg-opacity-60"
|
||||
: "bg-secondary text-secondary-foreground"
|
||||
}
|
||||
aria-label="Camera Group"
|
||||
size="xs"
|
||||
onClick={() => setGroup(name, group != "default")}
|
||||
onMouseEnter={() => (isDesktop ? showTooltip(name) : null)}
|
||||
@@ -191,6 +193,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
|
||||
<Button
|
||||
className="bg-secondary text-muted-foreground"
|
||||
aria-label="Add camera group"
|
||||
size="xs"
|
||||
onClick={() => setAddGroup(true)}
|
||||
>
|
||||
@@ -355,6 +358,7 @@ function NewGroupDialog({
|
||||
"size-6 rounded-md bg-secondary-foreground p-1 text-background",
|
||||
isMobile && "text-secondary-foreground",
|
||||
)}
|
||||
aria-label="Add camera group"
|
||||
onClick={() => {
|
||||
setEditState("add");
|
||||
}}
|
||||
@@ -536,10 +540,16 @@ export function CameraGroupRow({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={onEditGroup}>
|
||||
<DropdownMenuItem
|
||||
aria-label="Edit group"
|
||||
onClick={onEditGroup}
|
||||
>
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)}>
|
||||
<DropdownMenuItem
|
||||
aria-label="Delete group"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -793,13 +803,19 @@ export function CameraGroupEdit({
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
|
||||
<div className="flex flex-row gap-2 py-5 md:pb-0">
|
||||
<Button type="button" className="flex flex-1" onClick={onCancel}>
|
||||
<Button
|
||||
type="button"
|
||||
className="flex flex-1"
|
||||
aria-label="Cancel"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
disabled={isLoading}
|
||||
className="flex flex-1"
|
||||
aria-label="Save"
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
|
||||
@@ -55,6 +55,7 @@ export function CamerasFilterButton({
|
||||
const trigger = (
|
||||
<Button
|
||||
className="flex items-center gap-2 capitalize"
|
||||
aria-label="Cameras Filter"
|
||||
variant={selectedCameras?.length == undefined ? "default" : "select"}
|
||||
size="sm"
|
||||
>
|
||||
@@ -202,6 +203,7 @@ export function CamerasFilterContent({
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex items-center justify-evenly p-2">
|
||||
<Button
|
||||
aria-label="Apply"
|
||||
variant="select"
|
||||
disabled={currentCameras?.length === 0}
|
||||
onClick={() => {
|
||||
@@ -212,6 +214,7 @@ export function CamerasFilterContent({
|
||||
Apply
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Reset"
|
||||
onClick={() => {
|
||||
setCurrentCameras(undefined);
|
||||
updateCameraFilter(undefined);
|
||||
|
||||
@@ -17,7 +17,11 @@ export function LogLevelFilterButton({
|
||||
updateLabelFilter,
|
||||
}: LogLevelFilterButtonProps) {
|
||||
const trigger = (
|
||||
<Button size="sm" className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
aria-label="Filter log level"
|
||||
>
|
||||
<FaFilter className="text-secondary-foreground" />
|
||||
<div className="hidden text-primary md:block">Filter</div>
|
||||
</Button>
|
||||
|
||||
@@ -104,6 +104,7 @@ export default function ReviewActionGroup({
|
||||
{selectedReviews.length == 1 && (
|
||||
<Button
|
||||
className="flex items-center gap-2 p-2"
|
||||
aria-label="Export"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onExport(selectedReviews[0]);
|
||||
@@ -116,6 +117,7 @@ export default function ReviewActionGroup({
|
||||
)}
|
||||
<Button
|
||||
className="flex items-center gap-2 p-2"
|
||||
aria-label="Mark as reviewed"
|
||||
size="sm"
|
||||
onClick={onMarkAsReviewed}
|
||||
>
|
||||
@@ -124,6 +126,7 @@ export default function ReviewActionGroup({
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center gap-2 p-2"
|
||||
aria-label="Delete"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
|
||||
@@ -278,6 +278,7 @@ function ShowReviewFilter({
|
||||
|
||||
<Button
|
||||
className="block duration-0 md:hidden"
|
||||
aria-label="Show reviewed"
|
||||
variant={showReviewedSwitch ? "select" : "default"}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
@@ -338,6 +339,7 @@ function GeneralFilterButton({
|
||||
selectedLabels?.length || selectedZones?.length ? "select" : "default"
|
||||
}
|
||||
className="flex items-center gap-2 capitalize"
|
||||
aria-label="Filter"
|
||||
>
|
||||
<FaFilter
|
||||
className={`${selectedLabels?.length || selectedZones?.length ? "text-selected-foreground" : "text-secondary-foreground"}`}
|
||||
@@ -538,6 +540,7 @@ export function GeneralFilterContent({
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex items-center justify-evenly p-2">
|
||||
<Button
|
||||
aria-label="Apply"
|
||||
variant="select"
|
||||
onClick={() => {
|
||||
if (selectedLabels != currentLabels) {
|
||||
@@ -554,6 +557,7 @@ export function GeneralFilterContent({
|
||||
Apply
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Reset"
|
||||
onClick={() => {
|
||||
setCurrentLabels(undefined);
|
||||
setCurrentZones?.(undefined);
|
||||
@@ -601,6 +605,7 @@ function ShowMotionOnlyButton({
|
||||
<Button
|
||||
size="sm"
|
||||
className="duration-0"
|
||||
aria-label="Show Motion Only"
|
||||
variant={motionOnlyButton ? "select" : "default"}
|
||||
onClick={() => setMotionOnlyButton(!motionOnlyButton)}
|
||||
>
|
||||
|
||||
@@ -227,6 +227,7 @@ function GeneralFilterButton({
|
||||
size="sm"
|
||||
variant={selectedLabels?.length ? "select" : "default"}
|
||||
className="flex items-center gap-2 capitalize"
|
||||
aria-label="Labels"
|
||||
>
|
||||
<MdLabel
|
||||
className={`${selectedLabels?.length ? "text-selected-foreground" : "text-secondary-foreground"}`}
|
||||
@@ -336,6 +337,7 @@ export function GeneralFilterContent({
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex items-center justify-evenly p-2">
|
||||
<Button
|
||||
aria-label="Apply"
|
||||
variant="select"
|
||||
onClick={() => {
|
||||
if (selectedLabels != currentLabels) {
|
||||
@@ -348,6 +350,7 @@ export function GeneralFilterContent({
|
||||
Apply
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Reset"
|
||||
onClick={() => {
|
||||
setCurrentLabels(undefined);
|
||||
updateLabelFilter(undefined);
|
||||
|
||||
@@ -21,6 +21,7 @@ export function ZoneMaskFilterButton({
|
||||
size="sm"
|
||||
variant={selectedZoneMask?.length ? "select" : "default"}
|
||||
className="flex items-center gap-2 capitalize"
|
||||
aria-label="Filter by zone mask"
|
||||
>
|
||||
<FaFilter
|
||||
className={`${selectedZoneMask?.length ? "text-selected-foreground" : "text-secondary-foreground"}`}
|
||||
|
||||
@@ -216,7 +216,7 @@ export function CombinedStorageGraph({
|
||||
</Popover>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{getUnitSize(item.usage)}</TableCell>
|
||||
<TableCell>{getUnitSize(item.usage ?? 0)}</TableCell>
|
||||
<TableCell>{item.data[0].toFixed(2)}%</TableCell>
|
||||
<TableCell>
|
||||
{item.name === "Unused"
|
||||
|
||||
@@ -66,7 +66,10 @@ export default function IconPicker({
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
{!selectedIcon?.name || !selectedIcon?.Icon ? (
|
||||
<Button className="mt-2 w-full text-muted-foreground">
|
||||
<Button
|
||||
className="mt-2 w-full text-muted-foreground"
|
||||
aria-label="Select an icon"
|
||||
>
|
||||
Select an icon
|
||||
</Button>
|
||||
) : (
|
||||
|
||||
@@ -59,11 +59,14 @@ export function SaveSearchDialog({
|
||||
placeholder="Enter a name for your search"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button aria-label="Cancel" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant="select"
|
||||
className="mb-2 md:mb-0"
|
||||
aria-label="Save this search"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
|
||||
@@ -72,6 +72,7 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
|
||||
className={
|
||||
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
|
||||
}
|
||||
aria-label="Log out"
|
||||
>
|
||||
<a className="flex" href={logoutUrl}>
|
||||
<LuLogOut className="mr-2 size-4" />
|
||||
|
||||
@@ -176,6 +176,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
aria-label="Log out"
|
||||
>
|
||||
<a className="flex" href={logoutUrl}>
|
||||
<LuLogOut className="mr-2 size-4" />
|
||||
@@ -194,6 +195,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
? "cursor-pointer"
|
||||
: "flex w-full items-center p-2 text-sm"
|
||||
}
|
||||
aria-label="System metrics"
|
||||
>
|
||||
<LuActivity className="mr-2 size-4" />
|
||||
<span>System metrics</span>
|
||||
@@ -206,6 +208,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
? "cursor-pointer"
|
||||
: "flex w-full items-center p-2 text-sm"
|
||||
}
|
||||
aria-label="System logs"
|
||||
>
|
||||
<LuList className="mr-2 size-4" />
|
||||
<span>System logs</span>
|
||||
@@ -224,6 +227,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
? "cursor-pointer"
|
||||
: "flex w-full items-center p-2 text-sm"
|
||||
}
|
||||
aria-label="Settings"
|
||||
>
|
||||
<LuSettings className="mr-2 size-4" />
|
||||
<span>Settings</span>
|
||||
@@ -236,6 +240,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
? "cursor-pointer"
|
||||
: "flex w-full items-center p-2 text-sm"
|
||||
}
|
||||
aria-label="Configuration editor"
|
||||
>
|
||||
<LuPenSquare className="mr-2 size-4" />
|
||||
<span>Configuration editor</span>
|
||||
@@ -269,6 +274,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
aria-label="Light mode"
|
||||
onClick={() => setTheme("light")}
|
||||
>
|
||||
{theme === "light" ? (
|
||||
@@ -286,6 +292,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
aria-label="Dark mode"
|
||||
onClick={() => setTheme("dark")}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
@@ -303,6 +310,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
aria-label="Use the system settings for light or dark mode"
|
||||
onClick={() => setTheme("system")}
|
||||
>
|
||||
{theme === "system" ? (
|
||||
@@ -343,6 +351,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
aria-label={`Color scheme - ${scheme}`}
|
||||
onClick={() => setColorScheme(scheme)}
|
||||
>
|
||||
{scheme === colorScheme ? (
|
||||
@@ -370,6 +379,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
className={
|
||||
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
|
||||
}
|
||||
aria-label="Frigate documentation"
|
||||
>
|
||||
<LuLifeBuoy className="mr-2 size-4" />
|
||||
<span>Documentation</span>
|
||||
@@ -383,6 +393,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
className={
|
||||
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
|
||||
}
|
||||
aria-label="Frigate Github"
|
||||
>
|
||||
<LuGithub className="mr-2 size-4" />
|
||||
<span>GitHub</span>
|
||||
@@ -393,6 +404,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
className={
|
||||
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
|
||||
}
|
||||
aria-label="Restart Frigate"
|
||||
onClick={() => setRestartDialogOpen(true)}
|
||||
>
|
||||
<LuRotateCw className="mr-2 size-4" />
|
||||
@@ -446,7 +458,12 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
<p>This page will reload in {countdown} seconds.</p>
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<Button size="lg" className="mt-5" onClick={handleForceReload}>
|
||||
<Button
|
||||
size="lg"
|
||||
className="mt-5"
|
||||
aria-label="Force reload now"
|
||||
onClick={handleForceReload}
|
||||
>
|
||||
Force Reload Now
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
230
web/src/components/menu/SearchResultActions.tsx
Normal file
230
web/src/components/menu/SearchResultActions.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { useState, ReactNode } from "react";
|
||||
import { SearchResult } from "@/types/search";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { toast } from "sonner";
|
||||
import axios from "axios";
|
||||
import { LuCamera, LuDownload, LuMoreVertical, LuTrash2 } from "react-icons/lu";
|
||||
import { FaArrowsRotate } from "react-icons/fa6";
|
||||
import { MdImageSearch } from "react-icons/md";
|
||||
import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon";
|
||||
import { isMobileOnly } from "react-device-detect";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { FrigatePlusDialog } from "@/components/overlay/dialog/FrigatePlusDialog";
|
||||
import useSWR from "swr";
|
||||
import { Event } from "@/types/event";
|
||||
|
||||
type SearchResultActionsProps = {
|
||||
searchResult: SearchResult;
|
||||
findSimilar: () => void;
|
||||
refreshResults: () => void;
|
||||
showObjectLifecycle: () => void;
|
||||
isContextMenu?: boolean;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export default function SearchResultActions({
|
||||
searchResult,
|
||||
findSimilar,
|
||||
refreshResults,
|
||||
showObjectLifecycle,
|
||||
isContextMenu = false,
|
||||
children,
|
||||
}: SearchResultActionsProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
const [showFrigatePlus, setShowFrigatePlus] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
const handleDelete = () => {
|
||||
axios
|
||||
.delete(`events/${searchResult.id}`)
|
||||
.then((resp) => {
|
||||
if (resp.status == 200) {
|
||||
toast.success("Tracked object deleted successfully.", {
|
||||
position: "top-center",
|
||||
});
|
||||
refreshResults();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Failed to delete tracked object.", {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const MenuItem = isContextMenu ? ContextMenuItem : DropdownMenuItem;
|
||||
|
||||
const menuItems = (
|
||||
<>
|
||||
{searchResult.has_clip && (
|
||||
<MenuItem aria-label="Download video">
|
||||
<a
|
||||
className="flex items-center"
|
||||
href={`${baseUrl}api/events/${searchResult.id}/clip.mp4`}
|
||||
download={`${searchResult.camera}_${searchResult.label}.mp4`}
|
||||
>
|
||||
<LuDownload className="mr-2 size-4" />
|
||||
<span>Download video</span>
|
||||
</a>
|
||||
</MenuItem>
|
||||
)}
|
||||
{searchResult.has_snapshot && (
|
||||
<MenuItem aria-label="Download snapshot">
|
||||
<a
|
||||
className="flex items-center"
|
||||
href={`${baseUrl}api/events/${searchResult.id}/snapshot.jpg`}
|
||||
download={`${searchResult.camera}_${searchResult.label}.jpg`}
|
||||
>
|
||||
<LuCamera className="mr-2 size-4" />
|
||||
<span>Download snapshot</span>
|
||||
</a>
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
aria-label="Show the object lifecycle"
|
||||
onClick={showObjectLifecycle}
|
||||
>
|
||||
<FaArrowsRotate className="mr-2 size-4" />
|
||||
<span>View object lifecycle</span>
|
||||
</MenuItem>
|
||||
{config?.semantic_search?.enabled && isContextMenu && (
|
||||
<MenuItem
|
||||
aria-label="Find similar tracked objects"
|
||||
onClick={findSimilar}
|
||||
>
|
||||
<MdImageSearch className="mr-2 size-4" />
|
||||
<span>Find similar</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
{isMobileOnly &&
|
||||
config?.plus?.enabled &&
|
||||
searchResult.has_snapshot &&
|
||||
searchResult.end_time &&
|
||||
!searchResult.plus_id && (
|
||||
<MenuItem
|
||||
aria-label="Submit to Frigate Plus"
|
||||
onClick={() => setShowFrigatePlus(true)}
|
||||
>
|
||||
<FrigatePlusIcon className="mr-2 size-4 cursor-pointer text-primary" />
|
||||
<span>Submit to Frigate+</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
aria-label="Delete this tracked object"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
>
|
||||
<LuTrash2 className="mr-2 size-4" />
|
||||
<span>Delete</span>
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AlertDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirm Delete</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this tracked object?
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={buttonVariants({ variant: "destructive" })}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<FrigatePlusDialog
|
||||
upload={
|
||||
showFrigatePlus ? (searchResult as unknown as Event) : undefined
|
||||
}
|
||||
onClose={() => setShowFrigatePlus(false)}
|
||||
onEventUploaded={() => {
|
||||
searchResult.plus_id = "submitted";
|
||||
}}
|
||||
/>
|
||||
|
||||
{isContextMenu ? (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent>{menuItems}</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
) : (
|
||||
<>
|
||||
{config?.semantic_search?.enabled && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<MdImageSearch
|
||||
className="size-5 cursor-pointer text-primary-variant hover:text-primary"
|
||||
onClick={findSimilar}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Find similar</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{!isMobileOnly &&
|
||||
config?.plus?.enabled &&
|
||||
searchResult.has_snapshot &&
|
||||
searchResult.end_time &&
|
||||
!searchResult.plus_id && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<FrigatePlusIcon
|
||||
className="size-5 cursor-pointer text-primary-variant hover:text-primary"
|
||||
onClick={() => setShowFrigatePlus(true)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Submit to Frigate+</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<LuMoreVertical className="size-5 cursor-pointer text-primary-variant hover:text-primary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">{menuItems}</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,13 @@ export function MobilePage({
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
|
||||
|
||||
const open = controlledOpen ?? uncontrolledOpen;
|
||||
const setOpen = onOpenChange ?? setUncontrolledOpen;
|
||||
const setOpen = (value: boolean) => {
|
||||
if (onOpenChange) {
|
||||
onOpenChange(value);
|
||||
} else {
|
||||
setUncontrolledOpen(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MobilePageContext.Provider value={{ open, onOpenChange: setOpen }}>
|
||||
@@ -154,6 +160,7 @@ export function MobilePageHeader({
|
||||
>
|
||||
<Button
|
||||
className="absolute left-0 rounded-lg"
|
||||
aria-label="Go back"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { IoIosWarning } from "react-icons/io";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
||||
import useSWR from "swr";
|
||||
import { FrigateStats } from "@/types/stats";
|
||||
import { useFrigateStats } from "@/api/ws";
|
||||
import { useEmbeddingsReindexProgress, useFrigateStats } from "@/api/ws";
|
||||
import { useContext, useEffect, useMemo } from "react";
|
||||
import useStats from "@/hooks/use-stats";
|
||||
import GeneralSettings from "../menu/GeneralSettings";
|
||||
@@ -74,6 +74,23 @@ function StatusAlertNav({ className }: StatusAlertNavProps) {
|
||||
});
|
||||
}, [potentialProblems, addMessage, clearMessages]);
|
||||
|
||||
const { payload: reindexState } = useEmbeddingsReindexProgress();
|
||||
|
||||
useEffect(() => {
|
||||
if (reindexState) {
|
||||
if (reindexState.status == "indexing") {
|
||||
clearMessages("embeddings-reindex");
|
||||
addMessage(
|
||||
"embeddings-reindex",
|
||||
`Reindexing embeddings (${Math.floor((reindexState.processed_objects / reindexState.total_objects) * 100)}% complete)`,
|
||||
);
|
||||
}
|
||||
if (reindexState.status === "completed") {
|
||||
clearMessages("embeddings-reindex");
|
||||
}
|
||||
}
|
||||
}, [reindexState, addMessage, clearMessages]);
|
||||
|
||||
if (!messages || Object.keys(messages).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -167,7 +167,11 @@ export default function CameraInfoDialog({
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="select" onClick={() => onCopyFfprobe()}>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label="Copy"
|
||||
onClick={() => onCopyFfprobe()}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -98,7 +98,11 @@ export default function CreateUserDialog({
|
||||
)}
|
||||
/>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="select" disabled={isLoading}>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label="Create user"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading && <ActivityIndicator className="mr-2 h-4 w-4" />}
|
||||
Create User
|
||||
</Button>
|
||||
|
||||
@@ -27,6 +27,7 @@ export default function DeleteUserDialog({
|
||||
<DialogFooter>
|
||||
<Button
|
||||
className="flex items-center gap-1"
|
||||
aria-label="Confirm delete"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
|
||||
@@ -142,6 +142,7 @@ export default function ExportDialog({
|
||||
<Trigger asChild>
|
||||
<Button
|
||||
className="flex items-center gap-2"
|
||||
aria-label="Export"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const now = new Date(latestTime * 1000);
|
||||
@@ -307,6 +308,7 @@ export function ExportContent({
|
||||
</div>
|
||||
<Button
|
||||
className={isDesktop ? "" : "w-full"}
|
||||
aria-label="Select or export"
|
||||
variant="select"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
@@ -420,6 +422,7 @@ function CustomTimeSelector({
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
|
||||
aria-label="Start time"
|
||||
variant={startOpen ? "select" : "default"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
@@ -485,6 +488,7 @@ function CustomTimeSelector({
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
|
||||
aria-label="End time"
|
||||
variant={endOpen ? "select" : "default"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
|
||||
@@ -59,8 +59,17 @@ export default function GPUInfoDialog({
|
||||
<ActivityIndicator />
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setShowGpuInfo(false)}>Close</Button>
|
||||
<Button variant="select" onClick={() => onCopyInfo()}>
|
||||
<Button
|
||||
aria-label="Close GPU info"
|
||||
onClick={() => setShowGpuInfo(false)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Copy GPU info"
|
||||
variant="select"
|
||||
onClick={() => onCopyInfo()}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -88,8 +97,17 @@ export default function GPUInfoDialog({
|
||||
<ActivityIndicator />
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setShowGpuInfo(false)}>Close</Button>
|
||||
<Button variant="select" onClick={() => onCopyInfo()}>
|
||||
<Button
|
||||
aria-label="Close GPU info"
|
||||
onClick={() => setShowGpuInfo(false)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Copy GPU info"
|
||||
variant="select"
|
||||
onClick={() => onCopyInfo()}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -23,7 +23,11 @@ export default function MobileCameraDrawer({
|
||||
return (
|
||||
<Drawer open={cameraDrawer} onOpenChange={setCameraDrawer}>
|
||||
<DrawerTrigger asChild>
|
||||
<Button className="rounded-lg capitalize" size="sm">
|
||||
<Button
|
||||
className="rounded-lg capitalize"
|
||||
aria-label="Cameras"
|
||||
size="sm"
|
||||
>
|
||||
<FaVideo className="text-secondary-foreground" />
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
|
||||
@@ -132,6 +132,7 @@ export default function MobileReviewSettingsDrawer({
|
||||
{features.includes("export") && (
|
||||
<Button
|
||||
className="flex w-full items-center justify-center gap-2"
|
||||
aria-label="Export"
|
||||
onClick={() => {
|
||||
setDrawerMode("export");
|
||||
setMode("select");
|
||||
@@ -144,6 +145,7 @@ export default function MobileReviewSettingsDrawer({
|
||||
{features.includes("calendar") && (
|
||||
<Button
|
||||
className="flex w-full items-center justify-center gap-2"
|
||||
aria-label="Calendar"
|
||||
variant={filter?.after ? "select" : "default"}
|
||||
onClick={() => setDrawerMode("calendar")}
|
||||
>
|
||||
@@ -156,6 +158,7 @@ export default function MobileReviewSettingsDrawer({
|
||||
{features.includes("filter") && (
|
||||
<Button
|
||||
className="flex w-full items-center justify-center gap-2"
|
||||
aria-label="Filter"
|
||||
variant={filter?.labels || filter?.zones ? "select" : "default"}
|
||||
onClick={() => setDrawerMode("filter")}
|
||||
>
|
||||
@@ -226,6 +229,7 @@ export default function MobileReviewSettingsDrawer({
|
||||
<SelectSeparator />
|
||||
<div className="flex items-center justify-center p-2">
|
||||
<Button
|
||||
aria-label="Reset"
|
||||
onClick={() => {
|
||||
onUpdateFilter({
|
||||
...filter,
|
||||
@@ -306,6 +310,7 @@ export default function MobileReviewSettingsDrawer({
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
className="rounded-lg capitalize"
|
||||
aria-label="Filters"
|
||||
variant={
|
||||
filter?.labels || filter?.after || filter?.zones
|
||||
? "select"
|
||||
|
||||
@@ -22,7 +22,11 @@ export default function MobileTimelineDrawer({
|
||||
return (
|
||||
<Drawer open={drawer} onOpenChange={setDrawer}>
|
||||
<DrawerTrigger asChild>
|
||||
<Button className="rounded-lg capitalize" size="sm">
|
||||
<Button
|
||||
className="rounded-lg capitalize"
|
||||
aria-label="Select timeline or events list"
|
||||
size="sm"
|
||||
>
|
||||
<FaFlag className="text-secondary-foreground" />
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
|
||||
@@ -28,6 +28,7 @@ export default function SaveExportOverlay({
|
||||
>
|
||||
<Button
|
||||
className="flex items-center gap-1 text-primary"
|
||||
aria-label="Cancel"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
>
|
||||
@@ -36,6 +37,7 @@ export default function SaveExportOverlay({
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center gap-1"
|
||||
aria-label="Preview export"
|
||||
size="sm"
|
||||
onClick={onPreview}
|
||||
>
|
||||
@@ -44,6 +46,7 @@ export default function SaveExportOverlay({
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center gap-1"
|
||||
aria-label="Save export"
|
||||
variant="select"
|
||||
size="sm"
|
||||
onClick={onSave}
|
||||
|
||||
@@ -36,6 +36,7 @@ export default function SetPasswordDialog({
|
||||
<DialogFooter>
|
||||
<Button
|
||||
className="flex items-center gap-1"
|
||||
aria-label="Save Password"
|
||||
variant="select"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
|
||||
@@ -207,12 +207,14 @@ export function AnnotationSettingsPane({
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label="Apply"
|
||||
onClick={form.handleSubmit(onApply)}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label="Save"
|
||||
disabled={isLoading}
|
||||
className="flex flex-1"
|
||||
type="submit"
|
||||
|
||||
@@ -242,6 +242,7 @@ export default function ObjectLifecycle({
|
||||
<div className={cn("flex items-center gap-2")}>
|
||||
<Button
|
||||
className="mb-2 mt-3 flex items-center gap-2.5 rounded-lg md:mt-0"
|
||||
aria-label="Go back"
|
||||
size="sm"
|
||||
onClick={() => setPane("overview")}
|
||||
>
|
||||
@@ -346,6 +347,7 @@ export default function ObjectLifecycle({
|
||||
<Button
|
||||
variant={showControls ? "select" : "default"}
|
||||
className="size-7 p-1.5"
|
||||
aria-label="Adjust annotation settings"
|
||||
>
|
||||
<LuSettings
|
||||
className="size-5"
|
||||
|
||||
@@ -13,7 +13,7 @@ import { getIconForLabel } from "@/utils/iconUtil";
|
||||
import { useApiHost } from "@/api";
|
||||
import { ReviewDetailPaneType, ReviewSegment } from "@/types/review";
|
||||
import { Event } from "@/types/event";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FrigatePlusDialog } from "../dialog/FrigatePlusDialog";
|
||||
import ObjectLifecycle from "./ObjectLifecycle";
|
||||
@@ -91,6 +91,22 @@ export default function ReviewDetailDialog({
|
||||
review != undefined,
|
||||
);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
setIsOpen(open);
|
||||
if (!open) {
|
||||
// short timeout to allow the mobile page animation
|
||||
// to complete before updating the state
|
||||
setTimeout(() => {
|
||||
setReview(undefined);
|
||||
setSelectedEvent(undefined);
|
||||
setPane("overview");
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
[setReview, setIsOpen],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsOpen(review != undefined);
|
||||
// we know that these deps are correct
|
||||
@@ -109,16 +125,7 @@ export default function ReviewDetailDialog({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Overlay
|
||||
open={isOpen ?? false}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setReview(undefined);
|
||||
setSelectedEvent(undefined);
|
||||
setPane("overview");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Overlay open={isOpen ?? false} onOpenChange={handleOpenChange}>
|
||||
<FrigatePlusDialog
|
||||
upload={upload}
|
||||
onClose={() => setUpload(undefined)}
|
||||
@@ -140,7 +147,7 @@ export default function ReviewDetailDialog({
|
||||
>
|
||||
<span tabIndex={0} className="sr-only" />
|
||||
{pane == "overview" && (
|
||||
<Header className="justify-center" onClose={() => setIsOpen(false)}>
|
||||
<Header className="justify-center">
|
||||
<Title>Review Item Details</Title>
|
||||
<Description className="sr-only">Review item details</Description>
|
||||
<div
|
||||
@@ -153,6 +160,7 @@ export default function ReviewDetailDialog({
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
aria-label="Share this review item"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
shareOrCopy(`${baseUrl}review?id=${review.id}`)
|
||||
|
||||
@@ -27,6 +27,7 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import {
|
||||
FaCheckCircle,
|
||||
FaChevronDown,
|
||||
FaDownload,
|
||||
FaHistory,
|
||||
FaImage,
|
||||
FaRegListAlt,
|
||||
@@ -68,6 +69,7 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { LuInfo } from "react-icons/lu";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
|
||||
const SEARCH_TABS = [
|
||||
"details",
|
||||
@@ -107,6 +109,20 @@ export default function SearchDetailDialog({
|
||||
|
||||
const [isOpen, setIsOpen] = useState(search != undefined);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
setIsOpen(open);
|
||||
if (!open) {
|
||||
// short timeout to allow the mobile page animation
|
||||
// to complete before updating the state
|
||||
setTimeout(() => {
|
||||
setSearch(undefined);
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
[setSearch],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (search) {
|
||||
setIsOpen(search != undefined);
|
||||
@@ -156,14 +172,7 @@ export default function SearchDetailDialog({
|
||||
const Description = isDesktop ? DialogDescription : MobilePageDescription;
|
||||
|
||||
return (
|
||||
<Overlay
|
||||
open={isOpen}
|
||||
onOpenChange={() => {
|
||||
if (search) {
|
||||
setSearch(undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Overlay open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<Content
|
||||
className={cn(
|
||||
"scrollbar-container overflow-y-auto",
|
||||
@@ -172,7 +181,7 @@ export default function SearchDetailDialog({
|
||||
isMobile && "px-4",
|
||||
)}
|
||||
>
|
||||
<Header onClose={() => setIsOpen(false)}>
|
||||
<Header>
|
||||
<Title>Tracked Object Details</Title>
|
||||
<Description className="sr-only">Tracked object details</Description>
|
||||
</Header>
|
||||
@@ -285,7 +294,7 @@ function ObjectDetailsTab({
|
||||
return 0;
|
||||
}
|
||||
|
||||
const value = search.data.top_score;
|
||||
const value = search.data.top_score ?? search.top_score ?? 0;
|
||||
|
||||
return Math.round(value * 100);
|
||||
}, [search]);
|
||||
@@ -296,7 +305,7 @@ function ObjectDetailsTab({
|
||||
}
|
||||
|
||||
if (search.sub_label) {
|
||||
return Math.round((search.data?.top_score ?? 0) * 100);
|
||||
return Math.round((search.data?.sub_label_score ?? 0) * 100);
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
@@ -321,6 +330,22 @@ function ObjectDetailsTab({
|
||||
(key.includes("events") ||
|
||||
key.includes("events/search") ||
|
||||
key.includes("events/explore")),
|
||||
(currentData: SearchResult[][] | SearchResult[] | undefined) => {
|
||||
if (!currentData) return currentData;
|
||||
// optimistic update
|
||||
return currentData
|
||||
.flat()
|
||||
.map((event) =>
|
||||
event.id === search.id
|
||||
? { ...event, data: { ...event.data, description: desc } }
|
||||
: event,
|
||||
);
|
||||
},
|
||||
{
|
||||
optimisticData: true,
|
||||
rollbackOnError: true,
|
||||
revalidate: false,
|
||||
},
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -350,9 +375,9 @@ function ObjectDetailsTab({
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((error) => {
|
||||
toast.error(
|
||||
`Failed to call ${capitalizeAll(config?.genai.provider.replaceAll("_", " ") ?? "Generative AI")} for a new description`,
|
||||
`Failed to call ${capitalizeAll(config?.genai.provider.replaceAll("_", " ") ?? "Generative AI")} for a new description: ${error.response.data.message}`,
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
@@ -424,6 +449,7 @@ function ObjectDetailsTab({
|
||||
/>
|
||||
{config?.semantic_search.enabled && (
|
||||
<Button
|
||||
aria-label="Find similar tracked objects"
|
||||
onClick={() => {
|
||||
setSearch(undefined);
|
||||
|
||||
@@ -450,6 +476,7 @@ function ObjectDetailsTab({
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
className="rounded-r-none border-r-0"
|
||||
aria-label="Regenerate tracked object description"
|
||||
onClick={() => regenerateDescription("thumbnails")}
|
||||
>
|
||||
Regenerate
|
||||
@@ -457,19 +484,24 @@ function ObjectDetailsTab({
|
||||
{search.has_snapshot && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="rounded-l-none border-l-0 px-2">
|
||||
<Button
|
||||
className="rounded-l-none border-l-0 px-2"
|
||||
aria-label="Expand regeneration menu"
|
||||
>
|
||||
<FaChevronDown className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
aria-label="Regenerate from snapshot"
|
||||
onClick={() => regenerateDescription("snapshot")}
|
||||
>
|
||||
Regenerate from Snapshot
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
aria-label="Regenerate from thumbnails"
|
||||
onClick={() => regenerateDescription("thumbnails")}
|
||||
>
|
||||
Regenerate from Thumbnails
|
||||
@@ -479,7 +511,11 @@ function ObjectDetailsTab({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Button variant="select" onClick={updateDescription}>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label="Save"
|
||||
onClick={updateDescription}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
@@ -550,16 +586,39 @@ function ObjectSnapshotTab({
|
||||
}}
|
||||
>
|
||||
{search?.id && (
|
||||
<img
|
||||
ref={imgRef}
|
||||
className={`mx-auto max-h-[60dvh] bg-black object-contain`}
|
||||
src={`${baseUrl}api/events/${search?.id}/snapshot.jpg`}
|
||||
alt={`${search?.label}`}
|
||||
loading={isSafari ? "eager" : "lazy"}
|
||||
onLoad={() => {
|
||||
onImgLoad();
|
||||
}}
|
||||
/>
|
||||
<div className="relative mx-auto">
|
||||
<img
|
||||
ref={imgRef}
|
||||
className={`mx-auto max-h-[60dvh] bg-black object-contain`}
|
||||
src={`${baseUrl}api/events/${search?.id}/snapshot.jpg`}
|
||||
alt={`${search?.label}`}
|
||||
loading={isSafari ? "eager" : "lazy"}
|
||||
onLoad={() => {
|
||||
onImgLoad();
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-1 top-1 flex items-center gap-2",
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
download
|
||||
href={`${baseUrl}api/events/${search?.id}/snapshot.jpg`}
|
||||
>
|
||||
<Chip className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500">
|
||||
<FaDownload className="size-4 text-white" />
|
||||
</Chip>
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>Download</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TransformComponent>
|
||||
{search.plus_id !== "not_enabled" && search.end_time && (
|
||||
@@ -585,6 +644,7 @@ function ObjectSnapshotTab({
|
||||
<>
|
||||
<Button
|
||||
className="bg-success"
|
||||
aria-label="Confirm this label for Frigate Plus"
|
||||
onClick={() => {
|
||||
setState("uploading");
|
||||
onSubmitToPlus(false);
|
||||
@@ -594,6 +654,7 @@ function ObjectSnapshotTab({
|
||||
</Button>
|
||||
<Button
|
||||
className="text-white"
|
||||
aria-label="Do not confirm this label for Frigate Plus"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setState("uploading");
|
||||
@@ -640,7 +701,7 @@ export function VideoTab({ search }: VideoTabProps) {
|
||||
{reviewItem && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-2 z-10 flex items-center",
|
||||
"absolute top-2 z-10 flex items-center gap-2",
|
||||
isIOS ? "right-8" : "right-2",
|
||||
)}
|
||||
>
|
||||
@@ -660,7 +721,24 @@ export function VideoTab({ search }: VideoTabProps) {
|
||||
<FaHistory className="size-4 text-white" />
|
||||
</Chip>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">View in History</TooltipContent>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>View in History</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
download
|
||||
href={`${baseUrl}api/${search.camera}/start/${search.start_time}/end/${endTime}/clip.mp4`}
|
||||
>
|
||||
<Chip className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500">
|
||||
<FaDownload className="size-4 text-white" />
|
||||
</Chip>
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>Download</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -131,9 +131,14 @@ export function FrigatePlusDialog({
|
||||
<DialogFooter className="flex flex-row justify-end gap-2">
|
||||
{state == "reviewing" && (
|
||||
<>
|
||||
{dialog && <Button onClick={onClose}>Cancel</Button>}
|
||||
{dialog && (
|
||||
<Button aria-label="Cancel" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="bg-success"
|
||||
aria-label="Confirm this label for Frigate Plus"
|
||||
onClick={() => {
|
||||
setState("uploading");
|
||||
onSubmitToPlus(false);
|
||||
@@ -143,6 +148,7 @@ export function FrigatePlusDialog({
|
||||
</Button>
|
||||
<Button
|
||||
className="text-white"
|
||||
aria-label="Do not confirm this label for Frigate Plus"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setState("uploading");
|
||||
|
||||
@@ -76,6 +76,7 @@ export default function SearchFilterDialog({
|
||||
const trigger = (
|
||||
<Button
|
||||
className="flex items-center gap-2"
|
||||
aria-label="More Filters"
|
||||
size="sm"
|
||||
variant={moreFiltersSelected ? "select" : "default"}
|
||||
>
|
||||
@@ -141,6 +142,7 @@ export default function SearchFilterDialog({
|
||||
<div className="flex items-center justify-evenly p-2">
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label="Apply"
|
||||
onClick={() => {
|
||||
if (currentFilter != filter) {
|
||||
onUpdateFilter(currentFilter);
|
||||
@@ -152,6 +154,7 @@ export default function SearchFilterDialog({
|
||||
Apply
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Reset filters to default values"
|
||||
onClick={() => {
|
||||
setCurrentFilter((prevFilter) => ({
|
||||
...prevFilter,
|
||||
@@ -256,6 +259,7 @@ function TimeRangeFilterContent({
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className={`text-primary ${isDesktop ? "" : "text-xs"} `}
|
||||
aria-label="Select Start Time"
|
||||
variant={startOpen ? "select" : "default"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
@@ -293,6 +297,7 @@ function TimeRangeFilterContent({
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
|
||||
aria-label="Select End Time"
|
||||
variant={endOpen ? "select" : "default"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
|
||||
@@ -190,6 +190,7 @@ export default function HlsVideoPlayer({
|
||||
minScale={1.0}
|
||||
wheel={{ smoothStep: 0.005 }}
|
||||
onZoom={(zoom) => setZoomScale(zoom.state.scale)}
|
||||
disabled={!frigateControls}
|
||||
>
|
||||
{frigateControls && (
|
||||
<VideoControls
|
||||
|
||||
@@ -308,11 +308,16 @@ export default function MotionMaskEditPane({
|
||||
/>
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button className="flex flex-1" onClick={onCancel}>
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label="Cancel"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label="Save"
|
||||
disabled={isLoading}
|
||||
className="flex flex-1"
|
||||
type="submit"
|
||||
|
||||
@@ -335,13 +335,18 @@ export default function ObjectMaskEditPane({
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button className="flex flex-1" onClick={onCancel}>
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label="Cancel"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
disabled={isLoading}
|
||||
className="flex flex-1"
|
||||
aria-label="Save"
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
|
||||
@@ -74,6 +74,7 @@ export default function PolygonEditControls({
|
||||
<Button
|
||||
variant="default"
|
||||
className="size-6 rounded-md p-1"
|
||||
aria-label="Remove last point"
|
||||
disabled={!polygons[activePolygonIndex].points.length}
|
||||
onClick={undo}
|
||||
>
|
||||
@@ -87,6 +88,7 @@ export default function PolygonEditControls({
|
||||
<Button
|
||||
variant="default"
|
||||
className="size-6 rounded-md p-1"
|
||||
aria-label="Clear all points"
|
||||
disabled={!polygons[activePolygonIndex].points.length}
|
||||
onClick={reset}
|
||||
>
|
||||
|
||||
@@ -276,6 +276,7 @@ export default function PolygonItem({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
aria-label="Edit"
|
||||
onClick={() => {
|
||||
setActivePolygonIndex(index);
|
||||
setEditPane(polygon.type);
|
||||
@@ -283,10 +284,14 @@ export default function PolygonItem({
|
||||
>
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleCopyCoordinates(index)}>
|
||||
<DropdownMenuItem
|
||||
aria-label="Copy"
|
||||
onClick={() => handleCopyCoordinates(index)}
|
||||
>
|
||||
Copy
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
aria-label="Delete"
|
||||
disabled={isLoading}
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
>
|
||||
|
||||
@@ -44,7 +44,11 @@ export default function SearchSettings({
|
||||
]);
|
||||
|
||||
const trigger = (
|
||||
<Button className="flex items-center gap-2" size="sm">
|
||||
<Button
|
||||
className="flex items-center gap-2"
|
||||
aria-label="Search Settings"
|
||||
size="sm"
|
||||
>
|
||||
<FaCog className="text-secondary-foreground" />
|
||||
Settings
|
||||
</Button>
|
||||
|
||||
@@ -466,13 +466,18 @@ export default function ZoneEditPane({
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button className="flex flex-1" onClick={onCancel}>
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label="Cancel"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
disabled={isLoading}
|
||||
className="flex flex-1"
|
||||
aria-label="Save"
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
|
||||
@@ -283,6 +283,7 @@ export function DateRangePicker({
|
||||
}): JSX.Element => (
|
||||
<Button
|
||||
className={cn(isSelected && "pointer-events-none text-primary")}
|
||||
aria-label={label}
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setPreset(preset);
|
||||
@@ -417,6 +418,7 @@ export function DateRangePicker({
|
||||
<div className="mx-auto flex w-64 items-center justify-evenly gap-2 py-2">
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label="Apply"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
if (
|
||||
@@ -436,6 +438,7 @@ export function DateRangePicker({
|
||||
onReset?.();
|
||||
}}
|
||||
variant="ghost"
|
||||
aria-label="Reset"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
|
||||
@@ -1,43 +1,43 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react"
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||
} from "embla-carousel-react";
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1]
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||
type CarouselOptions = UseCarouselParameters[0]
|
||||
type CarouselPlugin = UseCarouselParameters[1]
|
||||
type CarouselApi = UseEmblaCarouselType[1];
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||
type CarouselOptions = UseCarouselParameters[0];
|
||||
type CarouselPlugin = UseCarouselParameters[1];
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions
|
||||
plugins?: CarouselPlugin
|
||||
orientation?: "horizontal" | "vertical"
|
||||
setApi?: (api: CarouselApi) => void
|
||||
}
|
||||
opts?: CarouselOptions;
|
||||
plugins?: CarouselPlugin;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
setApi?: (api: CarouselApi) => void;
|
||||
};
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||
scrollPrev: () => void
|
||||
scrollNext: () => void
|
||||
canScrollPrev: boolean
|
||||
canScrollNext: boolean
|
||||
} & CarouselProps
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
||||
api: ReturnType<typeof useEmblaCarousel>[1];
|
||||
scrollPrev: () => void;
|
||||
scrollNext: () => void;
|
||||
canScrollPrev: boolean;
|
||||
canScrollNext: boolean;
|
||||
} & CarouselProps;
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext)
|
||||
const context = React.useContext(CarouselContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />")
|
||||
throw new Error("useCarousel must be used within a <Carousel />");
|
||||
}
|
||||
|
||||
return context
|
||||
return context;
|
||||
}
|
||||
|
||||
const Carousel = React.forwardRef<
|
||||
@@ -54,69 +54,69 @@ const Carousel = React.forwardRef<
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
ref,
|
||||
) => {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins
|
||||
)
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||
plugins,
|
||||
);
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
setCanScrollPrev(api.canScrollPrev())
|
||||
setCanScrollNext(api.canScrollNext())
|
||||
}, [])
|
||||
setCanScrollPrev(api.canScrollPrev());
|
||||
setCanScrollNext(api.canScrollNext());
|
||||
}, []);
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev()
|
||||
}, [api])
|
||||
api?.scrollPrev();
|
||||
}, [api]);
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext()
|
||||
}, [api])
|
||||
api?.scrollNext();
|
||||
}, [api]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault()
|
||||
scrollPrev()
|
||||
event.preventDefault();
|
||||
scrollPrev();
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
scrollNext()
|
||||
event.preventDefault();
|
||||
scrollNext();
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext]
|
||||
)
|
||||
[scrollPrev, scrollNext],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
setApi(api)
|
||||
}, [api, setApi])
|
||||
setApi(api);
|
||||
}, [api, setApi]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect(api)
|
||||
api.on("reInit", onSelect)
|
||||
api.on("select", onSelect)
|
||||
onSelect(api);
|
||||
api.on("reInit", onSelect);
|
||||
api.on("select", onSelect);
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect)
|
||||
}
|
||||
}, [api, onSelect])
|
||||
api?.off("select", onSelect);
|
||||
};
|
||||
}, [api, onSelect]);
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
@@ -143,16 +143,16 @@ const Carousel = React.forwardRef<
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
)
|
||||
}
|
||||
)
|
||||
Carousel.displayName = "Carousel"
|
||||
);
|
||||
},
|
||||
);
|
||||
Carousel.displayName = "Carousel";
|
||||
|
||||
const CarouselContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { carouselRef, orientation } = useCarousel()
|
||||
const { carouselRef, orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div ref={carouselRef} className="overflow-hidden">
|
||||
@@ -161,20 +161,20 @@ const CarouselContent = React.forwardRef<
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
CarouselContent.displayName = "CarouselContent"
|
||||
);
|
||||
});
|
||||
CarouselContent.displayName = "CarouselContent";
|
||||
|
||||
const CarouselItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { orientation } = useCarousel()
|
||||
const { orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -184,19 +184,19 @@ const CarouselItem = React.forwardRef<
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
CarouselItem.displayName = "CarouselItem"
|
||||
);
|
||||
});
|
||||
CarouselItem.displayName = "CarouselItem";
|
||||
|
||||
const CarouselPrevious = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -204,12 +204,13 @@ const CarouselPrevious = React.forwardRef<
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full",
|
||||
"absolute h-8 w-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-left-12 top-1/2 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
aria-label="Previous slide"
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
@@ -217,15 +218,15 @@ const CarouselPrevious = React.forwardRef<
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
CarouselPrevious.displayName = "CarouselPrevious"
|
||||
);
|
||||
});
|
||||
CarouselPrevious.displayName = "CarouselPrevious";
|
||||
|
||||
const CarouselNext = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -237,8 +238,9 @@ const CarouselNext = React.forwardRef<
|
||||
orientation === "horizontal"
|
||||
? "-right-12 top-1/2 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
aria-label="Next slide"
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
@@ -246,9 +248,9 @@ const CarouselNext = React.forwardRef<
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
CarouselNext.displayName = "CarouselNext"
|
||||
);
|
||||
});
|
||||
CarouselNext.displayName = "CarouselNext";
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
@@ -257,4 +259,4 @@ export {
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -192,6 +192,7 @@ function ConfigEditor() {
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
aria-label="Copy config"
|
||||
onClick={() => handleCopyConfig()}
|
||||
>
|
||||
<LuCopy className="text-secondary-foreground" />
|
||||
@@ -200,6 +201,7 @@ function ConfigEditor() {
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
aria-label="Save and restart"
|
||||
onClick={() => onHandleSaveConfig("restart")}
|
||||
>
|
||||
<div className="relative size-5">
|
||||
@@ -211,6 +213,7 @@ function ConfigEditor() {
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
aria-label="Save only without restarting"
|
||||
onClick={() => onHandleSaveConfig("saveonly")}
|
||||
>
|
||||
<LuSave className="text-secondary-foreground" />
|
||||
|
||||
@@ -407,10 +407,6 @@ export default function Events() {
|
||||
review.severity == "detection"
|
||||
? item.reviewed_detection + 1
|
||||
: item.reviewed_detection,
|
||||
reviewed_motion:
|
||||
review.severity == "significant_motion"
|
||||
? item.reviewed_motion + 1
|
||||
: item.reviewed_motion,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ import { isMobileOnly } from "react-device-detect";
|
||||
import { LuCheck, LuExternalLink, LuX } from "react-icons/lu";
|
||||
import { TbExclamationCircle } from "react-icons/tb";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
|
||||
@@ -177,9 +178,21 @@ export default function Explore() {
|
||||
const { data, size, setSize, isValidating, mutate } = useSWRInfinite<
|
||||
SearchResult[]
|
||||
>(getKey, {
|
||||
revalidateFirstPage: false,
|
||||
revalidateFirstPage: true,
|
||||
revalidateOnFocus: true,
|
||||
revalidateAll: false,
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
`Error fetching tracked objects: ${error.response.data.message}`,
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
if (error.response.status === 404) {
|
||||
// reset all filters if 404
|
||||
setSearchFilter({});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const searchResults = useMemo(
|
||||
|
||||
@@ -125,6 +125,7 @@ function Exports() {
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<Button
|
||||
className="text-white"
|
||||
aria-label="Delete Export"
|
||||
variant="destructive"
|
||||
onClick={() => onHandleDelete()}
|
||||
>
|
||||
|
||||
@@ -339,6 +339,7 @@ function Logs() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
className="flex items-center justify-between gap-2"
|
||||
aria-label="Copy logs to clipboard"
|
||||
size="sm"
|
||||
onClick={handleCopyLogs}
|
||||
>
|
||||
@@ -349,6 +350,7 @@ function Logs() {
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center justify-between gap-2"
|
||||
aria-label="Download logs"
|
||||
size="sm"
|
||||
onClick={handleDownloadLogs}
|
||||
>
|
||||
@@ -365,6 +367,7 @@ function Logs() {
|
||||
{initialScroll && !endVisible && (
|
||||
<Button
|
||||
className="absolute bottom-8 left-[50%] z-20 flex -translate-x-[50%] items-center gap-1 rounded-md p-2"
|
||||
aria-label="Jump to bottom of logs"
|
||||
onClick={() =>
|
||||
contentRef.current?.scrollTo({
|
||||
top: contentRef.current?.scrollHeight,
|
||||
|
||||
@@ -252,6 +252,7 @@ function CameraSelectButton({
|
||||
const trigger = (
|
||||
<Button
|
||||
className="flex items-center gap-2 bg-selected capitalize hover:bg-selected"
|
||||
aria-label="Select a camera"
|
||||
size="sm"
|
||||
>
|
||||
<FaVideo className="text-background dark:text-primary" />
|
||||
|
||||
@@ -42,10 +42,8 @@ type ReviewSummaryDay = {
|
||||
day: string;
|
||||
reviewed_alert: number;
|
||||
reviewed_detection: number;
|
||||
reviewed_motion: number;
|
||||
total_alert: number;
|
||||
total_detection: number;
|
||||
total_motion: number;
|
||||
};
|
||||
|
||||
export type ReviewSummary = {
|
||||
|
||||
@@ -117,13 +117,11 @@ export default function EventView({
|
||||
return {
|
||||
alert: summary.total_alert ?? 0,
|
||||
detection: summary.total_detection ?? 0,
|
||||
significant_motion: summary.total_motion ?? 0,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
alert: summary.total_alert - summary.reviewed_alert,
|
||||
detection: summary.total_detection - summary.reviewed_detection,
|
||||
significant_motion: summary.total_motion - summary.reviewed_motion,
|
||||
};
|
||||
}
|
||||
}, [filter, showReviewed, reviewSummary]);
|
||||
@@ -737,6 +735,7 @@ function DetectionReview({
|
||||
<div className="col-span-full flex items-center justify-center">
|
||||
<Button
|
||||
className="text-white"
|
||||
aria-label="Mark these items as reviewed"
|
||||
variant="select"
|
||||
onClick={() => {
|
||||
setSelectedReviews([]);
|
||||
|
||||
@@ -18,15 +18,22 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { useEventUpdate } from "@/api/ws";
|
||||
import { isEqual } from "lodash";
|
||||
import TimeAgo from "@/components/dynamic/TimeAgo";
|
||||
import SearchResultActions from "@/components/menu/SearchResultActions";
|
||||
import { SearchTab } from "@/components/overlay/detail/SearchDetailDialog";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
|
||||
type ExploreViewProps = {
|
||||
searchDetail: SearchResult | undefined;
|
||||
setSearchDetail: (search: SearchResult | undefined) => void;
|
||||
setSimilaritySearch: (search: SearchResult) => void;
|
||||
onSelectSearch: (item: SearchResult, index: number, page?: SearchTab) => void;
|
||||
};
|
||||
|
||||
export default function ExploreView({
|
||||
searchDetail,
|
||||
setSearchDetail,
|
||||
setSimilaritySearch,
|
||||
onSelectSearch,
|
||||
}: ExploreViewProps) {
|
||||
// title
|
||||
|
||||
@@ -102,6 +109,9 @@ export default function ExploreView({
|
||||
isValidating={isValidating}
|
||||
objectType={label}
|
||||
setSearchDetail={setSearchDetail}
|
||||
mutate={mutate}
|
||||
setSimilaritySearch={setSimilaritySearch}
|
||||
onSelectSearch={onSelectSearch}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -113,6 +123,9 @@ type ThumbnailRowType = {
|
||||
searchResults?: SearchResult[];
|
||||
isValidating: boolean;
|
||||
setSearchDetail: (search: SearchResult | undefined) => void;
|
||||
mutate: () => void;
|
||||
setSimilaritySearch: (search: SearchResult) => void;
|
||||
onSelectSearch: (item: SearchResult, index: number, page?: SearchTab) => void;
|
||||
};
|
||||
|
||||
function ThumbnailRow({
|
||||
@@ -120,6 +133,9 @@ function ThumbnailRow({
|
||||
searchResults,
|
||||
isValidating,
|
||||
setSearchDetail,
|
||||
mutate,
|
||||
setSimilaritySearch,
|
||||
onSelectSearch,
|
||||
}: ThumbnailRowType) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -155,6 +171,9 @@ function ThumbnailRow({
|
||||
<ExploreThumbnailImage
|
||||
event={event}
|
||||
setSearchDetail={setSearchDetail}
|
||||
mutate={mutate}
|
||||
setSimilaritySearch={setSimilaritySearch}
|
||||
onSelectSearch={onSelectSearch}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -184,54 +203,78 @@ function ThumbnailRow({
|
||||
type ExploreThumbnailImageProps = {
|
||||
event: SearchResult;
|
||||
setSearchDetail: (search: SearchResult | undefined) => void;
|
||||
mutate: () => void;
|
||||
setSimilaritySearch: (search: SearchResult) => void;
|
||||
onSelectSearch: (item: SearchResult, index: number, page?: SearchTab) => void;
|
||||
};
|
||||
function ExploreThumbnailImage({
|
||||
event,
|
||||
setSearchDetail,
|
||||
mutate,
|
||||
setSimilaritySearch,
|
||||
onSelectSearch,
|
||||
}: ExploreThumbnailImageProps) {
|
||||
const apiHost = useApiHost();
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ImageLoadingIndicator
|
||||
className="absolute inset-0"
|
||||
imgLoaded={imgLoaded}
|
||||
/>
|
||||
const handleFindSimilar = () => {
|
||||
if (config?.semantic_search.enabled) {
|
||||
setSimilaritySearch(event);
|
||||
}
|
||||
};
|
||||
|
||||
<img
|
||||
ref={imgRef}
|
||||
className={cn(
|
||||
"absolute h-full w-full cursor-pointer rounded-lg object-cover transition-all duration-300 ease-in-out lg:rounded-2xl",
|
||||
)}
|
||||
style={
|
||||
isIOS
|
||||
? {
|
||||
WebkitUserSelect: "none",
|
||||
WebkitTouchCallout: "none",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
loading={isSafari ? "eager" : "lazy"}
|
||||
draggable={false}
|
||||
src={`${apiHost}api/events/${event.id}/thumbnail.jpg`}
|
||||
onClick={() => setSearchDetail(event)}
|
||||
onLoad={() => {
|
||||
onImgLoad();
|
||||
}}
|
||||
/>
|
||||
{isDesktop && (
|
||||
<div className="absolute bottom-1 right-1 z-10 rounded-lg bg-black/50 px-2 py-1 text-xs text-white">
|
||||
{event.end_time ? (
|
||||
<TimeAgo time={event.start_time * 1000} dense />
|
||||
) : (
|
||||
<div>
|
||||
<ActivityIndicator size={10} />
|
||||
</div>
|
||||
const handleShowObjectLifecycle = () => {
|
||||
onSelectSearch(event, 0, "object lifecycle");
|
||||
};
|
||||
|
||||
return (
|
||||
<SearchResultActions
|
||||
searchResult={event}
|
||||
findSimilar={handleFindSimilar}
|
||||
refreshResults={mutate}
|
||||
showObjectLifecycle={handleShowObjectLifecycle}
|
||||
isContextMenu={true}
|
||||
>
|
||||
<div className="relative size-full">
|
||||
<ImageLoadingIndicator
|
||||
className="absolute inset-0"
|
||||
imgLoaded={imgLoaded}
|
||||
/>
|
||||
<img
|
||||
ref={imgRef}
|
||||
className={cn(
|
||||
"absolute size-full cursor-pointer rounded-lg object-cover transition-all duration-300 ease-in-out lg:rounded-2xl",
|
||||
!imgLoaded && "invisible",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
style={
|
||||
isIOS
|
||||
? {
|
||||
WebkitUserSelect: "none",
|
||||
WebkitTouchCallout: "none",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
loading={isSafari ? "eager" : "lazy"}
|
||||
draggable={false}
|
||||
src={`${apiHost}api/events/${event.id}/thumbnail.jpg`}
|
||||
onClick={() => setSearchDetail(event)}
|
||||
onLoad={onImgLoad}
|
||||
alt={`${event.label} thumbnail`}
|
||||
/>
|
||||
{isDesktop && (
|
||||
<div className="absolute bottom-1 right-1 z-10 rounded-lg bg-black/50 px-2 py-1 text-xs text-white">
|
||||
{event.end_time ? (
|
||||
<TimeAgo time={event.start_time * 1000} dense />
|
||||
) : (
|
||||
<div>
|
||||
<ActivityIndicator size={10} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SearchResultActions>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -144,6 +144,7 @@ export default function LiveBirdseyeView({
|
||||
{!fullscreen ? (
|
||||
<Button
|
||||
className={`flex items-center gap-2 rounded-lg ${isMobile ? "ml-2" : "ml-0"}`}
|
||||
aria-label="Go Back"
|
||||
size={isMobile ? "icon" : "sm"}
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
|
||||
@@ -352,6 +352,7 @@ export default function LiveCameraView({
|
||||
>
|
||||
<Button
|
||||
className={`flex items-center gap-2.5 rounded-lg`}
|
||||
aria-label="Go back"
|
||||
size="sm"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
@@ -360,6 +361,7 @@ export default function LiveCameraView({
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center gap-2.5 rounded-lg"
|
||||
aria-label="Show historical footage"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
navigate("review", {
|
||||
@@ -388,6 +390,7 @@ export default function LiveCameraView({
|
||||
{fullscreen && (
|
||||
<Button
|
||||
className="bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-primary"
|
||||
aria-label="Go back"
|
||||
size="sm"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
@@ -603,6 +606,7 @@ function PtzControlPanel({
|
||||
{ptz?.features?.includes("pt") && (
|
||||
<>
|
||||
<Button
|
||||
aria-label="Move PTZ camera to the left"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
sendPtz("MOVE_LEFT");
|
||||
@@ -617,6 +621,7 @@ function PtzControlPanel({
|
||||
<FaAngleLeft />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Move PTZ camera up"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
sendPtz("MOVE_UP");
|
||||
@@ -631,6 +636,7 @@ function PtzControlPanel({
|
||||
<FaAngleUp />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Move PTZ camera down"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
sendPtz("MOVE_DOWN");
|
||||
@@ -645,6 +651,7 @@ function PtzControlPanel({
|
||||
<FaAngleDown />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Move PTZ camera to the right"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
sendPtz("MOVE_RIGHT");
|
||||
@@ -663,6 +670,7 @@ function PtzControlPanel({
|
||||
{ptz?.features?.includes("zoom") && (
|
||||
<>
|
||||
<Button
|
||||
aria-label="Zoom PTZ camera in"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
sendPtz("ZOOM_IN");
|
||||
@@ -677,6 +685,7 @@ function PtzControlPanel({
|
||||
<MdZoomIn />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Zoom PTZ camera out"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
sendPtz("ZOOM_OUT");
|
||||
@@ -696,6 +705,7 @@ function PtzControlPanel({
|
||||
<>
|
||||
<Button
|
||||
className={`${clickOverlay ? "text-selected" : "text-primary"}`}
|
||||
aria-label="Click in the frame to center the PTZ camera"
|
||||
onClick={() => setClickOverlay(!clickOverlay)}
|
||||
>
|
||||
<TbViewfinder />
|
||||
@@ -705,7 +715,7 @@ function PtzControlPanel({
|
||||
{(ptz?.presets?.length ?? 0) > 0 && (
|
||||
<DropdownMenu modal={!isDesktop}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button>
|
||||
<Button aria-label="PTZ camera presets">
|
||||
<BsThreeDotsVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -717,6 +727,7 @@ function PtzControlPanel({
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={preset}
|
||||
aria-label={preset}
|
||||
className="cursor-pointer"
|
||||
onSelect={() => sendPtz(`preset_${preset}`)}
|
||||
>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user