Showing 2 changed files with 1631 additions and 523 deletions
+570 -307
scripts/autonas-media-importer.sh 1000644 → 1000755
@@ -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
+1061 -216
standalone-media-importer.sh 1000644 → 1000755
@@ -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