diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index f5efcf8d6..fe0ceb86b 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -54,6 +54,7 @@ jobs: python_tests: runs-on: ubuntu-latest + name: Python Tests steps: - name: Check out code uses: actions/checkout@v2 @@ -69,6 +70,8 @@ jobs: uses: docker/setup-qemu-action@v1 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v1 + - name: Create Version Module + run: make version - name: Build run: make - name: Run mypy diff --git a/docker/Dockerfile b/docker/Dockerfile index 0bced8c3a..56579c7ff 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -46,7 +46,7 @@ RUN pip3 wheel --wheel-dir=/wheels -r requirements-wheels.txt FROM debian:11-slim ARG TARGETARCH -ARG JELLYFIN_FFMPEG_VERSION=4.3.2-1 +ARG JELLYFIN_FFMPEG_VERSION=4.4.1-4 # https://askubuntu.com/questions/972516/debian-frontend-environment-variable ARG DEBIAN_FRONTEND="noninteractive" # http://stackoverflow.com/questions/48162574/ddg#49462622 @@ -73,20 +73,21 @@ RUN apt-get -qq update \ && apt-key adv --fetch-keys https://packages.cloud.google.com/apt/doc/apt-key.gpg \ && echo "deb https://packages.cloud.google.com/apt coral-edgetpu-stable main" > /etc/apt/sources.list.d/coral-edgetpu.list \ && echo "libedgetpu1-max libedgetpu/accepted-eula select true" | debconf-set-selections \ + # enable non-free repo + && sed -i -e's/ main/ main contrib non-free/g' /etc/apt/sources.list \ && apt-get -qq update \ && apt-get -qq install --no-install-recommends --no-install-suggests -y \ # coral drivers libedgetpu1-max python3-tflite-runtime python3-pycoral \ && pip3 install -U /wheels/*.whl \ + # jellyfin-ffmpeg + && wget -O jellyfin.deb "https://repo.jellyfin.org/releases/server/debian/versions/jellyfin-ffmpeg/${JELLYFIN_FFMPEG_VERSION}/jellyfin-ffmpeg_${JELLYFIN_FFMPEG_VERSION}-$( awk -F'=' '/^VERSION_CODENAME=/{ print $NF }' /etc/os-release )_$( dpkg --print-architecture ).deb" \ + && apt-get -qq install --no-install-recommends --no-install-suggests -y ./jellyfin.deb \ + && rm jellyfin.deb \ # arch specific packages && if [ "${TARGETARCH}" = "amd64" ]; then \ - # jellyfin-ffmpeg - wget -O /tmp/jellyfin.deb "https://repo.jellyfin.org/releases/server/debian/versions/jellyfin-ffmpeg/${JELLYFIN_FFMPEG_VERSION}/jellyfin-ffmpeg_${JELLYFIN_FFMPEG_VERSION}-$( awk -F'=' '/^VERSION_CODENAME=/{ print $NF }' /etc/os-release )_$( dpkg --print-architecture ).deb" \ - && apt-get -qq install --no-install-recommends --no-install-suggests -y \ - mesa-va-drivers /tmp/jellyfin.deb \ - && rm /tmp/jellyfin.deb; else \ apt-get -qq install --no-install-recommends --no-install-suggests -y \ - ffmpeg; \ + mesa-va-drivers intel-media-va-driver-non-free; \ fi \ && rm -rf /wheels \ && apt-get remove gnupg apt-transport-https -y \ diff --git a/docs/docs/configuration/hardware_acceleration.md b/docs/docs/configuration/hardware_acceleration.md index 23874c93f..aaef378b9 100644 --- a/docs/docs/configuration/hardware_acceleration.md +++ b/docs/docs/configuration/hardware_acceleration.md @@ -12,33 +12,21 @@ Ensure you increase the allocated RAM for your GPU to at least 128 (raspi-config ```yaml ffmpeg: - hwaccel_args: - - -c:v - - h264_v4l2m2m + hwaccel_args: -c:v h264_v4l2m2m ``` ### Intel-based CPUs (<10th Generation) via Quicksync ```yaml ffmpeg: - hwaccel_args: - - -hwaccel - - vaapi - - -hwaccel_device - - /dev/dri/renderD128 - - -hwaccel_output_format - - yuv420p + hwaccel_args: -hwaccel vaapi -hwaccel_device /dev/dri/renderD128 -hwaccel_output_format yuv420p ``` ### Intel-based CPUs (>=10th Generation) via Quicksync ```yaml ffmpeg: - hwaccel_args: - - -hwaccel - - qsv - - -qsv_device - - /dev/dri/renderD128 + hwaccel_args: -c:v h264_qsv ``` ### AMD/ATI GPUs (Radeon HD 2000 and newer GPUs) via libva-mesa-driver @@ -47,11 +35,7 @@ ffmpeg: ```yaml ffmpeg: - hwaccel_args: - - -hwaccel - - vaapi - - -hwaccel_device - - /dev/dri/renderD128 + hwaccel_args: -hwaccel vaapi -hwaccel_device /dev/dri/renderD128 -hwaccel_output_format yuv420p ``` ### NVIDIA GPU @@ -91,13 +75,11 @@ A list of supported codecs (you can use `ffmpeg -decoders | grep cuvid` in the c V..... vp9_cuvid Nvidia CUVID VP9 decoder (codec vp9) ``` -For example, for H265 video (hevc), you'll select `hevc_cuvid`. +For example, for H264 video, you'll select `h264_cuvid`. ```yaml ffmpeg: - hwaccel_args: - - -c:v - - hevc_cuvid + hwaccel_args: -c:v h264_cuvid ``` If everything is working correctly, you should see a significant improvement in performance. diff --git a/docs/docs/contributing.md b/docs/docs/contributing.md index 59c1423f2..82903d599 100644 --- a/docs/docs/contributing.md +++ b/docs/docs/contributing.md @@ -108,6 +108,18 @@ ffmpeg -c:v h264_v4l2m2m -re -stream_loop -1 -i https://streams.videolan.org/ffm ffmpeg -c:v h264_cuvid -re -stream_loop -1 -i https://streams.videolan.org/ffmpeg/incoming/720p60.mp4 -f rawvideo -pix_fmt yuv420p pipe: > /dev/null ``` +**VAAPI** + +```shell +ffmpeg -hwaccel vaapi -hwaccel_device /dev/dri/renderD128 -hwaccel_output_format yuv420p -re -stream_loop -1 -i https://streams.videolan.org/ffmpeg/incoming/720p60.mp4 -f rawvideo -pix_fmt yuv420p pipe: > /dev/null +``` + +**QSV** + +```shell +ffmpeg -c:v h264_qsv -re -stream_loop -1 -i https://streams.videolan.org/ffmpeg/incoming/720p60.mp4 -f rawvideo -pix_fmt yuv420p pipe: > /dev/null +``` + ## Web Interface ### Prerequisites diff --git a/docs/docs/integrations/api.md b/docs/docs/integrations/api.md index cae8aff68..c7a963ca6 100644 --- a/docs/docs/integrations/api.md +++ b/docs/docs/integrations/api.md @@ -183,6 +183,10 @@ Permanently deletes the event along with any clips/snapshots. Sets retain to true for the event id. +### `POST /api/events//plus` + +Submits the snapshot of the event to Frigate+ for labeling. + ### `DELETE /api/events//retain` Sets retain to false for the event id (event may be deleted quickly after removing). diff --git a/frigate/http.py b/frigate/http.py index e24d37b0b..323e8b476 100644 --- a/frigate/http.py +++ b/frigate/http.py @@ -256,7 +256,10 @@ def get_sub_labels(): ) sub_labels = [e.sub_label for e in events] - sub_labels.remove(None) + + if None in sub_labels: + sub_labels.remove(None) + return jsonify(sub_labels) diff --git a/frigate/plus.py b/frigate/plus.py index a0ba6cd0a..faf70752e 100644 --- a/frigate/plus.py +++ b/frigate/plus.py @@ -1,6 +1,8 @@ import datetime +import json import logging import os +import re import requests from frigate.const import PLUS_ENV_VAR, PLUS_API_HOST from requests.models import Response @@ -28,10 +30,23 @@ def get_jpg_bytes(image: ndarray, max_dim: int, quality: int) -> bytes: class PlusApi: def __init__(self) -> None: self.host = PLUS_API_HOST + self.key = None if PLUS_ENV_VAR in os.environ: self.key = os.environ.get(PLUS_ENV_VAR) - else: + # check for the addon options file + elif os.path.isfile("/data/options.json"): + with open("/data/options.json") as f: + raw_options = f.read() + options = json.loads(raw_options) + self.key = options.get("plus_api_key") + + if self.key is not None and not re.match( + r"[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}:[a-z0-9]{40}", + self.key, + ): + logger.error("Plus API Key is not formatted correctly.") self.key = None + self._is_active: bool = self.key is not None self._token_data: dict = {} diff --git a/frigate/test/const.py b/frigate/test/const.py new file mode 100644 index 000000000..bef02e235 --- /dev/null +++ b/frigate/test/const.py @@ -0,0 +1,4 @@ +"""Consts for testing.""" + +TEST_DB = "test.db" +TEST_DB_CLEANUPS = ["test.db", "test.db-shm", "test.db-wal"] diff --git a/frigate/test/test_http.py b/frigate/test/test_http.py new file mode 100644 index 000000000..ca8f578b1 --- /dev/null +++ b/frigate/test/test_http.py @@ -0,0 +1,327 @@ +import datetime +import json +import logging +import os +import unittest +from unittest.mock import patch + +from peewee_migrate import Router +from playhouse.sqlite_ext import SqliteExtDatabase +from playhouse.sqliteq import SqliteQueueDatabase +from playhouse.shortcuts import model_to_dict + +from frigate.config import FrigateConfig +from frigate.http import create_app +from frigate.models import Event, Recordings + +from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS + + +class TestHttp(unittest.TestCase): + def setUp(self): + # setup clean database for each test run + migrate_db = SqliteExtDatabase("test.db") + del logging.getLogger("peewee_migrate").handlers[:] + router = Router(migrate_db) + router.run() + migrate_db.close() + self.db = SqliteQueueDatabase(TEST_DB) + models = [Event, Recordings] + self.db.bind(models) + + self.minimal_config = { + "mqtt": {"host": "mqtt"}, + "cameras": { + "front_door": { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]} + ] + }, + "detect": { + "height": 1080, + "width": 1920, + "fps": 5, + }, + } + }, + } + self.test_stats = { + "detection_fps": 13.7, + "detectors": { + "cpu1": { + "detection_start": 0.0, + "inference_speed": 91.43, + "pid": 42, + }, + "cpu2": { + "detection_start": 0.0, + "inference_speed": 84.99, + "pid": 44, + }, + }, + "front_door": { + "camera_fps": 0.0, + "capture_pid": 53, + "detection_fps": 0.0, + "pid": 52, + "process_fps": 0.0, + "skipped_fps": 0.0, + }, + "service": { + "storage": { + "/dev/shm": { + "free": 50.5, + "mount_type": "tmpfs", + "total": 67.1, + "used": 16.6, + }, + "/media/frigate/clips": { + "free": 42429.9, + "mount_type": "ext4", + "total": 244529.7, + "used": 189607.0, + }, + "/media/frigate/recordings": { + "free": 0.2, + "mount_type": "ext4", + "total": 8.0, + "used": 7.8, + }, + "/tmp/cache": { + "free": 976.8, + "mount_type": "tmpfs", + "total": 1000.0, + "used": 23.2, + }, + }, + "uptime": 101113, + "version": "0.10.1", + "latest_version": "0.11", + }, + } + + def tearDown(self): + if not self.db.is_closed(): + self.db.close() + + try: + for file in TEST_DB_CLEANUPS: + os.remove(file) + except OSError: + pass + + def test_get_event_list(self): + app = create_app( + FrigateConfig(**self.minimal_config), self.db, None, None, None + ) + id = "123456.random" + id2 = "7890.random" + + with app.test_client() as client: + _insert_mock_event(id) + events = client.get(f"/events").json + assert events + assert len(events) == 1 + assert events[0]["id"] == id + _insert_mock_event(id2) + events = client.get(f"/events").json + assert events + assert len(events) == 2 + events = client.get( + f"/events", + query_string={"limit": 1}, + ).json + assert events + assert len(events) == 1 + events = client.get( + f"/events", + query_string={"has_clip": 0}, + ).json + assert not events + + def test_get_good_event(self): + app = create_app( + FrigateConfig(**self.minimal_config), self.db, None, None, None + ) + id = "123456.random" + + with app.test_client() as client: + _insert_mock_event(id) + event = client.get(f"/events/{id}").json + + assert event + assert event["id"] == id + assert event == model_to_dict(Event.get(Event.id == id)) + + def test_get_bad_event(self): + app = create_app( + FrigateConfig(**self.minimal_config), self.db, None, None, None + ) + id = "123456.random" + bad_id = "654321.other" + + with app.test_client() as client: + _insert_mock_event(id) + event = client.get(f"/events/{bad_id}").json + + assert not event + + def test_delete_event(self): + app = create_app( + FrigateConfig(**self.minimal_config), self.db, None, None, None + ) + id = "123456.random" + + with app.test_client() as client: + _insert_mock_event(id) + event = client.get(f"/events/{id}").json + assert event + assert event["id"] == id + client.delete(f"/events/{id}") + event = client.get(f"/events/{id}").json + assert not event + + def test_event_retention(self): + app = create_app( + FrigateConfig(**self.minimal_config), self.db, None, None, None + ) + id = "123456.random" + + with app.test_client() as client: + _insert_mock_event(id) + client.post(f"/events/{id}/retain") + event = client.get(f"/events/{id}").json + assert event + assert event["id"] == id + assert event["retain_indefinitely"] == True + client.delete(f"/events/{id}/retain") + event = client.get(f"/events/{id}").json + assert event + assert event["id"] == id + assert event["retain_indefinitely"] == False + + def test_set_delete_sub_label(self): + app = create_app( + FrigateConfig(**self.minimal_config), self.db, None, None, None + ) + id = "123456.random" + sub_label = "sub" + + with app.test_client() as client: + _insert_mock_event(id) + client.post( + f"/events/{id}/sub_label", + data=json.dumps({"subLabel": sub_label}), + content_type="application/json", + ) + event = client.get(f"/events/{id}").json + assert event + assert event["id"] == id + assert event["sub_label"] == sub_label + client.post( + f"/events/{id}/sub_label", + data=json.dumps({"subLabel": ""}), + content_type="application/json", + ) + event = client.get(f"/events/{id}").json + assert event + assert event["id"] == id + assert event["sub_label"] == "" + + def test_sub_label_list(self): + app = create_app( + FrigateConfig(**self.minimal_config), self.db, None, None, None + ) + id = "123456.random" + sub_label = "sub" + + with app.test_client() as client: + _insert_mock_event(id) + client.post( + f"/events/{id}/sub_label", + data=json.dumps({"subLabel": sub_label}), + content_type="application/json", + ) + sub_labels = client.get("/sub_labels").json + assert sub_labels + assert sub_labels == [sub_label] + + def test_config(self): + app = create_app( + FrigateConfig(**self.minimal_config).runtime_config, + self.db, + None, + None, + None, + ) + + with app.test_client() as client: + config = client.get("/config").json + assert config + assert config["cameras"]["front_door"] + + def test_recordings(self): + app = create_app( + FrigateConfig(**self.minimal_config).runtime_config, + self.db, + None, + None, + None, + ) + id = "123456.random" + + with app.test_client() as client: + _insert_mock_recording(id) + recording = client.get("/front_door/recordings").json + assert recording + assert recording[0]["id"] == id + + @patch("frigate.http.stats_snapshot") + def test_stats(self, mock_stats): + app = create_app( + FrigateConfig(**self.minimal_config).runtime_config, + self.db, + None, + None, + None, + ) + mock_stats.return_value = self.test_stats + + with app.test_client() as client: + stats = client.get("/stats").json + assert stats == self.test_stats + + +def _insert_mock_event(id: str) -> Event: + """Inserts a basic event model with a given id.""" + return Event.insert( + id=id, + label="Mock", + camera="front_door", + start_time=datetime.datetime.now().timestamp(), + end_time=datetime.datetime.now().timestamp() + 20, + top_score=100, + false_positive=False, + zones=list(), + thumbnail="", + region=[], + box=[], + area=0, + has_clip=True, + has_snapshot=True, + ).execute() + + +def _insert_mock_recording(id: str) -> Event: + """Inserts a basic recording model with a given id.""" + return Recordings.insert( + id=id, + camera="front_door", + path=f"/recordings/{id}", + start_time=datetime.datetime.now().timestamp() - 50, + end_time=datetime.datetime.now().timestamp() - 60, + duration=10, + motion=True, + objects=True, + ).execute() diff --git a/requirements-wheels.txt b/requirements-wheels.txt index 2dd2356cc..38adee697 100644 --- a/requirements-wheels.txt +++ b/requirements-wheels.txt @@ -3,7 +3,7 @@ Flask == 2.1.* imutils == 0.5.* matplotlib == 3.5.* mypy == 0.942 -numpy == 1.22.* +numpy == 1.19.* opencv-python-headless == 4.5.5.* paho-mqtt == 1.6.* peewee == 3.14.*