Last active
July 17, 2023 19:55
-
-
Save fakuivan/b5f2bb82999cff772fcc546a4bdbcb26 to your computer and use it in GitHub Desktop.
Basic rtsp stream recorder written on bash (python exists for a reason)
This file contains 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
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 |
This file contains 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
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" ] | |
This file contains 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
#!/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 | |
This file contains 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
#!/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