#!/bin/bash # ====================================================================== # shutterlinksort # ---------------- # Camera image downloader and organizer for PTP/IP-connected devices. # # Connects to a camera over network (using gphoto2), downloads new # photos, extracts EXIF metadata (date, shutter count), and sorts # images into folders by date. # # Usage: # shutterlinksort # Dependencies: # gphoto2, exiftool, bash # # Author: James Blackmore (reclusejay) # Version: 1.0 # ====================================================================== # --- Safety options --- # -e : Exit immediately if a command exits with non-zero status. # -u : Treat unset variables as an error and exit. # -o pipefail : Fail a pipeline if *any* command in it fails. set -euo pipefail # ============================================================ # Cleanup handler — runs on interrupt/termination # ============================================================ cleanup() { # Disable any special globbing options we enabled shopt -u nullglob nocaseglob || true # Delete any 0-byte (incomplete) downloads if present find "$cam_dl_dir" -type f -size 0 -delete 2>/dev/null || true log "Caught interrupt, exiting" exit 0 } # Trap cleanup on Ctrl+C, kill, or quit signals trap cleanup SIGINT SIGTERM SIGQUIT # ============================================================ # Function: php_get # Pulls a config value from the PHP $CONFIG array # ============================================================ php_get() { if ! command -v php >/dev/null 2>&1; then # fallback to php-cli if command -v php-cli >/dev/null 2>&1; then php-cli -r "include('../src/includes/config.php'); print \$CONFIG['$1'];" else echo "Error: Neither 'php' nor 'php-cli' is available." exit 1 fi else php -r "include('../src/includes/config.php'); print \$CONFIG['$1'];" fi return 0 } # ============================================================ # Load all settings from PHP config # ============================================================ cam_uuid=$(php_get "camera_uuid") cam_dl_dir=$(php_get "upload_dir") cam_sort_dir=$(php_get "sorted_img_dir") cam_index_file=$(php_get "index_file") cam_xml_url=$(php_get "camera_xml_url") cam_ext_format=$(php_get "camera_ext_formats") cam_mac=$(php_get "camera_mac") g2_port=$(php_get "gphoto2_config_port") g2_model=$(php_get "gphoto2_config_model") g2_guid=$(php_get "gphoto2_config_guid") g2_conf=$(php_get "gphoto2_config_file") # Create the index file if missing [ -f "$cam_index_file" ] || echo 0 > "$cam_index_file" # ============================================================ # Global variables # ============================================================ debug=true ver="v.25.10.1" STATUS="INITIALIZING" CAM_IP="" # ============================================================ # Function: log # Provides timestamped console and syslog logging # ============================================================ log() { local msg lev full_msg if $debug; then msg="${1:-NO MESSAGE!}" lev="${2:-info}" full_msg="[debug $lev] Version: $ver - $(date +[%Y/%m/%d-%H:%M:%S]) - $msg" # Always print to stdout for debugging echo "$full_msg" # Try syslog first; fallback to a file log if logger fails logger -t camera-download-sort "$full_msg" 2>/dev/null || \ echo "$full_msg" >> /var/log/camera-download-sort.log fi } # ============================================================ # Function: find_ip_by_mac # Locates the IP address of a device by its MAC address # ============================================================ find_ip_by_mac() { local mac ip file # Normalize MAC to lowercase for comparison mac=$(echo "$1" | tr '[:upper:]' '[:lower:]') [ -z "$mac" ] && log "No MAC provided" && return 1 # 1️⃣ Primary source: /proc/net/arp (most reliable) ip=$(awk -v mac="$mac" 'NR>1 && tolower($4)==mac {print $1; exit}' /proc/net/arp) # 2️⃣ Fallback: BusyBox arp command output if [ -z "$ip" ] && command -v arp >/dev/null 2>&1; then ip=$(arp | awk -v mac="$mac" 'NR>1 && tolower($4)==mac {print $1; exit}') fi # 3️⃣ Fallback: dnsmasq lease files if [ -z "$ip" ]; then for file in /tmp/dhcp.leases /var/lib/misc/dnsmasq.leases; do [ -f "$file" ] || continue ip=$(awk -v mac="$mac" 'tolower($2)==mac {print $3; exit}' "$file") [ -n "$ip" ] && break done fi # If found, set global CAM_IP variable if [ -n "$ip" ]; then CAM_IP="$ip" log "Camera found at $CAM_IP" return 0 fi # Not found return 1 } # ============================================================ # Function: is_camera_connected # Checks whether the camera’s MAC address is visible # ============================================================ is_camera_connected() { if find_ip_by_mac "$cam_mac"; then if [ "$STATUS" = "WAITING" ]; then # Already in waiting state — skip return 1 fi STATUS="CONNECTED" return 0 fi STATUS="DISCONNECTED" return 1 } # ============================================================ # Function: download_images # Uses gphoto2 (PTP/IP) to pull new images from camera # ============================================================ download_images() { local wd="$PWD" STATUS="WAITING" cd "$cam_dl_dir" || return 1 # Create gphoto2 config file if it doesn’t exist if [ ! -f "$g2_conf" ]; then { echo "$g2_port$CAM_IP" echo "$g2_model" echo "$g2_guid$cam_uuid" } > "$g2_conf" fi # Check for gphoto2 availability if command -v gphoto2 >/dev/null 2>&1; then # Download all new files, skipping ones that already exist if gphoto2 --port "ptpip:$CAM_IP" -P --skip-existing; then log "Downloaded new images from $CAM_IP" cd "$wd" || true return 0 fi log "gphoto2 download failed" error else log "gphoto2 not found — skipping download" warn fi cd "$wd" || true return 1 } # ============================================================ # Function: sort_images # Sorts downloaded images into folders by EXIF date # ============================================================ sort_images() { local ext img datetime shuttercount yyyy mm dd timepart hhmmss ext_lower \ output_dir new_filename new_file uid index STATUS="SORTING" log "$STATUS" # Ensure exiftool exists command -v exiftool >/dev/null 2>&1 || { log "exiftool missing — cannot sort" error return 1 } # Get current image index number index=$(cat "$cam_index_file") # Loop over each configured image extension (e.g. JPG ARW) for ext in $cam_ext_format; do # Enable flexible globbing (case-insensitive, no literal fail) shopt -s nullglob nocaseglob # Iterate through each matching file for img in "$cam_dl_dir"/*."$ext"; do # Pull EXIF date and shutter count readarray -t exif_data < <(exiftool -s -s -s -DateTimeOriginal -ShutterCount "$img") datetime="${exif_data[0]:-0000:00:00 00:00:00}" shuttercount="${exif_data[1]:-}" log "Processing $img (index $index)" # Generate a unique ID if no shuttercount found if [ -z "$shuttercount" ]; then uid="rn${RANDOM}idx${index}" log "No shuttercount in $img, uid=$uid" else uid="sc${shuttercount}idx${index}" log "Shuttercount=$shuttercount uid=$uid" fi # Parse EXIF date into components yyyy=$(echo "$datetime" | cut -d':' -f1) mm=$(echo "$datetime" | cut -d':' -f2) dd=$(echo "$datetime" | cut -d':' -f3 | cut -d' ' -f1) timepart=$(echo "$datetime" | cut -d' ' -f2) hhmmss=$(echo "$timepart" | tr -d ':') # Normalize extension to lowercase ext_lower=${ext,,} # Build destination folder structure output_dir="$cam_sort_dir/$ext_lower/$yyyy/$mm/$dd" mkdir -p "$output_dir" || { log "mkdir failed: $output_dir" error; return 1; } # Construct new filename new_filename="${hhmmss}_${uid}.${ext_lower}" new_file="$output_dir/$new_filename" log "Moving $img -> $new_file" # Move and increment index if mv "$img" "$new_file"; then index=$((index + 1)) else log "Failed to move $img" error fi done # Update index file after processing each extension type printf '%s\n' "$index" > "$cam_index_file" done STATUS="DONE" log "$STATUS" return 0 } # ============================================================ # Main loop — runs forever # ============================================================ log "Camera script started" while :; do # If camera detected and not already downloading/sorting if is_camera_connected && [ "$STATUS" != "WAITING" ] && [ "$STATUS" != "SORTING" ]; then download_images || log "Download step failed" error # If camera disconnected, begin sorting elif [ "$STATUS" = "DISCONNECTED" ]; then sort_images || log "Sort step failed" error fi # Avoid hammering system; check every 2 seconds sleep 2 done