record.py 9.7 KB

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