MediaImporter / media-importer.sh
Newer Older
1495 lines | 50.581kb
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
# Safe move/copy helpers: filter out benign "set flags (was: ...): Operation not supported"
211
# which appears when moving files onto filesystems that don't support BSD file flags
212
# (macOS mv may try to preserve flags and print this warning while still succeeding).
213
safe_mv() {
214
    local src="$1" dst="$2"
Bogdan Timofte authored 2 weeks ago
215
    if [[ -e "$dst" ]]; then
216
        log_message "Refusing to overwrite existing destination: $dst" "ERROR"
217
        return 1
218
    fi
Bogdan Timofte authored 9 months ago
219
    # Redirect stderr through a filter that removes the known benign message
220
    mv "$src" "$dst" 2> >(grep -v "set flags (was:" >&2)
221
    return $?
222
}
223

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

            
234
ensure_unique_destination_path() {
235
    local desired_path="$1"
236

            
237
    if [[ -z "$desired_path" ]]; then
238
        return 1
239
    fi
240

            
241
    if ! destination_path_unavailable "$desired_path"; then
242
        echo "$desired_path"
243
        return 0
244
    fi
245

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

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

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

            
273
    return 1
274
}
275

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

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

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

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

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

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

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

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

            
358
    if [[ -z "$desired_path" ]]; then
359
        return 1
360
    fi
361

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

            
368
    if [[ "$CONFLICT_APPLY_ALL" == "skip" ]]; then
369
        return 3
370
    fi
371

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

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

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

            
417
    if [[ -z "$resolved_path" ]]; then
418
        return 1
419
    fi
420

            
421
    RESOLVED_DESTINATION_PATH="$resolved_path"
422
    reserve_destination_path "$RESOLVED_DESTINATION_PATH"
423
    return 0
424
}
425

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

            
435
    local epoch=""
436

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

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

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

            
468
    echo "$file"
469
}
470

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

            
477
should_prefer_gopro_filesystem_date() {
478
    local file="$1"
479

            
480
    is_gopro_media_file "$file"
481
}
482

            
483
filesystem_date_source_label() {
484
    local file="$1"
485
    local reference="$2"
486

            
487
    if is_gopro_media_file "$file"; then
488
        echo "Filesystem:$(basename "$reference")"
489
    elif [[ "$reference" != "$file" ]]; then
490
        echo "Filesystem:$(basename "$reference")"
491
    else
492
        echo "Filesystem"
493
    fi
494
}
495

            
496
date_to_exiftool_format() {
497
    # yyyy-mm-dd hh:mm:ss -> yyyy:mm:dd hh:mm:ss
498
    local s="$1"
499
    if [[ "$s" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})[[:space:]]+([0-9]{2}):([0-9]{2}):([0-9]{2})$ ]]; then
500
        echo "${BASH_REMATCH[1]}:${BASH_REMATCH[2]}:${BASH_REMATCH[3]} ${BASH_REMATCH[4]}:${BASH_REMATCH[5]}:${BASH_REMATCH[6]}"
501
        return 0
502
    fi
503
    return 1
504
}
505

            
506
sync_destination_metadata_to_date() {
507
    local file="$1"
508
    local date_str="$2" # yyyy-mm-dd hh:mm:ss
509

            
510
    local exif_dt
511
    exif_dt=$(date_to_exiftool_format "$date_str") || return 1
512

            
513
    exiftool -overwrite_original \
514
        "-CreateDate=$exif_dt" \
515
        "-DateTimeOriginal=$exif_dt" \
516
        "-DateTime=$exif_dt" \
517
        "-ModifyDate=$exif_dt" \
518
        "-MediaCreateDate=$exif_dt" \
519
        "-TrackCreateDate=$exif_dt" \
520
        "-QuickTime:CreateDate=$exif_dt" \
521
        "-QuickTime:ModifyDate=$exif_dt" \
522
        "$file" >/dev/null 2>&1
523
    return 0
524
}
525

            
526
verify_synced_metadata_date() {
527
    local file="$1"
528
    local expected_date="$2"
529

            
530
    local metadata_date
531
    metadata_date=$(exiftool -api QuickTimeUTC=0 -s -s -s -QuickTime:CreateDate "$file" 2>/dev/null | head -1)
532
    if [[ -z "$metadata_date" ]]; then
533
        metadata_date=$(exiftool -api QuickTimeUTC=0 -s -s -s -CreateDate "$file" 2>/dev/null | head -1)
534
    fi
535

            
536
    if [[ "$metadata_date" =~ ^([0-9]{4}):([0-9]{2}):([0-9]{2})[[:space:]]+([0-9]{2}):([0-9]{2}):([0-9]{2}) ]]; then
537
        metadata_date="${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]} ${BASH_REMATCH[4]}:${BASH_REMATCH[5]}:${BASH_REMATCH[6]}"
538
    fi
539

            
540
    if [[ "$metadata_date" != "$expected_date" ]]; then
541
        log_message "Destination metadata sync mismatch: expected $expected_date, got ${metadata_date:-none} for $file" "ERROR"
542
        return 1
543
    fi
544

            
545
    return 0
546
}
547

            
548
should_sync_imported_metadata() {
549
    local original_filename="$1"
550
    local date_source="$2"
551

            
552
    if [[ $SYNC_METADATA -eq 1 ]]; then
553
        return 0
554
    fi
555

            
556
    if [[ "$date_source" == Filesystem* ]] && is_gopro_media_file "$original_filename"; then
557
        return 0
558
    fi
559

            
560
    return 1
Bogdan Timofte authored 9 months ago
561
}
562

            
Bogdan Timofte authored 3 weeks ago
563
verify_copied_file() {
564
    local src="$1"
565
    local dst="$2"
566
    local expected_date="$3"
567

            
568
    if [[ ! -f "$dst" ]]; then
569
        log_message "Verified copy missing at destination: $dst" "ERROR"
570
        return 1
571
    fi
572

            
573
    local src_size dst_size
574
    src_size=$(get_file_size "$src")
575
    dst_size=$(get_file_size "$dst")
576
    if [[ "$src_size" != "$dst_size" ]]; then
577
        log_message "Size mismatch after copy: $src ($src_size) != $dst ($dst_size)" "ERROR"
578
        return 1
579
    fi
580

            
581
    if [[ "$VERIFY_MODE" == "strict" ]]; then
582
        if ! cmp -s "$src" "$dst"; then
583
            log_message "Content mismatch after copy: $src -> $dst" "ERROR"
584
            return 1
585
        fi
586
    elif [[ "$VERIFY_MODE" == "none" ]]; then
587
        return 0
588
    fi
589

            
590
    if [[ -n "$expected_date" ]]; then
591
        local destination_date_info
592
        destination_date_info=$(extract_file_date "$dst")
593
        local extract_status=$?
594
        if [[ $extract_status -ne 0 || -z "$destination_date_info" ]]; then
595
            log_message "Destination metadata validation failed: $dst" "ERROR"
596
            return 1
597
        fi
598

            
599
        local destination_date="${destination_date_info%|*}"
600
        if [[ "$destination_date" != "$expected_date" ]]; then
601
            log_message "Destination metadata mismatch: expected $expected_date, got $destination_date for $dst" "ERROR"
602
            return 1
603
        fi
604
    fi
605

            
606
    return 0
607
}
608

            
609
remove_source_file() {
610
    local src="$1"
611
    rm -f "$src"
612
}
613

            
614
copy_with_verification() {
615
    local src="$1"
616
    local dst="$2"
617
    local expected_date="$3"
618

            
Bogdan Timofte authored 2 weeks ago
619
    if [[ -e "$dst" ]]; then
620
        log_message "Refusing to overwrite existing destination: $dst" "ERROR"
Bogdan Timofte authored 3 weeks ago
621
        return 1
622
    fi
623

            
Bogdan Timofte authored 2 weeks ago
624
    local dst_dir tmp
625
    dst_dir=$(dirname "$dst")
626
    tmp=$(mktemp "$dst_dir/.media-importer.$(basename "$dst").tmp.XXXXXX") || return 1
627
    rm -f "$tmp"
628

            
629
    if ! safe_cp "$src" "$tmp"; then
630
        rm -f "$tmp"
631
        return 1
632
    fi
633

            
634
    if ! verify_copied_file "$src" "$tmp" "$expected_date"; then
635
        rm -f "$tmp"
636
        return 1
637
    fi
638

            
639
    if [[ -e "$dst" ]]; then
640
        log_message "Destination appeared during copy, refusing to overwrite: $dst" "ERROR"
641
        rm -f "$tmp"
642
        return 1
643
    fi
644

            
645
    if ! safe_mv "$tmp" "$dst"; then
646
        rm -f "$tmp"
647
        return 1
648
    fi
649

            
650
    if [[ ! -f "$dst" ]]; then
651
        log_message "Copied file missing after final move: $dst" "ERROR"
Bogdan Timofte authored 3 weeks ago
652
        return 1
653
    fi
654

            
655
    return 0
656
}
657

            
658
verified_move_file() {
659
    local src="$1"
660
    local dst="$2"
661
    local expected_date="$3"
662

            
663
    if ! copy_with_verification "$src" "$dst" "$expected_date"; then
664
        return 1
665
    fi
666

            
667
    if ! remove_source_file "$src"; then
668
        log_message "Copied and verified destination, but failed to remove source: $src" "ERROR"
669
        return 1
670
    fi
671

            
672
    return 0
673
}
674

            
Bogdan Timofte authored 9 months ago
675
# Function to format file size
676
format_size() {
677
    local size=$1
678
    if (( size < 1024 )); then
679
        echo "${size}B"
680
    elif (( size < 1048576 )); then
681
        echo "$(( size / 1024 ))KB"
682
    elif (( size < 1073741824 )); then
683
        echo "$(( size / 1048576 ))MB"
684
    else
685
        echo "$(( size / 1073741824 ))GB"
686
    fi
687
}
688

            
Bogdan Timofte authored 2 weeks ago
689
format_duration() {
690
    local total_seconds=$1
691
    local hours=$((total_seconds / 3600))
692
    local minutes=$(((total_seconds % 3600) / 60))
693
    local seconds=$((total_seconds % 60))
694

            
695
    if (( hours > 0 )); then
696
        printf "%dh %02dm %02ds" "$hours" "$minutes" "$seconds"
697
    elif (( minutes > 0 )); then
698
        printf "%dm %02ds" "$minutes" "$seconds"
699
    else
700
        printf "%ds" "$seconds"
701
    fi
702
}
703

            
Bogdan Timofte authored 2 weeks ago
704
format_data_rate() {
705
    local bytes_count="$1"
706
    local elapsed_seconds="$2"
Bogdan Timofte authored 2 weeks ago
707

            
Bogdan Timofte authored 2 weeks ago
708
    awk -v bytes="$bytes_count" -v seconds="$elapsed_seconds" '
Bogdan Timofte authored 2 weeks ago
709
        BEGIN {
Bogdan Timofte authored 2 weeks ago
710
            if (seconds <= 0 || bytes <= 0) {
Bogdan Timofte authored 2 weeks ago
711
                exit
712
            }
713

            
714
            mb_per_second = bytes / seconds / 1048576
Bogdan Timofte authored 2 weeks ago
715
            printf "%.2f MB/sec", mb_per_second
Bogdan Timofte authored 2 weeks ago
716
        }
717
    '
718
}
719

            
Bogdan Timofte authored 2 weeks ago
720
report_line() {
721
    local label="$1"
722
    local value="$2"
723
    printf "  %-24s %s\n" "$label" "$value"
724
}
725

            
Bogdan Timofte authored 9 months ago
726
# Function to extract date from file
727
extract_file_date() {
728
    local file="$1"
729
    local create_date=""
730
    local date_source=""
731
    local exif_found=0
Bogdan Timofte authored 2 weeks ago
732

            
733
    # Filesystem authoritative mode, and GoPro media in auto mode.
734
    # GoPro fallback order is THM, LRV, then the MP4 filesystem timestamp.
735
    if [[ "$DATE_SOURCE" == "filesystem" ]] || { [[ "$DATE_SOURCE" == "auto" ]] && should_prefer_gopro_filesystem_date "$file"; }; then
736
        local filesystem_reference
737
        filesystem_reference=$(filesystem_date_reference "$file")
738
        create_date=$(extract_filesystem_date "$filesystem_reference") || return 2
739
        echo "$create_date|$(filesystem_date_source_label "$file" "$filesystem_reference")"
740
        return 0
741
    fi
742

            
Bogdan Timofte authored 9 months ago
743
    # Try to get creation date from EXIF data
744
    local exif_output=$(exiftool -G1 -s -CreateDate -DateTimeOriginal -DateTime "$file" 2>/dev/null)
745
    if [[ -n "$exif_output" ]]; then
746
        # Parse the exiftool output to find the best date
747
        while IFS= read -r line; do
748
            if [[ "$line" =~ ^\[([^\]]+)\][[:space:]]*([^:]+)[[:space:]]*:[[:space:]]*(.+)$ ]]; then
749
                local group="${BASH_REMATCH[1]}"
750
                local tag="${BASH_REMATCH[2]}"
751
                local value="${BASH_REMATCH[3]}"
752
                # Trim spaces from tag name
753
                tag=$(echo "$tag" | sed 's/[[:space:]]*$//')
754
                # Prefer DateTimeOriginal, then CreateDate, then DateTime
755
                if [[ "$tag" == "DateTimeOriginal" && -z "$create_date" ]]; then
756
                    create_date="$value"
757
                    date_source="$group:$tag"
758
                    exif_found=1
759
                elif [[ "$tag" == "CreateDate" && "$date_source" != *"DateTimeOriginal"* ]]; then
760
                    create_date="$value"
761
                    date_source="$group:$tag"
762
                    exif_found=1
763
                elif [[ "$tag" == "DateTime" && -z "$create_date" ]]; then
764
                    create_date="$value"
765
                    date_source="$group:$tag"
766
                    exif_found=1
767
                fi
768
            fi
769
        done <<< "$exif_output"
770
    fi
771
    # If no EXIF date found, try mediainfo for video files
772
    if [[ -z "$create_date" ]] && command -v mediainfo &> /dev/null; then
773
        local media_date=$(mediainfo --Inform="General;%Recorded_Date%" "$file" 2>/dev/null)
774
        if [[ -n "$media_date" && "$media_date" != "0000-00-00 00:00:00" ]]; then
775
            create_date="$media_date"
776
            date_source="MediaInfo:Recorded_Date"
777
        fi
778
    fi
Bogdan Timofte authored 2 weeks ago
779

            
780
    # In auto mode, if metadata is missing/unreliable, fall back to filesystem timestamps
781
    if [[ -z "$create_date" && "$DATE_SOURCE" == "auto" ]]; then
782
        local filesystem_reference
783
        filesystem_reference=$(filesystem_date_reference "$file")
784
        create_date=$(extract_filesystem_date "$filesystem_reference") || return 2
785
        date_source=$(filesystem_date_source_label "$file" "$filesystem_reference")
786
    fi
787

            
Bogdan Timofte authored 9 months ago
788
    # If no EXIF or mediainfo date found, return failure
789
    if [[ -z "$create_date" ]]; then
790
        return 2  # No date metadata found
791
    fi
792

            
793
    # Convert EXIF format (YYYY:MM:DD HH:MM:SS) to standard format
794
    # Always output as yyyy-mm-dd hh:mm:ss (pad single digits)
795
    if [[ "$create_date" =~ ^([0-9]{4}):([0-9]{1,2}):([0-9]{1,2})[[:space:]]*([0-9]{1,2}):([0-9]{1,2}):([0-9]{1,2})$ ]]; then
796
        year="${BASH_REMATCH[1]}"
797
        month=$(printf "%02d" "$((10#${BASH_REMATCH[2]}))")
798
        day=$(printf "%02d" "$((10#${BASH_REMATCH[3]}))")
799
        hour=$(printf "%02d" "$((10#${BASH_REMATCH[4]}))")
800
        minute=$(printf "%02d" "$((10#${BASH_REMATCH[5]}))")
801
        second=$(printf "%02d" "$((10#${BASH_REMATCH[6]}))")
802
        create_date="$year-$month-$day $hour:$minute:$second"
803
    else
804
        # Try to convert yyyy-mm-dd hh:mm:ss (already correct)
805
        if [[ "$create_date" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})[[:space:]]*([0-9]{2}):([0-9]{2}):([0-9]{2})$ ]]; then
806
            # Already correct
807
            :
808
        else
809
            print_color "$RED" "Error: Cannot parse date '$create_date'" >&2
810
            return 2
811
        fi
812
    fi
813

            
814
    # For QuickTime files, the CreateDate is in UTC and needs conversion to local time
815
    if [[ "$date_source" == *"QuickTime"* ]]; then
816
        # Convert UTC time to local time
817
        if [[ "$OSTYPE" == "darwin"* ]]; then
818
            # On macOS, use TZ=UTC to interpret the input time as UTC
819
            local utc_timestamp=$(TZ=UTC date -j -f "%Y-%m-%d %H:%M:%S" "$create_date" "+%s" 2>/dev/null)
820
            if [[ -n "$utc_timestamp" ]]; then
821
                create_date=$(date -j -r "$utc_timestamp" "+%Y-%m-%d %H:%M:%S" 2>/dev/null)
822
                date_source="$date_source (converted from UTC)"
823
            fi
824
        else
825
            local utc_timestamp=$(date -d "$create_date UTC" "+%s" 2>/dev/null)
826
            if [[ -n "$utc_timestamp" ]]; then
827
                create_date=$(date -d "@$utc_timestamp" "+%Y-%m-%d %H:%M:%S" 2>/dev/null)
828
                date_source="$date_source (converted from UTC)"
829
            fi
830
        fi
831
    fi
832

            
833
    echo "$create_date|$date_source"
834
    return 0
835
}
836

            
837
# Function to generate destination path based on organization pattern
838
generate_destination_path() {
839
    local date_str="$1"
840
    local original_filename="$2"
841
    local base_destination="$3"
842

            
843
    # Extract date components - handle both GNU and BSD date
844
    local year month day hour minute second
845
    if [[ "$OSTYPE" == "darwin"* ]]; then
846
        # macOS (BSD date) - use more robust regex (allow single-digit month/day, tolerate extra spaces)
847
        if [[ "$date_str" =~ ^([0-9]{4})-([0-9]{1,2})-([0-9]{1,2})[[:space:]]*([0-9]{1,2}):([0-9]{1,2}):([0-9]{1,2})$ ]]; then
848
            year="${BASH_REMATCH[1]}"
849
            month=$(printf "%02d" "$((10#${BASH_REMATCH[2]}))")
850
            day=$(printf "%02d" "$((10#${BASH_REMATCH[3]}))")
851
            hour=$(printf "%02d" "$((10#${BASH_REMATCH[4]}))")
852
            minute=$(printf "%02d" "$((10#${BASH_REMATCH[5]}))")
853
            second=$(printf "%02d" "$((10#${BASH_REMATCH[6]}))")
854
        else
855
            return 1
856
        fi
857
    else
858
        # Linux (GNU date)
859
        year=$(date -d "$date_str" "+%Y" 2>/dev/null)
860
        month=$(date -d "$date_str" "+%m" 2>/dev/null)
861
        day=$(date -d "$date_str" "+%d" 2>/dev/null)
862
        hour=$(date -d "$date_str" "+%H" 2>/dev/null)
863
        minute=$(date -d "$date_str" "+%M" 2>/dev/null)
864
        second=$(date -d "$date_str" "+%S" 2>/dev/null)
865
    fi
866

            
867
    if [[ -z "$year" || -z "$month" || -z "$day" ]]; then
868
        return 1
869
    fi
870

            
871
    # Get file extension
872
    local extension="${original_filename##*.}"
873
    local lowercase_ext=$(echo "$extension" | tr '[:upper:]' '[:lower:]')
874

            
875
    # Generate path and filename based on organization pattern
876
    local dir_path=""
877
    local filename=""
Bogdan Timofte authored 9 months ago
878

            
879
    # If no organization specified, use flat destination (base) and choose filename per mode
880
    if [[ -z "$ORGANIZATION" ]]; then
Bogdan Timofte authored 9 months ago
881
        dir_path="$base_destination"
Bogdan Timofte authored 9 months ago
882
        if [[ "$FILENAME_MODE" == "orig" ]]; then
883
            filename="$original_filename"
884
        else
885
            # full or auto both map to full date for flat layout
886
            filename="${year}-${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
887
        fi
888
        echo "$dir_path/$filename"
889
        return 0
890
    fi
891

            
892
    case "$ORGANIZATION" in
Bogdan Timofte authored 9 months ago
893
            "y")
894
                dir_path="$base_destination/$year"
895
                filename="${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
896
                ;;
897
            "m")
898
                dir_path="$base_destination/$year/$month"
899
                filename="${day}_${hour}-${minute}-${second}.${lowercase_ext}"
900
                ;;
901
            "d")
902
                dir_path="$base_destination/$year/$month/$day"
903
                filename="${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
904
                ;;
905
            "h")
906
                dir_path="$base_destination/$year/$month/$day/$hour"
907
                filename="${minute}-${second}.${lowercase_ext}"
908
                ;;
Bogdan Timofte authored 9 months ago
909
            "ym")
910
                # Single folder per month named yyyy-mm; filename includes day and time
911
                dir_path="$base_destination/${year}-${month}"
912
                filename="${day}_${hour}-${minute}-${second}.${lowercase_ext}"
913
                ;;
Bogdan Timofte authored 8 months ago
914
            "ymd")
Bogdan Timofte authored 9 months ago
915
                # Single folder per day named yyyy-mm-dd; filename is time
916
                dir_path="$base_destination/${year}-${month}-${day}"
917
                filename="${hour}-${minute}-${second}.${lowercase_ext}"
918
                ;;
Bogdan Timofte authored 9 months ago
919
            *)
920
                log_message "Invalid organization pattern: $ORGANIZATION" "ERROR"
921
                return 1
922
                ;;
923
        esac
Bogdan Timofte authored 9 months ago
924

            
925
    # Apply filename mode overrides
926
    case "$FILENAME_MODE" in
927
        orig)
928
            filename="$original_filename"
929
            ;;
930
        full)
931
            filename="${year}-${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
932
            ;;
933
        auto)
934
            # keep the auto-generated filename from the organization case
935
            ;;
936
        *)
937
            # fallback to full
938
            filename="${year}-${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
939
            ;;
940
    esac
941

            
Bogdan Timofte authored 9 months ago
942
    echo "$dir_path/$filename"
943
    return 0
944
}
945

            
946
# Function to find files matching patterns
947
find_source_files() {
Bogdan Timofte authored 9 months ago
948
    # Emit absolute file paths for all media files under sources, excluding DESTINATION when it is inside a source
949
    local abs_dest=""
950
    if [[ -n "$DESTINATION" ]]; then
951
        abs_dest=$(cd "$DESTINATION" 2>/dev/null && pwd) || abs_dest="$DESTINATION"
952
    fi
953

            
954
    # Build -iname expression for find
955
    local ext_expr=""
956
    for ext in "${MEDIA_EXTENSIONS[@]}"; do
957
        if [[ -n "$ext_expr" ]]; then
958
            ext_expr="$ext_expr -o"
959
        fi
960
        ext_expr="$ext_expr -iname $ext"
961
    done
962

            
Bogdan Timofte authored 9 months ago
963
    if [[ ${#SOURCE_PATTERNS[@]} -eq 0 ]]; then
Bogdan Timofte authored 9 months ago
964
        # Default: scan current directory
965
        local start_dot="."
966
        local abs_current
967
        abs_current=$(pwd)
968
        local find_cmd=(find -L "$start_dot" -type f)
969
        # If dest is inside cwd, add exclusion
970
        if [[ -n "$abs_dest" && "$abs_dest" == "$abs_current"* ]]; then
971
            find_cmd+=( ! -path "$abs_dest/*" ! -path "$abs_dest" )
Bogdan Timofte authored 9 months ago
972
        fi
Bogdan Timofte authored 9 months ago
973
        # Add expression
974
        # shellcheck disable=SC2068
Bogdan Timofte authored 2 weeks ago
975
        "${find_cmd[@]}" ! -name '._*' \( $ext_expr \) 2>/dev/null || true
Bogdan Timofte authored 9 months ago
976
    else
Bogdan Timofte authored 9 months ago
977
        # Scan each provided source
978
        for src in "${SOURCE_PATTERNS[@]}"; do
979
            if [[ -f "$src" ]]; then
Bogdan Timofte authored 2 weeks ago
980
                if [[ "$(basename "$src")" == ._* ]]; then
981
                    continue
982
                fi
Bogdan Timofte authored 9 months ago
983
                # single file - skip if it's inside dest
984
                local abs_file
985
                abs_file=$(cd "$(dirname "$src")" 2>/dev/null && pwd)/$(basename "$src")
986
                if [[ -n "$abs_dest" && "$abs_file" == "$abs_dest"* ]]; then
987
                    continue
988
                fi
989
                echo "$abs_file"
990
            elif [[ -d "$src" ]]; then
991
                local abs_src
992
                abs_src=$(cd "$src" 2>/dev/null && pwd)
993
                if [[ -n "$abs_src" ]]; then
994
                    if [[ -n "$abs_dest" && "$abs_dest" == "$abs_src"* ]]; then
Bogdan Timofte authored 2 weeks ago
995
                        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
996
                    else
Bogdan Timofte authored 2 weeks ago
997
                        find -L "$abs_src" -type f ! -name '._*' \( $ext_expr \) 2>/dev/null || true
Bogdan Timofte authored 9 months ago
998
                    fi
Bogdan Timofte authored 9 months ago
999
                else
1000
                    print_color "$YELLOW" "Warning: Could not resolve source directory: $src"
Bogdan Timofte authored 9 months ago
1001
                fi
Bogdan Timofte authored 9 months ago
1002
            else
1003
                print_color "$YELLOW" "Warning: Source path does not exist or is not a file/directory: $src"
Bogdan Timofte authored 9 months ago
1004
            fi
1005
        done
1006
    fi
1007
}
1008

            
1009
# Function to process a single file
1010
process_file() {
1011
    local file="$1"
1012
    local file_size=$(get_file_size "$file")
Bogdan Timofte authored 2 weeks ago
1013
    local file_label
1014
    file_label="$(basename "$file")"
Bogdan Timofte authored 9 months ago
1015
    TOTAL_SIZE=$((TOTAL_SIZE + file_size))
1016

            
Bogdan Timofte authored 2 weeks ago
1017
    if [[ $TOTAL_FILES -gt 0 && $CURRENT_FILE_INDEX -gt 0 ]]; then
1018
        print_color "$BLUE" "Processing [$CURRENT_FILE_INDEX/$TOTAL_FILES]: $file_label ($(format_size "$file_size"))"
1019
    else
1020
        print_color "$BLUE" "Processing: $file_label ($(format_size "$file_size"))"
1021
    fi
Bogdan Timofte authored 9 months ago
1022
    log_message "Processing: $file" "INFO"
1023

            
1024
    # Extract date information
1025
    local date_info=$(extract_file_date "$file")
1026
        local extract_status=$?
1027
        if [[ $extract_status -eq 2 ]]; then
1028
            if [[ $COLLECT_UNSORTABLE -eq 1 ]]; then
1029
                local unsortable_dir="$DESTINATION/unsortable"
1030
                mkdir -p "$unsortable_dir"
1031
                local unsortable_path="$unsortable_dir/$(basename "$file")"
Bogdan Timofte authored 2 weeks ago
1032
                local desired_unsortable_path="$unsortable_path"
1033
                local unsortable_conflict_status
1034
                resolve_destination_conflict "$unsortable_path" "$file"
1035
                unsortable_conflict_status=$?
1036
                if [[ $unsortable_conflict_status -eq 0 ]]; then
1037
                    unsortable_path="$RESOLVED_DESTINATION_PATH"
1038
                    if [[ "$unsortable_path" != "$desired_unsortable_path" ]]; then
1039
                        log_message "Destination already exists or is already planned: $desired_unsortable_path - using: $unsortable_path" "WARNING"
1040
                    fi
1041
                elif [[ $unsortable_conflict_status -eq 3 ]]; then
1042
                    log_message "Destination conflict skipped: $desired_unsortable_path" "WARNING"
1043
                    SKIPPED_FILES=$((SKIPPED_FILES + 1))
1044
                    return 1
1045
                elif [[ $unsortable_conflict_status -eq 4 ]]; then
1046
                    log_message "Import aborted by user at destination conflict: $desired_unsortable_path" "ERROR"
1047
                    ERROR_FILES=$((ERROR_FILES + 1))
1048
                    FATAL_ERROR=1
1049
                    return 2
1050
                else
1051
                    log_message "Could not resolve a unique destination path for $file (wanted: $desired_unsortable_path)" "ERROR"
1052
                    ERROR_FILES=$((ERROR_FILES + 1))
1053
                    return 1
1054
                fi
Bogdan Timofte authored 9 months ago
1055
                if [[ $DRY_RUN -eq 1 ]]; then
1056
                    print_color "$BLUE" "Would move unsortable: $file -> $unsortable_path"
1057
                else
Bogdan Timofte authored 3 weeks ago
1058
                    if verified_move_file "$file" "$unsortable_path" ""; then
Bogdan Timofte authored 9 months ago
1059
                        log_message "Unsortable: $file -> $unsortable_path" "SUCCESS"
1060
                    else
Bogdan Timofte authored 3 weeks ago
1061
                        log_message "Failed to move unsortable file after verification: $file" "ERROR"
Bogdan Timofte authored 9 months ago
1062
                    fi
1063
                fi
1064
                SKIPPED_FILES=$((SKIPPED_FILES + 1))
1065
            else
1066
                log_message "Could not extract date from $file - skipping" "WARNING"
1067
                SKIPPED_FILES=$((SKIPPED_FILES + 1))
1068
            fi
1069
            return 1
1070
        elif [[ $extract_status -ne 0 ]] || [[ -z "$date_info" ]]; then
1071
            log_message "Could not extract date from $file - skipping" "WARNING"
1072
            SKIPPED_FILES=$((SKIPPED_FILES + 1))
1073
            return 1
1074
        fi
1075
        local date_str="${date_info%|*}"
1076
        local date_source="${date_info#*|}"
1077
        log_message "Date: $date_str (from $date_source)" "INFO"
1078
        # Generate destination path
Bogdan Timofte authored 2 weeks ago
1079
        local original_basename
1080
        original_basename="$(basename "$file")"
1081
        local dest_path
1082
        dest_path=$(generate_destination_path "$date_str" "$original_basename" "$DESTINATION")
Bogdan Timofte authored 9 months ago
1083
        if [[ $? -ne 0 ]] || [[ -z "$dest_path" ]]; then
1084
            log_message "Could not generate destination path for $file" "ERROR"
1085
            ERROR_FILES=$((ERROR_FILES + 1))
1086
            FATAL_ERROR=1
1087
            return 2
1088
    fi
Bogdan Timofte authored 2 weeks ago
1089

            
1090
    local desired_dest_path="$dest_path"
1091
    local conflict_status
1092
    resolve_destination_conflict "$dest_path" "$file"
1093
    conflict_status=$?
1094
    if [[ $conflict_status -eq 0 ]]; then
1095
        dest_path="$RESOLVED_DESTINATION_PATH"
1096
    elif [[ $conflict_status -eq 3 ]]; then
1097
        log_message "Destination conflict skipped: $desired_dest_path" "WARNING"
1098
        SKIPPED_FILES=$((SKIPPED_FILES + 1))
1099
        return 1
1100
    elif [[ $conflict_status -eq 4 ]]; then
1101
        log_message "Import aborted by user at destination conflict: $desired_dest_path" "ERROR"
1102
        ERROR_FILES=$((ERROR_FILES + 1))
1103
        FATAL_ERROR=1
1104
        return 2
1105
    else
1106
        log_message "Could not resolve a unique destination path for $file (wanted: $desired_dest_path)" "ERROR"
1107
        ERROR_FILES=$((ERROR_FILES + 1))
1108
        return 1
Bogdan Timofte authored 9 months ago
1109
    fi
Bogdan Timofte authored 2 weeks ago
1110
    if [[ "$dest_path" != "$desired_dest_path" ]]; then
1111
        log_message "Destination already exists or is already planned: $desired_dest_path - using: $dest_path" "WARNING"
1112
    fi
1113

            
1114
    local dest_dir
1115
    dest_dir=$(dirname "$dest_path")
Bogdan Timofte authored 9 months ago
1116

            
1117
    if [[ $DRY_RUN -eq 1 ]]; then
1118
        if [[ $KEEP_ORIGINALS -eq 1 ]]; then
1119
            print_color "$BLUE" "Would copy: $file -> $dest_path"
1120
        else
1121
            print_color "$BLUE" "Would move: $file -> $dest_path"
1122
        fi
Bogdan Timofte authored 2 weeks ago
1123
        if should_sync_imported_metadata "$original_basename" "$date_source"; then
1124
            print_color "$BLUE" "Would sync metadata date: $dest_path -> $date_str"
1125
        fi
Bogdan Timofte authored 9 months ago
1126
        PROCESSED_FILES=$((PROCESSED_FILES + 1))
1127
        PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
1128
        return 0
1129
    fi
1130

            
1131
    # Create destination directory
1132
    if ! mkdir -p "$dest_dir"; then
1133
        log_message "Could not create directory: $dest_dir" "ERROR"
1134
        ERROR_FILES=$((ERROR_FILES + 1))
1135
        return 1
1136
    fi
1137

            
Bogdan Timofte authored 2 weeks ago
1138
    local sync_metadata_after_copy=0
1139
    local verification_date="$date_str"
1140
    if should_sync_imported_metadata "$original_basename" "$date_source"; then
1141
        sync_metadata_after_copy=1
1142
        verification_date=""
1143
    fi
1144

            
1145
    # Copy or move file using safe helpers after destination conflicts are resolved.
Bogdan Timofte authored 9 months ago
1146
    if [[ $KEEP_ORIGINALS -eq 1 ]]; then
Bogdan Timofte authored 2 weeks ago
1147
        if copy_with_verification "$file" "$dest_path" "$verification_date"; then
1148
            if [[ $sync_metadata_after_copy -eq 1 ]]; then
1149
                if ! sync_destination_metadata_to_date "$dest_path" "$date_str"; then
1150
                    log_message "Failed to sync destination metadata timestamps: $dest_path" "ERROR"
1151
                    ERROR_FILES=$((ERROR_FILES + 1))
1152
                    return 1
1153
                elif ! verify_synced_metadata_date "$dest_path" "$date_str"; then
1154
                    ERROR_FILES=$((ERROR_FILES + 1))
1155
                    return 1
1156
                fi
1157
            fi
Bogdan Timofte authored 9 months ago
1158
            log_message "Copied: $file -> $dest_path" "SUCCESS"
1159
            PROCESSED_FILES=$((PROCESSED_FILES + 1))
1160
            PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
1161
            return 0
1162
        else
Bogdan Timofte authored 3 weeks ago
1163
            log_message "Failed to copy or verify destination: $file -> $dest_path" "ERROR"
Bogdan Timofte authored 9 months ago
1164
            ERROR_FILES=$((ERROR_FILES + 1))
1165
            return 1
1166
        fi
1167
    else
Bogdan Timofte authored 2 weeks ago
1168
        if [[ $sync_metadata_after_copy -eq 1 ]]; then
1169
            if copy_with_verification "$file" "$dest_path" "$verification_date"; then
1170
                if ! sync_destination_metadata_to_date "$dest_path" "$date_str"; then
1171
                    log_message "Failed to sync destination metadata timestamps: $dest_path" "ERROR"
1172
                    ERROR_FILES=$((ERROR_FILES + 1))
1173
                    return 1
1174
                fi
1175
                if ! verify_synced_metadata_date "$dest_path" "$date_str"; then
1176
                    ERROR_FILES=$((ERROR_FILES + 1))
1177
                    return 1
1178
                fi
1179
                if ! remove_source_file "$file"; then
1180
                    log_message "Copied, verified, and synced destination, but failed to remove source: $file" "ERROR"
1181
                    ERROR_FILES=$((ERROR_FILES + 1))
1182
                    return 1
1183
                fi
1184
                log_message "Moved: $file -> $dest_path" "SUCCESS"
1185
                PROCESSED_FILES=$((PROCESSED_FILES + 1))
1186
                PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
1187
                return 0
1188
            else
1189
                log_message "Failed to move after copy verification: $file -> $dest_path" "ERROR"
1190
                ERROR_FILES=$((ERROR_FILES + 1))
1191
                return 1
1192
            fi
1193
        elif verified_move_file "$file" "$dest_path" "$date_str"; then
Bogdan Timofte authored 9 months ago
1194
            log_message "Moved: $file -> $dest_path" "SUCCESS"
Bogdan Timofte authored 2 weeks ago
1195
            if [[ $sync_metadata_after_copy -eq 1 ]]; then
1196
                if ! sync_destination_metadata_to_date "$dest_path" "$date_str"; then
1197
                    log_message "Failed to sync destination metadata timestamps: $dest_path" "WARNING"
1198
                elif ! verify_synced_metadata_date "$dest_path" "$date_str"; then
1199
                    log_message "Failed to verify synced destination metadata timestamps: $dest_path" "WARNING"
1200
                fi
1201
            fi
Bogdan Timofte authored 9 months ago
1202
            PROCESSED_FILES=$((PROCESSED_FILES + 1))
1203
            PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
1204
            return 0
1205
        else
Bogdan Timofte authored 3 weeks ago
1206
            log_message "Failed to move after copy verification: $file -> $dest_path" "ERROR"
Bogdan Timofte authored 9 months ago
1207
            ERROR_FILES=$((ERROR_FILES + 1))
1208
            return 1
1209
        fi
1210
    fi
1211
}
1212

            
1213
# Function to display final report
1214
show_report() {
1215
    local end_time=$(date +%s)
1216
    local elapsed_time=$((end_time - START_TIME))
1217
    local hours=$((elapsed_time / 3600))
1218
    local minutes=$(((elapsed_time % 3600) / 60))
1219
    local seconds=$((elapsed_time % 60))
1220

            
1221
    echo ""
1222
    print_color "$GREEN" "=========================================="
1223
    print_color "$GREEN" "           PROCESSING REPORT"
1224
    print_color "$GREEN" "=========================================="
1225
    echo ""
Bogdan Timofte authored 2 weeks ago
1226

            
1227
    echo "Files Summary:"
1228
    report_line "Total files found:" "$TOTAL_FILES"
1229
    report_line "Successfully processed:" "$PROCESSED_FILES"
1230
    report_line "Skipped:" "$SKIPPED_FILES"
1231
    report_line "Errors:" "$ERROR_FILES"
1232
    echo ""
1233

            
Bogdan Timofte authored 9 months ago
1234
    echo "Size Summary:"
Bogdan Timofte authored 2 weeks ago
1235
    report_line "Total size found:" "$(format_size $TOTAL_SIZE)"
1236
    report_line "Successfully processed:" "$(format_size $PROCESSED_SIZE)"
Bogdan Timofte authored 9 months ago
1237
    echo ""
Bogdan Timofte authored 2 weeks ago
1238

            
1239
    echo "Time Summary:"
1240
    report_line "Time elapsed:" "$(printf "%02d:%02d:%02d" $hours $minutes $seconds)"
1241
    if [[ $elapsed_time -gt 0 && $PROCESSED_SIZE -gt 0 ]]; then
1242
        local data_rate
1243
        data_rate=$(format_data_rate "$PROCESSED_SIZE" "$elapsed_time")
1244
        if [[ -n "$data_rate" ]]; then
1245
            report_line "Data rate:" "$data_rate"
1246
        fi
1247
    fi
1248

            
1249
    echo ""
1250

            
Bogdan Timofte authored 9 months ago
1251
    if [[ $DRY_RUN -eq 1 ]]; then
1252
        print_color "$YELLOW" "DRY RUN MODE - No files were actually moved/copied"
1253
    elif [[ $KEEP_ORIGINALS -eq 1 ]]; then
1254
        print_color "$BLUE" "COPY MODE - Original files were preserved"
1255
    else
1256
        print_color "$GREEN" "MOVE MODE - Files were moved to destination"
1257
    fi
1258

            
1259
    echo ""
1260
    print_color "$GREEN" "=========================================="
1261
}
1262

            
Bogdan Timofte authored 2 weeks ago
1263
if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then
1264
    return 0
1265
fi
1266

            
Bogdan Timofte authored 9 months ago
1267
# Parse command line arguments
1268
while [[ $# -gt 0 ]]; do
1269
    case $1 in
1270
        -o|--organization)
1271
            ORGANIZATION="$2"
Bogdan Timofte authored 8 months ago
1272
            # Accept new patterns: ym, ymd as well as single-letter ones
1273
            if [[ ! "$ORGANIZATION" =~ ^(y|m|d|h|ym|ymd)$ ]]; then
Bogdan Timofte authored 9 months ago
1274
                print_color "$RED" "Error: Invalid organization pattern. Must be one of: y, m, d, h, ym, ymd"
Bogdan Timofte authored 9 months ago
1275
                exit 1
1276
            fi
1277
            shift 2
1278
            ;;
Bogdan Timofte authored 9 months ago
1279

            
1280
        -F|--filename-mode)
1281
            FILENAME_MODE="$2"
1282
            if [[ ! "$FILENAME_MODE" =~ ^(auto|full|orig)$ ]]; then
1283
                print_color "$RED" "Error: Invalid filename mode. Must be one of: auto, full, orig"
1284
                exit 1
1285
            fi
1286
            shift 2
Bogdan Timofte authored 9 months ago
1287
            ;;
Bogdan Timofte authored 9 months ago
1288
    # (conflict resolution option removed; let exiftool/filesystem handle naming conflicts)
Bogdan Timofte authored 9 months ago
1289
        --collect-unsortable)
1290
            COLLECT_UNSORTABLE=1
1291
            shift
1292
            ;;
Bogdan Timofte authored 8 months ago
1293
        --keep-empty-dirs)
1294
            CLEANUP_EMPTY_DIRS=0
1295
            shift
1296
            ;;
Bogdan Timofte authored 9 months ago
1297
        -s|--source)
1298
            SOURCE_PATTERNS+=("$2")
1299
            shift 2
1300
            ;;
1301
        -d|--destination)
1302
            DESTINATION="$2"
1303
            shift 2
1304
            ;;
1305
        -k|--keep-originals)
1306
            KEEP_ORIGINALS=1
1307
            shift
1308
            ;;
Bogdan Timofte authored 3 weeks ago
1309
        --verify-mode)
1310
            VERIFY_MODE="$2"
1311
            if [[ ! "$VERIFY_MODE" =~ ^(size|strict|none)$ ]]; then
1312
                print_color "$RED" "Error: Invalid verify mode. Must be one of: size, strict, none"
1313
                exit 1
1314
            fi
1315
            shift 2
1316
            ;;
Bogdan Timofte authored 2 weeks ago
1317
        --date-source)
1318
            DATE_SOURCE="$2"
1319
            if [[ ! "$DATE_SOURCE" =~ ^(auto|exif|filesystem)$ ]]; then
1320
                print_color "$RED" "Error: Invalid date source. Must be one of: auto, exif, filesystem"
1321
                exit 1
1322
            fi
1323
            shift 2
1324
            ;;
1325
        --sync-metadata)
1326
            SYNC_METADATA=1
1327
            shift
1328
            ;;
1329
        --unattended)
1330
            UNATTENDED=1
1331
            shift
1332
            ;;
Bogdan Timofte authored 9 months ago
1333
        --dry-run)
1334
            DRY_RUN=1
1335
            shift
1336
            ;;
1337
        -v|--verbose)
1338
            VERBOSE=1
1339
            shift
1340
            ;;
1341
        -h|--help)
1342
            show_help
1343
            exit 0
1344
            ;;
1345
        --version)
1346
            show_version
1347
            exit 0
1348
            ;;
1349
        *)
1350
            print_color "$RED" "Error: Unknown option: $1"
1351
            echo "Use -h or --help for usage information."
1352
            exit 1
1353
            ;;
1354
    esac
1355
done
1356

            
Bogdan Timofte authored 2 weeks ago
1357
# Non-interactive execution cannot safely ask conflict questions.
1358
if [[ $UNATTENDED -eq 0 && ( ! -t 0 || ! -t 1 ) ]]; then
1359
    UNATTENDED=1
1360
fi
1361

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

            
Bogdan Timofte authored 3 weeks ago
1364
# If no source specified, default to current directory. Refuse to run when cwd is unsafe.
1365
if [[ ${#SOURCE_PATTERNS[@]} -eq 0 ]]; then
1366
    cwd=$(pwd)
1367
    # Resolve home and root paths
1368
    home_dir="$HOME"
1369
    case "$cwd" in
1370
        "$home_dir"|"/"|"/etc"|"/var"|"/bin"|"/sbin"|"/dev"|"/proc"|"/sys"|"/run"|"/usr"|"/lib"|"/lib64"|"/boot"|"/snap")
1371
            print_color "$RED" "Refusing to run with default source in unsafe directory: $cwd"
1372
            print_color "$RED" "Please specify $cwd as source explicitly with -s or run from a safer directory."
1373
            exit 1
1374
            ;;
1375
        *)
1376
            SOURCE_PATTERNS+=("$cwd")
1377
            ;;
1378
    esac
1379
fi
1380

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

            
1389
    if [[ ${#SOURCE_PATTERNS[@]} -gt 0 ]]; then
1390
        first_source="${SOURCE_PATTERNS[0]}"
Bogdan Timofte authored 3 weeks ago
1391
        if [[ -d "$first_source" ]]; then
1392
            DESTINATION="$first_source/sorted"
1393
        elif [[ -f "$first_source" ]]; then
1394
            DESTINATION="$(dirname "$first_source")/sorted"
Bogdan Timofte authored 9 months ago
1395
        else
1396
            print_color "$YELLOW" "Warning: first source does not exist; defaulting destination to ./sorted"
1397
            DESTINATION="./sorted"
1398
        fi
1399
    else
1400
        DESTINATION="./sorted"
1401
    fi
Bogdan Timofte authored 9 months ago
1402
fi
1403

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

            
1407
# Display configuration
1408
print_color "$GREEN" "$SCRIPT_NAME v$VERSION"
1409
echo ""
1410
echo "Configuration:"
1411
echo "  Organization pattern: $ORGANIZATION"
1412
echo "  Destination:         $DESTINATION"
1413
echo "  Keep originals:      $([ $KEEP_ORIGINALS -eq 1 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored 3 weeks ago
1414
echo "  Verify mode:         $VERIFY_MODE"
Bogdan Timofte authored 2 weeks ago
1415
echo "  Date source:         $DATE_SOURCE"
1416
echo "  Sync metadata:       $([ $SYNC_METADATA -eq 1 ] && echo "Yes" || echo "No")"
1417
echo "  Unattended:          $([ $UNATTENDED -eq 1 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored 9 months ago
1418
echo "  Dry run:             $([ $DRY_RUN -eq 1 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored 8 months ago
1419
echo "  Keep empty dirs:     $([ $CLEANUP_EMPTY_DIRS -eq 0 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored 9 months ago
1420
echo "  Verbose:             $([ $VERBOSE -eq 1 ] && echo "Yes" || echo "No")"
1421

            
1422
if [[ ${#SOURCE_PATTERNS[@]} -gt 0 ]]; then
1423
    echo "  Source patterns:"
1424
    for pattern in "${SOURCE_PATTERNS[@]}"; do
1425
        echo "    - $pattern"
1426
    done
1427
else
1428
    echo "  Source patterns:     All media files in current directory"
1429
fi
1430

            
1431
echo ""
1432

            
1433
# Check dependencies
1434
check_dependencies
1435

            
1436
# Create destination directory if it doesn't exist (unless dry run)
1437
if [[ $DRY_RUN -eq 0 ]]; then
1438
    if ! mkdir -p "$DESTINATION"; then
1439
        print_color "$RED" "Error: Cannot create destination directory: $DESTINATION"
1440
        exit 1
1441
    fi
1442
fi
1443

            
1444
# Find all source files
1445

            
1446
print_color "$BLUE" "Scanning for media files..."
1447
files=()
1448
while IFS= read -r file; do
1449
    files+=("$file")
1450
done < <(find_source_files)
Bogdan Timofte authored 2 weeks ago
1451
if [[ ${#files[@]} -gt 0 ]]; then
1452
    IFS=$'\n' files=($(printf '%s\n' "${files[@]}" | sort))
1453
    unset IFS
1454
fi
Bogdan Timofte authored 9 months ago
1455
TOTAL_FILES=${#files[@]}
1456

            
1457
if [[ $TOTAL_FILES -eq 0 ]]; then
1458
    print_color "$YELLOW" "No media files found matching the specified patterns."
1459
    exit 0
1460
fi
1461

            
1462
print_color "$BLUE" "Found $TOTAL_FILES media files to process"
1463
echo ""
1464

            
1465
# Process each file
1466

            
1467
FATAL_ERROR=0
1468
for file in "${files[@]}"; do
1469
    if [[ -f "$file" ]]; then
Bogdan Timofte authored 2 weeks ago
1470
        CURRENT_FILE_INDEX=$((CURRENT_FILE_INDEX + 1))
Bogdan Timofte authored 9 months ago
1471
        process_file "$file"
1472
        if [[ $FATAL_ERROR -eq 1 ]]; then
1473
            print_color "$RED" "Fatal error encountered. Stopping further processing."
1474
            break
1475
        fi
1476
    fi
1477
done
1478

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

            
Bogdan Timofte authored 9 months ago
1487
# Show final report
1488
show_report
1489

            
1490
# Exit with appropriate code
1491
if [[ $ERROR_FILES -gt 0 ]]; then
1492
    exit 1
1493
else
1494
    exit 0
1495
fi