MediaImporter / media-importer.sh
Newer Older
1715 lines | 60.077kb
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
Bogdan Timofte authored a week ago
22
QUICKTIME_UTC=1     # when 0, treat QuickTime dates as already local (for cameras that store local time instead of UTC)
Bogdan Timofte authored 2 weeks ago
23
UNATTENDED=0        # when 1, never prompt; destination conflicts get numeric suffixes
Bogdan Timofte authored 9 months ago
24
DRY_RUN=0
25
VERBOSE=0
Bogdan Timofte authored 8 months ago
26
CLEANUP_EMPTY_DIRS=1
Bogdan Timofte authored 2 weeks ago
27
CLEANUP_MEDIA_SIDECARS=1  # when 1, delete device sidecars (GoPro THM/LRV, Garmin GLV) after successful import
Bogdan Timofte authored a week ago
28
SYNC_GPS=0              # when 1, embed first GPS fix from GPMF stream into destination file EXIF
Bogdan Timofte authored 2 weeks ago
29
CONFLICT_APPLY_ALL=""  # suffix|skip after an interactive "all similar" choice
Bogdan Timofte authored a week ago
30

            
31
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
Bogdan Timofte authored 2 weeks ago
32
RESOLVED_DESTINATION_PATH=""
33
RESERVED_DESTINATION_PATHS=()
Bogdan Timofte authored 9 months ago
34

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

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

            
59
# Function to print colored output
60
print_color() {
61
    local color="$1"
62
    local message="$2"
63
    echo -e "${color}${message}${NC}"
64
}
65

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

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

            
93
# Function to display help
94
show_help() {
Bogdan Timofte authored 9 months ago
95
        cat << EOF
96
    $SCRIPT_NAME v$VERSION
Bogdan Timofte authored 9 months ago
97

            
Bogdan Timofte authored 9 months ago
98
Usage:
Bogdan Timofte authored 9 months ago
99
    $0 [OPTIONS]
100

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

            
106
Options:
107
    -o, --organization PATTERN   y|m|d|h|ym|ymd   (default: ymd)
108
    -F, --filename-mode MODE     auto|full|orig    (default: full)
109
    -s, --source PATH            File or directory to process (repeatable). Default: cwd
110
    -d, --destination PATH       Destination folder. Required when multiple -s are given.
Bogdan Timofte authored 8 months ago
111
    -k, --keep-originals         Copy files instead of moving
Bogdan Timofte authored 3 weeks ago
112
    --verify-mode MODE           size|strict|none (default: size)
Bogdan Timofte authored 2 weeks ago
113
    --date-source SOURCE         auto|exif|filesystem (default: auto)
114
    --sync-metadata              Write chosen date into destination metadata (automatic for GoPro filesystem dates)
Bogdan Timofte authored a week ago
115
    --no-quicktime-utc           Treat QuickTime dates as already local time (for cameras that store local time instead of UTC)
Bogdan Timofte authored 2 weeks ago
116
    --unattended                 Never prompt; resolve destination conflicts with numeric suffixes
Bogdan Timofte authored 9 months ago
117
    --collect-unsortable         Put files without dates into DEST/unsortable
Bogdan Timofte authored 2 weeks ago
118
    --keep-sidecars              Keep device sidecar files (default: delete after import)
Bogdan Timofte authored a week ago
119
    --sync-gps                   Embed first valid GPS fix from GPMF stream into destination EXIF
Bogdan Timofte authored 8 months ago
120
    --keep-empty-dirs            Keep empty directories after processing
121
    --dry-run                    Show actions without changing files
Bogdan Timofte authored 9 months ago
122
    -v, --verbose                Verbose output
123
    -h, --help                   Show this help
124
    --version                    Show version
125

            
126
Examples:
127
    $0 -s /path/to/photos
128
    $0 -s /path/to/DCIM -d /mnt/sorted --dry-run
129

            
130
Dependencies:
131
    exiftool (required). mediainfo and file are optional.
Bogdan Timofte authored 9 months ago
132
EOF
133
}
134

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

            
Bogdan Timofte authored 9 months ago
138
# Function to show version
139
show_version() {
140
    echo "$SCRIPT_NAME v$VERSION"
141
    echo "A comprehensive media file organizer with timezone support"
142
}
143

            
144
# Function to check dependencies
145
check_dependencies() {
146
    local missing_deps=()
147

            
148
    # Check for required dependencies
149
    if ! command -v exiftool &> /dev/null; then
150
        missing_deps+=("exiftool")
151
    fi
152

            
153
    # Check for optional dependencies
154
    local optional_missing=()
155
    if ! command -v mediainfo &> /dev/null; then
156
        optional_missing+=("mediainfo")
157
    fi
158

            
159
    if ! command -v file &> /dev/null; then
160
        optional_missing+=("file")
161
    fi
162

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

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

            
181
    log_message "All required dependencies found" "SUCCESS"
182
}
183

            
Bogdan Timofte authored 9 months ago
184
# Determine filesystem/device ID for a path (portable between Linux and macOS)
185
get_dev() {
186
    local path="$1"
187
    if [[ -z "$path" ]]; then
188
        path="."
189
    fi
190

            
191
    # Prefer GNU stat if available
192
    if stat --version >/dev/null 2>&1; then
193
        stat -c %d "$path" 2>/dev/null || stat -c %i "$path" 2>/dev/null || echo ""
194
    else
195
        # BSD/macOS stat
196
        stat -f %d "$path" 2>/dev/null || stat -f %i "$path" 2>/dev/null || echo ""
197
    fi
198
}
199

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

            
Bogdan Timofte authored 9 months ago
218
# Safe move/copy helpers: filter out benign "set flags (was: ...): Operation not supported"
219
# which appears when moving files onto filesystems that don't support BSD file flags
220
# (macOS mv may try to preserve flags and print this warning while still succeeding).
221
safe_mv() {
222
    local src="$1" dst="$2"
Bogdan Timofte authored 2 weeks ago
223
    if [[ -e "$dst" ]]; then
224
        log_message "Refusing to overwrite existing destination: $dst" "ERROR"
225
        return 1
226
    fi
Bogdan Timofte authored 9 months ago
227
    # Redirect stderr through a filter that removes the known benign message
228
    mv "$src" "$dst" 2> >(grep -v "set flags (was:" >&2)
229
    return $?
230
}
231

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

            
242
ensure_unique_destination_path() {
243
    local desired_path="$1"
244

            
245
    if [[ -z "$desired_path" ]]; then
246
        return 1
247
    fi
248

            
249
    if ! destination_path_unavailable "$desired_path"; then
250
        echo "$desired_path"
251
        return 0
252
    fi
253

            
254
    local dir_path filename ext stem candidate
255
    dir_path=$(dirname "$desired_path")
256
    filename=$(basename "$desired_path")
257

            
258
    if [[ "$filename" == *.* ]]; then
259
        ext="${filename##*.}"
260
        stem="${filename%.*}"
261
    else
262
        ext=""
263
        stem="$filename"
264
    fi
265

            
266
    local i
267
    i=1
268
    while [[ $i -le 9999 ]]; do
269
        if [[ -n "$ext" ]]; then
270
            candidate="$dir_path/${stem}_${i}.${ext}"
271
        else
272
            candidate="$dir_path/${stem}_${i}"
273
        fi
274
        if ! destination_path_unavailable "$candidate"; then
275
            echo "$candidate"
276
            return 0
277
        fi
278
        i=$((i + 1))
279
    done
280

            
281
    return 1
282
}
283

            
Bogdan Timofte authored 2 weeks ago
284
destination_path_reserved() {
285
    local candidate="$1"
286
    local reserved_path
287
    for reserved_path in "${RESERVED_DESTINATION_PATHS[@]}"; do
288
        if [[ "$reserved_path" == "$candidate" ]]; then
289
            return 0
290
        fi
291
    done
292
    return 1
293
}
Bogdan Timofte authored 9 months ago
294

            
Bogdan Timofte authored 2 weeks ago
295
destination_path_unavailable() {
296
    local candidate="$1"
297
    [[ -e "$candidate" ]] || destination_path_reserved "$candidate"
298
}
299

            
300
reserve_destination_path() {
301
    local candidate="$1"
302
    if [[ -n "$candidate" ]] && ! destination_path_reserved "$candidate"; then
303
        RESERVED_DESTINATION_PATHS+=("$candidate")
304
    fi
305
}
306

            
307
prompt_destination_conflict_choice() {
308
    local source_file="$1"
309
    local desired_path="$2"
310
    local choice
311

            
312
    if [[ ! -r /dev/tty || ! -w /dev/tty ]]; then
313
        return 1
314
    fi
315

            
316
    {
317
        print_color "$YELLOW" "Destination already exists:"
318
        echo "  Source:      $source_file"
319
        echo "  Destination: $desired_path"
320
        echo ""
321
        echo "Choose conflict action:"
322
        echo "  [s] suffix once"
323
        echo "  [S] suffix for all similar conflicts"
324
        echo "  [k] skip once"
325
        echo "  [K] skip all similar conflicts"
326
        echo "  [a] abort import"
327
    } > /dev/tty
328

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

            
360
resolve_destination_conflict() {
361
    local desired_path="$1"
362
    local source_file="$2"
363
    local resolved_path choice
364
    RESOLVED_DESTINATION_PATH=""
365

            
366
    if [[ -z "$desired_path" ]]; then
367
        return 1
368
    fi
369

            
370
    if ! destination_path_unavailable "$desired_path"; then
371
        RESOLVED_DESTINATION_PATH="$desired_path"
372
        reserve_destination_path "$RESOLVED_DESTINATION_PATH"
373
        return 0
374
    fi
375

            
376
    if [[ "$CONFLICT_APPLY_ALL" == "skip" ]]; then
377
        return 3
378
    fi
379

            
380
    if [[ "$CONFLICT_APPLY_ALL" == "suffix" || $UNATTENDED -eq 1 ]]; then
381
        resolved_path=$(ensure_unique_destination_path "$desired_path")
382
        if [[ -z "$resolved_path" ]]; then
383
            return 1
384
        fi
385
        RESOLVED_DESTINATION_PATH="$resolved_path"
386
        reserve_destination_path "$RESOLVED_DESTINATION_PATH"
387
        return 0
388
    fi
389

            
390
    choice=$(prompt_destination_conflict_choice "$source_file" "$desired_path")
391
    if [[ $? -ne 0 || -z "$choice" ]]; then
392
        log_message "Cannot prompt for destination conflict; using unattended numeric suffix mode" "WARNING"
393
        resolved_path=$(ensure_unique_destination_path "$desired_path")
394
        if [[ -z "$resolved_path" ]]; then
395
            return 1
396
        fi
397
        RESOLVED_DESTINATION_PATH="$resolved_path"
398
        reserve_destination_path "$RESOLVED_DESTINATION_PATH"
399
        return 0
400
    fi
401

            
402
    case "$choice" in
403
        suffix)
404
            resolved_path=$(ensure_unique_destination_path "$desired_path")
405
            ;;
406
        suffix_all)
407
            CONFLICT_APPLY_ALL="suffix"
408
            resolved_path=$(ensure_unique_destination_path "$desired_path")
409
            ;;
410
        skip)
411
            return 3
412
            ;;
413
        skip_all)
414
            CONFLICT_APPLY_ALL="skip"
415
            return 3
416
            ;;
417
        abort)
418
            return 4
419
            ;;
420
        *)
421
            return 1
422
            ;;
423
    esac
424

            
425
    if [[ -z "$resolved_path" ]]; then
426
        return 1
427
    fi
428

            
429
    RESOLVED_DESTINATION_PATH="$resolved_path"
430
    reserve_destination_path "$RESOLVED_DESTINATION_PATH"
431
    return 0
432
}
433

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

            
443
    local epoch=""
444

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

            
458
filesystem_date_reference() {
459
    local file="$1"
460
    local dir base stem ext sidecar_ext sidecar
461
    dir=$(dirname "$file")
462
    base=$(basename "$file")
463
    stem="${base%.*}"
464
    ext="${base##*.}"
465

            
466
    if [[ "$ext" =~ ^([Mm][Pp]4)$ ]]; then
467
        for sidecar_ext in THM thm LRV lrv; do
468
            sidecar="$dir/$stem.$sidecar_ext"
469
            if [[ -f "$sidecar" ]]; then
470
                echo "$sidecar"
471
                return 0
472
            fi
473
        done
474
    fi
475

            
476
    echo "$file"
477
}
478

            
479
is_gopro_media_file() {
480
    local filename
481
    filename=$(basename "$1")
482
    [[ "$filename" =~ ^G[HPX][0-9]{6}\.[Mm][Pp]4$ ]]
483
}
484

            
Bogdan Timofte authored 2 weeks ago
485
is_garmin_media_file() {
486
    local filename
487
    filename=$(basename "$1")
488
    [[ "$filename" =~ ^VIRB[0-9]{4}\.[Mm][Pp]4$ ]]
489
}
490

            
491
get_device_sidecar_extensions() {
492
    local file="$1"
493
    local filename
494
    filename=$(basename "$file")
495

            
496
    if is_gopro_media_file "$file"; then
497
        echo "THM thm LRV lrv"
498
    elif is_garmin_media_file "$file"; then
499
        echo "GLV glv"
500
    fi
501
}
502

            
Bogdan Timofte authored 2 weeks ago
503
should_prefer_gopro_filesystem_date() {
504
    local file="$1"
505

            
506
    is_gopro_media_file "$file"
507
}
508

            
509
filesystem_date_source_label() {
510
    local file="$1"
511
    local reference="$2"
512

            
513
    if is_gopro_media_file "$file"; then
514
        echo "Filesystem:$(basename "$reference")"
515
    elif [[ "$reference" != "$file" ]]; then
516
        echo "Filesystem:$(basename "$reference")"
517
    else
518
        echo "Filesystem"
519
    fi
520
}
521

            
522
date_to_exiftool_format() {
523
    # yyyy-mm-dd hh:mm:ss -> yyyy:mm:dd hh:mm:ss
524
    local s="$1"
525
    if [[ "$s" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})[[:space:]]+([0-9]{2}):([0-9]{2}):([0-9]{2})$ ]]; then
526
        echo "${BASH_REMATCH[1]}:${BASH_REMATCH[2]}:${BASH_REMATCH[3]} ${BASH_REMATCH[4]}:${BASH_REMATCH[5]}:${BASH_REMATCH[6]}"
527
        return 0
528
    fi
529
    return 1
530
}
531

            
532
sync_destination_metadata_to_date() {
533
    local file="$1"
534
    local date_str="$2" # yyyy-mm-dd hh:mm:ss
535

            
536
    local exif_dt
537
    exif_dt=$(date_to_exiftool_format "$date_str") || return 1
538

            
539
    exiftool -overwrite_original \
540
        "-CreateDate=$exif_dt" \
541
        "-DateTimeOriginal=$exif_dt" \
542
        "-DateTime=$exif_dt" \
543
        "-ModifyDate=$exif_dt" \
544
        "-MediaCreateDate=$exif_dt" \
545
        "-TrackCreateDate=$exif_dt" \
546
        "-QuickTime:CreateDate=$exif_dt" \
547
        "-QuickTime:ModifyDate=$exif_dt" \
548
        "$file" >/dev/null 2>&1
549
    return 0
550
}
551

            
552
verify_synced_metadata_date() {
553
    local file="$1"
554
    local expected_date="$2"
555

            
556
    local metadata_date
557
    metadata_date=$(exiftool -api QuickTimeUTC=0 -s -s -s -QuickTime:CreateDate "$file" 2>/dev/null | head -1)
558
    if [[ -z "$metadata_date" ]]; then
559
        metadata_date=$(exiftool -api QuickTimeUTC=0 -s -s -s -CreateDate "$file" 2>/dev/null | head -1)
560
    fi
561

            
562
    if [[ "$metadata_date" =~ ^([0-9]{4}):([0-9]{2}):([0-9]{2})[[:space:]]+([0-9]{2}):([0-9]{2}):([0-9]{2}) ]]; then
563
        metadata_date="${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]} ${BASH_REMATCH[4]}:${BASH_REMATCH[5]}:${BASH_REMATCH[6]}"
564
    fi
565

            
566
    if [[ "$metadata_date" != "$expected_date" ]]; then
567
        log_message "Destination metadata sync mismatch: expected $expected_date, got ${metadata_date:-none} for $file" "ERROR"
568
        return 1
569
    fi
570

            
571
    return 0
572
}
573

            
574
should_sync_imported_metadata() {
575
    local original_filename="$1"
576
    local date_source="$2"
577

            
578
    if [[ $SYNC_METADATA -eq 1 ]]; then
579
        return 0
580
    fi
581

            
582
    if [[ "$date_source" == Filesystem* ]] && is_gopro_media_file "$original_filename"; then
583
        return 0
584
    fi
585

            
586
    return 1
Bogdan Timofte authored 9 months ago
587
}
588

            
Bogdan Timofte authored 3 weeks ago
589
verify_copied_file() {
590
    local src="$1"
591
    local dst="$2"
592
    local expected_date="$3"
593

            
594
    if [[ ! -f "$dst" ]]; then
595
        log_message "Verified copy missing at destination: $dst" "ERROR"
596
        return 1
597
    fi
598

            
599
    local src_size dst_size
600
    src_size=$(get_file_size "$src")
601
    dst_size=$(get_file_size "$dst")
602
    if [[ "$src_size" != "$dst_size" ]]; then
603
        log_message "Size mismatch after copy: $src ($src_size) != $dst ($dst_size)" "ERROR"
604
        return 1
605
    fi
606

            
607
    if [[ "$VERIFY_MODE" == "strict" ]]; then
608
        if ! cmp -s "$src" "$dst"; then
609
            log_message "Content mismatch after copy: $src -> $dst" "ERROR"
610
            return 1
611
        fi
612
    elif [[ "$VERIFY_MODE" == "none" ]]; then
613
        return 0
614
    fi
615

            
616
    if [[ -n "$expected_date" ]]; then
617
        local destination_date_info
618
        destination_date_info=$(extract_file_date "$dst")
619
        local extract_status=$?
620
        if [[ $extract_status -ne 0 || -z "$destination_date_info" ]]; then
621
            log_message "Destination metadata validation failed: $dst" "ERROR"
622
            return 1
623
        fi
624

            
625
        local destination_date="${destination_date_info%|*}"
626
        if [[ "$destination_date" != "$expected_date" ]]; then
627
            log_message "Destination metadata mismatch: expected $expected_date, got $destination_date for $dst" "ERROR"
628
            return 1
629
        fi
630
    fi
631

            
632
    return 0
633
}
634

            
635
remove_source_file() {
636
    local src="$1"
637
    rm -f "$src"
638
}
639

            
640
copy_with_verification() {
641
    local src="$1"
642
    local dst="$2"
643
    local expected_date="$3"
644

            
Bogdan Timofte authored 2 weeks ago
645
    if [[ -e "$dst" ]]; then
646
        log_message "Refusing to overwrite existing destination: $dst" "ERROR"
Bogdan Timofte authored 3 weeks ago
647
        return 1
648
    fi
649

            
Bogdan Timofte authored 2 weeks ago
650
    local dst_dir tmp
651
    dst_dir=$(dirname "$dst")
652
    tmp=$(mktemp "$dst_dir/.media-importer.$(basename "$dst").tmp.XXXXXX") || return 1
653
    rm -f "$tmp"
654

            
655
    if ! safe_cp "$src" "$tmp"; then
656
        rm -f "$tmp"
657
        return 1
658
    fi
659

            
660
    if ! verify_copied_file "$src" "$tmp" "$expected_date"; then
661
        rm -f "$tmp"
662
        return 1
663
    fi
664

            
665
    if [[ -e "$dst" ]]; then
666
        log_message "Destination appeared during copy, refusing to overwrite: $dst" "ERROR"
667
        rm -f "$tmp"
668
        return 1
669
    fi
670

            
671
    if ! safe_mv "$tmp" "$dst"; then
672
        rm -f "$tmp"
673
        return 1
674
    fi
675

            
676
    if [[ ! -f "$dst" ]]; then
677
        log_message "Copied file missing after final move: $dst" "ERROR"
Bogdan Timofte authored 3 weeks ago
678
        return 1
679
    fi
680

            
681
    return 0
682
}
683

            
684
verified_move_file() {
685
    local src="$1"
686
    local dst="$2"
687
    local expected_date="$3"
688

            
689
    if ! copy_with_verification "$src" "$dst" "$expected_date"; then
690
        return 1
691
    fi
692

            
693
    if ! remove_source_file "$src"; then
694
        log_message "Copied and verified destination, but failed to remove source: $src" "ERROR"
695
        return 1
696
    fi
697

            
698
    return 0
699
}
700

            
Bogdan Timofte authored 9 months ago
701
# Function to format file size
702
format_size() {
703
    local size=$1
704
    if (( size < 1024 )); then
705
        echo "${size}B"
706
    elif (( size < 1048576 )); then
707
        echo "$(( size / 1024 ))KB"
708
    elif (( size < 1073741824 )); then
709
        echo "$(( size / 1048576 ))MB"
710
    else
711
        echo "$(( size / 1073741824 ))GB"
712
    fi
713
}
714

            
Bogdan Timofte authored 2 weeks ago
715
format_duration() {
716
    local total_seconds=$1
717
    local hours=$((total_seconds / 3600))
718
    local minutes=$(((total_seconds % 3600) / 60))
719
    local seconds=$((total_seconds % 60))
720

            
721
    if (( hours > 0 )); then
722
        printf "%dh %02dm %02ds" "$hours" "$minutes" "$seconds"
723
    elif (( minutes > 0 )); then
724
        printf "%dm %02ds" "$minutes" "$seconds"
725
    else
726
        printf "%ds" "$seconds"
727
    fi
728
}
729

            
Bogdan Timofte authored 2 weeks ago
730
format_data_rate() {
731
    local bytes_count="$1"
732
    local elapsed_seconds="$2"
Bogdan Timofte authored 2 weeks ago
733

            
Bogdan Timofte authored 2 weeks ago
734
    awk -v bytes="$bytes_count" -v seconds="$elapsed_seconds" '
Bogdan Timofte authored 2 weeks ago
735
        BEGIN {
Bogdan Timofte authored 2 weeks ago
736
            if (seconds <= 0 || bytes <= 0) {
Bogdan Timofte authored 2 weeks ago
737
                exit
738
            }
739

            
740
            mb_per_second = bytes / seconds / 1048576
Bogdan Timofte authored 2 weeks ago
741
            printf "%.2f MB/sec", mb_per_second
Bogdan Timofte authored 2 weeks ago
742
        }
743
    '
744
}
745

            
Bogdan Timofte authored 2 weeks ago
746
report_line() {
747
    local label="$1"
748
    local value="$2"
749
    printf "  %-24s %s\n" "$label" "$value"
750
}
751

            
Bogdan Timofte authored 9 months ago
752
# Function to extract date from file
753
extract_file_date() {
754
    local file="$1"
755
    local create_date=""
756
    local date_source=""
757
    local exif_found=0
Bogdan Timofte authored 2 weeks ago
758

            
Bogdan Timofte authored a week ago
759
    # Timecode of first frame is the most reliable local-time source for video files:
760
    # it is written by the camera's own clock at the moment recording starts, with no
761
    # UTC-vs-local ambiguity. Try it first (requires mediainfo) before any other method.
762
    if [[ "$DATE_SOURCE" != "filesystem" ]] && command -v mediainfo &>/dev/null; then
763
        local raw_tc
764
        raw_tc=$(mediainfo --Inform="Other;%TimeCode_FirstFrame%" "$file" 2>/dev/null | head -1)
765
        if [[ "$raw_tc" =~ ^([0-9]{2}):([0-9]{2}):([0-9]{2}): ]]; then
766
            local tc_hh="${BASH_REMATCH[1]}" tc_mm="${BASH_REMATCH[2]}" tc_ss="${BASH_REMATCH[3]}"
767
            local dt_re='^([0-9]{4}-[0-9]{2}-[0-9]{2})[T ]([0-9]{2}):[0-9]{2}:[0-9]{2}'
768
            local ref_date="" ref_hh=""
769

            
770
            # Primary: Recorded_Date includes timezone offset so its date+hour are already local.
771
            local recorded
772
            recorded=$(mediainfo --Inform="General;%Recorded_Date%" "$file" 2>/dev/null | head -1)
773
            if [[ "$recorded" =~ $dt_re ]]; then
774
                ref_date="${BASH_REMATCH[1]}"
775
                ref_hh="${BASH_REMATCH[2]}"
776
            else
777
                # Fallback: Encoded_Date is stored as UTC; convert to local to get the correct
778
                # reference date and hour for midnight-crossing detection.
779
                local encoded
780
                encoded=$(mediainfo --Inform="General;%Encoded_Date%" "$file" 2>/dev/null | head -1)
781
                encoded="${encoded% UTC}"
782
                if [[ "$encoded" =~ $dt_re ]]; then
783
                    local enc_date="${BASH_REMATCH[1]}" enc_time_str
784
                    enc_time_str="${encoded#* }"
785
                    if [[ "$OSTYPE" == "darwin"* ]]; then
786
                        local utc_ts local_dt
787
                        utc_ts=$(TZ=UTC date -j -f "%Y-%m-%d %H:%M:%S" "$encoded" "+%s" 2>/dev/null)
788
                        local_dt=$(date -j -r "$utc_ts" "+%Y-%m-%d %H" 2>/dev/null)
789
                        ref_date="${local_dt% *}"
790
                        ref_hh="${local_dt#* }"
791
                    else
792
                        ref_date=$(date -d "$encoded UTC" "+%Y-%m-%d" 2>/dev/null)
793
                        ref_hh=$(date -d "$encoded UTC" "+%H" 2>/dev/null)
794
                    fi
795
                fi
796
            fi
797

            
798
            if [[ -n "$ref_date" && -n "$ref_hh" ]]; then
799
                local start_date="$ref_date"
800
                # If timecode hour > end-time local hour, the clip crossed midnight:
801
                # subtract one day to get the recording-start calendar date.
802
                if (( 10#$tc_hh > 10#$ref_hh )); then
803
                    if [[ "$OSTYPE" == "darwin"* ]]; then
804
                        start_date=$(date -j -v-1d -f "%Y-%m-%d" "$ref_date" "+%Y-%m-%d" 2>/dev/null)
805
                    else
806
                        start_date=$(date -d "$ref_date - 1 day" "+%Y-%m-%d" 2>/dev/null)
807
                    fi
808
                fi
809
                if [[ -n "$start_date" ]]; then
810
                    echo "${start_date} ${tc_hh}:${tc_mm}:${tc_ss}|Timecode"
811
                    return 0
812
                fi
813
            fi
814
        fi
815
    fi
816

            
Bogdan Timofte authored 2 weeks ago
817
    # Filesystem authoritative mode, and GoPro media in auto mode.
818
    # GoPro fallback order is THM, LRV, then the MP4 filesystem timestamp.
819
    if [[ "$DATE_SOURCE" == "filesystem" ]] || { [[ "$DATE_SOURCE" == "auto" ]] && should_prefer_gopro_filesystem_date "$file"; }; then
820
        local filesystem_reference
821
        filesystem_reference=$(filesystem_date_reference "$file")
822
        create_date=$(extract_filesystem_date "$filesystem_reference") || return 2
823
        echo "$create_date|$(filesystem_date_source_label "$file" "$filesystem_reference")"
824
        return 0
825
    fi
826

            
Bogdan Timofte authored 9 months ago
827
    # Try to get creation date from EXIF data
828
    local exif_output=$(exiftool -G1 -s -CreateDate -DateTimeOriginal -DateTime "$file" 2>/dev/null)
829
    if [[ -n "$exif_output" ]]; then
830
        # Parse the exiftool output to find the best date
831
        while IFS= read -r line; do
832
            if [[ "$line" =~ ^\[([^\]]+)\][[:space:]]*([^:]+)[[:space:]]*:[[:space:]]*(.+)$ ]]; then
833
                local group="${BASH_REMATCH[1]}"
834
                local tag="${BASH_REMATCH[2]}"
835
                local value="${BASH_REMATCH[3]}"
836
                # Trim spaces from tag name
837
                tag=$(echo "$tag" | sed 's/[[:space:]]*$//')
838
                # Prefer DateTimeOriginal, then CreateDate, then DateTime
839
                if [[ "$tag" == "DateTimeOriginal" && -z "$create_date" ]]; then
840
                    create_date="$value"
841
                    date_source="$group:$tag"
842
                    exif_found=1
843
                elif [[ "$tag" == "CreateDate" && "$date_source" != *"DateTimeOriginal"* ]]; then
844
                    create_date="$value"
845
                    date_source="$group:$tag"
846
                    exif_found=1
847
                elif [[ "$tag" == "DateTime" && -z "$create_date" ]]; then
848
                    create_date="$value"
849
                    date_source="$group:$tag"
850
                    exif_found=1
851
                fi
852
            fi
853
        done <<< "$exif_output"
854
    fi
855
    # If no EXIF date found, try mediainfo for video files
856
    if [[ -z "$create_date" ]] && command -v mediainfo &> /dev/null; then
857
        local media_date=$(mediainfo --Inform="General;%Recorded_Date%" "$file" 2>/dev/null)
858
        if [[ -n "$media_date" && "$media_date" != "0000-00-00 00:00:00" ]]; then
859
            create_date="$media_date"
860
            date_source="MediaInfo:Recorded_Date"
861
        fi
862
    fi
Bogdan Timofte authored 2 weeks ago
863

            
864
    # In auto mode, if metadata is missing/unreliable, fall back to filesystem timestamps
865
    if [[ -z "$create_date" && "$DATE_SOURCE" == "auto" ]]; then
866
        local filesystem_reference
867
        filesystem_reference=$(filesystem_date_reference "$file")
868
        create_date=$(extract_filesystem_date "$filesystem_reference") || return 2
869
        date_source=$(filesystem_date_source_label "$file" "$filesystem_reference")
870
    fi
871

            
Bogdan Timofte authored 9 months ago
872
    # If no EXIF or mediainfo date found, return failure
873
    if [[ -z "$create_date" ]]; then
874
        return 2  # No date metadata found
875
    fi
876

            
877
    # Convert EXIF format (YYYY:MM:DD HH:MM:SS) to standard format
878
    # Always output as yyyy-mm-dd hh:mm:ss (pad single digits)
879
    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
880
        year="${BASH_REMATCH[1]}"
881
        month=$(printf "%02d" "$((10#${BASH_REMATCH[2]}))")
882
        day=$(printf "%02d" "$((10#${BASH_REMATCH[3]}))")
883
        hour=$(printf "%02d" "$((10#${BASH_REMATCH[4]}))")
884
        minute=$(printf "%02d" "$((10#${BASH_REMATCH[5]}))")
885
        second=$(printf "%02d" "$((10#${BASH_REMATCH[6]}))")
886
        create_date="$year-$month-$day $hour:$minute:$second"
887
    else
888
        # Try to convert yyyy-mm-dd hh:mm:ss (already correct)
889
        if [[ "$create_date" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})[[:space:]]*([0-9]{2}):([0-9]{2}):([0-9]{2})$ ]]; then
890
            # Already correct
891
            :
892
        else
893
            print_color "$RED" "Error: Cannot parse date '$create_date'" >&2
894
            return 2
895
        fi
896
    fi
897

            
Bogdan Timofte authored a week ago
898
    # For QuickTime files, the CreateDate is in UTC and needs conversion to local time.
899
    # Skip this conversion with --no-quicktime-utc for cameras that store local time in the UTC field
900
    # (common on some GoPro models/firmware with incorrect timezone settings).
901
    if [[ "$date_source" == *"QuickTime"* && $QUICKTIME_UTC -eq 1 ]]; then
Bogdan Timofte authored 9 months ago
902
        # Convert UTC time to local time
903
        if [[ "$OSTYPE" == "darwin"* ]]; then
904
            # On macOS, use TZ=UTC to interpret the input time as UTC
905
            local utc_timestamp=$(TZ=UTC date -j -f "%Y-%m-%d %H:%M:%S" "$create_date" "+%s" 2>/dev/null)
906
            if [[ -n "$utc_timestamp" ]]; then
907
                create_date=$(date -j -r "$utc_timestamp" "+%Y-%m-%d %H:%M:%S" 2>/dev/null)
908
                date_source="$date_source (converted from UTC)"
909
            fi
910
        else
911
            local utc_timestamp=$(date -d "$create_date UTC" "+%s" 2>/dev/null)
912
            if [[ -n "$utc_timestamp" ]]; then
913
                create_date=$(date -d "@$utc_timestamp" "+%Y-%m-%d %H:%M:%S" 2>/dev/null)
914
                date_source="$date_source (converted from UTC)"
915
            fi
916
        fi
917
    fi
918

            
919
    echo "$create_date|$date_source"
920
    return 0
921
}
922

            
923
# Function to generate destination path based on organization pattern
924
generate_destination_path() {
925
    local date_str="$1"
926
    local original_filename="$2"
927
    local base_destination="$3"
928

            
929
    # Extract date components - handle both GNU and BSD date
930
    local year month day hour minute second
931
    if [[ "$OSTYPE" == "darwin"* ]]; then
932
        # macOS (BSD date) - use more robust regex (allow single-digit month/day, tolerate extra spaces)
933
        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
934
            year="${BASH_REMATCH[1]}"
935
            month=$(printf "%02d" "$((10#${BASH_REMATCH[2]}))")
936
            day=$(printf "%02d" "$((10#${BASH_REMATCH[3]}))")
937
            hour=$(printf "%02d" "$((10#${BASH_REMATCH[4]}))")
938
            minute=$(printf "%02d" "$((10#${BASH_REMATCH[5]}))")
939
            second=$(printf "%02d" "$((10#${BASH_REMATCH[6]}))")
940
        else
941
            return 1
942
        fi
943
    else
944
        # Linux (GNU date)
945
        year=$(date -d "$date_str" "+%Y" 2>/dev/null)
946
        month=$(date -d "$date_str" "+%m" 2>/dev/null)
947
        day=$(date -d "$date_str" "+%d" 2>/dev/null)
948
        hour=$(date -d "$date_str" "+%H" 2>/dev/null)
949
        minute=$(date -d "$date_str" "+%M" 2>/dev/null)
950
        second=$(date -d "$date_str" "+%S" 2>/dev/null)
951
    fi
952

            
953
    if [[ -z "$year" || -z "$month" || -z "$day" ]]; then
954
        return 1
955
    fi
956

            
957
    # Get file extension
958
    local extension="${original_filename##*.}"
959
    local lowercase_ext=$(echo "$extension" | tr '[:upper:]' '[:lower:]')
960

            
961
    # Generate path and filename based on organization pattern
962
    local dir_path=""
963
    local filename=""
Bogdan Timofte authored 9 months ago
964

            
965
    # If no organization specified, use flat destination (base) and choose filename per mode
966
    if [[ -z "$ORGANIZATION" ]]; then
Bogdan Timofte authored 9 months ago
967
        dir_path="$base_destination"
Bogdan Timofte authored 9 months ago
968
        if [[ "$FILENAME_MODE" == "orig" ]]; then
969
            filename="$original_filename"
970
        else
971
            # full or auto both map to full date for flat layout
972
            filename="${year}-${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
973
        fi
974
        echo "$dir_path/$filename"
975
        return 0
976
    fi
977

            
978
    case "$ORGANIZATION" in
Bogdan Timofte authored 9 months ago
979
            "y")
980
                dir_path="$base_destination/$year"
981
                filename="${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
982
                ;;
983
            "m")
984
                dir_path="$base_destination/$year/$month"
985
                filename="${day}_${hour}-${minute}-${second}.${lowercase_ext}"
986
                ;;
987
            "d")
988
                dir_path="$base_destination/$year/$month/$day"
989
                filename="${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
990
                ;;
991
            "h")
992
                dir_path="$base_destination/$year/$month/$day/$hour"
993
                filename="${minute}-${second}.${lowercase_ext}"
994
                ;;
Bogdan Timofte authored 9 months ago
995
            "ym")
996
                # Single folder per month named yyyy-mm; filename includes day and time
997
                dir_path="$base_destination/${year}-${month}"
998
                filename="${day}_${hour}-${minute}-${second}.${lowercase_ext}"
999
                ;;
Bogdan Timofte authored 8 months ago
1000
            "ymd")
Bogdan Timofte authored 9 months ago
1001
                # Single folder per day named yyyy-mm-dd; filename is time
1002
                dir_path="$base_destination/${year}-${month}-${day}"
1003
                filename="${hour}-${minute}-${second}.${lowercase_ext}"
1004
                ;;
Bogdan Timofte authored 9 months ago
1005
            *)
1006
                log_message "Invalid organization pattern: $ORGANIZATION" "ERROR"
1007
                return 1
1008
                ;;
1009
        esac
Bogdan Timofte authored 9 months ago
1010

            
1011
    # Apply filename mode overrides
1012
    case "$FILENAME_MODE" in
1013
        orig)
1014
            filename="$original_filename"
1015
            ;;
1016
        full)
1017
            filename="${year}-${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
1018
            ;;
1019
        auto)
1020
            # keep the auto-generated filename from the organization case
1021
            ;;
1022
        *)
1023
            # fallback to full
1024
            filename="${year}-${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
1025
            ;;
1026
    esac
1027

            
Bogdan Timofte authored 9 months ago
1028
    echo "$dir_path/$filename"
1029
    return 0
1030
}
1031

            
1032
# Function to find files matching patterns
1033
find_source_files() {
Bogdan Timofte authored 9 months ago
1034
    # Emit absolute file paths for all media files under sources, excluding DESTINATION when it is inside a source
1035
    local abs_dest=""
1036
    if [[ -n "$DESTINATION" ]]; then
1037
        abs_dest=$(cd "$DESTINATION" 2>/dev/null && pwd) || abs_dest="$DESTINATION"
1038
    fi
1039

            
1040
    # Build -iname expression for find
1041
    local ext_expr=""
1042
    for ext in "${MEDIA_EXTENSIONS[@]}"; do
1043
        if [[ -n "$ext_expr" ]]; then
1044
            ext_expr="$ext_expr -o"
1045
        fi
1046
        ext_expr="$ext_expr -iname $ext"
1047
    done
1048

            
Bogdan Timofte authored 9 months ago
1049
    if [[ ${#SOURCE_PATTERNS[@]} -eq 0 ]]; then
Bogdan Timofte authored 9 months ago
1050
        # Default: scan current directory
1051
        local start_dot="."
1052
        local abs_current
1053
        abs_current=$(pwd)
1054
        local find_cmd=(find -L "$start_dot" -type f)
1055
        # If dest is inside cwd, add exclusion
1056
        if [[ -n "$abs_dest" && "$abs_dest" == "$abs_current"* ]]; then
1057
            find_cmd+=( ! -path "$abs_dest/*" ! -path "$abs_dest" )
Bogdan Timofte authored 9 months ago
1058
        fi
Bogdan Timofte authored 9 months ago
1059
        # Add expression
1060
        # shellcheck disable=SC2068
Bogdan Timofte authored 2 weeks ago
1061
        "${find_cmd[@]}" ! -name '._*' \( $ext_expr \) 2>/dev/null || true
Bogdan Timofte authored 9 months ago
1062
    else
Bogdan Timofte authored 9 months ago
1063
        # Scan each provided source
1064
        for src in "${SOURCE_PATTERNS[@]}"; do
1065
            if [[ -f "$src" ]]; then
Bogdan Timofte authored 2 weeks ago
1066
                if [[ "$(basename "$src")" == ._* ]]; then
1067
                    continue
1068
                fi
Bogdan Timofte authored 9 months ago
1069
                # single file - skip if it's inside dest
1070
                local abs_file
1071
                abs_file=$(cd "$(dirname "$src")" 2>/dev/null && pwd)/$(basename "$src")
1072
                if [[ -n "$abs_dest" && "$abs_file" == "$abs_dest"* ]]; then
1073
                    continue
1074
                fi
1075
                echo "$abs_file"
1076
            elif [[ -d "$src" ]]; then
1077
                local abs_src
1078
                abs_src=$(cd "$src" 2>/dev/null && pwd)
1079
                if [[ -n "$abs_src" ]]; then
1080
                    if [[ -n "$abs_dest" && "$abs_dest" == "$abs_src"* ]]; then
Bogdan Timofte authored 2 weeks ago
1081
                        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
1082
                    else
Bogdan Timofte authored 2 weeks ago
1083
                        find -L "$abs_src" -type f ! -name '._*' \( $ext_expr \) 2>/dev/null || true
Bogdan Timofte authored 9 months ago
1084
                    fi
Bogdan Timofte authored 9 months ago
1085
                else
1086
                    print_color "$YELLOW" "Warning: Could not resolve source directory: $src"
Bogdan Timofte authored 9 months ago
1087
                fi
Bogdan Timofte authored 9 months ago
1088
            else
1089
                print_color "$YELLOW" "Warning: Source path does not exist or is not a file/directory: $src"
Bogdan Timofte authored 9 months ago
1090
            fi
1091
        done
1092
    fi
1093
}
1094

            
Bogdan Timofte authored a week ago
1095
# Return the companion LRV path for a GoPro source file, or empty string.
1096
# GoPro naming: GX/GH/GP chapter files have a matching GL*.LRV low-res proxy.
1097
find_companion_lrv() {
1098
    local file="$1"
1099
    local filename dir stem num
1100
    filename=$(basename "$file")
1101
    dir=$(dirname "$file")
1102
    if [[ "$filename" =~ ^G[HPX]([0-9]{6})\.[Mm][Pp]4$ ]]; then
1103
        num="${BASH_REMATCH[1]}"
1104
        local lrv
1105
        for lrv in "$dir/GL${num}.LRV" "$dir/GL${num}.lrv"; do
1106
            [[ -f "$lrv" ]] && echo "$lrv" && return 0
1107
        done
1108
    fi
1109
    return 1
1110
}
1111

            
1112
# Extract first valid GPS fix from a GoPro video or LRV file.
1113
# Uses ffmpeg to pull the GPMF data stream (fast: reads index only, not video frames),
1114
# then parses it with the bundled Python script.
1115
# Outputs: "lat lon alt" in decimal degrees / metres.  Exit 1 if no fix found.
1116
extract_gps_first_fix() {
1117
    local file="$1"
1118
    local py="$SCRIPT_DIR/extract_gpmf_gps.py"
1119
    if [[ ! -f "$py" ]]; then
1120
        return 1
1121
    fi
1122
    python3 "$py" "$file" 2>/dev/null
1123
}
1124

            
1125
# Write GPS coordinates into a file using exiftool QuickTime GPS tags.
1126
embed_gps_into_file() {
1127
    local file="$1"
1128
    local lat="$2" lon="$3" alt="$4"
1129

            
1130
    local ref_lat="N" ref_lon="E" ref_alt="0"
1131
    if awk "BEGIN{exit !($lat < 0)}" 2>/dev/null; then
1132
        ref_lat="S"; lat="${lat#-}"
1133
    fi
1134
    if awk "BEGIN{exit !($lon < 0)}" 2>/dev/null; then
1135
        ref_lon="W"; lon="${lon#-}"
1136
    fi
1137
    if awk "BEGIN{exit !($alt < 0)}" 2>/dev/null; then
1138
        ref_alt="1"   # below sea level
1139
    fi
1140

            
1141
    exiftool -overwrite_original \
1142
        "-GPSLatitude=$lat"   "-GPSLatitudeRef=$ref_lat" \
1143
        "-GPSLongitude=$lon"  "-GPSLongitudeRef=$ref_lon" \
1144
        "-GPSAltitude=$alt"   "-GPSAltitudeRef=$ref_alt" \
1145
        "$file" >/dev/null 2>&1
1146
}
1147

            
Bogdan Timofte authored 2 weeks ago
1148
cleanup_media_sidecars() {
Bogdan Timofte authored 2 weeks ago
1149
    local file="$1"
1150
    local dir base stem ext sidecar_ext sidecar
1151
    dir=$(dirname "$file")
1152
    base=$(basename "$file")
1153
    stem="${base%.*}"
1154
    ext="${base##*.}"
1155

            
1156
    if [[ ! "$ext" =~ ^([Mm][Pp]4)$ ]]; then
1157
        return 0
1158
    fi
1159

            
Bogdan Timofte authored 2 weeks ago
1160
    local sidecar_exts
1161
    sidecar_exts=$(get_device_sidecar_extensions "$file")
1162
    [[ -z "$sidecar_exts" ]] && return 0
1163

            
1164
    for sidecar_ext in $sidecar_exts; do
Bogdan Timofte authored 2 weeks ago
1165
        sidecar="$dir/$stem.$sidecar_ext"
1166
        if [[ -f "$sidecar" ]]; then
1167
            if rm -f "$sidecar"; then
Bogdan Timofte authored 2 weeks ago
1168
                log_message "Deleted sidecar: $sidecar" "INFO"
Bogdan Timofte authored 2 weeks ago
1169
            else
Bogdan Timofte authored 2 weeks ago
1170
                log_message "Failed to delete sidecar: $sidecar" "WARNING"
Bogdan Timofte authored 2 weeks ago
1171
            fi
1172
        fi
1173
    done
1174
}
1175

            
Bogdan Timofte authored 9 months ago
1176
# Function to process a single file
1177
process_file() {
1178
    local file="$1"
1179
    local file_size=$(get_file_size "$file")
Bogdan Timofte authored 2 weeks ago
1180
    local file_label
1181
    file_label="$(basename "$file")"
Bogdan Timofte authored 9 months ago
1182
    TOTAL_SIZE=$((TOTAL_SIZE + file_size))
1183

            
Bogdan Timofte authored 2 weeks ago
1184
    if [[ $TOTAL_FILES -gt 0 && $CURRENT_FILE_INDEX -gt 0 ]]; then
1185
        print_color "$BLUE" "Processing [$CURRENT_FILE_INDEX/$TOTAL_FILES]: $file_label ($(format_size "$file_size"))"
1186
    else
1187
        print_color "$BLUE" "Processing: $file_label ($(format_size "$file_size"))"
1188
    fi
Bogdan Timofte authored 9 months ago
1189
    log_message "Processing: $file" "INFO"
1190

            
1191
    # Extract date information
1192
    local date_info=$(extract_file_date "$file")
1193
        local extract_status=$?
1194
        if [[ $extract_status -eq 2 ]]; then
1195
            if [[ $COLLECT_UNSORTABLE -eq 1 ]]; then
1196
                local unsortable_dir="$DESTINATION/unsortable"
1197
                mkdir -p "$unsortable_dir"
1198
                local unsortable_path="$unsortable_dir/$(basename "$file")"
Bogdan Timofte authored 2 weeks ago
1199
                local desired_unsortable_path="$unsortable_path"
1200
                local unsortable_conflict_status
1201
                resolve_destination_conflict "$unsortable_path" "$file"
1202
                unsortable_conflict_status=$?
1203
                if [[ $unsortable_conflict_status -eq 0 ]]; then
1204
                    unsortable_path="$RESOLVED_DESTINATION_PATH"
1205
                    if [[ "$unsortable_path" != "$desired_unsortable_path" ]]; then
1206
                        log_message "Destination already exists or is already planned: $desired_unsortable_path - using: $unsortable_path" "WARNING"
1207
                    fi
1208
                elif [[ $unsortable_conflict_status -eq 3 ]]; then
1209
                    log_message "Destination conflict skipped: $desired_unsortable_path" "WARNING"
1210
                    SKIPPED_FILES=$((SKIPPED_FILES + 1))
1211
                    return 1
1212
                elif [[ $unsortable_conflict_status -eq 4 ]]; then
1213
                    log_message "Import aborted by user at destination conflict: $desired_unsortable_path" "ERROR"
1214
                    ERROR_FILES=$((ERROR_FILES + 1))
1215
                    FATAL_ERROR=1
1216
                    return 2
1217
                else
1218
                    log_message "Could not resolve a unique destination path for $file (wanted: $desired_unsortable_path)" "ERROR"
1219
                    ERROR_FILES=$((ERROR_FILES + 1))
1220
                    return 1
1221
                fi
Bogdan Timofte authored 9 months ago
1222
                if [[ $DRY_RUN -eq 1 ]]; then
1223
                    print_color "$BLUE" "Would move unsortable: $file -> $unsortable_path"
1224
                else
Bogdan Timofte authored 3 weeks ago
1225
                    if verified_move_file "$file" "$unsortable_path" ""; then
Bogdan Timofte authored 9 months ago
1226
                        log_message "Unsortable: $file -> $unsortable_path" "SUCCESS"
1227
                    else
Bogdan Timofte authored 3 weeks ago
1228
                        log_message "Failed to move unsortable file after verification: $file" "ERROR"
Bogdan Timofte authored 9 months ago
1229
                    fi
1230
                fi
1231
                SKIPPED_FILES=$((SKIPPED_FILES + 1))
1232
            else
1233
                log_message "Could not extract date from $file - skipping" "WARNING"
1234
                SKIPPED_FILES=$((SKIPPED_FILES + 1))
1235
            fi
1236
            return 1
1237
        elif [[ $extract_status -ne 0 ]] || [[ -z "$date_info" ]]; then
1238
            log_message "Could not extract date from $file - skipping" "WARNING"
1239
            SKIPPED_FILES=$((SKIPPED_FILES + 1))
1240
            return 1
1241
        fi
1242
        local date_str="${date_info%|*}"
1243
        local date_source="${date_info#*|}"
1244
        log_message "Date: $date_str (from $date_source)" "INFO"
1245
        # Generate destination path
Bogdan Timofte authored 2 weeks ago
1246
        local original_basename
1247
        original_basename="$(basename "$file")"
1248
        local dest_path
1249
        dest_path=$(generate_destination_path "$date_str" "$original_basename" "$DESTINATION")
Bogdan Timofte authored 9 months ago
1250
        if [[ $? -ne 0 ]] || [[ -z "$dest_path" ]]; then
1251
            log_message "Could not generate destination path for $file" "ERROR"
1252
            ERROR_FILES=$((ERROR_FILES + 1))
1253
            FATAL_ERROR=1
1254
            return 2
1255
    fi
Bogdan Timofte authored 2 weeks ago
1256

            
1257
    local desired_dest_path="$dest_path"
1258
    local conflict_status
1259
    resolve_destination_conflict "$dest_path" "$file"
1260
    conflict_status=$?
1261
    if [[ $conflict_status -eq 0 ]]; then
1262
        dest_path="$RESOLVED_DESTINATION_PATH"
1263
    elif [[ $conflict_status -eq 3 ]]; then
1264
        log_message "Destination conflict skipped: $desired_dest_path" "WARNING"
1265
        SKIPPED_FILES=$((SKIPPED_FILES + 1))
1266
        return 1
1267
    elif [[ $conflict_status -eq 4 ]]; then
1268
        log_message "Import aborted by user at destination conflict: $desired_dest_path" "ERROR"
1269
        ERROR_FILES=$((ERROR_FILES + 1))
1270
        FATAL_ERROR=1
1271
        return 2
1272
    else
1273
        log_message "Could not resolve a unique destination path for $file (wanted: $desired_dest_path)" "ERROR"
1274
        ERROR_FILES=$((ERROR_FILES + 1))
1275
        return 1
Bogdan Timofte authored 9 months ago
1276
    fi
Bogdan Timofte authored 2 weeks ago
1277
    if [[ "$dest_path" != "$desired_dest_path" ]]; then
1278
        log_message "Destination already exists or is already planned: $desired_dest_path - using: $dest_path" "WARNING"
1279
    fi
1280

            
1281
    local dest_dir
1282
    dest_dir=$(dirname "$dest_path")
Bogdan Timofte authored 9 months ago
1283

            
1284
    if [[ $DRY_RUN -eq 1 ]]; then
1285
        if [[ $KEEP_ORIGINALS -eq 1 ]]; then
1286
            print_color "$BLUE" "Would copy: $file -> $dest_path"
1287
        else
1288
            print_color "$BLUE" "Would move: $file -> $dest_path"
1289
        fi
Bogdan Timofte authored 2 weeks ago
1290
        if should_sync_imported_metadata "$original_basename" "$date_source"; then
1291
            print_color "$BLUE" "Would sync metadata date: $dest_path -> $date_str"
1292
        fi
Bogdan Timofte authored 9 months ago
1293
        PROCESSED_FILES=$((PROCESSED_FILES + 1))
1294
        PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
1295
        return 0
1296
    fi
1297

            
Bogdan Timofte authored a week ago
1298
    # Pre-extract GPS from source before the move (source may be on faster local media).
1299
    # LRV companion preferred: same GPMF data, smaller file, faster extraction.
1300
    local pre_gps_lat="" pre_gps_lon="" pre_gps_alt=""
1301
    if [[ $SYNC_GPS -eq 1 ]]; then
1302
        local lrv_companion
1303
        lrv_companion=$(find_companion_lrv "$file")
1304
        local gps_src="${lrv_companion:-$file}"
1305
        read -r pre_gps_lat pre_gps_lon pre_gps_alt < <(extract_gps_first_fix "$gps_src")
1306
        if [[ -n "$pre_gps_lat" ]]; then
1307
            log_message "GPS: first fix at ($pre_gps_lat, $pre_gps_lon, ${pre_gps_alt}m) from $(basename "$gps_src")" "INFO"
1308
        else
1309
            log_message "GPS: no valid fix found in $(basename "$gps_src")" "INFO"
1310
        fi
1311
    fi
1312

            
Bogdan Timofte authored 9 months ago
1313
    # Create destination directory
1314
    if ! mkdir -p "$dest_dir"; then
1315
        log_message "Could not create directory: $dest_dir" "ERROR"
1316
        ERROR_FILES=$((ERROR_FILES + 1))
1317
        return 1
1318
    fi
Bogdan Timofte authored a week ago
1319

            
Bogdan Timofte authored 2 weeks ago
1320
    local sync_metadata_after_copy=0
1321
    local verification_date="$date_str"
1322
    if should_sync_imported_metadata "$original_basename" "$date_source"; then
1323
        sync_metadata_after_copy=1
1324
        verification_date=""
1325
    fi
1326

            
1327
    # Copy or move file using safe helpers after destination conflicts are resolved.
Bogdan Timofte authored 9 months ago
1328
    if [[ $KEEP_ORIGINALS -eq 1 ]]; then
Bogdan Timofte authored 2 weeks ago
1329
        if copy_with_verification "$file" "$dest_path" "$verification_date"; then
1330
            if [[ $sync_metadata_after_copy -eq 1 ]]; then
1331
                if ! sync_destination_metadata_to_date "$dest_path" "$date_str"; then
1332
                    log_message "Failed to sync destination metadata timestamps: $dest_path" "ERROR"
1333
                    ERROR_FILES=$((ERROR_FILES + 1))
1334
                    return 1
1335
                elif ! verify_synced_metadata_date "$dest_path" "$date_str"; then
1336
                    ERROR_FILES=$((ERROR_FILES + 1))
1337
                    return 1
1338
                fi
1339
            fi
Bogdan Timofte authored 9 months ago
1340
            log_message "Copied: $file -> $dest_path" "SUCCESS"
Bogdan Timofte authored a week ago
1341
            if [[ $SYNC_GPS -eq 1 && -n "$pre_gps_lat" ]]; then
1342
                embed_gps_into_file "$dest_path" "$pre_gps_lat" "$pre_gps_lon" "$pre_gps_alt" \
1343
                    && log_message "GPS: embedded into $dest_path" "INFO" \
1344
                    || log_message "GPS: failed to embed into $dest_path" "WARNING"
1345
            fi
Bogdan Timofte authored 2 weeks ago
1346
            if [[ $CLEANUP_MEDIA_SIDECARS -eq 1 ]]; then
1347
                cleanup_media_sidecars "$file"
Bogdan Timofte authored 2 weeks ago
1348
            fi
Bogdan Timofte authored 9 months ago
1349
            PROCESSED_FILES=$((PROCESSED_FILES + 1))
1350
            PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
1351
            return 0
1352
        else
Bogdan Timofte authored 3 weeks ago
1353
            log_message "Failed to copy or verify destination: $file -> $dest_path" "ERROR"
Bogdan Timofte authored 9 months ago
1354
            ERROR_FILES=$((ERROR_FILES + 1))
1355
            return 1
1356
        fi
1357
    else
Bogdan Timofte authored 2 weeks ago
1358
        if [[ $sync_metadata_after_copy -eq 1 ]]; then
1359
            if copy_with_verification "$file" "$dest_path" "$verification_date"; then
1360
                if ! sync_destination_metadata_to_date "$dest_path" "$date_str"; then
1361
                    log_message "Failed to sync destination metadata timestamps: $dest_path" "ERROR"
1362
                    ERROR_FILES=$((ERROR_FILES + 1))
1363
                    return 1
1364
                fi
1365
                if ! verify_synced_metadata_date "$dest_path" "$date_str"; then
1366
                    ERROR_FILES=$((ERROR_FILES + 1))
1367
                    return 1
1368
                fi
1369
                if ! remove_source_file "$file"; then
1370
                    log_message "Copied, verified, and synced destination, but failed to remove source: $file" "ERROR"
1371
                    ERROR_FILES=$((ERROR_FILES + 1))
1372
                    return 1
1373
                fi
1374
                log_message "Moved: $file -> $dest_path" "SUCCESS"
Bogdan Timofte authored a week ago
1375
            if [[ $SYNC_GPS -eq 1 && -n "$pre_gps_lat" ]]; then
1376
                embed_gps_into_file "$dest_path" "$pre_gps_lat" "$pre_gps_lon" "$pre_gps_alt" \
1377
                    && log_message "GPS: embedded into $dest_path" "INFO" \
1378
                    || log_message "GPS: failed to embed into $dest_path" "WARNING"
1379
            fi
Bogdan Timofte authored 2 weeks ago
1380
            if [[ $CLEANUP_MEDIA_SIDECARS -eq 1 ]]; then
1381
                cleanup_media_sidecars "$file"
Bogdan Timofte authored 2 weeks ago
1382
            fi
Bogdan Timofte authored 2 weeks ago
1383
                PROCESSED_FILES=$((PROCESSED_FILES + 1))
1384
                PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
1385
                return 0
1386
            else
1387
                log_message "Failed to move after copy verification: $file -> $dest_path" "ERROR"
1388
                ERROR_FILES=$((ERROR_FILES + 1))
1389
                return 1
1390
            fi
1391
        elif verified_move_file "$file" "$dest_path" "$date_str"; then
Bogdan Timofte authored 9 months ago
1392
            log_message "Moved: $file -> $dest_path" "SUCCESS"
Bogdan Timofte authored a week ago
1393
            if [[ $SYNC_GPS -eq 1 && -n "$pre_gps_lat" ]]; then
1394
                embed_gps_into_file "$dest_path" "$pre_gps_lat" "$pre_gps_lon" "$pre_gps_alt" \
1395
                    && log_message "GPS: embedded into $dest_path" "INFO" \
1396
                    || log_message "GPS: failed to embed into $dest_path" "WARNING"
1397
            fi
Bogdan Timofte authored 2 weeks ago
1398
            if [[ $CLEANUP_MEDIA_SIDECARS -eq 1 ]]; then
1399
                cleanup_media_sidecars "$file"
Bogdan Timofte authored 2 weeks ago
1400
            fi
Bogdan Timofte authored 2 weeks ago
1401
            if [[ $sync_metadata_after_copy -eq 1 ]]; then
1402
                if ! sync_destination_metadata_to_date "$dest_path" "$date_str"; then
1403
                    log_message "Failed to sync destination metadata timestamps: $dest_path" "WARNING"
1404
                elif ! verify_synced_metadata_date "$dest_path" "$date_str"; then
1405
                    log_message "Failed to verify synced destination metadata timestamps: $dest_path" "WARNING"
1406
                fi
1407
            fi
Bogdan Timofte authored 9 months ago
1408
            PROCESSED_FILES=$((PROCESSED_FILES + 1))
1409
            PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
1410
            return 0
1411
        else
Bogdan Timofte authored 3 weeks ago
1412
            log_message "Failed to move after copy verification: $file -> $dest_path" "ERROR"
Bogdan Timofte authored 9 months ago
1413
            ERROR_FILES=$((ERROR_FILES + 1))
1414
            return 1
1415
        fi
1416
    fi
1417
}
1418

            
1419
# Function to display final report
1420
show_report() {
1421
    local end_time=$(date +%s)
1422
    local elapsed_time=$((end_time - START_TIME))
1423
    local hours=$((elapsed_time / 3600))
1424
    local minutes=$(((elapsed_time % 3600) / 60))
1425
    local seconds=$((elapsed_time % 60))
1426

            
1427
    echo ""
1428
    print_color "$GREEN" "=========================================="
1429
    print_color "$GREEN" "           PROCESSING REPORT"
1430
    print_color "$GREEN" "=========================================="
1431
    echo ""
Bogdan Timofte authored 2 weeks ago
1432

            
1433
    echo "Files Summary:"
1434
    report_line "Total files found:" "$TOTAL_FILES"
1435
    report_line "Successfully processed:" "$PROCESSED_FILES"
1436
    report_line "Skipped:" "$SKIPPED_FILES"
1437
    report_line "Errors:" "$ERROR_FILES"
1438
    echo ""
1439

            
Bogdan Timofte authored 9 months ago
1440
    echo "Size Summary:"
Bogdan Timofte authored 2 weeks ago
1441
    report_line "Total size found:" "$(format_size $TOTAL_SIZE)"
1442
    report_line "Successfully processed:" "$(format_size $PROCESSED_SIZE)"
Bogdan Timofte authored 9 months ago
1443
    echo ""
Bogdan Timofte authored 2 weeks ago
1444

            
1445
    echo "Time Summary:"
1446
    report_line "Time elapsed:" "$(printf "%02d:%02d:%02d" $hours $minutes $seconds)"
1447
    if [[ $elapsed_time -gt 0 && $PROCESSED_SIZE -gt 0 ]]; then
1448
        local data_rate
1449
        data_rate=$(format_data_rate "$PROCESSED_SIZE" "$elapsed_time")
1450
        if [[ -n "$data_rate" ]]; then
1451
            report_line "Data rate:" "$data_rate"
1452
        fi
1453
    fi
1454

            
1455
    echo ""
1456

            
Bogdan Timofte authored 9 months ago
1457
    if [[ $DRY_RUN -eq 1 ]]; then
1458
        print_color "$YELLOW" "DRY RUN MODE - No files were actually moved/copied"
1459
    elif [[ $KEEP_ORIGINALS -eq 1 ]]; then
1460
        print_color "$BLUE" "COPY MODE - Original files were preserved"
1461
    else
1462
        print_color "$GREEN" "MOVE MODE - Files were moved to destination"
1463
    fi
1464

            
1465
    echo ""
1466
    print_color "$GREEN" "=========================================="
1467
}
1468

            
Bogdan Timofte authored 2 weeks ago
1469
if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then
1470
    return 0
1471
fi
1472

            
Bogdan Timofte authored 9 months ago
1473
# Parse command line arguments
1474
while [[ $# -gt 0 ]]; do
1475
    case $1 in
1476
        -o|--organization)
1477
            ORGANIZATION="$2"
Bogdan Timofte authored 8 months ago
1478
            # Accept new patterns: ym, ymd as well as single-letter ones
1479
            if [[ ! "$ORGANIZATION" =~ ^(y|m|d|h|ym|ymd)$ ]]; then
Bogdan Timofte authored 9 months ago
1480
                print_color "$RED" "Error: Invalid organization pattern. Must be one of: y, m, d, h, ym, ymd"
Bogdan Timofte authored 9 months ago
1481
                exit 1
1482
            fi
1483
            shift 2
1484
            ;;
Bogdan Timofte authored 9 months ago
1485

            
1486
        -F|--filename-mode)
1487
            FILENAME_MODE="$2"
1488
            if [[ ! "$FILENAME_MODE" =~ ^(auto|full|orig)$ ]]; then
1489
                print_color "$RED" "Error: Invalid filename mode. Must be one of: auto, full, orig"
1490
                exit 1
1491
            fi
1492
            shift 2
Bogdan Timofte authored 9 months ago
1493
            ;;
Bogdan Timofte authored 9 months ago
1494
    # (conflict resolution option removed; let exiftool/filesystem handle naming conflicts)
Bogdan Timofte authored 9 months ago
1495
        --collect-unsortable)
1496
            COLLECT_UNSORTABLE=1
1497
            shift
1498
            ;;
Bogdan Timofte authored 8 months ago
1499
        --keep-empty-dirs)
1500
            CLEANUP_EMPTY_DIRS=0
1501
            shift
1502
            ;;
Bogdan Timofte authored 9 months ago
1503
        -s|--source)
1504
            SOURCE_PATTERNS+=("$2")
1505
            shift 2
1506
            ;;
1507
        -d|--destination)
1508
            DESTINATION="$2"
1509
            shift 2
1510
            ;;
1511
        -k|--keep-originals)
1512
            KEEP_ORIGINALS=1
1513
            shift
1514
            ;;
Bogdan Timofte authored 3 weeks ago
1515
        --verify-mode)
1516
            VERIFY_MODE="$2"
1517
            if [[ ! "$VERIFY_MODE" =~ ^(size|strict|none)$ ]]; then
1518
                print_color "$RED" "Error: Invalid verify mode. Must be one of: size, strict, none"
1519
                exit 1
1520
            fi
1521
            shift 2
1522
            ;;
Bogdan Timofte authored 2 weeks ago
1523
        --date-source)
1524
            DATE_SOURCE="$2"
1525
            if [[ ! "$DATE_SOURCE" =~ ^(auto|exif|filesystem)$ ]]; then
1526
                print_color "$RED" "Error: Invalid date source. Must be one of: auto, exif, filesystem"
1527
                exit 1
1528
            fi
1529
            shift 2
1530
            ;;
1531
        --sync-metadata)
1532
            SYNC_METADATA=1
1533
            shift
1534
            ;;
Bogdan Timofte authored a week ago
1535
        --no-quicktime-utc)
1536
            QUICKTIME_UTC=0
1537
            shift
1538
            ;;
Bogdan Timofte authored 2 weeks ago
1539
        --unattended)
1540
            UNATTENDED=1
1541
            shift
1542
            ;;
Bogdan Timofte authored 2 weeks ago
1543
        --keep-sidecars)
1544
            CLEANUP_MEDIA_SIDECARS=0
Bogdan Timofte authored 2 weeks ago
1545
            shift
1546
            ;;
Bogdan Timofte authored a week ago
1547
        --sync-gps)
1548
            SYNC_GPS=1
1549
            shift
1550
            ;;
Bogdan Timofte authored 9 months ago
1551
        --dry-run)
1552
            DRY_RUN=1
1553
            shift
1554
            ;;
1555
        -v|--verbose)
1556
            VERBOSE=1
1557
            shift
1558
            ;;
1559
        -h|--help)
1560
            show_help
1561
            exit 0
1562
            ;;
1563
        --version)
1564
            show_version
1565
            exit 0
1566
            ;;
1567
        *)
1568
            print_color "$RED" "Error: Unknown option: $1"
1569
            echo "Use -h or --help for usage information."
1570
            exit 1
1571
            ;;
1572
    esac
1573
done
1574

            
Bogdan Timofte authored 2 weeks ago
1575
# Non-interactive execution cannot safely ask conflict questions.
1576
if [[ $UNATTENDED -eq 0 && ( ! -t 0 || ! -t 1 ) ]]; then
1577
    UNATTENDED=1
1578
fi
1579

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

            
Bogdan Timofte authored 3 weeks ago
1582
# If no source specified, default to current directory. Refuse to run when cwd is unsafe.
1583
if [[ ${#SOURCE_PATTERNS[@]} -eq 0 ]]; then
1584
    cwd=$(pwd)
1585
    # Resolve home and root paths
1586
    home_dir="$HOME"
1587
    case "$cwd" in
1588
        "$home_dir"|"/"|"/etc"|"/var"|"/bin"|"/sbin"|"/dev"|"/proc"|"/sys"|"/run"|"/usr"|"/lib"|"/lib64"|"/boot"|"/snap")
1589
            print_color "$RED" "Refusing to run with default source in unsafe directory: $cwd"
1590
            print_color "$RED" "Please specify $cwd as source explicitly with -s or run from a safer directory."
1591
            exit 1
1592
            ;;
1593
        *)
1594
            SOURCE_PATTERNS+=("$cwd")
1595
            ;;
1596
    esac
1597
fi
1598

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

            
1607
    if [[ ${#SOURCE_PATTERNS[@]} -gt 0 ]]; then
1608
        first_source="${SOURCE_PATTERNS[0]}"
Bogdan Timofte authored 3 weeks ago
1609
        if [[ -d "$first_source" ]]; then
1610
            DESTINATION="$first_source/sorted"
1611
        elif [[ -f "$first_source" ]]; then
1612
            DESTINATION="$(dirname "$first_source")/sorted"
Bogdan Timofte authored 9 months ago
1613
        else
1614
            print_color "$YELLOW" "Warning: first source does not exist; defaulting destination to ./sorted"
1615
            DESTINATION="./sorted"
1616
        fi
1617
    else
1618
        DESTINATION="./sorted"
1619
    fi
Bogdan Timofte authored 9 months ago
1620
fi
1621

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

            
1625
# Display configuration
1626
print_color "$GREEN" "$SCRIPT_NAME v$VERSION"
1627
echo ""
1628
echo "Configuration:"
1629
echo "  Organization pattern: $ORGANIZATION"
1630
echo "  Destination:         $DESTINATION"
1631
echo "  Keep originals:      $([ $KEEP_ORIGINALS -eq 1 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored 3 weeks ago
1632
echo "  Verify mode:         $VERIFY_MODE"
Bogdan Timofte authored 2 weeks ago
1633
echo "  Date source:         $DATE_SOURCE"
1634
echo "  Sync metadata:       $([ $SYNC_METADATA -eq 1 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored a week ago
1635
echo "  QuickTime UTC:       $([ $QUICKTIME_UTC -eq 1 ] && echo "Yes" || echo "No (local time assumed)")"
Bogdan Timofte authored a week ago
1636
echo "  Sync GPS:            $([ $SYNC_GPS -eq 1 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored 2 weeks ago
1637
echo "  Unattended:          $([ $UNATTENDED -eq 1 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored 9 months ago
1638
echo "  Dry run:             $([ $DRY_RUN -eq 1 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored 8 months ago
1639
echo "  Keep empty dirs:     $([ $CLEANUP_EMPTY_DIRS -eq 0 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored 9 months ago
1640
echo "  Verbose:             $([ $VERBOSE -eq 1 ] && echo "Yes" || echo "No")"
1641

            
1642
if [[ ${#SOURCE_PATTERNS[@]} -gt 0 ]]; then
1643
    echo "  Source patterns:"
1644
    for pattern in "${SOURCE_PATTERNS[@]}"; do
1645
        echo "    - $pattern"
1646
    done
1647
else
1648
    echo "  Source patterns:     All media files in current directory"
1649
fi
1650

            
1651
echo ""
1652

            
1653
# Check dependencies
1654
check_dependencies
1655

            
1656
# Create destination directory if it doesn't exist (unless dry run)
1657
if [[ $DRY_RUN -eq 0 ]]; then
1658
    if ! mkdir -p "$DESTINATION"; then
1659
        print_color "$RED" "Error: Cannot create destination directory: $DESTINATION"
1660
        exit 1
1661
    fi
1662
fi
1663

            
1664
# Find all source files
1665

            
1666
print_color "$BLUE" "Scanning for media files..."
1667
files=()
1668
while IFS= read -r file; do
1669
    files+=("$file")
1670
done < <(find_source_files)
Bogdan Timofte authored 2 weeks ago
1671
if [[ ${#files[@]} -gt 0 ]]; then
1672
    IFS=$'\n' files=($(printf '%s\n' "${files[@]}" | sort))
1673
    unset IFS
1674
fi
Bogdan Timofte authored 9 months ago
1675
TOTAL_FILES=${#files[@]}
1676

            
1677
if [[ $TOTAL_FILES -eq 0 ]]; then
1678
    print_color "$YELLOW" "No media files found matching the specified patterns."
1679
    exit 0
1680
fi
1681

            
1682
print_color "$BLUE" "Found $TOTAL_FILES media files to process"
1683
echo ""
1684

            
1685
# Process each file
1686

            
1687
FATAL_ERROR=0
1688
for file in "${files[@]}"; do
1689
    if [[ -f "$file" ]]; then
Bogdan Timofte authored 2 weeks ago
1690
        CURRENT_FILE_INDEX=$((CURRENT_FILE_INDEX + 1))
Bogdan Timofte authored 9 months ago
1691
        process_file "$file"
1692
        if [[ $FATAL_ERROR -eq 1 ]]; then
1693
            print_color "$RED" "Fatal error encountered. Stopping further processing."
1694
            break
1695
        fi
1696
    fi
1697
done
1698

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

            
Bogdan Timofte authored 9 months ago
1707
# Show final report
1708
show_report
1709

            
1710
# Exit with appropriate code
1711
if [[ $ERROR_FILES -gt 0 ]]; then
1712
    exit 1
1713
else
1714
    exit 0
1715
fi