Last active
December 6, 2024 06:27
-
-
Save a9udn9u/7721fe257f78ae5155a51bc7133a7ff7 to your computer and use it in GitHub Desktop.
Export Frigate Recordings
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/bin/bash | |
# | |
# Export Frigate video recordings to a remote location using Rclone. | |
# | |
# Prerequisites: | |
# 1. Frigate is configured to save recordings | |
# 2. Rclone is configured to read from / write to a remote server | |
# | |
# This script does the following each time it runs: | |
# (For each camera) | |
# 1. Find the last exported clip from the remote location. | |
# 2. If clip exists, get the clip end time, resume exporting from then. | |
# Otherwise, start from the current time. | |
# 3. Export a clip, with pre-defined duration. | |
# 4. Upload the exported clip to the remote location. | |
# 5. Delete the local exported file. | |
# | |
# Exported files will be stored at a subdirectory in the remote location, the | |
# subdirectory name will be the same as the Frigate camera name. | |
# | |
# File names will be in the format of "yyyymmdd-hh-MM", i.e., 20241201-10-05. | |
# Don't set clip duration to be shorter than a minute or old files may be | |
# overwritten. | |
# | |
# Note: Script is only tested on Google Drive | |
# | |
# Frigate API endpoint | |
endpoint="http://localhost:5000/api" | |
# Rclone remote prefix | |
remote="google_drive:/security-camera-recordings" | |
# Clip length in seconds | |
duration=300 | |
# Remote file retention time, supported suffices are: ms|s|m|h|d|w|M|y | |
retention=7d | |
# Frigate export timeout in seconds | |
export_timeout=10 | |
# Delete the local file after uploading to the remote location | |
delete_local=1 | |
# Current timestamp | |
current_ts=$(date "+%s") | |
# CURL command with default parameters | |
curl_exec="curl -s" | |
# Speical error return codes | |
err_not_recorded=101 | |
# All configured Frigate cameras | |
cameras=($($curl_exec "$endpoint/stats" | jq -r '.cameras | keys[]')) | |
# Print an error message | |
echo_err() { | |
echo "$@" >&2 | |
} | |
# Check if a string is blank | |
is_blank() { | |
if [ -z "${1//[[:space:]]/}" ]; then | |
return 0 | |
else | |
return 1 | |
fi | |
} | |
# Compute the clip start time | |
# If previously exported files exist, get the last exported file timestamp to | |
# resume from there. Otherwise, start from the current time by round down to | |
# the nearest checkpoint | |
get_start_ts() { | |
camera="$1" | |
# Convert file name back to date | |
# For example: 20241201-10-05.mp4 -> 2024-12-01 10:05 | |
last_time=$( | |
rclone lsjson "$remote/$camera" 2>/dev/null |\ | |
jq -r 'sort_by(.Path) | .[-1].Path // empty' 2>/dev/null |\ | |
perl -pe 's/^'"$camera"'-(\d{4})(\d{2})(\d{2})-(\d{2})-(\d{2})\.\w+$/$1-$2-$3 $4:$5/' | |
) | |
if is_blank "$last_time"; then | |
# Start from current time, round down to the nearest checkpoint | |
printf "%d" $(($current_ts / $duration * $duration - $duration)) | |
else | |
last_start_ts=$(date -d "$last_time" '+%s') | |
# Resume from the last clip end time, also round down in case of | |
# duration change | |
printf "%d" $(($last_start_ts / $duration * $duration + $duration)) | |
fi | |
} | |
get_end_ts() { | |
start_ts="$1" | |
printf "%d" $(($start_ts + $duration)) | |
} | |
# Check if the file export should proceed | |
should_proceed() { | |
end_ts="$1" | |
if [ "$current_ts" -lt "$end_ts" ]; then | |
# The end time is in the future | |
return 1 | |
fi | |
return 0 | |
} | |
# Create a Frigate export | |
export_clip() { | |
camera="$1" | |
start_ts="$2" | |
end_ts="$3" | |
start_time=$(date -d @$start_ts '+%Y%m%d-%H-%M') | |
clip_name="${camera}-${start_time}" | |
# Kick off exporting | |
result=$( | |
$curl_exec -X POST \ | |
-H "content-type: application/json" \ | |
-d "{\"playback\": \"realtime\", \"name\": \"${clip_name}\"}" \ | |
"$endpoint/export/$camera/start/$start_ts/end/$end_ts" | |
) | |
success=$(echo "$result" | jq -r '.success') | |
message=$(echo "$result" | jq -r '.message') | |
if [[ "true" != "$success" ]]; then | |
echo_err Exporting '"'$clip_name'"' failed: $message | |
if [[ "No recordings found for time range" == "$message" ]]; then | |
return $err_not_recorded | |
fi | |
return 1 | |
fi | |
# Pull export staus, timeout after 10 seconds | |
created_at=$(date "+%s") | |
while true; do | |
result=$( | |
$curl_exec "$endpoint/exports" |\ | |
jq ".[] | select(.name == \"$clip_name\" \ | |
and .date == $start_ts \ | |
and .in_progress == false) \ | |
// empty" \ | |
2>/dev/null | |
) | |
if is_blank "$result"; then | |
now=$(date "+%s") | |
elapsed=$(($now - $created_at)) | |
if [ $elapsed -ge $export_timeout ]; then | |
echo_err Exporting '"'$clip_name'"' timed out | |
return 2 | |
fi | |
sleep 1 | |
else | |
echo "$result" | |
return 0 | |
fi | |
done | |
} | |
# Upload a local clip | |
upload_clip() { | |
camera="$1" | |
local_path="$2" | |
remote_name="$3.${local_path##*.}" | |
# Successful upload won't print a message so any output would be an error | |
error=$(rclone copyto "$local_path" "$remote/$camera/$remote_name" 2>&1) | |
if ! is_blank $error; then | |
echo_err Uploading '"'$remote_name'"' failed: $error | |
return 1 | |
fi | |
return 0 | |
} | |
# Delete an locally exported clip | |
delete_clip() { | |
export_id="$1" | |
result=$($curl_exec -X DELETE "$endpoint/export/$export_id") | |
success=$(echo "$result" | jq -r '.success') | |
message=$(echo "$result" | jq -r '.message') | |
if [[ "true" != "$success" ]]; then | |
echo_err Deleting '"'$export_id'"' failed: $message | |
fi | |
} | |
# Sometimes export stuck at `in_progress` state, clean them up | |
cleanup_stuck_exports() { | |
entries=($( | |
$curl_exec "$endpoint/exports" |\ | |
jq -r '.[] | select(.in_progress == true) | .id' 2>/dev/null | |
)) | |
for entry in "${entries[@]}"; do | |
delete_clip "$entry" | |
done | |
} | |
# Clean up expired remote files | |
cleanup_expired_exports() { | |
camera="$1" | |
error=$(rclone delete "$remote/$camera" --min-age "$retention" 2>&1) | |
if ! is_blank $error; then | |
echo_err Cleaning up expired '"'$remote_name'"' clips failed: $error | |
return 1 | |
fi | |
return 0 | |
} | |
# Main loop | |
for camera in "${cameras[@]}"; do | |
# Loop until clip time catches up to current time | |
start_ts= | |
skip_clip=0 | |
while true; do | |
if [[ $skip_clip -eq 1 ]]; then | |
start_ts=$(($start_ts + $duration)) | |
skip_clip=0 | |
else | |
start_ts=$(get_start_ts "$camera") | |
fi | |
end_ts=$(get_end_ts "$start_ts") | |
echo Exporting ${duration}s '"'$camera'"' clip at $(date -d @$start_ts) | |
if ! should_proceed "$end_ts"; then | |
break | |
fi | |
export_output="$(export_clip "$camera" "$start_ts" "$end_ts")" | |
# Should skip the current time range because recordings are missing | |
if [[ $? -eq $err_not_recorded ]]; then | |
skip_clip=1 | |
continue | |
fi | |
# Export failed, continue to the next camera | |
if is_blank "$export_output"; then | |
break | |
fi | |
file_id="$(echo "$export_output" | jq -r '.id')" | |
file_name="$(echo "$export_output" | jq -r '.name')" | |
file_path="$(echo "$export_output" | jq -r '.video_path')" | |
if ! upload_clip "$camera" "$file_path" "$file_name"; then | |
break | |
fi | |
if [ "0" -ne "$delete_local" ]; then | |
delete_clip "$file_id" | |
fi | |
done | |
cleanup_expired_exports "$camera" | |
done | |
cleanup_stuck_exports | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment