Last active
October 2, 2026 18:18
-
-
Save abra-chan/456ac46c6b2a7e79fed6e44f240d7775 to your computer and use it in GitHub Desktop.
VOD backup script for OBS
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 | |
| # 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