|
#!/usr/bin/env zsh |
|
# DJI Osmo Pocket 3 Backup and Wipe Script |
|
# Copies videos from DJI camera to dated folder, then wipes the card |
|
# |
|
# Author: Oleg Kossoy <[email protected]> |
|
# Version: 2.0.0 |
|
# Last Updated: October 7, 2025 |
|
|
|
set -euo pipefail |
|
|
|
# ----------------------------------------------------------------------------- |
|
# Configuration |
|
# ----------------------------------------------------------------------------- |
|
|
|
# Load configuration file if it exists |
|
CONFIG_FILE="${HOME}/.dji_backup_config" |
|
if [[ -f "$CONFIG_FILE" ]]; then |
|
source "$CONFIG_FILE" |
|
fi |
|
|
|
# Default configuration (can be overridden in config file) |
|
: ${SRC_VOLUME:="/Volumes/DJI Osmo P3"} |
|
: ${SRC_FOLDER:="${SRC_VOLUME}/DCIM/DJI_001"} |
|
: ${DEST_BASE:="/Volumes/Media/Videos/DJI Osmo Pocket 3"} |
|
: ${VIDEO_LINKS_SCRIPT:="${HOME}/bin/video_links.zsh"} |
|
: ${HISTORY_FILE:="${HOME}/.dji_backup_history"} |
|
: ${ENABLE_NOTIFICATIONS:=1} |
|
: ${ENABLE_CHECKSUM_VERIFY:=0} # Slower but more thorough |
|
|
|
TIMESTAMP=$(date "+%Y/%m/%d") |
|
DEST_FOLDER="${DEST_BASE}/${TIMESTAMP}" |
|
|
|
# Use Homebrew rsync (3.4.1) for better progress and features |
|
RSYNC_BIN="/opt/homebrew/bin/rsync" |
|
if [[ ! -x "$RSYNC_BIN" ]]; then |
|
# Fallback to system rsync if Homebrew version not available |
|
RSYNC_BIN="rsync" |
|
fi |
|
|
|
# Runtime flags |
|
DRY_RUN=0 |
|
SKIP_WIPE=0 |
|
VERBOSE=0 |
|
|
|
# Colors for terminal output |
|
RED='\033[0;31m' |
|
GREEN='\033[0;32m' |
|
YELLOW='\033[1;33m' |
|
BLUE='\033[0;34m' |
|
CYAN='\033[0;36m' |
|
PURPLE='\033[0;35m' |
|
NC='\033[0m' # No Color |
|
|
|
# ----------------------------------------------------------------------------- |
|
# Error Handling |
|
# ----------------------------------------------------------------------------- |
|
|
|
OPERATION_STARTED=0 |
|
COPY_COMPLETED=0 |
|
|
|
error_handler() { |
|
local exit_code=$1 |
|
local line_no=$2 |
|
|
|
echo "" |
|
log_error "Error on line ${line_no} (exit code: ${exit_code})" |
|
|
|
if [[ $COPY_COMPLETED -eq 1 ]]; then |
|
log_warning "Files were copied successfully but an error occurred during cleanup" |
|
log_info "Source files NOT deleted for safety" |
|
elif [[ $OPERATION_STARTED -eq 1 ]]; then |
|
log_warning "Operation interrupted - source files preserved" |
|
log_info "You may need to manually check: ${DEST_FOLDER}" |
|
fi |
|
|
|
# Send error notification |
|
if [[ $ENABLE_NOTIFICATIONS -eq 1 ]]; then |
|
osascript -e "display notification \"Backup failed - check terminal\" with title \"❌ DJI Backup Error\" sound name \"Basso\"" 2>/dev/null || true |
|
fi |
|
|
|
exit "$exit_code" |
|
} |
|
|
|
trap 'error_handler $? $LINENO' ERR |
|
trap 'log_warning "Operation cancelled by user"; exit 130' INT TERM |
|
|
|
# ----------------------------------------------------------------------------- |
|
# Utility Functions |
|
# ----------------------------------------------------------------------------- |
|
|
|
print_header() { |
|
local title="$1" |
|
echo "" |
|
echo "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" |
|
echo "${CYAN}${title}${NC}" |
|
echo "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" |
|
echo "" |
|
} |
|
|
|
log_info() { |
|
echo "${BLUE}ℹ️ ${1}${NC}" |
|
} |
|
|
|
log_success() { |
|
echo "${GREEN}✅ ${1}${NC}" |
|
} |
|
|
|
log_warning() { |
|
echo "${YELLOW}⚠️ ${1}${NC}" |
|
} |
|
|
|
log_error() { |
|
echo "${RED}❌ ${1}${NC}" >&2 |
|
} |
|
|
|
log_debug() { |
|
if [[ $VERBOSE -eq 1 ]]; then |
|
echo "${PURPLE}[DEBUG] ${1}${NC}" |
|
fi |
|
} |
|
|
|
show_usage() { |
|
cat << EOF |
|
${CYAN}DJI Osmo Pocket 3 Backup & Wipe Script${NC} |
|
|
|
Usage: $(basename "$0") [OPTIONS] |
|
|
|
Options: |
|
--dry-run Show what would be done without making changes |
|
--no-wipe Copy and verify files but don't wipe the source |
|
--checksum Use checksum verification (slower but thorough) |
|
--stats Show backup statistics before running |
|
--verbose, -v Enable verbose debug output |
|
--help, -h Show this help message |
|
|
|
Examples: |
|
$(basename "$0") # Normal operation |
|
$(basename "$0") --dry-run # Preview what will happen |
|
$(basename "$0") --no-wipe # Copy only, don't delete source |
|
$(basename "$0") --checksum # Use checksums for verification |
|
|
|
Configuration: |
|
Config file: ${CONFIG_FILE} |
|
History file: ${HISTORY_FILE} |
|
|
|
EOF |
|
} |
|
|
|
# ----------------------------------------------------------------------------- |
|
# History & Statistics Functions |
|
# ----------------------------------------------------------------------------- |
|
|
|
log_to_history() { |
|
local operation_status="$1" |
|
local file_count="$2" |
|
local total_size="$3" |
|
local duration="$4" |
|
|
|
local timestamp=$(date '+%Y-%m-%d %H:%M:%S') |
|
echo "${timestamp}|${operation_status}|${file_count}|${total_size}|${duration}s|${DEST_FOLDER}" >> "$HISTORY_FILE" |
|
} |
|
|
|
show_statistics() { |
|
if [[ ! -f "$HISTORY_FILE" ]]; then |
|
log_info "No backup history found" |
|
return |
|
fi |
|
|
|
print_header "📊 Recent Backup History" |
|
|
|
local total_backups=$(wc -l < "$HISTORY_FILE" | tr -d ' ') |
|
log_info "Total backups recorded: ${total_backups}" |
|
echo "" |
|
|
|
echo "${CYAN}Last 10 backups:${NC}" |
|
tail -10 "$HISTORY_FILE" | while IFS='|' read -r date operation_status files size duration dest; do |
|
local status_icon="✅" |
|
local color=$GREEN |
|
if [[ "$operation_status" == "ERROR" ]]; then |
|
status_icon="❌" |
|
color=$RED |
|
elif [[ "$operation_status" == "DRY_RUN" ]]; then |
|
status_icon="🔍" |
|
color=$BLUE |
|
fi |
|
|
|
printf " ${color}%s${NC} %s - %s files - %s - %ss\n" "$status_icon" "$date" "$files" "$size" "$duration" |
|
done |
|
echo "" |
|
} |
|
|
|
send_notification() { |
|
local title="$1" |
|
local message="$2" |
|
local sound="${3:-Glass}" |
|
|
|
if [[ $ENABLE_NOTIFICATIONS -eq 1 ]]; then |
|
osascript -e "display notification \"${message}\" with title \"${title}\" sound name \"${sound}\"" 2>/dev/null || true |
|
fi |
|
} |
|
|
|
# ----------------------------------------------------------------------------- |
|
# Validation Functions |
|
# ----------------------------------------------------------------------------- |
|
|
|
check_paths() { |
|
local has_error=0 |
|
|
|
if [[ ! -d "$SRC_VOLUME" ]]; then |
|
log_error "Source volume not found: ${SRC_VOLUME}" |
|
log_info "Is the Osmo Pocket 3 connected?" |
|
has_error=1 |
|
fi |
|
|
|
if [[ ! -d "$SRC_FOLDER" ]]; then |
|
log_error "Source folder not found: ${SRC_FOLDER}" |
|
has_error=1 |
|
fi |
|
|
|
if [[ ! -d "$DEST_BASE" ]]; then |
|
log_warning "Destination base does not exist. Creating it..." |
|
if [[ $DRY_RUN -eq 0 ]]; then |
|
mkdir -p "$DEST_BASE" || { |
|
log_error "Failed to create ${DEST_BASE}" |
|
has_error=1 |
|
} |
|
fi |
|
fi |
|
|
|
return $has_error |
|
} |
|
|
|
count_files() { |
|
local dir="$1" |
|
# Exclude .LRF and ._ (AppleDouble/metadata) files from count |
|
local count=$(find "$dir" -type f -not -name "*.LRF" -not -name "._*" 2>/dev/null | wc -l | tr -d ' ') |
|
echo "$count" |
|
} |
|
|
|
get_total_size() { |
|
local dir="$1" |
|
du -sh "$dir" 2>/dev/null | cut -f1 |
|
} |
|
|
|
# ----------------------------------------------------------------------------- |
|
# Main Process Functions |
|
# ----------------------------------------------------------------------------- |
|
|
|
copy_files() { |
|
log_info "Creating destination directory: ${DEST_FOLDER}" |
|
|
|
if [[ $DRY_RUN -eq 0 ]]; then |
|
mkdir -p "$DEST_FOLDER" || { |
|
log_error "Failed to create destination folder" |
|
exit 5 |
|
} |
|
fi |
|
|
|
if [[ $DRY_RUN -eq 1 ]]; then |
|
log_warning "DRY RUN: Would copy files from ${SRC_FOLDER} to ${DEST_FOLDER}" |
|
echo "" |
|
"$RSYNC_BIN" -ahn --info=progress2 --no-inc-recursive --exclude='*.LRF' "$SRC_FOLDER/" "$DEST_FOLDER/" || true |
|
echo "" |
|
log_info "DRY RUN: No files were actually copied" |
|
return 0 |
|
fi |
|
|
|
log_info "Copying files with progress (excluding .LRF files)..." |
|
echo "" |
|
|
|
# Use modern rsync with better progress display and resume capability |
|
# --info=progress2: Single-line progress with overall stats |
|
# -h: Human-readable sizes |
|
# -a: Archive mode (preserves permissions, times, etc.) |
|
# --no-inc-recursive: More accurate progress for large directories |
|
# --partial: Keep partially transferred files (enables resume) |
|
# --partial-dir: Store partial files in hidden directory |
|
"$RSYNC_BIN" -ah --info=progress2 --no-inc-recursive \ |
|
--partial --partial-dir=.rsync-partial \ |
|
--exclude='*.LRF' "$SRC_FOLDER/" "$DEST_FOLDER/" || { |
|
log_error "Copy failed! Aborting." |
|
exit 5 |
|
} |
|
|
|
echo "" |
|
log_success "Files copied successfully" |
|
} |
|
|
|
verify_copy() { |
|
if [[ $ENABLE_CHECKSUM_VERIFY -eq 1 ]]; then |
|
verify_copy_checksum |
|
return $? |
|
else |
|
verify_copy_size |
|
return $? |
|
fi |
|
} |
|
|
|
verify_copy_size() { |
|
log_info "Verifying copied files (size comparison)..." |
|
|
|
local all_verified=0 |
|
local verified_count=0 |
|
local total_count=0 |
|
|
|
# Check that each source file exists in destination with matching size |
|
while IFS= read -r src_file; do |
|
((total_count++)) |
|
local filename=$(basename "$src_file") |
|
local dest_file="${DEST_FOLDER}/${filename}" |
|
|
|
# Show progress |
|
printf "\r${BLUE}ℹ️ Verifying: ${verified_count}/${total_count} files${NC}" |
|
|
|
if [[ ! -f "$dest_file" ]]; then |
|
echo "" # newline after progress |
|
log_error "Missing file: ${filename}" |
|
all_verified=1 |
|
else |
|
# Compare file sizes |
|
local src_size=$(stat -f%z "$src_file" 2>/dev/null || stat -c%s "$src_file") |
|
local dest_size=$(stat -f%z "$dest_file" 2>/dev/null || stat -c%s "$dest_file") |
|
|
|
if [[ "$src_size" -eq "$dest_size" ]]; then |
|
((verified_count++)) |
|
else |
|
echo "" # newline after progress |
|
log_error "Size mismatch for ${filename}: source=${src_size}, dest=${dest_size}" |
|
all_verified=1 |
|
fi |
|
fi |
|
done < <(find "$SRC_FOLDER" -type f -not -name "*.LRF" -not -name "._*" 2>/dev/null) |
|
|
|
echo "" # newline after progress |
|
|
|
if [[ $all_verified -eq 0 && $verified_count -eq $total_count ]]; then |
|
log_success "Verification passed: ${verified_count} of ${total_count} files verified" |
|
return 0 |
|
else |
|
log_error "Verification failed! Only ${verified_count} of ${total_count} files verified successfully" |
|
return 1 |
|
fi |
|
} |
|
|
|
verify_copy_checksum() { |
|
log_info "Verifying copied files (checksum comparison - this may take a while)..." |
|
echo "" |
|
|
|
# Use rsync's built-in checksum verification |
|
# -c: skip based on checksum, not mod-time & size |
|
# -n: dry-run (just compare, don't copy) |
|
local diff_output=$("$RSYNC_BIN" -ahn --checksum --exclude='*.LRF' "$SRC_FOLDER/" "$DEST_FOLDER/" 2>&1) |
|
|
|
# Filter out rsync status messages |
|
local files_differ=$(echo "$diff_output" | grep -v "sending incremental file list" | grep -v "^$" | grep -v "sent " | grep -v "total size") |
|
|
|
if [[ -z "$files_differ" ]]; then |
|
log_success "Checksum verification passed: All files match exactly" |
|
return 0 |
|
else |
|
log_error "Checksum verification failed! Files differ:" |
|
echo "$files_differ" | sed 's/^/ /' |
|
return 1 |
|
fi |
|
} |
|
|
|
wipe_volume() { |
|
if [[ $DRY_RUN -eq 1 ]]; then |
|
log_warning "DRY RUN: Would delete all files from ${SRC_VOLUME}" |
|
return 0 |
|
fi |
|
|
|
if [[ $SKIP_WIPE -eq 1 ]]; then |
|
log_warning "SKIP: Not wiping source volume (--no-wipe flag)" |
|
return 0 |
|
fi |
|
|
|
log_warning "Deleting all files from ${SRC_VOLUME}..." |
|
|
|
# This won't touch the mount point itself or special '.' and '..' |
|
rm -rf "$SRC_VOLUME/"* "$SRC_VOLUME/".* 2>/dev/null |
|
|
|
log_success "Volume wiped" |
|
} |
|
|
|
eject_volume() { |
|
if [[ $DRY_RUN -eq 1 ]]; then |
|
log_warning "DRY RUN: Would eject ${SRC_VOLUME}" |
|
return 0 |
|
fi |
|
|
|
if [[ $SKIP_WIPE -eq 1 ]]; then |
|
log_info "SKIP: Not ejecting volume (--no-wipe flag)" |
|
return 0 |
|
fi |
|
|
|
log_info "Getting device identifier..." |
|
|
|
local device_id=$(diskutil info "$SRC_VOLUME" 2>/dev/null | awk -F': *' '/Device Node/ {print $2}') |
|
|
|
if [[ -z "$device_id" ]]; then |
|
log_error "Could not determine device node for ${SRC_VOLUME}" |
|
return 10 |
|
fi |
|
|
|
log_info "Device: ${device_id}" |
|
log_info "Attempting to eject ${SRC_VOLUME}..." |
|
|
|
# Try a normal eject first |
|
if diskutil eject "$SRC_VOLUME" 2>/dev/null; then |
|
log_success "Volume ejected successfully" |
|
else |
|
log_warning "Normal eject failed, trying force unmount..." |
|
if diskutil unmountDisk force "$device_id" && diskutil eject "$device_id"; then |
|
log_success "Volume force-ejected successfully" |
|
else |
|
log_error "Failed to eject volume" |
|
return 11 |
|
fi |
|
fi |
|
} |
|
|
|
update_symlinks() { |
|
if [[ $DRY_RUN -eq 1 ]]; then |
|
log_warning "DRY RUN: Would update video symlinks" |
|
return 0 |
|
fi |
|
|
|
if [[ -f "$VIDEO_LINKS_SCRIPT" ]]; then |
|
log_info "Updating video folder symlinks..." |
|
/bin/zsh "$VIDEO_LINKS_SCRIPT" || { |
|
log_warning "Failed to update symlinks (non-critical)" |
|
} |
|
else |
|
log_debug "Video links script not found: ${VIDEO_LINKS_SCRIPT}" |
|
fi |
|
} |
|
|
|
# ----------------------------------------------------------------------------- |
|
# Main Function |
|
# ----------------------------------------------------------------------------- |
|
|
|
main() { |
|
local start_time=$(date +%s) |
|
|
|
# Parse command line arguments |
|
while [[ $# -gt 0 ]]; do |
|
case $1 in |
|
--dry-run) |
|
DRY_RUN=1 |
|
shift |
|
;; |
|
--no-wipe) |
|
SKIP_WIPE=1 |
|
shift |
|
;; |
|
--checksum) |
|
ENABLE_CHECKSUM_VERIFY=1 |
|
shift |
|
;; |
|
--stats) |
|
show_statistics |
|
exit 0 |
|
;; |
|
--verbose|-v) |
|
VERBOSE=1 |
|
shift |
|
;; |
|
--help|-h) |
|
show_usage |
|
exit 0 |
|
;; |
|
*) |
|
log_error "Unknown option: $1" |
|
show_usage |
|
exit 1 |
|
;; |
|
esac |
|
done |
|
|
|
print_header "📹 DJI Osmo Pocket 3 Backup & Wipe" |
|
|
|
if [[ $DRY_RUN -eq 1 ]]; then |
|
echo "${YELLOW}⚠️ DRY RUN MODE - No changes will be made${NC}" |
|
echo "" |
|
fi |
|
|
|
echo "$(date '+%Y-%m-%d %H:%M:%S')" |
|
echo "Host: $(hostname -s)" |
|
echo "" |
|
|
|
OPERATION_STARTED=1 |
|
|
|
# Check all paths |
|
log_info "Checking paths..." |
|
check_paths || exit 1 |
|
|
|
# Show what we're about to do |
|
local file_count=$(count_files "$SRC_FOLDER") |
|
local total_size=$(get_total_size "$SRC_FOLDER") |
|
|
|
if [[ $file_count -eq 0 ]]; then |
|
log_warning "No files found on DJI camera!" |
|
log_info "Nothing to backup." |
|
exit 0 |
|
fi |
|
|
|
echo "" |
|
log_info "Source: ${SRC_FOLDER}" |
|
log_info "Destination: ${DEST_FOLDER}" |
|
log_info "Files: ${file_count} (${total_size})" |
|
if [[ $ENABLE_CHECKSUM_VERIFY -eq 1 ]]; then |
|
log_info "Verification: Checksum (slower)" |
|
else |
|
log_info "Verification: Size comparison (fast)" |
|
fi |
|
echo "" |
|
|
|
log_warning "This will:" |
|
echo " 1. Copy files from DJI to ${DEST_FOLDER}" |
|
if [[ $SKIP_WIPE -eq 1 ]]; then |
|
echo " 2. ${YELLOW}SKIP${NC} deleting files (--no-wipe)" |
|
echo " 3. ${YELLOW}SKIP${NC} ejecting disk (--no-wipe)" |
|
else |
|
echo " 2. Delete ALL FILES from ${SRC_VOLUME}" |
|
echo " 3. Eject the disk" |
|
fi |
|
echo "" |
|
|
|
# Uncomment these lines if you want confirmation |
|
# if [[ $DRY_RUN -eq 0 ]]; then |
|
# read "yn?Type 'yes' to confirm and proceed: " |
|
# if [[ "$yn" != "yes" ]]; then |
|
# log_warning "Aborting." |
|
# exit 4 |
|
# fi |
|
# fi |
|
|
|
# Execute main process |
|
copy_files |
|
COPY_COMPLETED=1 |
|
|
|
verify_copy || { |
|
log_error "Copy verification failed! Not wiping source." |
|
|
|
local end_time=$(date +%s) |
|
local duration=$((end_time - start_time)) |
|
log_to_history "ERROR" "$file_count" "$total_size" "$duration" |
|
|
|
send_notification "❌ DJI Backup Failed" "Verification failed - source preserved" "Basso" |
|
exit 6 |
|
} |
|
|
|
wipe_volume |
|
eject_volume |
|
update_symlinks |
|
|
|
local end_time=$(date +%s) |
|
local duration=$((end_time - start_time)) |
|
|
|
print_header "✅ Backup Complete" |
|
log_success "Files saved to: ${DEST_FOLDER}" |
|
log_success "Total files: ${file_count} (${total_size})" |
|
log_success "Duration: ${duration}s" |
|
echo "" |
|
|
|
# Log to history |
|
if [[ $DRY_RUN -eq 1 ]]; then |
|
log_to_history "DRY_RUN" "$file_count" "$total_size" "$duration" |
|
else |
|
log_to_history "SUCCESS" "$file_count" "$total_size" "$duration" |
|
fi |
|
|
|
# Send success notification |
|
if [[ $DRY_RUN -eq 0 ]]; then |
|
send_notification "✅ DJI Backup Complete" "${file_count} files backed up (${total_size})" "Glass" |
|
fi |
|
} |
|
|
|
# Run main function |
|
main "$@" |