MediaImporter / media-importer.sh
Newer Older
1556 lines | 52.362kb
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
CLEANUP_MEDIA_SIDECARS=1  # when 1, delete device sidecars (GoPro THM/LRV, Garmin GLV) after successful import
Bogdan Timofte authored 2 weeks ago
27
CONFLICT_APPLY_ALL=""  # suffix|skip after an interactive "all similar" choice
28
RESOLVED_DESTINATION_PATH=""
29
RESERVED_DESTINATION_PATHS=()
Bogdan Timofte authored 9 months ago
30

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

            
41
# Colors for output
42
RED='\033[0;31m'
43
GREEN='\033[0;32m'
44
YELLOW='\033[1;33m'
Bogdan Timofte authored 9 months ago
45
# BLUE is used for informational/verbose messages. Dark terminal themes may render the
46
# default blue hard to read, so use a brighter cyan by default and allow users to
47
# override via the VERBOSE_COLOR environment variable (must be a terminal escape seq).
48
if [[ -n "${VERBOSE_COLOR:-}" ]]; then
49
    BLUE="$VERBOSE_COLOR"
50
else
51
    BLUE=$'\033[1;36m'  # bright cyan
52
fi
Bogdan Timofte authored 9 months ago
53
NC='\033[0m' # No Color
54

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

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

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

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

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

            
Bogdan Timofte authored 9 months ago
97
What it does:
98
    Sorts photos and videos into dated folders (by year/month/day/hour) and
99
    generates filenames from the file's creation timestamp or preserves the
100
    original name.
101

            
102
Options:
103
    -o, --organization PATTERN   y|m|d|h|ym|ymd   (default: ymd)
104
    -F, --filename-mode MODE     auto|full|orig    (default: full)
105
    -s, --source PATH            File or directory to process (repeatable). Default: cwd
106
    -d, --destination PATH       Destination folder. Required when multiple -s are given.
Bogdan Timofte authored 8 months ago
107
    -k, --keep-originals         Copy files instead of moving
Bogdan Timofte authored 3 weeks ago
108
    --verify-mode MODE           size|strict|none (default: size)
Bogdan Timofte authored 2 weeks ago
109
    --date-source SOURCE         auto|exif|filesystem (default: auto)
110
    --sync-metadata              Write chosen date into destination metadata (automatic for GoPro filesystem dates)
111
    --unattended                 Never prompt; resolve destination conflicts with numeric suffixes
Bogdan Timofte authored 9 months ago
112
    --collect-unsortable         Put files without dates into DEST/unsortable
Bogdan Timofte authored 2 weeks ago
113
    --keep-sidecars              Keep device sidecar files (default: delete after import)
Bogdan Timofte authored 8 months ago
114
    --keep-empty-dirs            Keep empty directories after processing
115
    --dry-run                    Show actions without changing files
Bogdan Timofte authored 9 months ago
116
    -v, --verbose                Verbose output
117
    -h, --help                   Show this help
118
    --version                    Show version
119

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

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

            
Bogdan Timofte authored 9 months ago
129
# Central media extensions list (used by find functions)
130
MEDIA_EXTENSIONS=("*.jpg" "*.jpeg" "*.png" "*.tiff" "*.tif" "*.cr2" "*.nef" "*.arw" "*.dng" "*.raw" "*.mp4" "*.mov" "*.avi" "*.mts" "*.m2ts" "*.mkv" "*.wmv" "*.3gp" "*.m4v")
131

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

            
138
# Function to check dependencies
139
check_dependencies() {
140
    local missing_deps=()
141

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

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

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

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

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

            
175
    log_message "All required dependencies found" "SUCCESS"
176
}
177

            
Bogdan Timofte authored 9 months ago
178
# Determine filesystem/device ID for a path (portable between Linux and macOS)
179
get_dev() {
180
    local path="$1"
181
    if [[ -z "$path" ]]; then
182
        path="."
183
    fi
184

            
185
    # Prefer GNU stat if available
186
    if stat --version >/dev/null 2>&1; then
187
        stat -c %d "$path" 2>/dev/null || stat -c %i "$path" 2>/dev/null || echo ""
188
    else
189
        # BSD/macOS stat
190
        stat -f %d "$path" 2>/dev/null || stat -f %i "$path" 2>/dev/null || echo ""
191
    fi
192
}
193

            
194
# Function to get file size in bytes (portable between Linux and macOS)
Bogdan Timofte authored 9 months ago
195
get_file_size() {
196
    local file="$1"
197
    if [[ -f "$file" ]]; then
Bogdan Timofte authored 9 months ago
198
        # Try GNU stat
199
        if stat -c%s "$file" >/dev/null 2>&1; then
Bogdan Timofte authored 9 months ago
200
            stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null
Bogdan Timofte authored 9 months ago
201
        elif stat -f%z "$file" >/dev/null 2>&1; then
202
            stat -f%z "$file" 2>/dev/null
Bogdan Timofte authored 9 months ago
203
        else
204
            # Fallback to ls
Bogdan Timofte authored 9 months ago
205
            ls -ln "$file" | awk '{print $5}'
Bogdan Timofte authored 9 months ago
206
        fi
207
    else
208
        echo "0"
209
    fi
210
}
211

            
Bogdan Timofte authored 9 months ago
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"
Bogdan Timofte authored 2 weeks ago
217
    if [[ -e "$dst" ]]; then
218
        log_message "Refusing to overwrite existing destination: $dst" "ERROR"
219
        return 1
220
    fi
Bogdan Timofte authored 9 months ago
221
    # Redirect stderr through a filter that removes the known benign message
222
    mv "$src" "$dst" 2> >(grep -v "set flags (was:" >&2)
223
    return $?
224
}
225

            
Bogdan Timofte authored 2 weeks ago
226
safe_cp() {
227
    local src="$1" dst="$2"
228
    if [[ -e "$dst" ]]; then
229
        log_message "Refusing to overwrite existing destination: $dst" "ERROR"
230
        return 1
231
    fi
232
    cp -p "$src" "$dst" 2> >(grep -v "set flags (was:" >&2)
233
    return $?
234
}
235

            
236
ensure_unique_destination_path() {
237
    local desired_path="$1"
238

            
239
    if [[ -z "$desired_path" ]]; then
240
        return 1
241
    fi
242

            
243
    if ! destination_path_unavailable "$desired_path"; then
244
        echo "$desired_path"
245
        return 0
246
    fi
247

            
248
    local dir_path filename ext stem candidate
249
    dir_path=$(dirname "$desired_path")
250
    filename=$(basename "$desired_path")
251

            
252
    if [[ "$filename" == *.* ]]; then
253
        ext="${filename##*.}"
254
        stem="${filename%.*}"
255
    else
256
        ext=""
257
        stem="$filename"
258
    fi
259

            
260
    local i
261
    i=1
262
    while [[ $i -le 9999 ]]; do
263
        if [[ -n "$ext" ]]; then
264
            candidate="$dir_path/${stem}_${i}.${ext}"
265
        else
266
            candidate="$dir_path/${stem}_${i}"
267
        fi
268
        if ! destination_path_unavailable "$candidate"; then
269
            echo "$candidate"
270
            return 0
271
        fi
272
        i=$((i + 1))
273
    done
274

            
275
    return 1
276
}
277

            
Bogdan Timofte authored 2 weeks ago
278
destination_path_reserved() {
279
    local candidate="$1"
280
    local reserved_path
281
    for reserved_path in "${RESERVED_DESTINATION_PATHS[@]}"; do
282
        if [[ "$reserved_path" == "$candidate" ]]; then
283
            return 0
284
        fi
285
    done
286
    return 1
287
}
Bogdan Timofte authored 9 months ago
288

            
Bogdan Timofte authored 2 weeks ago
289
destination_path_unavailable() {
290
    local candidate="$1"
291
    [[ -e "$candidate" ]] || destination_path_reserved "$candidate"
292
}
293

            
294
reserve_destination_path() {
295
    local candidate="$1"
296
    if [[ -n "$candidate" ]] && ! destination_path_reserved "$candidate"; then
297
        RESERVED_DESTINATION_PATHS+=("$candidate")
298
    fi
299
}
300

            
301
prompt_destination_conflict_choice() {
302
    local source_file="$1"
303
    local desired_path="$2"
304
    local choice
305

            
306
    if [[ ! -r /dev/tty || ! -w /dev/tty ]]; then
307
        return 1
308
    fi
309

            
310
    {
311
        print_color "$YELLOW" "Destination already exists:"
312
        echo "  Source:      $source_file"
313
        echo "  Destination: $desired_path"
314
        echo ""
315
        echo "Choose conflict action:"
316
        echo "  [s] suffix once"
317
        echo "  [S] suffix for all similar conflicts"
318
        echo "  [k] skip once"
319
        echo "  [K] skip all similar conflicts"
320
        echo "  [a] abort import"
321
    } > /dev/tty
322

            
323
    while true; do
324
        printf "Action [s/S/k/K/a]: " > /dev/tty
325
        IFS= read -r choice < /dev/tty || return 1
326
        case "$choice" in
327
            s|"")
328
                echo "suffix"
329
                return 0
330
                ;;
331
            S)
332
                echo "suffix_all"
333
                return 0
334
                ;;
335
            k)
336
                echo "skip"
337
                return 0
338
                ;;
339
            K)
340
                echo "skip_all"
341
                return 0
342
                ;;
343
            a|A)
344
                echo "abort"
345
                return 0
346
                ;;
347
            *)
348
                print_color "$YELLOW" "Please choose s, S, k, K, or a." > /dev/tty
349
                ;;
350
        esac
351
    done
352
}
353

            
354
resolve_destination_conflict() {
355
    local desired_path="$1"
356
    local source_file="$2"
357
    local resolved_path choice
358
    RESOLVED_DESTINATION_PATH=""
359

            
360
    if [[ -z "$desired_path" ]]; then
361
        return 1
362
    fi
363

            
364
    if ! destination_path_unavailable "$desired_path"; then
365
        RESOLVED_DESTINATION_PATH="$desired_path"
366
        reserve_destination_path "$RESOLVED_DESTINATION_PATH"
367
        return 0
368
    fi
369

            
370
    if [[ "$CONFLICT_APPLY_ALL" == "skip" ]]; then
371
        return 3
372
    fi
373

            
374
    if [[ "$CONFLICT_APPLY_ALL" == "suffix" || $UNATTENDED -eq 1 ]]; then
375
        resolved_path=$(ensure_unique_destination_path "$desired_path")
376
        if [[ -z "$resolved_path" ]]; then
377
            return 1
378
        fi
379
        RESOLVED_DESTINATION_PATH="$resolved_path"
380
        reserve_destination_path "$RESOLVED_DESTINATION_PATH"
381
        return 0
382
    fi
383

            
384
    choice=$(prompt_destination_conflict_choice "$source_file" "$desired_path")
385
    if [[ $? -ne 0 || -z "$choice" ]]; then
386
        log_message "Cannot prompt for destination conflict; using unattended numeric suffix mode" "WARNING"
387
        resolved_path=$(ensure_unique_destination_path "$desired_path")
388
        if [[ -z "$resolved_path" ]]; then
389
            return 1
390
        fi
391
        RESOLVED_DESTINATION_PATH="$resolved_path"
392
        reserve_destination_path "$RESOLVED_DESTINATION_PATH"
393
        return 0
394
    fi
395

            
396
    case "$choice" in
397
        suffix)
398
            resolved_path=$(ensure_unique_destination_path "$desired_path")
399
            ;;
400
        suffix_all)
401
            CONFLICT_APPLY_ALL="suffix"
402
            resolved_path=$(ensure_unique_destination_path "$desired_path")
403
            ;;
404
        skip)
405
            return 3
406
            ;;
407
        skip_all)
408
            CONFLICT_APPLY_ALL="skip"
409
            return 3
410
            ;;
411
        abort)
412
            return 4
413
            ;;
414
        *)
415
            return 1
416
            ;;
417
    esac
418

            
419
    if [[ -z "$resolved_path" ]]; then
420
        return 1
421
    fi
422

            
423
    RESOLVED_DESTINATION_PATH="$resolved_path"
424
    reserve_destination_path "$RESOLVED_DESTINATION_PATH"
425
    return 0
426
}
427

            
428
extract_filesystem_date() {
429
    # Returns yyyy-mm-dd hh:mm:ss based on filesystem mtime.
430
    # We intentionally use mtime (not birthtime) because birthtime isn't preserved by copies
431
    # across filesystems, while mtime can be preserved via `cp -p`.
432
    local file="$1"
433
    if [[ ! -e "$file" ]]; then
434
        return 2
435
    fi
436

            
437
    local epoch=""
438

            
439
    if [[ "$OSTYPE" == "darwin"* ]]; then
440
        epoch=$(stat -f %m "$file" 2>/dev/null || echo "")
441
        [[ -n "$epoch" ]] || return 2
442
        date -j -r "$epoch" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || return 2
443
        return 0
444
    else
445
        epoch=$(stat -c %Y "$file" 2>/dev/null || echo "")
446
        [[ -n "$epoch" ]] || return 2
447
        date -d "@$epoch" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || return 2
448
        return 0
449
    fi
450
}
451

            
452
filesystem_date_reference() {
453
    local file="$1"
454
    local dir base stem ext sidecar_ext sidecar
455
    dir=$(dirname "$file")
456
    base=$(basename "$file")
457
    stem="${base%.*}"
458
    ext="${base##*.}"
459

            
460
    if [[ "$ext" =~ ^([Mm][Pp]4)$ ]]; then
461
        for sidecar_ext in THM thm LRV lrv; do
462
            sidecar="$dir/$stem.$sidecar_ext"
463
            if [[ -f "$sidecar" ]]; then
464
                echo "$sidecar"
465
                return 0
466
            fi
467
        done
468
    fi
469

            
470
    echo "$file"
471
}
472

            
473
is_gopro_media_file() {
474
    local filename
475
    filename=$(basename "$1")
476
    [[ "$filename" =~ ^G[HPX][0-9]{6}\.[Mm][Pp]4$ ]]
477
}
478

            
Bogdan Timofte authored 2 weeks ago
479
is_garmin_media_file() {
480
    local filename
481
    filename=$(basename "$1")
482
    [[ "$filename" =~ ^VIRB[0-9]{4}\.[Mm][Pp]4$ ]]
483
}
484

            
485
get_device_sidecar_extensions() {
486
    local file="$1"
487
    local filename
488
    filename=$(basename "$file")
489

            
490
    if is_gopro_media_file "$file"; then
491
        echo "THM thm LRV lrv"
492
    elif is_garmin_media_file "$file"; then
493
        echo "GLV glv"
494
    fi
495
}
496

            
Bogdan Timofte authored 2 weeks ago
497
should_prefer_gopro_filesystem_date() {
498
    local file="$1"
499

            
500
    is_gopro_media_file "$file"
501
}
502

            
503
filesystem_date_source_label() {
504
    local file="$1"
505
    local reference="$2"
506

            
507
    if is_gopro_media_file "$file"; then
508
        echo "Filesystem:$(basename "$reference")"
509
    elif [[ "$reference" != "$file" ]]; then
510
        echo "Filesystem:$(basename "$reference")"
511
    else
512
        echo "Filesystem"
513
    fi
514
}
515

            
516
date_to_exiftool_format() {
517
    # yyyy-mm-dd hh:mm:ss -> yyyy:mm:dd hh:mm:ss
518
    local s="$1"
519
    if [[ "$s" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})[[:space:]]+([0-9]{2}):([0-9]{2}):([0-9]{2})$ ]]; then
520
        echo "${BASH_REMATCH[1]}:${BASH_REMATCH[2]}:${BASH_REMATCH[3]} ${BASH_REMATCH[4]}:${BASH_REMATCH[5]}:${BASH_REMATCH[6]}"
521
        return 0
522
    fi
523
    return 1
524
}
525

            
526
sync_destination_metadata_to_date() {
527
    local file="$1"
528
    local date_str="$2" # yyyy-mm-dd hh:mm:ss
529

            
530
    local exif_dt
531
    exif_dt=$(date_to_exiftool_format "$date_str") || return 1
532

            
533
    exiftool -overwrite_original \
534
        "-CreateDate=$exif_dt" \
535
        "-DateTimeOriginal=$exif_dt" \
536
        "-DateTime=$exif_dt" \
537
        "-ModifyDate=$exif_dt" \
538
        "-MediaCreateDate=$exif_dt" \
539
        "-TrackCreateDate=$exif_dt" \
540
        "-QuickTime:CreateDate=$exif_dt" \
541
        "-QuickTime:ModifyDate=$exif_dt" \
542
        "$file" >/dev/null 2>&1
543
    return 0
544
}
545

            
546
verify_synced_metadata_date() {
547
    local file="$1"
548
    local expected_date="$2"
549

            
550
    local metadata_date
551
    metadata_date=$(exiftool -api QuickTimeUTC=0 -s -s -s -QuickTime:CreateDate "$file" 2>/dev/null | head -1)
552
    if [[ -z "$metadata_date" ]]; then
553
        metadata_date=$(exiftool -api QuickTimeUTC=0 -s -s -s -CreateDate "$file" 2>/dev/null | head -1)
554
    fi
555

            
556
    if [[ "$metadata_date" =~ ^([0-9]{4}):([0-9]{2}):([0-9]{2})[[:space:]]+([0-9]{2}):([0-9]{2}):([0-9]{2}) ]]; then
557
        metadata_date="${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]} ${BASH_REMATCH[4]}:${BASH_REMATCH[5]}:${BASH_REMATCH[6]}"
558
    fi
559

            
560
    if [[ "$metadata_date" != "$expected_date" ]]; then
561
        log_message "Destination metadata sync mismatch: expected $expected_date, got ${metadata_date:-none} for $file" "ERROR"
562
        return 1
563
    fi
564

            
565
    return 0
566
}
567

            
568
should_sync_imported_metadata() {
569
    local original_filename="$1"
570
    local date_source="$2"
571

            
572
    if [[ $SYNC_METADATA -eq 1 ]]; then
573
        return 0
574
    fi
575

            
576
    if [[ "$date_source" == Filesystem* ]] && is_gopro_media_file "$original_filename"; then
577
        return 0
578
    fi
579

            
580
    return 1
Bogdan Timofte authored 9 months ago
581
}
582

            
Bogdan Timofte authored 3 weeks ago
583
verify_copied_file() {
584
    local src="$1"
585
    local dst="$2"
586
    local expected_date="$3"
587

            
588
    if [[ ! -f "$dst" ]]; then
589
        log_message "Verified copy missing at destination: $dst" "ERROR"
590
        return 1
591
    fi
592

            
593
    local src_size dst_size
594
    src_size=$(get_file_size "$src")
595
    dst_size=$(get_file_size "$dst")
596
    if [[ "$src_size" != "$dst_size" ]]; then
597
        log_message "Size mismatch after copy: $src ($src_size) != $dst ($dst_size)" "ERROR"
598
        return 1
599
    fi
600

            
601
    if [[ "$VERIFY_MODE" == "strict" ]]; then
602
        if ! cmp -s "$src" "$dst"; then
603
            log_message "Content mismatch after copy: $src -> $dst" "ERROR"
604
            return 1
605
        fi
606
    elif [[ "$VERIFY_MODE" == "none" ]]; then
607
        return 0
608
    fi
609

            
610
    if [[ -n "$expected_date" ]]; then
611
        local destination_date_info
612
        destination_date_info=$(extract_file_date "$dst")
613
        local extract_status=$?
614
        if [[ $extract_status -ne 0 || -z "$destination_date_info" ]]; then
615
            log_message "Destination metadata validation failed: $dst" "ERROR"
616
            return 1
617
        fi
618

            
619
        local destination_date="${destination_date_info%|*}"
620
        if [[ "$destination_date" != "$expected_date" ]]; then
621
            log_message "Destination metadata mismatch: expected $expected_date, got $destination_date for $dst" "ERROR"
622
            return 1
623
        fi
624
    fi
625

            
626
    return 0
627
}
628

            
629
remove_source_file() {
630
    local src="$1"
631
    rm -f "$src"
632
}
633

            
634
copy_with_verification() {
635
    local src="$1"
636
    local dst="$2"
637
    local expected_date="$3"
638

            
Bogdan Timofte authored 2 weeks ago
639
    if [[ -e "$dst" ]]; then
640
        log_message "Refusing to overwrite existing destination: $dst" "ERROR"
Bogdan Timofte authored 3 weeks ago
641
        return 1
642
    fi
643

            
Bogdan Timofte authored 2 weeks ago
644
    local dst_dir tmp
645
    dst_dir=$(dirname "$dst")
646
    tmp=$(mktemp "$dst_dir/.media-importer.$(basename "$dst").tmp.XXXXXX") || return 1
647
    rm -f "$tmp"
648

            
649
    if ! safe_cp "$src" "$tmp"; then
650
        rm -f "$tmp"
651
        return 1
652
    fi
653

            
654
    if ! verify_copied_file "$src" "$tmp" "$expected_date"; then
655
        rm -f "$tmp"
656
        return 1
657
    fi
658

            
659
    if [[ -e "$dst" ]]; then
660
        log_message "Destination appeared during copy, refusing to overwrite: $dst" "ERROR"
661
        rm -f "$tmp"
662
        return 1
663
    fi
664

            
665
    if ! safe_mv "$tmp" "$dst"; then
666
        rm -f "$tmp"
667
        return 1
668
    fi
669

            
670
    if [[ ! -f "$dst" ]]; then
671
        log_message "Copied file missing after final move: $dst" "ERROR"
Bogdan Timofte authored 3 weeks ago
672
        return 1
673
    fi
674

            
675
    return 0
676
}
677

            
678
verified_move_file() {
679
    local src="$1"
680
    local dst="$2"
681
    local expected_date="$3"
682

            
683
    if ! copy_with_verification "$src" "$dst" "$expected_date"; then
684
        return 1
685
    fi
686

            
687
    if ! remove_source_file "$src"; then
688
        log_message "Copied and verified destination, but failed to remove source: $src" "ERROR"
689
        return 1
690
    fi
691

            
692
    return 0
693
}
694

            
Bogdan Timofte authored 9 months ago
695
# Function to format file size
696
format_size() {
697
    local size=$1
698
    if (( size < 1024 )); then
699
        echo "${size}B"
700
    elif (( size < 1048576 )); then
701
        echo "$(( size / 1024 ))KB"
702
    elif (( size < 1073741824 )); then
703
        echo "$(( size / 1048576 ))MB"
704
    else
705
        echo "$(( size / 1073741824 ))GB"
706
    fi
707
}
708

            
Bogdan Timofte authored 2 weeks ago
709
format_duration() {
710
    local total_seconds=$1
711
    local hours=$((total_seconds / 3600))
712
    local minutes=$(((total_seconds % 3600) / 60))
713
    local seconds=$((total_seconds % 60))
714

            
715
    if (( hours > 0 )); then
716
        printf "%dh %02dm %02ds" "$hours" "$minutes" "$seconds"
717
    elif (( minutes > 0 )); then
718
        printf "%dm %02ds" "$minutes" "$seconds"
719
    else
720
        printf "%ds" "$seconds"
721
    fi
722
}
723

            
Bogdan Timofte authored 2 weeks ago
724
format_data_rate() {
725
    local bytes_count="$1"
726
    local elapsed_seconds="$2"
Bogdan Timofte authored 2 weeks ago
727

            
Bogdan Timofte authored 2 weeks ago
728
    awk -v bytes="$bytes_count" -v seconds="$elapsed_seconds" '
Bogdan Timofte authored 2 weeks ago
729
        BEGIN {
Bogdan Timofte authored 2 weeks ago
730
            if (seconds <= 0 || bytes <= 0) {
Bogdan Timofte authored 2 weeks ago
731
                exit
732
            }
733

            
734
            mb_per_second = bytes / seconds / 1048576
Bogdan Timofte authored 2 weeks ago
735
            printf "%.2f MB/sec", mb_per_second
Bogdan Timofte authored 2 weeks ago
736
        }
737
    '
738
}
739

            
Bogdan Timofte authored 2 weeks ago
740
report_line() {
741
    local label="$1"
742
    local value="$2"
743
    printf "  %-24s %s\n" "$label" "$value"
744
}
745

            
Bogdan Timofte authored 9 months ago
746
# Function to extract date from file
747
extract_file_date() {
748
    local file="$1"
749
    local create_date=""
750
    local date_source=""
751
    local exif_found=0
Bogdan Timofte authored 2 weeks ago
752

            
753
    # Filesystem authoritative mode, and GoPro media in auto mode.
754
    # GoPro fallback order is THM, LRV, then the MP4 filesystem timestamp.
755
    if [[ "$DATE_SOURCE" == "filesystem" ]] || { [[ "$DATE_SOURCE" == "auto" ]] && should_prefer_gopro_filesystem_date "$file"; }; then
756
        local filesystem_reference
757
        filesystem_reference=$(filesystem_date_reference "$file")
758
        create_date=$(extract_filesystem_date "$filesystem_reference") || return 2
759
        echo "$create_date|$(filesystem_date_source_label "$file" "$filesystem_reference")"
760
        return 0
761
    fi
762

            
Bogdan Timofte authored 9 months ago
763
    # Try to get creation date from EXIF data
764
    local exif_output=$(exiftool -G1 -s -CreateDate -DateTimeOriginal -DateTime "$file" 2>/dev/null)
765
    if [[ -n "$exif_output" ]]; then
766
        # Parse the exiftool output to find the best date
767
        while IFS= read -r line; do
768
            if [[ "$line" =~ ^\[([^\]]+)\][[:space:]]*([^:]+)[[:space:]]*:[[:space:]]*(.+)$ ]]; then
769
                local group="${BASH_REMATCH[1]}"
770
                local tag="${BASH_REMATCH[2]}"
771
                local value="${BASH_REMATCH[3]}"
772
                # Trim spaces from tag name
773
                tag=$(echo "$tag" | sed 's/[[:space:]]*$//')
774
                # Prefer DateTimeOriginal, then CreateDate, then DateTime
775
                if [[ "$tag" == "DateTimeOriginal" && -z "$create_date" ]]; then
776
                    create_date="$value"
777
                    date_source="$group:$tag"
778
                    exif_found=1
779
                elif [[ "$tag" == "CreateDate" && "$date_source" != *"DateTimeOriginal"* ]]; then
780
                    create_date="$value"
781
                    date_source="$group:$tag"
782
                    exif_found=1
783
                elif [[ "$tag" == "DateTime" && -z "$create_date" ]]; then
784
                    create_date="$value"
785
                    date_source="$group:$tag"
786
                    exif_found=1
787
                fi
788
            fi
789
        done <<< "$exif_output"
790
    fi
791
    # If no EXIF date found, try mediainfo for video files
792
    if [[ -z "$create_date" ]] && command -v mediainfo &> /dev/null; then
793
        local media_date=$(mediainfo --Inform="General;%Recorded_Date%" "$file" 2>/dev/null)
794
        if [[ -n "$media_date" && "$media_date" != "0000-00-00 00:00:00" ]]; then
795
            create_date="$media_date"
796
            date_source="MediaInfo:Recorded_Date"
797
        fi
798
    fi
Bogdan Timofte authored 2 weeks ago
799

            
800
    # In auto mode, if metadata is missing/unreliable, fall back to filesystem timestamps
801
    if [[ -z "$create_date" && "$DATE_SOURCE" == "auto" ]]; then
802
        local filesystem_reference
803
        filesystem_reference=$(filesystem_date_reference "$file")
804
        create_date=$(extract_filesystem_date "$filesystem_reference") || return 2
805
        date_source=$(filesystem_date_source_label "$file" "$filesystem_reference")
806
    fi
807

            
Bogdan Timofte authored 9 months ago
808
    # If no EXIF or mediainfo date found, return failure
809
    if [[ -z "$create_date" ]]; then
810
        return 2  # No date metadata found
811
    fi
812

            
813
    # Convert EXIF format (YYYY:MM:DD HH:MM:SS) to standard format
814
    # Always output as yyyy-mm-dd hh:mm:ss (pad single digits)
815
    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
816
        year="${BASH_REMATCH[1]}"
817
        month=$(printf "%02d" "$((10#${BASH_REMATCH[2]}))")
818
        day=$(printf "%02d" "$((10#${BASH_REMATCH[3]}))")
819
        hour=$(printf "%02d" "$((10#${BASH_REMATCH[4]}))")
820
        minute=$(printf "%02d" "$((10#${BASH_REMATCH[5]}))")
821
        second=$(printf "%02d" "$((10#${BASH_REMATCH[6]}))")
822
        create_date="$year-$month-$day $hour:$minute:$second"
823
    else
824
        # Try to convert yyyy-mm-dd hh:mm:ss (already correct)
825
        if [[ "$create_date" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})[[:space:]]*([0-9]{2}):([0-9]{2}):([0-9]{2})$ ]]; then
826
            # Already correct
827
            :
828
        else
829
            print_color "$RED" "Error: Cannot parse date '$create_date'" >&2
830
            return 2
831
        fi
832
    fi
833

            
834
    # For QuickTime files, the CreateDate is in UTC and needs conversion to local time
835
    if [[ "$date_source" == *"QuickTime"* ]]; then
836
        # Convert UTC time to local time
837
        if [[ "$OSTYPE" == "darwin"* ]]; then
838
            # On macOS, use TZ=UTC to interpret the input time as UTC
839
            local utc_timestamp=$(TZ=UTC date -j -f "%Y-%m-%d %H:%M:%S" "$create_date" "+%s" 2>/dev/null)
840
            if [[ -n "$utc_timestamp" ]]; then
841
                create_date=$(date -j -r "$utc_timestamp" "+%Y-%m-%d %H:%M:%S" 2>/dev/null)
842
                date_source="$date_source (converted from UTC)"
843
            fi
844
        else
845
            local utc_timestamp=$(date -d "$create_date UTC" "+%s" 2>/dev/null)
846
            if [[ -n "$utc_timestamp" ]]; then
847
                create_date=$(date -d "@$utc_timestamp" "+%Y-%m-%d %H:%M:%S" 2>/dev/null)
848
                date_source="$date_source (converted from UTC)"
849
            fi
850
        fi
851
    fi
852

            
853
    echo "$create_date|$date_source"
854
    return 0
855
}
856

            
857
# Function to generate destination path based on organization pattern
858
generate_destination_path() {
859
    local date_str="$1"
860
    local original_filename="$2"
861
    local base_destination="$3"
862

            
863
    # Extract date components - handle both GNU and BSD date
864
    local year month day hour minute second
865
    if [[ "$OSTYPE" == "darwin"* ]]; then
866
        # macOS (BSD date) - use more robust regex (allow single-digit month/day, tolerate extra spaces)
867
        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
868
            year="${BASH_REMATCH[1]}"
869
            month=$(printf "%02d" "$((10#${BASH_REMATCH[2]}))")
870
            day=$(printf "%02d" "$((10#${BASH_REMATCH[3]}))")
871
            hour=$(printf "%02d" "$((10#${BASH_REMATCH[4]}))")
872
            minute=$(printf "%02d" "$((10#${BASH_REMATCH[5]}))")
873
            second=$(printf "%02d" "$((10#${BASH_REMATCH[6]}))")
874
        else
875
            return 1
876
        fi
877
    else
878
        # Linux (GNU date)
879
        year=$(date -d "$date_str" "+%Y" 2>/dev/null)
880
        month=$(date -d "$date_str" "+%m" 2>/dev/null)
881
        day=$(date -d "$date_str" "+%d" 2>/dev/null)
882
        hour=$(date -d "$date_str" "+%H" 2>/dev/null)
883
        minute=$(date -d "$date_str" "+%M" 2>/dev/null)
884
        second=$(date -d "$date_str" "+%S" 2>/dev/null)
885
    fi
886

            
887
    if [[ -z "$year" || -z "$month" || -z "$day" ]]; then
888
        return 1
889
    fi
890

            
891
    # Get file extension
892
    local extension="${original_filename##*.}"
893
    local lowercase_ext=$(echo "$extension" | tr '[:upper:]' '[:lower:]')
894

            
895
    # Generate path and filename based on organization pattern
896
    local dir_path=""
897
    local filename=""
Bogdan Timofte authored 9 months ago
898

            
899
    # If no organization specified, use flat destination (base) and choose filename per mode
900
    if [[ -z "$ORGANIZATION" ]]; then
Bogdan Timofte authored 9 months ago
901
        dir_path="$base_destination"
Bogdan Timofte authored 9 months ago
902
        if [[ "$FILENAME_MODE" == "orig" ]]; then
903
            filename="$original_filename"
904
        else
905
            # full or auto both map to full date for flat layout
906
            filename="${year}-${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
907
        fi
908
        echo "$dir_path/$filename"
909
        return 0
910
    fi
911

            
912
    case "$ORGANIZATION" in
Bogdan Timofte authored 9 months ago
913
            "y")
914
                dir_path="$base_destination/$year"
915
                filename="${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
916
                ;;
917
            "m")
918
                dir_path="$base_destination/$year/$month"
919
                filename="${day}_${hour}-${minute}-${second}.${lowercase_ext}"
920
                ;;
921
            "d")
922
                dir_path="$base_destination/$year/$month/$day"
923
                filename="${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
924
                ;;
925
            "h")
926
                dir_path="$base_destination/$year/$month/$day/$hour"
927
                filename="${minute}-${second}.${lowercase_ext}"
928
                ;;
Bogdan Timofte authored 9 months ago
929
            "ym")
930
                # Single folder per month named yyyy-mm; filename includes day and time
931
                dir_path="$base_destination/${year}-${month}"
932
                filename="${day}_${hour}-${minute}-${second}.${lowercase_ext}"
933
                ;;
Bogdan Timofte authored 8 months ago
934
            "ymd")
Bogdan Timofte authored 9 months ago
935
                # Single folder per day named yyyy-mm-dd; filename is time
936
                dir_path="$base_destination/${year}-${month}-${day}"
937
                filename="${hour}-${minute}-${second}.${lowercase_ext}"
938
                ;;
Bogdan Timofte authored 9 months ago
939
            *)
940
                log_message "Invalid organization pattern: $ORGANIZATION" "ERROR"
941
                return 1
942
                ;;
943
        esac
Bogdan Timofte authored 9 months ago
944

            
945
    # Apply filename mode overrides
946
    case "$FILENAME_MODE" in
947
        orig)
948
            filename="$original_filename"
949
            ;;
950
        full)
951
            filename="${year}-${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
952
            ;;
953
        auto)
954
            # keep the auto-generated filename from the organization case
955
            ;;
956
        *)
957
            # fallback to full
958
            filename="${year}-${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
959
            ;;
960
    esac
961

            
Bogdan Timofte authored 9 months ago
962
    echo "$dir_path/$filename"
963
    return 0
964
}
965

            
966
# Function to find files matching patterns
967
find_source_files() {
Bogdan Timofte authored 9 months ago
968
    # Emit absolute file paths for all media files under sources, excluding DESTINATION when it is inside a source
969
    local abs_dest=""
970
    if [[ -n "$DESTINATION" ]]; then
971
        abs_dest=$(cd "$DESTINATION" 2>/dev/null && pwd) || abs_dest="$DESTINATION"
972
    fi
973

            
974
    # Build -iname expression for find
975
    local ext_expr=""
976
    for ext in "${MEDIA_EXTENSIONS[@]}"; do
977
        if [[ -n "$ext_expr" ]]; then
978
            ext_expr="$ext_expr -o"
979
        fi
980
        ext_expr="$ext_expr -iname $ext"
981
    done
982

            
Bogdan Timofte authored 9 months ago
983
    if [[ ${#SOURCE_PATTERNS[@]} -eq 0 ]]; then
Bogdan Timofte authored 9 months ago
984
        # Default: scan current directory
985
        local start_dot="."
986
        local abs_current
987
        abs_current=$(pwd)
988
        local find_cmd=(find -L "$start_dot" -type f)
989
        # If dest is inside cwd, add exclusion
990
        if [[ -n "$abs_dest" && "$abs_dest" == "$abs_current"* ]]; then
991
            find_cmd+=( ! -path "$abs_dest/*" ! -path "$abs_dest" )
Bogdan Timofte authored 9 months ago
992
        fi
Bogdan Timofte authored 9 months ago
993
        # Add expression
994
        # shellcheck disable=SC2068
Bogdan Timofte authored 2 weeks ago
995
        "${find_cmd[@]}" ! -name '._*' \( $ext_expr \) 2>/dev/null || true
Bogdan Timofte authored 9 months ago
996
    else
Bogdan Timofte authored 9 months ago
997
        # Scan each provided source
998
        for src in "${SOURCE_PATTERNS[@]}"; do
999
            if [[ -f "$src" ]]; then
Bogdan Timofte authored 2 weeks ago
1000
                if [[ "$(basename "$src")" == ._* ]]; then
1001
                    continue
1002
                fi
Bogdan Timofte authored 9 months ago
1003
                # single file - skip if it's inside dest
1004
                local abs_file
1005
                abs_file=$(cd "$(dirname "$src")" 2>/dev/null && pwd)/$(basename "$src")
1006
                if [[ -n "$abs_dest" && "$abs_file" == "$abs_dest"* ]]; then
1007
                    continue
1008
                fi
1009
                echo "$abs_file"
1010
            elif [[ -d "$src" ]]; then
1011
                local abs_src
1012
                abs_src=$(cd "$src" 2>/dev/null && pwd)
1013
                if [[ -n "$abs_src" ]]; then
1014
                    if [[ -n "$abs_dest" && "$abs_dest" == "$abs_src"* ]]; then
Bogdan Timofte authored 2 weeks ago
1015
                        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
1016
                    else
Bogdan Timofte authored 2 weeks ago
1017
                        find -L "$abs_src" -type f ! -name '._*' \( $ext_expr \) 2>/dev/null || true
Bogdan Timofte authored 9 months ago
1018
                    fi
Bogdan Timofte authored 9 months ago
1019
                else
1020
                    print_color "$YELLOW" "Warning: Could not resolve source directory: $src"
Bogdan Timofte authored 9 months ago
1021
                fi
Bogdan Timofte authored 9 months ago
1022
            else
1023
                print_color "$YELLOW" "Warning: Source path does not exist or is not a file/directory: $src"
Bogdan Timofte authored 9 months ago
1024
            fi
1025
        done
1026
    fi
1027
}
1028

            
Bogdan Timofte authored 2 weeks ago
1029
cleanup_media_sidecars() {
Bogdan Timofte authored 2 weeks ago
1030
    local file="$1"
1031
    local dir base stem ext sidecar_ext sidecar
1032
    dir=$(dirname "$file")
1033
    base=$(basename "$file")
1034
    stem="${base%.*}"
1035
    ext="${base##*.}"
1036

            
1037
    if [[ ! "$ext" =~ ^([Mm][Pp]4)$ ]]; then
1038
        return 0
1039
    fi
1040

            
Bogdan Timofte authored 2 weeks ago
1041
    local sidecar_exts
1042
    sidecar_exts=$(get_device_sidecar_extensions "$file")
1043
    [[ -z "$sidecar_exts" ]] && return 0
1044

            
1045
    for sidecar_ext in $sidecar_exts; do
Bogdan Timofte authored 2 weeks ago
1046
        sidecar="$dir/$stem.$sidecar_ext"
1047
        if [[ -f "$sidecar" ]]; then
1048
            if rm -f "$sidecar"; then
Bogdan Timofte authored 2 weeks ago
1049
                log_message "Deleted sidecar: $sidecar" "INFO"
Bogdan Timofte authored 2 weeks ago
1050
            else
Bogdan Timofte authored 2 weeks ago
1051
                log_message "Failed to delete sidecar: $sidecar" "WARNING"
Bogdan Timofte authored 2 weeks ago
1052
            fi
1053
        fi
1054
    done
1055
}
1056

            
Bogdan Timofte authored 9 months ago
1057
# Function to process a single file
1058
process_file() {
1059
    local file="$1"
1060
    local file_size=$(get_file_size "$file")
Bogdan Timofte authored 2 weeks ago
1061
    local file_label
1062
    file_label="$(basename "$file")"
Bogdan Timofte authored 9 months ago
1063
    TOTAL_SIZE=$((TOTAL_SIZE + file_size))
1064

            
Bogdan Timofte authored 2 weeks ago
1065
    if [[ $TOTAL_FILES -gt 0 && $CURRENT_FILE_INDEX -gt 0 ]]; then
1066
        print_color "$BLUE" "Processing [$CURRENT_FILE_INDEX/$TOTAL_FILES]: $file_label ($(format_size "$file_size"))"
1067
    else
1068
        print_color "$BLUE" "Processing: $file_label ($(format_size "$file_size"))"
1069
    fi
Bogdan Timofte authored 9 months ago
1070
    log_message "Processing: $file" "INFO"
1071

            
1072
    # Extract date information
1073
    local date_info=$(extract_file_date "$file")
1074
        local extract_status=$?
1075
        if [[ $extract_status -eq 2 ]]; then
1076
            if [[ $COLLECT_UNSORTABLE -eq 1 ]]; then
1077
                local unsortable_dir="$DESTINATION/unsortable"
1078
                mkdir -p "$unsortable_dir"
1079
                local unsortable_path="$unsortable_dir/$(basename "$file")"
Bogdan Timofte authored 2 weeks ago
1080
                local desired_unsortable_path="$unsortable_path"
1081
                local unsortable_conflict_status
1082
                resolve_destination_conflict "$unsortable_path" "$file"
1083
                unsortable_conflict_status=$?
1084
                if [[ $unsortable_conflict_status -eq 0 ]]; then
1085
                    unsortable_path="$RESOLVED_DESTINATION_PATH"
1086
                    if [[ "$unsortable_path" != "$desired_unsortable_path" ]]; then
1087
                        log_message "Destination already exists or is already planned: $desired_unsortable_path - using: $unsortable_path" "WARNING"
1088
                    fi
1089
                elif [[ $unsortable_conflict_status -eq 3 ]]; then
1090
                    log_message "Destination conflict skipped: $desired_unsortable_path" "WARNING"
1091
                    SKIPPED_FILES=$((SKIPPED_FILES + 1))
1092
                    return 1
1093
                elif [[ $unsortable_conflict_status -eq 4 ]]; then
1094
                    log_message "Import aborted by user at destination conflict: $desired_unsortable_path" "ERROR"
1095
                    ERROR_FILES=$((ERROR_FILES + 1))
1096
                    FATAL_ERROR=1
1097
                    return 2
1098
                else
1099
                    log_message "Could not resolve a unique destination path for $file (wanted: $desired_unsortable_path)" "ERROR"
1100
                    ERROR_FILES=$((ERROR_FILES + 1))
1101
                    return 1
1102
                fi
Bogdan Timofte authored 9 months ago
1103
                if [[ $DRY_RUN -eq 1 ]]; then
1104
                    print_color "$BLUE" "Would move unsortable: $file -> $unsortable_path"
1105
                else
Bogdan Timofte authored 3 weeks ago
1106
                    if verified_move_file "$file" "$unsortable_path" ""; then
Bogdan Timofte authored 9 months ago
1107
                        log_message "Unsortable: $file -> $unsortable_path" "SUCCESS"
1108
                    else
Bogdan Timofte authored 3 weeks ago
1109
                        log_message "Failed to move unsortable file after verification: $file" "ERROR"
Bogdan Timofte authored 9 months ago
1110
                    fi
1111
                fi
1112
                SKIPPED_FILES=$((SKIPPED_FILES + 1))
1113
            else
1114
                log_message "Could not extract date from $file - skipping" "WARNING"
1115
                SKIPPED_FILES=$((SKIPPED_FILES + 1))
1116
            fi
1117
            return 1
1118
        elif [[ $extract_status -ne 0 ]] || [[ -z "$date_info" ]]; then
1119
            log_message "Could not extract date from $file - skipping" "WARNING"
1120
            SKIPPED_FILES=$((SKIPPED_FILES + 1))
1121
            return 1
1122
        fi
1123
        local date_str="${date_info%|*}"
1124
        local date_source="${date_info#*|}"
1125
        log_message "Date: $date_str (from $date_source)" "INFO"
1126
        # Generate destination path
Bogdan Timofte authored 2 weeks ago
1127
        local original_basename
1128
        original_basename="$(basename "$file")"
1129
        local dest_path
1130
        dest_path=$(generate_destination_path "$date_str" "$original_basename" "$DESTINATION")
Bogdan Timofte authored 9 months ago
1131
        if [[ $? -ne 0 ]] || [[ -z "$dest_path" ]]; then
1132
            log_message "Could not generate destination path for $file" "ERROR"
1133
            ERROR_FILES=$((ERROR_FILES + 1))
1134
            FATAL_ERROR=1
1135
            return 2
1136
    fi
Bogdan Timofte authored 2 weeks ago
1137

            
1138
    local desired_dest_path="$dest_path"
1139
    local conflict_status
1140
    resolve_destination_conflict "$dest_path" "$file"
1141
    conflict_status=$?
1142
    if [[ $conflict_status -eq 0 ]]; then
1143
        dest_path="$RESOLVED_DESTINATION_PATH"
1144
    elif [[ $conflict_status -eq 3 ]]; then
1145
        log_message "Destination conflict skipped: $desired_dest_path" "WARNING"
1146
        SKIPPED_FILES=$((SKIPPED_FILES + 1))
1147
        return 1
1148
    elif [[ $conflict_status -eq 4 ]]; then
1149
        log_message "Import aborted by user at destination conflict: $desired_dest_path" "ERROR"
1150
        ERROR_FILES=$((ERROR_FILES + 1))
1151
        FATAL_ERROR=1
1152
        return 2
1153
    else
1154
        log_message "Could not resolve a unique destination path for $file (wanted: $desired_dest_path)" "ERROR"
1155
        ERROR_FILES=$((ERROR_FILES + 1))
1156
        return 1
Bogdan Timofte authored 9 months ago
1157
    fi
Bogdan Timofte authored 2 weeks ago
1158
    if [[ "$dest_path" != "$desired_dest_path" ]]; then
1159
        log_message "Destination already exists or is already planned: $desired_dest_path - using: $dest_path" "WARNING"
1160
    fi
1161

            
1162
    local dest_dir
1163
    dest_dir=$(dirname "$dest_path")
Bogdan Timofte authored 9 months ago
1164

            
1165
    if [[ $DRY_RUN -eq 1 ]]; then
1166
        if [[ $KEEP_ORIGINALS -eq 1 ]]; then
1167
            print_color "$BLUE" "Would copy: $file -> $dest_path"
1168
        else
1169
            print_color "$BLUE" "Would move: $file -> $dest_path"
1170
        fi
Bogdan Timofte authored 2 weeks ago
1171
        if should_sync_imported_metadata "$original_basename" "$date_source"; then
1172
            print_color "$BLUE" "Would sync metadata date: $dest_path -> $date_str"
1173
        fi
Bogdan Timofte authored 9 months ago
1174
        PROCESSED_FILES=$((PROCESSED_FILES + 1))
1175
        PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
1176
        return 0
1177
    fi
1178

            
1179
    # Create destination directory
1180
    if ! mkdir -p "$dest_dir"; then
1181
        log_message "Could not create directory: $dest_dir" "ERROR"
1182
        ERROR_FILES=$((ERROR_FILES + 1))
1183
        return 1
1184
    fi
1185

            
Bogdan Timofte authored 2 weeks ago
1186
    local sync_metadata_after_copy=0
1187
    local verification_date="$date_str"
1188
    if should_sync_imported_metadata "$original_basename" "$date_source"; then
1189
        sync_metadata_after_copy=1
1190
        verification_date=""
1191
    fi
1192

            
1193
    # Copy or move file using safe helpers after destination conflicts are resolved.
Bogdan Timofte authored 9 months ago
1194
    if [[ $KEEP_ORIGINALS -eq 1 ]]; then
Bogdan Timofte authored 2 weeks ago
1195
        if copy_with_verification "$file" "$dest_path" "$verification_date"; then
1196
            if [[ $sync_metadata_after_copy -eq 1 ]]; then
1197
                if ! sync_destination_metadata_to_date "$dest_path" "$date_str"; then
1198
                    log_message "Failed to sync destination metadata timestamps: $dest_path" "ERROR"
1199
                    ERROR_FILES=$((ERROR_FILES + 1))
1200
                    return 1
1201
                elif ! verify_synced_metadata_date "$dest_path" "$date_str"; then
1202
                    ERROR_FILES=$((ERROR_FILES + 1))
1203
                    return 1
1204
                fi
1205
            fi
Bogdan Timofte authored 9 months ago
1206
            log_message "Copied: $file -> $dest_path" "SUCCESS"
Bogdan Timofte authored 2 weeks ago
1207
            if [[ $CLEANUP_MEDIA_SIDECARS -eq 1 ]]; then
1208
                cleanup_media_sidecars "$file"
Bogdan Timofte authored 2 weeks ago
1209
            fi
Bogdan Timofte authored 9 months ago
1210
            PROCESSED_FILES=$((PROCESSED_FILES + 1))
1211
            PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
1212
            return 0
1213
        else
Bogdan Timofte authored 3 weeks ago
1214
            log_message "Failed to copy or verify destination: $file -> $dest_path" "ERROR"
Bogdan Timofte authored 9 months ago
1215
            ERROR_FILES=$((ERROR_FILES + 1))
1216
            return 1
1217
        fi
1218
    else
Bogdan Timofte authored 2 weeks ago
1219
        if [[ $sync_metadata_after_copy -eq 1 ]]; then
1220
            if copy_with_verification "$file" "$dest_path" "$verification_date"; then
1221
                if ! sync_destination_metadata_to_date "$dest_path" "$date_str"; then
1222
                    log_message "Failed to sync destination metadata timestamps: $dest_path" "ERROR"
1223
                    ERROR_FILES=$((ERROR_FILES + 1))
1224
                    return 1
1225
                fi
1226
                if ! verify_synced_metadata_date "$dest_path" "$date_str"; then
1227
                    ERROR_FILES=$((ERROR_FILES + 1))
1228
                    return 1
1229
                fi
1230
                if ! remove_source_file "$file"; then
1231
                    log_message "Copied, verified, and synced destination, but failed to remove source: $file" "ERROR"
1232
                    ERROR_FILES=$((ERROR_FILES + 1))
1233
                    return 1
1234
                fi
1235
                log_message "Moved: $file -> $dest_path" "SUCCESS"
Bogdan Timofte authored 2 weeks ago
1236
            if [[ $CLEANUP_MEDIA_SIDECARS -eq 1 ]]; then
1237
                cleanup_media_sidecars "$file"
Bogdan Timofte authored 2 weeks ago
1238
            fi
Bogdan Timofte authored 2 weeks ago
1239
                PROCESSED_FILES=$((PROCESSED_FILES + 1))
1240
                PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
1241
                return 0
1242
            else
1243
                log_message "Failed to move after copy verification: $file -> $dest_path" "ERROR"
1244
                ERROR_FILES=$((ERROR_FILES + 1))
1245
                return 1
1246
            fi
1247
        elif verified_move_file "$file" "$dest_path" "$date_str"; then
Bogdan Timofte authored 9 months ago
1248
            log_message "Moved: $file -> $dest_path" "SUCCESS"
Bogdan Timofte authored 2 weeks ago
1249
            if [[ $CLEANUP_MEDIA_SIDECARS -eq 1 ]]; then
1250
                cleanup_media_sidecars "$file"
Bogdan Timofte authored 2 weeks ago
1251
            fi
Bogdan Timofte authored 2 weeks ago
1252
            if [[ $sync_metadata_after_copy -eq 1 ]]; then
1253
                if ! sync_destination_metadata_to_date "$dest_path" "$date_str"; then
1254
                    log_message "Failed to sync destination metadata timestamps: $dest_path" "WARNING"
1255
                elif ! verify_synced_metadata_date "$dest_path" "$date_str"; then
1256
                    log_message "Failed to verify synced destination metadata timestamps: $dest_path" "WARNING"
1257
                fi
1258
            fi
Bogdan Timofte authored 9 months ago
1259
            PROCESSED_FILES=$((PROCESSED_FILES + 1))
1260
            PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
1261
            return 0
1262
        else
Bogdan Timofte authored 3 weeks ago
1263
            log_message "Failed to move after copy verification: $file -> $dest_path" "ERROR"
Bogdan Timofte authored 9 months ago
1264
            ERROR_FILES=$((ERROR_FILES + 1))
1265
            return 1
1266
        fi
1267
    fi
1268
}
1269

            
1270
# Function to display final report
1271
show_report() {
1272
    local end_time=$(date +%s)
1273
    local elapsed_time=$((end_time - START_TIME))
1274
    local hours=$((elapsed_time / 3600))
1275
    local minutes=$(((elapsed_time % 3600) / 60))
1276
    local seconds=$((elapsed_time % 60))
1277

            
1278
    echo ""
1279
    print_color "$GREEN" "=========================================="
1280
    print_color "$GREEN" "           PROCESSING REPORT"
1281
    print_color "$GREEN" "=========================================="
1282
    echo ""
Bogdan Timofte authored 2 weeks ago
1283

            
1284
    echo "Files Summary:"
1285
    report_line "Total files found:" "$TOTAL_FILES"
1286
    report_line "Successfully processed:" "$PROCESSED_FILES"
1287
    report_line "Skipped:" "$SKIPPED_FILES"
1288
    report_line "Errors:" "$ERROR_FILES"
1289
    echo ""
1290

            
Bogdan Timofte authored 9 months ago
1291
    echo "Size Summary:"
Bogdan Timofte authored 2 weeks ago
1292
    report_line "Total size found:" "$(format_size $TOTAL_SIZE)"
1293
    report_line "Successfully processed:" "$(format_size $PROCESSED_SIZE)"
Bogdan Timofte authored 9 months ago
1294
    echo ""
Bogdan Timofte authored 2 weeks ago
1295

            
1296
    echo "Time Summary:"
1297
    report_line "Time elapsed:" "$(printf "%02d:%02d:%02d" $hours $minutes $seconds)"
1298
    if [[ $elapsed_time -gt 0 && $PROCESSED_SIZE -gt 0 ]]; then
1299
        local data_rate
1300
        data_rate=$(format_data_rate "$PROCESSED_SIZE" "$elapsed_time")
1301
        if [[ -n "$data_rate" ]]; then
1302
            report_line "Data rate:" "$data_rate"
1303
        fi
1304
    fi
1305

            
1306
    echo ""
1307

            
Bogdan Timofte authored 9 months ago
1308
    if [[ $DRY_RUN -eq 1 ]]; then
1309
        print_color "$YELLOW" "DRY RUN MODE - No files were actually moved/copied"
1310
    elif [[ $KEEP_ORIGINALS -eq 1 ]]; then
1311
        print_color "$BLUE" "COPY MODE - Original files were preserved"
1312
    else
1313
        print_color "$GREEN" "MOVE MODE - Files were moved to destination"
1314
    fi
1315

            
1316
    echo ""
1317
    print_color "$GREEN" "=========================================="
1318
}
1319

            
Bogdan Timofte authored 2 weeks ago
1320
if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then
1321
    return 0
1322
fi
1323

            
Bogdan Timofte authored 9 months ago
1324
# Parse command line arguments
1325
while [[ $# -gt 0 ]]; do
1326
    case $1 in
1327
        -o|--organization)
1328
            ORGANIZATION="$2"
Bogdan Timofte authored 8 months ago
1329
            # Accept new patterns: ym, ymd as well as single-letter ones
1330
            if [[ ! "$ORGANIZATION" =~ ^(y|m|d|h|ym|ymd)$ ]]; then
Bogdan Timofte authored 9 months ago
1331
                print_color "$RED" "Error: Invalid organization pattern. Must be one of: y, m, d, h, ym, ymd"
Bogdan Timofte authored 9 months ago
1332
                exit 1
1333
            fi
1334
            shift 2
1335
            ;;
Bogdan Timofte authored 9 months ago
1336

            
1337
        -F|--filename-mode)
1338
            FILENAME_MODE="$2"
1339
            if [[ ! "$FILENAME_MODE" =~ ^(auto|full|orig)$ ]]; then
1340
                print_color "$RED" "Error: Invalid filename mode. Must be one of: auto, full, orig"
1341
                exit 1
1342
            fi
1343
            shift 2
Bogdan Timofte authored 9 months ago
1344
            ;;
Bogdan Timofte authored 9 months ago
1345
    # (conflict resolution option removed; let exiftool/filesystem handle naming conflicts)
Bogdan Timofte authored 9 months ago
1346
        --collect-unsortable)
1347
            COLLECT_UNSORTABLE=1
1348
            shift
1349
            ;;
Bogdan Timofte authored 8 months ago
1350
        --keep-empty-dirs)
1351
            CLEANUP_EMPTY_DIRS=0
1352
            shift
1353
            ;;
Bogdan Timofte authored 9 months ago
1354
        -s|--source)
1355
            SOURCE_PATTERNS+=("$2")
1356
            shift 2
1357
            ;;
1358
        -d|--destination)
1359
            DESTINATION="$2"
1360
            shift 2
1361
            ;;
1362
        -k|--keep-originals)
1363
            KEEP_ORIGINALS=1
1364
            shift
1365
            ;;
Bogdan Timofte authored 3 weeks ago
1366
        --verify-mode)
1367
            VERIFY_MODE="$2"
1368
            if [[ ! "$VERIFY_MODE" =~ ^(size|strict|none)$ ]]; then
1369
                print_color "$RED" "Error: Invalid verify mode. Must be one of: size, strict, none"
1370
                exit 1
1371
            fi
1372
            shift 2
1373
            ;;
Bogdan Timofte authored 2 weeks ago
1374
        --date-source)
1375
            DATE_SOURCE="$2"
1376
            if [[ ! "$DATE_SOURCE" =~ ^(auto|exif|filesystem)$ ]]; then
1377
                print_color "$RED" "Error: Invalid date source. Must be one of: auto, exif, filesystem"
1378
                exit 1
1379
            fi
1380
            shift 2
1381
            ;;
1382
        --sync-metadata)
1383
            SYNC_METADATA=1
1384
            shift
1385
            ;;
1386
        --unattended)
1387
            UNATTENDED=1
1388
            shift
1389
            ;;
Bogdan Timofte authored 2 weeks ago
1390
        --keep-sidecars)
1391
            CLEANUP_MEDIA_SIDECARS=0
Bogdan Timofte authored 2 weeks ago
1392
            shift
1393
            ;;
Bogdan Timofte authored 9 months ago
1394
        --dry-run)
1395
            DRY_RUN=1
1396
            shift
1397
            ;;
1398
        -v|--verbose)
1399
            VERBOSE=1
1400
            shift
1401
            ;;
1402
        -h|--help)
1403
            show_help
1404
            exit 0
1405
            ;;
1406
        --version)
1407
            show_version
1408
            exit 0
1409
            ;;
1410
        *)
1411
            print_color "$RED" "Error: Unknown option: $1"
1412
            echo "Use -h or --help for usage information."
1413
            exit 1
1414
            ;;
1415
    esac
1416
done
1417

            
Bogdan Timofte authored 2 weeks ago
1418
# Non-interactive execution cannot safely ask conflict questions.
1419
if [[ $UNATTENDED -eq 0 && ( ! -t 0 || ! -t 1 ) ]]; then
1420
    UNATTENDED=1
1421
fi
1422

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

            
Bogdan Timofte authored 3 weeks ago
1425
# If no source specified, default to current directory. Refuse to run when cwd is unsafe.
1426
if [[ ${#SOURCE_PATTERNS[@]} -eq 0 ]]; then
1427
    cwd=$(pwd)
1428
    # Resolve home and root paths
1429
    home_dir="$HOME"
1430
    case "$cwd" in
1431
        "$home_dir"|"/"|"/etc"|"/var"|"/bin"|"/sbin"|"/dev"|"/proc"|"/sys"|"/run"|"/usr"|"/lib"|"/lib64"|"/boot"|"/snap")
1432
            print_color "$RED" "Refusing to run with default source in unsafe directory: $cwd"
1433
            print_color "$RED" "Please specify $cwd as source explicitly with -s or run from a safer directory."
1434
            exit 1
1435
            ;;
1436
        *)
1437
            SOURCE_PATTERNS+=("$cwd")
1438
            ;;
1439
    esac
1440
fi
1441

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

            
1450
    if [[ ${#SOURCE_PATTERNS[@]} -gt 0 ]]; then
1451
        first_source="${SOURCE_PATTERNS[0]}"
Bogdan Timofte authored 3 weeks ago
1452
        if [[ -d "$first_source" ]]; then
1453
            DESTINATION="$first_source/sorted"
1454
        elif [[ -f "$first_source" ]]; then
1455
            DESTINATION="$(dirname "$first_source")/sorted"
Bogdan Timofte authored 9 months ago
1456
        else
1457
            print_color "$YELLOW" "Warning: first source does not exist; defaulting destination to ./sorted"
1458
            DESTINATION="./sorted"
1459
        fi
1460
    else
1461
        DESTINATION="./sorted"
1462
    fi
Bogdan Timofte authored 9 months ago
1463
fi
1464

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

            
1468
# Display configuration
1469
print_color "$GREEN" "$SCRIPT_NAME v$VERSION"
1470
echo ""
1471
echo "Configuration:"
1472
echo "  Organization pattern: $ORGANIZATION"
1473
echo "  Destination:         $DESTINATION"
1474
echo "  Keep originals:      $([ $KEEP_ORIGINALS -eq 1 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored 3 weeks ago
1475
echo "  Verify mode:         $VERIFY_MODE"
Bogdan Timofte authored 2 weeks ago
1476
echo "  Date source:         $DATE_SOURCE"
1477
echo "  Sync metadata:       $([ $SYNC_METADATA -eq 1 ] && echo "Yes" || echo "No")"
1478
echo "  Unattended:          $([ $UNATTENDED -eq 1 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored 9 months ago
1479
echo "  Dry run:             $([ $DRY_RUN -eq 1 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored 8 months ago
1480
echo "  Keep empty dirs:     $([ $CLEANUP_EMPTY_DIRS -eq 0 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored 9 months ago
1481
echo "  Verbose:             $([ $VERBOSE -eq 1 ] && echo "Yes" || echo "No")"
1482

            
1483
if [[ ${#SOURCE_PATTERNS[@]} -gt 0 ]]; then
1484
    echo "  Source patterns:"
1485
    for pattern in "${SOURCE_PATTERNS[@]}"; do
1486
        echo "    - $pattern"
1487
    done
1488
else
1489
    echo "  Source patterns:     All media files in current directory"
1490
fi
1491

            
1492
echo ""
1493

            
1494
# Check dependencies
1495
check_dependencies
1496

            
1497
# Create destination directory if it doesn't exist (unless dry run)
1498
if [[ $DRY_RUN -eq 0 ]]; then
1499
    if ! mkdir -p "$DESTINATION"; then
1500
        print_color "$RED" "Error: Cannot create destination directory: $DESTINATION"
1501
        exit 1
1502
    fi
1503
fi
1504

            
1505
# Find all source files
1506

            
1507
print_color "$BLUE" "Scanning for media files..."
1508
files=()
1509
while IFS= read -r file; do
1510
    files+=("$file")
1511
done < <(find_source_files)
Bogdan Timofte authored 2 weeks ago
1512
if [[ ${#files[@]} -gt 0 ]]; then
1513
    IFS=$'\n' files=($(printf '%s\n' "${files[@]}" | sort))
1514
    unset IFS
1515
fi
Bogdan Timofte authored 9 months ago
1516
TOTAL_FILES=${#files[@]}
1517

            
1518
if [[ $TOTAL_FILES -eq 0 ]]; then
1519
    print_color "$YELLOW" "No media files found matching the specified patterns."
1520
    exit 0
1521
fi
1522

            
1523
print_color "$BLUE" "Found $TOTAL_FILES media files to process"
1524
echo ""
1525

            
1526
# Process each file
1527

            
1528
FATAL_ERROR=0
1529
for file in "${files[@]}"; do
1530
    if [[ -f "$file" ]]; then
Bogdan Timofte authored 2 weeks ago
1531
        CURRENT_FILE_INDEX=$((CURRENT_FILE_INDEX + 1))
Bogdan Timofte authored 9 months ago
1532
        process_file "$file"
1533
        if [[ $FATAL_ERROR -eq 1 ]]; then
1534
            print_color "$RED" "Fatal error encountered. Stopping further processing."
1535
            break
1536
        fi
1537
    fi
1538
done
1539

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

            
Bogdan Timofte authored 9 months ago
1548
# Show final report
1549
show_report
1550

            
1551
# Exit with appropriate code
1552
if [[ $ERROR_FILES -gt 0 ]]; then
1553
    exit 1
1554
else
1555
    exit 0
1556
fi