2020-11-30 04:31:02 +01:00
|
|
|
import datetime
|
2021-05-21 17:39:14 +02:00
|
|
|
import itertools
|
2020-11-30 04:31:02 +01:00
|
|
|
import logging
|
|
|
|
import os
|
2021-06-07 03:24:36 +02:00
|
|
|
import random
|
2021-07-09 22:14:16 +02:00
|
|
|
import shutil
|
2021-06-07 03:24:36 +02:00
|
|
|
import string
|
2020-11-30 04:31:02 +01:00
|
|
|
import subprocess as sp
|
|
|
|
import threading
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
import psutil
|
|
|
|
|
2021-07-09 22:14:16 +02:00
|
|
|
from peewee import JOIN
|
|
|
|
|
2020-11-30 04:31:02 +01:00
|
|
|
from frigate.config import FrigateConfig
|
2021-07-09 22:14:16 +02:00
|
|
|
from frigate.const import CACHE_DIR, RECORD_DIR
|
|
|
|
from frigate.models import Event, Recordings
|
2020-11-30 04:31:02 +01:00
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
SECONDS_IN_DAY = 60 * 60 * 24
|
|
|
|
|
2021-02-17 14:23:32 +01:00
|
|
|
|
2020-12-01 04:08:47 +01:00
|
|
|
def remove_empty_directories(directory):
|
2021-02-17 14:23:32 +01:00
|
|
|
# list all directories recursively and sort them by path,
|
|
|
|
# longest first
|
|
|
|
paths = sorted(
|
|
|
|
[x[0] for x in os.walk(RECORD_DIR)],
|
|
|
|
key=lambda p: len(str(p)),
|
|
|
|
reverse=True,
|
|
|
|
)
|
|
|
|
for path in paths:
|
|
|
|
# don't delete the parent
|
|
|
|
if path == RECORD_DIR:
|
|
|
|
continue
|
|
|
|
if len(os.listdir(path)) == 0:
|
|
|
|
os.rmdir(path)
|
|
|
|
|
2020-12-01 04:08:47 +01:00
|
|
|
|
2020-11-30 04:31:02 +01:00
|
|
|
class RecordingMaintainer(threading.Thread):
|
|
|
|
def __init__(self, config: FrigateConfig, stop_event):
|
|
|
|
threading.Thread.__init__(self)
|
2021-02-17 14:23:32 +01:00
|
|
|
self.name = "recording_maint"
|
2020-11-30 04:31:02 +01:00
|
|
|
self.config = config
|
|
|
|
self.stop_event = stop_event
|
|
|
|
|
|
|
|
def move_files(self):
|
2021-02-17 14:23:32 +01:00
|
|
|
recordings = [
|
|
|
|
d
|
2021-07-09 22:14:16 +02:00
|
|
|
for d in os.listdir(CACHE_DIR)
|
|
|
|
if os.path.isfile(os.path.join(CACHE_DIR, d))
|
|
|
|
and d.endswith(".mp4")
|
2021-08-10 15:27:31 +02:00
|
|
|
and not d.startswith("clip_")
|
2021-02-17 14:23:32 +01:00
|
|
|
]
|
2020-11-30 04:31:02 +01:00
|
|
|
|
|
|
|
files_in_use = []
|
|
|
|
for process in psutil.process_iter():
|
|
|
|
try:
|
2021-02-17 14:23:32 +01:00
|
|
|
if process.name() != "ffmpeg":
|
2020-12-24 21:23:59 +01:00
|
|
|
continue
|
2020-11-30 04:31:02 +01:00
|
|
|
flist = process.open_files()
|
|
|
|
if flist:
|
|
|
|
for nt in flist:
|
2021-07-09 22:14:16 +02:00
|
|
|
if nt.path.startswith(CACHE_DIR):
|
2021-02-17 14:23:32 +01:00
|
|
|
files_in_use.append(nt.path.split("/")[-1])
|
2020-11-30 04:31:02 +01:00
|
|
|
except:
|
|
|
|
continue
|
|
|
|
|
|
|
|
for f in recordings:
|
2021-07-11 21:34:48 +02:00
|
|
|
# Skip files currently in use
|
2020-11-30 04:31:02 +01:00
|
|
|
if f in files_in_use:
|
|
|
|
continue
|
|
|
|
|
2021-07-09 22:14:16 +02:00
|
|
|
cache_path = os.path.join(CACHE_DIR, f)
|
2021-05-22 05:35:25 +02:00
|
|
|
basename = os.path.splitext(f)[0]
|
|
|
|
camera, date = basename.rsplit("-", maxsplit=1)
|
|
|
|
start_time = datetime.datetime.strptime(date, "%Y%m%d%H%M%S")
|
|
|
|
|
2021-07-11 21:34:48 +02:00
|
|
|
# Just delete files if recordings are turned off
|
|
|
|
if not self.config.cameras[camera].record.enabled:
|
|
|
|
Path(cache_path).unlink(missing_ok=True)
|
|
|
|
continue
|
|
|
|
|
2021-05-22 05:35:25 +02:00
|
|
|
ffprobe_cmd = [
|
|
|
|
"ffprobe",
|
|
|
|
"-v",
|
|
|
|
"error",
|
|
|
|
"-show_entries",
|
|
|
|
"format=duration",
|
|
|
|
"-of",
|
|
|
|
"default=noprint_wrappers=1:nokey=1",
|
2021-07-09 22:14:16 +02:00
|
|
|
f"{cache_path}",
|
2021-05-22 05:35:25 +02:00
|
|
|
]
|
|
|
|
p = sp.run(ffprobe_cmd, capture_output=True)
|
|
|
|
if p.returncode == 0:
|
|
|
|
duration = float(p.stdout.decode().strip())
|
2021-06-07 03:24:36 +02:00
|
|
|
end_time = start_time + datetime.timedelta(seconds=duration)
|
2020-11-30 04:31:02 +01:00
|
|
|
else:
|
|
|
|
logger.info(f"bad file: {f}")
|
2021-07-09 22:14:16 +02:00
|
|
|
Path(cache_path).unlink(missing_ok=True)
|
2020-11-30 04:31:02 +01:00
|
|
|
continue
|
|
|
|
|
2021-02-17 14:23:32 +01:00
|
|
|
directory = os.path.join(
|
|
|
|
RECORD_DIR, start_time.strftime("%Y-%m/%d/%H"), camera
|
|
|
|
)
|
2020-11-30 04:31:02 +01:00
|
|
|
|
|
|
|
if not os.path.exists(directory):
|
|
|
|
os.makedirs(directory)
|
|
|
|
|
|
|
|
file_name = f"{start_time.strftime('%M.%S.mp4')}"
|
2021-06-07 03:24:36 +02:00
|
|
|
file_path = os.path.join(directory, file_name)
|
2020-11-30 04:31:02 +01:00
|
|
|
|
2021-08-17 13:52:15 +02:00
|
|
|
# copy then delete is required when recordings are stored on some network drives
|
|
|
|
shutil.copyfile(cache_path, file_path)
|
|
|
|
os.remove(cache_path)
|
2021-06-07 03:24:36 +02:00
|
|
|
|
|
|
|
rand_id = "".join(
|
|
|
|
random.choices(string.ascii_lowercase + string.digits, k=6)
|
|
|
|
)
|
|
|
|
Recordings.create(
|
|
|
|
id=f"{start_time.timestamp()}-{rand_id}",
|
|
|
|
camera=camera,
|
|
|
|
path=file_path,
|
|
|
|
start_time=start_time.timestamp(),
|
|
|
|
end_time=end_time.timestamp(),
|
|
|
|
duration=duration,
|
|
|
|
)
|
2020-11-30 04:31:02 +01:00
|
|
|
|
2021-07-11 21:34:48 +02:00
|
|
|
def run(self):
|
|
|
|
# Check for new files every 5 seconds
|
|
|
|
while not self.stop_event.wait(5):
|
|
|
|
self.move_files()
|
|
|
|
|
|
|
|
logger.info(f"Exiting recording maintenance...")
|
|
|
|
|
|
|
|
|
|
|
|
class RecordingCleanup(threading.Thread):
|
|
|
|
def __init__(self, config: FrigateConfig, stop_event):
|
|
|
|
threading.Thread.__init__(self)
|
|
|
|
self.name = "recording_cleanup"
|
|
|
|
self.config = config
|
|
|
|
self.stop_event = stop_event
|
|
|
|
|
2021-08-11 14:39:03 +02:00
|
|
|
def clean_tmp_clips(self):
|
|
|
|
# delete any clips more than 5 minutes old
|
|
|
|
for p in Path("/tmp/cache").rglob("clip_*.mp4"):
|
|
|
|
logger.debug(f"Checking tmp clip {p}.")
|
|
|
|
if p.stat().st_mtime < (datetime.datetime.now().timestamp() - 60 * 1):
|
|
|
|
logger.debug("Deleting tmp clip.")
|
|
|
|
p.unlink(missing_ok=True)
|
|
|
|
|
2021-07-09 22:14:16 +02:00
|
|
|
def expire_recordings(self):
|
2021-07-11 06:22:45 +02:00
|
|
|
logger.debug("Start expire recordings (new).")
|
2021-07-09 22:14:16 +02:00
|
|
|
|
2021-07-11 06:22:45 +02:00
|
|
|
logger.debug("Start deleted cameras.")
|
2021-07-09 22:14:16 +02:00
|
|
|
# Handle deleted cameras
|
|
|
|
no_camera_recordings: Recordings = Recordings.select().where(
|
|
|
|
Recordings.camera.not_in(list(self.config.cameras.keys())),
|
|
|
|
)
|
|
|
|
|
|
|
|
for recording in no_camera_recordings:
|
|
|
|
expire_days = self.config.record.retain_days
|
|
|
|
expire_before = (
|
|
|
|
datetime.datetime.now() - datetime.timedelta(days=expire_days)
|
|
|
|
).timestamp()
|
2021-07-10 03:19:39 +02:00
|
|
|
if recording.end_time < expire_before:
|
2021-07-09 22:14:16 +02:00
|
|
|
Path(recording.path).unlink(missing_ok=True)
|
|
|
|
Recordings.delete_by_id(recording.id)
|
2021-07-11 06:22:45 +02:00
|
|
|
logger.debug("End deleted cameras.")
|
2021-07-09 22:14:16 +02:00
|
|
|
|
2021-07-11 06:22:45 +02:00
|
|
|
logger.debug("Start all cameras.")
|
2021-07-09 22:14:16 +02:00
|
|
|
for camera, config in self.config.cameras.items():
|
2021-07-11 06:22:45 +02:00
|
|
|
logger.debug(f"Start camera: {camera}.")
|
|
|
|
# When deleting recordings without events, we have to keep at LEAST the configured max clip duration
|
2021-07-09 22:14:16 +02:00
|
|
|
min_end = (
|
|
|
|
datetime.datetime.now()
|
|
|
|
- datetime.timedelta(seconds=config.record.events.max_seconds)
|
|
|
|
).timestamp()
|
2021-07-11 06:22:45 +02:00
|
|
|
expire_days = config.record.retain_days
|
|
|
|
expire_before = (
|
|
|
|
datetime.datetime.now() - datetime.timedelta(days=expire_days)
|
|
|
|
).timestamp()
|
|
|
|
expire_date = min(min_end, expire_before)
|
|
|
|
|
|
|
|
# Get recordings to remove
|
2021-07-09 22:14:16 +02:00
|
|
|
recordings: Recordings = Recordings.select().where(
|
|
|
|
Recordings.camera == camera,
|
2021-07-11 06:22:45 +02:00
|
|
|
Recordings.end_time < expire_date,
|
2021-07-09 22:14:16 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
for recording in recordings:
|
2021-07-11 06:22:45 +02:00
|
|
|
# See if there are any associated events
|
|
|
|
events: Event = Event.select().where(
|
|
|
|
Event.camera == recording.camera,
|
|
|
|
(
|
|
|
|
Event.start_time.between(
|
|
|
|
recording.start_time, recording.end_time
|
|
|
|
)
|
|
|
|
| Event.end_time.between(
|
|
|
|
recording.start_time, recording.end_time
|
|
|
|
)
|
|
|
|
| (
|
|
|
|
(recording.start_time > Event.start_time)
|
|
|
|
& (recording.end_time < Event.end_time)
|
|
|
|
)
|
|
|
|
),
|
|
|
|
)
|
|
|
|
keep = False
|
|
|
|
event_ids = set()
|
|
|
|
|
|
|
|
event: Event
|
|
|
|
for event in events:
|
|
|
|
event_ids.add(event.id)
|
|
|
|
# Check event/label retention and keep the recording if within window
|
|
|
|
expire_days_event = (
|
|
|
|
0
|
|
|
|
if not config.record.events.enabled
|
|
|
|
else config.record.events.retain.objects.get(
|
|
|
|
event.label, config.record.events.retain.default
|
|
|
|
)
|
|
|
|
)
|
|
|
|
expire_before_event = (
|
|
|
|
datetime.datetime.now()
|
|
|
|
- datetime.timedelta(days=expire_days_event)
|
|
|
|
).timestamp()
|
|
|
|
if recording.end_time >= expire_before_event:
|
|
|
|
keep = True
|
|
|
|
|
|
|
|
# Delete recordings outside of the retention window
|
|
|
|
if not keep:
|
2021-07-09 22:14:16 +02:00
|
|
|
Path(recording.path).unlink(missing_ok=True)
|
|
|
|
Recordings.delete_by_id(recording.id)
|
2021-07-11 06:22:45 +02:00
|
|
|
if event_ids:
|
|
|
|
# Update associated events
|
|
|
|
Event.update(has_clip=False).where(
|
|
|
|
Event.id.in_(list(event_ids))
|
|
|
|
).execute()
|
|
|
|
|
|
|
|
logger.debug(f"End camera: {camera}.")
|
|
|
|
|
|
|
|
logger.debug("End all cameras.")
|
|
|
|
logger.debug("End expire recordings (new).")
|
2021-07-09 22:14:16 +02:00
|
|
|
|
2020-11-30 04:31:02 +01:00
|
|
|
def expire_files(self):
|
2021-07-11 06:22:45 +02:00
|
|
|
logger.debug("Start expire files (legacy).")
|
2021-07-09 22:14:16 +02:00
|
|
|
default_expire = (
|
|
|
|
datetime.datetime.now().timestamp()
|
|
|
|
- SECONDS_IN_DAY * self.config.record.retain_days
|
|
|
|
)
|
2020-11-30 04:31:02 +01:00
|
|
|
delete_before = {}
|
|
|
|
for name, camera in self.config.cameras.items():
|
2021-02-17 14:23:32 +01:00
|
|
|
delete_before[name] = (
|
|
|
|
datetime.datetime.now().timestamp()
|
|
|
|
- SECONDS_IN_DAY * camera.record.retain_days
|
|
|
|
)
|
2020-11-30 04:31:02 +01:00
|
|
|
|
2021-02-17 14:23:32 +01:00
|
|
|
for p in Path("/media/frigate/recordings").rglob("*.mp4"):
|
2021-07-09 22:14:16 +02:00
|
|
|
# Ignore files that have a record in the recordings DB
|
|
|
|
if Recordings.select().where(Recordings.path == str(p)).count():
|
2020-11-30 04:31:02 +01:00
|
|
|
continue
|
2021-07-09 22:14:16 +02:00
|
|
|
if p.stat().st_mtime < delete_before.get(p.parent.name, default_expire):
|
2020-11-30 04:31:02 +01:00
|
|
|
p.unlink(missing_ok=True)
|
|
|
|
|
2021-07-11 06:22:45 +02:00
|
|
|
logger.debug("End expire files (legacy).")
|
|
|
|
|
2020-11-30 04:31:02 +01:00
|
|
|
def run(self):
|
2021-07-11 21:34:48 +02:00
|
|
|
# Expire recordings every minute, clean directories every 5 minutes.
|
|
|
|
for counter in itertools.cycle(range(5)):
|
|
|
|
if self.stop_event.wait(60):
|
|
|
|
logger.info(f"Exiting recording cleanup...")
|
2020-11-30 04:31:02 +01:00
|
|
|
break
|
|
|
|
|
2021-07-11 21:34:48 +02:00
|
|
|
self.expire_recordings()
|
2021-08-11 14:39:03 +02:00
|
|
|
self.clean_tmp_clips()
|
2021-07-09 22:14:16 +02:00
|
|
|
|
2021-05-21 17:39:14 +02:00
|
|
|
if counter == 0:
|
2020-11-30 04:31:02 +01:00
|
|
|
self.expire_files()
|
2020-12-01 14:22:23 +01:00
|
|
|
remove_empty_directories(RECORD_DIR)
|