forked from Github/frigate
Compare commits
40 Commits
v0.11.0-be
...
v0.11.0-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c46a33992 | ||
|
|
ed1897db71 | ||
|
|
dfbebb63ff | ||
|
|
a67a768e89 | ||
|
|
43f05c18d6 | ||
|
|
3b076c28c2 | ||
|
|
cbf12e3f90 | ||
|
|
17b745434c | ||
|
|
37011c2fda | ||
|
|
fa95a041dd | ||
|
|
0879d7a2d1 | ||
|
|
061fb15a80 | ||
|
|
3246fcce22 | ||
|
|
f2a3797b46 | ||
|
|
b80080ac52 | ||
|
|
b36b63599b | ||
|
|
5d8c0e43c2 | ||
|
|
7845995dfd | ||
|
|
afe88d6e3a | ||
|
|
560ee0104d | ||
|
|
dc8b625d55 | ||
|
|
162c0147d2 | ||
|
|
ef54cd6fb3 | ||
|
|
c2465a46a8 | ||
|
|
24d3a9cdd5 | ||
|
|
5e82eaed88 | ||
|
|
1d45b0b351 | ||
|
|
ba119e4f96 | ||
|
|
75c2feb387 | ||
|
|
da637d3c8f | ||
|
|
bc078fcc88 | ||
|
|
5f9d477863 | ||
|
|
ca693240b1 | ||
|
|
468febc434 | ||
|
|
4b81c88794 | ||
|
|
2ac28b93f3 | ||
|
|
3e7ed982d4 | ||
|
|
d8d410802f | ||
|
|
ca7bad8909 | ||
|
|
7b2b5bfa71 |
3
.github/workflows/pull_request.yml
vendored
3
.github/workflows/pull_request.yml
vendored
@@ -54,6 +54,7 @@ jobs:
|
|||||||
|
|
||||||
python_tests:
|
python_tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
name: Python Tests
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
@@ -69,6 +70,8 @@ jobs:
|
|||||||
uses: docker/setup-qemu-action@v1
|
uses: docker/setup-qemu-action@v1
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
uses: docker/setup-buildx-action@v1
|
||||||
|
- name: Create Version Module
|
||||||
|
run: make version
|
||||||
- name: Build
|
- name: Build
|
||||||
run: make
|
run: make
|
||||||
- name: Run mypy
|
- name: Run mypy
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ services:
|
|||||||
# add groups from host for render, plugdev, video
|
# add groups from host for render, plugdev, video
|
||||||
group_add:
|
group_add:
|
||||||
- "109" # render
|
- "109" # render
|
||||||
|
- "110" # render
|
||||||
- "44" # video
|
- "44" # video
|
||||||
- "46" # plugdev
|
- "46" # plugdev
|
||||||
shm_size: "256mb"
|
shm_size: "256mb"
|
||||||
@@ -23,6 +24,7 @@ services:
|
|||||||
- /dev/bus/usb:/dev/bus/usb
|
- /dev/bus/usb:/dev/bus/usb
|
||||||
ports:
|
ports:
|
||||||
- "1935:1935"
|
- "1935:1935"
|
||||||
|
- "3000:3000"
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
- "5001:5001"
|
- "5001:5001"
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ RUN apt-get -qq update \
|
|||||||
apt-transport-https \
|
apt-transport-https \
|
||||||
gnupg \
|
gnupg \
|
||||||
wget \
|
wget \
|
||||||
&& wget -O - http://archive.raspberrypi.org/debian/raspberrypi.gpg.key | apt-key add - \
|
&& apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 9165938D90FDDD2E \
|
||||||
&& echo "deb http://archive.raspberrypi.org/debian/ bullseye main" | tee /etc/apt/sources.list.d/raspi.list \
|
&& echo "deb http://raspbian.raspberrypi.org/raspbian/ bullseye main contrib non-free rpi" | tee /etc/apt/sources.list.d/raspi.list \
|
||||||
&& apt-get -qq update \
|
&& apt-get -qq update \
|
||||||
&& apt-get -qq install -y \
|
&& apt-get -qq install -y \
|
||||||
python3 \
|
python3 \
|
||||||
@@ -46,6 +46,7 @@ RUN pip3 wheel --wheel-dir=/wheels -r requirements-wheels.txt
|
|||||||
FROM debian:11-slim
|
FROM debian:11-slim
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
|
|
||||||
|
ARG JELLYFIN_FFMPEG_VERSION=5.0.1-7
|
||||||
# https://askubuntu.com/questions/972516/debian-frontend-environment-variable
|
# https://askubuntu.com/questions/972516/debian-frontend-environment-variable
|
||||||
ARG DEBIAN_FRONTEND="noninteractive"
|
ARG DEBIAN_FRONTEND="noninteractive"
|
||||||
# http://stackoverflow.com/questions/48162574/ddg#49462622
|
# http://stackoverflow.com/questions/48162574/ddg#49462622
|
||||||
@@ -66,26 +67,37 @@ RUN apt-get -qq update \
|
|||||||
unzip tzdata libxml2 xz-utils \
|
unzip tzdata libxml2 xz-utils \
|
||||||
python3-pip \
|
python3-pip \
|
||||||
# add raspberry pi repo
|
# add raspberry pi repo
|
||||||
&& wget -O - http://archive.raspberrypi.org/debian/raspberrypi.gpg.key | apt-key add - \
|
&& apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 9165938D90FDDD2E \
|
||||||
&& echo "deb http://archive.raspberrypi.org/debian/ bullseye main" | tee /etc/apt/sources.list.d/raspi.list \
|
&& echo "deb http://raspbian.raspberrypi.org/raspbian/ bullseye main contrib non-free rpi" | tee /etc/apt/sources.list.d/raspi.list \
|
||||||
# add coral repo
|
# add coral repo
|
||||||
&& apt-key adv --fetch-keys https://packages.cloud.google.com/apt/doc/apt-key.gpg \
|
&& apt-key adv --fetch-keys https://packages.cloud.google.com/apt/doc/apt-key.gpg \
|
||||||
&& echo "deb https://packages.cloud.google.com/apt coral-edgetpu-stable main" > /etc/apt/sources.list.d/coral-edgetpu.list \
|
&& echo "deb https://packages.cloud.google.com/apt coral-edgetpu-stable main" > /etc/apt/sources.list.d/coral-edgetpu.list \
|
||||||
&& echo "libedgetpu1-max libedgetpu/accepted-eula select true" | debconf-set-selections \
|
&& echo "libedgetpu1-max libedgetpu/accepted-eula select true" | debconf-set-selections \
|
||||||
# jellyfin-ffmpeg
|
# enable non-free repo
|
||||||
&& wget -O - https://repo.jellyfin.org/jellyfin_team.gpg.key | apt-key add - \
|
&& sed -i -e's/ main/ main contrib non-free/g' /etc/apt/sources.list \
|
||||||
&& echo "deb [arch=$( dpkg --print-architecture )] https://repo.jellyfin.org/$( awk -F'=' '/^ID=/{ print $NF }' /etc/os-release ) $( awk -F'=' '/^VERSION_CODENAME=/{ print $NF }' /etc/os-release ) main" | tee /etc/apt/sources.list.d/jellyfin.list \
|
|
||||||
&& apt-get -qq update \
|
&& apt-get -qq update \
|
||||||
&& apt-get -qq install --no-install-recommends --no-install-suggests -y \
|
&& apt-get -qq install --no-install-recommends --no-install-suggests -y \
|
||||||
# coral drivers
|
# coral drivers
|
||||||
libedgetpu1-max python3-tflite-runtime python3-pycoral \
|
libedgetpu1-max python3-tflite-runtime python3-pycoral \
|
||||||
&& pip3 install -U /wheels/*.whl \
|
&& pip3 install -U /wheels/*.whl \
|
||||||
|
# jellyfin-ffmpeg
|
||||||
|
&& wget -O jellyfin.deb "https://repo.jellyfin.org/releases/server/debian/versions/jellyfin-ffmpeg/${JELLYFIN_FFMPEG_VERSION}/jellyfin-ffmpeg5_${JELLYFIN_FFMPEG_VERSION}-$( awk -F'=' '/^VERSION_CODENAME=/{ print $NF }' /etc/os-release )_$( dpkg --print-architecture ).deb" \
|
||||||
|
&& apt-get -qq install --no-install-recommends --no-install-suggests -y ./jellyfin.deb \
|
||||||
|
&& rm jellyfin.deb \
|
||||||
# arch specific packages
|
# arch specific packages
|
||||||
&& if [ "${TARGETARCH}" = "amd64" ]; then \
|
&& if [ "${TARGETARCH}" = "amd64" ]; then \
|
||||||
apt-get -qq install --no-install-recommends --no-install-suggests -y \
|
apt-get -qq install --no-install-recommends --no-install-suggests -y \
|
||||||
mesa-va-drivers jellyfin-ffmpeg; else \
|
mesa-va-drivers intel-media-va-driver-non-free; \
|
||||||
|
fi \
|
||||||
|
# not sure why 32bit arm requires all these
|
||||||
|
&& if [ "${TARGETARCH}" = "arm" ]; then \
|
||||||
apt-get -qq install --no-install-recommends --no-install-suggests -y \
|
apt-get -qq install --no-install-recommends --no-install-suggests -y \
|
||||||
ffmpeg; \
|
libgtk-3-dev \
|
||||||
|
libavcodec-dev libavformat-dev libswscale-dev libv4l-dev \
|
||||||
|
libxvidcore-dev libx264-dev libjpeg-dev libpng-dev libtiff-dev \
|
||||||
|
gfortran openexr libatlas-base-dev libssl-dev\
|
||||||
|
libtbb2 libtbb-dev libdc1394-22-dev libopenexr-dev \
|
||||||
|
libgstreamer-plugins-base1.0-dev libgstreamer1.0-dev; \
|
||||||
fi \
|
fi \
|
||||||
&& rm -rf /wheels \
|
&& rm -rf /wheels \
|
||||||
&& apt-get remove gnupg apt-transport-https -y \
|
&& apt-get remove gnupg apt-transport-https -y \
|
||||||
|
|||||||
@@ -205,11 +205,13 @@ http {
|
|||||||
add_header Cache-Control "public";
|
add_header Cache-Control "public";
|
||||||
}
|
}
|
||||||
|
|
||||||
sub_filter 'href="/' 'href="$http_x_ingress_path/';
|
sub_filter 'href="/BASE_PATH/' 'href="$http_x_ingress_path/';
|
||||||
sub_filter 'url(/' 'url($http_x_ingress_path/';
|
sub_filter 'url(/BASE_PATH/' 'url($http_x_ingress_path/';
|
||||||
sub_filter '"/dist/' '"$http_x_ingress_path/dist/';
|
sub_filter '"/BASE_PATH/dist/' '"$http_x_ingress_path/dist/';
|
||||||
sub_filter '"/js/' '"$http_x_ingress_path/js/';
|
sub_filter '"/BASE_PATH/js/' '"$http_x_ingress_path/js/';
|
||||||
sub_filter '<body>' '<body><script>window.baseUrl="$http_x_ingress_path";</script>';
|
sub_filter '"/BASE_PATH/assets/' '"$http_x_ingress_path/assets/';
|
||||||
|
sub_filter '="/BASE_PATH/"' '=window.baseUrl';
|
||||||
|
sub_filter '<body>' '<body><script>window.baseUrl="$http_x_ingress_path/";</script>';
|
||||||
sub_filter_types text/css application/javascript;
|
sub_filter_types text/css application/javascript;
|
||||||
sub_filter_once off;
|
sub_filter_once off;
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ You will need to remove `nobuffer` flag for Blue Iris RTSP cameras
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
ffmpeg:
|
ffmpeg:
|
||||||
input_args: -avoid_negative_ts make_zero -flags low_delay -strict experimental -fflags +genpts+discardcorrupt -rtsp_transport tcp -stimeout 5000000 -use_wallclock_as_timestamps 1
|
input_args: -avoid_negative_ts make_zero -flags low_delay -strict experimental -fflags +genpts+discardcorrupt -rtsp_transport tcp -timeout 5000000 -use_wallclock_as_timestamps 1
|
||||||
```
|
```
|
||||||
|
|
||||||
### UDP Only Cameras
|
### UDP Only Cameras
|
||||||
@@ -111,5 +111,5 @@ If your cameras do not support TCP connections for RTSP, you can use UDP.
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
ffmpeg:
|
ffmpeg:
|
||||||
input_args: -avoid_negative_ts make_zero -fflags +genpts+discardcorrupt -rtsp_transport udp -stimeout 5000000 -use_wallclock_as_timestamps 1
|
input_args: -avoid_negative_ts make_zero -fflags +genpts+discardcorrupt -rtsp_transport udp -timeout 5000000 -use_wallclock_as_timestamps 1
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -12,33 +12,22 @@ Ensure you increase the allocated RAM for your GPU to at least 128 (raspi-config
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
ffmpeg:
|
ffmpeg:
|
||||||
hwaccel_args:
|
hwaccel_args: -c:v h264_v4l2m2m
|
||||||
- -c:v
|
|
||||||
- h264_v4l2m2m
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Intel-based CPUs (<10th Generation) via Quicksync
|
### Intel-based CPUs (<10th Generation) via Quicksync
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
ffmpeg:
|
ffmpeg:
|
||||||
hwaccel_args:
|
hwaccel_args: -hwaccel vaapi -hwaccel_device /dev/dri/renderD128 -hwaccel_output_format yuv420p
|
||||||
- -hwaccel
|
|
||||||
- vaapi
|
|
||||||
- -hwaccel_device
|
|
||||||
- /dev/dri/renderD128
|
|
||||||
- -hwaccel_output_format
|
|
||||||
- yuv420p
|
|
||||||
```
|
```
|
||||||
|
**NOTICE**: With some of the processors, like the J4125, the default driver `iHD` doesn't seem to work correctly for hardware acceleration. You may need to change the driver to `i965` by adding the following environment variable `LIBVA_DRIVER_NAME_JELLYFIN=i965` to your docker-compose file.
|
||||||
|
|
||||||
### Intel-based CPUs (>=10th Generation) via Quicksync
|
### Intel-based CPUs (>=10th Generation) via Quicksync
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
ffmpeg:
|
ffmpeg:
|
||||||
hwaccel_args:
|
hwaccel_args: -c:v h264_qsv
|
||||||
- -hwaccel
|
|
||||||
- qsv
|
|
||||||
- -qsv_device
|
|
||||||
- /dev/dri/renderD128
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### AMD/ATI GPUs (Radeon HD 2000 and newer GPUs) via libva-mesa-driver
|
### AMD/ATI GPUs (Radeon HD 2000 and newer GPUs) via libva-mesa-driver
|
||||||
@@ -47,11 +36,7 @@ ffmpeg:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
ffmpeg:
|
ffmpeg:
|
||||||
hwaccel_args:
|
hwaccel_args: -hwaccel vaapi -hwaccel_device /dev/dri/renderD128 -hwaccel_output_format yuv420p
|
||||||
- -hwaccel
|
|
||||||
- vaapi
|
|
||||||
- -hwaccel_device
|
|
||||||
- /dev/dri/renderD128
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### NVIDIA GPU
|
### NVIDIA GPU
|
||||||
@@ -69,7 +54,9 @@ services:
|
|||||||
resources:
|
resources:
|
||||||
reservations:
|
reservations:
|
||||||
devices:
|
devices:
|
||||||
- capabilities: [gpu]
|
- driver: nvidia
|
||||||
|
count: 1
|
||||||
|
capabilities: [gpu]
|
||||||
```
|
```
|
||||||
|
|
||||||
The decoder you need to pass in the `hwaccel_args` will depend on the input video.
|
The decoder you need to pass in the `hwaccel_args` will depend on the input video.
|
||||||
@@ -89,13 +76,11 @@ A list of supported codecs (you can use `ffmpeg -decoders | grep cuvid` in the c
|
|||||||
V..... vp9_cuvid Nvidia CUVID VP9 decoder (codec vp9)
|
V..... vp9_cuvid Nvidia CUVID VP9 decoder (codec vp9)
|
||||||
```
|
```
|
||||||
|
|
||||||
For example, for H265 video (hevc), you'll select `hevc_cuvid`.
|
For example, for H264 video, you'll select `h264_cuvid`.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
ffmpeg:
|
ffmpeg:
|
||||||
hwaccel_args:
|
hwaccel_args: -c:v h264_cuvid
|
||||||
- -c:v
|
|
||||||
- hevc_cuvid
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If everything is working correctly, you should see a significant improvement in performance.
|
If everything is working correctly, you should see a significant improvement in performance.
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ ffmpeg:
|
|||||||
# NOTE: See hardware acceleration docs for your specific device
|
# NOTE: See hardware acceleration docs for your specific device
|
||||||
hwaccel_args: []
|
hwaccel_args: []
|
||||||
# Optional: global input args (default: shown below)
|
# Optional: global input args (default: shown below)
|
||||||
input_args: -avoid_negative_ts make_zero -fflags +genpts+discardcorrupt -rtsp_transport tcp -stimeout 5000000 -use_wallclock_as_timestamps 1
|
input_args: -avoid_negative_ts make_zero -fflags +genpts+discardcorrupt -rtsp_transport tcp -timeout 5000000 -use_wallclock_as_timestamps 1
|
||||||
# Optional: global output args
|
# Optional: global output args
|
||||||
output_args:
|
output_args:
|
||||||
# Optional: output args for detect streams (default: shown below)
|
# Optional: output args for detect streams (default: shown below)
|
||||||
@@ -278,10 +278,6 @@ record:
|
|||||||
mode: all
|
mode: all
|
||||||
# Optional: Event recording settings
|
# Optional: Event recording settings
|
||||||
events:
|
events:
|
||||||
# Optional: Maximum length of time to retain video during long events. (default: shown below)
|
|
||||||
# NOTE: If an object is being tracked for longer than this amount of time, the retained recordings
|
|
||||||
# will be the last x seconds of the event unless retain->days under record is > 0.
|
|
||||||
max_seconds: 300
|
|
||||||
# Optional: Number of seconds before the event to include (default: shown below)
|
# Optional: Number of seconds before the event to include (default: shown below)
|
||||||
pre_capture: 5
|
pre_capture: 5
|
||||||
# Optional: Number of seconds after the event to include (default: shown below)
|
# Optional: Number of seconds after the event to include (default: shown below)
|
||||||
|
|||||||
@@ -108,6 +108,18 @@ ffmpeg -c:v h264_v4l2m2m -re -stream_loop -1 -i https://streams.videolan.org/ffm
|
|||||||
ffmpeg -c:v h264_cuvid -re -stream_loop -1 -i https://streams.videolan.org/ffmpeg/incoming/720p60.mp4 -f rawvideo -pix_fmt yuv420p pipe: > /dev/null
|
ffmpeg -c:v h264_cuvid -re -stream_loop -1 -i https://streams.videolan.org/ffmpeg/incoming/720p60.mp4 -f rawvideo -pix_fmt yuv420p pipe: > /dev/null
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**VAAPI**
|
||||||
|
|
||||||
|
```shell
|
||||||
|
ffmpeg -hwaccel vaapi -hwaccel_device /dev/dri/renderD128 -hwaccel_output_format yuv420p -re -stream_loop -1 -i https://streams.videolan.org/ffmpeg/incoming/720p60.mp4 -f rawvideo -pix_fmt yuv420p pipe: > /dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
**QSV**
|
||||||
|
|
||||||
|
```shell
|
||||||
|
ffmpeg -c:v h264_qsv -re -stream_loop -1 -i https://streams.videolan.org/ffmpeg/incoming/720p60.mp4 -f rawvideo -pix_fmt yuv420p pipe: > /dev/null
|
||||||
|
```
|
||||||
|
|
||||||
## Web Interface
|
## Web Interface
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
@@ -196,3 +208,16 @@ npm run build
|
|||||||
```
|
```
|
||||||
|
|
||||||
This command generates static content into the `build` directory and can be served using any static contents hosting service.
|
This command generates static content into the `build` directory and can be served using any static contents hosting service.
|
||||||
|
|
||||||
|
## Official builds
|
||||||
|
|
||||||
|
Setup buildx for multiarch
|
||||||
|
|
||||||
|
```
|
||||||
|
docker buildx stop builder && docker buildx rm builder # <---- if existing
|
||||||
|
docker run --privileged --rm tonistiigi/binfmt --install all
|
||||||
|
docker buildx create --name builder --driver docker-container --driver-opt network=host --use
|
||||||
|
docker buildx inspect builder --bootstrap
|
||||||
|
make build_web
|
||||||
|
make push
|
||||||
|
```
|
||||||
|
|||||||
@@ -183,6 +183,10 @@ Permanently deletes the event along with any clips/snapshots.
|
|||||||
|
|
||||||
Sets retain to true for the event id.
|
Sets retain to true for the event id.
|
||||||
|
|
||||||
|
### `POST /api/events/<id>/plus`
|
||||||
|
|
||||||
|
Submits the snapshot of the event to Frigate+ for labeling.
|
||||||
|
|
||||||
### `DELETE /api/events/<id>/retain`
|
### `DELETE /api/events/<id>/retain`
|
||||||
|
|
||||||
Sets retain to false for the event id (event may be deleted quickly after removing).
|
Sets retain to false for the event id (event may be deleted quickly after removing).
|
||||||
|
|||||||
@@ -140,3 +140,19 @@ Topic to turn improve_contrast for a camera on and off. Expected values are `ON`
|
|||||||
### `frigate/<camera_name>/improve_contrast/state`
|
### `frigate/<camera_name>/improve_contrast/state`
|
||||||
|
|
||||||
Topic with current state of improve_contrast for a camera. Published values are `ON` and `OFF`.
|
Topic with current state of improve_contrast for a camera. Published values are `ON` and `OFF`.
|
||||||
|
|
||||||
|
### `frigate/<camera_name>/motion_threshold/set`
|
||||||
|
|
||||||
|
Topic to adjust motion threshold for a camera. Expected value is an integer.
|
||||||
|
|
||||||
|
### `frigate/<camera_name>/motion_threshold/state`
|
||||||
|
|
||||||
|
Topic with current motion threshold for a camera. Published value is an integer.
|
||||||
|
|
||||||
|
### `frigate/<camera_name>/motion_contour_area/set`
|
||||||
|
|
||||||
|
Topic to adjust motion contour area for a camera. Expected value is an integer.
|
||||||
|
|
||||||
|
### `frigate/<camera_name>/motion_contour_area/state`
|
||||||
|
|
||||||
|
Topic with current motion contour area for a camera. Published value is an integer.
|
||||||
@@ -95,6 +95,12 @@ class FrigateApp:
|
|||||||
"improve_contrast_enabled": mp.Value(
|
"improve_contrast_enabled": mp.Value(
|
||||||
"i", self.config.cameras[camera_name].motion.improve_contrast
|
"i", self.config.cameras[camera_name].motion.improve_contrast
|
||||||
),
|
),
|
||||||
|
"motion_threshold": mp.Value(
|
||||||
|
"i", self.config.cameras[camera_name].motion.threshold
|
||||||
|
),
|
||||||
|
"motion_contour_area": mp.Value(
|
||||||
|
"i", self.config.cameras[camera_name].motion.contour_area
|
||||||
|
),
|
||||||
"detection_fps": mp.Value("d", 0.0),
|
"detection_fps": mp.Value("d", 0.0),
|
||||||
"detection_frame": mp.Value("d", 0.0),
|
"detection_frame": mp.Value("d", 0.0),
|
||||||
"read_start": mp.Value("d", 0.0),
|
"read_start": mp.Value("d", 0.0),
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ class RetainConfig(FrigateBaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class EventsConfig(FrigateBaseModel):
|
class EventsConfig(FrigateBaseModel):
|
||||||
max_seconds: int = Field(default=300, title="Maximum event duration.")
|
|
||||||
pre_capture: int = Field(default=5, title="Seconds to retain before event starts.")
|
pre_capture: int = Field(default=5, title="Seconds to retain before event starts.")
|
||||||
post_capture: int = Field(default=5, title="Seconds to retain after event ends.")
|
post_capture: int = Field(default=5, title="Seconds to retain after event ends.")
|
||||||
required_zones: List[str] = Field(
|
required_zones: List[str] = Field(
|
||||||
@@ -347,7 +346,7 @@ FFMPEG_INPUT_ARGS_DEFAULT = [
|
|||||||
"+genpts+discardcorrupt",
|
"+genpts+discardcorrupt",
|
||||||
"-rtsp_transport",
|
"-rtsp_transport",
|
||||||
"tcp",
|
"tcp",
|
||||||
"-stimeout",
|
"-timeout",
|
||||||
"5000000",
|
"5000000",
|
||||||
"-use_wallclock_as_timestamps",
|
"-use_wallclock_as_timestamps",
|
||||||
"1",
|
"1",
|
||||||
|
|||||||
@@ -2,18 +2,14 @@ import base64
|
|||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import copy
|
import copy
|
||||||
import json
|
|
||||||
import glob
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import subprocess as sp
|
import subprocess as sp
|
||||||
import time
|
import time
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
from flask.helpers import send_file
|
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from flask import (
|
from flask import (
|
||||||
@@ -26,13 +22,12 @@ from flask import (
|
|||||||
request,
|
request,
|
||||||
)
|
)
|
||||||
|
|
||||||
from peewee import SqliteDatabase, operator, fn, DoesNotExist, Value
|
from peewee import SqliteDatabase, operator, fn, DoesNotExist
|
||||||
from playhouse.shortcuts import model_to_dict
|
from playhouse.shortcuts import model_to_dict
|
||||||
|
|
||||||
from frigate.const import CLIPS_DIR, PLUS_ENV_VAR
|
from frigate.const import CLIPS_DIR, PLUS_ENV_VAR
|
||||||
from frigate.models import Event, Recordings
|
from frigate.models import Event, Recordings
|
||||||
from frigate.stats import stats_snapshot
|
from frigate.stats import stats_snapshot
|
||||||
from frigate.util import calculate_region
|
|
||||||
from frigate.version import VERSION
|
from frigate.version import VERSION
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -251,6 +246,23 @@ def set_sub_label(id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/sub_labels")
|
||||||
|
def get_sub_labels():
|
||||||
|
try:
|
||||||
|
events = Event.select(Event.sub_label).distinct()
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify(
|
||||||
|
{"success": False, "message": f"Failed to get sub_labels: {e}"}, "404"
|
||||||
|
)
|
||||||
|
|
||||||
|
sub_labels = [e.sub_label for e in events]
|
||||||
|
|
||||||
|
if None in sub_labels:
|
||||||
|
sub_labels.remove(None)
|
||||||
|
|
||||||
|
return jsonify(sub_labels)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/events/<id>", methods=("DELETE",))
|
@bp.route("/events/<id>", methods=("DELETE",))
|
||||||
def delete_event(id):
|
def delete_event(id):
|
||||||
try:
|
try:
|
||||||
@@ -480,6 +492,7 @@ def events():
|
|||||||
limit = request.args.get("limit", 100)
|
limit = request.args.get("limit", 100)
|
||||||
camera = request.args.get("camera", "all")
|
camera = request.args.get("camera", "all")
|
||||||
label = request.args.get("label", "all")
|
label = request.args.get("label", "all")
|
||||||
|
sub_label = request.args.get("sub_label", "all")
|
||||||
zone = request.args.get("zone", "all")
|
zone = request.args.get("zone", "all")
|
||||||
after = request.args.get("after", type=float)
|
after = request.args.get("after", type=float)
|
||||||
before = request.args.get("before", type=float)
|
before = request.args.get("before", type=float)
|
||||||
@@ -511,6 +524,9 @@ def events():
|
|||||||
if label != "all":
|
if label != "all":
|
||||||
clauses.append((Event.label == label))
|
clauses.append((Event.label == label))
|
||||||
|
|
||||||
|
if sub_label != "all":
|
||||||
|
clauses.append((Event.sub_label == sub_label))
|
||||||
|
|
||||||
if zone != "all":
|
if zone != "all":
|
||||||
clauses.append((Event.zones.cast("text") % f'*"{zone}"*'))
|
clauses.append((Event.zones.cast("text") % f'*"{zone}"*'))
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,14 @@ from frigate.config import MotionConfig
|
|||||||
|
|
||||||
|
|
||||||
class MotionDetector:
|
class MotionDetector:
|
||||||
def __init__(self, frame_shape, config: MotionConfig, improve_contrast_enabled):
|
def __init__(
|
||||||
|
self,
|
||||||
|
frame_shape,
|
||||||
|
config: MotionConfig,
|
||||||
|
improve_contrast_enabled,
|
||||||
|
motion_threshold,
|
||||||
|
motion_contour_area,
|
||||||
|
):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.frame_shape = frame_shape
|
self.frame_shape = frame_shape
|
||||||
self.resize_factor = frame_shape[0] / config.frame_height
|
self.resize_factor = frame_shape[0] / config.frame_height
|
||||||
@@ -25,6 +32,8 @@ class MotionDetector:
|
|||||||
self.mask = np.where(resized_mask == [0])
|
self.mask = np.where(resized_mask == [0])
|
||||||
self.save_images = False
|
self.save_images = False
|
||||||
self.improve_contrast = improve_contrast_enabled
|
self.improve_contrast = improve_contrast_enabled
|
||||||
|
self.threshold = motion_threshold
|
||||||
|
self.contour_area = motion_contour_area
|
||||||
|
|
||||||
def detect(self, frame):
|
def detect(self, frame):
|
||||||
motion_boxes = []
|
motion_boxes = []
|
||||||
@@ -69,7 +78,7 @@ class MotionDetector:
|
|||||||
|
|
||||||
# compute the threshold image for the current frame
|
# compute the threshold image for the current frame
|
||||||
current_thresh = cv2.threshold(
|
current_thresh = cv2.threshold(
|
||||||
frameDelta, self.config.threshold, 255, cv2.THRESH_BINARY
|
frameDelta, self.threshold.value, 255, cv2.THRESH_BINARY
|
||||||
)[1]
|
)[1]
|
||||||
|
|
||||||
# black out everything in the avg_delta where there isnt motion in the current frame
|
# black out everything in the avg_delta where there isnt motion in the current frame
|
||||||
@@ -79,7 +88,7 @@ class MotionDetector:
|
|||||||
# then look for deltas above the threshold, but only in areas where there is a delta
|
# then look for deltas above the threshold, but only in areas where there is a delta
|
||||||
# in the current frame. this prevents deltas from previous frames from being included
|
# in the current frame. this prevents deltas from previous frames from being included
|
||||||
thresh = cv2.threshold(
|
thresh = cv2.threshold(
|
||||||
avg_delta_image, self.config.threshold, 255, cv2.THRESH_BINARY
|
avg_delta_image, self.threshold.value, 255, cv2.THRESH_BINARY
|
||||||
)[1]
|
)[1]
|
||||||
|
|
||||||
# dilate the thresholded image to fill in holes, then find contours
|
# dilate the thresholded image to fill in holes, then find contours
|
||||||
@@ -94,7 +103,7 @@ class MotionDetector:
|
|||||||
for c in cnts:
|
for c in cnts:
|
||||||
# if the contour is big enough, count it as motion
|
# if the contour is big enough, count it as motion
|
||||||
contour_area = cv2.contourArea(c)
|
contour_area = cv2.contourArea(c)
|
||||||
if contour_area > self.config.contour_area:
|
if contour_area > self.contour_area.value:
|
||||||
x, y, w, h = cv2.boundingRect(c)
|
x, y, w, h = cv2.boundingRect(c)
|
||||||
motion_boxes.append(
|
motion_boxes.append(
|
||||||
(
|
(
|
||||||
@@ -111,8 +120,7 @@ class MotionDetector:
|
|||||||
# print(self.frame_counter)
|
# print(self.frame_counter)
|
||||||
for c in cnts:
|
for c in cnts:
|
||||||
contour_area = cv2.contourArea(c)
|
contour_area = cv2.contourArea(c)
|
||||||
# print(contour_area)
|
if contour_area > self.contour_area.value:
|
||||||
if contour_area > self.config.contour_area:
|
|
||||||
x, y, w, h = cv2.boundingRect(c)
|
x, y, w, h = cv2.boundingRect(c)
|
||||||
cv2.rectangle(
|
cv2.rectangle(
|
||||||
thresh_dilated,
|
thresh_dilated,
|
||||||
|
|||||||
@@ -145,6 +145,52 @@ def create_mqtt_client(config: FrigateConfig, camera_metrics):
|
|||||||
state_topic = f"{message.topic[:-4]}/state"
|
state_topic = f"{message.topic[:-4]}/state"
|
||||||
client.publish(state_topic, payload, retain=True)
|
client.publish(state_topic, payload, retain=True)
|
||||||
|
|
||||||
|
def on_motion_threshold_command(client, userdata, message):
|
||||||
|
try:
|
||||||
|
payload = int(message.payload.decode())
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(
|
||||||
|
f"Received unsupported value at {message.topic}: {message.payload.decode()}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.debug(f"on_motion_threshold_toggle: {message.topic} {payload}")
|
||||||
|
|
||||||
|
camera_name = message.topic.split("/")[-3]
|
||||||
|
|
||||||
|
motion_settings = config.cameras[camera_name].motion
|
||||||
|
|
||||||
|
logger.info(f"Setting motion threshold for {camera_name} via mqtt: {payload}")
|
||||||
|
camera_metrics[camera_name]["motion_threshold"].value = payload
|
||||||
|
motion_settings.threshold = payload
|
||||||
|
|
||||||
|
state_topic = f"{message.topic[:-4]}/state"
|
||||||
|
client.publish(state_topic, payload, retain=True)
|
||||||
|
|
||||||
|
def on_motion_contour_area_command(client, userdata, message):
|
||||||
|
try:
|
||||||
|
payload = int(message.payload.decode())
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(
|
||||||
|
f"Received unsupported value at {message.topic}: {message.payload.decode()}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.debug(f"on_motion_contour_area_toggle: {message.topic} {payload}")
|
||||||
|
|
||||||
|
camera_name = message.topic.split("/")[-3]
|
||||||
|
|
||||||
|
motion_settings = config.cameras[camera_name].motion
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Setting motion contour area for {camera_name} via mqtt: {payload}"
|
||||||
|
)
|
||||||
|
camera_metrics[camera_name]["motion_contour_area"].value = payload
|
||||||
|
motion_settings.contour_area = payload
|
||||||
|
|
||||||
|
state_topic = f"{message.topic[:-4]}/state"
|
||||||
|
client.publish(state_topic, payload, retain=True)
|
||||||
|
|
||||||
def on_restart_command(client, userdata, message):
|
def on_restart_command(client, userdata, message):
|
||||||
restart_frigate()
|
restart_frigate()
|
||||||
|
|
||||||
@@ -195,6 +241,14 @@ def create_mqtt_client(config: FrigateConfig, camera_metrics):
|
|||||||
f"{mqtt_config.topic_prefix}/{name}/improve_contrast/set",
|
f"{mqtt_config.topic_prefix}/{name}/improve_contrast/set",
|
||||||
on_improve_contrast_command,
|
on_improve_contrast_command,
|
||||||
)
|
)
|
||||||
|
client.message_callback_add(
|
||||||
|
f"{mqtt_config.topic_prefix}/{name}/motion_threshold/set",
|
||||||
|
on_motion_threshold_command,
|
||||||
|
)
|
||||||
|
client.message_callback_add(
|
||||||
|
f"{mqtt_config.topic_prefix}/{name}/motion_contour_area/set",
|
||||||
|
on_motion_contour_area_command,
|
||||||
|
)
|
||||||
|
|
||||||
client.message_callback_add(
|
client.message_callback_add(
|
||||||
f"{mqtt_config.topic_prefix}/restart", on_restart_command
|
f"{mqtt_config.topic_prefix}/restart", on_restart_command
|
||||||
@@ -250,6 +304,21 @@ def create_mqtt_client(config: FrigateConfig, camera_metrics):
|
|||||||
"ON" if config.cameras[name].motion.improve_contrast else "OFF",
|
"ON" if config.cameras[name].motion.improve_contrast else "OFF",
|
||||||
retain=True,
|
retain=True,
|
||||||
)
|
)
|
||||||
|
client.publish(
|
||||||
|
f"{mqtt_config.topic_prefix}/{name}/motion_threshold/state",
|
||||||
|
config.cameras[name].motion.threshold,
|
||||||
|
retain=True,
|
||||||
|
)
|
||||||
|
client.publish(
|
||||||
|
f"{mqtt_config.topic_prefix}/{name}/motion_contour_area/state",
|
||||||
|
config.cameras[name].motion.contour_area,
|
||||||
|
retain=True,
|
||||||
|
)
|
||||||
|
client.publish(
|
||||||
|
f"{mqtt_config.topic_prefix}/{name}/motion",
|
||||||
|
"OFF",
|
||||||
|
retain=False,
|
||||||
|
)
|
||||||
|
|
||||||
return client
|
return client
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import requests
|
import requests
|
||||||
from frigate.const import PLUS_ENV_VAR, PLUS_API_HOST
|
from frigate.const import PLUS_ENV_VAR, PLUS_API_HOST
|
||||||
from requests.models import Response
|
from requests.models import Response
|
||||||
@@ -28,10 +30,23 @@ def get_jpg_bytes(image: ndarray, max_dim: int, quality: int) -> bytes:
|
|||||||
class PlusApi:
|
class PlusApi:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.host = PLUS_API_HOST
|
self.host = PLUS_API_HOST
|
||||||
|
self.key = None
|
||||||
if PLUS_ENV_VAR in os.environ:
|
if PLUS_ENV_VAR in os.environ:
|
||||||
self.key = os.environ.get(PLUS_ENV_VAR)
|
self.key = os.environ.get(PLUS_ENV_VAR)
|
||||||
else:
|
# check for the addon options file
|
||||||
|
elif os.path.isfile("/data/options.json"):
|
||||||
|
with open("/data/options.json") as f:
|
||||||
|
raw_options = f.read()
|
||||||
|
options = json.loads(raw_options)
|
||||||
|
self.key = options.get("plus_api_key")
|
||||||
|
|
||||||
|
if self.key is not None and not re.match(
|
||||||
|
r"[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}:[a-z0-9]{40}",
|
||||||
|
self.key,
|
||||||
|
):
|
||||||
|
logger.error("Plus API Key is not formatted correctly.")
|
||||||
self.key = None
|
self.key = None
|
||||||
|
|
||||||
self._is_active: bool = self.key is not None
|
self._is_active: bool = self.key is not None
|
||||||
self._token_data: dict = {}
|
self._token_data: dict = {}
|
||||||
|
|
||||||
|
|||||||
@@ -99,11 +99,23 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
# delete all cached files past the most recent 5
|
# delete all cached files past the most recent 5
|
||||||
keep_count = 5
|
keep_count = 5
|
||||||
for camera in grouped_recordings.keys():
|
for camera in grouped_recordings.keys():
|
||||||
if len(grouped_recordings[camera]) > keep_count:
|
segment_count = len(grouped_recordings[camera])
|
||||||
|
if segment_count > keep_count:
|
||||||
|
####
|
||||||
|
# Need to find a way to tell if these are aging out based on retention settings or if the system is overloaded.
|
||||||
|
####
|
||||||
|
# logger.warning(
|
||||||
|
# f"Too many recording segments in cache for {camera}. Keeping the {keep_count} most recent segments out of {segment_count}, discarding the rest..."
|
||||||
|
# )
|
||||||
to_remove = grouped_recordings[camera][:-keep_count]
|
to_remove = grouped_recordings[camera][:-keep_count]
|
||||||
for f in to_remove:
|
for f in to_remove:
|
||||||
Path(f["cache_path"]).unlink(missing_ok=True)
|
cache_path = f["cache_path"]
|
||||||
self.end_time_cache.pop(f["cache_path"], None)
|
####
|
||||||
|
# Need to find a way to tell if these are aging out based on retention settings or if the system is overloaded.
|
||||||
|
####
|
||||||
|
# logger.warning(f"Discarding a recording segment: {cache_path}")
|
||||||
|
Path(cache_path).unlink(missing_ok=True)
|
||||||
|
self.end_time_cache.pop(cache_path, None)
|
||||||
grouped_recordings[camera] = grouped_recordings[camera][-keep_count:]
|
grouped_recordings[camera] = grouped_recordings[camera][-keep_count:]
|
||||||
|
|
||||||
for camera, recordings in grouped_recordings.items():
|
for camera, recordings in grouped_recordings.items():
|
||||||
@@ -377,16 +389,11 @@ class RecordingCleanup(threading.Thread):
|
|||||||
logger.debug("Start all cameras.")
|
logger.debug("Start all cameras.")
|
||||||
for camera, config in self.config.cameras.items():
|
for camera, config in self.config.cameras.items():
|
||||||
logger.debug(f"Start camera: {camera}.")
|
logger.debug(f"Start camera: {camera}.")
|
||||||
# When deleting recordings without events, we have to keep at LEAST the configured max clip duration
|
# Get the timestamp for cutoff of retained days
|
||||||
min_end = (
|
|
||||||
datetime.datetime.now()
|
|
||||||
- datetime.timedelta(seconds=config.record.events.max_seconds)
|
|
||||||
).timestamp()
|
|
||||||
expire_days = config.record.retain.days
|
expire_days = config.record.retain.days
|
||||||
expire_before = (
|
expire_date = (
|
||||||
datetime.datetime.now() - datetime.timedelta(days=expire_days)
|
datetime.datetime.now() - datetime.timedelta(days=expire_days)
|
||||||
).timestamp()
|
).timestamp()
|
||||||
expire_date = min(min_end, expire_before)
|
|
||||||
|
|
||||||
# Get recordings to check for expiration
|
# Get recordings to check for expiration
|
||||||
recordings: Recordings = (
|
recordings: Recordings = (
|
||||||
|
|||||||
@@ -20,9 +20,13 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
def get_latest_version() -> str:
|
def get_latest_version() -> str:
|
||||||
request = requests.get(
|
try:
|
||||||
"https://api.github.com/repos/blakeblackshear/frigate/releases/latest"
|
request = requests.get(
|
||||||
)
|
"https://api.github.com/repos/blakeblackshear/frigate/releases/latest"
|
||||||
|
)
|
||||||
|
except:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
response = request.json()
|
response = request.json()
|
||||||
|
|
||||||
if request.ok and response and "tag_name" in response:
|
if request.ok and response and "tag_name" in response:
|
||||||
|
|||||||
4
frigate/test/const.py
Normal file
4
frigate/test/const.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""Consts for testing."""
|
||||||
|
|
||||||
|
TEST_DB = "test.db"
|
||||||
|
TEST_DB_CLEANUPS = ["test.db", "test.db-shm", "test.db-wal"]
|
||||||
327
frigate/test/test_http.py
Normal file
327
frigate/test/test_http.py
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from peewee_migrate import Router
|
||||||
|
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||||
|
from playhouse.sqliteq import SqliteQueueDatabase
|
||||||
|
from playhouse.shortcuts import model_to_dict
|
||||||
|
|
||||||
|
from frigate.config import FrigateConfig
|
||||||
|
from frigate.http import create_app
|
||||||
|
from frigate.models import Event, Recordings
|
||||||
|
|
||||||
|
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
|
||||||
|
|
||||||
|
|
||||||
|
class TestHttp(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
# setup clean database for each test run
|
||||||
|
migrate_db = SqliteExtDatabase("test.db")
|
||||||
|
del logging.getLogger("peewee_migrate").handlers[:]
|
||||||
|
router = Router(migrate_db)
|
||||||
|
router.run()
|
||||||
|
migrate_db.close()
|
||||||
|
self.db = SqliteQueueDatabase(TEST_DB)
|
||||||
|
models = [Event, Recordings]
|
||||||
|
self.db.bind(models)
|
||||||
|
|
||||||
|
self.minimal_config = {
|
||||||
|
"mqtt": {"host": "mqtt"},
|
||||||
|
"cameras": {
|
||||||
|
"front_door": {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [
|
||||||
|
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"detect": {
|
||||||
|
"height": 1080,
|
||||||
|
"width": 1920,
|
||||||
|
"fps": 5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
self.test_stats = {
|
||||||
|
"detection_fps": 13.7,
|
||||||
|
"detectors": {
|
||||||
|
"cpu1": {
|
||||||
|
"detection_start": 0.0,
|
||||||
|
"inference_speed": 91.43,
|
||||||
|
"pid": 42,
|
||||||
|
},
|
||||||
|
"cpu2": {
|
||||||
|
"detection_start": 0.0,
|
||||||
|
"inference_speed": 84.99,
|
||||||
|
"pid": 44,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"front_door": {
|
||||||
|
"camera_fps": 0.0,
|
||||||
|
"capture_pid": 53,
|
||||||
|
"detection_fps": 0.0,
|
||||||
|
"pid": 52,
|
||||||
|
"process_fps": 0.0,
|
||||||
|
"skipped_fps": 0.0,
|
||||||
|
},
|
||||||
|
"service": {
|
||||||
|
"storage": {
|
||||||
|
"/dev/shm": {
|
||||||
|
"free": 50.5,
|
||||||
|
"mount_type": "tmpfs",
|
||||||
|
"total": 67.1,
|
||||||
|
"used": 16.6,
|
||||||
|
},
|
||||||
|
"/media/frigate/clips": {
|
||||||
|
"free": 42429.9,
|
||||||
|
"mount_type": "ext4",
|
||||||
|
"total": 244529.7,
|
||||||
|
"used": 189607.0,
|
||||||
|
},
|
||||||
|
"/media/frigate/recordings": {
|
||||||
|
"free": 0.2,
|
||||||
|
"mount_type": "ext4",
|
||||||
|
"total": 8.0,
|
||||||
|
"used": 7.8,
|
||||||
|
},
|
||||||
|
"/tmp/cache": {
|
||||||
|
"free": 976.8,
|
||||||
|
"mount_type": "tmpfs",
|
||||||
|
"total": 1000.0,
|
||||||
|
"used": 23.2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"uptime": 101113,
|
||||||
|
"version": "0.10.1",
|
||||||
|
"latest_version": "0.11",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
if not self.db.is_closed():
|
||||||
|
self.db.close()
|
||||||
|
|
||||||
|
try:
|
||||||
|
for file in TEST_DB_CLEANUPS:
|
||||||
|
os.remove(file)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_get_event_list(self):
|
||||||
|
app = create_app(
|
||||||
|
FrigateConfig(**self.minimal_config), self.db, None, None, None
|
||||||
|
)
|
||||||
|
id = "123456.random"
|
||||||
|
id2 = "7890.random"
|
||||||
|
|
||||||
|
with app.test_client() as client:
|
||||||
|
_insert_mock_event(id)
|
||||||
|
events = client.get(f"/events").json
|
||||||
|
assert events
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0]["id"] == id
|
||||||
|
_insert_mock_event(id2)
|
||||||
|
events = client.get(f"/events").json
|
||||||
|
assert events
|
||||||
|
assert len(events) == 2
|
||||||
|
events = client.get(
|
||||||
|
f"/events",
|
||||||
|
query_string={"limit": 1},
|
||||||
|
).json
|
||||||
|
assert events
|
||||||
|
assert len(events) == 1
|
||||||
|
events = client.get(
|
||||||
|
f"/events",
|
||||||
|
query_string={"has_clip": 0},
|
||||||
|
).json
|
||||||
|
assert not events
|
||||||
|
|
||||||
|
def test_get_good_event(self):
|
||||||
|
app = create_app(
|
||||||
|
FrigateConfig(**self.minimal_config), self.db, None, None, None
|
||||||
|
)
|
||||||
|
id = "123456.random"
|
||||||
|
|
||||||
|
with app.test_client() as client:
|
||||||
|
_insert_mock_event(id)
|
||||||
|
event = client.get(f"/events/{id}").json
|
||||||
|
|
||||||
|
assert event
|
||||||
|
assert event["id"] == id
|
||||||
|
assert event == model_to_dict(Event.get(Event.id == id))
|
||||||
|
|
||||||
|
def test_get_bad_event(self):
|
||||||
|
app = create_app(
|
||||||
|
FrigateConfig(**self.minimal_config), self.db, None, None, None
|
||||||
|
)
|
||||||
|
id = "123456.random"
|
||||||
|
bad_id = "654321.other"
|
||||||
|
|
||||||
|
with app.test_client() as client:
|
||||||
|
_insert_mock_event(id)
|
||||||
|
event = client.get(f"/events/{bad_id}").json
|
||||||
|
|
||||||
|
assert not event
|
||||||
|
|
||||||
|
def test_delete_event(self):
|
||||||
|
app = create_app(
|
||||||
|
FrigateConfig(**self.minimal_config), self.db, None, None, None
|
||||||
|
)
|
||||||
|
id = "123456.random"
|
||||||
|
|
||||||
|
with app.test_client() as client:
|
||||||
|
_insert_mock_event(id)
|
||||||
|
event = client.get(f"/events/{id}").json
|
||||||
|
assert event
|
||||||
|
assert event["id"] == id
|
||||||
|
client.delete(f"/events/{id}")
|
||||||
|
event = client.get(f"/events/{id}").json
|
||||||
|
assert not event
|
||||||
|
|
||||||
|
def test_event_retention(self):
|
||||||
|
app = create_app(
|
||||||
|
FrigateConfig(**self.minimal_config), self.db, None, None, None
|
||||||
|
)
|
||||||
|
id = "123456.random"
|
||||||
|
|
||||||
|
with app.test_client() as client:
|
||||||
|
_insert_mock_event(id)
|
||||||
|
client.post(f"/events/{id}/retain")
|
||||||
|
event = client.get(f"/events/{id}").json
|
||||||
|
assert event
|
||||||
|
assert event["id"] == id
|
||||||
|
assert event["retain_indefinitely"] == True
|
||||||
|
client.delete(f"/events/{id}/retain")
|
||||||
|
event = client.get(f"/events/{id}").json
|
||||||
|
assert event
|
||||||
|
assert event["id"] == id
|
||||||
|
assert event["retain_indefinitely"] == False
|
||||||
|
|
||||||
|
def test_set_delete_sub_label(self):
|
||||||
|
app = create_app(
|
||||||
|
FrigateConfig(**self.minimal_config), self.db, None, None, None
|
||||||
|
)
|
||||||
|
id = "123456.random"
|
||||||
|
sub_label = "sub"
|
||||||
|
|
||||||
|
with app.test_client() as client:
|
||||||
|
_insert_mock_event(id)
|
||||||
|
client.post(
|
||||||
|
f"/events/{id}/sub_label",
|
||||||
|
data=json.dumps({"subLabel": sub_label}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
event = client.get(f"/events/{id}").json
|
||||||
|
assert event
|
||||||
|
assert event["id"] == id
|
||||||
|
assert event["sub_label"] == sub_label
|
||||||
|
client.post(
|
||||||
|
f"/events/{id}/sub_label",
|
||||||
|
data=json.dumps({"subLabel": ""}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
event = client.get(f"/events/{id}").json
|
||||||
|
assert event
|
||||||
|
assert event["id"] == id
|
||||||
|
assert event["sub_label"] == ""
|
||||||
|
|
||||||
|
def test_sub_label_list(self):
|
||||||
|
app = create_app(
|
||||||
|
FrigateConfig(**self.minimal_config), self.db, None, None, None
|
||||||
|
)
|
||||||
|
id = "123456.random"
|
||||||
|
sub_label = "sub"
|
||||||
|
|
||||||
|
with app.test_client() as client:
|
||||||
|
_insert_mock_event(id)
|
||||||
|
client.post(
|
||||||
|
f"/events/{id}/sub_label",
|
||||||
|
data=json.dumps({"subLabel": sub_label}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
sub_labels = client.get("/sub_labels").json
|
||||||
|
assert sub_labels
|
||||||
|
assert sub_labels == [sub_label]
|
||||||
|
|
||||||
|
def test_config(self):
|
||||||
|
app = create_app(
|
||||||
|
FrigateConfig(**self.minimal_config).runtime_config,
|
||||||
|
self.db,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.test_client() as client:
|
||||||
|
config = client.get("/config").json
|
||||||
|
assert config
|
||||||
|
assert config["cameras"]["front_door"]
|
||||||
|
|
||||||
|
def test_recordings(self):
|
||||||
|
app = create_app(
|
||||||
|
FrigateConfig(**self.minimal_config).runtime_config,
|
||||||
|
self.db,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
id = "123456.random"
|
||||||
|
|
||||||
|
with app.test_client() as client:
|
||||||
|
_insert_mock_recording(id)
|
||||||
|
recording = client.get("/front_door/recordings").json
|
||||||
|
assert recording
|
||||||
|
assert recording[0]["id"] == id
|
||||||
|
|
||||||
|
@patch("frigate.http.stats_snapshot")
|
||||||
|
def test_stats(self, mock_stats):
|
||||||
|
app = create_app(
|
||||||
|
FrigateConfig(**self.minimal_config).runtime_config,
|
||||||
|
self.db,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
mock_stats.return_value = self.test_stats
|
||||||
|
|
||||||
|
with app.test_client() as client:
|
||||||
|
stats = client.get("/stats").json
|
||||||
|
assert stats == self.test_stats
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_mock_event(id: str) -> Event:
|
||||||
|
"""Inserts a basic event model with a given id."""
|
||||||
|
return Event.insert(
|
||||||
|
id=id,
|
||||||
|
label="Mock",
|
||||||
|
camera="front_door",
|
||||||
|
start_time=datetime.datetime.now().timestamp(),
|
||||||
|
end_time=datetime.datetime.now().timestamp() + 20,
|
||||||
|
top_score=100,
|
||||||
|
false_positive=False,
|
||||||
|
zones=list(),
|
||||||
|
thumbnail="",
|
||||||
|
region=[],
|
||||||
|
box=[],
|
||||||
|
area=0,
|
||||||
|
has_clip=True,
|
||||||
|
has_snapshot=True,
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_mock_recording(id: str) -> Event:
|
||||||
|
"""Inserts a basic recording model with a given id."""
|
||||||
|
return Recordings.insert(
|
||||||
|
id=id,
|
||||||
|
camera="front_door",
|
||||||
|
path=f"/recordings/{id}",
|
||||||
|
start_time=datetime.datetime.now().timestamp() - 50,
|
||||||
|
end_time=datetime.datetime.now().timestamp() - 60,
|
||||||
|
duration=10,
|
||||||
|
motion=True,
|
||||||
|
objects=True,
|
||||||
|
).execute()
|
||||||
@@ -16,6 +16,8 @@ class CameraMetricsTypes(TypedDict):
|
|||||||
frame_queue: Queue
|
frame_queue: Queue
|
||||||
motion_enabled: Synchronized
|
motion_enabled: Synchronized
|
||||||
improve_contrast_enabled: Synchronized
|
improve_contrast_enabled: Synchronized
|
||||||
|
motion_threshold: Synchronized
|
||||||
|
motion_contour_area: Synchronized
|
||||||
process: Optional[Process]
|
process: Optional[Process]
|
||||||
process_fps: Synchronized
|
process_fps: Synchronized
|
||||||
read_start: Synchronized
|
read_start: Synchronized
|
||||||
|
|||||||
@@ -363,13 +363,19 @@ def track_camera(
|
|||||||
detection_enabled = process_info["detection_enabled"]
|
detection_enabled = process_info["detection_enabled"]
|
||||||
motion_enabled = process_info["motion_enabled"]
|
motion_enabled = process_info["motion_enabled"]
|
||||||
improve_contrast_enabled = process_info["improve_contrast_enabled"]
|
improve_contrast_enabled = process_info["improve_contrast_enabled"]
|
||||||
|
motion_threshold = process_info["motion_threshold"]
|
||||||
|
motion_contour_area = process_info["motion_contour_area"]
|
||||||
|
|
||||||
frame_shape = config.frame_shape
|
frame_shape = config.frame_shape
|
||||||
objects_to_track = config.objects.track
|
objects_to_track = config.objects.track
|
||||||
object_filters = config.objects.filters
|
object_filters = config.objects.filters
|
||||||
|
|
||||||
motion_detector = MotionDetector(
|
motion_detector = MotionDetector(
|
||||||
frame_shape, config.motion, improve_contrast_enabled
|
frame_shape,
|
||||||
|
config.motion,
|
||||||
|
improve_contrast_enabled,
|
||||||
|
motion_threshold,
|
||||||
|
motion_contour_area,
|
||||||
)
|
)
|
||||||
object_detector = RemoteObjectDetector(
|
object_detector = RemoteObjectDetector(
|
||||||
name, labelmap, detection_queue, result_connection, model_shape
|
name, labelmap, detection_queue, result_connection, model_shape
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { rest } from 'msw';
|
|||||||
import { API_HOST } from '../src/env';
|
import { API_HOST } from '../src/env';
|
||||||
|
|
||||||
export const handlers = [
|
export const handlers = [
|
||||||
rest.get(`${API_HOST}/api/config`, (req, res, ctx) => {
|
rest.get(`${API_HOST}api/config`, (req, res, ctx) => {
|
||||||
return res(
|
return res(
|
||||||
ctx.status(200),
|
ctx.status(200),
|
||||||
ctx.json({
|
ctx.json({
|
||||||
@@ -35,7 +35,7 @@ export const handlers = [
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
rest.get(`${API_HOST}/api/stats`, (req, res, ctx) => {
|
rest.get(`${API_HOST}api/stats`, (req, res, ctx) => {
|
||||||
return res(
|
return res(
|
||||||
ctx.status(200),
|
ctx.status(200),
|
||||||
ctx.json({
|
ctx.json({
|
||||||
@@ -54,7 +54,7 @@ export const handlers = [
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
rest.get(`${API_HOST}/api/events`, (req, res, ctx) => {
|
rest.get(`${API_HOST}api/events`, (req, res, ctx) => {
|
||||||
return res(
|
return res(
|
||||||
ctx.status(200),
|
ctx.status(200),
|
||||||
ctx.json(
|
ctx.json(
|
||||||
@@ -72,4 +72,13 @@ export const handlers = [
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
rest.get(`${API_HOST}api/sub_labels`, (req, res, ctx) => {
|
||||||
|
return res(
|
||||||
|
ctx.status(200),
|
||||||
|
ctx.json([
|
||||||
|
'one',
|
||||||
|
'two',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|||||||
13
web/package-lock.json
generated
13
web/package-lock.json
generated
@@ -30,6 +30,7 @@
|
|||||||
"@testing-library/preact": "^2.0.1",
|
"@testing-library/preact": "^2.0.1",
|
||||||
"@testing-library/preact-hooks": "^1.1.0",
|
"@testing-library/preact-hooks": "^1.1.0",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"@types/video.js": "^7.3.42",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.18.0",
|
"@typescript-eslint/eslint-plugin": "^5.18.0",
|
||||||
"@typescript-eslint/parser": "^5.18.0",
|
"@typescript-eslint/parser": "^5.18.0",
|
||||||
"autoprefixer": "^10.4.2",
|
"autoprefixer": "^10.4.2",
|
||||||
@@ -3234,6 +3235,12 @@
|
|||||||
"@types/jest": "*"
|
"@types/jest": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/video.js": {
|
||||||
|
"version": "7.3.42",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/video.js/-/video.js-7.3.42.tgz",
|
||||||
|
"integrity": "sha512-AD6AQNMgLTqrgoayC6SshKh8EDkDd9x5pmEuiY9YsniHlhn5jPXdkVqrzKLwviapaRhQF15TQYxo1JWpqXCUBg==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/@types/yargs": {
|
"node_modules/@types/yargs": {
|
||||||
"version": "16.0.4",
|
"version": "16.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz",
|
||||||
@@ -14504,6 +14511,12 @@
|
|||||||
"@types/jest": "*"
|
"@types/jest": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@types/video.js": {
|
||||||
|
"version": "7.3.42",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/video.js/-/video.js-7.3.42.tgz",
|
||||||
|
"integrity": "sha512-AD6AQNMgLTqrgoayC6SshKh8EDkDd9x5pmEuiY9YsniHlhn5jPXdkVqrzKLwviapaRhQF15TQYxo1JWpqXCUBg==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"@types/yargs": {
|
"@types/yargs": {
|
||||||
"version": "16.0.4",
|
"version": "16.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz",
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite --host",
|
||||||
"lint": "eslint ./ --ext .jsx,.js,.tsx,.ts",
|
"lint": "eslint ./ --ext .jsx,.js,.tsx,.ts",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build --base=/BASE_PATH/",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "jest"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
"@testing-library/preact": "^2.0.1",
|
"@testing-library/preact": "^2.0.1",
|
||||||
"@testing-library/preact-hooks": "^1.1.0",
|
"@testing-library/preact-hooks": "^1.1.0",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"@types/video.js": "^7.3.42",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.18.0",
|
"@typescript-eslint/eslint-plugin": "^5.18.0",
|
||||||
"@typescript-eslint/parser": "^5.18.0",
|
"@typescript-eslint/parser": "^5.18.0",
|
||||||
"autoprefixer": "^10.4.2",
|
"autoprefixer": "^10.4.2",
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export const ENV = 'test';
|
export const ENV = 'test';
|
||||||
export const API_HOST = 'http://base-url.local:5000';
|
export const API_HOST = 'http://base-url.local:5000/';
|
||||||
|
|||||||
@@ -18,6 +18,6 @@ describe('useApiHost', () => {
|
|||||||
<Test />
|
<Test />
|
||||||
</ApiProvider>
|
</ApiProvider>
|
||||||
);
|
);
|
||||||
expect(screen.queryByText('http://base-url.local:5000')).toBeInTheDocument();
|
expect(screen.queryByText('http://base-url.local:5000/')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
import { API_HOST } from '../env';
|
import { API_HOST } from '../env';
|
||||||
export const baseUrl = API_HOST || `${window.location.protocol}//${window.location.host}${window.baseUrl || ''}`;
|
export const baseUrl = API_HOST || `${window.location.protocol}//${window.location.host}${window.baseUrl || '/'}`;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import useSWR, { SWRConfig } from 'swr';
|
|||||||
import { MqttProvider } from './mqtt';
|
import { MqttProvider } from './mqtt';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
axios.defaults.baseURL = `${baseUrl}/api/`;
|
axios.defaults.baseURL = `${baseUrl}api/`;
|
||||||
|
|
||||||
export function ApiProvider({ children, options }) {
|
export function ApiProvider({ children, options }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function MqttProvider({
|
|||||||
config,
|
config,
|
||||||
children,
|
children,
|
||||||
createWebsocket = defaultCreateWebsocket,
|
createWebsocket = defaultCreateWebsocket,
|
||||||
mqttUrl = `${baseUrl.replace(/^http/, 'ws')}/ws`,
|
mqttUrl = `${baseUrl.replace(/^http/, 'ws')}ws`,
|
||||||
}) {
|
}) {
|
||||||
const [state, dispatch] = useReducer(reducer, initialState);
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
const wsRef = useRef();
|
const wsRef = useRef();
|
||||||
|
|||||||
@@ -3,17 +3,17 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|||||||
import { useApiHost } from '../../api';
|
import { useApiHost } from '../../api';
|
||||||
import { isNullOrUndefined } from '../../utils/objectUtils';
|
import { isNullOrUndefined } from '../../utils/objectUtils';
|
||||||
|
|
||||||
|
import 'videojs-seek-buttons';
|
||||||
|
import 'video.js/dist/video-js.css';
|
||||||
|
import 'videojs-seek-buttons/dist/videojs-seek-buttons.css';
|
||||||
|
|
||||||
|
import videojs, { VideoJsPlayer } from 'video.js';
|
||||||
|
|
||||||
interface OnTimeUpdateEvent {
|
interface OnTimeUpdateEvent {
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VideoProperties {
|
|
||||||
posterUrl: string;
|
|
||||||
videoUrl: string;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HistoryVideoProps {
|
interface HistoryVideoProps {
|
||||||
id?: string;
|
id?: string;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
@@ -32,68 +32,39 @@ export const HistoryVideo = ({
|
|||||||
onPlay,
|
onPlay,
|
||||||
}: HistoryVideoProps) => {
|
}: HistoryVideoProps) => {
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
const videoRef = useRef<HTMLVideoElement|null>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const [videoHeight, setVideoHeight] = useState<number>(0);
|
|
||||||
const [videoProperties, setVideoProperties] = useState<VideoProperties>({
|
|
||||||
posterUrl: '',
|
|
||||||
videoUrl: '',
|
|
||||||
height: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentVideo = videoRef.current;
|
const [video, setVideo] = useState<VideoJsPlayer>();
|
||||||
if (currentVideo && !videoHeight) {
|
|
||||||
const currentVideoHeight = currentVideo.offsetHeight;
|
|
||||||
if (currentVideoHeight > 0) {
|
|
||||||
setVideoHeight(currentVideoHeight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const idExists = !isNullOrUndefined(id);
|
let video: VideoJsPlayer
|
||||||
if (idExists) {
|
if (videoRef.current) {
|
||||||
if (videoRef.current && !videoRef.current.paused) {
|
video = videojs(videoRef.current, {})
|
||||||
videoRef.current = null;
|
setVideo(video)
|
||||||
}
|
|
||||||
|
|
||||||
setVideoProperties({
|
|
||||||
posterUrl: `${apiHost}/api/events/${id}/snapshot.jpg`,
|
|
||||||
videoUrl: `${apiHost}/vod/event/${id}/index.m3u8`,
|
|
||||||
height: videoHeight,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setVideoProperties({
|
|
||||||
posterUrl: '',
|
|
||||||
videoUrl: '',
|
|
||||||
height: 0,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [id, videoHeight, videoRef, apiHost]);
|
() => video?.dispose()
|
||||||
|
}, [videoRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const playVideo = (video: HTMLMediaElement) => video.play();
|
if (!video) {
|
||||||
|
return
|
||||||
const attemptPlayVideo = (video: HTMLMediaElement) => {
|
|
||||||
const videoHasNotLoaded = video.readyState <= 1;
|
|
||||||
if (videoHasNotLoaded) {
|
|
||||||
video.oncanplay = () => {
|
|
||||||
playVideo(video);
|
|
||||||
};
|
|
||||||
video.load();
|
|
||||||
} else {
|
|
||||||
playVideo(video);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const video = videoRef.current;
|
|
||||||
const videoExists = !isNullOrUndefined(video);
|
|
||||||
if (video && videoExists) {
|
|
||||||
if (videoIsPlaying) {
|
|
||||||
attemptPlayVideo(video);
|
|
||||||
} else {
|
|
||||||
video.pause();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [videoIsPlaying, videoRef]);
|
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
video.pause()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
video.src({
|
||||||
|
src: `${apiHost}/vod/event/${id}/index.m3u8`,
|
||||||
|
type: 'application/vnd.apple.mpegurl',
|
||||||
|
});
|
||||||
|
video.poster(`${apiHost}/api/events/${id}/snapshot.jpg`);
|
||||||
|
if (videoIsPlaying) {
|
||||||
|
video.play();
|
||||||
|
}
|
||||||
|
}, [video, id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
@@ -111,32 +82,38 @@ export const HistoryVideo = ({
|
|||||||
isPlaying: videoIsPlaying,
|
isPlaying: videoIsPlaying,
|
||||||
timestamp: target.currentTime,
|
timestamp: target.currentTime,
|
||||||
};
|
};
|
||||||
|
|
||||||
onTimeUpdate && onTimeUpdate(timeUpdateEvent);
|
onTimeUpdate && onTimeUpdate(timeUpdateEvent);
|
||||||
},
|
},
|
||||||
[videoIsPlaying, onTimeUpdate]
|
[videoIsPlaying, onTimeUpdate]
|
||||||
);
|
);
|
||||||
|
|
||||||
const videoPropertiesIsUndefined = isNullOrUndefined(videoProperties);
|
useEffect(() => {
|
||||||
if (videoPropertiesIsUndefined) {
|
if (video && video.readyState() >= 1) {
|
||||||
return <div style={{ height: `${videoHeight}px`, width: '100%' }} />;
|
if (videoIsPlaying) {
|
||||||
}
|
video.play()
|
||||||
|
} else {
|
||||||
|
video.pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [video, videoIsPlaying])
|
||||||
|
|
||||||
const { posterUrl, videoUrl, height } = videoProperties;
|
const onLoad = useCallback(() => {
|
||||||
|
if (video && video.readyState() >= 1 && videoIsPlaying) {
|
||||||
|
video.play()
|
||||||
|
}
|
||||||
|
}, [video, videoIsPlaying])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<video
|
<div data-vjs-player>
|
||||||
ref={videoRef}
|
<video
|
||||||
key={posterUrl}
|
ref={videoRef}
|
||||||
onTimeUpdate={onTimeUpdateHandler}
|
onTimeUpdate={onTimeUpdateHandler}
|
||||||
onPause={onPause}
|
onLoadedMetadata={onLoad}
|
||||||
onPlay={onPlay}
|
onPause={onPause}
|
||||||
poster={posterUrl}
|
onPlay={onPlay}
|
||||||
preload='metadata'
|
className="video-js vjs-fluid"
|
||||||
controls
|
data-setup="{}"
|
||||||
style={height ? { minHeight: `${height}px` } : {}}
|
/>
|
||||||
playsInline
|
</div>
|
||||||
>
|
|
||||||
<source type='application/vnd.apple.mpegurl' src={videoUrl} />
|
|
||||||
</video>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import JSMpeg from '@cycjimmy/jsmpeg-player';
|
|||||||
|
|
||||||
export default function JSMpegPlayer({ camera, width, height }) {
|
export default function JSMpegPlayer({ camera, width, height }) {
|
||||||
const playerRef = useRef();
|
const playerRef = useRef();
|
||||||
const url = `${baseUrl.replace(/^http/, 'ws')}/live/${camera}`;
|
const url = `${baseUrl.replace(/^http/, 'ws')}live/${camera}`;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = new JSMpeg.VideoElement(
|
const video = new JSMpeg.VideoElement(
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ export const TimelineBlocks = ({ timeline, firstBlockOffset, onEventClick }: Tim
|
|||||||
const timelineBlockOffset = (timelineContainerHeight - largestYOffsetInBlocks) / 2;
|
const timelineBlockOffset = (timelineContainerHeight - largestYOffsetInBlocks) / 2;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className='relative'
|
className="relative"
|
||||||
style={{
|
style={{
|
||||||
height: `${timelineContainerHeight}px`,
|
height: `${timelineContainerHeight}px`,
|
||||||
width: `${timelineContainerWidth}px`,
|
width: `${timelineContainerWidth}px`,
|
||||||
background: "url('/marker.png')",
|
background: "url('/images/marker.png')",
|
||||||
backgroundPosition: 'center',
|
backgroundPosition: 'center',
|
||||||
backgroundSize: '30px',
|
backgroundSize: '30px',
|
||||||
backgroundRepeat: 'repeat',
|
backgroundRepeat: 'repeat',
|
||||||
@@ -41,7 +41,7 @@ export const TimelineBlocks = ({ timeline, firstBlockOffset, onEventClick }: Tim
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <div />
|
return <div />;
|
||||||
}, [timeline, onEventClick, firstBlockOffset]);
|
}, [timeline, onEventClick, firstBlockOffset]);
|
||||||
|
|
||||||
return timelineEventBlocks;
|
return timelineEventBlocks;
|
||||||
|
|||||||
@@ -93,7 +93,8 @@ export default function VideoPlayer({ children, options, seekOptions = {}, onRea
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-vjs-player>
|
<div data-vjs-player>
|
||||||
<video ref={playerRef} className="small-player video-js vjs-default-skin" controls playsinline />
|
{/* Setting an empty data-setup is required to override the default values and allow video to be fit the size of its parent */}
|
||||||
|
<video ref={playerRef} className="small-player video-js vjs-default-skin" data-setup="{}" controls playsinline />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export const ENV = import.meta.env.MODE;
|
export const ENV = import.meta.env.MODE;
|
||||||
export const API_HOST = ENV === "production" ? "" : "http://localhost:5000";
|
export const API_HOST = ENV === 'production' ? '' : 'http://localhost:5000/';
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export default function Events({ path, ...props }) {
|
|||||||
camera: props.camera ?? 'all',
|
camera: props.camera ?? 'all',
|
||||||
label: props.label ?? 'all',
|
label: props.label ?? 'all',
|
||||||
zone: props.zone ?? 'all',
|
zone: props.zone ?? 'all',
|
||||||
|
sub_label: props.sub_label ?? 'all',
|
||||||
});
|
});
|
||||||
const [state, setState] = useState({
|
const [state, setState] = useState({
|
||||||
showDownloadMenu: false,
|
showDownloadMenu: false,
|
||||||
@@ -59,6 +60,10 @@ export default function Events({ path, ...props }) {
|
|||||||
has_snapshot: false,
|
has_snapshot: false,
|
||||||
plus_id: undefined,
|
plus_id: undefined,
|
||||||
});
|
});
|
||||||
|
const [deleteFavoriteState, setDeleteFavoriteState] = useState({
|
||||||
|
deletingFavoriteEventId: null,
|
||||||
|
showDeleteFavorite: false,
|
||||||
|
});
|
||||||
|
|
||||||
const eventsFetcher = useCallback((path, params) => {
|
const eventsFetcher = useCallback((path, params) => {
|
||||||
params = { ...params, include_thumbnails: 0, limit: API_LIMIT };
|
params = { ...params, include_thumbnails: 0, limit: API_LIMIT };
|
||||||
@@ -82,6 +87,8 @@ export default function Events({ path, ...props }) {
|
|||||||
|
|
||||||
const { data: config } = useSWR('config');
|
const { data: config } = useSWR('config');
|
||||||
|
|
||||||
|
const { data: allSubLabels } = useSWR('sub_labels')
|
||||||
|
|
||||||
const filterValues = useMemo(
|
const filterValues = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
cameras: Object.keys(config?.cameras || {}),
|
cameras: Object.keys(config?.cameras || {}),
|
||||||
@@ -97,8 +104,9 @@ export default function Events({ path, ...props }) {
|
|||||||
return memo;
|
return memo;
|
||||||
}, config?.objects?.track || [])
|
}, config?.objects?.track || [])
|
||||||
.filter((value, i, self) => self.indexOf(value) === i),
|
.filter((value, i, self) => self.indexOf(value) === i),
|
||||||
|
sub_labels: Object.values(allSubLabels || []),
|
||||||
}),
|
}),
|
||||||
[config]
|
[config, allSubLabels]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onSave = async (e, eventId, save) => {
|
const onSave = async (e, eventId, save) => {
|
||||||
@@ -114,11 +122,16 @@ export default function Events({ path, ...props }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDelete = async (e, eventId) => {
|
const onDelete = async (e, eventId, saved) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const response = await axios.delete(`events/${eventId}`);
|
|
||||||
if (response.status === 200) {
|
if (saved) {
|
||||||
mutate();
|
setDeleteFavoriteState({ deletingFavoriteEventId: eventId, showDeleteFavorite: true });
|
||||||
|
} else {
|
||||||
|
const response = await axios.delete(`events/${eventId}`);
|
||||||
|
if (response.status === 200) {
|
||||||
|
mutate();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -231,11 +244,11 @@ export default function Events({ path, ...props }) {
|
|||||||
<Heading>Events</Heading>
|
<Heading>Events</Heading>
|
||||||
<div className="flex flex-wrap gap-2 items-center">
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
<select
|
<select
|
||||||
className="basis-1/4 cursor-pointer rounded dark:bg-slate-800"
|
className="basis-1/5 cursor-pointer rounded dark:bg-slate-800"
|
||||||
value={searchParams.camera}
|
value={searchParams.camera}
|
||||||
onChange={(e) => onFilter('camera', e.target.value)}
|
onChange={(e) => onFilter('camera', e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="all">all</option>
|
<option value="all">all cameras</option>
|
||||||
{filterValues.cameras.map((item) => (
|
{filterValues.cameras.map((item) => (
|
||||||
<option key={item} value={item}>
|
<option key={item} value={item}>
|
||||||
{item}
|
{item}
|
||||||
@@ -243,11 +256,11 @@ export default function Events({ path, ...props }) {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
className="basis-1/4 cursor-pointer rounded dark:bg-slate-800"
|
className="basis-1/5 cursor-pointer rounded dark:bg-slate-800"
|
||||||
value={searchParams.label}
|
value={searchParams.label}
|
||||||
onChange={(e) => onFilter('label', e.target.value)}
|
onChange={(e) => onFilter('label', e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="all">all</option>
|
<option value="all">all labels</option>
|
||||||
{filterValues.labels.map((item) => (
|
{filterValues.labels.map((item) => (
|
||||||
<option key={item} value={item}>
|
<option key={item} value={item}>
|
||||||
{item}
|
{item}
|
||||||
@@ -255,17 +268,32 @@ export default function Events({ path, ...props }) {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
className="basis-1/4 cursor-pointer rounded dark:bg-slate-800"
|
className="basis-1/5 cursor-pointer rounded dark:bg-slate-800"
|
||||||
value={searchParams.zone}
|
value={searchParams.zone}
|
||||||
onChange={(e) => onFilter('zone', e.target.value)}
|
onChange={(e) => onFilter('zone', e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="all">all</option>
|
<option value="all">all zones</option>
|
||||||
{filterValues.zones.map((item) => (
|
{filterValues.zones.map((item) => (
|
||||||
<option key={item} value={item}>
|
<option key={item} value={item}>
|
||||||
{item}
|
{item}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{
|
||||||
|
filterValues.sub_labels.length > 0 && (
|
||||||
|
<select
|
||||||
|
className="basis-1/5 cursor-pointer rounded dark:bg-slate-800"
|
||||||
|
value={searchParams.sub_label}
|
||||||
|
onChange={(e) => onFilter('sub_label', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">all sub labels</option>
|
||||||
|
{filterValues.sub_labels.map((item) => (
|
||||||
|
<option key={item} value={item}>
|
||||||
|
{item}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
<div ref={datePicker} className="ml-auto">
|
<div ref={datePicker} className="ml-auto">
|
||||||
<CalendarIcon
|
<CalendarIcon
|
||||||
className="h-8 w-8 cursor-pointer"
|
className="h-8 w-8 cursor-pointer"
|
||||||
@@ -374,6 +402,19 @@ export default function Events({ path, ...props }) {
|
|||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
|
{deleteFavoriteState.showDeleteFavorite && (
|
||||||
|
<Dialog>
|
||||||
|
<div className="p-4">
|
||||||
|
<Heading size="lg">Delete Saved Event?</Heading>
|
||||||
|
<p className="mb-2">Confirm deletion of saved event.</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 flex justify-start flex-row-reverse space-x-2">
|
||||||
|
<Button className="ml-2" color="red" onClick={(e) => { setDeleteFavoriteState({ ...state, showDeleteFavorite: false }); onDelete(e, deleteFavoriteState.deletingFavoriteEventId, false) }} type="text">
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{eventPages ? (
|
{eventPages ? (
|
||||||
eventPages.map((page, i) => {
|
eventPages.map((page, i) => {
|
||||||
@@ -441,7 +482,7 @@ export default function Events({ path, ...props }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<Delete className="cursor-pointer" stroke="#f87171" onClick={(e) => onDelete(e, event.id)} />
|
<Delete className="h-6 w-6 cursor-pointer" stroke="#f87171" onClick={(e) => onDelete(e, event.id, event.retain_indefinitely)} />
|
||||||
|
|
||||||
<Download
|
<Download
|
||||||
className="h-6 w-6 mt-auto"
|
className="h-6 w-6 mt-auto"
|
||||||
@@ -453,7 +494,7 @@ export default function Events({ path, ...props }) {
|
|||||||
</div>
|
</div>
|
||||||
{viewEvent !== event.id ? null : (
|
{viewEvent !== event.id ? null : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="mx-auto">
|
<div className="mx-auto max-w-7xl">
|
||||||
{event.has_clip ? (
|
{event.has_clip ? (
|
||||||
<>
|
<>
|
||||||
<Heading size="lg">Clip</Heading>
|
<Heading size="lg">Clip</Heading>
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ export default function Recording({ camera, date, hour = '00', minute = '00', se
|
|||||||
);
|
);
|
||||||
|
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
const { data: recordingsSummary } = useSWR(`${camera}/recordings/summary`);
|
const { data: recordingsSummary } = useSWR(`${camera}/recordings/summary`, { revalidateOnFocus: false });
|
||||||
|
|
||||||
const recordingParams = {
|
const recordingParams = {
|
||||||
before: getUnixTime(endOfHour(currentDate)),
|
before: getUnixTime(endOfHour(currentDate)),
|
||||||
after: getUnixTime(startOfHour(currentDate)),
|
after: getUnixTime(startOfHour(currentDate)),
|
||||||
};
|
};
|
||||||
const { data: recordings } = useSWR([`${camera}/recordings`, recordingParams]);
|
const { data: recordings } = useSWR([`${camera}/recordings`, recordingParams], { revalidateOnFocus: false });
|
||||||
|
|
||||||
// calculates the seek seconds by adding up all the seconds in the segments prior to the playback time
|
// calculates the seek seconds by adding up all the seconds in the segments prior to the playback time
|
||||||
const seekSeconds = useMemo(() => {
|
const seekSeconds = useMemo(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user