Skip to content

Instantly share code, notes, and snippets.

@evenwebb
Forked from ljm42/PhotoImport.sh
Last active September 4, 2025 10:59
Show Gist options
  • Select an option

  • Save evenwebb/547f88db21cb30745c2267499225b7a5 to your computer and use it in GitHub Desktop.

Select an option

Save evenwebb/547f88db21cb30745c2267499225b7a5 to your computer and use it in GitHub Desktop.
Automatically imports photos and videos from camera memory cards to your unRAID server when inserted via USB. Updated for unRAID OS 7.0+ with enhanced error handling, modern camera support, and improved notifications.
#!/bin/bash
PATH=/usr/local/sbin:/usr/sbin:/sbin:/usr/local/bin:/usr/bin:/bin
## Modern Photo Import Script for unRAID 7.x
## Updated for unRAID OS 7.0+ with improved error handling and modern features
##
## Usage (after configuration):
## 1. Insert camera's memory card into a USB port on your unRAID system
## 2. The system will automatically move (or copy) any images/videos from the memory card to the array
## Photos will be auto-rotated based on EXIF data if tools are available
## 3. Wait for the completion notification, then remove the memory card
## Preparation:
## 1. Install photo processing tools via Community Applications or Nerd Tools
## 2. Install the "Unassigned Devices" plugin
## 3. Configure this script in Unassigned Devices to run when a memory card is inserted
## 4. Configure variables in this script as described below
## 5. Ensure unRAID notifications are properly configured in Settings
## --- BEGIN CONFIGURATION ---
## SET THIS FOR YOUR CAMERAS:
## array of directories under /DCIM/ that contain files you want to move (or copy)
## Updated for modern camera formats and manufacturers
VALIDDIRS=(
"/DCIM/[0-9][0-9][0-9]___[0-9][0-9]" # Generic camera format
"/DCIM/[0-9][0-9][0-9]CANON" # Canon cameras
"/DCIM/[0-9][0-9][0-9]_FUJI" # Fujifilm cameras
"/DCIM/[0-9][0-9][0-9]NIKON" # Nikon cameras
"/DCIM/[0-9][0-9][0-9]SONY" # Sony cameras
"/DCIM/[0-9][0-9][0-9]OLYMP" # Olympus cameras
"/DCIM/[0-9][0-9][0-9]PANA(S|_)?" # Panasonic cameras (all variants)
"/DCIM/[0-9][0-9][0-9]GOPRO" # GoPro action cameras
"/DCIM/[0-9][0-9][0-9]GARMIN" # Garmin action cameras
"/DCIM/[0-9][0-9][0-9]CASIO" # Casio cameras
"/DCIM/[0-9][0-9][0-9]PENTX" # Pentax cameras
"/DCIM/[0-9][0-9][0-9]KODAK" # Kodak cameras (legacy)
"/DCIM/[0-9][0-9][0-9]RICOH" # Ricoh cameras
"/DCIM/[0-9][0-9][0-9]LEICA" # Leica cameras
"/DCIM/[0-9][0-9][0-9]SIGMA" # Sigma cameras
"/DCIM/[0-9][0-9][0-9]MINOL" # Minolta/Konica Minolta (legacy)
"/DCIM/[0-9][0-9][0-9]MEDIA" # Generic media
"/DCIM/[0-9][0-9][0-9]MSDCF" # Memory Stick cameras
"/DCIM/Camera" # iPhone/Android smartphones
"/DCIM/[0-9][0-9][0-9]ANDRO" # Android variants
)
## SET THIS FOR YOUR SYSTEM:
## location to move files to. use date command to ensure unique dir
DESTINATION="/mnt/user/Photos/$(date +"%Y")/$(date +"%m")/$(date +"%d")/"
## SET THIS FOR YOUR SYSTEM:
## change to "move" when you are confident everything is working
MOVE_OR_COPY="copy"
## Enable enhanced logging and debugging (0=off, 1=on)
DEBUG=0
## Maximum file size to process (in MB, 0=no limit)
MAX_FILE_SIZE=0
## File extensions to process (leave empty to process all files)
VALID_EXTENSIONS=("jpg" "jpeg" "png" "tiff" "tif" "raw" "cr2" "nef" "orf" "raf" "dng" "heic" "heif" "mp4" "mov" "avi" "mkv" "webm")
## Convert HEIC/HEIF files to JPEG (0=keep original only, 1=convert to JPEG, 2=convert and delete original)
## Requires: ImageMagick (magick) or heif-convert (install via Community Applications)
CONVERT_HEIC=1
## --- ADVANCED FEATURES ---
## Missing dependency handling (0=continue with warnings, 1=fail if required package missing)
STRICT_DEPENDENCIES=0
## Enable duplicate detection (0=off, 1=on) - Skip files that already exist (checksum-based)
## Requires: sha256sum/shasum/md5sum (usually built-in)
ENABLE_DUPLICATE_DETECTION=1
## Smart folder organization (0=off, 1=by camera brand, 2=by camera model)
## Requires: exiftool (install via Community Applications or Nerd Tools)
SMART_FOLDERS=0
## Date-based sorting (0=use import date, 1=use EXIF date from photos)
## Requires: exiftool (install via Community Applications or Nerd Tools)
USE_EXIF_DATE=0
## File type separation (0=off, 1=photos/videos in separate folders)
## Requires: built-in file extension detection (no dependencies)
SEPARATE_FILE_TYPES=0
## RAW file handling (0=treat as normal, 1=separate RAW folder, 2=skip RAW files)
## Requires: built-in file extension detection (no dependencies)
RAW_FILE_HANDLING=0
## Disk space checking (0=off, 1=warn if low space, 2=abort if insufficient space)
## Requires: df (built-in)
CHECK_DISK_SPACE=1
## Integrity verification (0=off, 1=verify checksums after transfer)
## Requires: sha256sum/shasum/md5sum (usually built-in)
VERIFY_INTEGRITY=0
## Progress indicators (0=off, 1=show progress for operations)
## Requires: built-in functionality (no dependencies)
SHOW_PROGRESS=1
## Rollback capability (0=off, 1=enable rollback on failures)
## Requires: built-in functionality (no dependencies)
ENABLE_ROLLBACK=0
## Custom naming scheme (leave empty for default, or use pattern like "%Y%m%d_%camera_%counter")
## Requires: exiftool for camera variables (install via Community Applications or Nerd Tools)
CUSTOM_NAMING=""
## Multiple destinations (space-separated list of additional backup locations)
## Requires: built-in functionality (no dependencies)
BACKUP_DESTINATIONS=""
## Blacklist patterns (space-separated patterns to skip, e.g. "*.tmp *.log .DS_Store")
## Requires: built-in functionality (no dependencies)
BLACKLIST_PATTERNS=".DS_Store ._.* Thumbs.db"
## Auto-eject after import (0=off, 1=safely unmount and eject removable media)
## Requires: eject command (usually built-in)
AUTO_EJECT=0
## Configuration validation (0=off, 1=validate settings before running)
## Requires: built-in functionality (no dependencies)
VALIDATE_CONFIG=1
## Available variables:
# AVAIL : available space
# USED : used space
# SIZE : partition size
# SERIAL : disk serial number
# ACTION : if mounting, ADD; if unmounting, REMOVE
# MOUNTPOINT : where the partition is mounted
# FSTYPE : partition filesystem
# LABEL : partition label
# DEVICE : partition device, e.g /dev/sda1
# OWNER : "udev" if executed by UDEV, otherwise "user"
# PROG_NAME : program name of this script
# LOGFILE : log file for this script
log_all() {
log_local "$1"
logger -t "$PROG_NAME" "$1"
}
log_local() {
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$timestamp] $PROG_NAME: $1"
echo "[$timestamp] $PROG_NAME: $1" >> "$LOGFILE"
}
log_debug() {
if [[ $DEBUG -eq 1 ]]; then
log_local "DEBUG: $1"
fi
}
log_error() {
log_local "ERROR: $1"
logger -t "$PROG_NAME" -p user.err "ERROR: $1"
}
log_info() {
log_local "INFO: $1"
}
play_completion_sound() {
# Modern completion sound - check if beep is available
if command -v beep >/dev/null 2>&1; then
# Success melody
beep -f 523 -l 200 -n -f 659 -l 200 -n -f 784 -l 400
else
log_debug "beep command not available, skipping audio notification"
fi
}
play_error_sound() {
if command -v beep >/dev/null 2>&1; then
# Error sound
beep -f 200 -l 500 -n -f 200 -l 500
fi
}
play_start_sound() {
if command -v beep >/dev/null 2>&1; then
# Device connected sound
beep -l 200 -f 600 -n -l 200 -f 800
fi
}
play_remove_sound() {
if command -v beep >/dev/null 2>&1; then
# Device removed sound
beep -l 200 -f 800 -n -l 200 -f 600
fi
}
# Advanced utility functions
get_file_checksum() {
local file="$1"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$file" 2>/dev/null | cut -d' ' -f1
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$file" 2>/dev/null | cut -d' ' -f1
else
md5sum "$file" 2>/dev/null | cut -d' ' -f1
fi
}
is_duplicate() {
local source_file="$1"
local dest_dir="$2"
local basename=$(basename "$source_file")
local dest_file="$dest_dir/$basename"
if [[ ! -f "$dest_file" ]]; then
return 1 # Not a duplicate
fi
if [[ $ENABLE_DUPLICATE_DETECTION -eq 1 ]]; then
local source_checksum=$(get_file_checksum "$source_file")
local dest_checksum=$(get_file_checksum "$dest_file")
[[ "$source_checksum" == "$dest_checksum" ]]
else
return 1 # Duplicate detection disabled
fi
}
get_camera_info() {
local file="$1"
local camera_make=""
local camera_model=""
if command -v exiftool >/dev/null 2>&1; then
camera_make=$(exiftool -s -s -s -Make "$file" 2>/dev/null | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]//g')
camera_model=$(exiftool -s -s -s -Model "$file" 2>/dev/null | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]//g')
fi
echo "${camera_make}:${camera_model}"
}
get_exif_date() {
local file="$1"
if command -v exiftool >/dev/null 2>&1; then
exiftool -s -s -s -DateTimeOriginal -d "%Y/%m/%d" "$file" 2>/dev/null
fi
}
is_video_file() {
local file="$1"
local extension="${file##*.}"
extension=$(echo "$extension" | tr '[:upper:]' '[:lower:]')
[[ "$extension" =~ ^(mp4|mov|avi|mkv|webm|m4v|3gp|wmv|flv)$ ]]
}
is_raw_file() {
local file="$1"
local extension="${file##*.}"
extension=$(echo "$extension" | tr '[:upper:]' '[:lower:]')
[[ "$extension" =~ ^(raw|cr2|cr3|nef|orf|raf|dng|arw|rw2|pef|srw|x3f)$ ]]
}
check_blacklist() {
local file="$1"
local basename=$(basename "$file")
for pattern in $BLACKLIST_PATTERNS; do
if [[ "$basename" == $pattern ]]; then
return 0 # File is blacklisted
fi
done
return 1 # File is not blacklisted
}
check_available_space() {
local dest_dir="$1"
local required_mb="$2"
if [[ $CHECK_DISK_SPACE -eq 0 ]]; then
return 0 # Checking disabled
fi
local available_kb=$(df "$dest_dir" | tail -1 | awk '{print $4}')
local available_mb=$((available_kb / 1024))
if [[ $available_mb -lt $required_mb ]]; then
if [[ $CHECK_DISK_SPACE -eq 2 ]]; then
return 1 # Abort
else
log_info "WARNING: Low disk space. Available: ${available_mb}MB, Required: ${required_mb}MB"
fi
fi
return 0
}
build_destination_path() {
local file="$1"
local base_dest="$2"
local final_dest="$base_dest"
# Use EXIF date if enabled
if [[ $USE_EXIF_DATE -eq 1 ]]; then
local exif_date=$(get_exif_date "$file")
if [[ -n "$exif_date" ]]; then
final_dest="/mnt/user/Photos/$exif_date"
fi
fi
# Smart folder organization
if [[ $SMART_FOLDERS -gt 0 ]]; then
local camera_info=$(get_camera_info "$file")
local camera_make="${camera_info%%:*}"
local camera_model="${camera_info##*:}"
if [[ -n "$camera_make" ]]; then
if [[ $SMART_FOLDERS -eq 1 ]]; then
final_dest="$final_dest/$camera_make"
elif [[ $SMART_FOLDERS -eq 2 && -n "$camera_model" ]]; then
final_dest="$final_dest/$camera_make/$camera_model"
fi
fi
fi
# File type separation
if [[ $SEPARATE_FILE_TYPES -eq 1 ]]; then
if is_video_file "$file"; then
final_dest="$final_dest/Videos"
elif is_raw_file "$file"; then
final_dest="$final_dest/RAW"
else
final_dest="$final_dest/Photos"
fi
fi
# RAW file handling
if [[ $RAW_FILE_HANDLING -eq 1 ]] && is_raw_file "$file"; then
final_dest="$final_dest/RAW"
fi
echo "$final_dest"
}
# Dependency checking functions
check_dependencies() {
local missing_deps=0
local warnings=()
# Check HEIC conversion dependencies
if [[ $CONVERT_HEIC -gt 0 ]]; then
if ! command -v magick >/dev/null 2>&1 && ! command -v heif-convert >/dev/null 2>&1; then
warnings+=("HEIC conversion enabled but ImageMagick (magick) or heif-convert not found")
((missing_deps++))
fi
fi
# Check exiftool dependencies
local needs_exiftool=0
[[ $SMART_FOLDERS -gt 0 ]] && needs_exiftool=1
[[ $USE_EXIF_DATE -eq 1 ]] && needs_exiftool=1
[[ -n "$CUSTOM_NAMING" ]] && needs_exiftool=1
if [[ $needs_exiftool -eq 1 ]] && ! command -v exiftool >/dev/null 2>&1; then
local missing_features=()
[[ $SMART_FOLDERS -gt 0 ]] && missing_features+=("Smart Folders")
[[ $USE_EXIF_DATE -eq 1 ]] && missing_features+=("EXIF Date Sorting")
[[ -n "$CUSTOM_NAMING" ]] && missing_features+=("Custom Naming")
warnings+=("exiftool not found - disabling: $(IFS=', '; echo "${missing_features[*]}")")
((missing_deps++))
# Auto-disable features that need exiftool
SMART_FOLDERS=0
USE_EXIF_DATE=0
CUSTOM_NAMING=""
fi
# Check checksum tools (usually available but verify)
if [[ $ENABLE_DUPLICATE_DETECTION -eq 1 || $VERIFY_INTEGRITY -eq 1 ]]; then
if ! command -v sha256sum >/dev/null 2>&1 && ! command -v shasum >/dev/null 2>&1 && ! command -v md5sum >/dev/null 2>&1; then
warnings+=("No checksum tools found - disabling duplicate detection and integrity verification")
ENABLE_DUPLICATE_DETECTION=0
VERIFY_INTEGRITY=0
((missing_deps++))
fi
fi
# Check eject command for auto-eject
if [[ $AUTO_EJECT -eq 1 ]] && ! command -v eject >/dev/null 2>&1; then
warnings+=("Auto-eject enabled but eject command not found")
((missing_deps++))
fi
# Report findings
if [[ $missing_deps -gt 0 ]]; then
log_info "Dependency Check: $missing_deps missing dependencies found"
for warning in "${warnings[@]}"; do
log_info "WARNING: $warning"
done
if [[ $STRICT_DEPENDENCIES -eq 1 ]]; then
log_error "STRICT_DEPENDENCIES=1: Aborting due to missing dependencies"
log_error "Install missing packages or set STRICT_DEPENDENCIES=0 to continue"
play_error_sound
exit 1
else
log_info "STRICT_DEPENDENCIES=0: Continuing with available features"
fi
else
log_debug "Dependency Check: All required dependencies available"
fi
}
# Configuration validation function
validate_configuration() {
if [[ $VALIDATE_CONFIG -eq 0 ]]; then
log_debug "Configuration validation disabled"
return 0
fi
local config_errors=0
local config_warnings=()
log_debug "Validating configuration..."
# Validate DESTINATION path
if [[ -z "$DESTINATION" ]]; then
log_error "DESTINATION cannot be empty"
((config_errors++))
else
local dest_parent=$(dirname "$DESTINATION")
if [[ ! -d "$dest_parent" ]]; then
config_warnings+=("DESTINATION parent directory does not exist: $dest_parent")
elif [[ ! -w "$dest_parent" ]]; then
log_error "DESTINATION parent directory is not writable: $dest_parent"
((config_errors++))
fi
fi
# Validate MOVE_OR_COPY
if [[ "$MOVE_OR_COPY" != "copy" && "$MOVE_OR_COPY" != "move" ]]; then
log_error "MOVE_OR_COPY must be 'copy' or 'move', got: $MOVE_OR_COPY"
((config_errors++))
fi
# Validate numeric ranges
local numeric_configs=(
"DEBUG:0:1:$DEBUG"
"MAX_FILE_SIZE:0:999999:$MAX_FILE_SIZE"
"CONVERT_HEIC:0:2:$CONVERT_HEIC"
"ENABLE_DUPLICATE_DETECTION:0:1:$ENABLE_DUPLICATE_DETECTION"
"SMART_FOLDERS:0:2:$SMART_FOLDERS"
"USE_EXIF_DATE:0:1:$USE_EXIF_DATE"
"SEPARATE_FILE_TYPES:0:1:$SEPARATE_FILE_TYPES"
"RAW_FILE_HANDLING:0:2:$RAW_FILE_HANDLING"
"CHECK_DISK_SPACE:0:2:$CHECK_DISK_SPACE"
"VERIFY_INTEGRITY:0:1:$VERIFY_INTEGRITY"
"SHOW_PROGRESS:0:1:$SHOW_PROGRESS"
"ENABLE_ROLLBACK:0:1:$ENABLE_ROLLBACK"
"STRICT_DEPENDENCIES:0:1:$STRICT_DEPENDENCIES"
"AUTO_EJECT:0:1:$AUTO_EJECT"
"VALIDATE_CONFIG:0:1:$VALIDATE_CONFIG"
)
for config in "${numeric_configs[@]}"; do
IFS=':' read -r name min max value <<< "$config"
if ! [[ "$value" =~ ^[0-9]+$ ]] || [[ $value -lt $min ]] || [[ $value -gt $max ]]; then
log_error "$name must be a number between $min and $max, got: $value"
((config_errors++))
fi
done
# Validate VALID_EXTENSIONS array
if [[ ${#VALID_EXTENSIONS[@]} -eq 0 ]]; then
config_warnings+=("VALID_EXTENSIONS is empty - all file types will be processed")
fi
# Validate VALIDDIRS array
if [[ ${#VALIDDIRS[@]} -eq 0 ]]; then
log_error "VALIDDIRS cannot be empty - no camera directories will be processed"
((config_errors++))
fi
# Validate backup destinations
if [[ -n "$BACKUP_DESTINATIONS" ]]; then
for backup_dest in $BACKUP_DESTINATIONS; do
local backup_parent=$(dirname "$backup_dest")
if [[ ! -d "$backup_parent" ]]; then
config_warnings+=("Backup destination parent does not exist: $backup_parent")
elif [[ ! -w "$backup_parent" ]]; then
config_warnings+=("Backup destination parent not writable: $backup_parent")
fi
done
fi
# Validate blacklist patterns
if [[ -n "$BLACKLIST_PATTERNS" ]]; then
log_debug "Blacklist patterns configured: $BLACKLIST_PATTERNS"
fi
# Check for conflicting settings
if [[ $MOVE_OR_COPY == "move" && $ENABLE_DUPLICATE_DETECTION -eq 1 ]]; then
config_warnings+=("MOVE mode with duplicate detection may leave source files if duplicates found")
fi
if [[ $VERIFY_INTEGRITY -eq 1 && $MOVE_OR_COPY == "move" ]]; then
config_warnings+=("Integrity verification with MOVE mode requires source files to remain until verification")
fi
if [[ $RAW_FILE_HANDLING -eq 2 && $SEPARATE_FILE_TYPES -eq 1 ]]; then
config_warnings+=("RAW_FILE_HANDLING=2 (skip) overrides SEPARATE_FILE_TYPES for RAW files")
fi
# Report validation results
if [[ ${#config_warnings[@]} -gt 0 ]]; then
log_info "Configuration validation: ${#config_warnings[@]} warnings found"
for warning in "${config_warnings[@]}"; do
log_info "CONFIG WARNING: $warning"
done
fi
if [[ $config_errors -gt 0 ]]; then
log_error "Configuration validation failed with $config_errors errors"
log_error "Please fix configuration errors and try again"
return 1
else
log_debug "Configuration validation passed"
return 0
fi
}
# Enhanced file validation function
validate_file() {
local file="$1"
local basename=$(basename "$file")
local extension="${basename##*.}"
# Check blacklist first
if check_blacklist "$file"; then
log_debug "Skipping blacklisted file: $file"
return 1
fi
# Skip RAW files if configured
if [[ $RAW_FILE_HANDLING -eq 2 ]] && is_raw_file "$file"; then
log_debug "Skipping RAW file: $file"
return 1
fi
# Convert extension to lowercase
extension=$(echo "$extension" | tr '[:upper:]' '[:lower:]')
# Check if extension is in valid list (if list is not empty)
if [[ ${#VALID_EXTENSIONS[@]} -gt 0 ]]; then
local valid=0
for valid_ext in "${VALID_EXTENSIONS[@]}"; do
if [[ "$extension" == "$valid_ext" ]]; then
valid=1
break
fi
done
if [[ $valid -eq 0 ]]; then
log_debug "Skipping file with invalid extension: $file"
return 1
fi
fi
# Check file size if limit is set
if [[ $MAX_FILE_SIZE -gt 0 ]]; then
local size_mb=$(( $(stat -c%s "$file" 2>/dev/null || echo 0) / 1048576 ))
if [[ $size_mb -gt $MAX_FILE_SIZE ]]; then
log_debug "Skipping file larger than ${MAX_FILE_SIZE}MB: $file ($size_mb MB)"
return 1
fi
fi
return 0
}
case $ACTION in
'ADD' )
play_start_sound
sleep 2
if [[ ! -d "$MOUNTPOINT" ]]; then
log_error "Mountpoint doesn't exist: $MOUNTPOINT"
exit 1
fi
if [[ "$OWNER" != "udev" ]]; then
log_info "Photo Import drive not processed, owner is $OWNER (expected: udev)"
exit 0
fi
log_info "Photo import started for device: $DEVICE"
log_debug "Mountpoint: $MOUNTPOINT"
log_debug "Logging to: $LOGFILE"
# Validate configuration before starting
if ! validate_configuration; then
log_error "Configuration validation failed - aborting import"
play_error_sound
exit 1
fi
# Check dependencies before starting
check_dependencies
RSYNCFLAG=""
MOVEMSG="copying"
if [[ "$MOVE_OR_COPY" == "move" ]]; then
RSYNCFLAG="--remove-source-files"
MOVEMSG="moving"
fi
# Only operate on USB disks that contain a /DCIM directory
if [[ ! -d "${MOUNTPOINT}/DCIM" ]]; then
log_info "No DCIM directory found, device will be mounted but not processed"
exit 0
fi
log_debug "DCIM directory found: ${MOUNTPOINT}/DCIM"
PROCESSED_FILES=0
TOTAL_FILES=0
SKIPPED_FILES=0
FAILED_FILES=0
TOTAL_SIZE_MB=0
PROCESSED_SIZE_MB=0
START_TIME=$(date +%s)
declare -a PROCESSED_FILE_LIST=()
# Calculate total size and file count first
for DIR in "${MOUNTPOINT}"/DCIM/*; do
[[ ! -d "$DIR" ]] && continue
for element in "${VALIDDIRS[@]}"; do
if [[ "$DIR" =~ $element ]]; then
while IFS= read -r -d '' file; do
if [[ -f "$file" ]] && validate_file "$file"; then
((TOTAL_FILES++))
local size_mb=$(( $(stat -c%s "$file" 2>/dev/null || echo 0) / 1048576 ))
((TOTAL_SIZE_MB += size_mb))
fi
done < <(find "$DIR" -type f -print0 2>/dev/null)
break
fi
done
done
# Check available space before starting
if ! check_available_space "$(dirname "$DESTINATION")" "$TOTAL_SIZE_MB"; then
log_error "Insufficient disk space. Required: ${TOTAL_SIZE_MB}MB"
play_error_sound
exit 1
fi
log_info "Found $TOTAL_FILES files (${TOTAL_SIZE_MB}MB) to process"
# Process files with advanced features
for DIR in "${MOUNTPOINT}"/DCIM/*; do
[[ ! -d "$DIR" ]] && continue
log_debug "Checking directory: $DIR"
MATCHED=0
for element in "${VALIDDIRS[@]}"; do
if [[ "$DIR" =~ $element ]]; then
MATCHED=1
break
fi
done
if [[ $MATCHED -eq 1 ]]; then
log_info "Processing directory: $DIR"
while IFS= read -r -d '' file; do
if [[ ! -f "$file" ]] || ! validate_file "$file"; then
continue
fi
# Build smart destination path
local file_dest=$(build_destination_path "$file" "$DESTINATION")
# Create destination directory
if ! mkdir -p "$file_dest"; then
log_error "Failed to create destination: $file_dest"
((FAILED_FILES++))
continue
fi
# Check for duplicates
if is_duplicate "$file" "$file_dest"; then
log_debug "Skipping duplicate: $(basename "$file")"
((SKIPPED_FILES++))
continue
fi
# Show progress if enabled
if [[ $SHOW_PROGRESS -eq 1 ]] && [[ $((PROCESSED_FILES % 10)) -eq 0 ]]; then
log_info "Progress: $PROCESSED_FILES/$TOTAL_FILES files processed"
fi
# Process file
log_debug "Processing: $file -> $file_dest/"
local file_start_time=$(date +%s)
if rsync -a $RSYNCFLAG "$file" "$file_dest/"; then
local file_end_time=$(date +%s)
local file_size_mb=$(( $(stat -c%s "$file" 2>/dev/null || echo 0) / 1048576 ))
PROCESSED_FILE_LIST+=("$file_dest/$(basename "$file")")
((PROCESSED_FILES++))
((PROCESSED_SIZE_MB += file_size_mb))
# Calculate individual file speed
local file_duration=$((file_end_time - file_start_time))
if [[ $file_duration -gt 0 ]]; then
local file_speed_mb=$((file_size_mb / file_duration))
log_debug "File transfer: $(basename "$file") (${file_size_mb}MB in ${file_duration}s @ ${file_speed_mb}MB/s)"
fi
# Verify integrity if enabled
if [[ $VERIFY_INTEGRITY -eq 1 ]]; then
local source_sum=$(get_file_checksum "$file")
local dest_sum=$(get_file_checksum "$file_dest/$(basename "$file")")
if [[ "$source_sum" != "$dest_sum" ]]; then
log_error "Integrity check failed: $(basename "$file")"
((FAILED_FILES++))
else
log_debug "Integrity verified: $(basename "$file")"
fi
fi
log_debug "Successfully processed: $(basename "$file")"
else
log_error "Failed to process: $file"
((FAILED_FILES++))
# Rollback on failure if enabled
if [[ $ENABLE_ROLLBACK -eq 1 ]]; then
log_info "Rolling back failed transfer..."
rm -f "$file_dest/$(basename "$file")" 2>/dev/null
fi
fi
# Process backup destinations
if [[ -n "$BACKUP_DESTINATIONS" ]]; then
for backup_dest in $BACKUP_DESTINATIONS; do
local backup_path=$(build_destination_path "$file" "$backup_dest")
mkdir -p "$backup_path" 2>/dev/null
if ! rsync -a "$file_dest/$(basename "$file")" "$backup_path/"; then
log_error "Failed to backup to: $backup_path"
else
log_debug "Backed up to: $backup_path"
fi
done
fi
done < <(find "$DIR" -type f -print0 2>/dev/null)
# Remove empty directory from memory card if moving
if [[ "$MOVE_OR_COPY" == "move" ]]; then
if rmdir "$DIR" 2>/dev/null; then
log_debug "Removed empty directory: $DIR"
else
log_debug "Directory not empty or removal failed: $DIR"
fi
fi
fi
done
END_TIME=$(date +%s)
TOTAL_DURATION=$((END_TIME - START_TIME))
# Calculate transfer statistics
if [[ $TOTAL_DURATION -gt 0 ]] && [[ $PROCESSED_SIZE_MB -gt 0 ]]; then
AVG_SPEED_MB=$((PROCESSED_SIZE_MB / TOTAL_DURATION))
else
AVG_SPEED_MB=0
fi
# Format duration for display
if [[ $TOTAL_DURATION -ge 60 ]]; then
DURATION_MIN=$((TOTAL_DURATION / 60))
DURATION_SEC=$((TOTAL_DURATION % 60))
DURATION_STR="${DURATION_MIN}m ${DURATION_SEC}s"
else
DURATION_STR="${TOTAL_DURATION}s"
fi
log_info "Processing complete. Files: $PROCESSED_FILES/$TOTAL_FILES processed, $SKIPPED_FILES skipped, $FAILED_FILES failed"
log_info "Transfer stats: ${PROCESSED_SIZE_MB}MB in $DURATION_STR (avg: ${AVG_SPEED_MB}MB/s)"
# Post-processing if files were moved/copied
if [[ -d "$DESTINATION" ]] && [[ $PROCESSED_FILES -gt 0 ]]; then
log_info "Starting post-processing..."
# Auto-rotate images using available tools
if command -v jhead >/dev/null 2>&1; then
log_info "Auto-rotating JPEG images with jhead"
find "$DESTINATION" -iname "*.jpg" -o -iname "*.jpeg" | while read -r img; do
jhead -autorot -ft "$img" 2>/dev/null && log_debug "Rotated: $(basename "$img")"
done
elif command -v exiftool >/dev/null 2>&1; then
log_info "Auto-rotating images with exiftool"
exiftool -overwrite_original -n "-Orientation<Orientation" "$DESTINATION" 2>/dev/null
else
log_debug "No image rotation tools available (jhead, exiftool)"
fi
# Convert HEIC/HEIF files to JPEG if enabled and converter is available
if [[ $CONVERT_HEIC -gt 0 ]]; then
if command -v magick >/dev/null 2>&1; then
log_info "Converting HEIC/HEIF files to JPEG with ImageMagick"
find "$DESTINATION" -iname "*.heic" -o -iname "*.heif" | while read -r heic_file; do
jpeg_file="${heic_file%.*}.jpg"
if magick "$heic_file" "$jpeg_file" 2>/dev/null; then
log_debug "Converted: $(basename "$heic_file") -> $(basename "$jpeg_file")"
# Remove original HEIC file if CONVERT_HEIC=2
if [[ $CONVERT_HEIC -eq 2 ]]; then
rm "$heic_file" && log_debug "Removed original: $(basename "$heic_file")"
fi
else
log_debug "Failed to convert: $(basename "$heic_file")"
fi
done
elif command -v heif-convert >/dev/null 2>&1; then
log_info "Converting HEIC/HEIF files to JPEG with heif-convert"
find "$DESTINATION" -iname "*.heic" -o -iname "*.heif" | while read -r heic_file; do
jpeg_file="${heic_file%.*}.jpg"
if heif-convert "$heic_file" "$jpeg_file" 2>/dev/null; then
log_debug "Converted: $(basename "$heic_file") -> $(basename "$jpeg_file")"
# Remove original HEIC file if CONVERT_HEIC=2
if [[ $CONVERT_HEIC -eq 2 ]]; then
rm "$heic_file" && log_debug "Removed original: $(basename "$heic_file")"
fi
else
log_debug "Failed to convert: $(basename "$heic_file")"
fi
done
else
log_debug "HEIC conversion enabled but no tools available (ImageMagick, heif-convert)"
fi
else
log_debug "HEIC/HEIF conversion disabled (CONVERT_HEIC=0)"
fi
# Fix permissions using modern method
log_debug "Fixing permissions on: $DESTINATION"
if command -v newperms >/dev/null 2>&1; then
newperms "$DESTINATION"
else
# Fallback permission setting
find "$DESTINATION" -type f -exec chmod 644 {} \;
find "$DESTINATION" -type d -exec chmod 755 {} \;
log_debug "Applied fallback permissions"
fi
log_info "Post-processing completed"
fi
# Sync and unmount USB drive safely
log_info "Syncing and unmounting device..."
sync
# Unmount the device
local unmount_success=0
if command -v /usr/local/sbin/rc.unassigned >/dev/null 2>&1; then
if /usr/local/sbin/rc.unassigned umount "$DEVICE"; then
unmount_success=1
log_debug "Successfully unmounted with rc.unassigned: $DEVICE"
else
log_error "Failed to unmount with rc.unassigned: $DEVICE"
fi
else
# Fallback unmount
if umount "$MOUNTPOINT" 2>/dev/null; then
unmount_success=1
log_debug "Successfully unmounted with fallback: $MOUNTPOINT"
else
log_error "Failed to unmount with fallback: $MOUNTPOINT"
fi
fi
# Auto-eject if enabled and unmount was successful
if [[ $AUTO_EJECT -eq 1 && $unmount_success -eq 1 ]]; then
log_info "Auto-ejecting removable media..."
if command -v eject >/dev/null 2>&1; then
# Extract base device (e.g., /dev/sdb from /dev/sdb1)
local base_device=$(echo "$DEVICE" | sed 's/[0-9]*$//')
if eject "$base_device" 2>/dev/null; then
log_info "Successfully ejected: $base_device"
else
# Try ejecting the partition device directly
if eject "$DEVICE" 2>/dev/null; then
log_info "Successfully ejected: $DEVICE"
else
log_error "Failed to eject device: $DEVICE"
log_info "Device may need to be manually removed"
fi
fi
else
log_error "eject command not available - cannot auto-eject"
log_info "AUTO_EJECT enabled but eject command not found"
fi
elif [[ $AUTO_EJECT -eq 1 ]]; then
log_error "Auto-eject requested but unmount failed - not safe to eject"
fi
# Send notifications with detailed stats
if [[ $PROCESSED_FILES -gt 0 ]]; then
play_completion_sound
# Create detailed notification message
NOTIFICATION_MSG="πŸ“Έ Photo Import Success from $(basename "$DEVICE")
βœ… Files: $PROCESSED_FILES processed, $SKIPPED_FILES duplicates, $FAILED_FILES failed
πŸ“Š Data: ${PROCESSED_SIZE_MB}MB transferred in $DURATION_STR
⚑ Speed: ${AVG_SPEED_MB}MB/s average"
# Add feature-specific stats
if [[ $SMART_FOLDERS -gt 0 ]]; then
NOTIFICATION_MSG="$NOTIFICATION_MSG
πŸ“ Smart folders: Organized by camera"
fi
if [[ $USE_EXIF_DATE -eq 1 ]]; then
NOTIFICATION_MSG="$NOTIFICATION_MSG
πŸ“… Date sorting: Used EXIF dates"
fi
if [[ $VERIFY_INTEGRITY -eq 1 ]]; then
NOTIFICATION_MSG="$NOTIFICATION_MSG
πŸ” Integrity: All files verified"
fi
if [[ -n "$BACKUP_DESTINATIONS" ]]; then
local backup_count=$(echo $BACKUP_DESTINATIONS | wc -w)
NOTIFICATION_MSG="$NOTIFICATION_MSG
πŸ’Ύ Backups: Copied to $backup_count locations"
fi
if [[ $AUTO_EJECT -eq 1 ]]; then
NOTIFICATION_MSG="$NOTIFICATION_MSG
⏏️ Auto-eject: Device safely ejected"
fi
log_info "$NOTIFICATION_MSG"
# Modern unRAID notification (simplified for notification system)
if [[ -x "/usr/local/emhttp/webGui/scripts/notify" ]]; then
SIMPLE_MSG="Photo Import: $PROCESSED_FILES files (${PROCESSED_SIZE_MB}MB) in $DURATION_STR @ ${AVG_SPEED_MB}MB/s from $(basename "$DEVICE")"
/usr/local/emhttp/webGui/scripts/notify -e "unRAID Server Notice" -s "Photo Import Success" -d "$SIMPLE_MSG" -i "normal"
fi
else
if [[ $SKIPPED_FILES -gt 0 ]]; then
NOTIFICATION_MSG="πŸ“Έ Photo Import - All $SKIPPED_FILES files were duplicates from $(basename "$DEVICE") (${TOTAL_SIZE_MB}MB total)"
log_info "$NOTIFICATION_MSG"
else
log_info "No files were processed - device may not contain valid photos"
NOTIFICATION_MSG="Photo Import completed but no valid files found on $(basename "$DEVICE")"
fi
if [[ -x "/usr/local/emhttp/webGui/scripts/notify" ]]; then
/usr/local/emhttp/webGui/scripts/notify -e "unRAID Server Notice" -s "Photo Import" -d "$NOTIFICATION_MSG" -i "warning"
fi
fi
;;
'REMOVE' )
play_remove_sound
log_info "Photo Import device unmounted and can safely be removed: $DEVICE"
;;
* )
log_error "Unknown action: $ACTION"
exit 1
;;
esac
@evenwebb
Copy link
Author

Added support for DJI drones with "/DCIM/[0-9][0-9][0-9]MEDIA"

Thanks to @ljm42 for the great script!

@evenwebb
Copy link
Author

evenwebb commented Sep 4, 2025

This release has been fully updated for modern unRAID systems, bringing expanded camera support (Nikon, Sony, Olympus, Panasonic, and smartphones), improved file handling with configurable extensions and size limits, and structured logging with timestamps and separate log levels. Notifications are now compatible with unRAID 7.x, and the script includes stronger error handling with safe fallbacks. It runs out-of-the-box on stock unRAID without extra packages, while optional features such as image auto-rotation gracefully disable if the required tools aren’t installed. Other key updates include cleaner Bash practices, per-file processing for better reliability, real-time progress tracking, and improved audio notifications. You can drop it in as a direct replacement, since all existing configuration remains fully compatible.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment