record.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. import datetime
  2. import itertools
  3. import logging
  4. import os
  5. import random
  6. import shutil
  7. import string
  8. import subprocess as sp
  9. import threading
  10. from pathlib import Path
  11. import psutil
  12. from peewee import JOIN, DoesNotExist
  13. from frigate.config import FrigateConfig
  14. from frigate.const import CACHE_DIR, RECORD_DIR
  15. from frigate.models import Event, Recordings
  16. logger = logging.getLogger(__name__)
  17. SECONDS_IN_DAY = 60 * 60 * 24
  18. def remove_empty_directories(directory):
  19. # list all directories recursively and sort them by path,
  20. # longest first
  21. paths = sorted(
  22. [x[0] for x in os.walk(RECORD_DIR)],
  23. key=lambda p: len(str(p)),
  24. reverse=True,
  25. )
  26. for path in paths:
  27. # don't delete the parent
  28. if path == RECORD_DIR:
  29. continue
  30. if len(os.listdir(path)) == 0:
  31. os.rmdir(path)
  32. class RecordingMaintainer(threading.Thread):
  33. def __init__(self, config: FrigateConfig, stop_event):
  34. threading.Thread.__init__(self)
  35. self.name = "recording_maint"
  36. self.config = config
  37. self.stop_event = stop_event
  38. def move_files(self):
  39. recordings = [
  40. d
  41. for d in os.listdir(CACHE_DIR)
  42. if os.path.isfile(os.path.join(CACHE_DIR, d))
  43. and d.endswith(".mp4")
  44. and not d.startswith("clip_")
  45. ]
  46. files_in_use = []
  47. for process in psutil.process_iter():
  48. try:
  49. if process.name() != "ffmpeg":
  50. continue
  51. flist = process.open_files()
  52. if flist:
  53. for nt in flist:
  54. if nt.path.startswith(CACHE_DIR):
  55. files_in_use.append(nt.path.split("/")[-1])
  56. except:
  57. continue
  58. for f in recordings:
  59. # Skip files currently in use
  60. if f in files_in_use:
  61. continue
  62. cache_path = os.path.join(CACHE_DIR, f)
  63. basename = os.path.splitext(f)[0]
  64. camera, date = basename.rsplit("-", maxsplit=1)
  65. start_time = datetime.datetime.strptime(date, "%Y%m%d%H%M%S")
  66. # Just delete files if recordings are turned off
  67. if (
  68. not camera in self.config.cameras
  69. or not self.config.cameras[camera].record.enabled
  70. ):
  71. Path(cache_path).unlink(missing_ok=True)
  72. continue
  73. ffprobe_cmd = [
  74. "ffprobe",
  75. "-v",
  76. "error",
  77. "-show_entries",
  78. "format=duration",
  79. "-of",
  80. "default=noprint_wrappers=1:nokey=1",
  81. f"{cache_path}",
  82. ]
  83. p = sp.run(ffprobe_cmd, capture_output=True)
  84. if p.returncode == 0:
  85. duration = float(p.stdout.decode().strip())
  86. end_time = start_time + datetime.timedelta(seconds=duration)
  87. else:
  88. logger.info(f"bad file: {f}")
  89. Path(cache_path).unlink(missing_ok=True)
  90. continue
  91. directory = os.path.join(
  92. RECORD_DIR, start_time.strftime("%Y-%m/%d/%H"), camera
  93. )
  94. if not os.path.exists(directory):
  95. os.makedirs(directory)
  96. file_name = f"{start_time.strftime('%M.%S.mp4')}"
  97. file_path = os.path.join(directory, file_name)
  98. # copy then delete is required when recordings are stored on some network drives
  99. shutil.copyfile(cache_path, file_path)
  100. os.remove(cache_path)
  101. rand_id = "".join(
  102. random.choices(string.ascii_lowercase + string.digits, k=6)
  103. )
  104. Recordings.create(
  105. id=f"{start_time.timestamp()}-{rand_id}",
  106. camera=camera,
  107. path=file_path,
  108. start_time=start_time.timestamp(),
  109. end_time=end_time.timestamp(),
  110. duration=duration,
  111. )
  112. def run(self):
  113. # Check for new files every 5 seconds
  114. while not self.stop_event.wait(5):
  115. self.move_files()
  116. logger.info(f"Exiting recording maintenance...")
  117. class RecordingCleanup(threading.Thread):
  118. def __init__(self, config: FrigateConfig, stop_event):
  119. threading.Thread.__init__(self)
  120. self.name = "recording_cleanup"
  121. self.config = config
  122. self.stop_event = stop_event
  123. def clean_tmp_clips(self):
  124. # delete any clips more than 5 minutes old
  125. for p in Path("/tmp/cache").rglob("clip_*.mp4"):
  126. logger.debug(f"Checking tmp clip {p}.")
  127. if p.stat().st_mtime < (datetime.datetime.now().timestamp() - 60 * 1):
  128. logger.debug("Deleting tmp clip.")
  129. p.unlink(missing_ok=True)
  130. def expire_recordings(self):
  131. logger.debug("Start expire recordings (new).")
  132. logger.debug("Start deleted cameras.")
  133. # Handle deleted cameras
  134. expire_days = self.config.record.retain_days
  135. expire_before = (
  136. datetime.datetime.now() - datetime.timedelta(days=expire_days)
  137. ).timestamp()
  138. no_camera_recordings: Recordings = Recordings.select().where(
  139. Recordings.camera.not_in(list(self.config.cameras.keys())),
  140. Recordings.end_time < expire_before,
  141. )
  142. deleted_recordings = set()
  143. for recording in no_camera_recordings:
  144. Path(recording.path).unlink(missing_ok=True)
  145. deleted_recordings.add(recording.id)
  146. logger.debug(f"Expiring {len(deleted_recordings)} recordings")
  147. Recordings.delete().where(Recordings.id << deleted_recordings).execute()
  148. logger.debug("End deleted cameras.")
  149. logger.debug("Start all cameras.")
  150. for camera, config in self.config.cameras.items():
  151. logger.debug(f"Start camera: {camera}.")
  152. # When deleting recordings without events, we have to keep at LEAST the configured max clip duration
  153. min_end = (
  154. datetime.datetime.now()
  155. - datetime.timedelta(seconds=config.record.events.max_seconds)
  156. ).timestamp()
  157. expire_days = config.record.retain_days
  158. expire_before = (
  159. datetime.datetime.now() - datetime.timedelta(days=expire_days)
  160. ).timestamp()
  161. expire_date = min(min_end, expire_before)
  162. # Get recordings to check for expiration
  163. recordings: Recordings = (
  164. Recordings.select()
  165. .where(
  166. Recordings.camera == camera,
  167. Recordings.end_time < expire_date,
  168. )
  169. .order_by(Recordings.start_time.desc())
  170. )
  171. # Get all the events to check against
  172. events: Event = (
  173. Event.select()
  174. .where(
  175. Event.camera == camera, Event.end_time < expire_date, Event.has_clip
  176. )
  177. .order_by(Event.start_time.desc())
  178. .objects()
  179. )
  180. # loop over recordings and see if they overlap with any non-expired events
  181. event_start = 0
  182. deleted_recordings = set()
  183. for recording in recordings.objects().iterator():
  184. keep = False
  185. # since the events and recordings are sorted, we can skip events
  186. # that start after the previous recording segment ended
  187. for idx in range(event_start, len(events)):
  188. event = events[idx]
  189. # if the next event ends before this segment starts, break
  190. if event.end_time < recording.start_time:
  191. break
  192. # if the next event starts after the current segment ends, skip it
  193. if event.start_time > recording.end_time:
  194. event_start = idx
  195. continue
  196. keep = True
  197. # Delete recordings outside of the retention window
  198. if not keep:
  199. Path(recording.path).unlink(missing_ok=True)
  200. deleted_recordings.add(recording.id)
  201. logger.debug(f"Expiring {len(deleted_recordings)} recordings")
  202. (Recordings.delete().where(Recordings.id << deleted_recordings).execute())
  203. logger.debug(f"End camera: {camera}.")
  204. logger.debug("End all cameras.")
  205. logger.debug("End expire recordings (new).")
  206. def expire_files(self):
  207. logger.debug("Start expire files (legacy).")
  208. default_expire = (
  209. datetime.datetime.now().timestamp()
  210. - SECONDS_IN_DAY * self.config.record.retain_days
  211. )
  212. delete_before = {}
  213. for name, camera in self.config.cameras.items():
  214. delete_before[name] = (
  215. datetime.datetime.now().timestamp()
  216. - SECONDS_IN_DAY * camera.record.retain_days
  217. )
  218. # find all the recordings older than the oldest recording in the db
  219. try:
  220. oldest_recording = (
  221. Recordings.select().order_by(Recordings.start_time.desc()).get()
  222. )
  223. oldest_timestamp = oldest_recording.start_time
  224. except DoesNotExist:
  225. oldest_timestamp = datetime.datetime.now().timestamp()
  226. logger.debug(f"Oldest recording in the db: {oldest_timestamp}")
  227. process = sp.run(
  228. ["find", RECORD_DIR, "-type", "f", "-newermt", f"@{oldest_timestamp}"],
  229. capture_output=True,
  230. text=True,
  231. )
  232. files_to_check = process.stdout.splitlines()
  233. for f in files_to_check:
  234. p = Path(f)
  235. if p.stat().st_mtime < delete_before.get(p.parent.name, default_expire):
  236. p.unlink(missing_ok=True)
  237. logger.debug("End expire files (legacy).")
  238. def run(self):
  239. # Expire recordings every minute, clean directories every hour.
  240. for counter in itertools.cycle(range(60)):
  241. if self.stop_event.wait(60):
  242. logger.info(f"Exiting recording cleanup...")
  243. break
  244. self.expire_recordings()
  245. self.clean_tmp_clips()
  246. if counter == 0:
  247. self.expire_files()
  248. remove_empty_directories(RECORD_DIR)