Skip to content

Instantly share code, notes, and snippets.

@abra-chan
Last active October 2, 2026 18:18
Show Gist options
  • Select an option

  • Save abra-chan/456ac46c6b2a7e79fed6e44f240d7775 to your computer and use it in GitHub Desktop.

Select an option

Save abra-chan/456ac46c6b2a7e79fed6e44f240d7775 to your computer and use it in GitHub Desktop.
VOD backup script for OBS
#!/bin/bash
# vod-backup.sh
#
# Copy closed OBS VODs from a local temp folder to a NAS-mounted drive,
# verify each copy with MD5 (openssl), then permanently delete the source.
#
# Designed for macOS (OBS.app, /Volumes mounts) and bash 3.2+.
# OBS closes a file every 15 minutes — run from cron at the same interval:
#
# PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin
# OBS_TEMP_DIR="/Users/$(whoami)/Desktop/stream/tmp"
# OBS_BACKUP_DIR="/Volumes/proto/External Storage/Seagate"
# */15 * * * * /path/to/vod-backup.sh
#
# Required environment:
# OBS_TEMP_DIR e.g. /Users/username/Desktop/stream/tmp
# OBS_BACKUP_DIR e.g. /Volumes/proto/External Storage/Seagate
#
# Optional:
# NOW_PLAYING_FILE defaults to $OBS_TEMP_DIR/now_playing.txt, then parent dir
set -u
# Cron PATH is minimal; prepend common Homebrew openssl 3 locations.
export PATH="/opt/homebrew/opt/openssl@3/bin:/usr/local/opt/openssl@3/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}"
username=$(whoami)
# ---------------------------------------------------------------------------
# Logging (buffered; flushed only when there was something to record)
# Each line is prepended with a newline, timestamp, colon, and space.
# ---------------------------------------------------------------------------
LOG_BUFFER=""
HAD_WORK=false
HAD_ERROR=false
timestamp_now() {
date '+%Y-%m-%d %H:%M:%S'
}
append_log() {
# Literal: newline + timestamp + ": " + message + newline
# so every log line is preceded by a blank line.
LOG_BUFFER="${LOG_BUFFER}
$(timestamp_now): $*
"
}
log_error() {
HAD_ERROR=true
append_log "ERROR: $*"
echo "ERROR: $*" >&2
}
seconds_since() {
# Integer seconds elapsed since the epoch value in $1.
local start=$1
local now
now=$(date +%s)
echo $((now - start))
}
format_secs() {
local s=$1
if [ "$s" -le 0 ]; then
echo "<1s"
else
echo "${s}s"
fi
}
file_mtime() {
# Epoch seconds. macOS uses BSD stat; Linux uses GNU stat.
if [ "$(uname -s)" = Darwin ]; then
stat -f %m "$1"
else
stat -c %Y "$1"
fi
}
file_size() {
if [ "$(uname -s)" = Darwin ]; then
stat -f %z "$1"
else
stat -c %s "$1"
fi
}
md5_of() {
# openssl 3.x: last field of `dgst -md5` is the hex digest.
openssl dgst -md5 "$1" | awk '{print $NF}'
}
flush_log() {
# Do not write the daily log when there was nothing to do.
if [ "$HAD_WORK" != true ] && [ "$HAD_ERROR" != true ]; then
return 0
fi
if [ -z "${LOG_FILE:-}" ]; then
return 0
fi
if [ -z "$LOG_BUFFER" ]; then
return 0
fi
mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || return 0
printf '%s' "$LOG_BUFFER" >> "$LOG_FILE"
}
cleanup_test_files() {
# Drop the 1MB probe (and any leftover foo-*.txt) on the backup volume.
if [ -n "${OBS_BACKUP_DIR:-}" ] && [ -d "$OBS_BACKUP_DIR/test" ]; then
rm -f "$OBS_BACKUP_DIR"/test/*.txt 2>/dev/null || true
fi
}
on_exit() {
cleanup_test_files
flush_log
if [ -n "${LOCK_DIR:-}" ] && [ -d "$LOCK_DIR" ]; then
rmdir "$LOCK_DIR" 2>/dev/null || true
fi
}
trap on_exit EXIT
# ---------------------------------------------------------------------------
# 1. Environment directories must exist and be real directories.
# ---------------------------------------------------------------------------
temp_ok=true
backup_ok=true
if [ -z "${OBS_TEMP_DIR:-}" ] || [ ! -d "$OBS_TEMP_DIR" ]; then
temp_ok=false
fi
if [ -z "${OBS_BACKUP_DIR:-}" ] || [ ! -d "$OBS_BACKUP_DIR" ]; then
backup_ok=false
fi
if [ "$temp_ok" != true ] || [ "$backup_ok" != true ]; then
if [ "$temp_ok" != true ] && [ "$backup_ok" != true ]; then
err="OBS_TEMP_DIR and OBS_BACKUP_DIR are invalid (OBS_TEMP_DIR='${OBS_TEMP_DIR:-}', OBS_BACKUP_DIR='${OBS_BACKUP_DIR:-}')"
elif [ "$temp_ok" != true ]; then
err="OBS_TEMP_DIR is invalid (OBS_TEMP_DIR='${OBS_TEMP_DIR:-}')"
else
err="OBS_BACKUP_DIR is invalid (OBS_BACKUP_DIR='${OBS_BACKUP_DIR:-}')"
fi
echo "ERROR: $err" >&2
# If the temp dir is usable we can still persist this to the daily log.
if [ "$temp_ok" = true ]; then
log_year=$(date +%Y)
log_month=$(date +%m)
log_stamp=$(date +%Y-%m-%d)
LOG_FILE="$OBS_TEMP_DIR/logs/$log_year/$log_month/vodbackup-$log_stamp.txt"
HAD_ERROR=true
append_log "ERROR: $err"
fi
exit 1
fi
log_year=$(date +%Y)
log_month=$(date +%m)
log_stamp=$(date +%Y-%m-%d)
LOG_FILE="$OBS_TEMP_DIR/logs/$log_year/$log_month/vodbackup-$log_stamp.txt"
# Prevent overlapping cron runs (a 15-min recording can take longer than 15 min to copy).
LOCK_DIR="${TMPDIR:-/tmp}/vod-backup-${username}.lock"
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
# Another instance is still running; not an error and not "work".
exit 0
fi
if ! command -v openssl >/dev/null 2>&1; then
log_error "openssl not found in PATH (need 3.6.2 for MD5 checksums)"
exit 1
fi
# ---------------------------------------------------------------------------
# 2. Probe the destination: halt if the NAS is unavailable or the disk is full.
# Write 1MB of /dev/random to $OBS_BACKUP_DIR/test/foo-<timestamp>.txt
# ---------------------------------------------------------------------------
if ! mkdir -p "$OBS_BACKUP_DIR/test" 2>/dev/null; then
log_error "destination server is unavailable (cannot create '$OBS_BACKUP_DIR/test')"
exit 1
fi
probe_stamp=$(date +%Y%m%d-%H%M%S)
PROBE_FILE="$OBS_BACKUP_DIR/test/foo-${probe_stamp}.txt"
PROBE_BYTES=1048576
if ! dd if=/dev/random of="$PROBE_FILE" bs=1024 count=1024 2>/dev/null; then
if [ -d "$OBS_BACKUP_DIR" ] && [ -w "$OBS_BACKUP_DIR" ]; then
log_error "destination disk is full (failed writing 1MB probe to '$PROBE_FILE')"
else
log_error "destination server is unavailable (failed writing 1MB probe to '$PROBE_FILE')"
fi
rm -f "$PROBE_FILE" 2>/dev/null || true
exit 1
fi
probe_size=$(file_size "$PROBE_FILE" 2>/dev/null || echo 0)
if [ "$probe_size" -lt "$PROBE_BYTES" ]; then
log_error "destination disk is full (probe '$PROBE_FILE' is ${probe_size} bytes, expected ${PROBE_BYTES})"
rm -f "$PROBE_FILE" 2>/dev/null || true
exit 1
fi
# ---------------------------------------------------------------------------
# 3. Is OBS currently recording?
# ---------------------------------------------------------------------------
OBS_IS_RUNNING=false
if ps aux 2>/dev/null | grep -q "[O]BS.app/Contents/MacOS/OBS"; then
OBS_IS_RUNNING=true
fi
# ---------------------------------------------------------------------------
# 4. Directory listing of the temp folder (file names only).
# ---------------------------------------------------------------------------
DIRLIST=()
for path in "$OBS_TEMP_DIR"/*; do
[ -f "$path" ] || continue
DIRLIST+=("${path##*/}")
done
# ---------------------------------------------------------------------------
# 5. Game title from now_playing.txt — downcased, default "untitled".
# ---------------------------------------------------------------------------
now_playing=""
if [ -n "${NOW_PLAYING_FILE:-}" ] && [ -f "$NOW_PLAYING_FILE" ]; then
now_playing="$NOW_PLAYING_FILE"
elif [ -f "$OBS_TEMP_DIR/now_playing.txt" ]; then
now_playing="$OBS_TEMP_DIR/now_playing.txt"
elif [ -f "$(dirname "$OBS_TEMP_DIR")/now_playing.txt" ]; then
now_playing="$(dirname "$OBS_TEMP_DIR")/now_playing.txt"
fi
game_title="untitled"
if [ -n "$now_playing" ] && [ -s "$now_playing" ]; then
# First line, strip CR, trim surrounding whitespace, downcase.
game_title=$(tr -d '\r' < "$now_playing" | sed -n '1p' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
game_title=$(printf '%s' "$game_title" | tr '[:upper:]' '[:lower:]')
# Slashes would create extra path segments.
game_title=$(printf '%s' "$game_title" | tr '/' '-')
fi
if [ -z "$game_title" ]; then
game_title="untitled"
fi
# ---------------------------------------------------------------------------
# 6. Keep only *.mp4 entries.
# ---------------------------------------------------------------------------
filtered=()
if [ ${#DIRLIST[@]} -gt 0 ]; then
for f in "${DIRLIST[@]}"; do
case "$f" in
*.mp4) filtered+=("$f") ;;
esac
done
fi
DIRLIST=()
if [ ${#filtered[@]} -gt 0 ]; then
DIRLIST=("${filtered[@]}")
fi
# ---------------------------------------------------------------------------
# 7. If OBS is running, skip the newest file (still open / being written).
# ---------------------------------------------------------------------------
skipped_newest=""
if [ "$OBS_IS_RUNNING" = true ] && [ ${#DIRLIST[@]} -gt 0 ]; then
newest=""
newest_mtime=0
for f in "${DIRLIST[@]}"; do
mtime=$(file_mtime "$OBS_TEMP_DIR/$f")
if [ "$mtime" -ge "$newest_mtime" ]; then
newest="$f"
newest_mtime="$mtime"
fi
done
skipped_newest="$newest"
kept=()
for f in "${DIRLIST[@]}"; do
[ "$f" = "$newest" ] && continue
kept+=("$f")
done
DIRLIST=()
if [ ${#kept[@]} -gt 0 ]; then
DIRLIST=("${kept[@]}")
fi
fi
# Nothing to do: no log, just the EXIT trap (probe cleanup).
if [ ${#DIRLIST[@]} -eq 0 ]; then
exit 0
fi
HAD_WORK=true
append_log "vod-backup start user=$username obs_running=$OBS_IS_RUNNING game_title='$game_title' files=${#DIRLIST[@]}"
if [ -n "$skipped_newest" ]; then
append_log "skipping in-progress recording '$skipped_newest'"
fi
# ---------------------------------------------------------------------------
# 8. Copy each closed VOD, verify MD5, delete source only on match.
# ---------------------------------------------------------------------------
for filename in "${DIRLIST[@]}"; do
src="$OBS_TEMP_DIR/$filename"
if [ ! -f "$src" ]; then
log_error "source disappeared before copy: '$filename'"
continue
fi
# Filename like "2026-09-02 06-48-46.mp4" → year=2026, month_day=09-02
date_part="${filename%% *}"
year="${date_part%%-*}"
month_day="${date_part#*-}"
if ! printf '%s' "$year" | grep -q '^[0-9][0-9][0-9][0-9]$'; then
log_error "cannot parse 4-digit year from filename '$filename'"
continue
fi
if ! printf '%s' "$month_day" | grep -q '^[0-9][0-9]-[0-9][0-9]'; then
log_error "cannot parse MM-DD from filename '$filename'"
continue
fi
# Keep only MM-DD (drop any trailing junk after the day, just in case).
month_day=$(printf '%s' "$month_day" | cut -c1-5)
dest_dir="$OBS_BACKUP_DIR/VODs/$year/$game_title/$month_day"
dest="$dest_dir/$filename"
if ! mkdir -p "$dest_dir" 2>/dev/null; then
log_error "cannot create destination directory '$dest_dir'"
continue
fi
t0=$(date +%s)
src_md5=$(md5_of "$src")
src_md5_secs=$(seconds_since "$t0")
if [ -z "$src_md5" ]; then
log_error "source MD5 failed for '$filename'"
continue
fi
append_log "source MD5 $src_md5 '$filename' in $(format_secs "$src_md5_secs")"
t0=$(date +%s)
if ! cp "$src" "$dest"; then
xfer_secs=$(seconds_since "$t0")
log_error "copy failed for '$filename' -> '$dest' after $(format_secs "$xfer_secs")"
rm -f "$dest" 2>/dev/null || true
continue
fi
xfer_secs=$(seconds_since "$t0")
append_log "copied '$filename' to '$dest' in $(format_secs "$xfer_secs")"
t0=$(date +%s)
dest_md5=$(md5_of "$dest")
if [ -z "$dest_md5" ]; then
cmp_secs=$(seconds_since "$t0")
log_error "destination MD5 failed for '$dest' after $(format_secs "$cmp_secs")"
continue
fi
if [ "$src_md5" = "$dest_md5" ]; then
cmp_secs=$(seconds_since "$t0")
append_log "checksum match $dest_md5 for '$filename' in $(format_secs "$cmp_secs")"
if rm -f "$src"; then
append_log "deleted source '$src'"
else
log_error "checksum matched but failed to delete source '$src'"
fi
else
cmp_secs=$(seconds_since "$t0")
log_error "checksum mismatch for '$filename' in $(format_secs "$cmp_secs") (source=$src_md5 dest=$dest_md5); source kept"
fi
done
append_log "vod-backup finished"
exit 0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment