Skip to content

Instantly share code, notes, and snippets.

@fakuivan
Last active July 17, 2023 19:55
Show Gist options
  • Save fakuivan/b5f2bb82999cff772fcc546a4bdbcb26 to your computer and use it in GitHub Desktop.
Save fakuivan/b5f2bb82999cff772fcc546a4bdbcb26 to your computer and use it in GitHub Desktop.
Basic rtsp stream recorder written on bash (python exists for a reason)
services:
camera-1:
image: fakuivan/simplistic-rtsp-nvr:latest
build: .
command:
- rtsp://<username>:<password>@<camera-ip>/Streaming/channels/1/
- /storage/camera-1
# Max size for rotated video files, in k, 1G is 1024**2
- "1048576"
volumes:
- ./storage:/storage
FROM alpine:3
RUN apk add --no-cache ffmpeg inotify-tools bash python3 coreutils tzdata
RUN mkdir /app
WORKDIR /app
COPY ./recorder.sh ./util.sh /app/
ENTRYPOINT [ "/app/recorder.sh" ]
#!/usr/bin/env bash
# shellcheck source=util.sh
source "${BASH_SOURCE%/*}/util.sh"
URL="$1"
LOCATION="$2"
USAGE_QUOTA="$3"
BY_EPOCH_DIRNAME="by-epoch"
get_hostname_from_url () {
python3 -c '
import sys
from urllib.parse import urlparse
hostname = urlparse(sys.argv[1]).hostname
if hostname is None:
sys.exit(1)
print(hostname)
' "$1"
}
find_first_file () {
python3 -c '
import sys
from pathlib import Path
print(min(str(file) for file in Path.iterdir(Path(sys.argv[1])) if file.is_file()))
' "$@"
}
log () {
echo "$(escaped "$1")" "${2@Q}";
}
log_on_error () {
local output;
local errno;
if capture_output output "$@"; then
return 0;
else
errno=$?
fi
log error.command "$(escaped "$@") failed with error code $errno: ${output@Q}"
return $errno
}
log_command () {
log info "Calling command:"
log info.command "$(escaped "$@")"
"$@"
}
start_recording () {
local url="$1"
local location="$2"
local segment_interval="$3" # in seconds
local args=(-hide_banner -loglevel error
-rtsp_transport tcp -i "$url" -f segment -strftime 1
-segment_time "$segment_interval" -segment_atclocktime 1
-segment_format flv -c copy -reset_timestamps 1
"$location/$BY_EPOCH_DIRNAME/%s.flv")
log_on_error stderr_to_stdout mkdir -p "$location/$BY_EPOCH_DIRNAME" || return 1
log_command stdout_to "$location"/ffmpeg.log stderr_to_stdout ffmpeg "${args[@]}"
}
mkdir_parents () {
local parents
parents="$(dirname -- "$1")" || return $?
mkdir -p -- "$parents" || return $?
}
ensure_parents () {
mkdir_parents "$1" &&
"${@:2}" "$1"
}
make_date_link () {
local file="$1"
local format="$2"
local timestamp
timestamp="$(basename -- "${file%.*}")" || return 1
if ! [[ -f "$file" ]]; then
error "Element ${file@Q} added to timestamp directory is not a file"
return 1
fi
if ! muted normalize_int "$timestamp"; then
error "Name for file ${file@Q} is not a valid timestamp"
return 1
fi
if ! link_name="$(date -d "@$timestamp" "+$format")"; then
error "Failed to format time ${timestamp@Q} as ${format@Q} for file ${file@Q}"
return 1
fi
ensure_parents "../by-date/$link_name" ln -srf "$file"
}
build_time_tree () {
local format="$1"
local file
log info "Building date tree..."
for file in *; do
log_on_error stderr_to_stdout make_date_link "$file" "$format"
done
log info "Finished building date tree."
}
watch_time_tree () {
local format="$1"
local file errno
log info "Watching directory to create by-date links"
while file="$(stderr_to_stdout inotifywait -q --format %f -e create .)"; errno=$?; (exit "$errno"); do
log_on_error stderr_to_stdout make_date_link "$file" "$format"
done
if ! (exit "$errno"); then
log error "Inotiftwait exited unexpectedly with error $errno: ${file@Q}"
fi
return "$errno"
}
time_tree () {
local location="$1"
local format="$2"
ensure_parents "$location/$BY_EPOCH_DIRNAME/." cd || return $?
watch_time_tree "$format" &
build_time_tree "$format" &
}
wait_for_host () {
local address url="$1" wait_time="$2";
if ! address="$(get_hostname_from_url "$url")"; then
log error "Unable to extract host address from URL"
return 1
fi
log info "Got host address from URL, waiting for it to be up before start recording"
until muted ping -w "$wait_time" -c 1 -- "$address" ; do
log warn "Failed to ping $(escaped "$address"), retrying..."
done
log info "Host is up!"
}
check_location () {
local location="$1"
if [[ ! -d "$location" ]]; then
log error "Not a valid directory: $(escaped "$location")"
exit 1
fi
}
delete_for_quota () {
local quota="$1" location="$2" current oldest
if ! muted normalize_int "$quota"; then
log error "Quota value ${quota@Q} is not an integer"
return 1
fi
if ! current="$(stderr_to_stdout du -s "$location/$BY_EPOCH_DIRNAME")"; then
log error "Failed to get occupued storage: ${current@Q}"
return 1
fi
current="$(echo "$current" | sed 's/\s.*$//')";
#log debug "Quota: $quota"
#log debug "Current: $current"
if (( current > quota )); then
if ! oldest="$(find_first_file "$location/$BY_EPOCH_DIRNAME")"; then
log error "Failed to find file to remove"
return 1
fi
#log info "Removing old file to comply with quota"
rm "$oldest" && return 0;
log error "Failed to remove file"
return 1
fi
return 2
}
watch_and_delete () {
local quota="$1" location="$2" first=true error errno
while [[ "$first" == "true" ]] || error="$(stderr_to_stdout inotifywait -qq -e create "$location")"; errno=$?; (exit "$errno"); do
first=false
while delete_for_quota "$quota" "$location"; do
if (( $? == 1 )); then
break;
fi
done
done
if ! (exit "$errno"); then
log error "Inotiftwait exited unexpectedly with error $errno: ${error@Q}"
fi
return "$errno"
}
trap 'log info "Recieved exit signal, exiting..."; exit;' SIGINT SIGTERM
check_location "$LOCATION"
wait_for_host "$URL" 4
( time_tree "$LOCATION" '%Y/%m/%d/%H/%M-%S-%z.flv' )
( watch_and_delete "$USAGE_QUOTA" "$LOCATION" & )
while ! ( start_recording "$URL" "$LOCATION" "$((5 * 60))" ); do
log error 'Recorder stopped, attempting to restart...'
check_location "$LOCATION"
wait_for_host "$URL" 10
sleep 1;
done
#!/usr/bin/env bash
valid_var_name () {
# shellcheck disable=2034
local -n name="$1" 2>/dev/null;
}
swap_stderr_stdout () {
"$@" 3>&2 2>&1 1>&3-
}
stderr_to_stdout () {
"$@" 2>&1
}
stdout_to () {
"${@:2}" > "$1"
}
append_stdout_to () {
"${@:2}" >> "$1"
}
stdout_to_stderr () {
"$@" 1>&2
}
error () {
stdout_to_stderr echo "$@"
}
muted () {
stdout_to /dev/null stderr_to_stdout "$@"
}
quoted () {
echo "${@@Q}"
}
escaped () {
local pad
printf -v pad "%q " "$@"
echo "${pad::-1}"
}
command_exists () {
command -v "$1"
}
normalize_int () {
if [[ -z $1 || -n ${1//[0-9]/} ]]; then
return 1
fi
echo $(("$1"))
}
validate_2d_list () {
local i=0
while [[ $# -gt 0 ]]; do
if ! muted normalize_int "$1"; then
error "Size ${1@Q} is not a valid integer";
return 1
fi
if ! [[ "$1" -le $(($# - 1)) ]]; then
error "Size $1 is larger than the array of given arguments: ${*@Q}"
return 1
fi
shift $(("$1"+1))
((i++))
done
echo "$i"
}
validate_sized_2d_list () {
local size expected_size="$1"
shift
size="$(validate_2d_list "$@")" || return $?
if [[ "$size" != "$expected_size" ]]; then
error "Array given is of size ${size@Q}, expected ${expected_size@Q}"
return 1;
fi
}
ifte () {
validate_sized_2d_list 3 "$@" || return 1
if "${@:2:$1}"; then
shift $(("$1"+1))
fi
shift $(("$1"+1))
"${@:2:$1}"
return 0;
}
ift () {
validate_sized_2d_list 2 "$@" || return 1
if "${@:2:$1}"; then
shift $(("$1"+1))
"${@:2:$1}"
fi
return 0;
}
stderr_if_error () {
set -- "$(mktemp)" "$(mktemp)" "$@"
trap 'rm "$1" "$2"' RETURN
"${@:3}" 2> "$2" > "$1"
set -- "$1" "$2" "$?"
if (exit "$3"); then cat "$1"; else cat "$2"; fi
return "$3"
}
# Captures the output of a command without spawning it into a subshell
capture_output () {
if ! valid_var_name "$1"; then
error "invalid variable name: $1"; return 1
fi
set -- "$1" "$(mktemp)" "${@:2}"
"${@:3}" > "$2"
set -- "$1" "$2" "$?"
eval "$1"='"$(cat "$2")"'
rm "$2"
return "$3"
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment