mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-05 02:35:22 +03:00
Merge branch 'dev' into prometheus-metrics
This commit is contained in:
commit
b1faa4c0ac
@ -1,6 +1,6 @@
|
|||||||
name: EdgeTpu Support Request
|
name: Detector Support Request
|
||||||
description: Support for setting up EdgeTPU in Frigate
|
description: Support for setting up object detector in Frigate (Coral, OpenVINO, TensorRT, etc.)
|
||||||
title: "[EdgeTPU Support]: "
|
title: "[Detector Support]: "
|
||||||
labels: ["support", "triage"]
|
labels: ["support", "triage"]
|
||||||
assignees: []
|
assignees: []
|
||||||
body:
|
body:
|
||||||
11
.github/workflows/ci.yml
vendored
11
.github/workflows/ci.yml
vendored
@ -19,6 +19,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Image Build
|
name: Image Build
|
||||||
steps:
|
steps:
|
||||||
|
- name: Remove unnecessary files
|
||||||
|
run: |
|
||||||
|
sudo rm -rf /usr/share/dotnet
|
||||||
|
sudo rm -rf /usr/local/lib/android
|
||||||
|
sudo rm -rf /opt/ghc
|
||||||
- id: lowercaseRepo
|
- id: lowercaseRepo
|
||||||
uses: ASzc/change-string-case-action@v5
|
uses: ASzc/change-string-case-action@v5
|
||||||
with:
|
with:
|
||||||
@ -40,18 +45,18 @@ jobs:
|
|||||||
- name: Create short sha
|
- name: Create short sha
|
||||||
run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV
|
run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
platforms: linux/amd64,linux/arm64
|
||||||
target: frigate
|
target: frigate
|
||||||
tags: |
|
tags: |
|
||||||
ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ github.ref_name }}-${{ env.SHORT_SHA }}
|
ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ github.ref_name }}-${{ env.SHORT_SHA }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
- name: Build and push TensorRT
|
- name: Build and push TensorRT
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
|
|||||||
4
.github/workflows/dependabot-auto-merge.yaml
vendored
4
.github/workflows/dependabot-auto-merge.yaml
vendored
@ -16,7 +16,9 @@ jobs:
|
|||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Enable auto-merge for Dependabot PRs
|
- name: Enable auto-merge for Dependabot PRs
|
||||||
if: steps.metadata.outputs.dependency-type == 'direct:development' && (steps.metadata.outputs.update-type == 'version-update:semver-minor' || steps.metadata.outputs.update-type == 'version-update:semver-patch')
|
if: steps.metadata.outputs.dependency-type == 'direct:development' && (steps.metadata.outputs.update-type == 'version-update:semver-minor' || steps.metadata.outputs.update-type == 'version-update:semver-patch')
|
||||||
run: gh pr merge --auto --squash "$PR_URL"
|
run: |
|
||||||
|
gh pr review --approve "$PR_URL"
|
||||||
|
gh pr merge --auto --squash "$PR_URL"
|
||||||
env:
|
env:
|
||||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
45
.github/workflows/maintain_cache.yml
vendored
Normal file
45
.github/workflows/maintain_cache.yml
vendored
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
name: Maintain Cache
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "13 0 * * 0,4"
|
||||||
|
|
||||||
|
env:
|
||||||
|
PYTHON_VERSION: 3.9
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
multi_arch_build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Image Build
|
||||||
|
steps:
|
||||||
|
- name: Remove unnecessary files
|
||||||
|
run: |
|
||||||
|
sudo rm -rf /usr/share/dotnet
|
||||||
|
sudo rm -rf /usr/local/lib/android
|
||||||
|
sudo rm -rf /opt/ghc
|
||||||
|
- id: lowercaseRepo
|
||||||
|
uses: ASzc/change-string-case-action@v5
|
||||||
|
with:
|
||||||
|
string: ${{ github.repository }}
|
||||||
|
- name: Check out code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Create version file
|
||||||
|
run: make version
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: false
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
target: frigate
|
||||||
|
cache-from: type=gha
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -4,7 +4,8 @@
|
|||||||
debug
|
debug
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/launch.json
|
!.vscode/launch.json
|
||||||
config/config.yml
|
config/*
|
||||||
|
!config/*.example
|
||||||
models
|
models
|
||||||
*.mp4
|
*.mp4
|
||||||
*.ts
|
*.ts
|
||||||
|
|||||||
@ -133,11 +133,6 @@ RUN apt-get -qq update \
|
|||||||
RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
|
RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
|
||||||
&& python3 get-pip.py "pip"
|
&& python3 get-pip.py "pip"
|
||||||
|
|
||||||
RUN if [ "${TARGETARCH}" = "arm" ]; \
|
|
||||||
then echo "[global]" > /etc/pip.conf \
|
|
||||||
&& echo "extra-index-url=https://www.piwheels.org/simple" >> /etc/pip.conf; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
COPY requirements.txt /requirements.txt
|
COPY requirements.txt /requirements.txt
|
||||||
RUN pip3 install -r requirements.txt
|
RUN pip3 install -r requirements.txt
|
||||||
|
|
||||||
|
|||||||
13
Makefile
13
Makefile
@ -1,7 +1,7 @@
|
|||||||
default_target: local
|
default_target: local
|
||||||
|
|
||||||
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1)
|
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1)
|
||||||
VERSION = 0.12.0
|
VERSION = 0.13.0
|
||||||
IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate
|
IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate
|
||||||
CURRENT_UID := $(shell id -u)
|
CURRENT_UID := $(shell id -u)
|
||||||
CURRENT_GID := $(shell id -g)
|
CURRENT_GID := $(shell id -g)
|
||||||
@ -22,18 +22,15 @@ amd64:
|
|||||||
arm64:
|
arm64:
|
||||||
docker buildx build --platform linux/arm64 --target=frigate --tag $(IMAGE_REPO):$(VERSION)-$(COMMIT_HASH) .
|
docker buildx build --platform linux/arm64 --target=frigate --tag $(IMAGE_REPO):$(VERSION)-$(COMMIT_HASH) .
|
||||||
|
|
||||||
armv7:
|
build: version amd64 arm64
|
||||||
docker buildx build --platform linux/arm/v7 --target=frigate --tag $(IMAGE_REPO):$(VERSION)-$(COMMIT_HASH) .
|
docker buildx build --platform linux/arm64/v8,linux/amd64 --target=frigate --tag $(IMAGE_REPO):$(VERSION)-$(COMMIT_HASH) .
|
||||||
|
|
||||||
build: version amd64 arm64 armv7
|
|
||||||
docker buildx build --platform linux/arm/v7,linux/arm64/v8,linux/amd64 --target=frigate --tag $(IMAGE_REPO):$(VERSION)-$(COMMIT_HASH) .
|
|
||||||
|
|
||||||
push: build
|
push: build
|
||||||
docker buildx build --push --platform linux/arm/v7,linux/arm64/v8,linux/amd64 --target=frigate --tag $(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH) .
|
docker buildx build --push --platform linux/arm64/v8,linux/amd64 --target=frigate --tag $(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH) .
|
||||||
docker buildx build --push --platform linux/amd64 --target=frigate-tensorrt --tag $(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH)-tensorrt .
|
docker buildx build --push --platform linux/amd64 --target=frigate-tensorrt --tag $(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH)-tensorrt .
|
||||||
|
|
||||||
run: local
|
run: local
|
||||||
docker run --rm --publish=5000:5000 --volume=${PWD}/config/config.yml:/config/config.yml frigate:latest
|
docker run --rm --publish=5000:5000 --volume=${PWD}/config:/config frigate:latest
|
||||||
|
|
||||||
run_tests: local
|
run_tests: local
|
||||||
docker run --rm --workdir=/opt/frigate --entrypoint= frigate:latest python3 -u -m unittest
|
docker run --rm --workdir=/opt/frigate --entrypoint= frigate:latest python3 -u -m unittest
|
||||||
|
|||||||
@ -27,7 +27,7 @@ services:
|
|||||||
- .:/workspace/frigate:cached
|
- .:/workspace/frigate:cached
|
||||||
- ./web/dist:/opt/frigate/web:cached
|
- ./web/dist:/opt/frigate/web:cached
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
- ./config/config.yml:/config/config.yml:ro
|
- ./config:/config
|
||||||
- ./debug:/media/frigate
|
- ./debug:/media/frigate
|
||||||
# Create the trt-models folder using the documented method of generating TRT models
|
# Create the trt-models folder using the documented method of generating TRT models
|
||||||
# - ./debug/trt-models:/trt-models
|
# - ./debug/trt-models:/trt-models
|
||||||
|
|||||||
@ -37,15 +37,6 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then
|
|||||||
rm -rf btbn-ffmpeg.tar.xz /usr/lib/btbn-ffmpeg/doc /usr/lib/btbn-ffmpeg/bin/ffplay
|
rm -rf btbn-ffmpeg.tar.xz /usr/lib/btbn-ffmpeg/doc /usr/lib/btbn-ffmpeg/bin/ffplay
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ffmpeg -> arm32
|
|
||||||
if [[ "${TARGETARCH}" == "arm" ]]; then
|
|
||||||
# add raspberry pi repo
|
|
||||||
gpg --no-default-keyring --keyring /usr/share/keyrings/raspbian.gpg --keyserver keyserver.ubuntu.com --recv-keys 9165938D90FDDD2E
|
|
||||||
echo "deb [signed-by=/usr/share/keyrings/raspbian.gpg] 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 install --no-install-recommends --no-install-suggests -y ffmpeg
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ffmpeg -> arm64
|
# ffmpeg -> arm64
|
||||||
if [[ "${TARGETARCH}" == "arm64" ]]; then
|
if [[ "${TARGETARCH}" == "arm64" ]]; then
|
||||||
# add raspberry pi repo
|
# add raspberry pi repo
|
||||||
@ -75,17 +66,13 @@ if [[ "${TARGETARCH}" == "arm64" ]]; then
|
|||||||
libva-drm2 mesa-va-drivers
|
libva-drm2 mesa-va-drivers
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# not sure why 32bit arm requires all these
|
|
||||||
if [[ "${TARGETARCH}" == "arm" ]]; then
|
|
||||||
apt-get -qq install --no-install-recommends --no-install-suggests -y \
|
|
||||||
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 libtbb-dev libdc1394-22-dev libopenexr-dev \
|
|
||||||
libgstreamer-plugins-base1.0-dev libgstreamer1.0-dev
|
|
||||||
fi
|
|
||||||
|
|
||||||
apt-get purge gnupg apt-transport-https wget xz-utils -y
|
apt-get purge gnupg apt-transport-https wget xz-utils -y
|
||||||
apt-get clean autoclean -y
|
apt-get clean autoclean -y
|
||||||
apt-get autoremove --purge -y
|
apt-get autoremove --purge -y
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install yq, for frigate-prepare and go2rtc echo source
|
||||||
|
curl -fsSL \
|
||||||
|
"https://github.com/mikefarah/yq/releases/download/v4.33.3/yq_linux_$(dpkg --print-architecture)" \
|
||||||
|
--output /usr/local/bin/yq
|
||||||
|
chmod +x /usr/local/bin/yq
|
||||||
|
|||||||
@ -6,8 +6,6 @@ s6_version="3.1.4.1"
|
|||||||
|
|
||||||
if [[ "${TARGETARCH}" == "amd64" ]]; then
|
if [[ "${TARGETARCH}" == "amd64" ]]; then
|
||||||
s6_arch="x86_64"
|
s6_arch="x86_64"
|
||||||
elif [[ "${TARGETARCH}" == "arm" ]]; then
|
|
||||||
s6_arch="armhf"
|
|
||||||
elif [[ "${TARGETARCH}" == "arm64" ]]; then
|
elif [[ "${TARGETARCH}" == "arm64" ]]; then
|
||||||
s6_arch="aarch64"
|
s6_arch="aarch64"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@ -9,6 +9,42 @@ set -o errexit -o nounset -o pipefail
|
|||||||
# Tell S6-Overlay not to restart this service
|
# Tell S6-Overlay not to restart this service
|
||||||
s6-svc -O .
|
s6-svc -O .
|
||||||
|
|
||||||
|
function migrate_db_path() {
|
||||||
|
# Find config file in yaml or yml, but prefer yaml
|
||||||
|
local config_file="${CONFIG_FILE:-"/config/config.yml"}"
|
||||||
|
local config_file_yaml="${config_file//.yml/.yaml}"
|
||||||
|
if [[ -f "${config_file_yaml}" ]]; then
|
||||||
|
config_file="${config_file_yaml}"
|
||||||
|
elif [[ ! -f "${config_file}" ]]; then
|
||||||
|
echo "[ERROR] Frigate config file not found"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
unset config_file_yaml
|
||||||
|
|
||||||
|
# Use yq to check if database.path is set
|
||||||
|
local user_db_path
|
||||||
|
user_db_path=$(yq eval '.database.path' "${config_file}")
|
||||||
|
|
||||||
|
if [[ "${user_db_path}" == "null" ]]; then
|
||||||
|
local previous_db_path="/media/frigate/frigate.db"
|
||||||
|
local new_db_dir="/config"
|
||||||
|
if [[ -f "${previous_db_path}" ]]; then
|
||||||
|
if mountpoint --quiet "${new_db_dir}"; then
|
||||||
|
# /config is a mount point, move the db
|
||||||
|
echo "[INFO] Moving db from '${previous_db_path}' to the '${new_db_dir}' dir..."
|
||||||
|
# Move all files that starts with frigate.db to the new directory
|
||||||
|
mv -vf "${previous_db_path}"* "${new_db_dir}"
|
||||||
|
else
|
||||||
|
echo "[ERROR] Trying to migrate the db path from '${previous_db_path}' to the '${new_db_dir}' dir, but '${new_db_dir}' is not a mountpoint, please mount the '${new_db_dir}' dir"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "[INFO] Preparing Frigate..."
|
||||||
|
migrate_db_path
|
||||||
|
|
||||||
echo "[INFO] Starting Frigate..."
|
echo "[INFO] Starting Frigate..."
|
||||||
|
|
||||||
cd /opt/frigate || echo "[ERROR] Failed to change working directory to /opt/frigate"
|
cd /opt/frigate || echo "[ERROR] Failed to change working directory to /opt/frigate"
|
||||||
|
|||||||
@ -54,8 +54,17 @@ if [[ ! -f "/dev/shm/go2rtc.yaml" ]]; then
|
|||||||
python3 /usr/local/go2rtc/create_config.py
|
python3 /usr/local/go2rtc/create_config.py
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
readonly config_path="/config"
|
||||||
|
|
||||||
|
if [[ -x "${config_path}/go2rtc" ]]; then
|
||||||
|
readonly binary_path="${config_path}/go2rtc"
|
||||||
|
echo "[WARN] Using go2rtc binary from '${binary_path}' instead of the embedded one"
|
||||||
|
else
|
||||||
|
readonly binary_path="/usr/local/go2rtc/bin/go2rtc"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "[INFO] Starting go2rtc..."
|
echo "[INFO] Starting go2rtc..."
|
||||||
|
|
||||||
# Replace the bash process with the go2rtc process, redirecting stderr to stdout
|
# Replace the bash process with the go2rtc process, redirecting stderr to stdout
|
||||||
exec 2>&1
|
exec 2>&1
|
||||||
exec go2rtc -config=/dev/shm/go2rtc.yaml
|
exec "${binary_path}" -config=/dev/shm/go2rtc.yaml
|
||||||
|
|||||||
@ -30,6 +30,12 @@ elif config_file.endswith(".json"):
|
|||||||
|
|
||||||
go2rtc_config: dict[str, any] = config.get("go2rtc", {})
|
go2rtc_config: dict[str, any] = config.get("go2rtc", {})
|
||||||
|
|
||||||
|
# Need to enable CORS for go2rtc so the frigate integration / card work automatically
|
||||||
|
if go2rtc_config.get("api") is None:
|
||||||
|
go2rtc_config["api"] = {"origin": "*"}
|
||||||
|
elif go2rtc_config["api"].get("origin") is None:
|
||||||
|
go2rtc_config["api"]["origin"] = "*"
|
||||||
|
|
||||||
# we want to ensure that logs are easy to read
|
# we want to ensure that logs are easy to read
|
||||||
if go2rtc_config.get("log") is None:
|
if go2rtc_config.get("log") is None:
|
||||||
go2rtc_config["log"] = {"format": "text"}
|
go2rtc_config["log"] = {"format": "text"}
|
||||||
|
|||||||
@ -42,7 +42,7 @@ environment_vars:
|
|||||||
|
|
||||||
### `database`
|
### `database`
|
||||||
|
|
||||||
Event and recording information is managed in a sqlite database at `/media/frigate/frigate.db`. If that database is deleted, recordings will be orphaned and will need to be cleaned up manually. They also won't show up in the Media Browser within Home Assistant.
|
Event and recording information is managed in a sqlite database at `/config/frigate.db`. If that database is deleted, recordings will be orphaned and will need to be cleaned up manually. They also won't show up in the Media Browser within Home Assistant.
|
||||||
|
|
||||||
If you are storing your database on a network share (SMB, NFS, etc), you may get a `database is locked` error message on startup. You can customize the location of the database in the config if necessary.
|
If you are storing your database on a network share (SMB, NFS, etc), you may get a `database is locked` error message on startup. You can customize the location of the database in the config if necessary.
|
||||||
|
|
||||||
|
|||||||
@ -16,7 +16,7 @@ Note that mjpeg cameras require encoding the video into h264 for recording, and
|
|||||||
```yaml
|
```yaml
|
||||||
go2rtc:
|
go2rtc:
|
||||||
streams:
|
streams:
|
||||||
mjpeg_cam: ffmpeg:{your_mjpeg_stream_url}#video=h264#hardware # <- use hardware acceleration to create an h264 stream usable for other components.
|
mjpeg_cam: "ffmpeg:{your_mjpeg_stream_url}#video=h264#hardware" # <- use hardware acceleration to create an h264 stream usable for other components.
|
||||||
|
|
||||||
cameras:
|
cameras:
|
||||||
...
|
...
|
||||||
@ -110,7 +110,7 @@ go2rtc:
|
|||||||
streams:
|
streams:
|
||||||
reolink:
|
reolink:
|
||||||
- http://reolink_ip/flv?port=1935&app=bcs&stream=channel0_main.bcs&user=username&password=password
|
- http://reolink_ip/flv?port=1935&app=bcs&stream=channel0_main.bcs&user=username&password=password
|
||||||
- ffmpeg:reolink#audio=opus
|
- "ffmpeg:reolink#audio=opus"
|
||||||
reolink_sub:
|
reolink_sub:
|
||||||
- http://reolink_ip/flv?port=1935&app=bcs&stream=channel0_ext.bcs&user=username&password=password
|
- http://reolink_ip/flv?port=1935&app=bcs&stream=channel0_ext.bcs&user=username&password=password
|
||||||
|
|
||||||
@ -130,7 +130,18 @@ cameras:
|
|||||||
|
|
||||||
### Unifi Protect Cameras
|
### Unifi Protect Cameras
|
||||||
|
|
||||||
Unifi protect cameras require the rtspx stream to be used with go2rtc https://github.com/AlexxIT/go2rtc#source-rtsp
|
Unifi protect cameras require the rtspx stream to be used with go2rtc.
|
||||||
|
To utilize a Unifi protect camera, modify the rtsps link to begin with rtspx.
|
||||||
|
Additionally, remove the "?enableSrtp" from the end of the Unifi link.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
go2rtc:
|
||||||
|
streams:
|
||||||
|
front:
|
||||||
|
- rtspx://192.168.1.1:7441/abcdefghijk
|
||||||
|
```
|
||||||
|
|
||||||
|
[See the go2rtc docs for more information](https://github.com/AlexxIT/go2rtc/tree/v1.2.0#source-rtsp)
|
||||||
|
|
||||||
In the Unifi 2.0 update Unifi Protect Cameras had a change in audio sample rate which causes issues for ffmpeg. The input rate needs to be set for record and rtmp if used directly with unifi protect.
|
In the Unifi 2.0 update Unifi Protect Cameras had a change in audio sample rate which causes issues for ffmpeg. The input rate needs to be set for record and rtmp if used directly with unifi protect.
|
||||||
|
|
||||||
|
|||||||
@ -24,7 +24,7 @@ ffmpeg:
|
|||||||
hwaccel_args: preset-vaapi
|
hwaccel_args: preset-vaapi
|
||||||
```
|
```
|
||||||
|
|
||||||
**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=i965` to your docker-compose file or [in the frigate.yml for HA OS users](advanced.md#environment_vars).
|
**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=i965` to your docker-compose file or [in the `frigate.yaml` for HA OS users](advanced.md#environment_vars).
|
||||||
|
|
||||||
### Intel-based CPUs (>=10th Generation) via Quicksync
|
### Intel-based CPUs (>=10th Generation) via Quicksync
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ id: index
|
|||||||
title: Configuration File
|
title: Configuration File
|
||||||
---
|
---
|
||||||
|
|
||||||
For Home Assistant Addon installations, the config file needs to be in the root of your Home Assistant config directory (same location as `configuration.yaml`) and named `frigate.yml`.
|
For Home Assistant Addon installations, the config file needs to be in the root of your Home Assistant config directory (same location as `configuration.yaml`). It can be named `frigate.yaml` or `frigate.yml`, but if both files exist `frigate.yaml` will be preferred and `frigate.yml` will be ignored.
|
||||||
|
|
||||||
For all other installation types, the config file should be mapped to `/config/config.yml` inside the container.
|
For all other installation types, the config file should be mapped to `/config/config.yml` inside the container.
|
||||||
|
|
||||||
@ -36,6 +36,25 @@ It is not recommended to copy this full configuration file. Only specify values
|
|||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
**Note:** The following values will be replaced at runtime by using environment variables
|
||||||
|
|
||||||
|
- `{FRIGATE_MQTT_USER}`
|
||||||
|
- `{FRIGATE_MQTT_PASSWORD}`
|
||||||
|
- `{FRIGATE_RTSP_USER}`
|
||||||
|
- `{FRIGATE_RTSP_PASSWORD}`
|
||||||
|
|
||||||
|
for example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mqtt:
|
||||||
|
user: "{FRIGATE_MQTT_USER}"
|
||||||
|
password: "{FRIGATE_MQTT_PASSWORD}"
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- path: rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@10.0.10.10:8554/unicast
|
||||||
|
```
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
mqtt:
|
mqtt:
|
||||||
# Optional: Enable mqtt server (default: shown below)
|
# Optional: Enable mqtt server (default: shown below)
|
||||||
@ -86,7 +105,7 @@ detectors:
|
|||||||
# Optional: Database configuration
|
# Optional: Database configuration
|
||||||
database:
|
database:
|
||||||
# The path to store the SQLite DB (default: shown below)
|
# The path to store the SQLite DB (default: shown below)
|
||||||
path: /media/frigate/frigate.db
|
path: /config/frigate.db
|
||||||
|
|
||||||
# Optional: model modifications
|
# Optional: model modifications
|
||||||
model:
|
model:
|
||||||
@ -148,7 +167,7 @@ birdseye:
|
|||||||
# More information about presets at https://docs.frigate.video/configuration/ffmpeg_presets
|
# More information about presets at https://docs.frigate.video/configuration/ffmpeg_presets
|
||||||
ffmpeg:
|
ffmpeg:
|
||||||
# Optional: global ffmpeg args (default: shown below)
|
# Optional: global ffmpeg args (default: shown below)
|
||||||
global_args: -hide_banner -loglevel warning -threads 1
|
global_args: -hide_banner -loglevel warning -threads 2
|
||||||
# Optional: global hwaccel args (default: shown below)
|
# Optional: global hwaccel args (default: shown below)
|
||||||
# NOTE: See hardware acceleration docs for your specific device
|
# NOTE: See hardware acceleration docs for your specific device
|
||||||
hwaccel_args: []
|
hwaccel_args: []
|
||||||
@ -157,7 +176,7 @@ ffmpeg:
|
|||||||
# 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)
|
||||||
detect: -threads 1 -f rawvideo -pix_fmt yuv420p
|
detect: -threads 2 -f rawvideo -pix_fmt yuv420p
|
||||||
# Optional: output args for record streams (default: shown below)
|
# Optional: output args for record streams (default: shown below)
|
||||||
record: preset-record-generic
|
record: preset-record-generic
|
||||||
# Optional: output args for rtmp streams (default: shown below)
|
# Optional: output args for rtmp streams (default: shown below)
|
||||||
@ -483,9 +502,32 @@ ui:
|
|||||||
# Optional: Set the default live mode for cameras in the UI (default: shown below)
|
# Optional: Set the default live mode for cameras in the UI (default: shown below)
|
||||||
live_mode: mse
|
live_mode: mse
|
||||||
# Optional: Set a timezone to use in the UI (default: use browser local time)
|
# Optional: Set a timezone to use in the UI (default: use browser local time)
|
||||||
timezone: None
|
# timezone: America/Denver
|
||||||
# Optional: Use an experimental recordings / camera view UI (default: shown below)
|
# Optional: Use an experimental recordings / camera view UI (default: shown below)
|
||||||
experimental_ui: False
|
use_experimental: False
|
||||||
|
# Optional: Set the time format used.
|
||||||
|
# Options are browser, 12hour, or 24hour (default: shown below)
|
||||||
|
time_format: browser
|
||||||
|
# Optional: Set the date style for a specified length.
|
||||||
|
# Options are: full, long, medium, short
|
||||||
|
# Examples:
|
||||||
|
# short: 2/11/23
|
||||||
|
# medium: Feb 11, 2023
|
||||||
|
# full: Saturday, February 11, 2023
|
||||||
|
# (default: shown below).
|
||||||
|
date_style: short
|
||||||
|
# Optional: Set the time style for a specified length.
|
||||||
|
# Options are: full, long, medium, short
|
||||||
|
# Examples:
|
||||||
|
# short: 8:14 PM
|
||||||
|
# medium: 8:15:22 PM
|
||||||
|
# full: 8:15:22 PM Mountain Standard Time
|
||||||
|
# (default: shown below).
|
||||||
|
time_style: medium
|
||||||
|
# Optional: Ability to manually override the date / time styling to use strftime format
|
||||||
|
# https://www.gnu.org/software/libc/manual/html_node/Formatting-Calendar-Time.html
|
||||||
|
# possible values are shown above (default: not set)
|
||||||
|
strftime_fmt: "%Y/%m/%d %H:%M"
|
||||||
|
|
||||||
# Optional: Telemetry configuration
|
# Optional: Telemetry configuration
|
||||||
telemetry:
|
telemetry:
|
||||||
|
|||||||
@ -59,7 +59,7 @@ cameras:
|
|||||||
roles:
|
roles:
|
||||||
- detect
|
- detect
|
||||||
live:
|
live:
|
||||||
stream_name: test_cam_sub
|
stream_name: rtsp_cam_sub
|
||||||
```
|
```
|
||||||
|
|
||||||
### WebRTC extra configuration:
|
### WebRTC extra configuration:
|
||||||
@ -101,4 +101,4 @@ If you are having difficulties getting WebRTC to work and you are running Frigat
|
|||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
See https://github.com/AlexxIT/go2rtc#module-webrtc for more information about this.
|
See [go2rtc WebRTC docs](https://github.com/AlexxIT/go2rtc/tree/v1.2.0#module-webrtc) for more information about this.
|
||||||
|
|||||||
@ -7,7 +7,7 @@ title: Restream
|
|||||||
|
|
||||||
Frigate can restream your video feed as an RTSP feed for other applications such as Home Assistant to utilize it at `rtsp://<frigate_host>:8554/<camera_name>`. Port 8554 must be open. [This allows you to use a video feed for detection in Frigate and Home Assistant live view at the same time without having to make two separate connections to the camera](#reduce-connections-to-camera). The video feed is copied from the original video feed directly to avoid re-encoding. This feed does not include any annotation by Frigate.
|
Frigate can restream your video feed as an RTSP feed for other applications such as Home Assistant to utilize it at `rtsp://<frigate_host>:8554/<camera_name>`. Port 8554 must be open. [This allows you to use a video feed for detection in Frigate and Home Assistant live view at the same time without having to make two separate connections to the camera](#reduce-connections-to-camera). The video feed is copied from the original video feed directly to avoid re-encoding. This feed does not include any annotation by Frigate.
|
||||||
|
|
||||||
Frigate uses [go2rtc](https://github.com/AlexxIT/go2rtc) to provide its restream and MSE/WebRTC capabilities. The go2rtc config is hosted at the `go2rtc` in the config, see [go2rtc docs](https://github.com/AlexxIT/go2rtc#configuration) for more advanced configurations and features.
|
Frigate uses [go2rtc](https://github.com/AlexxIT/go2rtc/tree/v1.2.0) to provide its restream and MSE/WebRTC capabilities. The go2rtc config is hosted at the `go2rtc` in the config, see [go2rtc docs](https://github.com/AlexxIT/go2rtc/tree/v1.2.0#configuration) for more advanced configurations and features.
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
|
|
||||||
@ -130,7 +130,7 @@ cameras:
|
|||||||
|
|
||||||
## Advanced Restream Configurations
|
## Advanced Restream Configurations
|
||||||
|
|
||||||
The [exec](https://github.com/AlexxIT/go2rtc#source-exec) source in go2rtc can be used for custom ffmpeg commands. An example is below:
|
The [exec](https://github.com/AlexxIT/go2rtc/tree/v1.2.0#source-exec) source in go2rtc can be used for custom ffmpeg commands. An example is below:
|
||||||
|
|
||||||
NOTE: The output will need to be passed with two curly braces `{{output}}`
|
NOTE: The output will need to be passed with two curly braces `{{output}}`
|
||||||
|
|
||||||
|
|||||||
@ -23,15 +23,11 @@ I may earn a small commission for my endorsement, recommendation, testimonial, o
|
|||||||
|
|
||||||
My current favorite is the Minisforum GK41 because of the dual NICs that allow you to setup a dedicated private network for your cameras where they can be blocked from accessing the internet. There are many used workstation options on eBay that work very well. Anything with an Intel CPU and capable of running Debian should work fine. As a bonus, you may want to look for devices with a M.2 or PCIe express slot that is compatible with the Google Coral. I may earn a small commission for my endorsement, recommendation, testimonial, or link to any products or services from this website.
|
My current favorite is the Minisforum GK41 because of the dual NICs that allow you to setup a dedicated private network for your cameras where they can be blocked from accessing the internet. There are many used workstation options on eBay that work very well. Anything with an Intel CPU and capable of running Debian should work fine. As a bonus, you may want to look for devices with a M.2 or PCIe express slot that is compatible with the Google Coral. I may earn a small commission for my endorsement, recommendation, testimonial, or link to any products or services from this website.
|
||||||
|
|
||||||
| Name | Coral Inference Speed | Coral Compatibility | Notes |
|
| Name | Coral Inference Speed | Coral Compatibility | Notes |
|
||||||
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Odyssey X86 Blue J4125 (<a href="https://amzn.to/3oH4BKi" target="_blank" rel="nofollow noopener sponsored">Amazon</a>) (<a href="https://www.seeedstudio.com/Frigate-NVR-with-Odyssey-Blue-and-Coral-USB-Accelerator.html?utm_source=Frigate" target="_blank" rel="nofollow noopener sponsored">SeeedStudio</a>) | 9-10ms | M.2 B+M, USB | Dual gigabit NICs for easy isolated camera network. Easily handles several 1080p cameras. |
|
| Odyssey X86 Blue J4125 (<a href="https://amzn.to/3oH4BKi" target="_blank" rel="nofollow noopener sponsored">Amazon</a>) (<a href="https://www.seeedstudio.com/Frigate-NVR-with-Odyssey-Blue-and-Coral-USB-Accelerator.html?utm_source=Frigate" target="_blank" rel="nofollow noopener sponsored">SeeedStudio</a>) | 9-10ms | M.2 B+M, USB | Dual gigabit NICs for easy isolated camera network. Easily handles several 1080p cameras. |
|
||||||
| Minisforum GK41 (<a href="https://amzn.to/3ptnb8D" target="_blank" rel="nofollow noopener sponsored">Amazon</a>) | 9-10ms | USB | Dual gigabit NICs for easy isolated camera network. Easily handles several 1080p cameras. |
|
| Minisforum GK41 (<a href="https://amzn.to/3ptnb8D" target="_blank" rel="nofollow noopener sponsored">Amazon</a>) | 9-10ms | USB | Dual gigabit NICs for easy isolated camera network. Easily handles several 1080p cameras. |
|
||||||
| Beelink GK55 (<a href="https://amzn.to/35E79BC" target="_blank" rel="nofollow noopener sponsored">Amazon</a>) | 9-10ms | USB | Dual gigabit NICs for easy isolated camera network. Easily handles several 1080p cameras. |
|
| Intel NUC (<a href="https://amzn.to/3psFlHi" target="_blank" rel="nofollow noopener sponsored">Amazon</a>) | 8-10ms | USB | Overkill for most, but great performance. Can handle many cameras at 5fps depending on typical amounts of motion. Requires extra parts. |
|
||||||
| Intel NUC (<a href="https://amzn.to/3psFlHi" target="_blank" rel="nofollow noopener sponsored">Amazon</a>) | 8-10ms | USB | Overkill for most, but great performance. Can handle many cameras at 5fps depending on typical amounts of motion. Requires extra parts. |
|
|
||||||
| BMAX B2 Plus (<a href="https://amzn.to/3a6TBh8" target="_blank" rel="nofollow noopener sponsored">Amazon</a>) | 10-12ms | USB | Good balance of performance and cost. Also capable of running many other services at the same time as Frigate. |
|
|
||||||
| Atomic Pi (<a href="https://amzn.to/2YjpY9m" target="_blank" rel="nofollow noopener sponsored">Amazon</a>) | 16ms | USB | Good option for a dedicated low power board with a small number of cameras. Can leverage Intel QuickSync for stream decoding. |
|
|
||||||
| Raspberry Pi 4 (64bit) (<a href="https://amzn.to/2YhSGHH" target="_blank" rel="nofollow noopener sponsored">Amazon</a>) | 10-15ms | USB | Can handle a small number of cameras. |
|
|
||||||
|
|
||||||
## Detectors
|
## Detectors
|
||||||
|
|
||||||
@ -50,8 +46,9 @@ A single Coral can handle many cameras and will be sufficient for the majority o
|
|||||||
### OpenVino
|
### OpenVino
|
||||||
|
|
||||||
The OpenVINO detector type is able to run on:
|
The OpenVINO detector type is able to run on:
|
||||||
|
|
||||||
- 6th Gen Intel Platforms and newer that have an iGPU
|
- 6th Gen Intel Platforms and newer that have an iGPU
|
||||||
- x86 & Arm32/64 hosts with VPU Hardware (ex: Intel NCS2)
|
- x86 & Arm64 hosts with VPU Hardware (ex: Intel NCS2)
|
||||||
|
|
||||||
More information is available [in the detector docs](/configuration/detectors#openvino-detector)
|
More information is available [in the detector docs](/configuration/detectors#openvino-detector)
|
||||||
|
|
||||||
@ -80,15 +77,15 @@ The TensortRT detector is able to run on x86 hosts that have an Nvidia GPU which
|
|||||||
Inference speeds will vary greatly depending on the GPU and the model used.
|
Inference speeds will vary greatly depending on the GPU and the model used.
|
||||||
`tiny` variants are faster than the equivalent non-tiny model, some known examples are below:
|
`tiny` variants are faster than the equivalent non-tiny model, some known examples are below:
|
||||||
|
|
||||||
| Name | Inference Speed |
|
| Name | Inference Speed |
|
||||||
| --------------- | ----------------- |
|
| --------------- | --------------- |
|
||||||
| GTX 1060 6GB | ~ 7 ms |
|
| GTX 1060 6GB | ~ 7 ms |
|
||||||
| GTX 1070 | ~ 6 ms |
|
| GTX 1070 | ~ 6 ms |
|
||||||
| GTX 1660 SUPER | ~ 4 ms |
|
| GTX 1660 SUPER | ~ 4 ms |
|
||||||
| RTX 3050 | 5 - 7 ms |
|
| RTX 3050 | 5 - 7 ms |
|
||||||
| RTX 3070 Mobile | ~ 5 ms |
|
| RTX 3070 Mobile | ~ 5 ms |
|
||||||
| Quadro P400 2GB | 20 - 25 ms |
|
| Quadro P400 2GB | 20 - 25 ms |
|
||||||
| Quadro P2000 | ~ 12 ms |
|
| Quadro P2000 | ~ 12 ms |
|
||||||
|
|
||||||
## What does Frigate use the CPU for and what does it use a detector for? (ELI5 Version)
|
## What does Frigate use the CPU for and what does it use a detector for? (ELI5 Version)
|
||||||
|
|
||||||
|
|||||||
@ -21,12 +21,11 @@ Windows is not officially supported, but some users have had success getting it
|
|||||||
|
|
||||||
Frigate uses the following locations for read/write operations in the container. Docker volume mappings can be used to map these to any location on your host machine.
|
Frigate uses the following locations for read/write operations in the container. Docker volume mappings can be used to map these to any location on your host machine.
|
||||||
|
|
||||||
|
- `/config`: Used to store the Frigate config file and sqlite database. You will also see a few files alongside the database file while Frigate is running.
|
||||||
- `/media/frigate/clips`: Used for snapshot storage. In the future, it will likely be renamed from `clips` to `snapshots`. The file structure here cannot be modified and isn't intended to be browsed or managed manually.
|
- `/media/frigate/clips`: Used for snapshot storage. In the future, it will likely be renamed from `clips` to `snapshots`. The file structure here cannot be modified and isn't intended to be browsed or managed manually.
|
||||||
- `/media/frigate/recordings`: Internal system storage for recording segments. The file structure here cannot be modified and isn't intended to be browsed or managed manually.
|
- `/media/frigate/recordings`: Internal system storage for recording segments. The file structure here cannot be modified and isn't intended to be browsed or managed manually.
|
||||||
- `/media/frigate/frigate.db`: Default location for the sqlite database. You will also see several files alongside this file while Frigate is running. If moving the database location (often needed when using a network drive at `/media/frigate`), it is recommended to mount a volume with docker at `/db` and change the storage location of the database to `/db/frigate.db` in the config file.
|
|
||||||
- `/tmp/cache`: Cache location for recording segments. Initial recordings are written here before being checked and converted to mp4 and moved to the recordings folder.
|
- `/tmp/cache`: Cache location for recording segments. Initial recordings are written here before being checked and converted to mp4 and moved to the recordings folder.
|
||||||
- `/dev/shm`: It is not recommended to modify this directory or map it with docker. This is the location for raw decoded frames in shared memory and it's size is impacted by the `shm-size` calculations below.
|
- `/dev/shm`: It is not recommended to modify this directory or map it with docker. This is the location for raw decoded frames in shared memory and it's size is impacted by the `shm-size` calculations below.
|
||||||
- `/config/config.yml`: Default location of the config file.
|
|
||||||
|
|
||||||
#### Common docker compose storage configurations
|
#### Common docker compose storage configurations
|
||||||
|
|
||||||
@ -38,7 +37,7 @@ services:
|
|||||||
frigate:
|
frigate:
|
||||||
...
|
...
|
||||||
volumes:
|
volumes:
|
||||||
- /path/to/your/config.yml:/config/config.yml
|
- /path/to/your/config:/config
|
||||||
- /path/to/your/storage:/media/frigate
|
- /path/to/your/storage:/media/frigate
|
||||||
- type: tmpfs # Optional: 1GB of memory, reduces SSD/SD Card wear
|
- type: tmpfs # Optional: 1GB of memory, reduces SSD/SD Card wear
|
||||||
target: /tmp/cache
|
target: /tmp/cache
|
||||||
@ -47,31 +46,6 @@ services:
|
|||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
Writing to a network drive with database on a local drive:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
version: "3.9"
|
|
||||||
services:
|
|
||||||
frigate:
|
|
||||||
...
|
|
||||||
volumes:
|
|
||||||
- /path/to/your/config.yml:/config/config.yml
|
|
||||||
- /path/to/network/storage:/media/frigate
|
|
||||||
- /path/to/local/disk:/db
|
|
||||||
- type: tmpfs # Optional: 1GB of memory, reduces SSD/SD Card wear
|
|
||||||
target: /tmp/cache
|
|
||||||
tmpfs:
|
|
||||||
size: 1000000000
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
frigate.yml
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
database:
|
|
||||||
path: /db/frigate.db
|
|
||||||
```
|
|
||||||
|
|
||||||
### Calculating required shm-size
|
### Calculating required shm-size
|
||||||
|
|
||||||
Frigate utilizes shared memory to store frames during processing. The default `shm-size` provided by Docker is **64MB**.
|
Frigate utilizes shared memory to store frames during processing. The default `shm-size` provided by Docker is **64MB**.
|
||||||
@ -123,7 +97,7 @@ services:
|
|||||||
- /dev/dri/renderD128 # for intel hwaccel, needs to be updated for your hardware
|
- /dev/dri/renderD128 # for intel hwaccel, needs to be updated for your hardware
|
||||||
volumes:
|
volumes:
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
- /path/to/your/config.yml:/config/config.yml
|
- /path/to/your/config:/config
|
||||||
- /path/to/your/storage:/media/frigate
|
- /path/to/your/storage:/media/frigate
|
||||||
- type: tmpfs # Optional: 1GB of memory, reduces SSD/SD Card wear
|
- type: tmpfs # Optional: 1GB of memory, reduces SSD/SD Card wear
|
||||||
target: /tmp/cache
|
target: /tmp/cache
|
||||||
@ -149,7 +123,7 @@ docker run -d \
|
|||||||
--device /dev/dri/renderD128 \
|
--device /dev/dri/renderD128 \
|
||||||
--shm-size=64m \
|
--shm-size=64m \
|
||||||
-v /path/to/your/storage:/media/frigate \
|
-v /path/to/your/storage:/media/frigate \
|
||||||
-v /path/to/your/config.yml:/config/config.yml \
|
-v /path/to/your/config:/config \
|
||||||
-v /etc/localtime:/etc/localtime:ro \
|
-v /etc/localtime:/etc/localtime:ro \
|
||||||
-e FRIGATE_RTSP_PASSWORD='password' \
|
-e FRIGATE_RTSP_PASSWORD='password' \
|
||||||
-p 5000:5000 \
|
-p 5000:5000 \
|
||||||
@ -163,7 +137,10 @@ docker run -d \
|
|||||||
|
|
||||||
:::caution
|
:::caution
|
||||||
|
|
||||||
Due to limitations in Home Assistant Operating System, utilizing external storage for recordings or snapshots requires [modifying udev rules manually](https://community.home-assistant.io/t/solved-mount-usb-drive-in-hassio-to-be-used-on-the-media-folder-with-udev-customization/258406/46).
|
There are important limitations in Home Assistant Operating System to be aware of:
|
||||||
|
- Utilizing external storage for recordings or snapshots requires [modifying udev rules manually](https://community.home-assistant.io/t/solved-mount-usb-drive-in-hassio-to-be-used-on-the-media-folder-with-udev-customization/258406/46).
|
||||||
|
- AMD GPUs are not supported because HA OS does not include the mesa driver.
|
||||||
|
- Nvidia GPUs are not supported because addons do not support the nvidia runtime.
|
||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
@ -179,7 +156,7 @@ HassOS users can install via the addon repository.
|
|||||||
2. Add https://github.com/blakeblackshear/frigate-hass-addons
|
2. Add https://github.com/blakeblackshear/frigate-hass-addons
|
||||||
3. Install your desired Frigate NVR Addon and navigate to it's page
|
3. Install your desired Frigate NVR Addon and navigate to it's page
|
||||||
4. Setup your network configuration in the `Configuration` tab
|
4. Setup your network configuration in the `Configuration` tab
|
||||||
5. (not for proxy addon) Create the file `frigate.yml` in your `config` directory with your detailed Frigate configuration
|
5. (not for proxy addon) Create the file `frigate.yaml` in your `config` directory with your detailed Frigate configuration
|
||||||
6. Start the addon container
|
6. Start the addon container
|
||||||
7. (not for proxy addon) If you are using hardware acceleration for ffmpeg, you may need to disable "Protection mode"
|
7. (not for proxy addon) If you are using hardware acceleration for ffmpeg, you may need to disable "Protection mode"
|
||||||
|
|
||||||
@ -194,6 +171,13 @@ There are several versions of the addon available:
|
|||||||
|
|
||||||
## Home Assistant Supervised
|
## Home Assistant Supervised
|
||||||
|
|
||||||
|
:::caution
|
||||||
|
|
||||||
|
There are important limitations in Home Assistant Supervised to be aware of:
|
||||||
|
- Nvidia GPUs are not supported because addons do not support the nvidia runtime.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
:::tip
|
:::tip
|
||||||
|
|
||||||
If possible, it is recommended to run Frigate standalone in Docker and use [Frigate's Proxy Addon](https://github.com/blakeblackshear/frigate-hass-addons/blob/main/frigate_proxy/README.md).
|
If possible, it is recommended to run Frigate standalone in Docker and use [Frigate's Proxy Addon](https://github.com/blakeblackshear/frigate-hass-addons/blob/main/frigate_proxy/README.md).
|
||||||
|
|||||||
@ -10,7 +10,7 @@ Use of the bundled go2rtc is optional. You can still configure FFmpeg to connect
|
|||||||
|
|
||||||
# Setup a go2rtc stream
|
# Setup a go2rtc stream
|
||||||
|
|
||||||
First, you will want to configure go2rtc to connect to your camera stream by adding the stream you want to use for live view in your Frigate config file. If you set the stream name under go2rtc to match the name of your camera, it will automatically be mapped and you will get additional live view options for the camera. Avoid changing any other parts of your config at this step. Note that go2rtc supports [many different stream types](https://github.com/AlexxIT/go2rtc#module-streams), not just rtsp.
|
First, you will want to configure go2rtc to connect to your camera stream by adding the stream you want to use for live view in your Frigate config file. If you set the stream name under go2rtc to match the name of your camera, it will automatically be mapped and you will get additional live view options for the camera. Avoid changing any other parts of your config at this step. Note that go2rtc supports [many different stream types](https://github.com/AlexxIT/go2rtc/tree/v1.2.0#module-streams), not just rtsp.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
go2rtc:
|
go2rtc:
|
||||||
@ -23,7 +23,7 @@ The easiest live view to get working is MSE. After adding this to the config, re
|
|||||||
|
|
||||||
### What if my video doesn't play?
|
### What if my video doesn't play?
|
||||||
|
|
||||||
If you are unable to see your video feed, first check the go2rtc logs in the Frigate UI under Logs in the sidebar. If go2rtc is having difficulty connecting to your camera, you should see some error messages in the log. If you do not see any errors, then the video codec of the stream may not be supported in your browser. If your camera stream is set to H265, try switching to H264. You can see more information about [video codec compatibility](https://github.com/AlexxIT/go2rtc#codecs-madness) in the go2rtc documentation. If you are not able to switch your camera settings from H265 to H264 or your stream is a different format such as MJPEG, you can use go2rtc to re-encode the video using the [FFmpeg parameters](https://github.com/AlexxIT/go2rtc#source-ffmpeg). It supports rotating and resizing video feeds and hardware acceleration. Keep in mind that transcoding video from one format to another is a resource intensive task and you may be better off using the built-in jsmpeg view. Here is an example of a config that will re-encode the stream to H264 without hardware acceleration:
|
If you are unable to see your video feed, first check the go2rtc logs in the Frigate UI under Logs in the sidebar. If go2rtc is having difficulty connecting to your camera, you should see some error messages in the log. If you do not see any errors, then the video codec of the stream may not be supported in your browser. If your camera stream is set to H265, try switching to H264. You can see more information about [video codec compatibility](https://github.com/AlexxIT/go2rtc/tree/v1.2.0#codecs-madness) in the go2rtc documentation. If you are not able to switch your camera settings from H265 to H264 or your stream is a different format such as MJPEG, you can use go2rtc to re-encode the video using the [FFmpeg parameters](https://github.com/AlexxIT/go2rtc/tree/v1.2.0#source-ffmpeg). It supports rotating and resizing video feeds and hardware acceleration. Keep in mind that transcoding video from one format to another is a resource intensive task and you may be better off using the built-in jsmpeg view. Here is an example of a config that will re-encode the stream to H264 without hardware acceleration:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
go2rtc:
|
go2rtc:
|
||||||
|
|||||||
@ -168,6 +168,16 @@ Events from the database. Accepts the following query string parameters:
|
|||||||
| `include_thumbnails` | int | Include thumbnails in the response (0 or 1) |
|
| `include_thumbnails` | int | Include thumbnails in the response (0 or 1) |
|
||||||
| `in_progress` | int | Limit to events in progress (0 or 1) |
|
| `in_progress` | int | Limit to events in progress (0 or 1) |
|
||||||
|
|
||||||
|
### `GET /api/timeline`
|
||||||
|
|
||||||
|
Timeline of key moments of an event(s) from the database. Accepts the following query string parameters:
|
||||||
|
|
||||||
|
| param | Type | Description |
|
||||||
|
| -------------------- | ---- | --------------------------------------------- |
|
||||||
|
| `camera` | int | Name of camera |
|
||||||
|
| `source_id` | str | ID of tracked object |
|
||||||
|
| `limit` | int | Limit the number of events returned |
|
||||||
|
|
||||||
### `GET /api/events/summary`
|
### `GET /api/events/summary`
|
||||||
|
|
||||||
Returns summary data for events in the database. Used by the Home Assistant integration.
|
Returns summary data for events in the database. Used by the Home Assistant integration.
|
||||||
@ -233,6 +243,10 @@ Accepts the following query string parameters, but they are only applied when an
|
|||||||
|
|
||||||
Returns the snapshot image from the latest event for the given camera and label combo. Using `any` as the label will return the latest thumbnail regardless of type.
|
Returns the snapshot image from the latest event for the given camera and label combo. Using `any` as the label will return the latest thumbnail regardless of type.
|
||||||
|
|
||||||
|
### `GET /api/<camera_name>/recording/<frame_time>/snapshot.png`
|
||||||
|
|
||||||
|
Returns the snapshot image from the specific point in that cameras recordings.
|
||||||
|
|
||||||
### `GET /clips/<camera>-<id>.jpg`
|
### `GET /clips/<camera>-<id>.jpg`
|
||||||
|
|
||||||
JPG snapshot for the given camera and event id.
|
JPG snapshot for the given camera and event id.
|
||||||
|
|||||||
@ -16,6 +16,8 @@ See the [MQTT integration
|
|||||||
documentation](https://www.home-assistant.io/integrations/mqtt/) for more
|
documentation](https://www.home-assistant.io/integrations/mqtt/) for more
|
||||||
details.
|
details.
|
||||||
|
|
||||||
|
In addition, MQTT must be enabled in your Frigate configuration file and Frigate must be connected to the same MQTT server as Home Assistant for many of the entities created by the integration to function.
|
||||||
|
|
||||||
### Integration installation
|
### Integration installation
|
||||||
|
|
||||||
Available via HACS as a default repository. To install:
|
Available via HACS as a default repository. To install:
|
||||||
@ -30,7 +32,7 @@ Home Assistant > HACS > Integrations > "Explore & Add Integrations" > Frigate
|
|||||||
- Then add/configure the integration:
|
- Then add/configure the integration:
|
||||||
|
|
||||||
```
|
```
|
||||||
Home Assistant > Configuration > Integrations > Add Integration > Frigate
|
Home Assistant > Settings > Devices & Services > Add Integration > Frigate
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: You will also need
|
Note: You will also need
|
||||||
@ -64,13 +66,13 @@ Home Assistant > Configuration > Integrations > Frigate > Options
|
|||||||
|
|
||||||
| Option | Description |
|
| Option | Description |
|
||||||
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| RTSP URL Template | A [jinja2](https://jinja.palletsprojects.com/) template that is used to override the standard RTMP stream URL (e.g. for use with reverse proxies). This option is only shown to users who have [advanced mode](https://www.home-assistant.io/blog/2019/07/17/release-96/#advanced-mode) enabled. See [RTSP streams](#streams) below. |
|
| RTSP URL Template | A [jinja2](https://jinja.palletsprojects.com/) template that is used to override the standard RTSP stream URL (e.g. for use with reverse proxies). This option is only shown to users who have [advanced mode](https://www.home-assistant.io/blog/2019/07/17/release-96/#advanced-mode) enabled. See [RTSP streams](#streams) below. |
|
||||||
|
|
||||||
## Entities Provided
|
## Entities Provided
|
||||||
|
|
||||||
| Platform | Description |
|
| Platform | Description |
|
||||||
| --------------- | --------------------------------------------------------------------------------- |
|
| --------------- | --------------------------------------------------------------------------------- |
|
||||||
| `camera` | Live camera stream (requires RTMP), camera for image of the last detected object. |
|
| `camera` | Live camera stream (requires RTSP), camera for image of the last detected object. |
|
||||||
| `sensor` | States to monitor Frigate performance, object counts for all zones and cameras. |
|
| `sensor` | States to monitor Frigate performance, object counts for all zones and cameras. |
|
||||||
| `switch` | Switch entities to toggle detection, recordings and snapshots. |
|
| `switch` | Switch entities to toggle detection, recordings and snapshots. |
|
||||||
| `binary_sensor` | A "motion" binary sensor entity per camera/zone/object. |
|
| `binary_sensor` | A "motion" binary sensor entity per camera/zone/object. |
|
||||||
|
|||||||
@ -39,6 +39,12 @@ You cannot use the `environment_vars` section of your configuration file to set
|
|||||||
|
|
||||||
Once your API key is configured, you can submit examples directly from the events page in Frigate using the `SEND TO FRIGATE+` button.
|
Once your API key is configured, you can submit examples directly from the events page in Frigate using the `SEND TO FRIGATE+` button.
|
||||||
|
|
||||||
|
:::note
|
||||||
|
|
||||||
|
Snapshots must be enabled to be able to submit examples to Frigate+
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### Annotate and verify
|
### Annotate and verify
|
||||||
|
|||||||
@ -11,7 +11,7 @@ This error message is due to a shm-size that is too small. Try updating your shm
|
|||||||
|
|
||||||
By default, Frigate removes audio from recordings to reduce the likelihood of failing for invalid data. If you would like to include audio, you need to set a [FFmpeg preset](/configuration/ffmpeg_presets) that supports audio:
|
By default, Frigate removes audio from recordings to reduce the likelihood of failing for invalid data. If you would like to include audio, you need to set a [FFmpeg preset](/configuration/ffmpeg_presets) that supports audio:
|
||||||
|
|
||||||
```yaml title="frigate.yml"
|
```yaml
|
||||||
ffmpeg:
|
ffmpeg:
|
||||||
output_args:
|
output_args:
|
||||||
record: preset-record-generic-audio-aac
|
record: preset-record-generic-audio-aac
|
||||||
|
|||||||
1365
docs/package-lock.json
generated
1365
docs/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -14,12 +14,12 @@
|
|||||||
"write-heading-ids": "docusaurus write-heading-ids"
|
"write-heading-ids": "docusaurus write-heading-ids"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@docusaurus/core": "^2.2.0",
|
"@docusaurus/core": "^2.4.0",
|
||||||
"@docusaurus/preset-classic": "^2.2.0",
|
"@docusaurus/preset-classic": "^2.4.0",
|
||||||
"@mdx-js/react": "^1.6.22",
|
"@mdx-js/react": "^1.6.22",
|
||||||
"clsx": "^1.2.1",
|
"clsx": "^1.2.1",
|
||||||
"raw-loader": "^4.0.2",
|
|
||||||
"prism-react-renderer": "^1.3.5",
|
"prism-react-renderer": "^1.3.5",
|
||||||
|
"raw-loader": "^4.0.2",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
"react-dom": "^17.0.2"
|
"react-dom": "^17.0.2"
|
||||||
},
|
},
|
||||||
@ -36,8 +36,8 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^17.0.0",
|
"@docusaurus/module-type-aliases": "^2.4.0",
|
||||||
"@docusaurus/module-type-aliases": "2.2.0"
|
"@types/react": "^17.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.14"
|
"node": ">=16.14"
|
||||||
|
|||||||
@ -18,18 +18,19 @@ from frigate.comms.dispatcher import Communicator, Dispatcher
|
|||||||
from frigate.comms.mqtt import MqttClient
|
from frigate.comms.mqtt import MqttClient
|
||||||
from frigate.comms.ws import WebSocketClient
|
from frigate.comms.ws import WebSocketClient
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.const import CACHE_DIR, CLIPS_DIR, RECORD_DIR
|
from frigate.const import CACHE_DIR, CLIPS_DIR, CONFIG_DIR, DEFAULT_DB_PATH, RECORD_DIR
|
||||||
from frigate.object_detection import ObjectDetectProcess
|
from frigate.object_detection import ObjectDetectProcess
|
||||||
from frigate.events import EventCleanup, EventProcessor
|
from frigate.events import EventCleanup, EventProcessor
|
||||||
from frigate.http import create_app
|
from frigate.http import create_app
|
||||||
from frigate.log import log_process, root_configurer
|
from frigate.log import log_process, root_configurer
|
||||||
from frigate.models import Event, Recordings
|
from frigate.models import Event, Recordings, Timeline
|
||||||
from frigate.object_processing import TrackedObjectProcessor
|
from frigate.object_processing import TrackedObjectProcessor
|
||||||
from frigate.output import output_frames
|
from frigate.output import output_frames
|
||||||
from frigate.plus import PlusApi
|
from frigate.plus import PlusApi
|
||||||
from frigate.record import RecordingCleanup, RecordingMaintainer
|
from frigate.record import RecordingCleanup, RecordingMaintainer
|
||||||
from frigate.monitoring.stats import StatsEmitter, stats_init
|
from frigate.monitoring.stats import StatsEmitter, stats_init
|
||||||
from frigate.storage import StorageMaintainer
|
from frigate.storage import StorageMaintainer
|
||||||
|
from frigate.timeline import TimelineProcessor
|
||||||
from frigate.version import VERSION
|
from frigate.version import VERSION
|
||||||
from frigate.video import capture_camera, track_camera
|
from frigate.video import capture_camera, track_camera
|
||||||
from frigate.watchdog import FrigateWatchdog
|
from frigate.watchdog import FrigateWatchdog
|
||||||
@ -54,7 +55,7 @@ class FrigateApp:
|
|||||||
os.environ[key] = value
|
os.environ[key] = value
|
||||||
|
|
||||||
def ensure_dirs(self) -> None:
|
def ensure_dirs(self) -> None:
|
||||||
for d in [RECORD_DIR, CLIPS_DIR, CACHE_DIR]:
|
for d in [CONFIG_DIR, RECORD_DIR, CLIPS_DIR, CACHE_DIR]:
|
||||||
if not os.path.exists(d) and not os.path.islink(d):
|
if not os.path.exists(d) and not os.path.islink(d):
|
||||||
logger.info(f"Creating directory: {d}")
|
logger.info(f"Creating directory: {d}")
|
||||||
os.makedirs(d)
|
os.makedirs(d)
|
||||||
@ -135,9 +136,12 @@ class FrigateApp:
|
|||||||
# Queue for recordings info
|
# Queue for recordings info
|
||||||
self.recordings_info_queue: Queue = mp.Queue()
|
self.recordings_info_queue: Queue = mp.Queue()
|
||||||
|
|
||||||
|
# Queue for timeline events
|
||||||
|
self.timeline_queue: Queue = mp.Queue()
|
||||||
|
|
||||||
def init_database(self) -> None:
|
def init_database(self) -> None:
|
||||||
# Migrate DB location
|
# Migrate DB location
|
||||||
old_db_path = os.path.join(CLIPS_DIR, "frigate.db")
|
old_db_path = DEFAULT_DB_PATH
|
||||||
if not os.path.isfile(self.config.database.path) and os.path.isfile(
|
if not os.path.isfile(self.config.database.path) and os.path.isfile(
|
||||||
old_db_path
|
old_db_path
|
||||||
):
|
):
|
||||||
@ -154,7 +158,7 @@ class FrigateApp:
|
|||||||
migrate_db.close()
|
migrate_db.close()
|
||||||
|
|
||||||
self.db = SqliteQueueDatabase(self.config.database.path)
|
self.db = SqliteQueueDatabase(self.config.database.path)
|
||||||
models = [Event, Recordings]
|
models = [Event, Recordings, Timeline]
|
||||||
self.db.bind(models)
|
self.db.bind(models)
|
||||||
|
|
||||||
def init_stats(self) -> None:
|
def init_stats(self) -> None:
|
||||||
@ -286,12 +290,19 @@ class FrigateApp:
|
|||||||
capture_process.start()
|
capture_process.start()
|
||||||
logger.info(f"Capture process started for {name}: {capture_process.pid}")
|
logger.info(f"Capture process started for {name}: {capture_process.pid}")
|
||||||
|
|
||||||
|
def start_timeline_processor(self) -> None:
|
||||||
|
self.timeline_processor = TimelineProcessor(
|
||||||
|
self.config, self.timeline_queue, self.stop_event
|
||||||
|
)
|
||||||
|
self.timeline_processor.start()
|
||||||
|
|
||||||
def start_event_processor(self) -> None:
|
def start_event_processor(self) -> None:
|
||||||
self.event_processor = EventProcessor(
|
self.event_processor = EventProcessor(
|
||||||
self.config,
|
self.config,
|
||||||
self.camera_metrics,
|
self.camera_metrics,
|
||||||
self.event_queue,
|
self.event_queue,
|
||||||
self.event_processed_queue,
|
self.event_processed_queue,
|
||||||
|
self.timeline_queue,
|
||||||
self.stop_event,
|
self.stop_event,
|
||||||
)
|
)
|
||||||
self.event_processor.start()
|
self.event_processor.start()
|
||||||
@ -384,6 +395,7 @@ class FrigateApp:
|
|||||||
self.start_storage_maintainer()
|
self.start_storage_maintainer()
|
||||||
self.init_stats()
|
self.init_stats()
|
||||||
self.init_web_server()
|
self.init_web_server()
|
||||||
|
self.start_timeline_processor()
|
||||||
self.start_event_processor()
|
self.start_event_processor()
|
||||||
self.start_event_cleanup()
|
self.start_event_cleanup()
|
||||||
self.start_recording_maintainer()
|
self.start_recording_maintainer()
|
||||||
@ -391,7 +403,6 @@ class FrigateApp:
|
|||||||
self.start_stats_emitter()
|
self.start_stats_emitter()
|
||||||
self.start_watchdog()
|
self.start_watchdog()
|
||||||
self.check_shm()
|
self.check_shm()
|
||||||
# self.zeroconf = broadcast_zeroconf(self.config.mqtt.client_id)
|
|
||||||
|
|
||||||
def receiveSignal(signalNumber: int, frame: Optional[FrameType]) -> None:
|
def receiveSignal(signalNumber: int, frame: Optional[FrameType]) -> None:
|
||||||
self.stop()
|
self.stop()
|
||||||
|
|||||||
@ -13,8 +13,8 @@ from pydantic import BaseModel, Extra, Field, validator, parse_obj_as
|
|||||||
from pydantic.fields import PrivateAttr
|
from pydantic.fields import PrivateAttr
|
||||||
|
|
||||||
from frigate.const import (
|
from frigate.const import (
|
||||||
BASE_DIR,
|
|
||||||
CACHE_DIR,
|
CACHE_DIR,
|
||||||
|
DEFAULT_DB_PATH,
|
||||||
REGEX_CAMERA_NAME,
|
REGEX_CAMERA_NAME,
|
||||||
YAML_EXT,
|
YAML_EXT,
|
||||||
)
|
)
|
||||||
@ -66,6 +66,12 @@ class LiveModeEnum(str, Enum):
|
|||||||
webrtc = "webrtc"
|
webrtc = "webrtc"
|
||||||
|
|
||||||
|
|
||||||
|
class TimeFormatEnum(str, Enum):
|
||||||
|
browser = "browser"
|
||||||
|
hours12 = "12hour"
|
||||||
|
hours24 = "24hour"
|
||||||
|
|
||||||
|
|
||||||
class DateTimeStyleEnum(str, Enum):
|
class DateTimeStyleEnum(str, Enum):
|
||||||
full = "full"
|
full = "full"
|
||||||
long = "long"
|
long = "long"
|
||||||
@ -79,7 +85,9 @@ class UIConfig(FrigateBaseModel):
|
|||||||
)
|
)
|
||||||
timezone: Optional[str] = Field(title="Override UI timezone.")
|
timezone: Optional[str] = Field(title="Override UI timezone.")
|
||||||
use_experimental: bool = Field(default=False, title="Experimental UI")
|
use_experimental: bool = Field(default=False, title="Experimental UI")
|
||||||
use12hour: Optional[bool] = Field(title="Override UI time format.")
|
time_format: TimeFormatEnum = Field(
|
||||||
|
default=TimeFormatEnum.browser, title="Override UI time format."
|
||||||
|
)
|
||||||
date_style: DateTimeStyleEnum = Field(
|
date_style: DateTimeStyleEnum = Field(
|
||||||
default=DateTimeStyleEnum.short, title="Override UI dateStyle."
|
default=DateTimeStyleEnum.short, title="Override UI dateStyle."
|
||||||
)
|
)
|
||||||
@ -156,8 +164,6 @@ class RecordConfig(FrigateBaseModel):
|
|||||||
default=60,
|
default=60,
|
||||||
title="Number of minutes to wait between cleanup runs.",
|
title="Number of minutes to wait between cleanup runs.",
|
||||||
)
|
)
|
||||||
# deprecated - to be removed in a future version
|
|
||||||
retain_days: Optional[float] = Field(title="Recording retention period in days.")
|
|
||||||
retain: RecordRetainConfig = Field(
|
retain: RecordRetainConfig = Field(
|
||||||
default_factory=RecordRetainConfig, title="Record retention settings."
|
default_factory=RecordRetainConfig, title="Record retention settings."
|
||||||
)
|
)
|
||||||
@ -387,11 +393,13 @@ class BirdseyeCameraConfig(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
FFMPEG_GLOBAL_ARGS_DEFAULT = ["-hide_banner", "-loglevel", "warning", "-threads", "1"]
|
# Note: Setting threads to less than 2 caused several issues with recording segments
|
||||||
|
# https://github.com/blakeblackshear/frigate/issues/5659
|
||||||
|
FFMPEG_GLOBAL_ARGS_DEFAULT = ["-hide_banner", "-loglevel", "warning", "-threads", "2"]
|
||||||
FFMPEG_INPUT_ARGS_DEFAULT = "preset-rtsp-generic"
|
FFMPEG_INPUT_ARGS_DEFAULT = "preset-rtsp-generic"
|
||||||
DETECT_FFMPEG_OUTPUT_ARGS_DEFAULT = [
|
DETECT_FFMPEG_OUTPUT_ARGS_DEFAULT = [
|
||||||
"-threads",
|
"-threads",
|
||||||
"1",
|
"2",
|
||||||
"-f",
|
"-f",
|
||||||
"rawvideo",
|
"rawvideo",
|
||||||
"-pix_fmt",
|
"-pix_fmt",
|
||||||
@ -723,9 +731,7 @@ class CameraConfig(FrigateBaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class DatabaseConfig(FrigateBaseModel):
|
class DatabaseConfig(FrigateBaseModel):
|
||||||
path: str = Field(
|
path: str = Field(default=DEFAULT_DB_PATH, title="Database path.")
|
||||||
default=os.path.join(BASE_DIR, "frigate.db"), title="Database path."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class LogLevelEnum(str, Enum):
|
class LogLevelEnum(str, Enum):
|
||||||
@ -775,16 +781,6 @@ def verify_valid_live_stream_name(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def verify_old_retain_config(camera_config: CameraConfig) -> None:
|
|
||||||
"""Leave log if old retain_days is used."""
|
|
||||||
if not camera_config.record.retain_days is None:
|
|
||||||
logger.warning(
|
|
||||||
"The 'retain_days' config option has been DEPRECATED and will be removed in a future version. Please use the 'days' setting under 'retain'"
|
|
||||||
)
|
|
||||||
if camera_config.record.retain.days == 0:
|
|
||||||
camera_config.record.retain.days = camera_config.record.retain_days
|
|
||||||
|
|
||||||
|
|
||||||
def verify_recording_retention(camera_config: CameraConfig) -> None:
|
def verify_recording_retention(camera_config: CameraConfig) -> None:
|
||||||
"""Verify that recording retention modes are ranked correctly."""
|
"""Verify that recording retention modes are ranked correctly."""
|
||||||
rank_map = {
|
rank_map = {
|
||||||
@ -991,7 +987,6 @@ class FrigateConfig(FrigateBaseModel):
|
|||||||
|
|
||||||
verify_config_roles(camera_config)
|
verify_config_roles(camera_config)
|
||||||
verify_valid_live_stream_name(config, camera_config)
|
verify_valid_live_stream_name(config, camera_config)
|
||||||
verify_old_retain_config(camera_config)
|
|
||||||
verify_recording_retention(camera_config)
|
verify_recording_retention(camera_config)
|
||||||
verify_recording_segments_setup_with_reasonable_time(camera_config)
|
verify_recording_segments_setup_with_reasonable_time(camera_config)
|
||||||
verify_zone_objects_are_tracked(camera_config)
|
verify_zone_objects_are_tracked(camera_config)
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
CONFIG_DIR = "/config"
|
||||||
|
DEFAULT_DB_PATH = f"{CONFIG_DIR}/frigate.db"
|
||||||
BASE_DIR = "/media/frigate"
|
BASE_DIR = "/media/frigate"
|
||||||
CLIPS_DIR = f"{BASE_DIR}/clips"
|
CLIPS_DIR = f"{BASE_DIR}/clips"
|
||||||
RECORD_DIR = f"{BASE_DIR}/recordings"
|
RECORD_DIR = f"{BASE_DIR}/recordings"
|
||||||
|
|||||||
@ -5,7 +5,11 @@ from frigate.detectors.detection_api import DetectionApi
|
|||||||
from frigate.detectors.detector_config import BaseDetectorConfig
|
from frigate.detectors.detector_config import BaseDetectorConfig
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
from pydantic import Extra, Field
|
from pydantic import Extra, Field
|
||||||
import tflite_runtime.interpreter as tflite
|
|
||||||
|
try:
|
||||||
|
from tflite_runtime.interpreter import Interpreter
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
from tensorflow.lite.python.interpreter import Interpreter
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -22,7 +26,7 @@ class CpuTfl(DetectionApi):
|
|||||||
type_key = DETECTOR_KEY
|
type_key = DETECTOR_KEY
|
||||||
|
|
||||||
def __init__(self, detector_config: CpuDetectorConfig):
|
def __init__(self, detector_config: CpuDetectorConfig):
|
||||||
self.interpreter = tflite.Interpreter(
|
self.interpreter = Interpreter(
|
||||||
model_path=detector_config.model.path or "/cpu_model.tflite",
|
model_path=detector_config.model.path or "/cpu_model.tflite",
|
||||||
num_threads=detector_config.num_threads or 3,
|
num_threads=detector_config.num_threads or 3,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -5,8 +5,11 @@ from frigate.detectors.detection_api import DetectionApi
|
|||||||
from frigate.detectors.detector_config import BaseDetectorConfig
|
from frigate.detectors.detector_config import BaseDetectorConfig
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
from pydantic import Extra, Field
|
from pydantic import Extra, Field
|
||||||
import tflite_runtime.interpreter as tflite
|
|
||||||
from tflite_runtime.interpreter import load_delegate
|
try:
|
||||||
|
from tflite_runtime.interpreter import Interpreter, load_delegate
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
from tensorflow.lite.python.interpreter import Interpreter, load_delegate
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -33,7 +36,7 @@ class EdgeTpuTfl(DetectionApi):
|
|||||||
logger.info(f"Attempting to load TPU as {device_config['device']}")
|
logger.info(f"Attempting to load TPU as {device_config['device']}")
|
||||||
edge_tpu_delegate = load_delegate("libedgetpu.so.1.0", device_config)
|
edge_tpu_delegate = load_delegate("libedgetpu.so.1.0", device_config)
|
||||||
logger.info("TPU found")
|
logger.info("TPU found")
|
||||||
self.interpreter = tflite.Interpreter(
|
self.interpreter = Interpreter(
|
||||||
model_path=detector_config.model.path or "/edgetpu_model.tflite",
|
model_path=detector_config.model.path or "/edgetpu_model.tflite",
|
||||||
experimental_delegates=[edge_tpu_delegate],
|
experimental_delegates=[edge_tpu_delegate],
|
||||||
)
|
)
|
||||||
|
|||||||
@ -3,14 +3,14 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
import threading
|
import threading
|
||||||
import time
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from peewee import fn
|
from peewee import fn
|
||||||
|
|
||||||
from frigate.config import EventsConfig, FrigateConfig, RecordConfig
|
from frigate.config import EventsConfig, FrigateConfig
|
||||||
from frigate.const import CLIPS_DIR
|
from frigate.const import CLIPS_DIR
|
||||||
from frigate.models import Event
|
from frigate.models import Event
|
||||||
|
from frigate.timeline import TimelineSourceEnum
|
||||||
from frigate.types import CameraMetricsTypes
|
from frigate.types import CameraMetricsTypes
|
||||||
|
|
||||||
from multiprocessing.queues import Queue
|
from multiprocessing.queues import Queue
|
||||||
@ -48,6 +48,7 @@ class EventProcessor(threading.Thread):
|
|||||||
camera_processes: dict[str, CameraMetricsTypes],
|
camera_processes: dict[str, CameraMetricsTypes],
|
||||||
event_queue: Queue,
|
event_queue: Queue,
|
||||||
event_processed_queue: Queue,
|
event_processed_queue: Queue,
|
||||||
|
timeline_queue: Queue,
|
||||||
stop_event: MpEvent,
|
stop_event: MpEvent,
|
||||||
):
|
):
|
||||||
threading.Thread.__init__(self)
|
threading.Thread.__init__(self)
|
||||||
@ -56,6 +57,7 @@ class EventProcessor(threading.Thread):
|
|||||||
self.camera_processes = camera_processes
|
self.camera_processes = camera_processes
|
||||||
self.event_queue = event_queue
|
self.event_queue = event_queue
|
||||||
self.event_processed_queue = event_processed_queue
|
self.event_processed_queue = event_processed_queue
|
||||||
|
self.timeline_queue = timeline_queue
|
||||||
self.events_in_process: Dict[str, Event] = {}
|
self.events_in_process: Dict[str, Event] = {}
|
||||||
self.stop_event = stop_event
|
self.stop_event = stop_event
|
||||||
|
|
||||||
@ -73,6 +75,16 @@ class EventProcessor(threading.Thread):
|
|||||||
|
|
||||||
logger.debug(f"Event received: {event_type} {camera} {event_data['id']}")
|
logger.debug(f"Event received: {event_type} {camera} {event_data['id']}")
|
||||||
|
|
||||||
|
self.timeline_queue.put(
|
||||||
|
(
|
||||||
|
camera,
|
||||||
|
TimelineSourceEnum.tracked_object,
|
||||||
|
event_type,
|
||||||
|
self.events_in_process.get(event_data["id"]),
|
||||||
|
event_data,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
event_config: EventsConfig = self.config.cameras[camera].record.events
|
event_config: EventsConfig = self.config.cameras[camera].record.events
|
||||||
|
|
||||||
if event_type == "start":
|
if event_type == "start":
|
||||||
|
|||||||
110
frigate/http.py
110
frigate/http.py
@ -35,7 +35,7 @@ from playhouse.shortcuts import model_to_dict
|
|||||||
|
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.const import CLIPS_DIR, MAX_SEGMENT_DURATION, RECORD_DIR
|
from frigate.const import CLIPS_DIR, MAX_SEGMENT_DURATION, RECORD_DIR
|
||||||
from frigate.models import Event, Recordings
|
from frigate.models import Event, Recordings, Timeline
|
||||||
from frigate.object_processing import TrackedObject
|
from frigate.object_processing import TrackedObject
|
||||||
from frigate.monitoring.prometheus import setupRegistry
|
from frigate.monitoring.prometheus import setupRegistry
|
||||||
|
|
||||||
@ -119,6 +119,7 @@ def events_summary():
|
|||||||
Event.select(
|
Event.select(
|
||||||
Event.camera,
|
Event.camera,
|
||||||
Event.label,
|
Event.label,
|
||||||
|
Event.sub_label,
|
||||||
fn.strftime(
|
fn.strftime(
|
||||||
"%Y-%m-%d",
|
"%Y-%m-%d",
|
||||||
fn.datetime(
|
fn.datetime(
|
||||||
@ -132,6 +133,7 @@ def events_summary():
|
|||||||
.group_by(
|
.group_by(
|
||||||
Event.camera,
|
Event.camera,
|
||||||
Event.label,
|
Event.label,
|
||||||
|
Event.sub_label,
|
||||||
fn.strftime(
|
fn.strftime(
|
||||||
"%Y-%m-%d",
|
"%Y-%m-%d",
|
||||||
fn.datetime(
|
fn.datetime(
|
||||||
@ -330,7 +332,9 @@ def get_sub_labels():
|
|||||||
sub_labels.remove(None)
|
sub_labels.remove(None)
|
||||||
|
|
||||||
if split_joined:
|
if split_joined:
|
||||||
for label in sub_labels:
|
original_labels = sub_labels.copy()
|
||||||
|
|
||||||
|
for label in original_labels:
|
||||||
if "," in label:
|
if "," in label:
|
||||||
sub_labels.remove(label)
|
sub_labels.remove(label)
|
||||||
parts = label.split(",")
|
parts = label.split(",")
|
||||||
@ -339,6 +343,7 @@ def get_sub_labels():
|
|||||||
if not (part.strip()) in sub_labels:
|
if not (part.strip()) in sub_labels:
|
||||||
sub_labels.append(part.strip())
|
sub_labels.append(part.strip())
|
||||||
|
|
||||||
|
sub_labels.sort()
|
||||||
return jsonify(sub_labels)
|
return jsonify(sub_labels)
|
||||||
|
|
||||||
|
|
||||||
@ -417,6 +422,42 @@ def event_thumbnail(id, max_cache_age=2592000):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/timeline")
|
||||||
|
def timeline():
|
||||||
|
camera = request.args.get("camera", "all")
|
||||||
|
source_id = request.args.get("source_id", type=str)
|
||||||
|
limit = request.args.get("limit", 100)
|
||||||
|
|
||||||
|
clauses = []
|
||||||
|
|
||||||
|
selected_columns = [
|
||||||
|
Timeline.timestamp,
|
||||||
|
Timeline.camera,
|
||||||
|
Timeline.source,
|
||||||
|
Timeline.source_id,
|
||||||
|
Timeline.class_type,
|
||||||
|
Timeline.data,
|
||||||
|
]
|
||||||
|
|
||||||
|
if camera != "all":
|
||||||
|
clauses.append((Timeline.camera == camera))
|
||||||
|
|
||||||
|
if source_id:
|
||||||
|
clauses.append((Timeline.source_id == source_id))
|
||||||
|
|
||||||
|
if len(clauses) == 0:
|
||||||
|
clauses.append((True))
|
||||||
|
|
||||||
|
timeline = (
|
||||||
|
Timeline.select(*selected_columns)
|
||||||
|
.where(reduce(operator.and_, clauses))
|
||||||
|
.order_by(Timeline.timestamp.asc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify([model_to_dict(t) for t in timeline])
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/<camera_name>/<label>/best.jpg")
|
@bp.route("/<camera_name>/<label>/best.jpg")
|
||||||
@bp.route("/<camera_name>/<label>/thumbnail.jpg")
|
@bp.route("/<camera_name>/<label>/thumbnail.jpg")
|
||||||
def label_thumbnail(camera_name, label):
|
def label_thumbnail(camera_name, label):
|
||||||
@ -646,7 +687,13 @@ def events():
|
|||||||
sub_label_clauses.append((Event.sub_label.is_null()))
|
sub_label_clauses.append((Event.sub_label.is_null()))
|
||||||
|
|
||||||
for label in filtered_sub_labels:
|
for label in filtered_sub_labels:
|
||||||
sub_label_clauses.append((Event.sub_label.cast("text") % f"*{label}*"))
|
sub_label_clauses.append(
|
||||||
|
(Event.sub_label.cast("text") == label)
|
||||||
|
) # include exact matches
|
||||||
|
|
||||||
|
# include this label when part of a list
|
||||||
|
sub_label_clauses.append((Event.sub_label.cast("text") % f"*{label},*"))
|
||||||
|
sub_label_clauses.append((Event.sub_label.cast("text") % f"*, {label}*"))
|
||||||
|
|
||||||
sub_label_clause = reduce(operator.or_, sub_label_clauses)
|
sub_label_clause = reduce(operator.or_, sub_label_clauses)
|
||||||
clauses.append((sub_label_clause))
|
clauses.append((sub_label_clause))
|
||||||
@ -921,6 +968,53 @@ def latest_frame(camera_name):
|
|||||||
return "Camera named {} not found".format(camera_name), 404
|
return "Camera named {} not found".format(camera_name), 404
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/<camera_name>/recordings/<frame_time>/snapshot.png")
|
||||||
|
def get_snapshot_from_recording(camera_name: str, frame_time: str):
|
||||||
|
if camera_name not in current_app.frigate_config.cameras:
|
||||||
|
return "Camera named {} not found".format(camera_name), 404
|
||||||
|
|
||||||
|
frame_time = float(frame_time)
|
||||||
|
recording_query = (
|
||||||
|
Recordings.select()
|
||||||
|
.where(
|
||||||
|
((frame_time > Recordings.start_time) & (frame_time < Recordings.end_time))
|
||||||
|
)
|
||||||
|
.where(Recordings.camera == camera_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
recording: Recordings = recording_query.get()
|
||||||
|
time_in_segment = frame_time - recording.start_time
|
||||||
|
|
||||||
|
ffmpeg_cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"warning",
|
||||||
|
"-ss",
|
||||||
|
f"00:00:{time_in_segment}",
|
||||||
|
"-i",
|
||||||
|
recording.path,
|
||||||
|
"-frames:v",
|
||||||
|
"1",
|
||||||
|
"-c:v",
|
||||||
|
"png",
|
||||||
|
"-f",
|
||||||
|
"image2pipe",
|
||||||
|
"-",
|
||||||
|
]
|
||||||
|
|
||||||
|
process = sp.run(
|
||||||
|
ffmpeg_cmd,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
response = make_response(process.stdout)
|
||||||
|
response.headers["Content-Type"] = "image/png"
|
||||||
|
return response
|
||||||
|
except DoesNotExist:
|
||||||
|
return "Recording not found for {} at {}".format(camera_name, frame_time), 404
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/recordings/storage", methods=["GET"])
|
@bp.route("/recordings/storage", methods=["GET"])
|
||||||
def get_recordings_storage_usage():
|
def get_recordings_storage_usage():
|
||||||
recording_stats = stats_snapshot(
|
recording_stats = stats_snapshot(
|
||||||
@ -1304,12 +1398,12 @@ def ffprobe():
|
|||||||
output.append(
|
output.append(
|
||||||
{
|
{
|
||||||
"return_code": ffprobe.returncode,
|
"return_code": ffprobe.returncode,
|
||||||
"stderr": json.loads(ffprobe.stderr.decode("unicode_escape").strip())
|
"stderr": ffprobe.stderr.decode("unicode_escape").strip()
|
||||||
if ffprobe.stderr.decode()
|
if ffprobe.returncode != 0
|
||||||
else {},
|
else "",
|
||||||
"stdout": json.loads(ffprobe.stdout.decode("unicode_escape").strip())
|
"stdout": json.loads(ffprobe.stdout.decode("unicode_escape").strip())
|
||||||
if ffprobe.stdout.decode()
|
if ffprobe.returncode == 0
|
||||||
else {},
|
else "",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -32,6 +32,15 @@ class Event(Model): # type: ignore[misc]
|
|||||||
plus_id = CharField(max_length=30)
|
plus_id = CharField(max_length=30)
|
||||||
|
|
||||||
|
|
||||||
|
class Timeline(Model): # type: ignore[misc]
|
||||||
|
timestamp = DateTimeField()
|
||||||
|
camera = CharField(index=True, max_length=20)
|
||||||
|
source = CharField(index=True, max_length=20) # ex: tracked object, audio, external
|
||||||
|
source_id = CharField(index=True, max_length=30)
|
||||||
|
class_type = CharField(max_length=50) # ex: entered_zone, audio_heard
|
||||||
|
data = JSONField() # ex: tracked object id, region, box, etc.
|
||||||
|
|
||||||
|
|
||||||
class Recordings(Model): # type: ignore[misc]
|
class Recordings(Model): # type: ignore[misc]
|
||||||
id = CharField(null=False, primary_key=True, max_length=30)
|
id = CharField(null=False, primary_key=True, max_length=30)
|
||||||
camera = CharField(index=True, max_length=20)
|
camera = CharField(index=True, max_length=20)
|
||||||
|
|||||||
@ -23,7 +23,6 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
def get_latest_version(config: FrigateConfig) -> str:
|
def get_latest_version(config: FrigateConfig) -> str:
|
||||||
|
|
||||||
if not config.telemetry.version_check:
|
if not config.telemetry.version_check:
|
||||||
return "disabled"
|
return "disabled"
|
||||||
|
|
||||||
|
|||||||
@ -20,8 +20,8 @@ class MotionDetector:
|
|||||||
config.frame_height,
|
config.frame_height,
|
||||||
config.frame_height * frame_shape[1] // frame_shape[0],
|
config.frame_height * frame_shape[1] // frame_shape[0],
|
||||||
)
|
)
|
||||||
self.avg_frame = np.zeros(self.motion_frame_size, np.float)
|
self.avg_frame = np.zeros(self.motion_frame_size, np.float32)
|
||||||
self.avg_delta = np.zeros(self.motion_frame_size, np.float)
|
self.avg_delta = np.zeros(self.motion_frame_size, np.float32)
|
||||||
self.motion_frame_count = 0
|
self.motion_frame_count = 0
|
||||||
self.frame_counter = 0
|
self.frame_counter = 0
|
||||||
resized_mask = cv2.resize(
|
resized_mask = cv2.resize(
|
||||||
|
|||||||
@ -59,6 +59,3 @@ ignore_errors = false
|
|||||||
[mypy-frigate.watchdog]
|
[mypy-frigate.watchdog]
|
||||||
ignore_errors = false
|
ignore_errors = false
|
||||||
disallow_untyped_calls = false
|
disallow_untyped_calls = false
|
||||||
|
|
||||||
[mypy-frigate.zeroconf]
|
|
||||||
ignore_errors = false
|
|
||||||
|
|||||||
@ -329,7 +329,6 @@ class BirdsEyeFrameManager:
|
|||||||
|
|
||||||
# update each position in the layout
|
# update each position in the layout
|
||||||
for position, camera in enumerate(self.camera_layout, start=0):
|
for position, camera in enumerate(self.camera_layout, start=0):
|
||||||
|
|
||||||
# if this camera was removed, replace it or clear it
|
# if this camera was removed, replace it or clear it
|
||||||
if camera in removed_cameras:
|
if camera in removed_cameras:
|
||||||
# if replacing this camera with a newly added one
|
# if replacing this camera with a newly added one
|
||||||
|
|||||||
@ -111,7 +111,6 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
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():
|
||||||
|
|
||||||
# clear out all the recording info for old frames
|
# clear out all the recording info for old frames
|
||||||
while (
|
while (
|
||||||
len(self.recordings_info[camera]) > 0
|
len(self.recordings_info[camera]) > 0
|
||||||
|
|||||||
@ -179,7 +179,6 @@ class StorageMaintainer(threading.Thread):
|
|||||||
def run(self):
|
def run(self):
|
||||||
"""Check every 5 minutes if storage needs to be cleaned up."""
|
"""Check every 5 minutes if storage needs to be cleaned up."""
|
||||||
while not self.stop_event.wait(300):
|
while not self.stop_event.wait(300):
|
||||||
|
|
||||||
if not self.camera_storage_stats or True in [
|
if not self.camera_storage_stats or True in [
|
||||||
r["needs_refresh"] for r in self.camera_storage_stats.values()
|
r["needs_refresh"] for r in self.camera_storage_stats.values()
|
||||||
]:
|
]:
|
||||||
|
|||||||
@ -36,3 +36,14 @@ class TestUserPassCleanup(unittest.TestCase):
|
|||||||
"""Test that no change is made to path with no special characters."""
|
"""Test that no change is made to path with no special characters."""
|
||||||
escaped = escape_special_characters(self.rtsp_with_pass)
|
escaped = escape_special_characters(self.rtsp_with_pass)
|
||||||
assert escaped == self.rtsp_with_pass
|
assert escaped == self.rtsp_with_pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserPassMasking(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.rtsp_log_message = "Did you mean file:rtsp://user:password@192.168.1.3:554"
|
||||||
|
|
||||||
|
def test_rtsp_in_log_message(self):
|
||||||
|
"""Test that the rtsp url in a log message is espaced."""
|
||||||
|
escaped = clean_camera_user_pass(self.rtsp_log_message)
|
||||||
|
print(f"The escaped is {escaped}")
|
||||||
|
assert escaped == "Did you mean file:rtsp://*:*@192.168.1.3:554"
|
||||||
|
|||||||
@ -673,7 +673,6 @@ class TestConfig(unittest.TestCase):
|
|||||||
assert runtime_config.cameras["back"].detect.max_disappeared == 5 * 5
|
assert runtime_config.cameras["back"].detect.max_disappeared == 5 * 5
|
||||||
|
|
||||||
def test_motion_frame_height_wont_go_below_120(self):
|
def test_motion_frame_height_wont_go_below_120(self):
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"cameras": {
|
"cameras": {
|
||||||
@ -702,7 +701,6 @@ class TestConfig(unittest.TestCase):
|
|||||||
assert runtime_config.cameras["back"].motion.frame_height == 50
|
assert runtime_config.cameras["back"].motion.frame_height == 50
|
||||||
|
|
||||||
def test_motion_contour_area_dynamic(self):
|
def test_motion_contour_area_dynamic(self):
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"cameras": {
|
"cameras": {
|
||||||
@ -731,7 +729,6 @@ class TestConfig(unittest.TestCase):
|
|||||||
assert round(runtime_config.cameras["back"].motion.contour_area) == 30
|
assert round(runtime_config.cameras["back"].motion.contour_area) == 30
|
||||||
|
|
||||||
def test_merge_labelmap(self):
|
def test_merge_labelmap(self):
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"model": {"labelmap": {7: "truck"}},
|
"model": {"labelmap": {7: "truck"}},
|
||||||
@ -761,7 +758,6 @@ class TestConfig(unittest.TestCase):
|
|||||||
assert runtime_config.model.merged_labelmap[7] == "truck"
|
assert runtime_config.model.merged_labelmap[7] == "truck"
|
||||||
|
|
||||||
def test_default_labelmap_empty(self):
|
def test_default_labelmap_empty(self):
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"cameras": {
|
"cameras": {
|
||||||
@ -790,7 +786,6 @@ class TestConfig(unittest.TestCase):
|
|||||||
assert runtime_config.model.merged_labelmap[0] == "person"
|
assert runtime_config.model.merged_labelmap[0] == "person"
|
||||||
|
|
||||||
def test_default_labelmap(self):
|
def test_default_labelmap(self):
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"model": {"width": 320, "height": 320},
|
"model": {"width": 320, "height": 320},
|
||||||
@ -820,7 +815,6 @@ class TestConfig(unittest.TestCase):
|
|||||||
assert runtime_config.model.merged_labelmap[0] == "person"
|
assert runtime_config.model.merged_labelmap[0] == "person"
|
||||||
|
|
||||||
def test_fails_on_invalid_role(self):
|
def test_fails_on_invalid_role(self):
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"cameras": {
|
"cameras": {
|
||||||
@ -849,7 +843,6 @@ class TestConfig(unittest.TestCase):
|
|||||||
self.assertRaises(ValidationError, lambda: FrigateConfig(**config))
|
self.assertRaises(ValidationError, lambda: FrigateConfig(**config))
|
||||||
|
|
||||||
def test_fails_on_missing_role(self):
|
def test_fails_on_missing_role(self):
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"cameras": {
|
"cameras": {
|
||||||
@ -880,7 +873,6 @@ class TestConfig(unittest.TestCase):
|
|||||||
self.assertRaises(ValueError, lambda: frigate_config.runtime_config)
|
self.assertRaises(ValueError, lambda: frigate_config.runtime_config)
|
||||||
|
|
||||||
def test_works_on_missing_role_multiple_cams(self):
|
def test_works_on_missing_role_multiple_cams(self):
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"cameras": {
|
"cameras": {
|
||||||
@ -929,7 +921,6 @@ class TestConfig(unittest.TestCase):
|
|||||||
runtime_config = frigate_config.runtime_config
|
runtime_config = frigate_config.runtime_config
|
||||||
|
|
||||||
def test_global_detect(self):
|
def test_global_detect(self):
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"detect": {"max_disappeared": 1},
|
"detect": {"max_disappeared": 1},
|
||||||
@ -959,7 +950,6 @@ class TestConfig(unittest.TestCase):
|
|||||||
assert runtime_config.cameras["back"].detect.height == 1080
|
assert runtime_config.cameras["back"].detect.height == 1080
|
||||||
|
|
||||||
def test_default_detect(self):
|
def test_default_detect(self):
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"cameras": {
|
"cameras": {
|
||||||
@ -983,7 +973,6 @@ class TestConfig(unittest.TestCase):
|
|||||||
assert runtime_config.cameras["back"].detect.height == 720
|
assert runtime_config.cameras["back"].detect.height == 720
|
||||||
|
|
||||||
def test_global_detect_merge(self):
|
def test_global_detect_merge(self):
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"detect": {"max_disappeared": 1, "height": 720},
|
"detect": {"max_disappeared": 1, "height": 720},
|
||||||
@ -1014,7 +1003,6 @@ class TestConfig(unittest.TestCase):
|
|||||||
assert runtime_config.cameras["back"].detect.width == 1920
|
assert runtime_config.cameras["back"].detect.width == 1920
|
||||||
|
|
||||||
def test_global_snapshots(self):
|
def test_global_snapshots(self):
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"snapshots": {"enabled": True},
|
"snapshots": {"enabled": True},
|
||||||
@ -1042,7 +1030,6 @@ class TestConfig(unittest.TestCase):
|
|||||||
assert runtime_config.cameras["back"].snapshots.height == 100
|
assert runtime_config.cameras["back"].snapshots.height == 100
|
||||||
|
|
||||||
def test_default_snapshots(self):
|
def test_default_snapshots(self):
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"cameras": {
|
"cameras": {
|
||||||
@ -1066,7 +1053,6 @@ class TestConfig(unittest.TestCase):
|
|||||||
assert runtime_config.cameras["back"].snapshots.quality == 70
|
assert runtime_config.cameras["back"].snapshots.quality == 70
|
||||||
|
|
||||||
def test_global_snapshots_merge(self):
|
def test_global_snapshots_merge(self):
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"snapshots": {"bounding_box": False, "height": 300},
|
"snapshots": {"bounding_box": False, "height": 300},
|
||||||
@ -1096,7 +1082,6 @@ class TestConfig(unittest.TestCase):
|
|||||||
assert runtime_config.cameras["back"].snapshots.enabled
|
assert runtime_config.cameras["back"].snapshots.enabled
|
||||||
|
|
||||||
def test_global_rtmp_disabled(self):
|
def test_global_rtmp_disabled(self):
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"cameras": {
|
"cameras": {
|
||||||
@ -1119,7 +1104,6 @@ class TestConfig(unittest.TestCase):
|
|||||||
assert not runtime_config.cameras["back"].rtmp.enabled
|
assert not runtime_config.cameras["back"].rtmp.enabled
|
||||||
|
|
||||||
def test_default_not_rtmp(self):
|
def test_default_not_rtmp(self):
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"cameras": {
|
"cameras": {
|
||||||
@ -1142,7 +1126,6 @@ class TestConfig(unittest.TestCase):
|
|||||||
assert not runtime_config.cameras["back"].rtmp.enabled
|
assert not runtime_config.cameras["back"].rtmp.enabled
|
||||||
|
|
||||||
def test_global_rtmp_merge(self):
|
def test_global_rtmp_merge(self):
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"rtmp": {"enabled": False},
|
"rtmp": {"enabled": False},
|
||||||
@ -1169,7 +1152,6 @@ class TestConfig(unittest.TestCase):
|
|||||||
assert runtime_config.cameras["back"].rtmp.enabled
|
assert runtime_config.cameras["back"].rtmp.enabled
|
||||||
|
|
||||||
def test_global_rtmp_default(self):
|
def test_global_rtmp_default(self):
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"cameras": {
|
"cameras": {
|
||||||
@ -1196,7 +1178,6 @@ class TestConfig(unittest.TestCase):
|
|||||||
assert not runtime_config.cameras["back"].rtmp.enabled
|
assert not runtime_config.cameras["back"].rtmp.enabled
|
||||||
|
|
||||||
def test_global_jsmpeg(self):
|
def test_global_jsmpeg(self):
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"live": {"quality": 4},
|
"live": {"quality": 4},
|
||||||
@ -1220,7 +1201,6 @@ class TestConfig(unittest.TestCase):
|
|||||||
assert runtime_config.cameras["back"].live.quality == 4
|
assert runtime_config.cameras["back"].live.quality == 4
|
||||||
|
|
||||||
def test_default_live(self):
|
def test_default_live(self):
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"cameras": {
|
"cameras": {
|
||||||
@ -1243,7 +1223,6 @@ class TestConfig(unittest.TestCase):
|
|||||||
assert runtime_config.cameras["back"].live.quality == 8
|
assert runtime_config.cameras["back"].live.quality == 8
|
||||||
|
|
||||||
def test_global_live_merge(self):
|
def test_global_live_merge(self):
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"live": {"quality": 4, "height": 480},
|
"live": {"quality": 4, "height": 480},
|
||||||
@ -1271,7 +1250,6 @@ class TestConfig(unittest.TestCase):
|
|||||||
assert runtime_config.cameras["back"].live.height == 480
|
assert runtime_config.cameras["back"].live.height == 480
|
||||||
|
|
||||||
def test_global_timestamp_style(self):
|
def test_global_timestamp_style(self):
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"timestamp_style": {"position": "bl"},
|
"timestamp_style": {"position": "bl"},
|
||||||
@ -1295,7 +1273,6 @@ class TestConfig(unittest.TestCase):
|
|||||||
assert runtime_config.cameras["back"].timestamp_style.position == "bl"
|
assert runtime_config.cameras["back"].timestamp_style.position == "bl"
|
||||||
|
|
||||||
def test_default_timestamp_style(self):
|
def test_default_timestamp_style(self):
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"cameras": {
|
"cameras": {
|
||||||
@ -1318,7 +1295,6 @@ class TestConfig(unittest.TestCase):
|
|||||||
assert runtime_config.cameras["back"].timestamp_style.position == "tl"
|
assert runtime_config.cameras["back"].timestamp_style.position == "tl"
|
||||||
|
|
||||||
def test_global_timestamp_style_merge(self):
|
def test_global_timestamp_style_merge(self):
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"rtmp": {"enabled": False},
|
"rtmp": {"enabled": False},
|
||||||
@ -1345,7 +1321,6 @@ class TestConfig(unittest.TestCase):
|
|||||||
assert runtime_config.cameras["back"].timestamp_style.thickness == 4
|
assert runtime_config.cameras["back"].timestamp_style.thickness == 4
|
||||||
|
|
||||||
def test_allow_retain_to_be_a_decimal(self):
|
def test_allow_retain_to_be_a_decimal(self):
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"snapshots": {"retain": {"default": 1.5}},
|
"snapshots": {"retain": {"default": 1.5}},
|
||||||
|
|||||||
140
frigate/timeline.py
Normal file
140
frigate/timeline.py
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
"""Record events for object, audio, etc. detections."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import queue
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from frigate.config import FrigateConfig
|
||||||
|
from frigate.models import Timeline
|
||||||
|
|
||||||
|
from multiprocessing.queues import Queue
|
||||||
|
from multiprocessing.synchronize import Event as MpEvent
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TimelineSourceEnum(str, Enum):
|
||||||
|
# api = "api"
|
||||||
|
# audio = "audio"
|
||||||
|
tracked_object = "tracked_object"
|
||||||
|
|
||||||
|
|
||||||
|
class TimelineProcessor(threading.Thread):
|
||||||
|
"""Handle timeline queue and update DB."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: FrigateConfig,
|
||||||
|
queue: Queue,
|
||||||
|
stop_event: MpEvent,
|
||||||
|
) -> None:
|
||||||
|
threading.Thread.__init__(self)
|
||||||
|
self.name = "timeline_processor"
|
||||||
|
self.config = config
|
||||||
|
self.queue = queue
|
||||||
|
self.stop_event = stop_event
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
while not self.stop_event.is_set():
|
||||||
|
try:
|
||||||
|
(
|
||||||
|
camera,
|
||||||
|
input_type,
|
||||||
|
event_type,
|
||||||
|
prev_event_data,
|
||||||
|
event_data,
|
||||||
|
) = self.queue.get(timeout=1)
|
||||||
|
except queue.Empty:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if input_type == TimelineSourceEnum.tracked_object:
|
||||||
|
self.handle_object_detection(
|
||||||
|
camera, event_type, prev_event_data, event_data
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle_object_detection(
|
||||||
|
self,
|
||||||
|
camera: str,
|
||||||
|
event_type: str,
|
||||||
|
prev_event_data: dict[any, any],
|
||||||
|
event_data: dict[any, any],
|
||||||
|
) -> None:
|
||||||
|
"""Handle object detection."""
|
||||||
|
camera_config = self.config.cameras[camera]
|
||||||
|
|
||||||
|
if event_type == "start":
|
||||||
|
Timeline.insert(
|
||||||
|
timestamp=event_data["frame_time"],
|
||||||
|
camera=camera,
|
||||||
|
source="tracked_object",
|
||||||
|
source_id=event_data["id"],
|
||||||
|
class_type="visible",
|
||||||
|
data={
|
||||||
|
"box": [
|
||||||
|
event_data["box"][0] / camera_config.detect.width,
|
||||||
|
event_data["box"][1] / camera_config.detect.height,
|
||||||
|
event_data["box"][2] / camera_config.detect.width,
|
||||||
|
event_data["box"][3] / camera_config.detect.height,
|
||||||
|
],
|
||||||
|
"label": event_data["label"],
|
||||||
|
"region": [
|
||||||
|
event_data["region"][0] / camera_config.detect.width,
|
||||||
|
event_data["region"][1] / camera_config.detect.height,
|
||||||
|
event_data["region"][2] / camera_config.detect.width,
|
||||||
|
event_data["region"][3] / camera_config.detect.height,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
).execute()
|
||||||
|
elif (
|
||||||
|
event_type == "update"
|
||||||
|
and prev_event_data["current_zones"] != event_data["current_zones"]
|
||||||
|
and len(event_data["current_zones"]) > 0
|
||||||
|
):
|
||||||
|
Timeline.insert(
|
||||||
|
timestamp=event_data["frame_time"],
|
||||||
|
camera=camera,
|
||||||
|
source="tracked_object",
|
||||||
|
source_id=event_data["id"],
|
||||||
|
class_type="entered_zone",
|
||||||
|
data={
|
||||||
|
"box": [
|
||||||
|
event_data["box"][0] / camera_config.detect.width,
|
||||||
|
event_data["box"][1] / camera_config.detect.height,
|
||||||
|
event_data["box"][2] / camera_config.detect.width,
|
||||||
|
event_data["box"][3] / camera_config.detect.height,
|
||||||
|
],
|
||||||
|
"label": event_data["label"],
|
||||||
|
"region": [
|
||||||
|
event_data["region"][0] / camera_config.detect.width,
|
||||||
|
event_data["region"][1] / camera_config.detect.height,
|
||||||
|
event_data["region"][2] / camera_config.detect.width,
|
||||||
|
event_data["region"][3] / camera_config.detect.height,
|
||||||
|
],
|
||||||
|
"zones": event_data["current_zones"],
|
||||||
|
},
|
||||||
|
).execute()
|
||||||
|
elif event_type == "end":
|
||||||
|
Timeline.insert(
|
||||||
|
timestamp=event_data["frame_time"],
|
||||||
|
camera=camera,
|
||||||
|
source="tracked_object",
|
||||||
|
source_id=event_data["id"],
|
||||||
|
class_type="gone",
|
||||||
|
data={
|
||||||
|
"box": [
|
||||||
|
event_data["box"][0] / camera_config.detect.width,
|
||||||
|
event_data["box"][1] / camera_config.detect.height,
|
||||||
|
event_data["box"][2] / camera_config.detect.width,
|
||||||
|
event_data["box"][3] / camera_config.detect.height,
|
||||||
|
],
|
||||||
|
"label": event_data["label"],
|
||||||
|
"region": [
|
||||||
|
event_data["region"][0] / camera_config.detect.width,
|
||||||
|
event_data["region"][1] / camera_config.detect.height,
|
||||||
|
event_data["region"][2] / camera_config.detect.width,
|
||||||
|
event_data["region"][3] / camera_config.detect.height,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
).execute()
|
||||||
@ -722,7 +722,7 @@ def load_labels(path, encoding="utf-8"):
|
|||||||
|
|
||||||
def clean_camera_user_pass(line: str) -> str:
|
def clean_camera_user_pass(line: str) -> str:
|
||||||
"""Removes user and password from line."""
|
"""Removes user and password from line."""
|
||||||
if line.startswith("rtsp://"):
|
if "rtsp://" in line:
|
||||||
return re.sub(REGEX_RTSP_CAMERA_USER_PASS, "://*:*@", line)
|
return re.sub(REGEX_RTSP_CAMERA_USER_PASS, "://*:*@", line)
|
||||||
else:
|
else:
|
||||||
return re.sub(REGEX_HTTP_CAMERA_USER_PASS, "user=*&password=*", line)
|
return re.sub(REGEX_HTTP_CAMERA_USER_PASS, "user=*&password=*", line)
|
||||||
@ -772,7 +772,6 @@ def get_docker_memlimit_bytes() -> int:
|
|||||||
|
|
||||||
# check running a supported cgroups version
|
# check running a supported cgroups version
|
||||||
if get_cgroups_version() == "cgroup2":
|
if get_cgroups_version() == "cgroup2":
|
||||||
|
|
||||||
memlimit_command = ["cat", "/sys/fs/cgroup/memory.max"]
|
memlimit_command = ["cat", "/sys/fs/cgroup/memory.max"]
|
||||||
|
|
||||||
p = sp.run(
|
p = sp.run(
|
||||||
@ -817,7 +816,6 @@ def get_cpu_stats() -> dict[str, dict]:
|
|||||||
for line in lines:
|
for line in lines:
|
||||||
stats = list(filter(lambda a: a != "", line.strip().split(" ")))
|
stats = list(filter(lambda a: a != "", line.strip().split(" ")))
|
||||||
try:
|
try:
|
||||||
|
|
||||||
if docker_memlimit > 0:
|
if docker_memlimit > 0:
|
||||||
mem_res = int(stats[5])
|
mem_res = int(stats[5])
|
||||||
mem_pct = str(
|
mem_pct = str(
|
||||||
|
|||||||
@ -104,7 +104,7 @@ def create_tensor_input(frame, model_config, region):
|
|||||||
if cropped_frame.shape != (model_config.height, model_config.width, 3):
|
if cropped_frame.shape != (model_config.height, model_config.width, 3):
|
||||||
cropped_frame = cv2.resize(
|
cropped_frame = cv2.resize(
|
||||||
cropped_frame,
|
cropped_frame,
|
||||||
dsize=(model_config.height, model_config.width),
|
dsize=(model_config.width, model_config.height),
|
||||||
interpolation=cv2.INTER_LINEAR,
|
interpolation=cv2.INTER_LINEAR,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -162,7 +162,6 @@ def capture_frames(
|
|||||||
current_frame: mp.Value,
|
current_frame: mp.Value,
|
||||||
stop_event: mp.Event,
|
stop_event: mp.Event,
|
||||||
):
|
):
|
||||||
|
|
||||||
frame_size = frame_shape[0] * frame_shape[1]
|
frame_size = frame_shape[0] * frame_shape[1]
|
||||||
frame_rate = EventsPerSecond()
|
frame_rate = EventsPerSecond()
|
||||||
frame_rate.start()
|
frame_rate.start()
|
||||||
@ -594,7 +593,6 @@ def process_frames(
|
|||||||
stop_event,
|
stop_event,
|
||||||
exit_on_empty: bool = False,
|
exit_on_empty: bool = False,
|
||||||
):
|
):
|
||||||
|
|
||||||
fps = process_info["process_fps"]
|
fps = process_info["process_fps"]
|
||||||
detection_fps = process_info["detection_fps"]
|
detection_fps = process_info["detection_fps"]
|
||||||
current_frame_time = process_info["detection_frame"]
|
current_frame_time = process_info["detection_frame"]
|
||||||
@ -748,7 +746,6 @@ def process_frames(
|
|||||||
|
|
||||||
selected_objects = []
|
selected_objects = []
|
||||||
for group in detected_object_groups.values():
|
for group in detected_object_groups.values():
|
||||||
|
|
||||||
# apply non-maxima suppression to suppress weak, overlapping bounding boxes
|
# apply non-maxima suppression to suppress weak, overlapping bounding boxes
|
||||||
# o[2] is the box of the object: xmin, ymin, xmax, ymax
|
# o[2] is the box of the object: xmin, ymin, xmax, ymax
|
||||||
# apply max/min to ensure values do not exceed the known frame size
|
# apply max/min to ensure values do not exceed the known frame size
|
||||||
|
|||||||
@ -1,62 +0,0 @@
|
|||||||
import logging
|
|
||||||
import socket
|
|
||||||
|
|
||||||
from zeroconf import (
|
|
||||||
ServiceInfo,
|
|
||||||
NonUniqueNameException,
|
|
||||||
InterfaceChoice,
|
|
||||||
IPVersion,
|
|
||||||
Zeroconf,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
ZEROCONF_TYPE = "_frigate._tcp.local."
|
|
||||||
|
|
||||||
# Taken from: http://stackoverflow.com/a/11735897
|
|
||||||
def get_local_ip() -> bytes:
|
|
||||||
"""Try to determine the local IP address of the machine."""
|
|
||||||
host_ip_str = ""
|
|
||||||
try:
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
|
|
||||||
# Use Google Public DNS server to determine own IP
|
|
||||||
sock.connect(("8.8.8.8", 80))
|
|
||||||
|
|
||||||
host_ip_str = sock.getsockname()[0]
|
|
||||||
except OSError:
|
|
||||||
try:
|
|
||||||
host_ip_str = socket.gethostbyname(socket.gethostname())
|
|
||||||
except socket.gaierror:
|
|
||||||
host_ip_str = "127.0.0.1"
|
|
||||||
finally:
|
|
||||||
sock.close()
|
|
||||||
|
|
||||||
try:
|
|
||||||
host_ip_pton = socket.inet_pton(socket.AF_INET, host_ip_str)
|
|
||||||
except OSError:
|
|
||||||
host_ip_pton = socket.inet_pton(socket.AF_INET6, host_ip_str)
|
|
||||||
|
|
||||||
return host_ip_pton
|
|
||||||
|
|
||||||
|
|
||||||
def broadcast_zeroconf(frigate_id: str) -> Zeroconf:
|
|
||||||
zeroconf = Zeroconf(interfaces=InterfaceChoice.Default, ip_version=IPVersion.V4Only)
|
|
||||||
|
|
||||||
host_ip = get_local_ip()
|
|
||||||
|
|
||||||
info = ServiceInfo(
|
|
||||||
ZEROCONF_TYPE,
|
|
||||||
name=f"{frigate_id}.{ZEROCONF_TYPE}",
|
|
||||||
addresses=[host_ip],
|
|
||||||
port=5000,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info("Starting Zeroconf broadcast")
|
|
||||||
try:
|
|
||||||
zeroconf.register_service(info)
|
|
||||||
except NonUniqueNameException:
|
|
||||||
logger.error(
|
|
||||||
"Frigate instance with identical name present in the local network"
|
|
||||||
)
|
|
||||||
return zeroconf
|
|
||||||
48
migrations/013_create_timeline_table.py
Normal file
48
migrations/013_create_timeline_table.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
"""Peewee migrations -- 013_create_timeline_table.py.
|
||||||
|
|
||||||
|
Some examples (model - class or model name)::
|
||||||
|
|
||||||
|
> Model = migrator.orm['model_name'] # Return model in current state by name
|
||||||
|
|
||||||
|
> migrator.sql(sql) # Run custom SQL
|
||||||
|
> migrator.python(func, *args, **kwargs) # Run python code
|
||||||
|
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||||
|
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||||
|
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||||
|
> migrator.change_fields(model, **fields) # Change fields
|
||||||
|
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||||
|
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||||
|
> migrator.rename_table(model, new_table_name)
|
||||||
|
> migrator.add_index(model, *col_names, unique=False)
|
||||||
|
> migrator.drop_index(model, *col_names)
|
||||||
|
> migrator.add_not_null(model, *field_names)
|
||||||
|
> migrator.drop_not_null(model, *field_names)
|
||||||
|
> migrator.add_default(model, field_name, default)
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import peewee as pw
|
||||||
|
from playhouse.sqlite_ext import *
|
||||||
|
from decimal import ROUND_HALF_EVEN
|
||||||
|
from frigate.models import Recordings
|
||||||
|
|
||||||
|
try:
|
||||||
|
import playhouse.postgres_ext as pw_pext
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
SQL = pw.SQL
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(migrator, database, fake=False, **kwargs):
|
||||||
|
migrator.sql(
|
||||||
|
'CREATE TABLE IF NOT EXISTS "timeline" ("timestamp" DATETIME NOT NULL, "camera" VARCHAR(20) NOT NULL, "source" VARCHAR(20) NOT NULL, "source_id" VARCHAR(30), "class_type" VARCHAR(50) NOT NULL, "data" JSON)'
|
||||||
|
)
|
||||||
|
migrator.sql('CREATE INDEX IF NOT EXISTS "timeline_camera" ON "timeline" ("camera")')
|
||||||
|
migrator.sql('CREATE INDEX IF NOT EXISTS "timeline_source" ON "timeline" ("source")')
|
||||||
|
migrator.sql('CREATE INDEX IF NOT EXISTS "timeline_source_id" ON "timeline" ("source_id")')
|
||||||
|
|
||||||
|
|
||||||
|
def rollback(migrator, database, fake=False, **kwargs):
|
||||||
|
pass
|
||||||
@ -1,2 +1,2 @@
|
|||||||
pylint == 2.15.*
|
pylint == 2.17.*
|
||||||
black == 22.12.*
|
black == 23.3.*
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
numpy == 1.19.*
|
numpy == 1.23.*
|
||||||
openvino == 2022.*
|
openvino == 2022.*
|
||||||
openvino-dev[tensorflow2] == 2022.*
|
openvino-dev[tensorflow2] == 2022.*
|
||||||
|
|||||||
@ -3,7 +3,7 @@ Flask == 2.2.*
|
|||||||
imutils == 0.5.*
|
imutils == 0.5.*
|
||||||
matplotlib == 3.6.*
|
matplotlib == 3.6.*
|
||||||
mypy == 0.942
|
mypy == 0.942
|
||||||
numpy == 1.22.*
|
numpy == 1.23.*
|
||||||
opencv-python-headless == 4.5.5.*
|
opencv-python-headless == 4.5.5.*
|
||||||
paho-mqtt == 1.6.*
|
paho-mqtt == 1.6.*
|
||||||
peewee == 3.15.*
|
peewee == 3.15.*
|
||||||
@ -11,12 +11,12 @@ peewee_migrate == 1.6.*
|
|||||||
psutil == 5.9.*
|
psutil == 5.9.*
|
||||||
pydantic == 1.10.*
|
pydantic == 1.10.*
|
||||||
PyYAML == 6.0
|
PyYAML == 6.0
|
||||||
pytz == 2022.7
|
pytz == 2023.3
|
||||||
tzlocal == 4.2
|
tzlocal == 4.2
|
||||||
types-PyYAML == 6.0.*
|
types-PyYAML == 6.0.*
|
||||||
requests == 2.28.*
|
requests == 2.28.*
|
||||||
types-requests == 2.28.*
|
types-requests == 2.28.*
|
||||||
scipy == 1.8.*
|
scipy == 1.10.*
|
||||||
setproctitle == 1.3.*
|
setproctitle == 1.3.*
|
||||||
ws4py == 0.5.*
|
ws4py == 0.5.*
|
||||||
zeroconf == 0.47.*
|
zeroconf == 0.47.*
|
||||||
@ -24,4 +24,3 @@ prometheus-client == 0.16.*
|
|||||||
# Openvino Library - Custom built with MYRIAD support
|
# Openvino Library - Custom built with MYRIAD support
|
||||||
openvino @ https://github.com/NateMeyer/openvino-wheels/releases/download/multi-arch_2022.2.0/openvino-2022.2.0-000-cp39-cp39-manylinux_2_31_x86_64.whl; platform_machine == 'x86_64'
|
openvino @ https://github.com/NateMeyer/openvino-wheels/releases/download/multi-arch_2022.2.0/openvino-2022.2.0-000-cp39-cp39-manylinux_2_31_x86_64.whl; platform_machine == 'x86_64'
|
||||||
openvino @ https://github.com/NateMeyer/openvino-wheels/releases/download/multi-arch_2022.2.0/openvino-2022.2.0-000-cp39-cp39-linux_aarch64.whl; platform_machine == 'aarch64'
|
openvino @ https://github.com/NateMeyer/openvino-wheels/releases/download/multi-arch_2022.2.0/openvino-2022.2.0-000-cp39-cp39-linux_aarch64.whl; platform_machine == 'aarch64'
|
||||||
openvino @ https://github.com/NateMeyer/openvino-wheels/releases/download/multi-arch_2022.2.0/openvino-2022.2.0-000-cp39-cp39-linux_armv7l.whl; platform_machine == 'armv7l'
|
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
scikit-build == 0.16.4
|
scikit-build == 0.17.1
|
||||||
nvidia-pyindex
|
nvidia-pyindex
|
||||||
|
|||||||
3868
web/package-lock.json
generated
3868
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -13,48 +13,46 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cycjimmy/jsmpeg-player": "^6.0.5",
|
"@cycjimmy/jsmpeg-player": "^6.0.5",
|
||||||
"axios": "^1.2.2",
|
"axios": "^1.3.5",
|
||||||
"copy-to-clipboard": "3.3.3",
|
"copy-to-clipboard": "3.3.3",
|
||||||
"date-fns": "^2.29.3",
|
"date-fns": "^2.29.3",
|
||||||
"idb-keyval": "^6.2.0",
|
"idb-keyval": "^6.2.0",
|
||||||
"immer": "^9.0.16",
|
"immer": "^9.0.21",
|
||||||
"monaco-yaml": "^4.0.2",
|
"monaco-yaml": "^4.0.4",
|
||||||
"preact": "^10.11.3",
|
"preact": "^10.13.2",
|
||||||
"preact-async-route": "^2.2.1",
|
"preact-async-route": "^2.2.1",
|
||||||
"preact-router": "^4.1.0",
|
"preact-router": "^4.1.0",
|
||||||
"react": "npm:@preact/compat@^17.1.2",
|
"react": "npm:@preact/compat@^17.1.2",
|
||||||
"react-dom": "npm:@preact/compat@^17.1.2",
|
"react-dom": "npm:@preact/compat@^17.1.2",
|
||||||
"strftime": "^0.10.1",
|
"strftime": "^0.10.1",
|
||||||
"swr": "^1.3.0",
|
"swr": "^1.3.0",
|
||||||
"video.js": "^7.20.3",
|
"video.js": "^8.3.0",
|
||||||
"videojs-playlist": "^5.0.0",
|
"videojs-playlist": "^5.1.0",
|
||||||
"videojs-seek-buttons": "^3.0.1",
|
|
||||||
"vite-plugin-monaco-editor": "^1.1.0"
|
"vite-plugin-monaco-editor": "^1.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@preact/preset-vite": "^2.5.0",
|
"@preact/preset-vite": "^2.5.0",
|
||||||
"@tailwindcss/forms": "^0.5.3",
|
"@tailwindcss/forms": "^0.5.3",
|
||||||
"@testing-library/jest-dom": "^5.16.5",
|
"@testing-library/jest-dom": "^5.16.5",
|
||||||
"@testing-library/preact": "^3.2.2",
|
"@testing-library/preact": "^3.2.3",
|
||||||
"@testing-library/user-event": "^14.4.3",
|
"@testing-library/user-event": "^14.4.3",
|
||||||
"@types/video.js": "^7.3.50",
|
"@typescript-eslint/eslint-plugin": "^5.58.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.47.1",
|
"@typescript-eslint/parser": "^5.58.0",
|
||||||
"@typescript-eslint/parser": "^5.47.1",
|
"@vitest/coverage-c8": "^0.30.1",
|
||||||
"@vitest/coverage-c8": "^0.26.2",
|
"@vitest/ui": "^0.30.1",
|
||||||
"@vitest/ui": "^0.26.2",
|
"autoprefixer": "^10.4.14",
|
||||||
"autoprefixer": "^10.4.13",
|
"eslint": "^8.38.0",
|
||||||
"eslint": "^8.30.0",
|
|
||||||
"eslint-config-preact": "^1.3.0",
|
"eslint-config-preact": "^1.3.0",
|
||||||
"eslint-config-prettier": "^8.5.0",
|
"eslint-config-prettier": "^8.8.0",
|
||||||
"eslint-plugin-vitest-globals": "^1.2.0",
|
"eslint-plugin-vitest-globals": "^1.3.1",
|
||||||
"fake-indexeddb": "^4.0.1",
|
"fake-indexeddb": "^4.0.1",
|
||||||
"jsdom": "^20.0.3",
|
"jsdom": "^21.1.1",
|
||||||
"msw": "^0.49.2",
|
"msw": "^1.2.1",
|
||||||
"postcss": "^8.4.19",
|
"postcss": "^8.4.19",
|
||||||
"prettier": "^2.8.0",
|
"prettier": "^2.8.7",
|
||||||
"tailwindcss": "^3.2.4",
|
"tailwindcss": "^3.3.1",
|
||||||
"typescript": "^4.8.4",
|
"typescript": "^5.0.4",
|
||||||
"vite": "^4.0.3",
|
"vite": "^4.2.1",
|
||||||
"vitest": "^0.25.3"
|
"vitest": "^0.30.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,7 +21,7 @@ export default function Dialog({ children, portalRootID = 'dialogs' }) {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
role="modal"
|
role="modal"
|
||||||
className={`absolute rounded shadow-2xl bg-white dark:bg-gray-700 max-w-sm text-gray-900 dark:text-white transition-transform transition-opacity duration-75 transform scale-90 opacity-0 ${
|
className={`absolute rounded shadow-2xl bg-white dark:bg-gray-700 sm:max-w-sm md:max-w-md lg:max-w-lg text-gray-900 dark:text-white transition-transform transition-opacity duration-75 transform scale-90 opacity-0 ${
|
||||||
show ? 'scale-100 opacity-100' : ''
|
show ? 'scale-100 opacity-100' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -3,11 +3,10 @@ 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 'video.js/dist/video-js.css';
|
||||||
import 'videojs-seek-buttons/dist/videojs-seek-buttons.css';
|
|
||||||
|
|
||||||
import videojs, { VideoJsPlayer } from 'video.js';
|
import videojs from 'video.js';
|
||||||
|
import type Player from 'video.js/dist/types/player';
|
||||||
|
|
||||||
interface OnTimeUpdateEvent {
|
interface OnTimeUpdateEvent {
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
@ -34,10 +33,10 @@ export const HistoryVideo = ({
|
|||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
|
||||||
const [video, setVideo] = useState<VideoJsPlayer>();
|
const [video, setVideo] = useState<Player>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let video: VideoJsPlayer
|
let video: Player
|
||||||
if (videoRef.current) {
|
if (videoRef.current) {
|
||||||
video = videojs(videoRef.current, {})
|
video = videojs(videoRef.current, {})
|
||||||
setVideo(video)
|
setVideo(video)
|
||||||
@ -88,7 +87,8 @@ export const HistoryVideo = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (video && video.readyState() >= 1) {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
if (video && (video as any).readyState() >= 1) {
|
||||||
if (videoIsPlaying) {
|
if (videoIsPlaying) {
|
||||||
video.play()
|
video.play()
|
||||||
} else {
|
} else {
|
||||||
@ -98,7 +98,8 @@ export const HistoryVideo = ({
|
|||||||
}, [video, videoIsPlaying])
|
}, [video, videoIsPlaying])
|
||||||
|
|
||||||
const onLoad = useCallback(() => {
|
const onLoad = useCallback(() => {
|
||||||
if (video && video.readyState() >= 1 && videoIsPlaying) {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
if (video && (video as any).readyState() >= 1 && videoIsPlaying) {
|
||||||
video.play()
|
video.play()
|
||||||
}
|
}
|
||||||
}, [video, videoIsPlaying])
|
}, [video, videoIsPlaying])
|
||||||
|
|||||||
@ -16,6 +16,8 @@ export default function MultiSelect({ className, title, options, selection, onTo
|
|||||||
|
|
||||||
const isOptionSelected = (item) => { return selection == "all" || selection.split(',').indexOf(item) > -1; }
|
const isOptionSelected = (item) => { return selection == "all" || selection.split(',').indexOf(item) > -1; }
|
||||||
|
|
||||||
|
const menuHeight = Math.round(window.innerHeight * 0.55);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${className} p-2`} ref={popupRef}>
|
<div className={`${className} p-2`} ref={popupRef}>
|
||||||
<div
|
<div
|
||||||
@ -26,7 +28,7 @@ export default function MultiSelect({ className, title, options, selection, onTo
|
|||||||
<ArrowDropdown className="w-6" />
|
<ArrowDropdown className="w-6" />
|
||||||
</div>
|
</div>
|
||||||
{state.showMenu ? (
|
{state.showMenu ? (
|
||||||
<Menu relativeTo={popupRef} onDismiss={() => setState({ showMenu: false })}>
|
<Menu className={`max-h-[${menuHeight}px] overflow-scroll`} relativeTo={popupRef} onDismiss={() => setState({ showMenu: false })}>
|
||||||
<div className="flex flex-wrap justify-between items-center">
|
<div className="flex flex-wrap justify-between items-center">
|
||||||
<Heading className="p-4 justify-center" size="md">{title}</Heading>
|
<Heading className="p-4 justify-center" size="md">{title}</Heading>
|
||||||
<Button tabindex="false" className="mx-4" onClick={() => onShowAll() }>
|
<Button tabindex="false" className="mx-4" onClick={() => onShowAll() }>
|
||||||
|
|||||||
131
web/src/components/TimelineSummary.jsx
Normal file
131
web/src/components/TimelineSummary.jsx
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import ActivityIndicator from './ActivityIndicator';
|
||||||
|
import { formatUnixTimestampToDateTime } from '../utils/dateUtil';
|
||||||
|
import PlayIcon from '../icons/Play';
|
||||||
|
import ExitIcon from '../icons/Exit';
|
||||||
|
import { Zone } from '../icons/Zone';
|
||||||
|
import { useState } from 'preact/hooks';
|
||||||
|
import Button from './Button';
|
||||||
|
import { getUnixTime } from 'date-fns';
|
||||||
|
|
||||||
|
export default function TimelineSummary({ event, onFrameSelected }) {
|
||||||
|
const { data: eventTimeline } = useSWR([
|
||||||
|
'timeline',
|
||||||
|
{
|
||||||
|
source_id: event.id,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { data: config } = useSWR('config');
|
||||||
|
|
||||||
|
const [timeIndex, setTimeIndex] = useState(-1);
|
||||||
|
|
||||||
|
const recordingParams = {
|
||||||
|
before: event.end_time || getUnixTime(),
|
||||||
|
after: event.start_time,
|
||||||
|
};
|
||||||
|
const { data: recordings } = useSWR([`${event.camera}/recordings`, recordingParams], { revalidateOnFocus: false });
|
||||||
|
|
||||||
|
// calculates the seek seconds by adding up all the seconds in the segments prior to the playback time
|
||||||
|
const getSeekSeconds = (seekUnix) => {
|
||||||
|
if (!recordings) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let seekSeconds = 0;
|
||||||
|
recordings.every((segment) => {
|
||||||
|
// if the next segment is past the desired time, stop calculating
|
||||||
|
if (segment.start_time > seekUnix) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segment.end_time < seekUnix) {
|
||||||
|
seekSeconds += segment.end_time - segment.start_time;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
seekSeconds += segment.end_time - segment.start_time - (segment.end_time - seekUnix);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return seekSeconds;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSelectMoment = async (index) => {
|
||||||
|
setTimeIndex(index);
|
||||||
|
onFrameSelected(eventTimeline[index], getSeekSeconds(eventTimeline[index].timestamp));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!eventTimeline || !config) {
|
||||||
|
return <ActivityIndicator />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventTimeline.length == 0) {
|
||||||
|
return <div />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="h-14 flex justify-center">
|
||||||
|
<div className="sm:w-1 md:w-1/4 flex flex-row flex-nowrap justify-between overflow-auto">
|
||||||
|
{eventTimeline.map((item, index) =>
|
||||||
|
item.class_type == 'visible' || item.class_type == 'gone' ? (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
className="rounded-full"
|
||||||
|
type="text"
|
||||||
|
color={index == timeIndex ? 'blue' : 'gray'}
|
||||||
|
aria-label={window.innerWidth > 640 ? getTimelineItemDescription(config, item, event) : ''}
|
||||||
|
onClick={() => onSelectMoment(index)}
|
||||||
|
>
|
||||||
|
{item.class_type == 'visible' ? <PlayIcon className="w-8" /> : <ExitIcon className="w-8" />}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
className="rounded-full"
|
||||||
|
type="text"
|
||||||
|
color={index == timeIndex ? 'blue' : 'gray'}
|
||||||
|
aria-label={window.innerWidth > 640 ? getTimelineItemDescription(config, item, event) : ''}
|
||||||
|
onClick={() => onSelectMoment(index)}
|
||||||
|
>
|
||||||
|
<Zone className="w-8" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{timeIndex >= 0 ? (
|
||||||
|
<div className="bg-gray-500 p-4 m-2 max-w-md self-center">
|
||||||
|
Disclaimer: This data comes from the detect feed but is shown on the recordings, it is unlikely that the
|
||||||
|
streams are perfectly in sync so the bounding box and the footage will not line up perfectly.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimelineItemDescription(config, timelineItem, event) {
|
||||||
|
if (timelineItem.class_type == 'visible') {
|
||||||
|
return `${event.label} detected at ${formatUnixTimestampToDateTime(timelineItem.timestamp, {
|
||||||
|
date_style: 'short',
|
||||||
|
time_style: 'medium',
|
||||||
|
time_format: config.ui.time_format,
|
||||||
|
})}`;
|
||||||
|
} else if (timelineItem.class_type == 'entered_zone') {
|
||||||
|
return `${event.label.replaceAll('_', ' ')} entered ${timelineItem.data.zones
|
||||||
|
.join(' and ')
|
||||||
|
.replaceAll('_', ' ')} at ${formatUnixTimestampToDateTime(timelineItem.timestamp, {
|
||||||
|
date_style: 'short',
|
||||||
|
time_style: 'medium',
|
||||||
|
time_format: config.ui.time_format,
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${event.label} left at ${formatUnixTimestampToDateTime(timelineItem.timestamp, {
|
||||||
|
date_style: 'short',
|
||||||
|
time_style: 'medium',
|
||||||
|
time_format: config.ui.time_format,
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
@ -49,7 +49,7 @@ export default function Tooltip({ relativeTo, text }) {
|
|||||||
const tooltip = (
|
const tooltip = (
|
||||||
<div
|
<div
|
||||||
role="tooltip"
|
role="tooltip"
|
||||||
className={`shadow max-w-lg absolute pointer-events-none bg-gray-900 dark:bg-gray-200 bg-opacity-80 rounded px-2 py-1 transition-transform transition-opacity duration-75 transform scale-90 opacity-0 text-gray-100 dark:text-gray-900 text-sm ${
|
className={`shadow max-w-lg absolute pointer-events-none bg-gray-900 dark:bg-gray-200 bg-opacity-80 rounded px-2 py-1 transition-transform transition-opacity duration-75 transform scale-90 opacity-0 text-gray-100 dark:text-gray-900 text-sm capitalize ${
|
||||||
position.top >= 0 ? 'opacity-100 scale-100' : ''
|
position.top >= 0 ? 'opacity-100 scale-100' : ''
|
||||||
}`}
|
}`}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@ -2,24 +2,21 @@ import { h } from 'preact';
|
|||||||
import { useRef, useEffect } from 'preact/hooks';
|
import { useRef, useEffect } from 'preact/hooks';
|
||||||
import videojs from 'video.js';
|
import videojs from 'video.js';
|
||||||
import 'videojs-playlist';
|
import 'videojs-playlist';
|
||||||
import 'videojs-seek-buttons';
|
|
||||||
import 'video.js/dist/video-js.css';
|
import 'video.js/dist/video-js.css';
|
||||||
import 'videojs-seek-buttons/dist/videojs-seek-buttons.css';
|
|
||||||
|
|
||||||
export default function VideoPlayer({ children, options, seekOptions = {}, onReady = () => {}, onDispose = () => {} }) {
|
export default function VideoPlayer({ children, options, seekOptions = {forward:30, backward: 10}, onReady = () => {}, onDispose = () => {} }) {
|
||||||
const playerRef = useRef();
|
const playerRef = useRef();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const defaultOptions = {
|
const defaultOptions = {
|
||||||
controls: true,
|
controls: true,
|
||||||
|
controlBar: {
|
||||||
|
skipButtons: seekOptions,
|
||||||
|
},
|
||||||
playbackRates: [0.5, 1, 2, 4, 8],
|
playbackRates: [0.5, 1, 2, 4, 8],
|
||||||
fluid: true,
|
fluid: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultSeekOptions = {
|
|
||||||
forward: 30,
|
|
||||||
back: 10,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!videojs.browser.IS_FIREFOX) {
|
if (!videojs.browser.IS_FIREFOX) {
|
||||||
defaultOptions.playbackRates.push(16);
|
defaultOptions.playbackRates.push(16);
|
||||||
@ -28,10 +25,6 @@ export default function VideoPlayer({ children, options, seekOptions = {}, onRea
|
|||||||
const player = videojs(playerRef.current, { ...defaultOptions, ...options }, () => {
|
const player = videojs(playerRef.current, { ...defaultOptions, ...options }, () => {
|
||||||
onReady(player);
|
onReady(player);
|
||||||
});
|
});
|
||||||
player.seekButtons({
|
|
||||||
...defaultSeekOptions,
|
|
||||||
...seekOptions,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Allows player to continue on error
|
// Allows player to continue on error
|
||||||
player.reloadSourceOnError();
|
player.reloadSourceOnError();
|
||||||
|
|||||||
12
web/src/icons/Exit.jsx
Normal file
12
web/src/icons/Exit.jsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
function Exit({ className = '' }) {
|
||||||
|
return (
|
||||||
|
<svg className={`fill-current ${className}`} viewBox="0 0 24 24">
|
||||||
|
<path d="M22 12l-4-4v3h-8v2h8v3m2 2a10 10 0 110-12h-2.73a8 8 0 100 12z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Exit);
|
||||||
@ -1,9 +1,9 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import { memo } from 'preact/compat';
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
export function Play() {
|
export function Play({ className = '' }) {
|
||||||
return (
|
return (
|
||||||
<svg style="width:24px;height:24px" viewBox="0 0 24 24">
|
<svg className={`fill-current ${className}`} viewBox="0 0 24 24">
|
||||||
<path fill="currentColor" d="M8,5.14V19.14L19,12.14L8,5.14Z" />
|
<path fill="currentColor" d="M8,5.14V19.14L19,12.14L8,5.14Z" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import Dialog from '../components/Dialog';
|
|||||||
import MultiSelect from '../components/MultiSelect';
|
import MultiSelect from '../components/MultiSelect';
|
||||||
import { formatUnixTimestampToDateTime, getDurationFromTimestamps } from '../utils/dateUtil';
|
import { formatUnixTimestampToDateTime, getDurationFromTimestamps } from '../utils/dateUtil';
|
||||||
import TimeAgo from '../components/TimeAgo';
|
import TimeAgo from '../components/TimeAgo';
|
||||||
|
import TimelineSummary from '../components/TimelineSummary';
|
||||||
|
|
||||||
const API_LIMIT = 25;
|
const API_LIMIT = 25;
|
||||||
|
|
||||||
@ -60,12 +61,14 @@ export default function Events({ path, ...props }) {
|
|||||||
});
|
});
|
||||||
const [uploading, setUploading] = useState([]);
|
const [uploading, setUploading] = useState([]);
|
||||||
const [viewEvent, setViewEvent] = useState();
|
const [viewEvent, setViewEvent] = useState();
|
||||||
|
const [eventOverlay, setEventOverlay] = useState();
|
||||||
const [eventDetailType, setEventDetailType] = useState('clip');
|
const [eventDetailType, setEventDetailType] = useState('clip');
|
||||||
const [downloadEvent, setDownloadEvent] = useState({
|
const [downloadEvent, setDownloadEvent] = useState({
|
||||||
id: null,
|
id: null,
|
||||||
has_clip: false,
|
has_clip: false,
|
||||||
has_snapshot: false,
|
has_snapshot: false,
|
||||||
plus_id: undefined,
|
plus_id: undefined,
|
||||||
|
end_time: null,
|
||||||
});
|
});
|
||||||
const [deleteFavoriteState, setDeleteFavoriteState] = useState({
|
const [deleteFavoriteState, setDeleteFavoriteState] = useState({
|
||||||
deletingFavoriteEventId: null,
|
deletingFavoriteEventId: null,
|
||||||
@ -179,6 +182,14 @@ export default function Events({ path, ...props }) {
|
|||||||
onFilter(name, items);
|
onFilter(name, items);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onEventFrameSelected = (event, frame, seekSeconds) => {
|
||||||
|
if (this.player) {
|
||||||
|
this.player.pause();
|
||||||
|
this.player.currentTime(seekSeconds);
|
||||||
|
setEventOverlay(frame);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const datePicker = useRef();
|
const datePicker = useRef();
|
||||||
|
|
||||||
const downloadButton = useRef();
|
const downloadButton = useRef();
|
||||||
@ -190,6 +201,7 @@ export default function Events({ path, ...props }) {
|
|||||||
has_clip: event.has_clip,
|
has_clip: event.has_clip,
|
||||||
has_snapshot: event.has_snapshot,
|
has_snapshot: event.has_snapshot,
|
||||||
plus_id: event.plus_id,
|
plus_id: event.plus_id,
|
||||||
|
end_time: event.end_time,
|
||||||
}));
|
}));
|
||||||
downloadButton.current = e.target;
|
downloadButton.current = e.target;
|
||||||
setState({ ...state, showDownloadMenu: true });
|
setState({ ...state, showDownloadMenu: true });
|
||||||
@ -524,7 +536,7 @@ export default function Events({ path, ...props }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hidden sm:flex flex-col justify-end mr-2">
|
<div class="hidden sm:flex flex-col justify-end mr-2">
|
||||||
{(event.end_time && event.has_snapshot) && (
|
{event.end_time && event.has_snapshot && (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
{event.plus_id ? (
|
{event.plus_id ? (
|
||||||
<div className="uppercase text-xs">Sent to Frigate+</div>
|
<div className="uppercase text-xs">Sent to Frigate+</div>
|
||||||
@ -571,20 +583,52 @@ export default function Events({ path, ...props }) {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
{eventDetailType == 'clip' && event.has_clip ? (
|
{eventDetailType == 'clip' && event.has_clip ? (
|
||||||
<VideoPlayer
|
<div>
|
||||||
options={{
|
<TimelineSummary
|
||||||
preload: 'auto',
|
event={event}
|
||||||
autoplay: true,
|
onFrameSelected={(frame, seekSeconds) => onEventFrameSelected(event, frame, seekSeconds)}
|
||||||
sources: [
|
/>
|
||||||
{
|
<div>
|
||||||
src: `${apiHost}vod/event/${event.id}/master.m3u8`,
|
<VideoPlayer
|
||||||
type: 'application/vnd.apple.mpegurl',
|
options={{
|
||||||
},
|
preload: 'auto',
|
||||||
],
|
autoplay: true,
|
||||||
}}
|
sources: [
|
||||||
seekOptions={{ forward: 10, back: 5 }}
|
{
|
||||||
onReady={() => {}}
|
src: `${apiHost}vod/event/${event.id}/master.m3u8`,
|
||||||
/>
|
type: 'application/vnd.apple.mpegurl',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
seekOptions={{ forward: 10, backward: 5 }}
|
||||||
|
onReady={(player) => {
|
||||||
|
this.player = player;
|
||||||
|
this.player.on('playing', () => {
|
||||||
|
setEventOverlay(undefined);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onDispose={() => {
|
||||||
|
this.player = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{eventOverlay ? (
|
||||||
|
<div
|
||||||
|
className="absolute border-4 border-red-600"
|
||||||
|
style={{
|
||||||
|
left: `${Math.round(eventOverlay.data.box[0] * 100)}%`,
|
||||||
|
top: `${Math.round(eventOverlay.data.box[1] * 100)}%`,
|
||||||
|
right: `${Math.round((1 - eventOverlay.data.box[2]) * 100)}%`,
|
||||||
|
bottom: `${Math.round((1 - eventOverlay.data.box[3]) * 100)}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{eventOverlay.class_type == 'entered_zone' ? (
|
||||||
|
<div className="absolute w-2 h-2 bg-yellow-500 left-[50%] bottom-0" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</VideoPlayer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{eventDetailType == 'image' || !event.has_clip ? (
|
{eventDetailType == 'image' || !event.has_clip ? (
|
||||||
|
|||||||
@ -128,7 +128,7 @@ export default function Storage() {
|
|||||||
<Tbody>
|
<Tbody>
|
||||||
<Tr>
|
<Tr>
|
||||||
<Td>{Math.round(camera['usage_percent'] ?? 0)}%</Td>
|
<Td>{Math.round(camera['usage_percent'] ?? 0)}%</Td>
|
||||||
<Td>{camera['bandwidth'] ? getUnitSize(camera['bandwidth']) : 'Calculating...'}/hr</Td>
|
<Td>{camera['bandwidth'] ? `${getUnitSize(camera['bandwidth'])}/hr` : 'Calculating...'}</Td>
|
||||||
</Tr>
|
</Tr>
|
||||||
</Tbody>
|
</Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|||||||
@ -49,14 +49,14 @@ export default function System() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
setState({ ...state, showFfprobe: true, ffprobe: JSON.stringify(response.data, null, 2) });
|
setState({ ...state, showFfprobe: true, ffprobe: response.data });
|
||||||
} else {
|
} else {
|
||||||
setState({ ...state, showFfprobe: true, ffprobe: 'There was an error getting the ffprobe output.' });
|
setState({ ...state, showFfprobe: true, ffprobe: 'There was an error getting the ffprobe output.' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onCopyFfprobe = async () => {
|
const onCopyFfprobe = async () => {
|
||||||
copy(JSON.stringify(state.ffprobe, null, 2));
|
copy(JSON.stringify(state.ffprobe).replace(/[\\\s]+/gi, ''));
|
||||||
setState({ ...state, ffprobe: '', showFfprobe: false });
|
setState({ ...state, ffprobe: '', showFfprobe: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -68,14 +68,18 @@ export default function System() {
|
|||||||
const response = await axios.get('vainfo');
|
const response = await axios.get('vainfo');
|
||||||
|
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
setState({ ...state, showVainfo: true, vainfo: JSON.stringify(response.data, null, 2) });
|
setState({
|
||||||
|
...state,
|
||||||
|
showVainfo: true,
|
||||||
|
vainfo: response.data,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setState({ ...state, showVainfo: true, vainfo: 'There was an error getting the vainfo output.' });
|
setState({ ...state, showVainfo: true, vainfo: 'There was an error getting the vainfo output.' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onCopyVainfo = async () => {
|
const onCopyVainfo = async () => {
|
||||||
copy(JSON.stringify(state.vainfo, null, 2));
|
copy(JSON.stringify(state.vainfo).replace(/[\\\s]+/gi, ''));
|
||||||
setState({ ...state, vainfo: '', showVainfo: false });
|
setState({ ...state, vainfo: '', showVainfo: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -107,9 +111,52 @@ export default function System() {
|
|||||||
|
|
||||||
{state.showFfprobe && (
|
{state.showFfprobe && (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<div className="p-4">
|
<div className="p-4 mb-2 max-h-96 whitespace-pre-line overflow-scroll">
|
||||||
<Heading size="lg">Ffprobe Output</Heading>
|
<Heading size="lg">Ffprobe Output</Heading>
|
||||||
{state.ffprobe != '' ? <p className="mb-2">{state.ffprobe}</p> : <ActivityIndicator />}
|
{state.ffprobe != '' ? (
|
||||||
|
<div>
|
||||||
|
{state.ffprobe.map((stream, idx) => (
|
||||||
|
<div key={idx} className="mb-2 max-h-96 whitespace-pre-line">
|
||||||
|
<div>Stream {idx}:</div>
|
||||||
|
<div className="px-2">Return Code: {stream.return_code}</div>
|
||||||
|
<br />
|
||||||
|
{stream.return_code == 0 ? (
|
||||||
|
<div>
|
||||||
|
{stream.stdout.streams.map((codec, idx) => (
|
||||||
|
<div className="px-2" key={idx}>
|
||||||
|
{codec.width ? (
|
||||||
|
<div>
|
||||||
|
<div>Video:</div>
|
||||||
|
<br />
|
||||||
|
<div>Codec: {codec.codec_long_name}</div>
|
||||||
|
<div>
|
||||||
|
Resolution: {codec.width}x{codec.height}
|
||||||
|
</div>
|
||||||
|
<div>FPS: {codec.avg_frame_rate == '0/0' ? 'Unknown' : codec.avg_frame_rate}</div>
|
||||||
|
<br />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div>Audio:</div>
|
||||||
|
<br />
|
||||||
|
<div>Codec: {codec.codec_long_name}</div>
|
||||||
|
<br />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-2">
|
||||||
|
<div>Error: {stream.stderr}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ActivityIndicator />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2 flex justify-start flex-row-reverse space-x-2">
|
<div className="p-2 flex justify-start flex-row-reverse space-x-2">
|
||||||
<Button className="ml-2" onClick={() => onCopyFfprobe()} type="text">
|
<Button className="ml-2" onClick={() => onCopyFfprobe()} type="text">
|
||||||
@ -128,10 +175,16 @@ export default function System() {
|
|||||||
|
|
||||||
{state.showVainfo && (
|
{state.showVainfo && (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<div className="p-4">
|
<div className="p-4 overflow-scroll whitespace-pre-line">
|
||||||
<Heading size="lg">Vainfo Output</Heading>
|
<Heading size="lg">Vainfo Output</Heading>
|
||||||
{state.vainfo != '' ? (
|
{state.vainfo != '' ? (
|
||||||
<p className="mb-2 max-h-96 overflow-scroll">{state.vainfo}</p>
|
<div className="mb-2 max-h-96 whitespace-pre-line">
|
||||||
|
<div className="">Return Code: {state.vainfo.return_code}</div>
|
||||||
|
<br />
|
||||||
|
<div className="">Process {state.vainfo.return_code == 0 ? 'Output' : 'Error'}:</div>
|
||||||
|
<br />
|
||||||
|
<div>{state.vainfo.return_code == 0 ? state.vainfo.stdout : state.vainfo.stderr}</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ActivityIndicator />
|
<ActivityIndicator />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -37,25 +37,26 @@ export const getNowYesterdayInLong = (): number => {
|
|||||||
*/
|
*/
|
||||||
interface DateTimeStyle {
|
interface DateTimeStyle {
|
||||||
timezone: string;
|
timezone: string;
|
||||||
use12hour: boolean | undefined;
|
time_format: 'browser' | '12hour' | '24hour';
|
||||||
date_style: 'full' | 'long' | 'medium' | 'short';
|
date_style: 'full' | 'long' | 'medium' | 'short';
|
||||||
time_style: 'full' | 'long' | 'medium' | 'short';
|
time_style: 'full' | 'long' | 'medium' | 'short';
|
||||||
strftime_fmt: string;
|
strftime_fmt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const formatUnixTimestampToDateTime = (unixTimestamp: number, config: DateTimeStyle): string => {
|
export const formatUnixTimestampToDateTime = (unixTimestamp: number, config: DateTimeStyle): string => {
|
||||||
const { timezone, use12hour, date_style, time_style, strftime_fmt } = config;
|
const { timezone, time_format, date_style, time_style, strftime_fmt } = config;
|
||||||
const locale = window.navigator?.language || 'en-US';
|
const locale = window.navigator?.language || 'en-us';
|
||||||
|
|
||||||
if (isNaN(unixTimestamp)) {
|
if (isNaN(unixTimestamp)) {
|
||||||
return 'Invalid time';
|
return 'Invalid time';
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const date = new Date(unixTimestamp * 1000);
|
const date = new Date(unixTimestamp * 1000);
|
||||||
|
|
||||||
// use strftime_fmt if defined in config file
|
// use strftime_fmt if defined in config file
|
||||||
if (strftime_fmt) {
|
if (strftime_fmt) {
|
||||||
const strftime_locale = strftime.localizeByIdentifier(locale);
|
const strftime_locale = strftime.timezone(getUTCOffset(date, timezone || Intl.DateTimeFormat().resolvedOptions().timeZone)).localizeByIdentifier(locale);
|
||||||
return strftime_locale(strftime_fmt, date);
|
return strftime_locale(strftime_fmt, date);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,7 +65,7 @@ export const formatUnixTimestampToDateTime = (unixTimestamp: number, config: Dat
|
|||||||
dateStyle: date_style,
|
dateStyle: date_style,
|
||||||
timeStyle: time_style,
|
timeStyle: time_style,
|
||||||
timeZone: timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
timeZone: timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
hour12: use12hour !== null ? use12hour : undefined,
|
hour12: time_format !== 'browser' ? time_format == '12hour' : undefined,
|
||||||
});
|
});
|
||||||
return formatter.format(date);
|
return formatter.format(date);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -113,3 +114,18 @@ export const getDurationFromTimestamps = (start_time: number, end_time: number |
|
|||||||
}
|
}
|
||||||
return duration;
|
return duration;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapted from https://stackoverflow.com/a/29268535 this takes a timezone string and
|
||||||
|
* returns the offset of that timezone from UTC in minutes.
|
||||||
|
* @param timezone string representation of the timezone the user is requesting
|
||||||
|
* @returns number of minutes offset from UTC
|
||||||
|
*/
|
||||||
|
const getUTCOffset = (date: Date, timezone: string): number => {
|
||||||
|
const utcDate = new Date(date.getTime() - (date.getTimezoneOffset() * 60 * 1000));
|
||||||
|
// locale of en-CA is required for proper locale format
|
||||||
|
let iso = utcDate.toLocaleString('en-CA', { timeZone: timezone, hour12: false }).replace(', ', 'T');
|
||||||
|
iso += `.${utcDate.getMilliseconds().toString().padStart(3, '0')}`;
|
||||||
|
const target = new Date(`${iso}Z`);
|
||||||
|
return (target.getTime() - utcDate.getTime()) / 60 / 1000;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user