-
-
Save evenwebb/547f88db21cb30745c2267499225b7a5 to your computer and use it in GitHub Desktop.
| #!/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 |
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.
Added support for DJI drones with
"/DCIM/[0-9][0-9][0-9]MEDIA"Thanks to @ljm42 for the great script!