Refactor Recordings (#7275)

* Run ffmpeg sub process & video_properties as async

* Run recording cleanup in the main process

* More cleanup

* Use inter process communication to write recordings into the DB

* Formatting
This commit is contained in:
Nicolas Mowen 2023-07-26 04:55:08 -06:00 committed by GitHub
parent 9016a48dc7
commit 761daf46ea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 61 additions and 34 deletions

View File

@ -36,13 +36,14 @@ from frigate.events.external import ExternalEventProcessor
from frigate.events.maintainer import EventProcessor from frigate.events.maintainer import 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, Timeline from frigate.models import Event, Recordings, RecordingsToDelete, Timeline
from frigate.object_detection import ObjectDetectProcess from frigate.object_detection import ObjectDetectProcess
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.ptz.autotrack import PtzAutoTrackerThread from frigate.ptz.autotrack import PtzAutoTrackerThread
from frigate.ptz.onvif import OnvifController from frigate.ptz.onvif import OnvifController
from frigate.record.cleanup import RecordingCleanup
from frigate.record.record import manage_recordings from frigate.record.record import manage_recordings
from frigate.stats import StatsEmitter, stats_init from frigate.stats import StatsEmitter, stats_init
from frigate.storage import StorageMaintainer from frigate.storage import StorageMaintainer
@ -292,6 +293,7 @@ class FrigateApp:
name="recording_manager", name="recording_manager",
args=( args=(
self.config, self.config,
self.inter_process_queue,
self.object_recordings_info_queue, self.object_recordings_info_queue,
self.audio_recordings_info_queue, self.audio_recordings_info_queue,
self.feature_metrics, self.feature_metrics,
@ -317,7 +319,7 @@ class FrigateApp:
60, 10 * len([c for c in self.config.cameras.values() if c.enabled]) 60, 10 * len([c for c in self.config.cameras.values() if c.enabled])
), ),
) )
models = [Event, Recordings, Timeline] models = [Event, Recordings, RecordingsToDelete, Timeline]
self.db.bind(models) self.db.bind(models)
def init_stats(self) -> None: def init_stats(self) -> None:
@ -522,6 +524,10 @@ class FrigateApp:
self.event_cleanup = EventCleanup(self.config, self.stop_event) self.event_cleanup = EventCleanup(self.config, self.stop_event)
self.event_cleanup.start() self.event_cleanup.start()
def start_record_cleanup(self) -> None:
self.record_cleanup = RecordingCleanup(self.config, self.stop_event)
self.record_cleanup.start()
def start_storage_maintainer(self) -> None: def start_storage_maintainer(self) -> None:
self.storage_maintainer = StorageMaintainer(self.config, self.stop_event) self.storage_maintainer = StorageMaintainer(self.config, self.stop_event)
self.storage_maintainer.start() self.storage_maintainer.start()
@ -607,6 +613,7 @@ class FrigateApp:
self.start_timeline_processor() self.start_timeline_processor()
self.start_event_processor() self.start_event_processor()
self.start_event_cleanup() self.start_event_cleanup()
self.start_record_cleanup()
self.start_stats_emitter() self.start_stats_emitter()
self.start_watchdog() self.start_watchdog()
self.check_shm() self.check_shm()
@ -643,6 +650,7 @@ class FrigateApp:
self.ptz_autotracker_thread.join() self.ptz_autotracker_thread.join()
self.event_processor.join() self.event_processor.join()
self.event_cleanup.join() self.event_cleanup.join()
self.record_cleanup.join()
self.stats_emitter.join() self.stats_emitter.join()
self.frigate_watchdog.join() self.frigate_watchdog.join()
self.db.stop() self.db.stop()

View File

@ -5,6 +5,8 @@ from abc import ABC, abstractmethod
from typing import Any, Callable from typing import Any, Callable
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.const import INSERT_MANY_RECORDINGS
from frigate.models import Recordings
from frigate.ptz.onvif import OnvifCommandEnum, OnvifController from frigate.ptz.onvif import OnvifCommandEnum, OnvifController
from frigate.types import CameraMetricsTypes, FeatureMetricsTypes, PTZMetricsTypes from frigate.types import CameraMetricsTypes, FeatureMetricsTypes, PTZMetricsTypes
from frigate.util.services import restart_frigate from frigate.util.services import restart_frigate
@ -86,6 +88,8 @@ class Dispatcher:
return return
elif topic == "restart": elif topic == "restart":
restart_frigate() restart_frigate()
elif topic == INSERT_MANY_RECORDINGS:
Recordings.insert_many(payload).execute()
else: else:
self.publish(topic, payload, retain=False) self.publish(topic, payload, retain=False)

View File

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import logging import logging
import os import os
@ -1059,7 +1060,7 @@ class FrigateConfig(FrigateBaseModel):
if "detect" in input.roles: if "detect" in input.roles:
stream_info = {"width": 0, "height": 0} stream_info = {"width": 0, "height": 0}
try: try:
stream_info = get_video_properties(input.path) stream_info = asyncio.run(get_video_properties(input.path))
except Exception: except Exception:
logger.warn( logger.warn(
f"Error detecting stream resolution automatically for {input.path} Applying default values." f"Error detecting stream resolution automatically for {input.path} Applying default values."

View File

@ -47,3 +47,7 @@ DRIVER_INTEL_iHD = "iHD"
MAX_SEGMENT_DURATION = 600 MAX_SEGMENT_DURATION = 600
MAX_PLAYLIST_SECONDS = 7200 # support 2 hour segments for a single playlist to account for cameras with inconsistent segment times MAX_PLAYLIST_SECONDS = 7200 # support 2 hour segments for a single playlist to account for cameras with inconsistent segment times
# Internal Comms Topics
INSERT_MANY_RECORDINGS = "insert_many_recordings"

View File

@ -8,7 +8,6 @@ import os
import queue import queue
import random import random
import string import string
import subprocess as sp
import threading import threading
from collections import defaultdict from collections import defaultdict
from multiprocessing.synchronize import Event as MpEvent from multiprocessing.synchronize import Event as MpEvent
@ -19,7 +18,12 @@ import numpy as np
import psutil import psutil
from frigate.config import FrigateConfig, RetainModeEnum from frigate.config import FrigateConfig, RetainModeEnum
from frigate.const import CACHE_DIR, MAX_SEGMENT_DURATION, RECORD_DIR from frigate.const import (
CACHE_DIR,
INSERT_MANY_RECORDINGS,
MAX_SEGMENT_DURATION,
RECORD_DIR,
)
from frigate.models import Event, Recordings from frigate.models import Event, Recordings
from frigate.types import FeatureMetricsTypes from frigate.types import FeatureMetricsTypes
from frigate.util.image import area from frigate.util.image import area
@ -51,6 +55,7 @@ class RecordingMaintainer(threading.Thread):
def __init__( def __init__(
self, self,
config: FrigateConfig, config: FrigateConfig,
inter_process_queue: mp.Queue,
object_recordings_info_queue: mp.Queue, object_recordings_info_queue: mp.Queue,
audio_recordings_info_queue: Optional[mp.Queue], audio_recordings_info_queue: Optional[mp.Queue],
process_info: dict[str, FeatureMetricsTypes], process_info: dict[str, FeatureMetricsTypes],
@ -59,6 +64,7 @@ class RecordingMaintainer(threading.Thread):
threading.Thread.__init__(self) threading.Thread.__init__(self)
self.name = "recording_maintainer" self.name = "recording_maintainer"
self.config = config self.config = config
self.inter_process_queue = inter_process_queue
self.object_recordings_info_queue = object_recordings_info_queue self.object_recordings_info_queue = object_recordings_info_queue
self.audio_recordings_info_queue = audio_recordings_info_queue self.audio_recordings_info_queue = audio_recordings_info_queue
self.process_info = process_info self.process_info = process_info
@ -161,9 +167,11 @@ class RecordingMaintainer(threading.Thread):
) )
recordings_to_insert: list[Optional[Recordings]] = await asyncio.gather(*tasks) recordings_to_insert: list[Optional[Recordings]] = await asyncio.gather(*tasks)
Recordings.insert_many(
[r for r in recordings_to_insert if r is not None] # fire and forget recordings entries
).execute() self.inter_process_queue.put(
(INSERT_MANY_RECORDINGS, [r for r in recordings_to_insert if r is not None])
)
async def validate_and_move_segment( async def validate_and_move_segment(
self, camera: str, events: Event, recording: dict[str, any] self, camera: str, events: Event, recording: dict[str, any]
@ -183,7 +191,7 @@ class RecordingMaintainer(threading.Thread):
if cache_path in self.end_time_cache: if cache_path in self.end_time_cache:
end_time, duration = self.end_time_cache[cache_path] end_time, duration = self.end_time_cache[cache_path]
else: else:
segment_info = get_video_properties(cache_path, get_duration=True) segment_info = await get_video_properties(cache_path, get_duration=True)
if segment_info["duration"]: if segment_info["duration"]:
duration = float(segment_info["duration"]) duration = float(segment_info["duration"])
@ -231,7 +239,7 @@ class RecordingMaintainer(threading.Thread):
if overlaps: if overlaps:
record_mode = self.config.cameras[camera].record.events.retain.mode record_mode = self.config.cameras[camera].record.events.retain.mode
# move from cache to recordings immediately # move from cache to recordings immediately
return self.move_segment( return await self.move_segment(
camera, camera,
start_time, start_time,
end_time, end_time,
@ -253,7 +261,7 @@ class RecordingMaintainer(threading.Thread):
# else retain days includes this segment # else retain days includes this segment
else: else:
record_mode = self.config.cameras[camera].record.retain.mode record_mode = self.config.cameras[camera].record.retain.mode
return self.move_segment( return await self.move_segment(
camera, start_time, end_time, duration, cache_path, record_mode camera, start_time, end_time, duration, cache_path, record_mode
) )
@ -296,7 +304,7 @@ class RecordingMaintainer(threading.Thread):
return SegmentInfo(motion_count, active_count, round(average_dBFS)) return SegmentInfo(motion_count, active_count, round(average_dBFS))
def move_segment( async def move_segment(
self, self,
camera: str, camera: str,
start_time: datetime.datetime, start_time: datetime.datetime,
@ -332,7 +340,7 @@ class RecordingMaintainer(threading.Thread):
start_frame = datetime.datetime.now().timestamp() start_frame = datetime.datetime.now().timestamp()
# add faststart to kept segments to improve metadata reading # add faststart to kept segments to improve metadata reading
ffmpeg_cmd = [ p = await asyncio.create_subprocess_exec(
"ffmpeg", "ffmpeg",
"-hide_banner", "-hide_banner",
"-y", "-y",
@ -343,17 +351,13 @@ class RecordingMaintainer(threading.Thread):
"-movflags", "-movflags",
"+faststart", "+faststart",
file_path, file_path,
] stderr=asyncio.subprocess.PIPE,
p = sp.run(
ffmpeg_cmd,
encoding="ascii",
capture_output=True,
) )
await p.wait()
if p.returncode != 0: if p.returncode != 0:
logger.error(f"Unable to convert {cache_path} to {file_path}") logger.error(f"Unable to convert {cache_path} to {file_path}")
logger.error(p.stderr) logger.error((await p.stderr.read()).decode("ascii"))
return None return None
else: else:
logger.debug( logger.debug(

View File

@ -11,8 +11,7 @@ from playhouse.sqliteq import SqliteQueueDatabase
from setproctitle import setproctitle from setproctitle import setproctitle
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.models import Event, Recordings, RecordingsToDelete, Timeline from frigate.models import Event, Recordings
from frigate.record.cleanup import RecordingCleanup
from frigate.record.maintainer import RecordingMaintainer from frigate.record.maintainer import RecordingMaintainer
from frigate.types import FeatureMetricsTypes from frigate.types import FeatureMetricsTypes
from frigate.util.services import listen from frigate.util.services import listen
@ -22,6 +21,7 @@ logger = logging.getLogger(__name__)
def manage_recordings( def manage_recordings(
config: FrigateConfig, config: FrigateConfig,
inter_process_queue: mp.Queue,
object_recordings_info_queue: mp.Queue, object_recordings_info_queue: mp.Queue,
audio_recordings_info_queue: mp.Queue, audio_recordings_info_queue: mp.Queue,
process_info: dict[str, FeatureMetricsTypes], process_info: dict[str, FeatureMetricsTypes],
@ -47,17 +47,15 @@ def manage_recordings(
}, },
timeout=max(60, 10 * len([c for c in config.cameras.values() if c.enabled])), timeout=max(60, 10 * len([c for c in config.cameras.values() if c.enabled])),
) )
models = [Event, Recordings, Timeline, RecordingsToDelete] models = [Event, Recordings]
db.bind(models) db.bind(models)
maintainer = RecordingMaintainer( maintainer = RecordingMaintainer(
config, config,
inter_process_queue,
object_recordings_info_queue, object_recordings_info_queue,
audio_recordings_info_queue, audio_recordings_info_queue,
process_info, process_info,
stop_event, stop_event,
) )
maintainer.start() maintainer.start()
cleanup = RecordingCleanup(config, stop_event)
cleanup.start()

View File

@ -1,5 +1,6 @@
"""Utilities for services.""" """Utilities for services."""
import asyncio
import json import json
import logging import logging
import os import os
@ -352,8 +353,8 @@ def vainfo_hwaccel(device_name: Optional[str] = None) -> sp.CompletedProcess:
return sp.run(ffprobe_cmd, capture_output=True) return sp.run(ffprobe_cmd, capture_output=True)
def get_video_properties(url, get_duration=False): async def get_video_properties(url, get_duration=False):
def calculate_duration(video: Optional[any]) -> float: async def calculate_duration(video: Optional[any]) -> float:
duration = None duration = None
if video is not None: if video is not None:
@ -366,7 +367,7 @@ def get_video_properties(url, get_duration=False):
# if cv2 failed need to use ffprobe # if cv2 failed need to use ffprobe
if duration is None: if duration is None:
ffprobe_cmd = [ p = await asyncio.create_subprocess_exec(
"ffprobe", "ffprobe",
"-v", "-v",
"error", "error",
@ -375,11 +376,18 @@ def get_video_properties(url, get_duration=False):
"-of", "-of",
"default=noprint_wrappers=1:nokey=1", "default=noprint_wrappers=1:nokey=1",
f"{url}", f"{url}",
] stdout=asyncio.subprocess.PIPE,
p = sp.run(ffprobe_cmd, capture_output=True) stderr=asyncio.subprocess.PIPE,
)
await p.wait()
if p.returncode == 0 and p.stdout.decode(): if p.returncode == 0:
duration = float(p.stdout.decode().strip()) result = (await p.stdout.read()).decode()
else:
result = None
if result:
duration = float(result.strip())
else: else:
duration = -1 duration = -1
@ -400,7 +408,7 @@ def get_video_properties(url, get_duration=False):
result = {} result = {}
if get_duration: if get_duration:
result["duration"] = calculate_duration(video) result["duration"] = await calculate_duration(video)
if video is not None: if video is not None:
# Get the width of frames in the video stream # Get the width of frames in the video stream