2023-06-08 13:32:35 +02:00
|
|
|
"""Export recordings to storage."""
|
|
|
|
|
|
|
|
import datetime
|
|
|
|
import logging
|
|
|
|
import os
|
|
|
|
import subprocess as sp
|
|
|
|
import threading
|
|
|
|
from enum import Enum
|
2023-10-31 01:24:11 +01:00
|
|
|
from pathlib import Path
|
2023-06-08 13:32:35 +02:00
|
|
|
|
|
|
|
from frigate.config import FrigateConfig
|
|
|
|
from frigate.const import EXPORT_DIR, MAX_PLAYLIST_SECONDS
|
|
|
|
from frigate.ffmpeg_presets import (
|
|
|
|
EncodeTypeEnum,
|
|
|
|
parse_preset_hardware_acceleration_encode,
|
|
|
|
)
|
2023-09-21 12:24:49 +02:00
|
|
|
from frigate.models import Recordings
|
2023-06-08 13:32:35 +02:00
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
2023-09-21 14:20:05 +02:00
|
|
|
TIMELAPSE_DATA_INPUT_ARGS = "-an -skip_frame nokey"
|
|
|
|
|
|
|
|
|
2023-09-21 12:22:35 +02:00
|
|
|
def lower_priority():
|
|
|
|
os.nice(10)
|
|
|
|
|
|
|
|
|
2023-06-08 13:32:35 +02:00
|
|
|
class PlaybackFactorEnum(str, Enum):
|
|
|
|
realtime = "realtime"
|
|
|
|
timelapse_25x = "timelapse_25x"
|
|
|
|
|
|
|
|
|
|
|
|
class RecordingExporter(threading.Thread):
|
|
|
|
"""Exports a specific set of recordings for a camera to storage as a single file."""
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
config: FrigateConfig,
|
|
|
|
camera: str,
|
2024-03-26 22:37:45 +01:00
|
|
|
name: str,
|
2023-06-08 13:32:35 +02:00
|
|
|
start_time: int,
|
|
|
|
end_time: int,
|
|
|
|
playback_factor: PlaybackFactorEnum,
|
|
|
|
) -> None:
|
|
|
|
threading.Thread.__init__(self)
|
|
|
|
self.config = config
|
|
|
|
self.camera = camera
|
2024-03-26 22:37:45 +01:00
|
|
|
self.user_provided_name = name
|
2023-06-08 13:32:35 +02:00
|
|
|
self.start_time = start_time
|
|
|
|
self.end_time = end_time
|
|
|
|
self.playback_factor = playback_factor
|
|
|
|
|
|
|
|
def get_datetime_from_timestamp(self, timestamp: int) -> str:
|
|
|
|
"""Convenience fun to get a simple date time from timestamp."""
|
2023-10-07 16:40:20 +02:00
|
|
|
return datetime.datetime.fromtimestamp(timestamp).strftime("%Y_%m_%d_%H_%M")
|
2023-06-08 13:32:35 +02:00
|
|
|
|
|
|
|
def run(self) -> None:
|
|
|
|
logger.debug(
|
|
|
|
f"Beginning export for {self.camera} from {self.start_time} to {self.end_time}"
|
|
|
|
)
|
2024-03-26 22:37:45 +01:00
|
|
|
file_name = (
|
|
|
|
self.user_provided_name
|
2024-04-03 16:02:07 +02:00
|
|
|
or f"{self.camera}_{self.get_datetime_from_timestamp(self.start_time)}__{self.get_datetime_from_timestamp(self.end_time)}"
|
2024-03-26 22:37:45 +01:00
|
|
|
)
|
|
|
|
file_path = f"{EXPORT_DIR}/in_progress.{file_name}.mp4"
|
|
|
|
final_file_path = f"{EXPORT_DIR}/{file_name}.mp4"
|
2023-06-08 13:32:35 +02:00
|
|
|
|
|
|
|
if (self.end_time - self.start_time) <= MAX_PLAYLIST_SECONDS:
|
|
|
|
playlist_lines = f"http://127.0.0.1:5000/vod/{self.camera}/start/{self.start_time}/end/{self.end_time}/index.m3u8"
|
|
|
|
ffmpeg_input = (
|
|
|
|
f"-y -protocol_whitelist pipe,file,http,tcp -i {playlist_lines}"
|
|
|
|
)
|
|
|
|
else:
|
|
|
|
playlist_lines = []
|
|
|
|
|
2023-09-21 12:24:49 +02:00
|
|
|
# get full set of recordings
|
|
|
|
export_recordings = (
|
|
|
|
Recordings.select()
|
|
|
|
.where(
|
|
|
|
Recordings.start_time.between(self.start_time, self.end_time)
|
|
|
|
| Recordings.end_time.between(self.start_time, self.end_time)
|
|
|
|
| (
|
|
|
|
(self.start_time > Recordings.start_time)
|
|
|
|
& (self.end_time < Recordings.end_time)
|
|
|
|
)
|
|
|
|
)
|
|
|
|
.where(Recordings.camera == self.camera)
|
|
|
|
.order_by(Recordings.start_time.asc())
|
|
|
|
)
|
|
|
|
|
|
|
|
# Use pagination to process records in chunks
|
|
|
|
page_size = 1000
|
|
|
|
num_pages = (export_recordings.count() + page_size - 1) // page_size
|
|
|
|
|
|
|
|
for page in range(1, num_pages + 1):
|
|
|
|
playlist = export_recordings.paginate(page, page_size)
|
2023-06-08 13:32:35 +02:00
|
|
|
playlist_lines.append(
|
2023-09-21 12:24:49 +02:00
|
|
|
f"file 'http://127.0.0.1:5000/vod/{self.camera}/start/{float(playlist[0].start_time)}/end/{float(playlist[-1].end_time)}/index.m3u8'"
|
2023-06-08 13:32:35 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
ffmpeg_input = "-y -protocol_whitelist pipe,file,http,tcp -f concat -safe 0 -i /dev/stdin"
|
|
|
|
|
|
|
|
if self.playback_factor == PlaybackFactorEnum.realtime:
|
|
|
|
ffmpeg_cmd = (
|
2024-04-08 14:36:50 +02:00
|
|
|
f"ffmpeg -hide_banner {ffmpeg_input} -c copy -movflags +faststart {file_path}"
|
2023-06-08 13:32:35 +02:00
|
|
|
).split(" ")
|
|
|
|
elif self.playback_factor == PlaybackFactorEnum.timelapse_25x:
|
|
|
|
ffmpeg_cmd = (
|
|
|
|
parse_preset_hardware_acceleration_encode(
|
|
|
|
self.config.ffmpeg.hwaccel_args,
|
2023-09-21 14:20:05 +02:00
|
|
|
f"{TIMELAPSE_DATA_INPUT_ARGS} {ffmpeg_input}",
|
2024-04-08 14:36:50 +02:00
|
|
|
f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart {file_path}",
|
2023-06-08 13:32:35 +02:00
|
|
|
EncodeTypeEnum.timelapse,
|
|
|
|
)
|
|
|
|
).split(" ")
|
|
|
|
|
|
|
|
p = sp.run(
|
|
|
|
ffmpeg_cmd,
|
|
|
|
input="\n".join(playlist_lines),
|
|
|
|
encoding="ascii",
|
2023-09-21 12:22:35 +02:00
|
|
|
preexec_fn=lower_priority,
|
2023-06-08 13:32:35 +02:00
|
|
|
capture_output=True,
|
|
|
|
)
|
|
|
|
|
|
|
|
if p.returncode != 0:
|
|
|
|
logger.error(
|
|
|
|
f"Failed to export recording for command {' '.join(ffmpeg_cmd)}"
|
|
|
|
)
|
|
|
|
logger.error(p.stderr)
|
2024-03-26 22:37:45 +01:00
|
|
|
Path(file_path).unlink(missing_ok=True)
|
2023-06-08 13:32:35 +02:00
|
|
|
return
|
|
|
|
|
2024-03-26 22:37:45 +01:00
|
|
|
logger.debug(f"Updating finalized export {file_path}")
|
|
|
|
os.rename(file_path, final_file_path)
|
|
|
|
logger.debug(f"Finished exporting {file_path}")
|