initial commit

This commit is contained in:
Jason Hunter 2021-07-09 16:14:16 -04:00 committed by Blake Blackshear
parent dc759a3e56
commit a476bc9885
6 changed files with 402 additions and 278 deletions

View File

@ -69,13 +69,32 @@ class RetainConfig(BaseModel):
)
# DEPRECATED: Will eventually be removed
class ClipsConfig(BaseModel):
enabled: bool = Field(default=False, title="Save clips.")
max_seconds: int = Field(default=300, title="Maximum clip duration.")
pre_capture: int = Field(default=5, title="Seconds to capture before event starts.")
post_capture: int = Field(default=5, title="Seconds to capture after event ends.")
required_zones: List[str] = Field(
default_factory=list,
title="List of required zones to be entered in order to save the clip.",
)
objects: Optional[List[str]] = Field(
title="List of objects to be detected in order to save the clip.",
)
retain: RetainConfig = Field(
default_factory=RetainConfig, title="Clip retention settings."
)
class RecordConfig(BaseModel):
enabled: bool = Field(default=False, title="Enable record on all cameras.")
retain_days: int = Field(default=0, title="Recording retention period in days.")
events: ClipsConfig = Field(
default_factory=ClipsConfig, title="Event specific settings."
)
class MotionConfig(BaseModel):
threshold: int = Field(
default=25,
@ -264,26 +283,11 @@ FFMPEG_INPUT_ARGS_DEFAULT = [
]
DETECT_FFMPEG_OUTPUT_ARGS_DEFAULT = ["-f", "rawvideo", "-pix_fmt", "yuv420p"]
RTMP_FFMPEG_OUTPUT_ARGS_DEFAULT = ["-c", "copy", "-f", "flv"]
SAVE_CLIPS_FFMPEG_OUTPUT_ARGS_DEFAULT = [
"-f",
"segment",
"-segment_time",
"10",
"-segment_format",
"mp4",
"-reset_timestamps",
"1",
"-strftime",
"1",
"-c",
"copy",
"-an",
]
RECORD_FFMPEG_OUTPUT_ARGS_DEFAULT = [
"-f",
"segment",
"-segment_time",
"60",
"10",
"-segment_format",
"mp4",
"-reset_timestamps",
@ -305,10 +309,6 @@ class FfmpegOutputArgsConfig(BaseModel):
default=RECORD_FFMPEG_OUTPUT_ARGS_DEFAULT,
title="Record role FFmpeg output arguments.",
)
clips: Union[str, List[str]] = Field(
default=SAVE_CLIPS_FFMPEG_OUTPUT_ARGS_DEFAULT,
title="Clips role FFmpeg output arguments.",
)
rtmp: Union[str, List[str]] = Field(
default=RTMP_FFMPEG_OUTPUT_ARGS_DEFAULT,
title="RTMP role FFmpeg output arguments.",
@ -423,20 +423,6 @@ class CameraMqttConfig(BaseModel):
)
class CameraClipsConfig(BaseModel):
enabled: bool = Field(default=False, title="Save clips.")
pre_capture: int = Field(default=5, title="Seconds to capture before event starts.")
post_capture: int = Field(default=5, title="Seconds to capture after event ends.")
required_zones: List[str] = Field(
default_factory=list,
title="List of required zones to be entered in order to save the clip.",
)
objects: Optional[List[str]] = Field(
title="List of objects to be detected in order to save the clip.",
)
retain: RetainConfig = Field(default_factory=RetainConfig, title="Clip retention.")
class CameraRtmpConfig(BaseModel):
enabled: bool = Field(default=True, title="RTMP restreaming enabled.")
@ -446,11 +432,6 @@ class CameraLiveConfig(BaseModel):
quality: int = Field(default=8, ge=1, le=31, title="Live camera view quality")
class RecordConfig(BaseModel):
enabled: bool = Field(default=False, title="Enable record on all cameras.")
retain_days: int = Field(default=30, title="Recording retention period in days.")
class CameraConfig(BaseModel):
name: Optional[str] = Field(title="Camera name.")
ffmpeg: CameraFfmpegConfig = Field(title="FFmpeg configuration for the camera.")
@ -466,9 +447,7 @@ class CameraConfig(BaseModel):
zones: Dict[str, ZoneConfig] = Field(
default_factory=dict, title="Zone configuration."
)
clips: CameraClipsConfig = Field(
default_factory=CameraClipsConfig, title="Clip configuration."
)
clips: ClipsConfig = Field(default_factory=ClipsConfig, title="Clip configuration.")
record: RecordConfig = Field(
default_factory=RecordConfig, title="Record configuration."
)
@ -541,18 +520,9 @@ class CameraConfig(BaseModel):
ffmpeg_output_args = (
rtmp_args + [f"rtmp://127.0.0.1/live/{self.name}"] + ffmpeg_output_args
)
if "clips" in ffmpeg_input.roles:
clips_args = (
self.ffmpeg.output_args.clips
if isinstance(self.ffmpeg.output_args.clips, list)
else self.ffmpeg.output_args.clips.split(" ")
)
ffmpeg_output_args = (
clips_args
+ [f"{os.path.join(CACHE_DIR, self.name)}-%Y%m%d%H%M%S.mp4"]
+ ffmpeg_output_args
)
if "record" in ffmpeg_input.roles and self.record.enabled:
if any(role in ["clips", "record"] for role in ffmpeg_input.roles) and (
self.record.enabled or self.clips.enabled
):
record_args = (
self.ffmpeg.output_args.record
if isinstance(self.ffmpeg.output_args.record, list)
@ -560,7 +530,7 @@ class CameraConfig(BaseModel):
)
ffmpeg_output_args = (
record_args
+ [f"{os.path.join(RECORD_DIR, self.name)}-%Y%m%d%H%M%S.mp4"]
+ [f"{os.path.join(CACHE_DIR, self.name)}-%Y%m%d%H%M%S.mp4"]
+ ffmpeg_output_args
)
@ -700,7 +670,7 @@ class FrigateConfig(BaseModel):
# Global config to propegate down to camera level
global_config = config.dict(
include={
"clips": {"retain"},
"clips": ...,
"record": ...,
"snapshots": ...,
"objects": ...,
@ -713,7 +683,9 @@ class FrigateConfig(BaseModel):
for name, camera in config.cameras.items():
merged_config = deep_merge(camera.dict(exclude_unset=True), global_config)
camera_config = CameraConfig.parse_obj({"name": name, **merged_config})
camera_config: CameraConfig = CameraConfig.parse_obj(
{"name": name, **merged_config}
)
# FFMPEG input substitution
for input in camera_config.ffmpeg.inputs:
@ -776,6 +748,21 @@ class FrigateConfig(BaseModel):
config.cameras[name] = camera_config
# Merge Clips configuration for backward compatibility
if camera_config.clips.enabled:
logger.warn(
"Clips configuration is deprecated. Configure clip settings under record -> events."
)
if not camera_config.record.enabled:
camera_config.record.enabled = True
camera_config.record.retain_days = 0
camera_config.record.events = ClipsConfig.parse_obj(
deep_merge(
camera_config.clips.dict(exclude_unset=True),
camera_config.record.events.dict(exclude_unset=True),
)
)
return config
@validator("cameras")

View File

@ -1,20 +1,14 @@
import datetime
import json
import logging
import os
import queue
import subprocess as sp
import threading
import time
from collections import defaultdict
from pathlib import Path
import psutil
import shutil
from frigate.config import FrigateConfig
from frigate.const import RECORD_DIR, CLIPS_DIR, CACHE_DIR
from frigate.models import Event
from frigate.config import FrigateConfig, RecordConfig
from frigate.const import CLIPS_DIR
from frigate.models import Event, Recordings
from peewee import fn
@ -39,8 +33,16 @@ class EventProcessor(threading.Thread):
if event_data["false_positive"]:
return False
# if there are required zones and there is no overlap
required_zones = self.config.cameras[camera].clips.required_zones
record_config: RecordConfig = self.config.cameras[camera].record
# Recording clips is disabled
if not record_config.enabled or (
record_config.retain_days == 0 and not record_config.events.enabled
):
return False
# If there are required zones and there is no overlap
required_zones = record_config.events.required_zones
if len(required_zones) > 0 and not set(event_data["entered_zones"]) & set(
required_zones
):
@ -49,208 +51,65 @@ class EventProcessor(threading.Thread):
)
return False
return True
def refresh_cache(self):
cached_files = os.listdir(CACHE_DIR)
files_in_use = []
for process in psutil.process_iter():
try:
if process.name() != "ffmpeg":
continue
flist = process.open_files()
if flist:
for nt in flist:
if nt.path.startswith(CACHE_DIR):
files_in_use.append(nt.path.split("/")[-1])
except:
continue
for f in cached_files:
if f in files_in_use or f in self.cached_clips:
continue
basename = os.path.splitext(f)[0]
camera, date = basename.rsplit("-", maxsplit=1)
start_time = datetime.datetime.strptime(date, "%Y%m%d%H%M%S")
ffprobe_cmd = [
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
f"{os.path.join(CACHE_DIR, f)}",
]
p = sp.run(ffprobe_cmd, capture_output=True)
if p.returncode == 0:
duration = float(p.stdout.decode().strip())
else:
logger.info(f"bad file: {f}")
os.remove(os.path.join(CACHE_DIR, f))
continue
self.cached_clips[f] = {
"path": f,
"camera": camera,
"start_time": start_time.timestamp(),
"duration": duration,
}
if len(self.events_in_process) > 0:
earliest_event = min(
self.events_in_process.values(), key=lambda x: x["start_time"]
)["start_time"]
else:
earliest_event = datetime.datetime.now().timestamp()
# if the earliest event is more tha max seconds ago, cap it
max_seconds = self.config.clips.max_seconds
earliest_event = max(
earliest_event,
datetime.datetime.now().timestamp() - self.config.clips.max_seconds,
)
for f, data in list(self.cached_clips.items()):
if earliest_event - 90 > data["start_time"] + data["duration"]:
del self.cached_clips[f]
logger.debug(f"Cleaning up cached file {f}")
os.remove(os.path.join(CACHE_DIR, f))
# if we are still using more than 90% of the cache, proactively cleanup
cache_usage = shutil.disk_usage("/tmp/cache")
while (
cache_usage.used / cache_usage.total > 0.9
and cache_usage.free < 200000000
and len(self.cached_clips) > 0
# If the required objects are not present
if (
record_config.events.objects is not None
and event_data["label"] not in record_config.events.objects
):
logger.warning("More than 90% of the cache is used.")
logger.warning(
"Consider increasing space available at /tmp/cache or reducing max_seconds in your clips config."
)
logger.warning("Proactively cleaning up the cache...")
oldest_clip = min(self.cached_clips.values(), key=lambda x: x["start_time"])
del self.cached_clips[oldest_clip["path"]]
os.remove(os.path.join(CACHE_DIR, oldest_clip["path"]))
cache_usage = shutil.disk_usage("/tmp/cache")
def create_clip(self, camera, event_data, pre_capture, post_capture):
# get all clips from the camera with the event sorted
sorted_clips = sorted(
[c for c in self.cached_clips.values() if c["camera"] == camera],
key=lambda i: i["start_time"],
)
# if there are no clips in the cache or we are still waiting on a needed file check every 5 seconds
wait_count = 0
while (
len(sorted_clips) == 0
or sorted_clips[-1]["start_time"] + sorted_clips[-1]["duration"]
< event_data["end_time"] + post_capture
):
if wait_count > 4:
logger.warning(
f"Unable to create clip for {camera} and event {event_data['id']}. There were no cache files for this event."
logger.debug(
f"Not creating clip for {event_data['id']} because it did not contain required objects"
)
return False
logger.debug(f"No cache clips for {camera}. Waiting...")
return True
def verify_clip(self, camera, end_time):
# check every 5 seconds for the last required recording
for _ in range(4):
recordings_count = (
Recordings.select()
.where(Recordings.camera == camera, Recordings.end_time > end_time)
.limit(1)
.count()
)
if recordings_count > 0:
return True
logger.debug(f"Missing recording for {camera} clip. Waiting...")
time.sleep(5)
self.refresh_cache()
# get all clips from the camera with the event sorted
sorted_clips = sorted(
[c for c in self.cached_clips.values() if c["camera"] == camera],
key=lambda i: i["start_time"],
)
wait_count += 1
playlist_start = event_data["start_time"] - pre_capture
playlist_end = event_data["end_time"] + post_capture
playlist_lines = []
for clip in sorted_clips:
# clip ends before playlist start time, skip
if clip["start_time"] + clip["duration"] < playlist_start:
continue
# clip starts after playlist ends, finish
if clip["start_time"] > playlist_end:
break
playlist_lines.append(f"file '{os.path.join(CACHE_DIR,clip['path'])}'")
# if this is the starting clip, add an inpoint
if clip["start_time"] < playlist_start:
playlist_lines.append(
f"inpoint {int(playlist_start-clip['start_time'])}"
logger.warning(
f"Unable to verify clip for {camera}. There were no recordings for this camera."
)
# if this is the ending clip, add an outpoint
if clip["start_time"] + clip["duration"] > playlist_end:
playlist_lines.append(
f"outpoint {int(playlist_end-clip['start_time'])}"
)
clip_name = f"{camera}-{event_data['id']}"
ffmpeg_cmd = [
"ffmpeg",
"-y",
"-protocol_whitelist",
"pipe,file",
"-f",
"concat",
"-safe",
"0",
"-i",
"-",
"-c",
"copy",
"-movflags",
"+faststart",
f"{os.path.join(CLIPS_DIR, clip_name)}.mp4",
]
p = sp.run(
ffmpeg_cmd,
input="\n".join(playlist_lines),
encoding="ascii",
capture_output=True,
)
if p.returncode != 0:
logger.error(p.stderr)
return False
return True
def run(self):
while not self.stop_event.is_set():
try:
event_type, camera, event_data = self.event_queue.get(timeout=10)
except queue.Empty:
if not self.stop_event.is_set():
self.refresh_cache()
# if not self.stop_event.is_set():
# self.refresh_cache()
continue
logger.debug(f"Event received: {event_type} {camera} {event_data['id']}")
self.refresh_cache()
# self.refresh_cache()
if event_type == "start":
self.events_in_process[event_data["id"]] = event_data
if event_type == "end":
clips_config = self.config.cameras[camera].clips
record_config: RecordConfig = self.config.cameras[camera].record
clip_created = False
if self.should_create_clip(camera, event_data):
if clips_config.enabled and (
clips_config.objects is None
or event_data["label"] in clips_config.objects
):
clip_created = self.create_clip(
has_clip = self.should_create_clip(camera, event_data)
# Wait for recordings to be ready
if has_clip:
has_clip = self.verify_clip(
camera,
event_data,
clips_config.pre_capture,
clips_config.post_capture,
event_data["end_time"] + record_config.events.post_capture,
)
if clip_created or event_data["has_snapshot"]:
if has_clip or event_data["has_snapshot"]:
Event.create(
id=event_data["id"],
label=event_data["label"],
@ -261,11 +120,12 @@ class EventProcessor(threading.Thread):
false_positive=event_data["false_positive"],
zones=list(event_data["entered_zones"]),
thumbnail=event_data["thumbnail"],
has_clip=clip_created,
has_clip=has_clip,
has_snapshot=event_data["has_snapshot"],
)
del self.events_in_process[event_data["id"]]
self.event_processed_queue.put((event_data["id"], camera, clip_created))
self.event_processed_queue.put((event_data["id"], camera, has_clip))
logger.info(f"Exiting event processor...")

View File

@ -6,11 +6,13 @@ import glob
import logging
import os
import re
import subprocess as sp
import time
from functools import reduce
from pathlib import Path
import cv2
from flask.helpers import send_file
import numpy as np
from flask import (
@ -223,6 +225,32 @@ def event_snapshot(id):
return response
@bp.route("/events/<id>/clip.mp4")
def event_clip(id):
event: Event = Event.get(Event.id == id)
if event is None:
return "Event not found.", 404
if not event.has_clip:
return "Clip not available", 404
event_config = current_app.frigate_config.cameras[event.camera].record.events
start_ts = event.start_time - event_config.pre_capture
end_ts = event.end_time + event_config.post_capture
clip_path = os.path.join(CLIPS_DIR, f"{event.camera}-{id}.mp4")
if not os.path.isfile(clip_path):
return recording_clip(event.camera, start_ts, end_ts)
return send_file(
clip_path,
mimetype="video/mp4",
as_attachment=True,
attachment_filename=f"{event.camera}_{start_ts}-{end_ts}.mp4",
)
@bp.route("/events")
def events():
limit = request.args.get("limit", 100)
@ -517,14 +545,84 @@ def recordings(camera_name):
)
@bp.route("/vod/<year_month>/<day>/<hour>/<camera>")
def vod(year_month, day, hour, camera):
start_date = datetime.strptime(f"{year_month}-{day} {hour}", "%Y-%m-%d %H")
end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1)
start_ts = start_date.timestamp()
end_ts = end_date.timestamp()
@bp.route("/<camera>/start/<int:start_ts>/end/<int:end_ts>/clip.mp4")
@bp.route("/<camera>/start/<float:start_ts>/end/<float:end_ts>/clip.mp4")
def recording_clip(camera, start_ts, end_ts):
recordings = (
Recordings.select()
.where(
(Recordings.start_time.between(start_ts, end_ts))
| (Recordings.end_time.between(start_ts, end_ts))
)
.where(Recordings.camera == camera)
.order_by(Recordings.start_time.asc())
)
# Select all recordings where either the start or end dates fall in the requested hour
playlist_lines = []
clip: Recordings
for clip in recordings:
playlist_lines.append(f"file '{clip.path}'")
# if this is the starting clip, add an inpoint
if clip.start_time < start_ts:
playlist_lines.append(f"inpoint {int(start_ts - clip.start_time)}")
# if this is the ending clip, add an outpoint
if clip.end_time > end_ts:
playlist_lines.append(f"outpoint {int(end_ts - clip.start_time)}")
path = f"/tmp/cache/tmp_clip_{camera}_{start_ts}-{end_ts}.mp4"
ffmpeg_cmd = [
"ffmpeg",
"-y",
"-protocol_whitelist",
"pipe,file",
"-f",
"concat",
"-safe",
"0",
"-i",
"-",
"-c",
"copy",
"-f",
"mp4",
"-movflags",
"+faststart",
path,
]
p = sp.run(
ffmpeg_cmd,
input="\n".join(playlist_lines),
encoding="ascii",
capture_output=True,
)
if p.returncode != 0:
logger.error(p.stderr)
return f"Could not create clip from recordings for {camera}.", 500
mp4_bytes = None
try:
# read clip from disk
with open(path, "rb") as mp4_file:
mp4_bytes = mp4_file.read()
# delete after we have the bytes
os.remove(path)
except DoesNotExist:
return f"Could not create clip from recordings for {camera}.", 500
response = make_response(mp4_bytes)
response.mimetype = "video/mp4"
response.headers[
"Content-Disposition"
] = f"attachment; filename={camera}_{start_ts}-{end_ts}.mp4"
return response
@bp.route("/vod/<camera>/start/<int:start_ts>/end/<int:end_ts>")
@bp.route("/vod/<camera>/start/<float:start_ts>/end/<float:end_ts>")
def vod_ts(camera, start_ts, end_ts):
recordings = (
Recordings.select()
.where(
@ -553,9 +651,13 @@ def vod(year_month, day, hour, camera):
clips.append(clip)
durations.append(duration)
if not clips:
return "No recordings found.", 404
hour_ago = datetime.now() - timedelta(hours=1)
return jsonify(
{
"cache": datetime.now() - timedelta(hours=1) > start_date,
"cache": hour_ago.timestamp() > start_ts,
"discontinuity": False,
"durations": durations,
"sequences": [{"clips": clips}],
@ -563,6 +665,45 @@ def vod(year_month, day, hour, camera):
)
@bp.route("/vod/<year_month>/<day>/<hour>/<camera>")
def vod_hour(year_month, day, hour, camera):
start_date = datetime.strptime(f"{year_month}-{day} {hour}", "%Y-%m-%d %H")
end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1)
start_ts = start_date.timestamp()
end_ts = end_date.timestamp()
return vod_ts(camera, start_ts, end_ts)
@bp.route("/vod/event/<id>")
def vod_event(id):
event: Event = Event.get(Event.id == id)
if event is None:
return "Event not found.", 404
if not event.has_clip:
return "Clip not available", 404
event_config = current_app.frigate_config.cameras[event.camera].record.events
start_ts = event.start_time - event_config.pre_capture
end_ts = event.end_time + event_config.post_capture
clip_path = os.path.join(CLIPS_DIR, f"{event.camera}-{id}.mp4")
if not os.path.isfile(clip_path):
return vod_ts(event.camera, start_ts, end_ts)
duration = int((end_ts - start_ts) * 1000)
return jsonify(
{
"cache": True,
"discontinuity": False,
"durations": [duration],
"sequences": [{"clips": [{"type": "source", "path": clip_path}]}],
}
)
def imagestream(detected_frames_processor, camera_name, fps, height, draw_options):
while True:
# max out at specified FPS

View File

@ -3,6 +3,7 @@ import itertools
import logging
import os
import random
import shutil
import string
import subprocess as sp
import threading
@ -10,9 +11,11 @@ from pathlib import Path
import psutil
from peewee import JOIN
from frigate.config import FrigateConfig
from frigate.const import RECORD_DIR
from frigate.models import Recordings
from frigate.const import CACHE_DIR, RECORD_DIR
from frigate.models import Event, Recordings
logger = logging.getLogger(__name__)
@ -45,8 +48,10 @@ class RecordingMaintainer(threading.Thread):
def move_files(self):
recordings = [
d
for d in os.listdir(RECORD_DIR)
if os.path.isfile(os.path.join(RECORD_DIR, d)) and d.endswith(".mp4")
for d in os.listdir(CACHE_DIR)
if os.path.isfile(os.path.join(CACHE_DIR, d))
and d.endswith(".mp4")
and not d.startswith("tmp_clip")
]
files_in_use = []
@ -57,7 +62,7 @@ class RecordingMaintainer(threading.Thread):
flist = process.open_files()
if flist:
for nt in flist:
if nt.path.startswith(RECORD_DIR):
if nt.path.startswith(CACHE_DIR):
files_in_use.append(nt.path.split("/")[-1])
except:
continue
@ -66,6 +71,7 @@ class RecordingMaintainer(threading.Thread):
if f in files_in_use:
continue
cache_path = os.path.join(CACHE_DIR, f)
basename = os.path.splitext(f)[0]
camera, date = basename.rsplit("-", maxsplit=1)
start_time = datetime.datetime.strptime(date, "%Y%m%d%H%M%S")
@ -78,7 +84,7 @@ class RecordingMaintainer(threading.Thread):
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
f"{os.path.join(RECORD_DIR, f)}",
f"{cache_path}",
]
p = sp.run(ffprobe_cmd, capture_output=True)
if p.returncode == 0:
@ -86,7 +92,7 @@ class RecordingMaintainer(threading.Thread):
end_time = start_time + datetime.timedelta(seconds=duration)
else:
logger.info(f"bad file: {f}")
os.remove(os.path.join(RECORD_DIR, f))
Path(cache_path).unlink(missing_ok=True)
continue
directory = os.path.join(
@ -99,7 +105,7 @@ class RecordingMaintainer(threading.Thread):
file_name = f"{start_time.strftime('%M.%S.mp4')}"
file_path = os.path.join(directory, file_name)
os.rename(os.path.join(RECORD_DIR, f), file_path)
shutil.move(cache_path, file_path)
rand_id = "".join(
random.choices(string.ascii_lowercase + string.digits, k=6)
@ -113,7 +119,135 @@ class RecordingMaintainer(threading.Thread):
duration=duration,
)
def expire_recordings(self):
event_recordings = Recordings.select(
Recordings.id.alias("recording_id"),
Recordings.camera,
Recordings.path,
Recordings.end_time,
Event.id.alias("event_id"),
Event.label,
).join(
Event,
on=(
(Recordings.camera == Event.camera)
& (
(Recordings.start_time.between(Event.start_time, Event.end_time))
| (Recordings.end_time.between(Event.start_time, Event.end_time))
),
),
)
retain = {}
for recording in event_recordings:
# Set default to delete
if recording.path not in retain:
retain[recording.path] = False
# Handle deleted cameras that still have recordings and events
if recording.camera in self.config.cameras:
record_config = self.config.cameras[recording.camera].record
else:
record_config = self.config.record
# Check event retention and set to True if within window
expire_days_event = (
0
if not record_config.events.enabled
else record_config.events.retain.objects.get(
recording.event.label, record_config.events.retain.default
)
)
expire_before_event = (
datetime.datetime.now() - datetime.timedelta(days=expire_days_event)
).timestamp()
if recording.end_time >= expire_before_event:
retain[recording.path] = True
# Check recording retention and set to True if within window
expire_days_record = record_config.retain_days
expire_before_record = (
datetime.datetime.now() - datetime.timedelta(days=expire_days_record)
).timestamp()
if recording.end_time > expire_before_record:
retain[recording.path] = True
# Actually expire recordings
for path, keep in retain.items():
if not keep:
Path(path).unlink(missing_ok=True)
Recordings.delete_by_id(recording.recording_id)
# Update Event
event_no_recordings = (
Event.select()
.join(
Recordings,
JOIN.LEFT_OUTER,
on=(
(Recordings.camera == Event.camera)
& (
(
Recordings.start_time.between(
Event.start_time, Event.end_time
)
)
| (
Recordings.end_time.between(
Event.start_time, Event.end_time
)
)
),
),
)
.where(Recordings.id.is_null())
)
update = Event.update(has_clip=False).where(Event.id << event_no_recordings)
update.execute()
event_paths = list(retain.keys())
# Handle deleted cameras
no_camera_recordings: Recordings = Recordings.select().where(
Recordings.camera.not_in(list(self.config.cameras.keys())),
Recordings.path.not_in(event_paths),
)
for recording in no_camera_recordings:
expire_days = self.config.record.retain_days
expire_before = (
datetime.datetime.now() - datetime.timedelta(days=expire_days)
).timestamp()
if recording.end_time >= expire_before:
Path(recording.path).unlink(missing_ok=True)
Recordings.delete_by_id(recording.id)
# When deleting recordings without events, we have to keep at LEAST the configured max clip duration
for camera, config in self.config.cameras.items():
min_end = (
datetime.datetime.now()
- datetime.timedelta(seconds=config.record.events.max_seconds)
).timestamp()
recordings: Recordings = Recordings.select().where(
Recordings.camera == camera,
Recordings.path.not_in(event_paths),
Recordings.end_time < min_end,
)
for recording in recordings:
expire_days = config.record.retain_days
expire_before = (
datetime.datetime.now() - datetime.timedelta(days=expire_days)
).timestamp()
if recording.end_time >= expire_before:
Path(recording.path).unlink(missing_ok=True)
Recordings.delete_by_id(recording.id)
def expire_files(self):
default_expire = (
datetime.datetime.now().timestamp()
- SECONDS_IN_DAY * self.config.record.retain_days
)
delete_before = {}
for name, camera in self.config.cameras.items():
delete_before[name] = (
@ -122,19 +256,22 @@ class RecordingMaintainer(threading.Thread):
)
for p in Path("/media/frigate/recordings").rglob("*.mp4"):
if not p.parent.name in delete_before:
# Ignore files that have a record in the recordings DB
if Recordings.select().where(Recordings.path == str(p)).count():
continue
if p.stat().st_mtime < delete_before[p.parent.name]:
Recordings.delete().where(Recordings.path == str(p)).execute()
if p.stat().st_mtime < delete_before.get(p.parent.name, default_expire):
p.unlink(missing_ok=True)
def run(self):
for counter in itertools.cycle(range(60)):
if self.stop_event.wait(10):
# only expire events every 10 minutes, but check for new files every 5 seconds
for counter in itertools.cycle(range(120)):
if self.stop_event.wait(5):
logger.info(f"Exiting recording maintenance...")
break
# only expire events every 10 minutes, but check for new files every 10 seconds
if counter % 12 == 0:
self.expire_recordings()
if counter == 0:
self.expire_files()
remove_empty_directories(RECORD_DIR)

View File

@ -198,7 +198,6 @@ class TestConfig(unittest.TestCase):
assert len(back_camera.objects.filters["person"].raw_mask) == 1
def test_default_input_args(self):
config = {
"mqtt": {"host": "mqtt"},
"cameras": {

View File

@ -115,8 +115,8 @@ export default function Event({ eventId }) {
options={{
sources: [
{
src: `${apiHost}/clips/${data.camera}-${eventId}.mp4`,
type: 'video/mp4',
src: `${apiHost}/vod/event/${eventId}/index.m3u8`,
type: 'application/vnd.apple.mpegurl',
},
],
poster: data.has_snapshot
@ -127,7 +127,7 @@ export default function Event({ eventId }) {
onReady={(player) => {}}
/>
<div className="text-center">
<Button className="mx-2" color="blue" href={`${apiHost}/clips/${data.camera}-${eventId}.mp4`} download>
<Button className="mx-2" color="blue" href={`${apiHost}/api/events/${eventId}/clip.mp4`} download>
<Clip className="w-6" /> Download Clip
</Button>
<Button className="mx-2" color="blue" href={`${apiHost}/clips/${data.camera}-${eventId}.jpg`} download>