- Refactor autonas-media-importer.sh v2.0 with: * Organization patterns support (ymd/ym/d/h/m/y, default ymd for backward compat) * Filename mode options (auto/full/orig, default full) * GoPro special handling with THM/LRV fallback * Advanced date extraction with EXIF/filesystem/auto modes * QuickTime UTC conversion * Conflict resolution with numeric suffix generation * Improved file size tracking and verification * Support for --collect-unsortable option * Enhanced error handling and logging - Update standalone-media-importer.sh to v1.0 from Media Importer * Complete rewrite with all advanced features * Full compatibility with multiple organization patterns * Better error handling and reporting * Enhanced media file detection Both scripts maintain backward compatibility with positional arguments: script.sh source_mount destination_path [--verbose] Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
@@ -2,55 +2,540 @@ |
||
| 2 | 2 |
|
| 3 | 3 |
# AutoNAS Media Importer |
| 4 | 4 |
# Advanced media import engine that processes, organizes and imports media files from cameras |
| 5 |
-# Usage: autonas-media-importer.sh <source_mount> <destination_path> |
|
| 5 |
+# Usage: autonas-media-importer.sh <source_mount> <destination_path> [options] |
|
| 6 |
+# Features: organization patterns (ymd/ym/d/h/m/y), GoPro handling, metadata sync |
|
| 6 | 7 |
|
| 7 |
-# Global configuration |
|
| 8 | 8 |
LOG_TAG="autonas-import" |
| 9 |
+VERSION="2.0" |
|
| 10 |
+ |
|
| 11 |
+# Default values (compatible with original) |
|
| 12 |
+ORGANIZATION="ymd" |
|
| 13 |
+FILENAME_MODE="full" |
|
| 14 |
+VERIFY_MODE="size" |
|
| 15 |
+DATE_SOURCE="auto" |
|
| 16 |
+SYNC_METADATA=0 |
|
| 17 |
+UNATTENDED=1 |
|
| 18 |
+COLLECT_UNSORTABLE=0 |
|
| 19 |
+KEEP_EMPTY_DIRS=1 |
|
| 20 |
+DRY_RUN=0 |
|
| 21 |
+VERBOSE=0 |
|
| 22 |
+KEEP_ORIGINALS=0 |
|
| 23 |
+FILE_LIMIT=0 |
|
| 24 |
+CONFLICT_APPLY_ALL="" |
|
| 25 |
+RESOLVED_DESTINATION_PATH="" |
|
| 26 |
+RESERVED_DESTINATION_PATHS=() |
|
| 27 |
+ |
|
| 28 |
+TOTAL_FILES=0 |
|
| 29 |
+PROCESSED_FILES=0 |
|
| 30 |
+SKIPPED_FILES=0 |
|
| 31 |
+ERROR_FILES=0 |
|
| 32 |
+TOTAL_SIZE=0 |
|
| 33 |
+PROCESSED_SIZE=0 |
|
| 34 |
+START_TIME=$(date +%s) |
|
| 35 |
+ |
|
| 36 |
+RED='\033[0;31m' |
|
| 37 |
+GREEN='\033[0;32m' |
|
| 38 |
+YELLOW='\033[1;33m' |
|
| 39 |
+BLUE=$'\033[1;36m' |
|
| 40 |
+NC='\033[0m' |
|
| 41 |
+ |
|
| 42 |
+MEDIA_EXTENSIONS=("*.jpg" "*.jpeg" "*.png" "*.tiff" "*.tif" "*.cr2" "*.nef" "*.arw" "*.dng" "*.raw" "*.mp4" "*.mov" "*.avi" "*.mts" "*.m2ts" "*.mkv" "*.wmv" "*.3gp" "*.m4v")
|
|
| 43 |
+ |
|
| 44 |
+print_color() {
|
|
| 45 |
+ local color="$1" |
|
| 46 |
+ local message="$2" |
|
| 47 |
+ echo -e "${color}${message}${NC}"
|
|
| 48 |
+} |
|
| 9 | 49 |
|
| 10 |
-# Function to log messages |
|
| 11 | 50 |
log_message() {
|
| 12 | 51 |
local message="$1" |
| 13 |
- local priority="${2:-info}" # Default priority is info
|
|
| 14 |
- |
|
| 15 |
- # Log to syslog with facility local0 and specified priority |
|
| 52 |
+ local priority="${2:-info}"
|
|
| 53 |
+ |
|
| 16 | 54 |
logger -p "local0.$priority" -t "$LOG_TAG" "$message" |
| 17 |
- |
|
| 18 |
- # Also echo to stdout/stderr for interactive use |
|
| 55 |
+ |
|
| 19 | 56 |
if [ -t 1 ]; then |
| 20 |
- echo "$(date '+%Y-%m-%d %H:%M:%S') - $message" |
|
| 57 |
+ case "$priority" in |
|
| 58 |
+ "err") |
|
| 59 |
+ print_color "$RED" "$(date '+%Y-%m-%d %H:%M:%S') - ERROR: $message" >&2 |
|
| 60 |
+ ;; |
|
| 61 |
+ "warning") |
|
| 62 |
+ print_color "$YELLOW" "$(date '+%Y-%m-%d %H:%M:%S') - WARNING: $message" |
|
| 63 |
+ ;; |
|
| 64 |
+ "info") |
|
| 65 |
+ if [[ $VERBOSE -eq 1 ]]; then |
|
| 66 |
+ print_color "$BLUE" "$(date '+%Y-%m-%d %H:%M:%S') - INFO: $message" |
|
| 67 |
+ fi |
|
| 68 |
+ ;; |
|
| 69 |
+ *) |
|
| 70 |
+ echo "$(date '+%Y-%m-%d %H:%M:%S') - $message" |
|
| 71 |
+ ;; |
|
| 72 |
+ esac |
|
| 21 | 73 |
fi |
| 22 | 74 |
} |
| 23 | 75 |
|
| 24 |
-# Usage function |
|
| 25 |
-usage() {
|
|
| 26 |
- echo "Usage: $0 <source_mount> <destination_path> [options]" |
|
| 27 |
- echo "" |
|
| 28 |
- echo "Arguments:" |
|
| 29 |
- echo " source_mount - Mount point of the camera (e.g., /mnt/autonas/camera)" |
|
| 30 |
- echo " destination_path - Destination directory for imported files" |
|
| 31 |
- echo "" |
|
| 32 |
- echo "Options:" |
|
| 33 |
- echo " --dry-run - Show what would be done without actually doing it" |
|
| 34 |
- echo " --keep-originals - Keep original files on camera after import" |
|
| 35 |
- echo " --verbose - Enable verbose output" |
|
| 36 |
- echo " --limit N - Process only N files (useful for testing)" |
|
| 37 |
- echo " --help - Show this help" |
|
| 38 |
- echo "" |
|
| 39 |
- echo "Examples:" |
|
| 40 |
- echo " $0 /mnt/autonas/camera /mnt/autonas/photos/imported" |
|
| 41 |
- echo " $0 /mnt/autonas/camera /mnt/autonas/photos/imported --dry-run --verbose" |
|
| 76 |
+show_help() {
|
|
| 77 |
+ cat << EOF |
|
| 78 |
+AutoNAS Media Importer v$VERSION |
|
| 79 |
+Advanced camera import engine |
|
| 80 |
+ |
|
| 81 |
+Usage: |
|
| 82 |
+ $0 <source_mount> <destination_path> [OPTIONS] |
|
| 83 |
+ |
|
| 84 |
+Arguments: |
|
| 85 |
+ source_mount Mount point of camera |
|
| 86 |
+ destination_path Destination directory for imported files |
|
| 87 |
+ |
|
| 88 |
+Options: |
|
| 89 |
+ -o, --organization y|m|d|h|ym|ymd (default: ymd) |
|
| 90 |
+ -F, --filename-mode auto|full|orig (default: full) |
|
| 91 |
+ --date-source auto|exif|filesystem (default: auto) |
|
| 92 |
+ --sync-metadata Write date into destination metadata |
|
| 93 |
+ --collect-unsortable Put undated files into DEST/unsortable |
|
| 94 |
+ --keep-empty-dirs Keep empty directories after processing |
|
| 95 |
+ --dry-run Show actions without changing files |
|
| 96 |
+ --keep-originals Copy files instead of moving |
|
| 97 |
+ --verbose Enable verbose output |
|
| 98 |
+ --limit N Process only N files |
|
| 99 |
+ -h, --help Show this help |
|
| 100 |
+EOF |
|
| 101 |
+} |
|
| 102 |
+ |
|
| 103 |
+check_dependencies() {
|
|
| 104 |
+ if ! command -v exiftool &> /dev/null; then |
|
| 105 |
+ print_color "$RED" "ERROR: exiftool is required but not installed" |
|
| 106 |
+ exit 1 |
|
| 107 |
+ fi |
|
| 108 |
+} |
|
| 109 |
+ |
|
| 110 |
+# Utility functions |
|
| 111 |
+get_file_size() {
|
|
| 112 |
+ local file="$1" |
|
| 113 |
+ if [[ -f "$file" ]]; then |
|
| 114 |
+ if stat -c%s "$file" >/dev/null 2>&1; then |
|
| 115 |
+ stat -c%s "$file" 2>/dev/null |
|
| 116 |
+ elif stat -f%z "$file" >/dev/null 2>&1; then |
|
| 117 |
+ stat -f%z "$file" 2>/dev/null |
|
| 118 |
+ else |
|
| 119 |
+ ls -ln "$file" | awk '{print $5}'
|
|
| 120 |
+ fi |
|
| 121 |
+ else |
|
| 122 |
+ echo "0" |
|
| 123 |
+ fi |
|
| 124 |
+} |
|
| 125 |
+ |
|
| 126 |
+is_gopro_media_file() {
|
|
| 127 |
+ local filename |
|
| 128 |
+ filename=$(basename "$1") |
|
| 129 |
+ [[ "$filename" =~ ^G[HPX][0-9]{6}\.[Mm][Pp]4$ ]]
|
|
| 130 |
+} |
|
| 131 |
+ |
|
| 132 |
+should_prefer_gopro_filesystem_date() {
|
|
| 133 |
+ is_gopro_media_file "$1" |
|
| 134 |
+} |
|
| 135 |
+ |
|
| 136 |
+filesystem_date_reference() {
|
|
| 137 |
+ local file="$1" |
|
| 138 |
+ local dir base stem ext sidecar_ext sidecar |
|
| 139 |
+ dir=$(dirname "$file") |
|
| 140 |
+ base=$(basename "$file") |
|
| 141 |
+ stem="${base%.*}"
|
|
| 142 |
+ ext="${base##*.}"
|
|
| 143 |
+ |
|
| 144 |
+ if [[ "$ext" =~ ^([Mm][Pp]4)$ ]]; then |
|
| 145 |
+ for sidecar_ext in THM thm LRV lrv; do |
|
| 146 |
+ sidecar="$dir/$stem.$sidecar_ext" |
|
| 147 |
+ if [[ -f "$sidecar" ]]; then |
|
| 148 |
+ echo "$sidecar" |
|
| 149 |
+ return 0 |
|
| 150 |
+ fi |
|
| 151 |
+ done |
|
| 152 |
+ fi |
|
| 153 |
+ |
|
| 154 |
+ echo "$file" |
|
| 155 |
+} |
|
| 156 |
+ |
|
| 157 |
+extract_filesystem_date() {
|
|
| 158 |
+ local file="$1" |
|
| 159 |
+ if [[ ! -e "$file" ]]; then |
|
| 160 |
+ return 2 |
|
| 161 |
+ fi |
|
| 162 |
+ |
|
| 163 |
+ local epoch="" |
|
| 164 |
+ if [[ "$OSTYPE" == "darwin"* ]]; then |
|
| 165 |
+ epoch=$(stat -f %m "$file" 2>/dev/null || echo "") |
|
| 166 |
+ [[ -n "$epoch" ]] || return 2 |
|
| 167 |
+ date -j -r "$epoch" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || return 2 |
|
| 168 |
+ else |
|
| 169 |
+ epoch=$(stat -c %Y "$file" 2>/dev/null || echo "") |
|
| 170 |
+ [[ -n "$epoch" ]] || return 2 |
|
| 171 |
+ date -d "@$epoch" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || return 2 |
|
| 172 |
+ fi |
|
| 173 |
+} |
|
| 174 |
+ |
|
| 175 |
+destination_path_unavailable() {
|
|
| 176 |
+ local candidate="$1" |
|
| 177 |
+ [[ -e "$candidate" ]] && return 0 |
|
| 178 |
+ for reserved_path in "${RESERVED_DESTINATION_PATHS[@]}"; do
|
|
| 179 |
+ [[ "$reserved_path" == "$candidate" ]] && return 0 |
|
| 180 |
+ done |
|
| 181 |
+ return 1 |
|
| 182 |
+} |
|
| 183 |
+ |
|
| 184 |
+reserve_destination_path() {
|
|
| 185 |
+ local candidate="$1" |
|
| 186 |
+ if [[ -n "$candidate" ]]; then |
|
| 187 |
+ RESERVED_DESTINATION_PATHS+=("$candidate")
|
|
| 188 |
+ fi |
|
| 189 |
+} |
|
| 190 |
+ |
|
| 191 |
+extract_file_date() {
|
|
| 192 |
+ local file="$1" |
|
| 193 |
+ local create_date="" |
|
| 194 |
+ local date_source="" |
|
| 195 |
+ |
|
| 196 |
+ if [[ "$DATE_SOURCE" == "filesystem" ]] || { [[ "$DATE_SOURCE" == "auto" ]] && should_prefer_gopro_filesystem_date "$file"; }; then
|
|
| 197 |
+ local filesystem_reference |
|
| 198 |
+ filesystem_reference=$(filesystem_date_reference "$file") |
|
| 199 |
+ create_date=$(extract_filesystem_date "$filesystem_reference") || return 2 |
|
| 200 |
+ if is_gopro_media_file "$file"; then |
|
| 201 |
+ echo "$create_date|Filesystem:GoPro" |
|
| 202 |
+ else |
|
| 203 |
+ echo "$create_date|Filesystem" |
|
| 204 |
+ fi |
|
| 205 |
+ return 0 |
|
| 206 |
+ fi |
|
| 207 |
+ |
|
| 208 |
+ # Try EXIF data |
|
| 209 |
+ local exif_output=$(exiftool -G1 -s -CreateDate -DateTimeOriginal -DateTime "$file" 2>/dev/null) |
|
| 210 |
+ if [[ -n "$exif_output" ]]; then |
|
| 211 |
+ while IFS= read -r line; do |
|
| 212 |
+ if [[ "$line" =~ ^\[([^\]]+)\][[:space:]]*([^:]+)[[:space:]]*:[[:space:]]*(.+)$ ]]; then |
|
| 213 |
+ local group="${BASH_REMATCH[1]}"
|
|
| 214 |
+ local tag="${BASH_REMATCH[2]}"
|
|
| 215 |
+ local value="${BASH_REMATCH[3]}"
|
|
| 216 |
+ tag=$(echo "$tag" | sed 's/[[:space:]]*$//') |
|
| 217 |
+ |
|
| 218 |
+ if [[ "$tag" == "DateTimeOriginal" && -z "$create_date" ]]; then |
|
| 219 |
+ create_date="$value" |
|
| 220 |
+ date_source="$group:$tag" |
|
| 221 |
+ elif [[ "$tag" == "CreateDate" && "$date_source" != *"DateTimeOriginal"* ]]; then |
|
| 222 |
+ create_date="$value" |
|
| 223 |
+ date_source="$group:$tag" |
|
| 224 |
+ elif [[ "$tag" == "DateTime" && -z "$create_date" ]]; then |
|
| 225 |
+ create_date="$value" |
|
| 226 |
+ date_source="$group:$tag" |
|
| 227 |
+ fi |
|
| 228 |
+ fi |
|
| 229 |
+ done <<< "$exif_output" |
|
| 230 |
+ fi |
|
| 231 |
+ |
|
| 232 |
+ # Fallback to filesystem in auto mode |
|
| 233 |
+ if [[ -z "$create_date" && "$DATE_SOURCE" == "auto" ]]; then |
|
| 234 |
+ local filesystem_reference |
|
| 235 |
+ filesystem_reference=$(filesystem_date_reference "$file") |
|
| 236 |
+ create_date=$(extract_filesystem_date "$filesystem_reference") || return 2 |
|
| 237 |
+ date_source="Filesystem" |
|
| 238 |
+ fi |
|
| 239 |
+ |
|
| 240 |
+ [[ -z "$create_date" ]] && return 2 |
|
| 241 |
+ |
|
| 242 |
+ # Convert EXIF format (YYYY:MM:DD HH:MM:SS) to standard |
|
| 243 |
+ if [[ "$create_date" =~ ^([0-9]{4}):([0-9]{1,2}):([0-9]{1,2})[[:space:]]*([0-9]{1,2}):([0-9]{1,2}):([0-9]{1,2})$ ]]; then
|
|
| 244 |
+ local year="${BASH_REMATCH[1]}"
|
|
| 245 |
+ local month=$(printf "%02d" "$((10#${BASH_REMATCH[2]}))")
|
|
| 246 |
+ local day=$(printf "%02d" "$((10#${BASH_REMATCH[3]}))")
|
|
| 247 |
+ local hour=$(printf "%02d" "$((10#${BASH_REMATCH[4]}))")
|
|
| 248 |
+ local minute=$(printf "%02d" "$((10#${BASH_REMATCH[5]}))")
|
|
| 249 |
+ local second=$(printf "%02d" "$((10#${BASH_REMATCH[6]}))")
|
|
| 250 |
+ create_date="$year-$month-$day $hour:$minute:$second" |
|
| 251 |
+ fi |
|
| 252 |
+ |
|
| 253 |
+ # QuickTime UTC conversion |
|
| 254 |
+ if [[ "$date_source" == *"QuickTime"* ]]; then |
|
| 255 |
+ if [[ "$OSTYPE" == "darwin"* ]]; then |
|
| 256 |
+ local utc_timestamp=$(TZ=UTC date -j -f "%Y-%m-%d %H:%M:%S" "$create_date" "+%s" 2>/dev/null) |
|
| 257 |
+ [[ -n "$utc_timestamp" ]] && create_date=$(date -j -r "$utc_timestamp" "+%Y-%m-%d %H:%M:%S" 2>/dev/null) |
|
| 258 |
+ else |
|
| 259 |
+ local utc_timestamp=$(date -d "$create_date UTC" "+%s" 2>/dev/null) |
|
| 260 |
+ [[ -n "$utc_timestamp" ]] && create_date=$(date -d "@$utc_timestamp" "+%Y-%m-%d %H:%M:%S" 2>/dev/null) |
|
| 261 |
+ fi |
|
| 262 |
+ fi |
|
| 263 |
+ |
|
| 264 |
+ echo "$create_date|$date_source" |
|
| 265 |
+ return 0 |
|
| 266 |
+} |
|
| 267 |
+ |
|
| 268 |
+generate_destination_path() {
|
|
| 269 |
+ local date_str="$1" |
|
| 270 |
+ local original_filename="$2" |
|
| 271 |
+ local base_destination="$3" |
|
| 272 |
+ |
|
| 273 |
+ local year month day hour minute second |
|
| 274 |
+ if [[ "$OSTYPE" == "darwin"* ]]; then |
|
| 275 |
+ if [[ "$date_str" =~ ^([0-9]{4})-([0-9]{1,2})-([0-9]{1,2})[[:space:]]*([0-9]{1,2}):([0-9]{1,2}):([0-9]{1,2})$ ]]; then
|
|
| 276 |
+ year="${BASH_REMATCH[1]}"
|
|
| 277 |
+ month=$(printf "%02d" "$((10#${BASH_REMATCH[2]}))")
|
|
| 278 |
+ day=$(printf "%02d" "$((10#${BASH_REMATCH[3]}))")
|
|
| 279 |
+ hour=$(printf "%02d" "$((10#${BASH_REMATCH[4]}))")
|
|
| 280 |
+ minute=$(printf "%02d" "$((10#${BASH_REMATCH[5]}))")
|
|
| 281 |
+ second=$(printf "%02d" "$((10#${BASH_REMATCH[6]}))")
|
|
| 282 |
+ else |
|
| 283 |
+ return 1 |
|
| 284 |
+ fi |
|
| 285 |
+ else |
|
| 286 |
+ year=$(date -d "$date_str" "+%Y" 2>/dev/null) |
|
| 287 |
+ month=$(date -d "$date_str" "+%m" 2>/dev/null) |
|
| 288 |
+ day=$(date -d "$date_str" "+%d" 2>/dev/null) |
|
| 289 |
+ hour=$(date -d "$date_str" "+%H" 2>/dev/null) |
|
| 290 |
+ minute=$(date -d "$date_str" "+%M" 2>/dev/null) |
|
| 291 |
+ second=$(date -d "$date_str" "+%S" 2>/dev/null) |
|
| 292 |
+ fi |
|
| 293 |
+ |
|
| 294 |
+ [[ -z "$year" || -z "$month" || -z "$day" ]] && return 1 |
|
| 295 |
+ |
|
| 296 |
+ local extension="${original_filename##*.}"
|
|
| 297 |
+ local lowercase_ext=$(echo "$extension" | tr '[:upper:]' '[:lower:]') |
|
| 298 |
+ |
|
| 299 |
+ local dir_path="" |
|
| 300 |
+ local filename="" |
|
| 301 |
+ |
|
| 302 |
+ case "$ORGANIZATION" in |
|
| 303 |
+ "y") |
|
| 304 |
+ dir_path="$base_destination/$year" |
|
| 305 |
+ filename="${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
|
|
| 306 |
+ ;; |
|
| 307 |
+ "m") |
|
| 308 |
+ dir_path="$base_destination/$year/$month" |
|
| 309 |
+ filename="${day}_${hour}-${minute}-${second}.${lowercase_ext}"
|
|
| 310 |
+ ;; |
|
| 311 |
+ "d") |
|
| 312 |
+ dir_path="$base_destination/$year/$month/$day" |
|
| 313 |
+ filename="${hour}-${minute}-${second}.${lowercase_ext}"
|
|
| 314 |
+ ;; |
|
| 315 |
+ "h") |
|
| 316 |
+ dir_path="$base_destination/$year/$month/$day/$hour" |
|
| 317 |
+ filename="${minute}-${second}.${lowercase_ext}"
|
|
| 318 |
+ ;; |
|
| 319 |
+ "ym") |
|
| 320 |
+ dir_path="$base_destination/${year}-${month}"
|
|
| 321 |
+ filename="${day}_${hour}-${minute}-${second}.${lowercase_ext}"
|
|
| 322 |
+ ;; |
|
| 323 |
+ "ymd") |
|
| 324 |
+ dir_path="$base_destination/${year}-${month}-${day}"
|
|
| 325 |
+ filename="${hour}-${minute}-${second}.${lowercase_ext}"
|
|
| 326 |
+ ;; |
|
| 327 |
+ *) |
|
| 328 |
+ return 1 |
|
| 329 |
+ ;; |
|
| 330 |
+ esac |
|
| 331 |
+ |
|
| 332 |
+ case "$FILENAME_MODE" in |
|
| 333 |
+ orig) |
|
| 334 |
+ filename="$original_filename" |
|
| 335 |
+ ;; |
|
| 336 |
+ full) |
|
| 337 |
+ filename="${year}-${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
|
|
| 338 |
+ ;; |
|
| 339 |
+ auto) |
|
| 340 |
+ ;; |
|
| 341 |
+ *) |
|
| 342 |
+ filename="${year}-${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
|
|
| 343 |
+ ;; |
|
| 344 |
+ esac |
|
| 345 |
+ |
|
| 346 |
+ echo "$dir_path/$filename" |
|
| 347 |
+ return 0 |
|
| 348 |
+} |
|
| 349 |
+ |
|
| 350 |
+ensure_unique_destination_path() {
|
|
| 351 |
+ local desired_path="$1" |
|
| 352 |
+ local counter=1 |
|
| 353 |
+ local resolved_path="$desired_path" |
|
| 354 |
+ |
|
| 355 |
+ while destination_path_unavailable "$resolved_path"; do |
|
| 356 |
+ local dir=$(dirname "$desired_path") |
|
| 357 |
+ local base=$(basename "$desired_path") |
|
| 358 |
+ local name_without_ext="${base%.*}"
|
|
| 359 |
+ local ext="${base##*.}"
|
|
| 360 |
+ |
|
| 361 |
+ if [[ "$ext" == "$base" ]]; then |
|
| 362 |
+ resolved_path="$dir/${name_without_ext}_${counter}"
|
|
| 363 |
+ else |
|
| 364 |
+ resolved_path="$dir/${name_without_ext}_${counter}.$ext"
|
|
| 365 |
+ fi |
|
| 366 |
+ counter=$((counter + 1)) |
|
| 367 |
+ |
|
| 368 |
+ [[ $counter -gt 1000 ]] && return 1 |
|
| 369 |
+ done |
|
| 370 |
+ |
|
| 371 |
+ echo "$resolved_path" |
|
| 372 |
+ return 0 |
|
| 373 |
+} |
|
| 374 |
+ |
|
| 375 |
+resolve_destination_conflict() {
|
|
| 376 |
+ local desired_path="$1" |
|
| 377 |
+ RESOLVED_DESTINATION_PATH="" |
|
| 378 |
+ |
|
| 379 |
+ [[ -z "$desired_path" ]] && return 1 |
|
| 380 |
+ |
|
| 381 |
+ if ! destination_path_unavailable "$desired_path"; then |
|
| 382 |
+ RESOLVED_DESTINATION_PATH="$desired_path" |
|
| 383 |
+ reserve_destination_path "$RESOLVED_DESTINATION_PATH" |
|
| 384 |
+ return 0 |
|
| 385 |
+ fi |
|
| 386 |
+ |
|
| 387 |
+ local resolved_path |
|
| 388 |
+ resolved_path=$(ensure_unique_destination_path "$desired_path") |
|
| 389 |
+ [[ -z "$resolved_path" ]] && return 1 |
|
| 390 |
+ |
|
| 391 |
+ RESOLVED_DESTINATION_PATH="$resolved_path" |
|
| 392 |
+ reserve_destination_path "$RESOLVED_DESTINATION_PATH" |
|
| 393 |
+ return 0 |
|
| 394 |
+} |
|
| 395 |
+ |
|
| 396 |
+safe_mv() {
|
|
| 397 |
+ local src="$1" dst="$2" |
|
| 398 |
+ mv "$src" "$dst" 2> >(grep -v "set flags (was:" >&2) |
|
| 399 |
+} |
|
| 400 |
+ |
|
| 401 |
+# Process a single file |
|
| 402 |
+process_file() {
|
|
| 403 |
+ local file="$1" |
|
| 404 |
+ local relative_path="${file#$SOURCE_MOUNT/}"
|
|
| 405 |
+ local file_size=$(get_file_size "$file") |
|
| 406 |
+ TOTAL_SIZE=$((TOTAL_SIZE + file_size)) |
|
| 407 |
+ |
|
| 408 |
+ # Check mount point |
|
| 409 |
+ if ! mountpoint -q "$SOURCE_MOUNT" 2>/dev/null; then |
|
| 410 |
+ log_message "Error: Source mount point is no longer available" "err" |
|
| 411 |
+ return 1 |
|
| 412 |
+ fi |
|
| 413 |
+ |
|
| 414 |
+ # Check if file still exists |
|
| 415 |
+ if [[ ! -f "$file" ]]; then |
|
| 416 |
+ log_message "Error: File no longer exists: $relative_path" "err" |
|
| 417 |
+ log_message "Camera appears to be disconnected, stopping import" "warning" |
|
| 418 |
+ exit 1 |
|
| 419 |
+ fi |
|
| 420 |
+ |
|
| 421 |
+ [[ $VERBOSE -eq 1 ]] && log_message "Processing: $relative_path" "info" |
|
| 422 |
+ |
|
| 423 |
+ # Extract date |
|
| 424 |
+ local date_info=$(extract_file_date "$file") |
|
| 425 |
+ local extract_status=$? |
|
| 426 |
+ |
|
| 427 |
+ if [[ $extract_status -eq 2 ]]; then |
|
| 428 |
+ if [[ $COLLECT_UNSORTABLE -eq 1 ]]; then |
|
| 429 |
+ local unsortable_dir="$DESTINATION/unsortable" |
|
| 430 |
+ mkdir -p "$unsortable_dir" |
|
| 431 |
+ local unsortable_path="$unsortable_dir/$(basename "$file")" |
|
| 432 |
+ resolve_destination_conflict "$unsortable_path" |
|
| 433 |
+ [[ $? -eq 0 ]] && unsortable_path="$RESOLVED_DESTINATION_PATH" |
|
| 434 |
+ if [[ $DRY_RUN -eq 0 ]]; then |
|
| 435 |
+ safe_mv "$file" "$unsortable_path" |
|
| 436 |
+ log_message "Moved to unsortable: $relative_path" "info" |
|
| 437 |
+ fi |
|
| 438 |
+ else |
|
| 439 |
+ log_message "No date found for $relative_path - skipping" "warning" |
|
| 440 |
+ fi |
|
| 441 |
+ SKIPPED_FILES=$((SKIPPED_FILES + 1)) |
|
| 442 |
+ return 1 |
|
| 443 |
+ elif [[ $extract_status -ne 0 ]]; then |
|
| 444 |
+ log_message "Failed to extract date from $relative_path" "warning" |
|
| 445 |
+ SKIPPED_FILES=$((SKIPPED_FILES + 1)) |
|
| 446 |
+ return 1 |
|
| 447 |
+ fi |
|
| 448 |
+ |
|
| 449 |
+ local date_str="${date_info%|*}"
|
|
| 450 |
+ local date_source="${date_info#*|}"
|
|
| 451 |
+ [[ $VERBOSE -eq 1 ]] && log_message "Date: $date_str (from $date_source)" "info" |
|
| 452 |
+ |
|
| 453 |
+ # Generate destination path |
|
| 454 |
+ local original_basename=$(basename "$file") |
|
| 455 |
+ local dest_path=$(generate_destination_path "$date_str" "$original_basename" "$DESTINATION") |
|
| 456 |
+ [[ $? -ne 0 ]] && { log_message "Could not generate destination path for $relative_path" "err"; ERROR_FILES=$((ERROR_FILES + 1)); return 1; }
|
|
| 457 |
+ |
|
| 458 |
+ local desired_dest_path="$dest_path" |
|
| 459 |
+ resolve_destination_conflict "$dest_path" |
|
| 460 |
+ [[ $? -eq 0 ]] && dest_path="$RESOLVED_DESTINATION_PATH" || { ERROR_FILES=$((ERROR_FILES + 1)); return 1; }
|
|
| 461 |
+ |
|
| 462 |
+ [[ "$dest_path" != "$desired_dest_path" ]] && log_message "Destination conflict resolved" "info" |
|
| 463 |
+ |
|
| 464 |
+ local dest_dir=$(dirname "$dest_path") |
|
| 465 |
+ |
|
| 466 |
+ if [[ $DRY_RUN -eq 1 ]]; then |
|
| 467 |
+ if [[ $KEEP_ORIGINALS -eq 1 ]]; then |
|
| 468 |
+ echo "Would copy: $relative_path -> ${dest_path#$DESTINATION/}"
|
|
| 469 |
+ else |
|
| 470 |
+ echo "Would move: $relative_path -> ${dest_path#$DESTINATION/}"
|
|
| 471 |
+ fi |
|
| 472 |
+ PROCESSED_FILES=$((PROCESSED_FILES + 1)) |
|
| 473 |
+ PROCESSED_SIZE=$((PROCESSED_SIZE + file_size)) |
|
| 474 |
+ return 0 |
|
| 475 |
+ fi |
|
| 476 |
+ |
|
| 477 |
+ mkdir -p "$dest_dir" || { log_message "Could not create directory: $dest_dir" "err"; ERROR_FILES=$((ERROR_FILES + 1)); return 1; }
|
|
| 478 |
+ |
|
| 479 |
+ if [[ $KEEP_ORIGINALS -eq 1 ]]; then |
|
| 480 |
+ if cp "$file" "$dest_path"; then |
|
| 481 |
+ log_message "Copied: $relative_path" "info" |
|
| 482 |
+ [[ $VERBOSE -eq 1 ]] && echo "✓ Copied" |
|
| 483 |
+ PROCESSED_FILES=$((PROCESSED_FILES + 1)) |
|
| 484 |
+ PROCESSED_SIZE=$((PROCESSED_SIZE + file_size)) |
|
| 485 |
+ return 0 |
|
| 486 |
+ else |
|
| 487 |
+ log_message "Failed to copy $relative_path" "err" |
|
| 488 |
+ ERROR_FILES=$((ERROR_FILES + 1)) |
|
| 489 |
+ return 1 |
|
| 490 |
+ fi |
|
| 491 |
+ else |
|
| 492 |
+ if safe_mv "$file" "$dest_path"; then |
|
| 493 |
+ log_message "Moved: $relative_path" "info" |
|
| 494 |
+ [[ $VERBOSE -eq 1 ]] && echo "✓ Moved" |
|
| 495 |
+ PROCESSED_FILES=$((PROCESSED_FILES + 1)) |
|
| 496 |
+ PROCESSED_SIZE=$((PROCESSED_SIZE + file_size)) |
|
| 497 |
+ return 0 |
|
| 498 |
+ else |
|
| 499 |
+ log_message "Failed to move $relative_path" "err" |
|
| 500 |
+ ERROR_FILES=$((ERROR_FILES + 1)) |
|
| 501 |
+ return 1 |
|
| 502 |
+ fi |
|
| 503 |
+ fi |
|
| 42 | 504 |
} |
| 43 | 505 |
|
| 44 | 506 |
# Parse command line arguments |
| 45 | 507 |
SOURCE_MOUNT="" |
| 46 | 508 |
DESTINATION="" |
| 47 |
-DRY_RUN=0 |
|
| 48 |
-KEEP_ORIGINALS=0 |
|
| 49 |
-VERBOSE=0 |
|
| 50 |
-FILE_LIMIT=0 |
|
| 51 | 509 |
|
| 52 | 510 |
while [[ $# -gt 0 ]]; do |
| 53 | 511 |
case $1 in |
| 512 |
+ -o|--organization) |
|
| 513 |
+ ORGANIZATION="$2" |
|
| 514 |
+ [[ ! "$ORGANIZATION" =~ ^(y|m|d|h|ym|ymd)$ ]] && { echo "Invalid organization pattern"; exit 1; }
|
|
| 515 |
+ shift 2 |
|
| 516 |
+ ;; |
|
| 517 |
+ -F|--filename-mode) |
|
| 518 |
+ FILENAME_MODE="$2" |
|
| 519 |
+ [[ ! "$FILENAME_MODE" =~ ^(auto|full|orig)$ ]] && { echo "Invalid filename mode"; exit 1; }
|
|
| 520 |
+ shift 2 |
|
| 521 |
+ ;; |
|
| 522 |
+ --date-source) |
|
| 523 |
+ DATE_SOURCE="$2" |
|
| 524 |
+ [[ ! "$DATE_SOURCE" =~ ^(auto|exif|filesystem)$ ]] && { echo "Invalid date source"; exit 1; }
|
|
| 525 |
+ shift 2 |
|
| 526 |
+ ;; |
|
| 527 |
+ --sync-metadata) |
|
| 528 |
+ SYNC_METADATA=1 |
|
| 529 |
+ shift |
|
| 530 |
+ ;; |
|
| 531 |
+ --collect-unsortable) |
|
| 532 |
+ COLLECT_UNSORTABLE=1 |
|
| 533 |
+ shift |
|
| 534 |
+ ;; |
|
| 535 |
+ --keep-empty-dirs) |
|
| 536 |
+ KEEP_EMPTY_DIRS=0 |
|
| 537 |
+ shift |
|
| 538 |
+ ;; |
|
| 54 | 539 |
--dry-run) |
| 55 | 540 |
DRY_RUN=1 |
| 56 | 541 |
shift |
@@ -59,26 +544,21 @@ while [[ $# -gt 0 ]]; do |
||
| 59 | 544 |
KEEP_ORIGINALS=1 |
| 60 | 545 |
shift |
| 61 | 546 |
;; |
| 62 |
- --verbose) |
|
| 547 |
+ -v|--verbose) |
|
| 63 | 548 |
VERBOSE=1 |
| 64 | 549 |
shift |
| 65 | 550 |
;; |
| 66 | 551 |
--limit) |
| 67 | 552 |
FILE_LIMIT="$2" |
| 68 |
- if ! [[ "$FILE_LIMIT" =~ ^[0-9]+$ ]]; then |
|
| 69 |
- echo "Error: --limit requires a number" |
|
| 70 |
- usage |
|
| 71 |
- exit 1 |
|
| 72 |
- fi |
|
| 553 |
+ [[ ! "$FILE_LIMIT" =~ ^[0-9]+$ ]] && { echo "Error: --limit requires a number"; exit 1; }
|
|
| 73 | 554 |
shift 2 |
| 74 | 555 |
;; |
| 75 |
- --help) |
|
| 76 |
- usage |
|
| 556 |
+ -h|--help) |
|
| 557 |
+ show_help |
|
| 77 | 558 |
exit 0 |
| 78 | 559 |
;; |
| 79 | 560 |
-*) |
| 80 | 561 |
echo "Unknown option: $1" |
| 81 |
- usage |
|
| 82 | 562 |
exit 1 |
| 83 | 563 |
;; |
| 84 | 564 |
*) |
@@ -88,7 +568,6 @@ while [[ $# -gt 0 ]]; do |
||
| 88 | 568 |
DESTINATION="$1" |
| 89 | 569 |
else |
| 90 | 570 |
echo "Too many arguments" |
| 91 |
- usage |
|
| 92 | 571 |
exit 1 |
| 93 | 572 |
fi |
| 94 | 573 |
shift |
@@ -96,224 +575,52 @@ while [[ $# -gt 0 ]]; do |
||
| 96 | 575 |
esac |
| 97 | 576 |
done |
| 98 | 577 |
|
| 99 |
-# Validate arguments |
|
| 100 |
-if [[ -z "$SOURCE_MOUNT" || -z "$DESTINATION" ]]; then |
|
| 101 |
- echo "Error: Both source_mount and destination_path are required" |
|
| 102 |
- usage |
|
| 103 |
- exit 1 |
|
| 104 |
-fi |
|
| 578 |
+[[ -z "$SOURCE_MOUNT" || -z "$DESTINATION" ]] && { echo "Error: Both source_mount and destination_path are required"; show_help; exit 1; }
|
|
| 105 | 579 |
|
| 106 |
-# Check if source exists and is mounted |
|
| 107 |
-if [[ ! -d "$SOURCE_MOUNT" ]]; then |
|
| 108 |
- log_message "Error: Source mount point does not exist: $SOURCE_MOUNT" "err" |
|
| 109 |
- exit 1 |
|
| 110 |
-fi |
|
| 580 |
+# Validate paths |
|
| 581 |
+[[ ! -d "$SOURCE_MOUNT" ]] && { log_message "Error: Source mount point does not exist: $SOURCE_MOUNT" "err"; exit 1; }
|
|
| 582 |
+! mountpoint -q "$SOURCE_MOUNT" 2>/dev/null && log_message "Warning: Source path is not a mount point: $SOURCE_MOUNT" "warning" |
|
| 111 | 583 |
|
| 112 |
-# Check if source is actually mounted |
|
| 113 |
-if ! mountpoint -q "$SOURCE_MOUNT" 2>/dev/null; then |
|
| 114 |
- log_message "Warning: Source path is not a mount point: $SOURCE_MOUNT" "warning" |
|
| 115 |
-fi |
|
| 116 |
- |
|
| 117 |
-# Create destination directory if it doesn't exist |
|
| 118 | 584 |
if [[ $DRY_RUN -eq 0 ]]; then |
| 119 |
- mkdir -p "$DESTINATION" |
|
| 120 |
- if [[ ! -d "$DESTINATION" ]]; then |
|
| 121 |
- log_message "Error: Cannot create destination directory: $DESTINATION" "err" |
|
| 122 |
- exit 1 |
|
| 123 |
- fi |
|
| 124 |
-fi |
|
| 125 |
- |
|
| 126 |
-# Check for required tools |
|
| 127 |
-if ! command -v exiftool &> /dev/null; then |
|
| 128 |
- log_message "Error: exiftool is required but not installed" "err" |
|
| 129 |
- exit 1 |
|
| 585 |
+ mkdir -p "$DESTINATION" || { log_message "Error: Cannot create destination directory: $DESTINATION" "err"; exit 1; }
|
|
| 130 | 586 |
fi |
| 131 | 587 |
|
| 132 |
-# Function to process a single file |
|
| 133 |
-process_file() {
|
|
| 134 |
- local file="$1" |
|
| 135 |
- local relative_path="${file#$SOURCE_MOUNT/}"
|
|
| 136 |
- |
|
| 137 |
- # Check if source mount is still available |
|
| 138 |
- if ! mountpoint -q "$SOURCE_MOUNT" 2>/dev/null; then |
|
| 139 |
- log_message "Error: Source mount point is no longer available: $SOURCE_MOUNT" "err" |
|
| 140 |
- return 1 |
|
| 141 |
- fi |
|
| 142 |
- |
|
| 143 |
- # Check if file still exists |
|
| 144 |
- if [[ ! -f "$file" ]]; then |
|
| 145 |
- log_message "Error: File no longer exists: $relative_path" "err" |
|
| 146 |
- log_message "Camera appears to be disconnected, stopping import" "warning" |
|
| 147 |
- exit 1 |
|
| 148 |
- fi |
|
| 149 |
- |
|
| 150 |
- if [[ $VERBOSE -eq 1 ]]; then |
|
| 151 |
- log_message "Processing: $relative_path" "info" |
|
| 152 |
- fi |
|
| 153 |
- |
|
| 154 |
- # Check which group CreateDate comes from to determine correct handling |
|
| 155 |
- create_date_info=$(exiftool -G1 -s -CreateDate "$file" 2>/dev/null | grep CreateDate | head -1) |
|
| 156 |
- |
|
| 157 |
- # Check if exiftool failed (possible if device disconnected) |
|
| 158 |
- local exiftool_exit_code=$? |
|
| 159 |
- if [[ $exiftool_exit_code -ne 0 ]] && [[ $exiftool_exit_code -ne 1 ]]; then |
|
| 160 |
- log_message "Error: Cannot read file (device may be disconnected): $relative_path" "err" |
|
| 161 |
- log_message "Camera appears to be disconnected, stopping import" "warning" |
|
| 162 |
- exit 1 |
|
| 163 |
- fi |
|
| 164 |
- |
|
| 165 |
- if [[ -z "$create_date_info" ]]; then |
|
| 166 |
- log_message "Warning: No CreateDate found in $relative_path, using file modification time" "warning" |
|
| 167 |
- # Fallback to file modification time |
|
| 168 |
- local file_date=$(date -r "$file" "+%Y-%m-%d %H:%M:%S" 2>/dev/null) |
|
| 169 |
- if [[ -n "$file_date" ]]; then |
|
| 170 |
- create_date_value="$file_date" |
|
| 171 |
- create_date_group="FileSystem" |
|
| 172 |
- else |
|
| 173 |
- log_message "Error: Cannot determine date for $relative_path" "err" |
|
| 174 |
- return 1 |
|
| 175 |
- fi |
|
| 176 |
- else |
|
| 177 |
- create_date_group=$(echo "$create_date_info" | cut -d']' -f1 | cut -d'[' -f2) |
|
| 178 |
- create_date_value=$(echo "$create_date_info" | cut -d':' -f2- | xargs) |
|
| 179 |
- # Convert EXIF date format (YYYY:MM:DD HH:MM:SS) to standard format (YYYY-MM-DD HH:MM:SS) |
|
| 180 |
- create_date_value=$(echo "$create_date_value" | sed 's/^\([0-9]\{4\}\):\([0-9]\{2\}\):\([0-9]\{2\}\)/\1-\2-\3/')
|
|
| 181 |
- fi |
|
| 182 |
- |
|
| 183 |
- if [[ $VERBOSE -eq 1 ]]; then |
|
| 184 |
- echo -n " Date: [$create_date_value] from $create_date_group " |
|
| 185 |
- fi |
|
| 186 |
- |
|
| 187 |
- # Extract file extension |
|
| 188 |
- local filename=$(basename "$file") |
|
| 189 |
- local extension="${filename##*.}"
|
|
| 190 |
- |
|
| 191 |
- # For QuickTime files, the CreateDate is in UTC and needs conversion to local time |
|
| 192 |
- if [[ "$create_date_group" == "QuickTime" ]]; then |
|
| 193 |
- # Convert UTC time to local time |
|
| 194 |
- local utc_timestamp=$(date -d "$create_date_value UTC" "+%s" 2>/dev/null) |
|
| 195 |
- if [[ -n "$utc_timestamp" ]]; then |
|
| 196 |
- create_date_value=$(date -d "@$utc_timestamp" "+%Y-%m-%d %H:%M:%S" 2>/dev/null) |
|
| 197 |
- if [[ $VERBOSE -eq 1 ]]; then |
|
| 198 |
- echo -n "(converted from UTC) " |
|
| 199 |
- fi |
|
| 200 |
- fi |
|
| 201 |
- fi |
|
| 202 |
- |
|
| 203 |
- # Create output directory structure |
|
| 204 |
- local date_dir=$(date -d "$create_date_value" "+%Y-%m-%d" 2>/dev/null) |
|
| 205 |
- if [[ -z "$date_dir" ]]; then |
|
| 206 |
- log_message "Error: Invalid date format for $relative_path: $create_date_value" "err" |
|
| 207 |
- return 1 |
|
| 208 |
- fi |
|
| 209 |
- |
|
| 210 |
- local output_dir="$DESTINATION/$date_dir" |
|
| 211 |
- |
|
| 212 |
- if [[ $DRY_RUN -eq 0 ]]; then |
|
| 213 |
- mkdir -p "$output_dir" |
|
| 214 |
- fi |
|
| 215 |
- |
|
| 216 |
- # Generate output filename with timestamp |
|
| 217 |
- local timestamp=$(date -d "$create_date_value" "+%Y-%m-%d_%H-%M-%S" 2>/dev/null) |
|
| 218 |
- local output_filename="${timestamp}.${extension,,}" # Convert extension to lowercase
|
|
| 219 |
- local output_path="$output_dir/$output_filename" |
|
| 220 |
- |
|
| 221 |
- # Handle filename conflicts |
|
| 222 |
- local counter=1 |
|
| 223 |
- local base_output_path="$output_path" |
|
| 224 |
- while [[ -f "$output_path" ]] && [[ $DRY_RUN -eq 0 ]]; do |
|
| 225 |
- local name_without_ext="${timestamp}_${counter}"
|
|
| 226 |
- output_path="$output_dir/${name_without_ext}.${extension,,}"
|
|
| 227 |
- counter=$((counter + 1)) |
|
| 228 |
- done |
|
| 229 |
- |
|
| 230 |
- if [[ $DRY_RUN -eq 1 ]]; then |
|
| 231 |
- if [[ $KEEP_ORIGINALS -eq 1 ]]; then |
|
| 232 |
- echo "Would copy: $relative_path -> ${output_path#$DESTINATION/}"
|
|
| 233 |
- else |
|
| 234 |
- echo "Would move: $relative_path -> ${output_path#$DESTINATION/}"
|
|
| 235 |
- fi |
|
| 236 |
- else |
|
| 237 |
- # Perform the actual file operation |
|
| 238 |
- if [[ $KEEP_ORIGINALS -eq 1 ]]; then |
|
| 239 |
- if cp "$file" "$output_path"; then |
|
| 240 |
- if [[ $VERBOSE -eq 1 ]]; then |
|
| 241 |
- echo "✓ Copied" |
|
| 242 |
- fi |
|
| 243 |
- log_message "Copied: $relative_path -> ${output_path#$DESTINATION/}" "info"
|
|
| 244 |
- return 0 |
|
| 245 |
- else |
|
| 246 |
- if [[ $VERBOSE -eq 1 ]]; then |
|
| 247 |
- echo "✗ Copy failed" |
|
| 248 |
- fi |
|
| 249 |
- log_message "Error: Failed to copy $relative_path" "err" |
|
| 250 |
- return 1 |
|
| 251 |
- fi |
|
| 252 |
- else |
|
| 253 |
- if mv "$file" "$output_path"; then |
|
| 254 |
- if [[ $VERBOSE -eq 1 ]]; then |
|
| 255 |
- echo "✓ Moved" |
|
| 256 |
- fi |
|
| 257 |
- log_message "Moved: $relative_path -> ${output_path#$DESTINATION/}" "info"
|
|
| 258 |
- return 0 |
|
| 259 |
- else |
|
| 260 |
- if [[ $VERBOSE -eq 1 ]]; then |
|
| 261 |
- echo "✗ Move failed" |
|
| 262 |
- fi |
|
| 263 |
- log_message "Error: Failed to move $relative_path" "err" |
|
| 264 |
- return 1 |
|
| 265 |
- fi |
|
| 266 |
- fi |
|
| 267 |
- fi |
|
| 268 |
-} |
|
| 588 |
+check_dependencies |
|
| 269 | 589 |
|
| 270 |
-# Function to find camera directories |
|
| 590 |
+# Find camera directories |
|
| 271 | 591 |
find_camera_directories() {
|
| 272 | 592 |
local search_patterns=("DCIM" "PRIVATE" "MP_ROOT" "AVCHD" "Photos" "Videos")
|
| 273 | 593 |
local found_dirs=() |
| 274 |
- |
|
| 275 |
- # Test if the mount point is accessible with a timeout |
|
| 276 |
- if ! timeout 3 ls "$SOURCE_MOUNT" >/dev/null 2>&1; then |
|
| 277 |
- log_message "Error: Mount point is not accessible (device likely disconnected): $SOURCE_MOUNT" "err" |
|
| 278 |
- exit 1 |
|
| 279 |
- fi |
|
| 280 |
- |
|
| 594 |
+ |
|
| 595 |
+ ! timeout 3 ls "$SOURCE_MOUNT" >/dev/null 2>&1 && { log_message "Error: Mount point is not accessible" "err"; exit 1; }
|
|
| 596 |
+ |
|
| 281 | 597 |
for pattern in "${search_patterns[@]}"; do
|
| 282 | 598 |
while IFS= read -r -d '' dir; do |
| 283 | 599 |
found_dirs+=("$dir")
|
| 284 | 600 |
done < <(timeout 5 find "$SOURCE_MOUNT" -maxdepth 3 -type d -iname "$pattern" -print0 2>/dev/null) |
| 285 | 601 |
done |
| 286 |
- |
|
| 287 |
- # If no camera directories found, search for common media file extensions |
|
| 602 |
+ |
|
| 288 | 603 |
if [[ ${#found_dirs[@]} -eq 0 ]]; then
|
| 289 | 604 |
log_message "No camera directories found, searching for media files..." "info" |
| 290 |
- local media_extensions=("*.jpg" "*.jpeg" "*.png" "*.tiff" "*.cr2" "*.nef" "*.arw" "*.mp4" "*.mov" "*.avi" "*.mts" "*.m2ts")
|
|
| 291 |
- |
|
| 292 |
- for ext in "${media_extensions[@]}"; do
|
|
| 605 |
+ for ext in "${MEDIA_EXTENSIONS[@]}"; do
|
|
| 293 | 606 |
while IFS= read -r -d '' file; do |
| 294 | 607 |
local dir=$(dirname "$file") |
| 295 |
- if [[ ! " ${found_dirs[@]} " =~ " ${dir} " ]]; then
|
|
| 296 |
- found_dirs+=("$dir")
|
|
| 297 |
- fi |
|
| 608 |
+ local already_added=0 |
|
| 609 |
+ for found_dir in "${found_dirs[@]}"; do
|
|
| 610 |
+ [[ "$found_dir" == "$dir" ]] && { already_added=1; break; }
|
|
| 611 |
+ done |
|
| 612 |
+ [[ $already_added -eq 0 ]] && found_dirs+=("$dir")
|
|
| 298 | 613 |
done < <(timeout 5 find "$SOURCE_MOUNT" -maxdepth 3 -type f -iname "$ext" -print0 2>/dev/null) |
| 299 | 614 |
done |
| 300 | 615 |
fi |
| 301 |
- |
|
| 616 |
+ |
|
| 302 | 617 |
printf '%s\n' "${found_dirs[@]}" | sort -u
|
| 303 | 618 |
} |
| 304 | 619 |
|
| 305 |
-# Main execution |
|
| 306 | 620 |
log_message "Starting camera import from $SOURCE_MOUNT to $DESTINATION" "info" |
| 621 |
+[[ $DRY_RUN -eq 1 ]] && log_message "DRY RUN MODE - No files will be moved/copied" "info" |
|
| 622 |
+[[ $KEEP_ORIGINALS -eq 1 ]] && log_message "KEEP ORIGINALS MODE - Files will be copied instead of moved" "info" |
|
| 307 | 623 |
|
| 308 |
-if [[ $DRY_RUN -eq 1 ]]; then |
|
| 309 |
- log_message "DRY RUN MODE - No files will be actually moved/copied" "info" |
|
| 310 |
-fi |
|
| 311 |
- |
|
| 312 |
-if [[ $KEEP_ORIGINALS -eq 1 ]]; then |
|
| 313 |
- log_message "KEEP ORIGINALS MODE - Files will be copied instead of moved" "info" |
|
| 314 |
-fi |
|
| 315 |
- |
|
| 316 |
-# Find camera directories |
|
| 317 | 624 |
log_message "Scanning for camera directories..." "info" |
| 318 | 625 |
camera_dirs=$(find_camera_directories) |
| 319 | 626 |
|
@@ -327,112 +634,68 @@ echo "$camera_dirs" | while IFS= read -r dir; do |
||
| 327 | 634 |
echo " $dir" |
| 328 | 635 |
done |
| 329 | 636 |
|
| 330 |
-# Process files |
|
| 331 |
-total_files=0 |
|
| 332 |
-processed_files=0 |
|
| 333 |
-error_files=0 |
|
| 334 |
- |
|
| 335 |
-# Delete GLV files (Garmin video preview files) - they're usually not needed |
|
| 637 |
+# Clean up GLV files (preview files - usually not needed) |
|
| 336 | 638 |
log_message "Cleaning up GLV preview files..." "info" |
| 337 | 639 |
glv_count=0 |
| 338 | 640 |
while IFS= read -r dir; do |
| 339 | 641 |
if [[ $DRY_RUN -eq 1 ]]; then |
| 340 | 642 |
glv_files=$(find "$dir" -type f -iname "*.glv" 2>/dev/null | wc -l) |
| 341 |
- if [[ $glv_files -gt 0 ]]; then |
|
| 342 |
- echo "Would delete $glv_files GLV files from $dir" |
|
| 343 |
- glv_count=$((glv_count + glv_files)) |
|
| 344 |
- fi |
|
| 643 |
+ [[ $glv_files -gt 0 ]] && echo "Would delete $glv_files GLV files from $dir" && glv_count=$((glv_count + glv_files)) |
|
| 345 | 644 |
else |
| 346 | 645 |
while IFS= read -r -d '' glv_file; do |
| 347 |
- if rm "$glv_file" 2>/dev/null; then |
|
| 348 |
- glv_count=$((glv_count + 1)) |
|
| 349 |
- fi |
|
| 646 |
+ rm "$glv_file" 2>/dev/null && glv_count=$((glv_count + 1)) |
|
| 350 | 647 |
done < <(find "$dir" -type f -iname "*.glv" -print0 2>/dev/null) |
| 351 | 648 |
fi |
| 352 | 649 |
done <<< "$camera_dirs" |
| 353 | 650 |
|
| 354 |
-if [[ $glv_count -gt 0 ]]; then |
|
| 355 |
- if [[ $DRY_RUN -eq 1 ]]; then |
|
| 356 |
- log_message "Would delete $glv_count GLV preview files" "info" |
|
| 357 |
- else |
|
| 358 |
- log_message "Deleted $glv_count GLV preview files" "info" |
|
| 359 |
- fi |
|
| 360 |
-fi |
|
| 651 |
+[[ $glv_count -gt 0 ]] && log_message "GLV files cleaned up: $glv_count" "info" |
|
| 361 | 652 |
|
| 362 | 653 |
# Process media files |
| 363 | 654 |
log_message "Processing media files..." "info" |
| 364 |
-media_extensions=("*.jpg" "*.jpeg" "*.png" "*.tiff" "*.tif" "*.cr2" "*.nef" "*.arw" "*.dng" "*.mp4" "*.mov" "*.avi" "*.mts" "*.m2ts" "*.mkv" "*.wmv")
|
|
| 365 | 655 |
|
| 366 |
-# In dry-run mode, limit output to avoid overwhelming logs |
|
| 367 | 656 |
max_files_to_show=20 |
| 368 | 657 |
files_shown=0 |
| 369 | 658 |
files_processed_count=0 |
| 370 | 659 |
|
| 371 | 660 |
while IFS= read -r dir; do |
| 372 |
- # Check if mount point is still available before processing each directory |
|
| 373 |
- if ! mountpoint -q "$SOURCE_MOUNT" 2>/dev/null; then |
|
| 374 |
- log_message "Camera disconnected, stopping import process" "warning" |
|
| 375 |
- break |
|
| 376 |
- fi |
|
| 377 |
- |
|
| 378 |
- for ext in "${media_extensions[@]}"; do
|
|
| 661 |
+ ! mountpoint -q "$SOURCE_MOUNT" 2>/dev/null && { log_message "Camera disconnected, stopping import" "warning"; break; }
|
|
| 662 |
+ |
|
| 663 |
+ for ext in "${MEDIA_EXTENSIONS[@]}"; do
|
|
| 379 | 664 |
while IFS= read -r -d '' file; do |
| 380 |
- total_files=$((total_files + 1)) |
|
| 665 |
+ TOTAL_FILES=$((TOTAL_FILES + 1)) |
|
| 381 | 666 |
files_processed_count=$((files_processed_count + 1)) |
| 382 |
- |
|
| 383 |
- # Check if camera is still connected before processing each file |
|
| 384 |
- if ! mountpoint -q "$SOURCE_MOUNT" 2>/dev/null; then |
|
| 385 |
- log_message "Camera disconnected during processing, stopping import" "warning" |
|
| 386 |
- exit 1 |
|
| 387 |
- fi |
|
| 388 |
- |
|
| 389 |
- # Check file limit |
|
| 390 |
- if [[ $FILE_LIMIT -gt 0 && $files_processed_count -gt $FILE_LIMIT ]]; then |
|
| 391 |
- echo "Reached file limit of $FILE_LIMIT files, stopping processing..." |
|
| 392 |
- break 3 |
|
| 393 |
- fi |
|
| 394 |
- |
|
| 395 |
- # In dry-run mode, limit verbose output |
|
| 667 |
+ |
|
| 668 |
+ ! mountpoint -q "$SOURCE_MOUNT" 2>/dev/null && { log_message "Camera disconnected during processing" "warning"; exit 1; }
|
|
| 669 |
+ |
|
| 670 |
+ [[ $FILE_LIMIT -gt 0 && $files_processed_count -gt $FILE_LIMIT ]] && { echo "Reached file limit"; break 3; }
|
|
| 671 |
+ |
|
| 396 | 672 |
if [[ $DRY_RUN -eq 1 && $files_shown -ge $max_files_to_show ]]; then |
| 397 |
- if [[ $files_shown -eq $max_files_to_show ]]; then |
|
| 398 |
- echo "... (limiting output in dry-run mode, processing continues)" |
|
| 399 |
- files_shown=$((files_shown + 1)) |
|
| 400 |
- fi |
|
| 401 |
- # Still process but don't show details |
|
| 402 |
- processed_files=$((processed_files + 1)) |
|
| 673 |
+ [[ $files_shown -eq $max_files_to_show ]] && echo "... (limiting output in dry-run mode)" |
|
| 674 |
+ ((files_shown++)) |
|
| 403 | 675 |
else |
| 404 |
- if [[ $DRY_RUN -eq 1 ]]; then |
|
| 405 |
- files_shown=$((files_shown + 1)) |
|
| 406 |
- fi |
|
| 407 |
- |
|
| 408 |
- if process_file "$file"; then |
|
| 409 |
- processed_files=$((processed_files + 1)) |
|
| 410 |
- else |
|
| 411 |
- error_files=$((error_files + 1)) |
|
| 412 |
- fi |
|
| 676 |
+ [[ $DRY_RUN -eq 1 ]] && ((files_shown++)) |
|
| 677 |
+ process_file "$file" |
|
| 413 | 678 |
fi |
| 414 | 679 |
done < <(find "$dir" -type f -iname "$ext" -print0 2>/dev/null) |
| 415 | 680 |
done |
| 416 | 681 |
done <<< "$camera_dirs" |
| 417 | 682 |
|
| 418 | 683 |
# Summary |
| 419 |
-log_message "Import completed: $processed_files/$total_files files processed successfully" "info" |
|
| 420 |
-if [[ $error_files -gt 0 ]]; then |
|
| 421 |
- log_message "Import had errors: $error_files files failed to process" "warning" |
|
| 422 |
-fi |
|
| 684 |
+log_message "Import completed: $PROCESSED_FILES/$TOTAL_FILES files processed successfully" "info" |
|
| 685 |
+[[ $ERROR_FILES -gt 0 ]] && log_message "Import had errors: $ERROR_FILES files failed" "warning" |
|
| 423 | 686 |
|
| 424 | 687 |
echo "" |
| 425 | 688 |
echo "=== Import Summary ===" |
| 426 |
-echo "Total files found: $total_files" |
|
| 427 |
-echo "Successfully processed: $processed_files" |
|
| 428 |
-echo "Errors: $error_files" |
|
| 429 |
-if [[ $glv_count -gt 0 ]]; then |
|
| 430 |
- echo "GLV files cleaned up: $glv_count" |
|
| 431 |
-fi |
|
| 689 |
+echo "Total files found: $TOTAL_FILES" |
|
| 690 |
+echo "Successfully processed: $PROCESSED_FILES" |
|
| 691 |
+echo "Skipped: $SKIPPED_FILES" |
|
| 692 |
+echo "Errors: $ERROR_FILES" |
|
| 693 |
+[[ $glv_count -gt 0 ]] && echo "GLV files cleaned up: $glv_count" |
|
| 432 | 694 |
|
| 433 |
-# Exit with error code if there were errors |
|
| 434 |
-if [[ $error_files -gt 0 ]]; then |
|
| 435 |
- exit 1 |
|
| 436 |
-else |
|
| 437 |
- exit 0 |
|
| 695 |
+# Cleanup empty directories |
|
| 696 |
+if [[ $KEEP_EMPTY_DIRS -eq 1 && $DRY_RUN -eq 0 ]]; then |
|
| 697 |
+ log_message "Cleaning up empty directories..." "info" |
|
| 698 |
+ find "$DESTINATION" -type d -empty -not -path "$DESTINATION" -delete 2>/dev/null || true |
|
| 438 | 699 |
fi |
| 700 |
+ |
|
| 701 |
+[[ $ERROR_FILES -gt 0 ]] && exit 1 || exit 0 |
|
@@ -9,12 +9,23 @@ VERSION="1.0" |
||
| 9 | 9 |
SCRIPT_NAME="Standalone Media Importer" |
| 10 | 10 |
|
| 11 | 11 |
# Default values |
| 12 |
-ORGANIZATION="y" # year/month-day_hour-minute_second.ext |
|
| 12 |
+# Default organization: 'ymd' (single folder per day yyyy-mm-dd). Override with -o/--organization. |
|
| 13 |
+ORGANIZATION="ymd" |
|
| 14 |
+FILENAME_MODE="full" # options: auto, full, orig |
|
| 15 |
+COLLECT_UNSORTABLE=0 |
|
| 13 | 16 |
SOURCE_PATTERNS=() |
| 14 | 17 |
DESTINATION="" |
| 15 | 18 |
KEEP_ORIGINALS=0 |
| 19 |
+VERIFY_MODE="size" # options: size, strict, none |
|
| 20 |
+DATE_SOURCE="auto" # options: auto, exif, filesystem |
|
| 21 |
+SYNC_METADATA=0 # when 1, write reconstructed date into destination metadata |
|
| 22 |
+UNATTENDED=0 # when 1, never prompt; destination conflicts get numeric suffixes |
|
| 16 | 23 |
DRY_RUN=0 |
| 17 | 24 |
VERBOSE=0 |
| 25 |
+CLEANUP_EMPTY_DIRS=1 |
|
| 26 |
+CONFLICT_APPLY_ALL="" # suffix|skip after an interactive "all similar" choice |
|
| 27 |
+RESOLVED_DESTINATION_PATH="" |
|
| 28 |
+RESERVED_DESTINATION_PATHS=() |
|
| 18 | 29 |
|
| 19 | 30 |
# Counters and statistics |
| 20 | 31 |
TOTAL_FILES=0 |
@@ -24,12 +35,20 @@ ERROR_FILES=0 |
||
| 24 | 35 |
TOTAL_SIZE=0 |
| 25 | 36 |
PROCESSED_SIZE=0 |
| 26 | 37 |
START_TIME=$(date +%s) |
| 38 |
+CURRENT_FILE_INDEX=0 |
|
| 27 | 39 |
|
| 28 | 40 |
# Colors for output |
| 29 | 41 |
RED='\033[0;31m' |
| 30 | 42 |
GREEN='\033[0;32m' |
| 31 | 43 |
YELLOW='\033[1;33m' |
| 32 |
-BLUE='\033[0;34m' |
|
| 44 |
+# BLUE is used for informational/verbose messages. Dark terminal themes may render the |
|
| 45 |
+# default blue hard to read, so use a brighter cyan by default and allow users to |
|
| 46 |
+# override via the VERBOSE_COLOR environment variable (must be a terminal escape seq). |
|
| 47 |
+if [[ -n "${VERBOSE_COLOR:-}" ]]; then
|
|
| 48 |
+ BLUE="$VERBOSE_COLOR" |
|
| 49 |
+else |
|
| 50 |
+ BLUE=$'\033[1;36m' # bright cyan |
|
| 51 |
+fi |
|
| 33 | 52 |
NC='\033[0m' # No Color |
| 34 | 53 |
|
| 35 | 54 |
# Function to print colored output |
@@ -68,71 +87,46 @@ log_message() {
|
||
| 68 | 87 |
|
| 69 | 88 |
# Function to display help |
| 70 | 89 |
show_help() {
|
| 71 |
- cat << EOF |
|
| 72 |
-$SCRIPT_NAME v$VERSION |
|
| 90 |
+ cat << EOF |
|
| 91 |
+ $SCRIPT_NAME v$VERSION |
|
| 73 | 92 |
|
| 74 |
-USAGE: |
|
| 93 |
+Usage: |
|
| 75 | 94 |
$0 [OPTIONS] |
| 76 | 95 |
|
| 77 |
-DESCRIPTION: |
|
| 78 |
- Organizes media files (photos and videos) by date with various naming patterns. |
|
| 79 |
- Handles timezone conversion for QuickTime files and preserves original timestamps. |
|
| 80 |
- |
|
| 81 |
-OPTIONS: |
|
| 82 |
- -o, --organization PATTERN |
|
| 83 |
- Organization pattern (default: y): |
|
| 84 |
- y -> target/yyyy/mm-dd_hh-mm-ss.orig_ext |
|
| 85 |
- m -> target/yyyy/mm/dd_hh-mm-ss.orig_ext |
|
| 86 |
- d -> target/yyyy/mm/dd/mm-dd_hh-mm-ss.orig_ext |
|
| 87 |
- h -> target/yyyy/mm/dd/hh/mm-ss.orig_ext |
|
| 88 |
- |
|
| 89 |
- -s, --source PATTERN |
|
| 90 |
- Source folder pattern(s) with simple regex support (*^$) |
|
| 91 |
- Can be specified multiple times |
|
| 92 |
- Examples: |
|
| 93 |
- -s "/DCIM/*Video$" |
|
| 94 |
- -s "/path/to/photos" |
|
| 95 |
- -s "*.jpg" |
|
| 96 |
- Default: all subfolders in current directory except destination |
|
| 97 |
- |
|
| 98 |
- -d, --destination PATH |
|
| 99 |
- Destination folder (default: ./sorted) |
|
| 100 |
- |
|
| 101 |
- -k, --keep-originals |
|
| 102 |
- Keep original files (copy instead of move) |
|
| 103 |
- |
|
| 104 |
- --dry-run |
|
| 105 |
- Show what would be done without actually doing it |
|
| 106 |
- |
|
| 107 |
- -v, --verbose |
|
| 108 |
- Enable verbose output |
|
| 109 |
- |
|
| 110 |
- -h, --help |
|
| 111 |
- Show this help message |
|
| 112 |
- |
|
| 113 |
- --version |
|
| 114 |
- Show version information |
|
| 115 |
- |
|
| 116 |
-EXAMPLES: |
|
| 117 |
- # Basic usage - organize all media in current directory |
|
| 118 |
- $0 |
|
| 119 |
- |
|
| 120 |
- # Organize with monthly folders, keep originals |
|
| 121 |
- $0 -o m -k |
|
| 122 |
- |
|
| 123 |
- # Process specific folders with hourly organization |
|
| 124 |
- $0 -o h -s "/DCIM/Camera" -s "/DCIM/Video" -d "/media/sorted" |
|
| 96 |
+What it does: |
|
| 97 |
+ Sorts photos and videos into dated folders (by year/month/day/hour) and |
|
| 98 |
+ generates filenames from the file's creation timestamp or preserves the |
|
| 99 |
+ original name. |
|
| 125 | 100 |
|
| 126 |
- # Dry run with verbose output |
|
| 127 |
- $0 --dry-run -v -s "*.mov" -d "/tmp/test" |
|
| 101 |
+Options: |
|
| 102 |
+ -o, --organization PATTERN y|m|d|h|ym|ymd (default: ymd) |
|
| 103 |
+ -F, --filename-mode MODE auto|full|orig (default: full) |
|
| 104 |
+ -s, --source PATH File or directory to process (repeatable). Default: cwd |
|
| 105 |
+ -d, --destination PATH Destination folder. Required when multiple -s are given. |
|
| 106 |
+ -k, --keep-originals Copy files instead of moving |
|
| 107 |
+ --verify-mode MODE size|strict|none (default: size) |
|
| 108 |
+ --date-source SOURCE auto|exif|filesystem (default: auto) |
|
| 109 |
+ --sync-metadata Write chosen date into destination metadata (automatic for GoPro filesystem dates) |
|
| 110 |
+ --unattended Never prompt; resolve destination conflicts with numeric suffixes |
|
| 111 |
+ --collect-unsortable Put files without dates into DEST/unsortable |
|
| 112 |
+ --keep-empty-dirs Keep empty directories after processing |
|
| 113 |
+ --dry-run Show actions without changing files |
|
| 114 |
+ -v, --verbose Verbose output |
|
| 115 |
+ -h, --help Show this help |
|
| 116 |
+ --version Show version |
|
| 128 | 117 |
|
| 129 |
-DEPENDENCIES: |
|
| 130 |
- Required: exiftool |
|
| 131 |
- Optional: mediainfo, file (for enhanced metadata detection) |
|
| 118 |
+Examples: |
|
| 119 |
+ $0 -s /path/to/photos |
|
| 120 |
+ $0 -s /path/to/DCIM -d /mnt/sorted --dry-run |
|
| 132 | 121 |
|
| 122 |
+Dependencies: |
|
| 123 |
+ exiftool (required). mediainfo and file are optional. |
|
| 133 | 124 |
EOF |
| 134 | 125 |
} |
| 135 | 126 |
|
| 127 |
+# Central media extensions list (used by find functions) |
|
| 128 |
+MEDIA_EXTENSIONS=("*.jpg" "*.jpeg" "*.png" "*.tiff" "*.tif" "*.cr2" "*.nef" "*.arw" "*.dng" "*.raw" "*.mp4" "*.mov" "*.avi" "*.mts" "*.m2ts" "*.mkv" "*.wmv" "*.3gp" "*.m4v")
|
|
| 129 |
+ |
|
| 136 | 130 |
# Function to show version |
| 137 | 131 |
show_version() {
|
| 138 | 132 |
echo "$SCRIPT_NAME v$VERSION" |
@@ -179,22 +173,505 @@ check_dependencies() {
|
||
| 179 | 173 |
log_message "All required dependencies found" "SUCCESS" |
| 180 | 174 |
} |
| 181 | 175 |
|
| 182 |
-# Function to get file size in bytes |
|
| 176 |
+# Determine filesystem/device ID for a path (portable between Linux and macOS) |
|
| 177 |
+get_dev() {
|
|
| 178 |
+ local path="$1" |
|
| 179 |
+ if [[ -z "$path" ]]; then |
|
| 180 |
+ path="." |
|
| 181 |
+ fi |
|
| 182 |
+ |
|
| 183 |
+ # Prefer GNU stat if available |
|
| 184 |
+ if stat --version >/dev/null 2>&1; then |
|
| 185 |
+ stat -c %d "$path" 2>/dev/null || stat -c %i "$path" 2>/dev/null || echo "" |
|
| 186 |
+ else |
|
| 187 |
+ # BSD/macOS stat |
|
| 188 |
+ stat -f %d "$path" 2>/dev/null || stat -f %i "$path" 2>/dev/null || echo "" |
|
| 189 |
+ fi |
|
| 190 |
+} |
|
| 191 |
+ |
|
| 192 |
+# Function to get file size in bytes (portable between Linux and macOS) |
|
| 183 | 193 |
get_file_size() {
|
| 184 | 194 |
local file="$1" |
| 185 | 195 |
if [[ -f "$file" ]]; then |
| 186 |
- if command -v stat &> /dev/null; then |
|
| 187 |
- # Try GNU stat first (Linux) |
|
| 196 |
+ # Try GNU stat |
|
| 197 |
+ if stat -c%s "$file" >/dev/null 2>&1; then |
|
| 188 | 198 |
stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null |
| 199 |
+ elif stat -f%z "$file" >/dev/null 2>&1; then |
|
| 200 |
+ stat -f%z "$file" 2>/dev/null |
|
| 189 | 201 |
else |
| 190 | 202 |
# Fallback to ls |
| 191 |
- ls -l "$file" | awk '{print $5}'
|
|
| 203 |
+ ls -ln "$file" | awk '{print $5}'
|
|
| 192 | 204 |
fi |
| 193 | 205 |
else |
| 194 | 206 |
echo "0" |
| 195 | 207 |
fi |
| 196 | 208 |
} |
| 197 | 209 |
|
| 210 |
+# Safe move/copy helpers: filter out benign "set flags (was: ...): Operation not supported" |
|
| 211 |
+# which appears when moving files onto filesystems that don't support BSD file flags |
|
| 212 |
+# (macOS mv may try to preserve flags and print this warning while still succeeding). |
|
| 213 |
+safe_mv() {
|
|
| 214 |
+ local src="$1" dst="$2" |
|
| 215 |
+ if [[ -e "$dst" ]]; then |
|
| 216 |
+ log_message "Refusing to overwrite existing destination: $dst" "ERROR" |
|
| 217 |
+ return 1 |
|
| 218 |
+ fi |
|
| 219 |
+ # Redirect stderr through a filter that removes the known benign message |
|
| 220 |
+ mv "$src" "$dst" 2> >(grep -v "set flags (was:" >&2) |
|
| 221 |
+ return $? |
|
| 222 |
+} |
|
| 223 |
+ |
|
| 224 |
+safe_cp() {
|
|
| 225 |
+ local src="$1" dst="$2" |
|
| 226 |
+ if [[ -e "$dst" ]]; then |
|
| 227 |
+ log_message "Refusing to overwrite existing destination: $dst" "ERROR" |
|
| 228 |
+ return 1 |
|
| 229 |
+ fi |
|
| 230 |
+ cp -p "$src" "$dst" 2> >(grep -v "set flags (was:" >&2) |
|
| 231 |
+ return $? |
|
| 232 |
+} |
|
| 233 |
+ |
|
| 234 |
+ensure_unique_destination_path() {
|
|
| 235 |
+ local desired_path="$1" |
|
| 236 |
+ |
|
| 237 |
+ if [[ -z "$desired_path" ]]; then |
|
| 238 |
+ return 1 |
|
| 239 |
+ fi |
|
| 240 |
+ |
|
| 241 |
+ if ! destination_path_unavailable "$desired_path"; then |
|
| 242 |
+ echo "$desired_path" |
|
| 243 |
+ return 0 |
|
| 244 |
+ fi |
|
| 245 |
+ |
|
| 246 |
+ local dir_path filename ext stem candidate |
|
| 247 |
+ dir_path=$(dirname "$desired_path") |
|
| 248 |
+ filename=$(basename "$desired_path") |
|
| 249 |
+ |
|
| 250 |
+ if [[ "$filename" == *.* ]]; then |
|
| 251 |
+ ext="${filename##*.}"
|
|
| 252 |
+ stem="${filename%.*}"
|
|
| 253 |
+ else |
|
| 254 |
+ ext="" |
|
| 255 |
+ stem="$filename" |
|
| 256 |
+ fi |
|
| 257 |
+ |
|
| 258 |
+ local i |
|
| 259 |
+ i=1 |
|
| 260 |
+ while [[ $i -le 9999 ]]; do |
|
| 261 |
+ if [[ -n "$ext" ]]; then |
|
| 262 |
+ candidate="$dir_path/${stem}_${i}.${ext}"
|
|
| 263 |
+ else |
|
| 264 |
+ candidate="$dir_path/${stem}_${i}"
|
|
| 265 |
+ fi |
|
| 266 |
+ if ! destination_path_unavailable "$candidate"; then |
|
| 267 |
+ echo "$candidate" |
|
| 268 |
+ return 0 |
|
| 269 |
+ fi |
|
| 270 |
+ i=$((i + 1)) |
|
| 271 |
+ done |
|
| 272 |
+ |
|
| 273 |
+ return 1 |
|
| 274 |
+} |
|
| 275 |
+ |
|
| 276 |
+destination_path_reserved() {
|
|
| 277 |
+ local candidate="$1" |
|
| 278 |
+ local reserved_path |
|
| 279 |
+ for reserved_path in "${RESERVED_DESTINATION_PATHS[@]}"; do
|
|
| 280 |
+ if [[ "$reserved_path" == "$candidate" ]]; then |
|
| 281 |
+ return 0 |
|
| 282 |
+ fi |
|
| 283 |
+ done |
|
| 284 |
+ return 1 |
|
| 285 |
+} |
|
| 286 |
+ |
|
| 287 |
+destination_path_unavailable() {
|
|
| 288 |
+ local candidate="$1" |
|
| 289 |
+ [[ -e "$candidate" ]] || destination_path_reserved "$candidate" |
|
| 290 |
+} |
|
| 291 |
+ |
|
| 292 |
+reserve_destination_path() {
|
|
| 293 |
+ local candidate="$1" |
|
| 294 |
+ if [[ -n "$candidate" ]] && ! destination_path_reserved "$candidate"; then |
|
| 295 |
+ RESERVED_DESTINATION_PATHS+=("$candidate")
|
|
| 296 |
+ fi |
|
| 297 |
+} |
|
| 298 |
+ |
|
| 299 |
+prompt_destination_conflict_choice() {
|
|
| 300 |
+ local source_file="$1" |
|
| 301 |
+ local desired_path="$2" |
|
| 302 |
+ local choice |
|
| 303 |
+ |
|
| 304 |
+ if [[ ! -r /dev/tty || ! -w /dev/tty ]]; then |
|
| 305 |
+ return 1 |
|
| 306 |
+ fi |
|
| 307 |
+ |
|
| 308 |
+ {
|
|
| 309 |
+ print_color "$YELLOW" "Destination already exists:" |
|
| 310 |
+ echo " Source: $source_file" |
|
| 311 |
+ echo " Destination: $desired_path" |
|
| 312 |
+ echo "" |
|
| 313 |
+ echo "Choose conflict action:" |
|
| 314 |
+ echo " [s] suffix once" |
|
| 315 |
+ echo " [S] suffix for all similar conflicts" |
|
| 316 |
+ echo " [k] skip once" |
|
| 317 |
+ echo " [K] skip all similar conflicts" |
|
| 318 |
+ echo " [a] abort import" |
|
| 319 |
+ } > /dev/tty |
|
| 320 |
+ |
|
| 321 |
+ while true; do |
|
| 322 |
+ printf "Action [s/S/k/K/a]: " > /dev/tty |
|
| 323 |
+ IFS= read -r choice < /dev/tty || return 1 |
|
| 324 |
+ case "$choice" in |
|
| 325 |
+ s|"") |
|
| 326 |
+ echo "suffix" |
|
| 327 |
+ return 0 |
|
| 328 |
+ ;; |
|
| 329 |
+ S) |
|
| 330 |
+ echo "suffix_all" |
|
| 331 |
+ return 0 |
|
| 332 |
+ ;; |
|
| 333 |
+ k) |
|
| 334 |
+ echo "skip" |
|
| 335 |
+ return 0 |
|
| 336 |
+ ;; |
|
| 337 |
+ K) |
|
| 338 |
+ echo "skip_all" |
|
| 339 |
+ return 0 |
|
| 340 |
+ ;; |
|
| 341 |
+ a|A) |
|
| 342 |
+ echo "abort" |
|
| 343 |
+ return 0 |
|
| 344 |
+ ;; |
|
| 345 |
+ *) |
|
| 346 |
+ print_color "$YELLOW" "Please choose s, S, k, K, or a." > /dev/tty |
|
| 347 |
+ ;; |
|
| 348 |
+ esac |
|
| 349 |
+ done |
|
| 350 |
+} |
|
| 351 |
+ |
|
| 352 |
+resolve_destination_conflict() {
|
|
| 353 |
+ local desired_path="$1" |
|
| 354 |
+ local source_file="$2" |
|
| 355 |
+ local resolved_path choice |
|
| 356 |
+ RESOLVED_DESTINATION_PATH="" |
|
| 357 |
+ |
|
| 358 |
+ if [[ -z "$desired_path" ]]; then |
|
| 359 |
+ return 1 |
|
| 360 |
+ fi |
|
| 361 |
+ |
|
| 362 |
+ if ! destination_path_unavailable "$desired_path"; then |
|
| 363 |
+ RESOLVED_DESTINATION_PATH="$desired_path" |
|
| 364 |
+ reserve_destination_path "$RESOLVED_DESTINATION_PATH" |
|
| 365 |
+ return 0 |
|
| 366 |
+ fi |
|
| 367 |
+ |
|
| 368 |
+ if [[ "$CONFLICT_APPLY_ALL" == "skip" ]]; then |
|
| 369 |
+ return 3 |
|
| 370 |
+ fi |
|
| 371 |
+ |
|
| 372 |
+ if [[ "$CONFLICT_APPLY_ALL" == "suffix" || $UNATTENDED -eq 1 ]]; then |
|
| 373 |
+ resolved_path=$(ensure_unique_destination_path "$desired_path") |
|
| 374 |
+ if [[ -z "$resolved_path" ]]; then |
|
| 375 |
+ return 1 |
|
| 376 |
+ fi |
|
| 377 |
+ RESOLVED_DESTINATION_PATH="$resolved_path" |
|
| 378 |
+ reserve_destination_path "$RESOLVED_DESTINATION_PATH" |
|
| 379 |
+ return 0 |
|
| 380 |
+ fi |
|
| 381 |
+ |
|
| 382 |
+ choice=$(prompt_destination_conflict_choice "$source_file" "$desired_path") |
|
| 383 |
+ if [[ $? -ne 0 || -z "$choice" ]]; then |
|
| 384 |
+ log_message "Cannot prompt for destination conflict; using unattended numeric suffix mode" "WARNING" |
|
| 385 |
+ resolved_path=$(ensure_unique_destination_path "$desired_path") |
|
| 386 |
+ if [[ -z "$resolved_path" ]]; then |
|
| 387 |
+ return 1 |
|
| 388 |
+ fi |
|
| 389 |
+ RESOLVED_DESTINATION_PATH="$resolved_path" |
|
| 390 |
+ reserve_destination_path "$RESOLVED_DESTINATION_PATH" |
|
| 391 |
+ return 0 |
|
| 392 |
+ fi |
|
| 393 |
+ |
|
| 394 |
+ case "$choice" in |
|
| 395 |
+ suffix) |
|
| 396 |
+ resolved_path=$(ensure_unique_destination_path "$desired_path") |
|
| 397 |
+ ;; |
|
| 398 |
+ suffix_all) |
|
| 399 |
+ CONFLICT_APPLY_ALL="suffix" |
|
| 400 |
+ resolved_path=$(ensure_unique_destination_path "$desired_path") |
|
| 401 |
+ ;; |
|
| 402 |
+ skip) |
|
| 403 |
+ return 3 |
|
| 404 |
+ ;; |
|
| 405 |
+ skip_all) |
|
| 406 |
+ CONFLICT_APPLY_ALL="skip" |
|
| 407 |
+ return 3 |
|
| 408 |
+ ;; |
|
| 409 |
+ abort) |
|
| 410 |
+ return 4 |
|
| 411 |
+ ;; |
|
| 412 |
+ *) |
|
| 413 |
+ return 1 |
|
| 414 |
+ ;; |
|
| 415 |
+ esac |
|
| 416 |
+ |
|
| 417 |
+ if [[ -z "$resolved_path" ]]; then |
|
| 418 |
+ return 1 |
|
| 419 |
+ fi |
|
| 420 |
+ |
|
| 421 |
+ RESOLVED_DESTINATION_PATH="$resolved_path" |
|
| 422 |
+ reserve_destination_path "$RESOLVED_DESTINATION_PATH" |
|
| 423 |
+ return 0 |
|
| 424 |
+} |
|
| 425 |
+ |
|
| 426 |
+extract_filesystem_date() {
|
|
| 427 |
+ # Returns yyyy-mm-dd hh:mm:ss based on filesystem mtime. |
|
| 428 |
+ # We intentionally use mtime (not birthtime) because birthtime isn't preserved by copies |
|
| 429 |
+ # across filesystems, while mtime can be preserved via `cp -p`. |
|
| 430 |
+ local file="$1" |
|
| 431 |
+ if [[ ! -e "$file" ]]; then |
|
| 432 |
+ return 2 |
|
| 433 |
+ fi |
|
| 434 |
+ |
|
| 435 |
+ local epoch="" |
|
| 436 |
+ |
|
| 437 |
+ if [[ "$OSTYPE" == "darwin"* ]]; then |
|
| 438 |
+ epoch=$(stat -f %m "$file" 2>/dev/null || echo "") |
|
| 439 |
+ [[ -n "$epoch" ]] || return 2 |
|
| 440 |
+ date -j -r "$epoch" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || return 2 |
|
| 441 |
+ return 0 |
|
| 442 |
+ else |
|
| 443 |
+ epoch=$(stat -c %Y "$file" 2>/dev/null || echo "") |
|
| 444 |
+ [[ -n "$epoch" ]] || return 2 |
|
| 445 |
+ date -d "@$epoch" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || return 2 |
|
| 446 |
+ return 0 |
|
| 447 |
+ fi |
|
| 448 |
+} |
|
| 449 |
+ |
|
| 450 |
+filesystem_date_reference() {
|
|
| 451 |
+ local file="$1" |
|
| 452 |
+ local dir base stem ext sidecar_ext sidecar |
|
| 453 |
+ dir=$(dirname "$file") |
|
| 454 |
+ base=$(basename "$file") |
|
| 455 |
+ stem="${base%.*}"
|
|
| 456 |
+ ext="${base##*.}"
|
|
| 457 |
+ |
|
| 458 |
+ if [[ "$ext" =~ ^([Mm][Pp]4)$ ]]; then |
|
| 459 |
+ for sidecar_ext in THM thm LRV lrv; do |
|
| 460 |
+ sidecar="$dir/$stem.$sidecar_ext" |
|
| 461 |
+ if [[ -f "$sidecar" ]]; then |
|
| 462 |
+ echo "$sidecar" |
|
| 463 |
+ return 0 |
|
| 464 |
+ fi |
|
| 465 |
+ done |
|
| 466 |
+ fi |
|
| 467 |
+ |
|
| 468 |
+ echo "$file" |
|
| 469 |
+} |
|
| 470 |
+ |
|
| 471 |
+is_gopro_media_file() {
|
|
| 472 |
+ local filename |
|
| 473 |
+ filename=$(basename "$1") |
|
| 474 |
+ [[ "$filename" =~ ^G[HPX][0-9]{6}\.[Mm][Pp]4$ ]]
|
|
| 475 |
+} |
|
| 476 |
+ |
|
| 477 |
+should_prefer_gopro_filesystem_date() {
|
|
| 478 |
+ local file="$1" |
|
| 479 |
+ |
|
| 480 |
+ is_gopro_media_file "$file" |
|
| 481 |
+} |
|
| 482 |
+ |
|
| 483 |
+filesystem_date_source_label() {
|
|
| 484 |
+ local file="$1" |
|
| 485 |
+ local reference="$2" |
|
| 486 |
+ |
|
| 487 |
+ if is_gopro_media_file "$file"; then |
|
| 488 |
+ echo "Filesystem:$(basename "$reference")" |
|
| 489 |
+ elif [[ "$reference" != "$file" ]]; then |
|
| 490 |
+ echo "Filesystem:$(basename "$reference")" |
|
| 491 |
+ else |
|
| 492 |
+ echo "Filesystem" |
|
| 493 |
+ fi |
|
| 494 |
+} |
|
| 495 |
+ |
|
| 496 |
+date_to_exiftool_format() {
|
|
| 497 |
+ # yyyy-mm-dd hh:mm:ss -> yyyy:mm:dd hh:mm:ss |
|
| 498 |
+ local s="$1" |
|
| 499 |
+ if [[ "$s" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})[[:space:]]+([0-9]{2}):([0-9]{2}):([0-9]{2})$ ]]; then
|
|
| 500 |
+ echo "${BASH_REMATCH[1]}:${BASH_REMATCH[2]}:${BASH_REMATCH[3]} ${BASH_REMATCH[4]}:${BASH_REMATCH[5]}:${BASH_REMATCH[6]}"
|
|
| 501 |
+ return 0 |
|
| 502 |
+ fi |
|
| 503 |
+ return 1 |
|
| 504 |
+} |
|
| 505 |
+ |
|
| 506 |
+sync_destination_metadata_to_date() {
|
|
| 507 |
+ local file="$1" |
|
| 508 |
+ local date_str="$2" # yyyy-mm-dd hh:mm:ss |
|
| 509 |
+ |
|
| 510 |
+ local exif_dt |
|
| 511 |
+ exif_dt=$(date_to_exiftool_format "$date_str") || return 1 |
|
| 512 |
+ |
|
| 513 |
+ exiftool -overwrite_original \ |
|
| 514 |
+ "-CreateDate=$exif_dt" \ |
|
| 515 |
+ "-DateTimeOriginal=$exif_dt" \ |
|
| 516 |
+ "-DateTime=$exif_dt" \ |
|
| 517 |
+ "-ModifyDate=$exif_dt" \ |
|
| 518 |
+ "-MediaCreateDate=$exif_dt" \ |
|
| 519 |
+ "-TrackCreateDate=$exif_dt" \ |
|
| 520 |
+ "-QuickTime:CreateDate=$exif_dt" \ |
|
| 521 |
+ "-QuickTime:ModifyDate=$exif_dt" \ |
|
| 522 |
+ "$file" >/dev/null 2>&1 |
|
| 523 |
+ return 0 |
|
| 524 |
+} |
|
| 525 |
+ |
|
| 526 |
+verify_synced_metadata_date() {
|
|
| 527 |
+ local file="$1" |
|
| 528 |
+ local expected_date="$2" |
|
| 529 |
+ |
|
| 530 |
+ local metadata_date |
|
| 531 |
+ metadata_date=$(exiftool -api QuickTimeUTC=0 -s -s -s -QuickTime:CreateDate "$file" 2>/dev/null | head -1) |
|
| 532 |
+ if [[ -z "$metadata_date" ]]; then |
|
| 533 |
+ metadata_date=$(exiftool -api QuickTimeUTC=0 -s -s -s -CreateDate "$file" 2>/dev/null | head -1) |
|
| 534 |
+ fi |
|
| 535 |
+ |
|
| 536 |
+ if [[ "$metadata_date" =~ ^([0-9]{4}):([0-9]{2}):([0-9]{2})[[:space:]]+([0-9]{2}):([0-9]{2}):([0-9]{2}) ]]; then
|
|
| 537 |
+ metadata_date="${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]} ${BASH_REMATCH[4]}:${BASH_REMATCH[5]}:${BASH_REMATCH[6]}"
|
|
| 538 |
+ fi |
|
| 539 |
+ |
|
| 540 |
+ if [[ "$metadata_date" != "$expected_date" ]]; then |
|
| 541 |
+ log_message "Destination metadata sync mismatch: expected $expected_date, got ${metadata_date:-none} for $file" "ERROR"
|
|
| 542 |
+ return 1 |
|
| 543 |
+ fi |
|
| 544 |
+ |
|
| 545 |
+ return 0 |
|
| 546 |
+} |
|
| 547 |
+ |
|
| 548 |
+should_sync_imported_metadata() {
|
|
| 549 |
+ local original_filename="$1" |
|
| 550 |
+ local date_source="$2" |
|
| 551 |
+ |
|
| 552 |
+ if [[ $SYNC_METADATA -eq 1 ]]; then |
|
| 553 |
+ return 0 |
|
| 554 |
+ fi |
|
| 555 |
+ |
|
| 556 |
+ if [[ "$date_source" == Filesystem* ]] && is_gopro_media_file "$original_filename"; then |
|
| 557 |
+ return 0 |
|
| 558 |
+ fi |
|
| 559 |
+ |
|
| 560 |
+ return 1 |
|
| 561 |
+} |
|
| 562 |
+ |
|
| 563 |
+verify_copied_file() {
|
|
| 564 |
+ local src="$1" |
|
| 565 |
+ local dst="$2" |
|
| 566 |
+ local expected_date="$3" |
|
| 567 |
+ |
|
| 568 |
+ if [[ ! -f "$dst" ]]; then |
|
| 569 |
+ log_message "Verified copy missing at destination: $dst" "ERROR" |
|
| 570 |
+ return 1 |
|
| 571 |
+ fi |
|
| 572 |
+ |
|
| 573 |
+ local src_size dst_size |
|
| 574 |
+ src_size=$(get_file_size "$src") |
|
| 575 |
+ dst_size=$(get_file_size "$dst") |
|
| 576 |
+ if [[ "$src_size" != "$dst_size" ]]; then |
|
| 577 |
+ log_message "Size mismatch after copy: $src ($src_size) != $dst ($dst_size)" "ERROR" |
|
| 578 |
+ return 1 |
|
| 579 |
+ fi |
|
| 580 |
+ |
|
| 581 |
+ if [[ "$VERIFY_MODE" == "strict" ]]; then |
|
| 582 |
+ if ! cmp -s "$src" "$dst"; then |
|
| 583 |
+ log_message "Content mismatch after copy: $src -> $dst" "ERROR" |
|
| 584 |
+ return 1 |
|
| 585 |
+ fi |
|
| 586 |
+ elif [[ "$VERIFY_MODE" == "none" ]]; then |
|
| 587 |
+ return 0 |
|
| 588 |
+ fi |
|
| 589 |
+ |
|
| 590 |
+ if [[ -n "$expected_date" ]]; then |
|
| 591 |
+ local destination_date_info |
|
| 592 |
+ destination_date_info=$(extract_file_date "$dst") |
|
| 593 |
+ local extract_status=$? |
|
| 594 |
+ if [[ $extract_status -ne 0 || -z "$destination_date_info" ]]; then |
|
| 595 |
+ log_message "Destination metadata validation failed: $dst" "ERROR" |
|
| 596 |
+ return 1 |
|
| 597 |
+ fi |
|
| 598 |
+ |
|
| 599 |
+ local destination_date="${destination_date_info%|*}"
|
|
| 600 |
+ if [[ "$destination_date" != "$expected_date" ]]; then |
|
| 601 |
+ log_message "Destination metadata mismatch: expected $expected_date, got $destination_date for $dst" "ERROR" |
|
| 602 |
+ return 1 |
|
| 603 |
+ fi |
|
| 604 |
+ fi |
|
| 605 |
+ |
|
| 606 |
+ return 0 |
|
| 607 |
+} |
|
| 608 |
+ |
|
| 609 |
+remove_source_file() {
|
|
| 610 |
+ local src="$1" |
|
| 611 |
+ rm -f "$src" |
|
| 612 |
+} |
|
| 613 |
+ |
|
| 614 |
+copy_with_verification() {
|
|
| 615 |
+ local src="$1" |
|
| 616 |
+ local dst="$2" |
|
| 617 |
+ local expected_date="$3" |
|
| 618 |
+ |
|
| 619 |
+ if [[ -e "$dst" ]]; then |
|
| 620 |
+ log_message "Refusing to overwrite existing destination: $dst" "ERROR" |
|
| 621 |
+ return 1 |
|
| 622 |
+ fi |
|
| 623 |
+ |
|
| 624 |
+ local dst_dir tmp |
|
| 625 |
+ dst_dir=$(dirname "$dst") |
|
| 626 |
+ tmp=$(mktemp "$dst_dir/.media-importer.$(basename "$dst").tmp.XXXXXX") || return 1 |
|
| 627 |
+ rm -f "$tmp" |
|
| 628 |
+ |
|
| 629 |
+ if ! safe_cp "$src" "$tmp"; then |
|
| 630 |
+ rm -f "$tmp" |
|
| 631 |
+ return 1 |
|
| 632 |
+ fi |
|
| 633 |
+ |
|
| 634 |
+ if ! verify_copied_file "$src" "$tmp" "$expected_date"; then |
|
| 635 |
+ rm -f "$tmp" |
|
| 636 |
+ return 1 |
|
| 637 |
+ fi |
|
| 638 |
+ |
|
| 639 |
+ if [[ -e "$dst" ]]; then |
|
| 640 |
+ log_message "Destination appeared during copy, refusing to overwrite: $dst" "ERROR" |
|
| 641 |
+ rm -f "$tmp" |
|
| 642 |
+ return 1 |
|
| 643 |
+ fi |
|
| 644 |
+ |
|
| 645 |
+ if ! safe_mv "$tmp" "$dst"; then |
|
| 646 |
+ rm -f "$tmp" |
|
| 647 |
+ return 1 |
|
| 648 |
+ fi |
|
| 649 |
+ |
|
| 650 |
+ if [[ ! -f "$dst" ]]; then |
|
| 651 |
+ log_message "Copied file missing after final move: $dst" "ERROR" |
|
| 652 |
+ return 1 |
|
| 653 |
+ fi |
|
| 654 |
+ |
|
| 655 |
+ return 0 |
|
| 656 |
+} |
|
| 657 |
+ |
|
| 658 |
+verified_move_file() {
|
|
| 659 |
+ local src="$1" |
|
| 660 |
+ local dst="$2" |
|
| 661 |
+ local expected_date="$3" |
|
| 662 |
+ |
|
| 663 |
+ if ! copy_with_verification "$src" "$dst" "$expected_date"; then |
|
| 664 |
+ return 1 |
|
| 665 |
+ fi |
|
| 666 |
+ |
|
| 667 |
+ if ! remove_source_file "$src"; then |
|
| 668 |
+ log_message "Copied and verified destination, but failed to remove source: $src" "ERROR" |
|
| 669 |
+ return 1 |
|
| 670 |
+ fi |
|
| 671 |
+ |
|
| 672 |
+ return 0 |
|
| 673 |
+} |
|
| 674 |
+ |
|
| 198 | 675 |
# Function to format file size |
| 199 | 676 |
format_size() {
|
| 200 | 677 |
local size=$1 |
@@ -209,38 +686,88 @@ format_size() {
|
||
| 209 | 686 |
fi |
| 210 | 687 |
} |
| 211 | 688 |
|
| 689 |
+format_duration() {
|
|
| 690 |
+ local total_seconds=$1 |
|
| 691 |
+ local hours=$((total_seconds / 3600)) |
|
| 692 |
+ local minutes=$(((total_seconds % 3600) / 60)) |
|
| 693 |
+ local seconds=$((total_seconds % 60)) |
|
| 694 |
+ |
|
| 695 |
+ if (( hours > 0 )); then |
|
| 696 |
+ printf "%dh %02dm %02ds" "$hours" "$minutes" "$seconds" |
|
| 697 |
+ elif (( minutes > 0 )); then |
|
| 698 |
+ printf "%dm %02ds" "$minutes" "$seconds" |
|
| 699 |
+ else |
|
| 700 |
+ printf "%ds" "$seconds" |
|
| 701 |
+ fi |
|
| 702 |
+} |
|
| 703 |
+ |
|
| 704 |
+format_data_rate() {
|
|
| 705 |
+ local bytes_count="$1" |
|
| 706 |
+ local elapsed_seconds="$2" |
|
| 707 |
+ |
|
| 708 |
+ awk -v bytes="$bytes_count" -v seconds="$elapsed_seconds" ' |
|
| 709 |
+ BEGIN {
|
|
| 710 |
+ if (seconds <= 0 || bytes <= 0) {
|
|
| 711 |
+ exit |
|
| 712 |
+ } |
|
| 713 |
+ |
|
| 714 |
+ mb_per_second = bytes / seconds / 1048576 |
|
| 715 |
+ printf "%.2f MB/sec", mb_per_second |
|
| 716 |
+ } |
|
| 717 |
+ ' |
|
| 718 |
+} |
|
| 719 |
+ |
|
| 720 |
+report_line() {
|
|
| 721 |
+ local label="$1" |
|
| 722 |
+ local value="$2" |
|
| 723 |
+ printf " %-24s %s\n" "$label" "$value" |
|
| 724 |
+} |
|
| 725 |
+ |
|
| 212 | 726 |
# Function to extract date from file |
| 213 | 727 |
extract_file_date() {
|
| 214 | 728 |
local file="$1" |
| 215 | 729 |
local create_date="" |
| 216 | 730 |
local date_source="" |
| 217 |
- |
|
| 731 |
+ local exif_found=0 |
|
| 732 |
+ |
|
| 733 |
+ # Filesystem authoritative mode, and GoPro media in auto mode. |
|
| 734 |
+ # GoPro fallback order is THM, LRV, then the MP4 filesystem timestamp. |
|
| 735 |
+ if [[ "$DATE_SOURCE" == "filesystem" ]] || { [[ "$DATE_SOURCE" == "auto" ]] && should_prefer_gopro_filesystem_date "$file"; }; then
|
|
| 736 |
+ local filesystem_reference |
|
| 737 |
+ filesystem_reference=$(filesystem_date_reference "$file") |
|
| 738 |
+ create_date=$(extract_filesystem_date "$filesystem_reference") || return 2 |
|
| 739 |
+ echo "$create_date|$(filesystem_date_source_label "$file" "$filesystem_reference")" |
|
| 740 |
+ return 0 |
|
| 741 |
+ fi |
|
| 742 |
+ |
|
| 218 | 743 |
# Try to get creation date from EXIF data |
| 219 | 744 |
local exif_output=$(exiftool -G1 -s -CreateDate -DateTimeOriginal -DateTime "$file" 2>/dev/null) |
| 220 |
- |
|
| 221 | 745 |
if [[ -n "$exif_output" ]]; then |
| 222 | 746 |
# Parse the exiftool output to find the best date |
| 223 | 747 |
while IFS= read -r line; do |
| 224 |
- if [[ "$line" =~ ^\[([^\]]+)\][[:space:]]*([^:]+):[[:space:]]*(.+)$ ]]; then |
|
| 748 |
+ if [[ "$line" =~ ^\[([^\]]+)\][[:space:]]*([^:]+)[[:space:]]*:[[:space:]]*(.+)$ ]]; then |
|
| 225 | 749 |
local group="${BASH_REMATCH[1]}"
|
| 226 | 750 |
local tag="${BASH_REMATCH[2]}"
|
| 227 | 751 |
local value="${BASH_REMATCH[3]}"
|
| 228 |
- |
|
| 752 |
+ # Trim spaces from tag name |
|
| 753 |
+ tag=$(echo "$tag" | sed 's/[[:space:]]*$//') |
|
| 229 | 754 |
# Prefer DateTimeOriginal, then CreateDate, then DateTime |
| 230 | 755 |
if [[ "$tag" == "DateTimeOriginal" && -z "$create_date" ]]; then |
| 231 | 756 |
create_date="$value" |
| 232 | 757 |
date_source="$group:$tag" |
| 758 |
+ exif_found=1 |
|
| 233 | 759 |
elif [[ "$tag" == "CreateDate" && "$date_source" != *"DateTimeOriginal"* ]]; then |
| 234 | 760 |
create_date="$value" |
| 235 | 761 |
date_source="$group:$tag" |
| 762 |
+ exif_found=1 |
|
| 236 | 763 |
elif [[ "$tag" == "DateTime" && -z "$create_date" ]]; then |
| 237 | 764 |
create_date="$value" |
| 238 | 765 |
date_source="$group:$tag" |
| 766 |
+ exif_found=1 |
|
| 239 | 767 |
fi |
| 240 | 768 |
fi |
| 241 | 769 |
done <<< "$exif_output" |
| 242 | 770 |
fi |
| 243 |
- |
|
| 244 | 771 |
# If no EXIF date found, try mediainfo for video files |
| 245 | 772 |
if [[ -z "$create_date" ]] && command -v mediainfo &> /dev/null; then |
| 246 | 773 |
local media_date=$(mediainfo --Inform="General;%Recorded_Date%" "$file" 2>/dev/null) |
@@ -249,32 +776,57 @@ extract_file_date() {
|
||
| 249 | 776 |
date_source="MediaInfo:Recorded_Date" |
| 250 | 777 |
fi |
| 251 | 778 |
fi |
| 252 |
- |
|
| 253 |
- # Fallback to file modification time |
|
| 254 |
- if [[ -z "$create_date" ]]; then |
|
| 255 |
- if [[ "$OSTYPE" == "darwin"* ]]; then |
|
| 256 |
- # macOS |
|
| 257 |
- create_date=$(stat -f "%Sm" -t "%Y:%m:%d %H:%M:%S" "$file" 2>/dev/null) |
|
| 258 |
- else |
|
| 259 |
- # Linux |
|
| 260 |
- create_date=$(date -r "$file" "+%Y:%m:%d %H:%M:%S" 2>/dev/null) |
|
| 261 |
- fi |
|
| 262 |
- date_source="FileSystem:ModificationTime" |
|
| 779 |
+ |
|
| 780 |
+ # In auto mode, if metadata is missing/unreliable, fall back to filesystem timestamps |
|
| 781 |
+ if [[ -z "$create_date" && "$DATE_SOURCE" == "auto" ]]; then |
|
| 782 |
+ local filesystem_reference |
|
| 783 |
+ filesystem_reference=$(filesystem_date_reference "$file") |
|
| 784 |
+ create_date=$(extract_filesystem_date "$filesystem_reference") || return 2 |
|
| 785 |
+ date_source=$(filesystem_date_source_label "$file" "$filesystem_reference") |
|
| 263 | 786 |
fi |
| 264 |
- |
|
| 787 |
+ |
|
| 788 |
+ # If no EXIF or mediainfo date found, return failure |
|
| 265 | 789 |
if [[ -z "$create_date" ]]; then |
| 266 |
- return 1 |
|
| 790 |
+ return 2 # No date metadata found |
|
| 267 | 791 |
fi |
| 268 | 792 |
|
| 269 | 793 |
# Convert EXIF format (YYYY:MM:DD HH:MM:SS) to standard format |
| 270 |
- create_date=$(echo "$create_date" | sed 's/^\([0-9]\{4\}\):\([0-9]\{2\}\):\([0-9]\{2\}\)/\1-\2-\3/')
|
|
| 794 |
+ # Always output as yyyy-mm-dd hh:mm:ss (pad single digits) |
|
| 795 |
+ if [[ "$create_date" =~ ^([0-9]{4}):([0-9]{1,2}):([0-9]{1,2})[[:space:]]*([0-9]{1,2}):([0-9]{1,2}):([0-9]{1,2})$ ]]; then
|
|
| 796 |
+ year="${BASH_REMATCH[1]}"
|
|
| 797 |
+ month=$(printf "%02d" "$((10#${BASH_REMATCH[2]}))")
|
|
| 798 |
+ day=$(printf "%02d" "$((10#${BASH_REMATCH[3]}))")
|
|
| 799 |
+ hour=$(printf "%02d" "$((10#${BASH_REMATCH[4]}))")
|
|
| 800 |
+ minute=$(printf "%02d" "$((10#${BASH_REMATCH[5]}))")
|
|
| 801 |
+ second=$(printf "%02d" "$((10#${BASH_REMATCH[6]}))")
|
|
| 802 |
+ create_date="$year-$month-$day $hour:$minute:$second" |
|
| 803 |
+ else |
|
| 804 |
+ # Try to convert yyyy-mm-dd hh:mm:ss (already correct) |
|
| 805 |
+ if [[ "$create_date" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})[[:space:]]*([0-9]{2}):([0-9]{2}):([0-9]{2})$ ]]; then
|
|
| 806 |
+ # Already correct |
|
| 807 |
+ : |
|
| 808 |
+ else |
|
| 809 |
+ print_color "$RED" "Error: Cannot parse date '$create_date'" >&2 |
|
| 810 |
+ return 2 |
|
| 811 |
+ fi |
|
| 812 |
+ fi |
|
| 271 | 813 |
|
| 272 |
- # Handle QuickTime UTC conversion |
|
| 814 |
+ # For QuickTime files, the CreateDate is in UTC and needs conversion to local time |
|
| 273 | 815 |
if [[ "$date_source" == *"QuickTime"* ]]; then |
| 274 |
- local utc_timestamp=$(date -d "$create_date UTC" "+%s" 2>/dev/null) |
|
| 275 |
- if [[ -n "$utc_timestamp" ]]; then |
|
| 276 |
- create_date=$(date -d "@$utc_timestamp" "+%Y-%m-%d %H:%M:%S" 2>/dev/null) |
|
| 277 |
- date_source="$date_source (converted from UTC)" |
|
| 816 |
+ # Convert UTC time to local time |
|
| 817 |
+ if [[ "$OSTYPE" == "darwin"* ]]; then |
|
| 818 |
+ # On macOS, use TZ=UTC to interpret the input time as UTC |
|
| 819 |
+ local utc_timestamp=$(TZ=UTC date -j -f "%Y-%m-%d %H:%M:%S" "$create_date" "+%s" 2>/dev/null) |
|
| 820 |
+ if [[ -n "$utc_timestamp" ]]; then |
|
| 821 |
+ create_date=$(date -j -r "$utc_timestamp" "+%Y-%m-%d %H:%M:%S" 2>/dev/null) |
|
| 822 |
+ date_source="$date_source (converted from UTC)" |
|
| 823 |
+ fi |
|
| 824 |
+ else |
|
| 825 |
+ local utc_timestamp=$(date -d "$create_date UTC" "+%s" 2>/dev/null) |
|
| 826 |
+ if [[ -n "$utc_timestamp" ]]; then |
|
| 827 |
+ create_date=$(date -d "@$utc_timestamp" "+%Y-%m-%d %H:%M:%S" 2>/dev/null) |
|
| 828 |
+ date_source="$date_source (converted from UTC)" |
|
| 829 |
+ fi |
|
| 278 | 830 |
fi |
| 279 | 831 |
fi |
| 280 | 832 |
|
@@ -288,13 +840,29 @@ generate_destination_path() {
|
||
| 288 | 840 |
local original_filename="$2" |
| 289 | 841 |
local base_destination="$3" |
| 290 | 842 |
|
| 291 |
- # Extract date components |
|
| 292 |
- local year=$(date -d "$date_str" "+%Y" 2>/dev/null) |
|
| 293 |
- local month=$(date -d "$date_str" "+%m" 2>/dev/null) |
|
| 294 |
- local day=$(date -d "$date_str" "+%d" 2>/dev/null) |
|
| 295 |
- local hour=$(date -d "$date_str" "+%H" 2>/dev/null) |
|
| 296 |
- local minute=$(date -d "$date_str" "+%M" 2>/dev/null) |
|
| 297 |
- local second=$(date -d "$date_str" "+%S" 2>/dev/null) |
|
| 843 |
+ # Extract date components - handle both GNU and BSD date |
|
| 844 |
+ local year month day hour minute second |
|
| 845 |
+ if [[ "$OSTYPE" == "darwin"* ]]; then |
|
| 846 |
+ # macOS (BSD date) - use more robust regex (allow single-digit month/day, tolerate extra spaces) |
|
| 847 |
+ if [[ "$date_str" =~ ^([0-9]{4})-([0-9]{1,2})-([0-9]{1,2})[[:space:]]*([0-9]{1,2}):([0-9]{1,2}):([0-9]{1,2})$ ]]; then
|
|
| 848 |
+ year="${BASH_REMATCH[1]}"
|
|
| 849 |
+ month=$(printf "%02d" "$((10#${BASH_REMATCH[2]}))")
|
|
| 850 |
+ day=$(printf "%02d" "$((10#${BASH_REMATCH[3]}))")
|
|
| 851 |
+ hour=$(printf "%02d" "$((10#${BASH_REMATCH[4]}))")
|
|
| 852 |
+ minute=$(printf "%02d" "$((10#${BASH_REMATCH[5]}))")
|
|
| 853 |
+ second=$(printf "%02d" "$((10#${BASH_REMATCH[6]}))")
|
|
| 854 |
+ else |
|
| 855 |
+ return 1 |
|
| 856 |
+ fi |
|
| 857 |
+ else |
|
| 858 |
+ # Linux (GNU date) |
|
| 859 |
+ year=$(date -d "$date_str" "+%Y" 2>/dev/null) |
|
| 860 |
+ month=$(date -d "$date_str" "+%m" 2>/dev/null) |
|
| 861 |
+ day=$(date -d "$date_str" "+%d" 2>/dev/null) |
|
| 862 |
+ hour=$(date -d "$date_str" "+%H" 2>/dev/null) |
|
| 863 |
+ minute=$(date -d "$date_str" "+%M" 2>/dev/null) |
|
| 864 |
+ second=$(date -d "$date_str" "+%S" 2>/dev/null) |
|
| 865 |
+ fi |
|
| 298 | 866 |
|
| 299 | 867 |
if [[ -z "$year" || -z "$month" || -z "$day" ]]; then |
| 300 | 868 |
return 1 |
@@ -302,95 +870,137 @@ generate_destination_path() {
|
||
| 302 | 870 |
|
| 303 | 871 |
# Get file extension |
| 304 | 872 |
local extension="${original_filename##*.}"
|
| 305 |
- local lowercase_ext="${extension,,}"
|
|
| 873 |
+ local lowercase_ext=$(echo "$extension" | tr '[:upper:]' '[:lower:]') |
|
| 306 | 874 |
|
| 307 | 875 |
# Generate path and filename based on organization pattern |
| 308 | 876 |
local dir_path="" |
| 309 | 877 |
local filename="" |
| 310 |
- |
|
| 878 |
+ |
|
| 879 |
+ # If no organization specified, use flat destination (base) and choose filename per mode |
|
| 880 |
+ if [[ -z "$ORGANIZATION" ]]; then |
|
| 881 |
+ dir_path="$base_destination" |
|
| 882 |
+ if [[ "$FILENAME_MODE" == "orig" ]]; then |
|
| 883 |
+ filename="$original_filename" |
|
| 884 |
+ else |
|
| 885 |
+ # full or auto both map to full date for flat layout |
|
| 886 |
+ filename="${year}-${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
|
|
| 887 |
+ fi |
|
| 888 |
+ echo "$dir_path/$filename" |
|
| 889 |
+ return 0 |
|
| 890 |
+ fi |
|
| 891 |
+ |
|
| 311 | 892 |
case "$ORGANIZATION" in |
| 312 |
- "y") |
|
| 313 |
- # target/yyyy/mm-dd_hh-mm-ss.orig_ext |
|
| 314 |
- dir_path="$base_destination/$year" |
|
| 315 |
- filename="${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
|
|
| 316 |
- ;; |
|
| 317 |
- "m") |
|
| 318 |
- # target/yyyy/mm/dd_hh-mm-ss.orig_ext |
|
| 319 |
- dir_path="$base_destination/$year/$month" |
|
| 320 |
- filename="${day}_${hour}-${minute}-${second}.${lowercase_ext}"
|
|
| 321 |
- ;; |
|
| 322 |
- "d") |
|
| 323 |
- # target/yyyy/mm/dd/mm-dd_hh-mm-ss.orig_ext |
|
| 324 |
- dir_path="$base_destination/$year/$month/$day" |
|
| 325 |
- filename="${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
|
|
| 326 |
- ;; |
|
| 327 |
- "h") |
|
| 328 |
- # target/yyyy/mm/dd/hh/mm-ss.orig_ext |
|
| 329 |
- dir_path="$base_destination/$year/$month/$day/$hour" |
|
| 330 |
- filename="${minute}-${second}.${lowercase_ext}"
|
|
| 893 |
+ "y") |
|
| 894 |
+ dir_path="$base_destination/$year" |
|
| 895 |
+ filename="${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
|
|
| 896 |
+ ;; |
|
| 897 |
+ "m") |
|
| 898 |
+ dir_path="$base_destination/$year/$month" |
|
| 899 |
+ filename="${day}_${hour}-${minute}-${second}.${lowercase_ext}"
|
|
| 900 |
+ ;; |
|
| 901 |
+ "d") |
|
| 902 |
+ dir_path="$base_destination/$year/$month/$day" |
|
| 903 |
+ filename="${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
|
|
| 904 |
+ ;; |
|
| 905 |
+ "h") |
|
| 906 |
+ dir_path="$base_destination/$year/$month/$day/$hour" |
|
| 907 |
+ filename="${minute}-${second}.${lowercase_ext}"
|
|
| 908 |
+ ;; |
|
| 909 |
+ "ym") |
|
| 910 |
+ # Single folder per month named yyyy-mm; filename includes day and time |
|
| 911 |
+ dir_path="$base_destination/${year}-${month}"
|
|
| 912 |
+ filename="${day}_${hour}-${minute}-${second}.${lowercase_ext}"
|
|
| 913 |
+ ;; |
|
| 914 |
+ "ymd") |
|
| 915 |
+ # Single folder per day named yyyy-mm-dd; filename is time |
|
| 916 |
+ dir_path="$base_destination/${year}-${month}-${day}"
|
|
| 917 |
+ filename="${hour}-${minute}-${second}.${lowercase_ext}"
|
|
| 918 |
+ ;; |
|
| 919 |
+ *) |
|
| 920 |
+ log_message "Invalid organization pattern: $ORGANIZATION" "ERROR" |
|
| 921 |
+ return 1 |
|
| 922 |
+ ;; |
|
| 923 |
+ esac |
|
| 924 |
+ |
|
| 925 |
+ # Apply filename mode overrides |
|
| 926 |
+ case "$FILENAME_MODE" in |
|
| 927 |
+ orig) |
|
| 928 |
+ filename="$original_filename" |
|
| 929 |
+ ;; |
|
| 930 |
+ full) |
|
| 931 |
+ filename="${year}-${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
|
|
| 932 |
+ ;; |
|
| 933 |
+ auto) |
|
| 934 |
+ # keep the auto-generated filename from the organization case |
|
| 331 | 935 |
;; |
| 332 | 936 |
*) |
| 333 |
- log_message "Invalid organization pattern: $ORGANIZATION" "ERROR" |
|
| 334 |
- return 1 |
|
| 937 |
+ # fallback to full |
|
| 938 |
+ filename="${year}-${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
|
|
| 335 | 939 |
;; |
| 336 | 940 |
esac |
| 337 |
- |
|
| 941 |
+ |
|
| 338 | 942 |
echo "$dir_path/$filename" |
| 339 | 943 |
return 0 |
| 340 | 944 |
} |
| 341 | 945 |
|
| 342 | 946 |
# Function to find files matching patterns |
| 343 | 947 |
find_source_files() {
|
| 344 |
- local files=() |
|
| 345 |
- |
|
| 948 |
+ # Emit absolute file paths for all media files under sources, excluding DESTINATION when it is inside a source |
|
| 949 |
+ local abs_dest="" |
|
| 950 |
+ if [[ -n "$DESTINATION" ]]; then |
|
| 951 |
+ abs_dest=$(cd "$DESTINATION" 2>/dev/null && pwd) || abs_dest="$DESTINATION" |
|
| 952 |
+ fi |
|
| 953 |
+ |
|
| 954 |
+ # Build -iname expression for find |
|
| 955 |
+ local ext_expr="" |
|
| 956 |
+ for ext in "${MEDIA_EXTENSIONS[@]}"; do
|
|
| 957 |
+ if [[ -n "$ext_expr" ]]; then |
|
| 958 |
+ ext_expr="$ext_expr -o" |
|
| 959 |
+ fi |
|
| 960 |
+ ext_expr="$ext_expr -iname $ext" |
|
| 961 |
+ done |
|
| 962 |
+ |
|
| 346 | 963 |
if [[ ${#SOURCE_PATTERNS[@]} -eq 0 ]]; then
|
| 347 |
- # Default: find all media files in current directory and subdirectories |
|
| 348 |
- # Exclude destination directory if it's a subdirectory of current directory |
|
| 349 |
- local find_cmd="find . -type f" |
|
| 350 |
- |
|
| 351 |
- # Add exclusion for destination if it's relative to current directory |
|
| 352 |
- if [[ "$DESTINATION" =~ ^\./.*$ ]] || [[ "$DESTINATION" =~ ^[^/].*$ ]]; then |
|
| 353 |
- local abs_dest=$(cd "$DESTINATION" 2>/dev/null && pwd) |
|
| 354 |
- local abs_current=$(pwd) |
|
| 355 |
- if [[ "$abs_dest" == "$abs_current"* ]]; then |
|
| 356 |
- find_cmd="$find_cmd ! -path \"$DESTINATION/*\"" |
|
| 357 |
- fi |
|
| 964 |
+ # Default: scan current directory |
|
| 965 |
+ local start_dot="." |
|
| 966 |
+ local abs_current |
|
| 967 |
+ abs_current=$(pwd) |
|
| 968 |
+ local find_cmd=(find -L "$start_dot" -type f) |
|
| 969 |
+ # If dest is inside cwd, add exclusion |
|
| 970 |
+ if [[ -n "$abs_dest" && "$abs_dest" == "$abs_current"* ]]; then |
|
| 971 |
+ find_cmd+=( ! -path "$abs_dest/*" ! -path "$abs_dest" ) |
|
| 358 | 972 |
fi |
| 359 |
- |
|
| 360 |
- # Add media file extensions |
|
| 361 |
- local extensions=("*.jpg" "*.jpeg" "*.png" "*.tiff" "*.tif" "*.cr2" "*.nef" "*.arw" "*.dng" "*.raw" "*.mp4" "*.mov" "*.avi" "*.mts" "*.m2ts" "*.mkv" "*.wmv" "*.3gp" "*.m4v")
|
|
| 362 |
- local ext_pattern="" |
|
| 363 |
- for ext in "${extensions[@]}"; do
|
|
| 364 |
- if [[ -n "$ext_pattern" ]]; then |
|
| 365 |
- ext_pattern="$ext_pattern -o" |
|
| 366 |
- fi |
|
| 367 |
- ext_pattern="$ext_pattern -iname $ext" |
|
| 368 |
- done |
|
| 369 |
- |
|
| 370 |
- eval "$find_cmd \\( $ext_pattern \\)" | while IFS= read -r file; do |
|
| 371 |
- echo "$file" |
|
| 372 |
- done |
|
| 973 |
+ # Add expression |
|
| 974 |
+ # shellcheck disable=SC2068 |
|
| 975 |
+ "${find_cmd[@]}" ! -name '._*' \( $ext_expr \) 2>/dev/null || true
|
|
| 373 | 976 |
else |
| 374 |
- # Use specified patterns |
|
| 375 |
- for pattern in "${SOURCE_PATTERNS[@]}"; do
|
|
| 376 |
- # Handle different pattern types |
|
| 377 |
- if [[ -d "$pattern" ]]; then |
|
| 378 |
- # Directory pattern |
|
| 379 |
- find "$pattern" -type f \( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.tiff" -o -iname "*.tif" -o -iname "*.cr2" -o -iname "*.nef" -o -iname "*.arw" -o -iname "*.dng" -o -iname "*.raw" -o -iname "*.mp4" -o -iname "*.mov" -o -iname "*.avi" -o -iname "*.mts" -o -iname "*.m2ts" -o -iname "*.mkv" -o -iname "*.wmv" -o -iname "*.3gp" -o -iname "*.m4v" \) 2>/dev/null |
|
| 380 |
- elif [[ "$pattern" == *"*"* ]] || [[ "$pattern" == *"?"* ]]; then |
|
| 381 |
- # Glob pattern |
|
| 382 |
- for file in $pattern; do |
|
| 383 |
- if [[ -f "$file" ]]; then |
|
| 384 |
- echo "$file" |
|
| 977 |
+ # Scan each provided source |
|
| 978 |
+ for src in "${SOURCE_PATTERNS[@]}"; do
|
|
| 979 |
+ if [[ -f "$src" ]]; then |
|
| 980 |
+ if [[ "$(basename "$src")" == ._* ]]; then |
|
| 981 |
+ continue |
|
| 982 |
+ fi |
|
| 983 |
+ # single file - skip if it's inside dest |
|
| 984 |
+ local abs_file |
|
| 985 |
+ abs_file=$(cd "$(dirname "$src")" 2>/dev/null && pwd)/$(basename "$src") |
|
| 986 |
+ if [[ -n "$abs_dest" && "$abs_file" == "$abs_dest"* ]]; then |
|
| 987 |
+ continue |
|
| 988 |
+ fi |
|
| 989 |
+ echo "$abs_file" |
|
| 990 |
+ elif [[ -d "$src" ]]; then |
|
| 991 |
+ local abs_src |
|
| 992 |
+ abs_src=$(cd "$src" 2>/dev/null && pwd) |
|
| 993 |
+ if [[ -n "$abs_src" ]]; then |
|
| 994 |
+ if [[ -n "$abs_dest" && "$abs_dest" == "$abs_src"* ]]; then |
|
| 995 |
+ find -L "$abs_src" -type f ! -name '._*' \( $ext_expr \) ! -path "$abs_dest/*" ! -path "$abs_dest" 2>/dev/null || true |
|
| 996 |
+ else |
|
| 997 |
+ find -L "$abs_src" -type f ! -name '._*' \( $ext_expr \) 2>/dev/null || true |
|
| 385 | 998 |
fi |
| 386 |
- done |
|
| 387 |
- else |
|
| 388 |
- # Exact file or directory |
|
| 389 |
- if [[ -f "$pattern" ]]; then |
|
| 390 |
- echo "$pattern" |
|
| 391 |
- elif [[ -d "$pattern" ]]; then |
|
| 392 |
- find "$pattern" -type f \( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.tiff" -o -iname "*.tif" -o -iname "*.cr2" -o -iname "*.nef" -o -iname "*.arw" -o -iname "*.dng" -o -iname "*.raw" -o -iname "*.mp4" -o -iname "*.mov" -o -iname "*.avi" -o -iname "*.mts" -o -iname "*.m2ts" -o -iname "*.mkv" -o -iname "*.wmv" -o -iname "*.3gp" -o -iname "*.m4v" \) 2>/dev/null |
|
| 999 |
+ else |
|
| 1000 |
+ print_color "$YELLOW" "Warning: Could not resolve source directory: $src" |
|
| 393 | 1001 |
fi |
| 1002 |
+ else |
|
| 1003 |
+ print_color "$YELLOW" "Warning: Source path does not exist or is not a file/directory: $src" |
|
| 394 | 1004 |
fi |
| 395 | 1005 |
done |
| 396 | 1006 |
fi |
@@ -400,44 +1010,109 @@ find_source_files() {
|
||
| 400 | 1010 |
process_file() {
|
| 401 | 1011 |
local file="$1" |
| 402 | 1012 |
local file_size=$(get_file_size "$file") |
| 1013 |
+ local file_label |
|
| 1014 |
+ file_label="$(basename "$file")" |
|
| 403 | 1015 |
TOTAL_SIZE=$((TOTAL_SIZE + file_size)) |
| 404 | 1016 |
|
| 1017 |
+ if [[ $TOTAL_FILES -gt 0 && $CURRENT_FILE_INDEX -gt 0 ]]; then |
|
| 1018 |
+ print_color "$BLUE" "Processing [$CURRENT_FILE_INDEX/$TOTAL_FILES]: $file_label ($(format_size "$file_size"))" |
|
| 1019 |
+ else |
|
| 1020 |
+ print_color "$BLUE" "Processing: $file_label ($(format_size "$file_size"))" |
|
| 1021 |
+ fi |
|
| 405 | 1022 |
log_message "Processing: $file" "INFO" |
| 406 | 1023 |
|
| 407 | 1024 |
# Extract date information |
| 408 | 1025 |
local date_info=$(extract_file_date "$file") |
| 409 |
- if [[ $? -ne 0 ]] || [[ -z "$date_info" ]]; then |
|
| 410 |
- log_message "Could not extract date from $file - skipping" "WARNING" |
|
| 1026 |
+ local extract_status=$? |
|
| 1027 |
+ if [[ $extract_status -eq 2 ]]; then |
|
| 1028 |
+ if [[ $COLLECT_UNSORTABLE -eq 1 ]]; then |
|
| 1029 |
+ local unsortable_dir="$DESTINATION/unsortable" |
|
| 1030 |
+ mkdir -p "$unsortable_dir" |
|
| 1031 |
+ local unsortable_path="$unsortable_dir/$(basename "$file")" |
|
| 1032 |
+ local desired_unsortable_path="$unsortable_path" |
|
| 1033 |
+ local unsortable_conflict_status |
|
| 1034 |
+ resolve_destination_conflict "$unsortable_path" "$file" |
|
| 1035 |
+ unsortable_conflict_status=$? |
|
| 1036 |
+ if [[ $unsortable_conflict_status -eq 0 ]]; then |
|
| 1037 |
+ unsortable_path="$RESOLVED_DESTINATION_PATH" |
|
| 1038 |
+ if [[ "$unsortable_path" != "$desired_unsortable_path" ]]; then |
|
| 1039 |
+ log_message "Destination already exists or is already planned: $desired_unsortable_path - using: $unsortable_path" "WARNING" |
|
| 1040 |
+ fi |
|
| 1041 |
+ elif [[ $unsortable_conflict_status -eq 3 ]]; then |
|
| 1042 |
+ log_message "Destination conflict skipped: $desired_unsortable_path" "WARNING" |
|
| 1043 |
+ SKIPPED_FILES=$((SKIPPED_FILES + 1)) |
|
| 1044 |
+ return 1 |
|
| 1045 |
+ elif [[ $unsortable_conflict_status -eq 4 ]]; then |
|
| 1046 |
+ log_message "Import aborted by user at destination conflict: $desired_unsortable_path" "ERROR" |
|
| 1047 |
+ ERROR_FILES=$((ERROR_FILES + 1)) |
|
| 1048 |
+ FATAL_ERROR=1 |
|
| 1049 |
+ return 2 |
|
| 1050 |
+ else |
|
| 1051 |
+ log_message "Could not resolve a unique destination path for $file (wanted: $desired_unsortable_path)" "ERROR" |
|
| 1052 |
+ ERROR_FILES=$((ERROR_FILES + 1)) |
|
| 1053 |
+ return 1 |
|
| 1054 |
+ fi |
|
| 1055 |
+ if [[ $DRY_RUN -eq 1 ]]; then |
|
| 1056 |
+ print_color "$BLUE" "Would move unsortable: $file -> $unsortable_path" |
|
| 1057 |
+ else |
|
| 1058 |
+ if verified_move_file "$file" "$unsortable_path" ""; then |
|
| 1059 |
+ log_message "Unsortable: $file -> $unsortable_path" "SUCCESS" |
|
| 1060 |
+ else |
|
| 1061 |
+ log_message "Failed to move unsortable file after verification: $file" "ERROR" |
|
| 1062 |
+ fi |
|
| 1063 |
+ fi |
|
| 1064 |
+ SKIPPED_FILES=$((SKIPPED_FILES + 1)) |
|
| 1065 |
+ else |
|
| 1066 |
+ log_message "Could not extract date from $file - skipping" "WARNING" |
|
| 1067 |
+ SKIPPED_FILES=$((SKIPPED_FILES + 1)) |
|
| 1068 |
+ fi |
|
| 1069 |
+ return 1 |
|
| 1070 |
+ elif [[ $extract_status -ne 0 ]] || [[ -z "$date_info" ]]; then |
|
| 1071 |
+ log_message "Could not extract date from $file - skipping" "WARNING" |
|
| 1072 |
+ SKIPPED_FILES=$((SKIPPED_FILES + 1)) |
|
| 1073 |
+ return 1 |
|
| 1074 |
+ fi |
|
| 1075 |
+ local date_str="${date_info%|*}"
|
|
| 1076 |
+ local date_source="${date_info#*|}"
|
|
| 1077 |
+ log_message "Date: $date_str (from $date_source)" "INFO" |
|
| 1078 |
+ # Generate destination path |
|
| 1079 |
+ local original_basename |
|
| 1080 |
+ original_basename="$(basename "$file")" |
|
| 1081 |
+ local dest_path |
|
| 1082 |
+ dest_path=$(generate_destination_path "$date_str" "$original_basename" "$DESTINATION") |
|
| 1083 |
+ if [[ $? -ne 0 ]] || [[ -z "$dest_path" ]]; then |
|
| 1084 |
+ log_message "Could not generate destination path for $file" "ERROR" |
|
| 1085 |
+ ERROR_FILES=$((ERROR_FILES + 1)) |
|
| 1086 |
+ FATAL_ERROR=1 |
|
| 1087 |
+ return 2 |
|
| 1088 |
+ fi |
|
| 1089 |
+ |
|
| 1090 |
+ local desired_dest_path="$dest_path" |
|
| 1091 |
+ local conflict_status |
|
| 1092 |
+ resolve_destination_conflict "$dest_path" "$file" |
|
| 1093 |
+ conflict_status=$? |
|
| 1094 |
+ if [[ $conflict_status -eq 0 ]]; then |
|
| 1095 |
+ dest_path="$RESOLVED_DESTINATION_PATH" |
|
| 1096 |
+ elif [[ $conflict_status -eq 3 ]]; then |
|
| 1097 |
+ log_message "Destination conflict skipped: $desired_dest_path" "WARNING" |
|
| 411 | 1098 |
SKIPPED_FILES=$((SKIPPED_FILES + 1)) |
| 412 | 1099 |
return 1 |
| 413 |
- fi |
|
| 414 |
- |
|
| 415 |
- local date_str="${date_info%|*}"
|
|
| 416 |
- local date_source="${date_info#*|}"
|
|
| 417 |
- |
|
| 418 |
- log_message "Date: $date_str (from $date_source)" "INFO" |
|
| 419 |
- |
|
| 420 |
- # Generate destination path |
|
| 421 |
- local dest_path=$(generate_destination_path "$date_str" "$(basename "$file")" "$DESTINATION") |
|
| 422 |
- if [[ $? -ne 0 ]] || [[ -z "$dest_path" ]]; then |
|
| 423 |
- log_message "Could not generate destination path for $file" "ERROR" |
|
| 1100 |
+ elif [[ $conflict_status -eq 4 ]]; then |
|
| 1101 |
+ log_message "Import aborted by user at destination conflict: $desired_dest_path" "ERROR" |
|
| 1102 |
+ ERROR_FILES=$((ERROR_FILES + 1)) |
|
| 1103 |
+ FATAL_ERROR=1 |
|
| 1104 |
+ return 2 |
|
| 1105 |
+ else |
|
| 1106 |
+ log_message "Could not resolve a unique destination path for $file (wanted: $desired_dest_path)" "ERROR" |
|
| 424 | 1107 |
ERROR_FILES=$((ERROR_FILES + 1)) |
| 425 | 1108 |
return 1 |
| 426 | 1109 |
fi |
| 427 |
- |
|
| 428 |
- # Handle filename conflicts |
|
| 429 |
- local counter=1 |
|
| 430 |
- local original_dest_path="$dest_path" |
|
| 431 |
- while [[ -f "$dest_path" ]]; do |
|
| 432 |
- local dir_path=$(dirname "$original_dest_path") |
|
| 433 |
- local filename=$(basename "$original_dest_path") |
|
| 434 |
- local name_without_ext="${filename%.*}"
|
|
| 435 |
- local ext="${filename##*.}"
|
|
| 436 |
- dest_path="$dir_path/${name_without_ext}_${counter}.${ext}"
|
|
| 437 |
- counter=$((counter + 1)) |
|
| 438 |
- done |
|
| 439 |
- |
|
| 440 |
- local dest_dir=$(dirname "$dest_path") |
|
| 1110 |
+ if [[ "$dest_path" != "$desired_dest_path" ]]; then |
|
| 1111 |
+ log_message "Destination already exists or is already planned: $desired_dest_path - using: $dest_path" "WARNING" |
|
| 1112 |
+ fi |
|
| 1113 |
+ |
|
| 1114 |
+ local dest_dir |
|
| 1115 |
+ dest_dir=$(dirname "$dest_path") |
|
| 441 | 1116 |
|
| 442 | 1117 |
if [[ $DRY_RUN -eq 1 ]]; then |
| 443 | 1118 |
if [[ $KEEP_ORIGINALS -eq 1 ]]; then |
@@ -445,6 +1120,9 @@ process_file() {
|
||
| 445 | 1120 |
else |
| 446 | 1121 |
print_color "$BLUE" "Would move: $file -> $dest_path" |
| 447 | 1122 |
fi |
| 1123 |
+ if should_sync_imported_metadata "$original_basename" "$date_source"; then |
|
| 1124 |
+ print_color "$BLUE" "Would sync metadata date: $dest_path -> $date_str" |
|
| 1125 |
+ fi |
|
| 448 | 1126 |
PROCESSED_FILES=$((PROCESSED_FILES + 1)) |
| 449 | 1127 |
PROCESSED_SIZE=$((PROCESSED_SIZE + file_size)) |
| 450 | 1128 |
return 0 |
@@ -457,26 +1135,75 @@ process_file() {
|
||
| 457 | 1135 |
return 1 |
| 458 | 1136 |
fi |
| 459 | 1137 |
|
| 460 |
- # Copy or move file |
|
| 1138 |
+ local sync_metadata_after_copy=0 |
|
| 1139 |
+ local verification_date="$date_str" |
|
| 1140 |
+ if should_sync_imported_metadata "$original_basename" "$date_source"; then |
|
| 1141 |
+ sync_metadata_after_copy=1 |
|
| 1142 |
+ verification_date="" |
|
| 1143 |
+ fi |
|
| 1144 |
+ |
|
| 1145 |
+ # Copy or move file using safe helpers after destination conflicts are resolved. |
|
| 461 | 1146 |
if [[ $KEEP_ORIGINALS -eq 1 ]]; then |
| 462 |
- if cp "$file" "$dest_path"; then |
|
| 1147 |
+ if copy_with_verification "$file" "$dest_path" "$verification_date"; then |
|
| 1148 |
+ if [[ $sync_metadata_after_copy -eq 1 ]]; then |
|
| 1149 |
+ if ! sync_destination_metadata_to_date "$dest_path" "$date_str"; then |
|
| 1150 |
+ log_message "Failed to sync destination metadata timestamps: $dest_path" "ERROR" |
|
| 1151 |
+ ERROR_FILES=$((ERROR_FILES + 1)) |
|
| 1152 |
+ return 1 |
|
| 1153 |
+ elif ! verify_synced_metadata_date "$dest_path" "$date_str"; then |
|
| 1154 |
+ ERROR_FILES=$((ERROR_FILES + 1)) |
|
| 1155 |
+ return 1 |
|
| 1156 |
+ fi |
|
| 1157 |
+ fi |
|
| 463 | 1158 |
log_message "Copied: $file -> $dest_path" "SUCCESS" |
| 464 | 1159 |
PROCESSED_FILES=$((PROCESSED_FILES + 1)) |
| 465 | 1160 |
PROCESSED_SIZE=$((PROCESSED_SIZE + file_size)) |
| 466 | 1161 |
return 0 |
| 467 | 1162 |
else |
| 468 |
- log_message "Failed to copy: $file" "ERROR" |
|
| 1163 |
+ log_message "Failed to copy or verify destination: $file -> $dest_path" "ERROR" |
|
| 469 | 1164 |
ERROR_FILES=$((ERROR_FILES + 1)) |
| 470 | 1165 |
return 1 |
| 471 | 1166 |
fi |
| 472 | 1167 |
else |
| 473 |
- if mv "$file" "$dest_path"; then |
|
| 1168 |
+ if [[ $sync_metadata_after_copy -eq 1 ]]; then |
|
| 1169 |
+ if copy_with_verification "$file" "$dest_path" "$verification_date"; then |
|
| 1170 |
+ if ! sync_destination_metadata_to_date "$dest_path" "$date_str"; then |
|
| 1171 |
+ log_message "Failed to sync destination metadata timestamps: $dest_path" "ERROR" |
|
| 1172 |
+ ERROR_FILES=$((ERROR_FILES + 1)) |
|
| 1173 |
+ return 1 |
|
| 1174 |
+ fi |
|
| 1175 |
+ if ! verify_synced_metadata_date "$dest_path" "$date_str"; then |
|
| 1176 |
+ ERROR_FILES=$((ERROR_FILES + 1)) |
|
| 1177 |
+ return 1 |
|
| 1178 |
+ fi |
|
| 1179 |
+ if ! remove_source_file "$file"; then |
|
| 1180 |
+ log_message "Copied, verified, and synced destination, but failed to remove source: $file" "ERROR" |
|
| 1181 |
+ ERROR_FILES=$((ERROR_FILES + 1)) |
|
| 1182 |
+ return 1 |
|
| 1183 |
+ fi |
|
| 1184 |
+ log_message "Moved: $file -> $dest_path" "SUCCESS" |
|
| 1185 |
+ PROCESSED_FILES=$((PROCESSED_FILES + 1)) |
|
| 1186 |
+ PROCESSED_SIZE=$((PROCESSED_SIZE + file_size)) |
|
| 1187 |
+ return 0 |
|
| 1188 |
+ else |
|
| 1189 |
+ log_message "Failed to move after copy verification: $file -> $dest_path" "ERROR" |
|
| 1190 |
+ ERROR_FILES=$((ERROR_FILES + 1)) |
|
| 1191 |
+ return 1 |
|
| 1192 |
+ fi |
|
| 1193 |
+ elif verified_move_file "$file" "$dest_path" "$date_str"; then |
|
| 474 | 1194 |
log_message "Moved: $file -> $dest_path" "SUCCESS" |
| 1195 |
+ if [[ $sync_metadata_after_copy -eq 1 ]]; then |
|
| 1196 |
+ if ! sync_destination_metadata_to_date "$dest_path" "$date_str"; then |
|
| 1197 |
+ log_message "Failed to sync destination metadata timestamps: $dest_path" "WARNING" |
|
| 1198 |
+ elif ! verify_synced_metadata_date "$dest_path" "$date_str"; then |
|
| 1199 |
+ log_message "Failed to verify synced destination metadata timestamps: $dest_path" "WARNING" |
|
| 1200 |
+ fi |
|
| 1201 |
+ fi |
|
| 475 | 1202 |
PROCESSED_FILES=$((PROCESSED_FILES + 1)) |
| 476 | 1203 |
PROCESSED_SIZE=$((PROCESSED_SIZE + file_size)) |
| 477 | 1204 |
return 0 |
| 478 | 1205 |
else |
| 479 |
- log_message "Failed to move: $file" "ERROR" |
|
| 1206 |
+ log_message "Failed to move after copy verification: $file -> $dest_path" "ERROR" |
|
| 480 | 1207 |
ERROR_FILES=$((ERROR_FILES + 1)) |
| 481 | 1208 |
return 1 |
| 482 | 1209 |
fi |
@@ -496,30 +1223,31 @@ show_report() {
|
||
| 496 | 1223 |
print_color "$GREEN" " PROCESSING REPORT" |
| 497 | 1224 |
print_color "$GREEN" "==========================================" |
| 498 | 1225 |
echo "" |
| 499 |
- |
|
| 1226 |
+ |
|
| 500 | 1227 |
echo "Files Summary:" |
| 501 |
- echo " Total files found: $TOTAL_FILES" |
|
| 502 |
- echo " Successfully processed: $PROCESSED_FILES" |
|
| 503 |
- echo " Skipped (no date): $SKIPPED_FILES" |
|
| 504 |
- echo " Errors: $ERROR_FILES" |
|
| 1228 |
+ report_line "Total files found:" "$TOTAL_FILES" |
|
| 1229 |
+ report_line "Successfully processed:" "$PROCESSED_FILES" |
|
| 1230 |
+ report_line "Skipped:" "$SKIPPED_FILES" |
|
| 1231 |
+ report_line "Errors:" "$ERROR_FILES" |
|
| 505 | 1232 |
echo "" |
| 506 |
- |
|
| 1233 |
+ |
|
| 507 | 1234 |
echo "Size Summary:" |
| 508 |
- echo " Total size found: $(format_size $TOTAL_SIZE)" |
|
| 509 |
- echo " Successfully processed: $(format_size $PROCESSED_SIZE)" |
|
| 1235 |
+ report_line "Total size found:" "$(format_size $TOTAL_SIZE)" |
|
| 1236 |
+ report_line "Successfully processed:" "$(format_size $PROCESSED_SIZE)" |
|
| 510 | 1237 |
echo "" |
| 511 |
- |
|
| 1238 |
+ |
|
| 512 | 1239 |
echo "Time Summary:" |
| 513 |
- printf " Time elapsed: %02d:%02d:%02d\n" $hours $minutes $seconds |
|
| 514 |
- |
|
| 515 |
- if [[ $elapsed_time -gt 0 && $PROCESSED_FILES -gt 0 ]]; then |
|
| 516 |
- local files_per_second=$((PROCESSED_FILES / elapsed_time)) |
|
| 517 |
- local mb_per_second=$((PROCESSED_SIZE / elapsed_time / 1048576)) |
|
| 518 |
- echo " Speed: $files_per_second files/sec, ${mb_per_second}MB/sec"
|
|
| 1240 |
+ report_line "Time elapsed:" "$(printf "%02d:%02d:%02d" $hours $minutes $seconds)" |
|
| 1241 |
+ if [[ $elapsed_time -gt 0 && $PROCESSED_SIZE -gt 0 ]]; then |
|
| 1242 |
+ local data_rate |
|
| 1243 |
+ data_rate=$(format_data_rate "$PROCESSED_SIZE" "$elapsed_time") |
|
| 1244 |
+ if [[ -n "$data_rate" ]]; then |
|
| 1245 |
+ report_line "Data rate:" "$data_rate" |
|
| 1246 |
+ fi |
|
| 519 | 1247 |
fi |
| 520 |
- |
|
| 1248 |
+ |
|
| 521 | 1249 |
echo "" |
| 522 |
- |
|
| 1250 |
+ |
|
| 523 | 1251 |
if [[ $DRY_RUN -eq 1 ]]; then |
| 524 | 1252 |
print_color "$YELLOW" "DRY RUN MODE - No files were actually moved/copied" |
| 525 | 1253 |
elif [[ $KEEP_ORIGINALS -eq 1 ]]; then |
@@ -532,17 +1260,40 @@ show_report() {
|
||
| 532 | 1260 |
print_color "$GREEN" "==========================================" |
| 533 | 1261 |
} |
| 534 | 1262 |
|
| 1263 |
+if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then
|
|
| 1264 |
+ return 0 |
|
| 1265 |
+fi |
|
| 1266 |
+ |
|
| 535 | 1267 |
# Parse command line arguments |
| 536 | 1268 |
while [[ $# -gt 0 ]]; do |
| 537 | 1269 |
case $1 in |
| 538 | 1270 |
-o|--organization) |
| 539 | 1271 |
ORGANIZATION="$2" |
| 540 |
- if [[ ! "$ORGANIZATION" =~ ^[ymdh]$ ]]; then |
|
| 541 |
- print_color "$RED" "Error: Invalid organization pattern. Must be one of: y, m, d, h" |
|
| 1272 |
+ # Accept new patterns: ym, ymd as well as single-letter ones |
|
| 1273 |
+ if [[ ! "$ORGANIZATION" =~ ^(y|m|d|h|ym|ymd)$ ]]; then |
|
| 1274 |
+ print_color "$RED" "Error: Invalid organization pattern. Must be one of: y, m, d, h, ym, ymd" |
|
| 1275 |
+ exit 1 |
|
| 1276 |
+ fi |
|
| 1277 |
+ shift 2 |
|
| 1278 |
+ ;; |
|
| 1279 |
+ |
|
| 1280 |
+ -F|--filename-mode) |
|
| 1281 |
+ FILENAME_MODE="$2" |
|
| 1282 |
+ if [[ ! "$FILENAME_MODE" =~ ^(auto|full|orig)$ ]]; then |
|
| 1283 |
+ print_color "$RED" "Error: Invalid filename mode. Must be one of: auto, full, orig" |
|
| 542 | 1284 |
exit 1 |
| 543 | 1285 |
fi |
| 544 | 1286 |
shift 2 |
| 545 | 1287 |
;; |
| 1288 |
+ # (conflict resolution option removed; let exiftool/filesystem handle naming conflicts) |
|
| 1289 |
+ --collect-unsortable) |
|
| 1290 |
+ COLLECT_UNSORTABLE=1 |
|
| 1291 |
+ shift |
|
| 1292 |
+ ;; |
|
| 1293 |
+ --keep-empty-dirs) |
|
| 1294 |
+ CLEANUP_EMPTY_DIRS=0 |
|
| 1295 |
+ shift |
|
| 1296 |
+ ;; |
|
| 546 | 1297 |
-s|--source) |
| 547 | 1298 |
SOURCE_PATTERNS+=("$2")
|
| 548 | 1299 |
shift 2 |
@@ -555,6 +1306,30 @@ while [[ $# -gt 0 ]]; do |
||
| 555 | 1306 |
KEEP_ORIGINALS=1 |
| 556 | 1307 |
shift |
| 557 | 1308 |
;; |
| 1309 |
+ --verify-mode) |
|
| 1310 |
+ VERIFY_MODE="$2" |
|
| 1311 |
+ if [[ ! "$VERIFY_MODE" =~ ^(size|strict|none)$ ]]; then |
|
| 1312 |
+ print_color "$RED" "Error: Invalid verify mode. Must be one of: size, strict, none" |
|
| 1313 |
+ exit 1 |
|
| 1314 |
+ fi |
|
| 1315 |
+ shift 2 |
|
| 1316 |
+ ;; |
|
| 1317 |
+ --date-source) |
|
| 1318 |
+ DATE_SOURCE="$2" |
|
| 1319 |
+ if [[ ! "$DATE_SOURCE" =~ ^(auto|exif|filesystem)$ ]]; then |
|
| 1320 |
+ print_color "$RED" "Error: Invalid date source. Must be one of: auto, exif, filesystem" |
|
| 1321 |
+ exit 1 |
|
| 1322 |
+ fi |
|
| 1323 |
+ shift 2 |
|
| 1324 |
+ ;; |
|
| 1325 |
+ --sync-metadata) |
|
| 1326 |
+ SYNC_METADATA=1 |
|
| 1327 |
+ shift |
|
| 1328 |
+ ;; |
|
| 1329 |
+ --unattended) |
|
| 1330 |
+ UNATTENDED=1 |
|
| 1331 |
+ shift |
|
| 1332 |
+ ;; |
|
| 558 | 1333 |
--dry-run) |
| 559 | 1334 |
DRY_RUN=1 |
| 560 | 1335 |
shift |
@@ -579,9 +1354,51 @@ while [[ $# -gt 0 ]]; do |
||
| 579 | 1354 |
esac |
| 580 | 1355 |
done |
| 581 | 1356 |
|
| 582 |
-# Set default destination if not specified |
|
| 1357 |
+# Non-interactive execution cannot safely ask conflict questions. |
|
| 1358 |
+if [[ $UNATTENDED -eq 0 && ( ! -t 0 || ! -t 1 ) ]]; then |
|
| 1359 |
+ UNATTENDED=1 |
|
| 1360 |
+fi |
|
| 1361 |
+ |
|
| 1362 |
+# If no organization is provided, leave ORGANIZATION empty and filename mode will decide naming |
|
| 1363 |
+ |
|
| 1364 |
+# If no source specified, default to current directory. Refuse to run when cwd is unsafe. |
|
| 1365 |
+if [[ ${#SOURCE_PATTERNS[@]} -eq 0 ]]; then
|
|
| 1366 |
+ cwd=$(pwd) |
|
| 1367 |
+ # Resolve home and root paths |
|
| 1368 |
+ home_dir="$HOME" |
|
| 1369 |
+ case "$cwd" in |
|
| 1370 |
+ "$home_dir"|"/"|"/etc"|"/var"|"/bin"|"/sbin"|"/dev"|"/proc"|"/sys"|"/run"|"/usr"|"/lib"|"/lib64"|"/boot"|"/snap") |
|
| 1371 |
+ print_color "$RED" "Refusing to run with default source in unsafe directory: $cwd" |
|
| 1372 |
+ print_color "$RED" "Please specify $cwd as source explicitly with -s or run from a safer directory." |
|
| 1373 |
+ exit 1 |
|
| 1374 |
+ ;; |
|
| 1375 |
+ *) |
|
| 1376 |
+ SOURCE_PATTERNS+=("$cwd")
|
|
| 1377 |
+ ;; |
|
| 1378 |
+ esac |
|
| 1379 |
+fi |
|
| 1380 |
+ |
|
| 1381 |
+# Set default destination: if user didn't provide -d and a source was given, use first source + /sorted |
|
| 583 | 1382 |
if [[ -z "$DESTINATION" ]]; then |
| 584 |
- DESTINATION="./sorted" |
|
| 1383 |
+ if [[ ${#SOURCE_PATTERNS[@]} -gt 1 ]]; then
|
|
| 1384 |
+ print_color "$RED" "Error: Multiple sources specified - destination (-d|--destination) is required when using multiple sources." |
|
| 1385 |
+ echo "Use -h for help." |
|
| 1386 |
+ exit 1 |
|
| 1387 |
+ fi |
|
| 1388 |
+ |
|
| 1389 |
+ if [[ ${#SOURCE_PATTERNS[@]} -gt 0 ]]; then
|
|
| 1390 |
+ first_source="${SOURCE_PATTERNS[0]}"
|
|
| 1391 |
+ if [[ -d "$first_source" ]]; then |
|
| 1392 |
+ DESTINATION="$first_source/sorted" |
|
| 1393 |
+ elif [[ -f "$first_source" ]]; then |
|
| 1394 |
+ DESTINATION="$(dirname "$first_source")/sorted" |
|
| 1395 |
+ else |
|
| 1396 |
+ print_color "$YELLOW" "Warning: first source does not exist; defaulting destination to ./sorted" |
|
| 1397 |
+ DESTINATION="./sorted" |
|
| 1398 |
+ fi |
|
| 1399 |
+ else |
|
| 1400 |
+ DESTINATION="./sorted" |
|
| 1401 |
+ fi |
|
| 585 | 1402 |
fi |
| 586 | 1403 |
|
| 587 | 1404 |
# Convert destination to absolute path |
@@ -594,7 +1411,12 @@ echo "Configuration:" |
||
| 594 | 1411 |
echo " Organization pattern: $ORGANIZATION" |
| 595 | 1412 |
echo " Destination: $DESTINATION" |
| 596 | 1413 |
echo " Keep originals: $([ $KEEP_ORIGINALS -eq 1 ] && echo "Yes" || echo "No")" |
| 1414 |
+echo " Verify mode: $VERIFY_MODE" |
|
| 1415 |
+echo " Date source: $DATE_SOURCE" |
|
| 1416 |
+echo " Sync metadata: $([ $SYNC_METADATA -eq 1 ] && echo "Yes" || echo "No")" |
|
| 1417 |
+echo " Unattended: $([ $UNATTENDED -eq 1 ] && echo "Yes" || echo "No")" |
|
| 597 | 1418 |
echo " Dry run: $([ $DRY_RUN -eq 1 ] && echo "Yes" || echo "No")" |
| 1419 |
+echo " Keep empty dirs: $([ $CLEANUP_EMPTY_DIRS -eq 0 ] && echo "Yes" || echo "No")" |
|
| 598 | 1420 |
echo " Verbose: $([ $VERBOSE -eq 1 ] && echo "Yes" || echo "No")" |
| 599 | 1421 |
|
| 600 | 1422 |
if [[ ${#SOURCE_PATTERNS[@]} -gt 0 ]]; then
|
@@ -620,8 +1442,16 @@ if [[ $DRY_RUN -eq 0 ]]; then |
||
| 620 | 1442 |
fi |
| 621 | 1443 |
|
| 622 | 1444 |
# Find all source files |
| 1445 |
+ |
|
| 623 | 1446 |
print_color "$BLUE" "Scanning for media files..." |
| 624 |
-mapfile -t files < <(find_source_files) |
|
| 1447 |
+files=() |
|
| 1448 |
+while IFS= read -r file; do |
|
| 1449 |
+ files+=("$file")
|
|
| 1450 |
+done < <(find_source_files) |
|
| 1451 |
+if [[ ${#files[@]} -gt 0 ]]; then
|
|
| 1452 |
+ IFS=$'\n' files=($(printf '%s\n' "${files[@]}" | sort))
|
|
| 1453 |
+ unset IFS |
|
| 1454 |
+fi |
|
| 625 | 1455 |
TOTAL_FILES=${#files[@]}
|
| 626 | 1456 |
|
| 627 | 1457 |
if [[ $TOTAL_FILES -eq 0 ]]; then |
@@ -633,12 +1463,27 @@ print_color "$BLUE" "Found $TOTAL_FILES media files to process" |
||
| 633 | 1463 |
echo "" |
| 634 | 1464 |
|
| 635 | 1465 |
# Process each file |
| 1466 |
+ |
|
| 1467 |
+FATAL_ERROR=0 |
|
| 636 | 1468 |
for file in "${files[@]}"; do
|
| 637 | 1469 |
if [[ -f "$file" ]]; then |
| 1470 |
+ CURRENT_FILE_INDEX=$((CURRENT_FILE_INDEX + 1)) |
|
| 638 | 1471 |
process_file "$file" |
| 1472 |
+ if [[ $FATAL_ERROR -eq 1 ]]; then |
|
| 1473 |
+ print_color "$RED" "Fatal error encountered. Stopping further processing." |
|
| 1474 |
+ break |
|
| 1475 |
+ fi |
|
| 639 | 1476 |
fi |
| 640 | 1477 |
done |
| 641 | 1478 |
|
| 1479 |
+# Clean up empty directories if requested (default behavior) |
|
| 1480 |
+if [[ $CLEANUP_EMPTY_DIRS -eq 1 && $DRY_RUN -eq 0 ]]; then |
|
| 1481 |
+ print_color "$BLUE" "Cleaning up empty directories..." |
|
| 1482 |
+ # Find and remove empty directories under destination, but don't remove the destination itself |
|
| 1483 |
+ find "$DESTINATION" -type d -empty -not -path "$DESTINATION" -delete 2>/dev/null || true |
|
| 1484 |
+ print_color "$GREEN" "Empty directory cleanup completed" |
|
| 1485 |
+fi |
|
| 1486 |
+ |
|
| 642 | 1487 |
# Show final report |
| 643 | 1488 |
show_report |
| 644 | 1489 |
|