record.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  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
  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("tmp_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. if f in files_in_use:
  60. continue
  61. cache_path = os.path.join(CACHE_DIR, f)
  62. basename = os.path.splitext(f)[0]
  63. camera, date = basename.rsplit("-", maxsplit=1)
  64. start_time = datetime.datetime.strptime(date, "%Y%m%d%H%M%S")
  65. ffprobe_cmd = [
  66. "ffprobe",
  67. "-v",
  68. "error",
  69. "-show_entries",
  70. "format=duration",
  71. "-of",
  72. "default=noprint_wrappers=1:nokey=1",
  73. f"{cache_path}",
  74. ]
  75. p = sp.run(ffprobe_cmd, capture_output=True)
  76. if p.returncode == 0:
  77. duration = float(p.stdout.decode().strip())
  78. end_time = start_time + datetime.timedelta(seconds=duration)
  79. else:
  80. logger.info(f"bad file: {f}")
  81. Path(cache_path).unlink(missing_ok=True)
  82. continue
  83. directory = os.path.join(
  84. RECORD_DIR, start_time.strftime("%Y-%m/%d/%H"), camera
  85. )
  86. if not os.path.exists(directory):
  87. os.makedirs(directory)
  88. file_name = f"{start_time.strftime('%M.%S.mp4')}"
  89. file_path = os.path.join(directory, file_name)
  90. shutil.move(cache_path, file_path)
  91. rand_id = "".join(
  92. random.choices(string.ascii_lowercase + string.digits, k=6)
  93. )
  94. Recordings.create(
  95. id=f"{start_time.timestamp()}-{rand_id}",
  96. camera=camera,
  97. path=file_path,
  98. start_time=start_time.timestamp(),
  99. end_time=end_time.timestamp(),
  100. duration=duration,
  101. )
  102. def expire_recordings(self):
  103. event_recordings = Recordings.select(
  104. Recordings.id.alias("recording_id"),
  105. Recordings.camera,
  106. Recordings.path,
  107. Recordings.end_time,
  108. Event.id.alias("event_id"),
  109. Event.label,
  110. ).join(
  111. Event,
  112. on=(
  113. (Recordings.camera == Event.camera)
  114. & (
  115. (Recordings.start_time.between(Event.start_time, Event.end_time))
  116. | (Recordings.end_time.between(Event.start_time, Event.end_time))
  117. ),
  118. ),
  119. )
  120. retain = {}
  121. for recording in event_recordings:
  122. # Set default to delete
  123. if recording.path not in retain:
  124. retain[recording.path] = False
  125. # Handle deleted cameras that still have recordings and events
  126. if recording.camera in self.config.cameras:
  127. record_config = self.config.cameras[recording.camera].record
  128. else:
  129. record_config = self.config.record
  130. # Check event retention and set to True if within window
  131. expire_days_event = (
  132. 0
  133. if not record_config.events.enabled
  134. else record_config.events.retain.objects.get(
  135. recording.event.label, record_config.events.retain.default
  136. )
  137. )
  138. expire_before_event = (
  139. datetime.datetime.now() - datetime.timedelta(days=expire_days_event)
  140. ).timestamp()
  141. if recording.end_time >= expire_before_event:
  142. retain[recording.path] = True
  143. # Check recording retention and set to True if within window
  144. expire_days_record = record_config.retain_days
  145. expire_before_record = (
  146. datetime.datetime.now() - datetime.timedelta(days=expire_days_record)
  147. ).timestamp()
  148. if recording.end_time > expire_before_record:
  149. retain[recording.path] = True
  150. # Actually expire recordings
  151. delete_paths = [path for path, keep in retain.items() if not keep]
  152. for path in delete_paths:
  153. Path(path).unlink(missing_ok=True)
  154. Recordings.delete().where(Recordings.path << delete_paths).execute()
  155. # Update Events to reflect deleted recordings
  156. event_no_recordings = (
  157. Event.select()
  158. .join(
  159. Recordings,
  160. JOIN.LEFT_OUTER,
  161. on=(
  162. (Recordings.camera == Event.camera)
  163. & (
  164. (
  165. Recordings.start_time.between(
  166. Event.start_time, Event.end_time
  167. )
  168. )
  169. | (
  170. Recordings.end_time.between(
  171. Event.start_time, Event.end_time
  172. )
  173. )
  174. ),
  175. ),
  176. )
  177. .where(Recordings.id.is_null())
  178. )
  179. Event.update(has_clip=False).where(Event.id << event_no_recordings).execute()
  180. event_paths = list(retain.keys())
  181. # Handle deleted cameras
  182. no_camera_recordings: Recordings = Recordings.select().where(
  183. Recordings.camera.not_in(list(self.config.cameras.keys())),
  184. Recordings.path.not_in(event_paths),
  185. )
  186. for recording in no_camera_recordings:
  187. expire_days = self.config.record.retain_days
  188. expire_before = (
  189. datetime.datetime.now() - datetime.timedelta(days=expire_days)
  190. ).timestamp()
  191. if recording.end_time < expire_before:
  192. Path(recording.path).unlink(missing_ok=True)
  193. Recordings.delete_by_id(recording.id)
  194. # When deleting recordings without events, we have to keep at LEAST the configured max clip duration
  195. for camera, config in self.config.cameras.items():
  196. min_end = (
  197. datetime.datetime.now()
  198. - datetime.timedelta(seconds=config.record.events.max_seconds)
  199. ).timestamp()
  200. recordings: Recordings = Recordings.select().where(
  201. Recordings.camera == camera,
  202. Recordings.path.not_in(event_paths),
  203. Recordings.end_time < min_end,
  204. )
  205. for recording in recordings:
  206. expire_days = config.record.retain_days
  207. expire_before = (
  208. datetime.datetime.now() - datetime.timedelta(days=expire_days)
  209. ).timestamp()
  210. if recording.end_time < expire_before:
  211. Path(recording.path).unlink(missing_ok=True)
  212. Recordings.delete_by_id(recording.id)
  213. def expire_files(self):
  214. default_expire = (
  215. datetime.datetime.now().timestamp()
  216. - SECONDS_IN_DAY * self.config.record.retain_days
  217. )
  218. delete_before = {}
  219. for name, camera in self.config.cameras.items():
  220. delete_before[name] = (
  221. datetime.datetime.now().timestamp()
  222. - SECONDS_IN_DAY * camera.record.retain_days
  223. )
  224. for p in Path("/media/frigate/recordings").rglob("*.mp4"):
  225. # Ignore files that have a record in the recordings DB
  226. if Recordings.select().where(Recordings.path == str(p)).count():
  227. continue
  228. if p.stat().st_mtime < delete_before.get(p.parent.name, default_expire):
  229. p.unlink(missing_ok=True)
  230. def run(self):
  231. # only expire events every 10 minutes, but check for new files every 5 seconds
  232. for counter in itertools.cycle(range(120)):
  233. if self.stop_event.wait(5):
  234. logger.info(f"Exiting recording maintenance...")
  235. break
  236. if counter % 12 == 0:
  237. self.expire_recordings()
  238. if counter == 0:
  239. self.expire_files()
  240. remove_empty_directories(RECORD_DIR)
  241. self.move_files()