MediaImporter / media-importer.sh
Newer Older
1425 lines | 49.052kb
Bogdan Timofte authored 9 months ago
1
#!/bin/bash
2

            
3
# Standalone Media Importer
4
# Version: 1.0
5
# A comprehensive media file organizer that sorts photos and videos by date
6
# with various organization patterns and timezone handling
7

            
8
VERSION="1.0"
9
SCRIPT_NAME="Standalone Media Importer"
10

            
11
# Default values
Bogdan Timofte authored 9 months ago
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
Bogdan Timofte authored 9 months ago
15
COLLECT_UNSORTABLE=0
16
SOURCE_PATTERNS=()
17
DESTINATION=""
18
KEEP_ORIGINALS=0
Bogdan Timofte authored 3 weeks ago
19
VERIFY_MODE="size"  # options: size, strict, none
Bogdan Timofte authored 2 weeks ago
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
Bogdan Timofte authored 9 months ago
23
DRY_RUN=0
24
VERBOSE=0
Bogdan Timofte authored 8 months ago
25
CLEANUP_EMPTY_DIRS=1
Bogdan Timofte authored 2 weeks ago
26
CONFLICT_APPLY_ALL=""  # suffix|skip after an interactive "all similar" choice
27
RESOLVED_DESTINATION_PATH=""
28
RESERVED_DESTINATION_PATHS=()
Bogdan Timofte authored 9 months ago
29

            
30
# Counters and statistics
31
TOTAL_FILES=0
32
PROCESSED_FILES=0
33
SKIPPED_FILES=0
34
ERROR_FILES=0
35
TOTAL_SIZE=0
36
PROCESSED_SIZE=0
37
START_TIME=$(date +%s)
Bogdan Timofte authored 2 weeks ago
38
CURRENT_FILE_INDEX=0
Bogdan Timofte authored 9 months ago
39

            
40
# Colors for output
41
RED='\033[0;31m'
42
GREEN='\033[0;32m'
43
YELLOW='\033[1;33m'
Bogdan Timofte authored 9 months ago
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
Bogdan Timofte authored 9 months ago
52
NC='\033[0m' # No Color
53

            
54
# Function to print colored output
55
print_color() {
56
    local color="$1"
57
    local message="$2"
58
    echo -e "${color}${message}${NC}"
59
}
60

            
61
# Function to log messages with timestamp
62
log_message() {
63
    local message="$1"
64
    local level="${2:-INFO}"
65
    local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
66

            
67
    case "$level" in
68
        "ERROR")
69
            print_color "$RED" "[$timestamp] ERROR: $message" >&2
70
            ;;
71
        "WARNING")
72
            print_color "$YELLOW" "[$timestamp] WARNING: $message"
73
            ;;
74
        "SUCCESS")
75
            print_color "$GREEN" "[$timestamp] SUCCESS: $message"
76
            ;;
77
        "INFO")
78
            if [[ $VERBOSE -eq 1 ]]; then
79
                print_color "$BLUE" "[$timestamp] INFO: $message"
80
            fi
81
            ;;
82
        *)
83
            echo "[$timestamp] $message"
84
            ;;
85
    esac
86
}
87

            
88
# Function to display help
89
show_help() {
Bogdan Timofte authored 9 months ago
90
        cat << EOF
91
    $SCRIPT_NAME v$VERSION
Bogdan Timofte authored 9 months ago
92

            
Bogdan Timofte authored 9 months ago
93
Usage:
Bogdan Timofte authored 9 months ago
94
    $0 [OPTIONS]
95

            
Bogdan Timofte authored 9 months ago
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.
100

            
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.
Bogdan Timofte authored 8 months ago
106
    -k, --keep-originals         Copy files instead of moving
Bogdan Timofte authored 3 weeks ago
107
    --verify-mode MODE           size|strict|none (default: size)
Bogdan Timofte authored 2 weeks ago
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
Bogdan Timofte authored 9 months ago
111
    --collect-unsortable         Put files without dates into DEST/unsortable
Bogdan Timofte authored 8 months ago
112
    --keep-empty-dirs            Keep empty directories after processing
113
    --dry-run                    Show actions without changing files
Bogdan Timofte authored 9 months ago
114
    -v, --verbose                Verbose output
115
    -h, --help                   Show this help
116
    --version                    Show version
117

            
118
Examples:
119
    $0 -s /path/to/photos
120
    $0 -s /path/to/DCIM -d /mnt/sorted --dry-run
121

            
122
Dependencies:
123
    exiftool (required). mediainfo and file are optional.
Bogdan Timofte authored 9 months ago
124
EOF
125
}
126

            
Bogdan Timofte authored 9 months ago
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

            
Bogdan Timofte authored 9 months ago
130
# Function to show version
131
show_version() {
132
    echo "$SCRIPT_NAME v$VERSION"
133
    echo "A comprehensive media file organizer with timezone support"
134
}
135

            
136
# Function to check dependencies
137
check_dependencies() {
138
    local missing_deps=()
139

            
140
    # Check for required dependencies
141
    if ! command -v exiftool &> /dev/null; then
142
        missing_deps+=("exiftool")
143
    fi
144

            
145
    # Check for optional dependencies
146
    local optional_missing=()
147
    if ! command -v mediainfo &> /dev/null; then
148
        optional_missing+=("mediainfo")
149
    fi
150

            
151
    if ! command -v file &> /dev/null; then
152
        optional_missing+=("file")
153
    fi
154

            
155
    if [[ ${#missing_deps[@]} -gt 0 ]]; then
156
        print_color "$RED" "ERROR: Missing required dependencies:"
157
        for dep in "${missing_deps[@]}"; do
158
            echo "  - $dep"
159
        done
160
        echo ""
161
        echo "Installation instructions:"
162
        echo "  macOS: brew install exiftool"
163
        echo "  Ubuntu/Debian: sudo apt-get install libimage-exiftool-perl"
164
        echo "  CentOS/RHEL: sudo yum install perl-Image-ExifTool"
165
        echo "  Arch: sudo pacman -S perl-image-exiftool"
166
        exit 1
167
    fi
168

            
169
    if [[ ${#optional_missing[@]} -gt 0 && $VERBOSE -eq 1 ]]; then
170
        log_message "Optional dependencies not found (functionality may be limited): ${optional_missing[*]}" "WARNING"
171
    fi
172

            
173
    log_message "All required dependencies found" "SUCCESS"
174
}
175

            
Bogdan Timofte authored 9 months ago
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)
Bogdan Timofte authored 9 months ago
193
get_file_size() {
194
    local file="$1"
195
    if [[ -f "$file" ]]; then
Bogdan Timofte authored 9 months ago
196
        # Try GNU stat
197
        if stat -c%s "$file" >/dev/null 2>&1; then
Bogdan Timofte authored 9 months ago
198
            stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null
Bogdan Timofte authored 9 months ago
199
        elif stat -f%z "$file" >/dev/null 2>&1; then
200
            stat -f%z "$file" 2>/dev/null
Bogdan Timofte authored 9 months ago
201
        else
202
            # Fallback to ls
Bogdan Timofte authored 9 months ago
203
            ls -ln "$file" | awk '{print $5}'
Bogdan Timofte authored 9 months ago
204
        fi
205
    else
206
        echo "0"
207
    fi
208
}
209

            
Bogdan Timofte authored 9 months ago
210
# (Removed checksum/prefix/conflict helper functions to revert to pre-conflict-resolution behavior)
211

            
212
# Safe move/copy helpers: filter out benign "set flags (was: ...): Operation not supported"
213
# which appears when moving files onto filesystems that don't support BSD file flags
214
# (macOS mv may try to preserve flags and print this warning while still succeeding).
215
safe_mv() {
216
    local src="$1" dst="$2"
217
    # Redirect stderr through a filter that removes the known benign message
218
    mv "$src" "$dst" 2> >(grep -v "set flags (was:" >&2)
219
    return $?
220
}
221

            
Bogdan Timofte authored 2 weeks ago
222
destination_path_reserved() {
223
    local candidate="$1"
224
    local reserved_path
225
    for reserved_path in "${RESERVED_DESTINATION_PATHS[@]}"; do
226
        if [[ "$reserved_path" == "$candidate" ]]; then
227
            return 0
228
        fi
229
    done
230
    return 1
231
}
Bogdan Timofte authored 9 months ago
232

            
Bogdan Timofte authored 2 weeks ago
233
destination_path_unavailable() {
234
    local candidate="$1"
235
    [[ -e "$candidate" ]] || destination_path_reserved "$candidate"
236
}
237

            
238
reserve_destination_path() {
239
    local candidate="$1"
240
    if [[ -n "$candidate" ]] && ! destination_path_reserved "$candidate"; then
241
        RESERVED_DESTINATION_PATHS+=("$candidate")
242
    fi
243
}
244

            
245
prompt_destination_conflict_choice() {
246
    local source_file="$1"
247
    local desired_path="$2"
248
    local choice
249

            
250
    if [[ ! -r /dev/tty || ! -w /dev/tty ]]; then
251
        return 1
252
    fi
253

            
254
    {
255
        print_color "$YELLOW" "Destination already exists:"
256
        echo "  Source:      $source_file"
257
        echo "  Destination: $desired_path"
258
        echo ""
259
        echo "Choose conflict action:"
260
        echo "  [s] suffix once"
261
        echo "  [S] suffix for all similar conflicts"
262
        echo "  [k] skip once"
263
        echo "  [K] skip all similar conflicts"
264
        echo "  [a] abort import"
265
    } > /dev/tty
266

            
267
    while true; do
268
        printf "Action [s/S/k/K/a]: " > /dev/tty
269
        IFS= read -r choice < /dev/tty || return 1
270
        case "$choice" in
271
            s|"")
272
                echo "suffix"
273
                return 0
274
                ;;
275
            S)
276
                echo "suffix_all"
277
                return 0
278
                ;;
279
            k)
280
                echo "skip"
281
                return 0
282
                ;;
283
            K)
284
                echo "skip_all"
285
                return 0
286
                ;;
287
            a|A)
288
                echo "abort"
289
                return 0
290
                ;;
291
            *)
292
                print_color "$YELLOW" "Please choose s, S, k, K, or a." > /dev/tty
293
                ;;
294
        esac
295
    done
296
}
297

            
298
resolve_destination_conflict() {
299
    local desired_path="$1"
300
    local source_file="$2"
301
    local resolved_path choice
302
    RESOLVED_DESTINATION_PATH=""
303

            
304
    if [[ -z "$desired_path" ]]; then
305
        return 1
306
    fi
307

            
308
    if ! destination_path_unavailable "$desired_path"; then
309
        RESOLVED_DESTINATION_PATH="$desired_path"
310
        reserve_destination_path "$RESOLVED_DESTINATION_PATH"
311
        return 0
312
    fi
313

            
314
    if [[ "$CONFLICT_APPLY_ALL" == "skip" ]]; then
315
        return 3
316
    fi
317

            
318
    if [[ "$CONFLICT_APPLY_ALL" == "suffix" || $UNATTENDED -eq 1 ]]; then
319
        resolved_path=$(ensure_unique_destination_path "$desired_path")
320
        if [[ -z "$resolved_path" ]]; then
321
            return 1
322
        fi
323
        RESOLVED_DESTINATION_PATH="$resolved_path"
324
        reserve_destination_path "$RESOLVED_DESTINATION_PATH"
325
        return 0
326
    fi
327

            
328
    choice=$(prompt_destination_conflict_choice "$source_file" "$desired_path")
329
    if [[ $? -ne 0 || -z "$choice" ]]; then
330
        log_message "Cannot prompt for destination conflict; using unattended numeric suffix mode" "WARNING"
331
        resolved_path=$(ensure_unique_destination_path "$desired_path")
332
        if [[ -z "$resolved_path" ]]; then
333
            return 1
334
        fi
335
        RESOLVED_DESTINATION_PATH="$resolved_path"
336
        reserve_destination_path "$RESOLVED_DESTINATION_PATH"
337
        return 0
338
    fi
339

            
340
    case "$choice" in
341
        suffix)
342
            resolved_path=$(ensure_unique_destination_path "$desired_path")
343
            ;;
344
        suffix_all)
345
            CONFLICT_APPLY_ALL="suffix"
346
            resolved_path=$(ensure_unique_destination_path "$desired_path")
347
            ;;
348
        skip)
349
            return 3
350
            ;;
351
        skip_all)
352
            CONFLICT_APPLY_ALL="skip"
353
            return 3
354
            ;;
355
        abort)
356
            return 4
357
            ;;
358
        *)
359
            return 1
360
            ;;
361
    esac
362

            
363
    if [[ -z "$resolved_path" ]]; then
364
        return 1
365
    fi
366

            
367
    RESOLVED_DESTINATION_PATH="$resolved_path"
368
    reserve_destination_path "$RESOLVED_DESTINATION_PATH"
369
    return 0
370
}
371

            
372
extract_filesystem_date() {
373
    # Returns yyyy-mm-dd hh:mm:ss based on filesystem mtime.
374
    # We intentionally use mtime (not birthtime) because birthtime isn't preserved by copies
375
    # across filesystems, while mtime can be preserved via `cp -p`.
376
    local file="$1"
377
    if [[ ! -e "$file" ]]; then
378
        return 2
379
    fi
380

            
381
    local epoch=""
382

            
383
    if [[ "$OSTYPE" == "darwin"* ]]; then
384
        epoch=$(stat -f %m "$file" 2>/dev/null || echo "")
385
        [[ -n "$epoch" ]] || return 2
386
        date -j -r "$epoch" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || return 2
387
        return 0
388
    else
389
        epoch=$(stat -c %Y "$file" 2>/dev/null || echo "")
390
        [[ -n "$epoch" ]] || return 2
391
        date -d "@$epoch" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || return 2
392
        return 0
393
    fi
394
}
395

            
396
filesystem_date_reference() {
397
    local file="$1"
398
    local dir base stem ext sidecar_ext sidecar
399
    dir=$(dirname "$file")
400
    base=$(basename "$file")
401
    stem="${base%.*}"
402
    ext="${base##*.}"
403

            
404
    if [[ "$ext" =~ ^([Mm][Pp]4)$ ]]; then
405
        for sidecar_ext in THM thm LRV lrv; do
406
            sidecar="$dir/$stem.$sidecar_ext"
407
            if [[ -f "$sidecar" ]]; then
408
                echo "$sidecar"
409
                return 0
410
            fi
411
        done
412
    fi
413

            
414
    echo "$file"
415
}
416

            
417
is_gopro_media_file() {
418
    local filename
419
    filename=$(basename "$1")
420
    [[ "$filename" =~ ^G[HPX][0-9]{6}\.[Mm][Pp]4$ ]]
421
}
422

            
423
should_prefer_gopro_filesystem_date() {
424
    local file="$1"
425

            
426
    is_gopro_media_file "$file"
427
}
428

            
429
filesystem_date_source_label() {
430
    local file="$1"
431
    local reference="$2"
432

            
433
    if is_gopro_media_file "$file"; then
434
        echo "Filesystem:$(basename "$reference")"
435
    elif [[ "$reference" != "$file" ]]; then
436
        echo "Filesystem:$(basename "$reference")"
437
    else
438
        echo "Filesystem"
439
    fi
440
}
441

            
442
date_to_exiftool_format() {
443
    # yyyy-mm-dd hh:mm:ss -> yyyy:mm:dd hh:mm:ss
444
    local s="$1"
445
    if [[ "$s" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})[[:space:]]+([0-9]{2}):([0-9]{2}):([0-9]{2})$ ]]; then
446
        echo "${BASH_REMATCH[1]}:${BASH_REMATCH[2]}:${BASH_REMATCH[3]} ${BASH_REMATCH[4]}:${BASH_REMATCH[5]}:${BASH_REMATCH[6]}"
447
        return 0
448
    fi
449
    return 1
450
}
451

            
452
sync_destination_metadata_to_date() {
453
    local file="$1"
454
    local date_str="$2" # yyyy-mm-dd hh:mm:ss
455

            
456
    local exif_dt
457
    exif_dt=$(date_to_exiftool_format "$date_str") || return 1
458

            
459
    exiftool -overwrite_original \
460
        "-CreateDate=$exif_dt" \
461
        "-DateTimeOriginal=$exif_dt" \
462
        "-DateTime=$exif_dt" \
463
        "-ModifyDate=$exif_dt" \
464
        "-MediaCreateDate=$exif_dt" \
465
        "-TrackCreateDate=$exif_dt" \
466
        "-QuickTime:CreateDate=$exif_dt" \
467
        "-QuickTime:ModifyDate=$exif_dt" \
468
        "$file" >/dev/null 2>&1
469
    return 0
470
}
471

            
472
verify_synced_metadata_date() {
473
    local file="$1"
474
    local expected_date="$2"
475

            
476
    local metadata_date
477
    metadata_date=$(exiftool -api QuickTimeUTC=0 -s -s -s -QuickTime:CreateDate "$file" 2>/dev/null | head -1)
478
    if [[ -z "$metadata_date" ]]; then
479
        metadata_date=$(exiftool -api QuickTimeUTC=0 -s -s -s -CreateDate "$file" 2>/dev/null | head -1)
480
    fi
481

            
482
    if [[ "$metadata_date" =~ ^([0-9]{4}):([0-9]{2}):([0-9]{2})[[:space:]]+([0-9]{2}):([0-9]{2}):([0-9]{2}) ]]; then
483
        metadata_date="${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]} ${BASH_REMATCH[4]}:${BASH_REMATCH[5]}:${BASH_REMATCH[6]}"
484
    fi
485

            
486
    if [[ "$metadata_date" != "$expected_date" ]]; then
487
        log_message "Destination metadata sync mismatch: expected $expected_date, got ${metadata_date:-none} for $file" "ERROR"
488
        return 1
489
    fi
490

            
491
    return 0
492
}
493

            
494
should_sync_imported_metadata() {
495
    local original_filename="$1"
496
    local date_source="$2"
497

            
498
    if [[ $SYNC_METADATA -eq 1 ]]; then
499
        return 0
500
    fi
501

            
502
    if [[ "$date_source" == Filesystem* ]] && is_gopro_media_file "$original_filename"; then
503
        return 0
504
    fi
505

            
506
    return 1
Bogdan Timofte authored 9 months ago
507
}
508

            
Bogdan Timofte authored 3 weeks ago
509
verify_copied_file() {
510
    local src="$1"
511
    local dst="$2"
512
    local expected_date="$3"
513

            
514
    if [[ ! -f "$dst" ]]; then
515
        log_message "Verified copy missing at destination: $dst" "ERROR"
516
        return 1
517
    fi
518

            
519
    local src_size dst_size
520
    src_size=$(get_file_size "$src")
521
    dst_size=$(get_file_size "$dst")
522
    if [[ "$src_size" != "$dst_size" ]]; then
523
        log_message "Size mismatch after copy: $src ($src_size) != $dst ($dst_size)" "ERROR"
524
        return 1
525
    fi
526

            
527
    if [[ "$VERIFY_MODE" == "strict" ]]; then
528
        if ! cmp -s "$src" "$dst"; then
529
            log_message "Content mismatch after copy: $src -> $dst" "ERROR"
530
            return 1
531
        fi
532
    elif [[ "$VERIFY_MODE" == "none" ]]; then
533
        return 0
534
    fi
535

            
536
    if [[ -n "$expected_date" ]]; then
537
        local destination_date_info
538
        destination_date_info=$(extract_file_date "$dst")
539
        local extract_status=$?
540
        if [[ $extract_status -ne 0 || -z "$destination_date_info" ]]; then
541
            log_message "Destination metadata validation failed: $dst" "ERROR"
542
            return 1
543
        fi
544

            
545
        local destination_date="${destination_date_info%|*}"
546
        if [[ "$destination_date" != "$expected_date" ]]; then
547
            log_message "Destination metadata mismatch: expected $expected_date, got $destination_date for $dst" "ERROR"
548
            return 1
549
        fi
550
    fi
551

            
552
    return 0
553
}
554

            
555
remove_source_file() {
556
    local src="$1"
557
    rm -f "$src"
558
}
559

            
560
copy_with_verification() {
561
    local src="$1"
562
    local dst="$2"
563
    local expected_date="$3"
564

            
Bogdan Timofte authored 2 weeks ago
565
    if [[ -e "$dst" ]]; then
566
        log_message "Refusing to overwrite existing destination: $dst" "ERROR"
Bogdan Timofte authored 3 weeks ago
567
        return 1
568
    fi
569

            
Bogdan Timofte authored 2 weeks ago
570
    local dst_dir tmp
571
    dst_dir=$(dirname "$dst")
572
    tmp=$(mktemp "$dst_dir/.media-importer.$(basename "$dst").tmp.XXXXXX") || return 1
573
    rm -f "$tmp"
574

            
575
    if ! safe_cp "$src" "$tmp"; then
576
        rm -f "$tmp"
577
        return 1
578
    fi
579

            
580
    if ! verify_copied_file "$src" "$tmp" "$expected_date"; then
581
        rm -f "$tmp"
582
        return 1
583
    fi
584

            
585
    if [[ -e "$dst" ]]; then
586
        log_message "Destination appeared during copy, refusing to overwrite: $dst" "ERROR"
587
        rm -f "$tmp"
588
        return 1
589
    fi
590

            
591
    if ! safe_mv "$tmp" "$dst"; then
592
        rm -f "$tmp"
593
        return 1
594
    fi
595

            
596
    if [[ ! -f "$dst" ]]; then
597
        log_message "Copied file missing after final move: $dst" "ERROR"
Bogdan Timofte authored 3 weeks ago
598
        return 1
599
    fi
600

            
601
    return 0
602
}
603

            
604
verified_move_file() {
605
    local src="$1"
606
    local dst="$2"
607
    local expected_date="$3"
608

            
609
    if ! copy_with_verification "$src" "$dst" "$expected_date"; then
610
        return 1
611
    fi
612

            
613
    if ! remove_source_file "$src"; then
614
        log_message "Copied and verified destination, but failed to remove source: $src" "ERROR"
615
        return 1
616
    fi
617

            
618
    return 0
619
}
620

            
Bogdan Timofte authored 9 months ago
621
# Function to format file size
622
format_size() {
623
    local size=$1
624
    if (( size < 1024 )); then
625
        echo "${size}B"
626
    elif (( size < 1048576 )); then
627
        echo "$(( size / 1024 ))KB"
628
    elif (( size < 1073741824 )); then
629
        echo "$(( size / 1048576 ))MB"
630
    else
631
        echo "$(( size / 1073741824 ))GB"
632
    fi
633
}
634

            
Bogdan Timofte authored 2 weeks ago
635
format_duration() {
636
    local total_seconds=$1
637
    local hours=$((total_seconds / 3600))
638
    local minutes=$(((total_seconds % 3600) / 60))
639
    local seconds=$((total_seconds % 60))
640

            
641
    if (( hours > 0 )); then
642
        printf "%dh %02dm %02ds" "$hours" "$minutes" "$seconds"
643
    elif (( minutes > 0 )); then
644
        printf "%dm %02ds" "$minutes" "$seconds"
645
    else
646
        printf "%ds" "$seconds"
647
    fi
648
}
649

            
650
format_processing_rate() {
651
    local files_count="$1"
652
    local bytes_count="$2"
653
    local elapsed_seconds="$3"
654

            
655
    awk -v files="$files_count" -v bytes="$bytes_count" -v seconds="$elapsed_seconds" '
656
        BEGIN {
657
            if (seconds <= 0 || files <= 0) {
658
                exit
659
            }
660

            
661
            files_per_second = files / seconds
662
            files_per_minute = files * 60 / seconds
663
            mb_per_second = bytes / seconds / 1048576
664

            
665
            if (files_per_second >= 1) {
666
                printf "%.2f files/sec, %.2f MB/sec", files_per_second, mb_per_second
667
            } else {
668
                printf "%.2f files/min, %.2f MB/sec", files_per_minute, mb_per_second
669
            }
670
        }
671
    '
672
}
673

            
Bogdan Timofte authored 9 months ago
674
# Function to extract date from file
675
extract_file_date() {
676
    local file="$1"
677
    local create_date=""
678
    local date_source=""
679
    local exif_found=0
Bogdan Timofte authored 2 weeks ago
680

            
681
    # Filesystem authoritative mode, and GoPro media in auto mode.
682
    # GoPro fallback order is THM, LRV, then the MP4 filesystem timestamp.
683
    if [[ "$DATE_SOURCE" == "filesystem" ]] || { [[ "$DATE_SOURCE" == "auto" ]] && should_prefer_gopro_filesystem_date "$file"; }; then
684
        local filesystem_reference
685
        filesystem_reference=$(filesystem_date_reference "$file")
686
        create_date=$(extract_filesystem_date "$filesystem_reference") || return 2
687
        echo "$create_date|$(filesystem_date_source_label "$file" "$filesystem_reference")"
688
        return 0
689
    fi
690

            
Bogdan Timofte authored 9 months ago
691
    # Try to get creation date from EXIF data
692
    local exif_output=$(exiftool -G1 -s -CreateDate -DateTimeOriginal -DateTime "$file" 2>/dev/null)
693
    if [[ -n "$exif_output" ]]; then
694
        # Parse the exiftool output to find the best date
695
        while IFS= read -r line; do
696
            if [[ "$line" =~ ^\[([^\]]+)\][[:space:]]*([^:]+)[[:space:]]*:[[:space:]]*(.+)$ ]]; then
697
                local group="${BASH_REMATCH[1]}"
698
                local tag="${BASH_REMATCH[2]}"
699
                local value="${BASH_REMATCH[3]}"
700
                # Trim spaces from tag name
701
                tag=$(echo "$tag" | sed 's/[[:space:]]*$//')
702
                # Prefer DateTimeOriginal, then CreateDate, then DateTime
703
                if [[ "$tag" == "DateTimeOriginal" && -z "$create_date" ]]; then
704
                    create_date="$value"
705
                    date_source="$group:$tag"
706
                    exif_found=1
707
                elif [[ "$tag" == "CreateDate" && "$date_source" != *"DateTimeOriginal"* ]]; then
708
                    create_date="$value"
709
                    date_source="$group:$tag"
710
                    exif_found=1
711
                elif [[ "$tag" == "DateTime" && -z "$create_date" ]]; then
712
                    create_date="$value"
713
                    date_source="$group:$tag"
714
                    exif_found=1
715
                fi
716
            fi
717
        done <<< "$exif_output"
718
    fi
719
    # If no EXIF date found, try mediainfo for video files
720
    if [[ -z "$create_date" ]] && command -v mediainfo &> /dev/null; then
721
        local media_date=$(mediainfo --Inform="General;%Recorded_Date%" "$file" 2>/dev/null)
722
        if [[ -n "$media_date" && "$media_date" != "0000-00-00 00:00:00" ]]; then
723
            create_date="$media_date"
724
            date_source="MediaInfo:Recorded_Date"
725
        fi
726
    fi
Bogdan Timofte authored 2 weeks ago
727

            
728
    # In auto mode, if metadata is missing/unreliable, fall back to filesystem timestamps
729
    if [[ -z "$create_date" && "$DATE_SOURCE" == "auto" ]]; then
730
        local filesystem_reference
731
        filesystem_reference=$(filesystem_date_reference "$file")
732
        create_date=$(extract_filesystem_date "$filesystem_reference") || return 2
733
        date_source=$(filesystem_date_source_label "$file" "$filesystem_reference")
734
    fi
735

            
Bogdan Timofte authored 9 months ago
736
    # If no EXIF or mediainfo date found, return failure
737
    if [[ -z "$create_date" ]]; then
738
        return 2  # No date metadata found
739
    fi
740

            
741
    # Convert EXIF format (YYYY:MM:DD HH:MM:SS) to standard format
742
    # Always output as yyyy-mm-dd hh:mm:ss (pad single digits)
743
    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
744
        year="${BASH_REMATCH[1]}"
745
        month=$(printf "%02d" "$((10#${BASH_REMATCH[2]}))")
746
        day=$(printf "%02d" "$((10#${BASH_REMATCH[3]}))")
747
        hour=$(printf "%02d" "$((10#${BASH_REMATCH[4]}))")
748
        minute=$(printf "%02d" "$((10#${BASH_REMATCH[5]}))")
749
        second=$(printf "%02d" "$((10#${BASH_REMATCH[6]}))")
750
        create_date="$year-$month-$day $hour:$minute:$second"
751
    else
752
        # Try to convert yyyy-mm-dd hh:mm:ss (already correct)
753
        if [[ "$create_date" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})[[:space:]]*([0-9]{2}):([0-9]{2}):([0-9]{2})$ ]]; then
754
            # Already correct
755
            :
756
        else
757
            print_color "$RED" "Error: Cannot parse date '$create_date'" >&2
758
            return 2
759
        fi
760
    fi
761

            
762
    # For QuickTime files, the CreateDate is in UTC and needs conversion to local time
763
    if [[ "$date_source" == *"QuickTime"* ]]; then
764
        # Convert UTC time to local time
765
        if [[ "$OSTYPE" == "darwin"* ]]; then
766
            # On macOS, use TZ=UTC to interpret the input time as UTC
767
            local utc_timestamp=$(TZ=UTC date -j -f "%Y-%m-%d %H:%M:%S" "$create_date" "+%s" 2>/dev/null)
768
            if [[ -n "$utc_timestamp" ]]; then
769
                create_date=$(date -j -r "$utc_timestamp" "+%Y-%m-%d %H:%M:%S" 2>/dev/null)
770
                date_source="$date_source (converted from UTC)"
771
            fi
772
        else
773
            local utc_timestamp=$(date -d "$create_date UTC" "+%s" 2>/dev/null)
774
            if [[ -n "$utc_timestamp" ]]; then
775
                create_date=$(date -d "@$utc_timestamp" "+%Y-%m-%d %H:%M:%S" 2>/dev/null)
776
                date_source="$date_source (converted from UTC)"
777
            fi
778
        fi
779
    fi
780

            
781
    echo "$create_date|$date_source"
782
    return 0
783
}
784

            
785
# Function to generate destination path based on organization pattern
786
generate_destination_path() {
787
    local date_str="$1"
788
    local original_filename="$2"
789
    local base_destination="$3"
790

            
791
    # Extract date components - handle both GNU and BSD date
792
    local year month day hour minute second
793
    if [[ "$OSTYPE" == "darwin"* ]]; then
794
        # macOS (BSD date) - use more robust regex (allow single-digit month/day, tolerate extra spaces)
795
        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
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
        else
803
            return 1
804
        fi
805
    else
806
        # Linux (GNU date)
807
        year=$(date -d "$date_str" "+%Y" 2>/dev/null)
808
        month=$(date -d "$date_str" "+%m" 2>/dev/null)
809
        day=$(date -d "$date_str" "+%d" 2>/dev/null)
810
        hour=$(date -d "$date_str" "+%H" 2>/dev/null)
811
        minute=$(date -d "$date_str" "+%M" 2>/dev/null)
812
        second=$(date -d "$date_str" "+%S" 2>/dev/null)
813
    fi
814

            
815
    if [[ -z "$year" || -z "$month" || -z "$day" ]]; then
816
        return 1
817
    fi
818

            
819
    # Get file extension
820
    local extension="${original_filename##*.}"
821
    local lowercase_ext=$(echo "$extension" | tr '[:upper:]' '[:lower:]')
822

            
823
    # Generate path and filename based on organization pattern
824
    local dir_path=""
825
    local filename=""
Bogdan Timofte authored 9 months ago
826

            
827
    # If no organization specified, use flat destination (base) and choose filename per mode
828
    if [[ -z "$ORGANIZATION" ]]; then
Bogdan Timofte authored 9 months ago
829
        dir_path="$base_destination"
Bogdan Timofte authored 9 months ago
830
        if [[ "$FILENAME_MODE" == "orig" ]]; then
831
            filename="$original_filename"
832
        else
833
            # full or auto both map to full date for flat layout
834
            filename="${year}-${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
835
        fi
836
        echo "$dir_path/$filename"
837
        return 0
838
    fi
839

            
840
    case "$ORGANIZATION" in
Bogdan Timofte authored 9 months ago
841
            "y")
842
                dir_path="$base_destination/$year"
843
                filename="${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
844
                ;;
845
            "m")
846
                dir_path="$base_destination/$year/$month"
847
                filename="${day}_${hour}-${minute}-${second}.${lowercase_ext}"
848
                ;;
849
            "d")
850
                dir_path="$base_destination/$year/$month/$day"
851
                filename="${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
852
                ;;
853
            "h")
854
                dir_path="$base_destination/$year/$month/$day/$hour"
855
                filename="${minute}-${second}.${lowercase_ext}"
856
                ;;
Bogdan Timofte authored 9 months ago
857
            "ym")
858
                # Single folder per month named yyyy-mm; filename includes day and time
859
                dir_path="$base_destination/${year}-${month}"
860
                filename="${day}_${hour}-${minute}-${second}.${lowercase_ext}"
861
                ;;
Bogdan Timofte authored 8 months ago
862
            "ymd")
Bogdan Timofte authored 9 months ago
863
                # Single folder per day named yyyy-mm-dd; filename is time
864
                dir_path="$base_destination/${year}-${month}-${day}"
865
                filename="${hour}-${minute}-${second}.${lowercase_ext}"
866
                ;;
Bogdan Timofte authored 9 months ago
867
            *)
868
                log_message "Invalid organization pattern: $ORGANIZATION" "ERROR"
869
                return 1
870
                ;;
871
        esac
Bogdan Timofte authored 9 months ago
872

            
873
    # Apply filename mode overrides
874
    case "$FILENAME_MODE" in
875
        orig)
876
            filename="$original_filename"
877
            ;;
878
        full)
879
            filename="${year}-${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
880
            ;;
881
        auto)
882
            # keep the auto-generated filename from the organization case
883
            ;;
884
        *)
885
            # fallback to full
886
            filename="${year}-${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
887
            ;;
888
    esac
889

            
Bogdan Timofte authored 9 months ago
890
    echo "$dir_path/$filename"
891
    return 0
892
}
893

            
894
# Function to find files matching patterns
895
find_source_files() {
Bogdan Timofte authored 9 months ago
896
    # Emit absolute file paths for all media files under sources, excluding DESTINATION when it is inside a source
897
    local abs_dest=""
898
    if [[ -n "$DESTINATION" ]]; then
899
        abs_dest=$(cd "$DESTINATION" 2>/dev/null && pwd) || abs_dest="$DESTINATION"
900
    fi
901

            
902
    # Build -iname expression for find
903
    local ext_expr=""
904
    for ext in "${MEDIA_EXTENSIONS[@]}"; do
905
        if [[ -n "$ext_expr" ]]; then
906
            ext_expr="$ext_expr -o"
907
        fi
908
        ext_expr="$ext_expr -iname $ext"
909
    done
910

            
Bogdan Timofte authored 9 months ago
911
    if [[ ${#SOURCE_PATTERNS[@]} -eq 0 ]]; then
Bogdan Timofte authored 9 months ago
912
        # Default: scan current directory
913
        local start_dot="."
914
        local abs_current
915
        abs_current=$(pwd)
916
        local find_cmd=(find -L "$start_dot" -type f)
917
        # If dest is inside cwd, add exclusion
918
        if [[ -n "$abs_dest" && "$abs_dest" == "$abs_current"* ]]; then
919
            find_cmd+=( ! -path "$abs_dest/*" ! -path "$abs_dest" )
Bogdan Timofte authored 9 months ago
920
        fi
Bogdan Timofte authored 9 months ago
921
        # Add expression
922
        # shellcheck disable=SC2068
Bogdan Timofte authored 2 weeks ago
923
        "${find_cmd[@]}" ! -name '._*' \( $ext_expr \) 2>/dev/null || true
Bogdan Timofte authored 9 months ago
924
    else
Bogdan Timofte authored 9 months ago
925
        # Scan each provided source
926
        for src in "${SOURCE_PATTERNS[@]}"; do
927
            if [[ -f "$src" ]]; then
Bogdan Timofte authored 2 weeks ago
928
                if [[ "$(basename "$src")" == ._* ]]; then
929
                    continue
930
                fi
Bogdan Timofte authored 9 months ago
931
                # single file - skip if it's inside dest
932
                local abs_file
933
                abs_file=$(cd "$(dirname "$src")" 2>/dev/null && pwd)/$(basename "$src")
934
                if [[ -n "$abs_dest" && "$abs_file" == "$abs_dest"* ]]; then
935
                    continue
936
                fi
937
                echo "$abs_file"
938
            elif [[ -d "$src" ]]; then
939
                local abs_src
940
                abs_src=$(cd "$src" 2>/dev/null && pwd)
941
                if [[ -n "$abs_src" ]]; then
942
                    if [[ -n "$abs_dest" && "$abs_dest" == "$abs_src"* ]]; then
Bogdan Timofte authored 2 weeks ago
943
                        find -L "$abs_src" -type f ! -name '._*' \( $ext_expr \) ! -path "$abs_dest/*" ! -path "$abs_dest" 2>/dev/null || true
Bogdan Timofte authored 9 months ago
944
                    else
Bogdan Timofte authored 2 weeks ago
945
                        find -L "$abs_src" -type f ! -name '._*' \( $ext_expr \) 2>/dev/null || true
Bogdan Timofte authored 9 months ago
946
                    fi
Bogdan Timofte authored 9 months ago
947
                else
948
                    print_color "$YELLOW" "Warning: Could not resolve source directory: $src"
Bogdan Timofte authored 9 months ago
949
                fi
Bogdan Timofte authored 9 months ago
950
            else
951
                print_color "$YELLOW" "Warning: Source path does not exist or is not a file/directory: $src"
Bogdan Timofte authored 9 months ago
952
            fi
953
        done
954
    fi
955
}
956

            
957
# Function to process a single file
958
process_file() {
959
    local file="$1"
960
    local file_size=$(get_file_size "$file")
Bogdan Timofte authored 2 weeks ago
961
    local file_label
962
    file_label="$(basename "$file")"
Bogdan Timofte authored 9 months ago
963
    TOTAL_SIZE=$((TOTAL_SIZE + file_size))
964

            
Bogdan Timofte authored 2 weeks ago
965
    if [[ $TOTAL_FILES -gt 0 && $CURRENT_FILE_INDEX -gt 0 ]]; then
966
        print_color "$BLUE" "Processing [$CURRENT_FILE_INDEX/$TOTAL_FILES]: $file_label ($(format_size "$file_size"))"
967
    else
968
        print_color "$BLUE" "Processing: $file_label ($(format_size "$file_size"))"
969
    fi
Bogdan Timofte authored 9 months ago
970
    log_message "Processing: $file" "INFO"
971

            
972
    # Extract date information
973
    local date_info=$(extract_file_date "$file")
974
        local extract_status=$?
975
        if [[ $extract_status -eq 2 ]]; then
976
            if [[ $COLLECT_UNSORTABLE -eq 1 ]]; then
977
                local unsortable_dir="$DESTINATION/unsortable"
978
                mkdir -p "$unsortable_dir"
979
                local unsortable_path="$unsortable_dir/$(basename "$file")"
Bogdan Timofte authored 2 weeks ago
980
                local desired_unsortable_path="$unsortable_path"
981
                local unsortable_conflict_status
982
                resolve_destination_conflict "$unsortable_path" "$file"
983
                unsortable_conflict_status=$?
984
                if [[ $unsortable_conflict_status -eq 0 ]]; then
985
                    unsortable_path="$RESOLVED_DESTINATION_PATH"
986
                    if [[ "$unsortable_path" != "$desired_unsortable_path" ]]; then
987
                        log_message "Destination already exists or is already planned: $desired_unsortable_path - using: $unsortable_path" "WARNING"
988
                    fi
989
                elif [[ $unsortable_conflict_status -eq 3 ]]; then
990
                    log_message "Destination conflict skipped: $desired_unsortable_path" "WARNING"
991
                    SKIPPED_FILES=$((SKIPPED_FILES + 1))
992
                    return 1
993
                elif [[ $unsortable_conflict_status -eq 4 ]]; then
994
                    log_message "Import aborted by user at destination conflict: $desired_unsortable_path" "ERROR"
995
                    ERROR_FILES=$((ERROR_FILES + 1))
996
                    FATAL_ERROR=1
997
                    return 2
998
                else
999
                    log_message "Could not resolve a unique destination path for $file (wanted: $desired_unsortable_path)" "ERROR"
1000
                    ERROR_FILES=$((ERROR_FILES + 1))
1001
                    return 1
1002
                fi
Bogdan Timofte authored 9 months ago
1003
                if [[ $DRY_RUN -eq 1 ]]; then
1004
                    print_color "$BLUE" "Would move unsortable: $file -> $unsortable_path"
1005
                else
Bogdan Timofte authored 3 weeks ago
1006
                    if verified_move_file "$file" "$unsortable_path" ""; then
Bogdan Timofte authored 9 months ago
1007
                        log_message "Unsortable: $file -> $unsortable_path" "SUCCESS"
1008
                    else
Bogdan Timofte authored 3 weeks ago
1009
                        log_message "Failed to move unsortable file after verification: $file" "ERROR"
Bogdan Timofte authored 9 months ago
1010
                    fi
1011
                fi
1012
                SKIPPED_FILES=$((SKIPPED_FILES + 1))
1013
            else
1014
                log_message "Could not extract date from $file - skipping" "WARNING"
1015
                SKIPPED_FILES=$((SKIPPED_FILES + 1))
1016
            fi
1017
            return 1
1018
        elif [[ $extract_status -ne 0 ]] || [[ -z "$date_info" ]]; then
1019
            log_message "Could not extract date from $file - skipping" "WARNING"
1020
            SKIPPED_FILES=$((SKIPPED_FILES + 1))
1021
            return 1
1022
        fi
1023
        local date_str="${date_info%|*}"
1024
        local date_source="${date_info#*|}"
1025
        log_message "Date: $date_str (from $date_source)" "INFO"
1026
        # Generate destination path
Bogdan Timofte authored 2 weeks ago
1027
        local original_basename
1028
        original_basename="$(basename "$file")"
1029
        local dest_path
1030
        dest_path=$(generate_destination_path "$date_str" "$original_basename" "$DESTINATION")
Bogdan Timofte authored 9 months ago
1031
        if [[ $? -ne 0 ]] || [[ -z "$dest_path" ]]; then
1032
            log_message "Could not generate destination path for $file" "ERROR"
1033
            ERROR_FILES=$((ERROR_FILES + 1))
1034
            FATAL_ERROR=1
1035
            return 2
1036
    fi
Bogdan Timofte authored 2 weeks ago
1037

            
1038
    local desired_dest_path="$dest_path"
1039
    local conflict_status
1040
    resolve_destination_conflict "$dest_path" "$file"
1041
    conflict_status=$?
1042
    if [[ $conflict_status -eq 0 ]]; then
1043
        dest_path="$RESOLVED_DESTINATION_PATH"
1044
    elif [[ $conflict_status -eq 3 ]]; then
1045
        log_message "Destination conflict skipped: $desired_dest_path" "WARNING"
1046
        SKIPPED_FILES=$((SKIPPED_FILES + 1))
1047
        return 1
1048
    elif [[ $conflict_status -eq 4 ]]; then
1049
        log_message "Import aborted by user at destination conflict: $desired_dest_path" "ERROR"
1050
        ERROR_FILES=$((ERROR_FILES + 1))
1051
        FATAL_ERROR=1
1052
        return 2
1053
    else
1054
        log_message "Could not resolve a unique destination path for $file (wanted: $desired_dest_path)" "ERROR"
1055
        ERROR_FILES=$((ERROR_FILES + 1))
1056
        return 1
Bogdan Timofte authored 9 months ago
1057
    fi
Bogdan Timofte authored 2 weeks ago
1058
    if [[ "$dest_path" != "$desired_dest_path" ]]; then
1059
        log_message "Destination already exists or is already planned: $desired_dest_path - using: $dest_path" "WARNING"
1060
    fi
1061

            
1062
    local dest_dir
1063
    dest_dir=$(dirname "$dest_path")
Bogdan Timofte authored 9 months ago
1064

            
1065
    if [[ $DRY_RUN -eq 1 ]]; then
1066
        if [[ $KEEP_ORIGINALS -eq 1 ]]; then
1067
            print_color "$BLUE" "Would copy: $file -> $dest_path"
1068
        else
1069
            print_color "$BLUE" "Would move: $file -> $dest_path"
1070
        fi
Bogdan Timofte authored 2 weeks ago
1071
        if should_sync_imported_metadata "$original_basename" "$date_source"; then
1072
            print_color "$BLUE" "Would sync metadata date: $dest_path -> $date_str"
1073
        fi
Bogdan Timofte authored 9 months ago
1074
        PROCESSED_FILES=$((PROCESSED_FILES + 1))
1075
        PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
1076
        return 0
1077
    fi
1078

            
1079
    # Create destination directory
1080
    if ! mkdir -p "$dest_dir"; then
1081
        log_message "Could not create directory: $dest_dir" "ERROR"
1082
        ERROR_FILES=$((ERROR_FILES + 1))
1083
        return 1
1084
    fi
1085

            
Bogdan Timofte authored 2 weeks ago
1086
    local sync_metadata_after_copy=0
1087
    local verification_date="$date_str"
1088
    if should_sync_imported_metadata "$original_basename" "$date_source"; then
1089
        sync_metadata_after_copy=1
1090
        verification_date=""
1091
    fi
1092

            
1093
    # Copy or move file using safe helpers after destination conflicts are resolved.
Bogdan Timofte authored 9 months ago
1094
    if [[ $KEEP_ORIGINALS -eq 1 ]]; then
Bogdan Timofte authored 2 weeks ago
1095
        if copy_with_verification "$file" "$dest_path" "$verification_date"; then
1096
            if [[ $sync_metadata_after_copy -eq 1 ]]; then
1097
                if ! sync_destination_metadata_to_date "$dest_path" "$date_str"; then
1098
                    log_message "Failed to sync destination metadata timestamps: $dest_path" "ERROR"
1099
                    ERROR_FILES=$((ERROR_FILES + 1))
1100
                    return 1
1101
                elif ! verify_synced_metadata_date "$dest_path" "$date_str"; then
1102
                    ERROR_FILES=$((ERROR_FILES + 1))
1103
                    return 1
1104
                fi
1105
            fi
Bogdan Timofte authored 9 months ago
1106
            log_message "Copied: $file -> $dest_path" "SUCCESS"
1107
            PROCESSED_FILES=$((PROCESSED_FILES + 1))
1108
            PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
1109
            return 0
1110
        else
Bogdan Timofte authored 3 weeks ago
1111
            log_message "Failed to copy or verify destination: $file -> $dest_path" "ERROR"
Bogdan Timofte authored 9 months ago
1112
            ERROR_FILES=$((ERROR_FILES + 1))
1113
            return 1
1114
        fi
1115
    else
Bogdan Timofte authored 2 weeks ago
1116
        if [[ $sync_metadata_after_copy -eq 1 ]]; then
1117
            if copy_with_verification "$file" "$dest_path" "$verification_date"; then
1118
                if ! sync_destination_metadata_to_date "$dest_path" "$date_str"; then
1119
                    log_message "Failed to sync destination metadata timestamps: $dest_path" "ERROR"
1120
                    ERROR_FILES=$((ERROR_FILES + 1))
1121
                    return 1
1122
                fi
1123
                if ! verify_synced_metadata_date "$dest_path" "$date_str"; then
1124
                    ERROR_FILES=$((ERROR_FILES + 1))
1125
                    return 1
1126
                fi
1127
                if ! remove_source_file "$file"; then
1128
                    log_message "Copied, verified, and synced destination, but failed to remove source: $file" "ERROR"
1129
                    ERROR_FILES=$((ERROR_FILES + 1))
1130
                    return 1
1131
                fi
1132
                log_message "Moved: $file -> $dest_path" "SUCCESS"
1133
                PROCESSED_FILES=$((PROCESSED_FILES + 1))
1134
                PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
1135
                return 0
1136
            else
1137
                log_message "Failed to move after copy verification: $file -> $dest_path" "ERROR"
1138
                ERROR_FILES=$((ERROR_FILES + 1))
1139
                return 1
1140
            fi
1141
        elif verified_move_file "$file" "$dest_path" "$date_str"; then
Bogdan Timofte authored 9 months ago
1142
            log_message "Moved: $file -> $dest_path" "SUCCESS"
Bogdan Timofte authored 2 weeks ago
1143
            if [[ $sync_metadata_after_copy -eq 1 ]]; then
1144
                if ! sync_destination_metadata_to_date "$dest_path" "$date_str"; then
1145
                    log_message "Failed to sync destination metadata timestamps: $dest_path" "WARNING"
1146
                elif ! verify_synced_metadata_date "$dest_path" "$date_str"; then
1147
                    log_message "Failed to verify synced destination metadata timestamps: $dest_path" "WARNING"
1148
                fi
1149
            fi
Bogdan Timofte authored 9 months ago
1150
            PROCESSED_FILES=$((PROCESSED_FILES + 1))
1151
            PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
1152
            return 0
1153
        else
Bogdan Timofte authored 3 weeks ago
1154
            log_message "Failed to move after copy verification: $file -> $dest_path" "ERROR"
Bogdan Timofte authored 9 months ago
1155
            ERROR_FILES=$((ERROR_FILES + 1))
1156
            return 1
1157
        fi
1158
    fi
1159
}
1160

            
1161
# Function to display final report
1162
show_report() {
1163
    local end_time=$(date +%s)
1164
    local elapsed_time=$((end_time - START_TIME))
1165
    local hours=$((elapsed_time / 3600))
1166
    local minutes=$(((elapsed_time % 3600) / 60))
1167
    local seconds=$((elapsed_time % 60))
1168

            
1169
    echo ""
1170
    print_color "$GREEN" "=========================================="
1171
    print_color "$GREEN" "           PROCESSING REPORT"
1172
    print_color "$GREEN" "=========================================="
1173
    echo ""
1174

            
1175
    echo "Size Summary:"
Bogdan Timofte authored 2 weeks ago
1176
    printf "  %-22s %s\n" "Total size found:" "$(format_size $TOTAL_SIZE)"
1177
    printf "  %-22s %s\n" "Successfully processed:" "$(format_size $PROCESSED_SIZE)"
Bogdan Timofte authored 9 months ago
1178

            
1179
    echo ""
1180

            
1181
    if [[ $DRY_RUN -eq 1 ]]; then
1182
        print_color "$YELLOW" "DRY RUN MODE - No files were actually moved/copied"
1183
    elif [[ $KEEP_ORIGINALS -eq 1 ]]; then
1184
        print_color "$BLUE" "COPY MODE - Original files were preserved"
1185
    else
1186
        print_color "$GREEN" "MOVE MODE - Files were moved to destination"
1187
    fi
1188

            
1189
    echo ""
1190
    print_color "$GREEN" "=========================================="
1191
}
1192

            
Bogdan Timofte authored 2 weeks ago
1193
if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then
1194
    return 0
1195
fi
1196

            
Bogdan Timofte authored 9 months ago
1197
# Parse command line arguments
1198
while [[ $# -gt 0 ]]; do
1199
    case $1 in
1200
        -o|--organization)
1201
            ORGANIZATION="$2"
Bogdan Timofte authored 8 months ago
1202
            # Accept new patterns: ym, ymd as well as single-letter ones
1203
            if [[ ! "$ORGANIZATION" =~ ^(y|m|d|h|ym|ymd)$ ]]; then
Bogdan Timofte authored 9 months ago
1204
                print_color "$RED" "Error: Invalid organization pattern. Must be one of: y, m, d, h, ym, ymd"
Bogdan Timofte authored 9 months ago
1205
                exit 1
1206
            fi
1207
            shift 2
1208
            ;;
Bogdan Timofte authored 9 months ago
1209

            
1210
        -F|--filename-mode)
1211
            FILENAME_MODE="$2"
1212
            if [[ ! "$FILENAME_MODE" =~ ^(auto|full|orig)$ ]]; then
1213
                print_color "$RED" "Error: Invalid filename mode. Must be one of: auto, full, orig"
1214
                exit 1
1215
            fi
1216
            shift 2
Bogdan Timofte authored 9 months ago
1217
            ;;
Bogdan Timofte authored 9 months ago
1218
    # (conflict resolution option removed; let exiftool/filesystem handle naming conflicts)
Bogdan Timofte authored 9 months ago
1219
        --collect-unsortable)
1220
            COLLECT_UNSORTABLE=1
1221
            shift
1222
            ;;
Bogdan Timofte authored 8 months ago
1223
        --keep-empty-dirs)
1224
            CLEANUP_EMPTY_DIRS=0
1225
            shift
1226
            ;;
Bogdan Timofte authored 9 months ago
1227
        -s|--source)
1228
            SOURCE_PATTERNS+=("$2")
1229
            shift 2
1230
            ;;
1231
        -d|--destination)
1232
            DESTINATION="$2"
1233
            shift 2
1234
            ;;
1235
        -k|--keep-originals)
1236
            KEEP_ORIGINALS=1
1237
            shift
1238
            ;;
Bogdan Timofte authored 3 weeks ago
1239
        --verify-mode)
1240
            VERIFY_MODE="$2"
1241
            if [[ ! "$VERIFY_MODE" =~ ^(size|strict|none)$ ]]; then
1242
                print_color "$RED" "Error: Invalid verify mode. Must be one of: size, strict, none"
1243
                exit 1
1244
            fi
1245
            shift 2
1246
            ;;
Bogdan Timofte authored 2 weeks ago
1247
        --date-source)
1248
            DATE_SOURCE="$2"
1249
            if [[ ! "$DATE_SOURCE" =~ ^(auto|exif|filesystem)$ ]]; then
1250
                print_color "$RED" "Error: Invalid date source. Must be one of: auto, exif, filesystem"
1251
                exit 1
1252
            fi
1253
            shift 2
1254
            ;;
1255
        --sync-metadata)
1256
            SYNC_METADATA=1
1257
            shift
1258
            ;;
1259
        --unattended)
1260
            UNATTENDED=1
1261
            shift
1262
            ;;
Bogdan Timofte authored 9 months ago
1263
        --dry-run)
1264
            DRY_RUN=1
1265
            shift
1266
            ;;
1267
        -v|--verbose)
1268
            VERBOSE=1
1269
            shift
1270
            ;;
1271
        -h|--help)
1272
            show_help
1273
            exit 0
1274
            ;;
1275
        --version)
1276
            show_version
1277
            exit 0
1278
            ;;
1279
        *)
1280
            print_color "$RED" "Error: Unknown option: $1"
1281
            echo "Use -h or --help for usage information."
1282
            exit 1
1283
            ;;
1284
    esac
1285
done
1286

            
Bogdan Timofte authored 2 weeks ago
1287
# Non-interactive execution cannot safely ask conflict questions.
1288
if [[ $UNATTENDED -eq 0 && ( ! -t 0 || ! -t 1 ) ]]; then
1289
    UNATTENDED=1
1290
fi
1291

            
Bogdan Timofte authored 9 months ago
1292
# If no organization is provided, leave ORGANIZATION empty and filename mode will decide naming
1293

            
Bogdan Timofte authored 3 weeks ago
1294
# If no source specified, default to current directory. Refuse to run when cwd is unsafe.
1295
if [[ ${#SOURCE_PATTERNS[@]} -eq 0 ]]; then
1296
    cwd=$(pwd)
1297
    # Resolve home and root paths
1298
    home_dir="$HOME"
1299
    case "$cwd" in
1300
        "$home_dir"|"/"|"/etc"|"/var"|"/bin"|"/sbin"|"/dev"|"/proc"|"/sys"|"/run"|"/usr"|"/lib"|"/lib64"|"/boot"|"/snap")
1301
            print_color "$RED" "Refusing to run with default source in unsafe directory: $cwd"
1302
            print_color "$RED" "Please specify $cwd as source explicitly with -s or run from a safer directory."
1303
            exit 1
1304
            ;;
1305
        *)
1306
            SOURCE_PATTERNS+=("$cwd")
1307
            ;;
1308
    esac
1309
fi
1310

            
1311
# Set default destination: if user didn't provide -d and a source was given, use first source + /sorted
Bogdan Timofte authored 9 months ago
1312
if [[ -z "$DESTINATION" ]]; then
Bogdan Timofte authored 9 months ago
1313
    if [[ ${#SOURCE_PATTERNS[@]} -gt 1 ]]; then
1314
        print_color "$RED" "Error: Multiple sources specified - destination (-d|--destination) is required when using multiple sources."
1315
        echo "Use -h for help."
1316
        exit 1
1317
    fi
1318

            
1319
    if [[ ${#SOURCE_PATTERNS[@]} -gt 0 ]]; then
1320
        first_source="${SOURCE_PATTERNS[0]}"
Bogdan Timofte authored 3 weeks ago
1321
        if [[ -d "$first_source" ]]; then
1322
            DESTINATION="$first_source/sorted"
1323
        elif [[ -f "$first_source" ]]; then
1324
            DESTINATION="$(dirname "$first_source")/sorted"
Bogdan Timofte authored 9 months ago
1325
        else
1326
            print_color "$YELLOW" "Warning: first source does not exist; defaulting destination to ./sorted"
1327
            DESTINATION="./sorted"
1328
        fi
1329
    else
1330
        DESTINATION="./sorted"
1331
    fi
Bogdan Timofte authored 9 months ago
1332
fi
1333

            
1334
# Convert destination to absolute path
1335
DESTINATION=$(cd "$(dirname "$DESTINATION")" 2>/dev/null && pwd)/$(basename "$DESTINATION") || DESTINATION=$(realpath "$DESTINATION" 2>/dev/null) || DESTINATION="$DESTINATION"
1336

            
1337
# Display configuration
1338
print_color "$GREEN" "$SCRIPT_NAME v$VERSION"
1339
echo ""
1340
echo "Configuration:"
1341
echo "  Organization pattern: $ORGANIZATION"
1342
echo "  Destination:         $DESTINATION"
1343
echo "  Keep originals:      $([ $KEEP_ORIGINALS -eq 1 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored 3 weeks ago
1344
echo "  Verify mode:         $VERIFY_MODE"
Bogdan Timofte authored 2 weeks ago
1345
echo "  Date source:         $DATE_SOURCE"
1346
echo "  Sync metadata:       $([ $SYNC_METADATA -eq 1 ] && echo "Yes" || echo "No")"
1347
echo "  Unattended:          $([ $UNATTENDED -eq 1 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored 9 months ago
1348
echo "  Dry run:             $([ $DRY_RUN -eq 1 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored 8 months ago
1349
echo "  Keep empty dirs:     $([ $CLEANUP_EMPTY_DIRS -eq 0 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored 9 months ago
1350
echo "  Verbose:             $([ $VERBOSE -eq 1 ] && echo "Yes" || echo "No")"
1351

            
1352
if [[ ${#SOURCE_PATTERNS[@]} -gt 0 ]]; then
1353
    echo "  Source patterns:"
1354
    for pattern in "${SOURCE_PATTERNS[@]}"; do
1355
        echo "    - $pattern"
1356
    done
1357
else
1358
    echo "  Source patterns:     All media files in current directory"
1359
fi
1360

            
1361
echo ""
1362

            
1363
# Check dependencies
1364
check_dependencies
1365

            
1366
# Create destination directory if it doesn't exist (unless dry run)
1367
if [[ $DRY_RUN -eq 0 ]]; then
1368
    if ! mkdir -p "$DESTINATION"; then
1369
        print_color "$RED" "Error: Cannot create destination directory: $DESTINATION"
1370
        exit 1
1371
    fi
1372
fi
1373

            
1374
# Find all source files
1375

            
1376
print_color "$BLUE" "Scanning for media files..."
1377
files=()
1378
while IFS= read -r file; do
1379
    files+=("$file")
1380
done < <(find_source_files)
Bogdan Timofte authored 2 weeks ago
1381
if [[ ${#files[@]} -gt 0 ]]; then
1382
    IFS=$'\n' files=($(printf '%s\n' "${files[@]}" | sort))
1383
    unset IFS
1384
fi
Bogdan Timofte authored 9 months ago
1385
TOTAL_FILES=${#files[@]}
1386

            
1387
if [[ $TOTAL_FILES -eq 0 ]]; then
1388
    print_color "$YELLOW" "No media files found matching the specified patterns."
1389
    exit 0
1390
fi
1391

            
1392
print_color "$BLUE" "Found $TOTAL_FILES media files to process"
1393
echo ""
1394

            
1395
# Process each file
1396

            
1397
FATAL_ERROR=0
1398
for file in "${files[@]}"; do
1399
    if [[ -f "$file" ]]; then
Bogdan Timofte authored 2 weeks ago
1400
        CURRENT_FILE_INDEX=$((CURRENT_FILE_INDEX + 1))
Bogdan Timofte authored 9 months ago
1401
        process_file "$file"
1402
        if [[ $FATAL_ERROR -eq 1 ]]; then
1403
            print_color "$RED" "Fatal error encountered. Stopping further processing."
1404
            break
1405
        fi
1406
    fi
1407
done
1408

            
Bogdan Timofte authored 8 months ago
1409
# Clean up empty directories if requested (default behavior)
1410
if [[ $CLEANUP_EMPTY_DIRS -eq 1 && $DRY_RUN -eq 0 ]]; then
1411
    print_color "$BLUE" "Cleaning up empty directories..."
1412
    # Find and remove empty directories under destination, but don't remove the destination itself
1413
    find "$DESTINATION" -type d -empty -not -path "$DESTINATION" -delete 2>/dev/null || true
1414
    print_color "$GREEN" "Empty directory cleanup completed"
1415
fi
1416

            
Bogdan Timofte authored 9 months ago
1417
# Show final report
1418
show_report
1419

            
1420
# Exit with appropriate code
1421
if [[ $ERROR_FILES -gt 0 ]]; then
1422
    exit 1
1423
else
1424
    exit 0
1425
fi