Skip to content

Instantly share code, notes, and snippets.

@a9udn9u
Last active December 6, 2024 06:27
Show Gist options
  • Save a9udn9u/7721fe257f78ae5155a51bc7133a7ff7 to your computer and use it in GitHub Desktop.
Save a9udn9u/7721fe257f78ae5155a51bc7133a7ff7 to your computer and use it in GitHub Desktop.
Export Frigate Recordings
#!/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