MediaImporter / media-importer.sh
Newer Older
1732 lines | 60.903kb
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=1              # 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
30
RESOLVED_DESTINATION_PATH=""
31
RESERVED_DESTINATION_PATHS=()
Bogdan Timofte authored 9 months ago
32

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

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

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

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

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

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

            
Bogdan Timofte authored 9 months ago
96
Usage:
Bogdan Timofte authored 9 months ago
97
    $0 [OPTIONS]
98

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

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

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

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

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

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

            
142
# Function to check dependencies
143
check_dependencies() {
144
    local missing_deps=()
145

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

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

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

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

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

            
179
    log_message "All required dependencies found" "SUCCESS"
180
}
181

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

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

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

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

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

            
240
ensure_unique_destination_path() {
241
    local desired_path="$1"
242

            
243
    if [[ -z "$desired_path" ]]; then
244
        return 1
245
    fi
246

            
247
    if ! destination_path_unavailable "$desired_path"; then
248
        echo "$desired_path"
249
        return 0
250
    fi
251

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

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

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

            
279
    return 1
280
}
281

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

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

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

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

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

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

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

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

            
364
    if [[ -z "$desired_path" ]]; then
365
        return 1
366
    fi
367

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

            
374
    if [[ "$CONFLICT_APPLY_ALL" == "skip" ]]; then
375
        return 3
376
    fi
377

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

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

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

            
423
    if [[ -z "$resolved_path" ]]; then
424
        return 1
425
    fi
426

            
427
    RESOLVED_DESTINATION_PATH="$resolved_path"
428
    reserve_destination_path "$RESOLVED_DESTINATION_PATH"
429
    return 0
430
}
431

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

            
441
    local epoch=""
442

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

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

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

            
474
    echo "$file"
475
}
476

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

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

            
489
get_device_sidecar_extensions() {
490
    local file="$1"
491
    local filename
492
    filename=$(basename "$file")
493

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

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

            
504
    is_gopro_media_file "$file"
505
}
506

            
507
filesystem_date_source_label() {
508
    local file="$1"
509
    local reference="$2"
510

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

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

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

            
534
    local exif_dt
535
    exif_dt=$(date_to_exiftool_format "$date_str") || return 1
536

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

            
550
verify_synced_metadata_date() {
551
    local file="$1"
552
    local expected_date="$2"
553

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

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

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

            
569
    return 0
570
}
571

            
572
should_sync_imported_metadata() {
573
    local original_filename="$1"
574
    local date_source="$2"
575

            
576
    if [[ $SYNC_METADATA -eq 1 ]]; then
577
        return 0
578
    fi
579

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

            
584
    return 1
Bogdan Timofte authored 9 months ago
585
}
586

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

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

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

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

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

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

            
630
    return 0
631
}
632

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

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

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

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

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

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

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

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

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

            
679
    return 0
680
}
681

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

            
687
    if ! copy_with_verification "$src" "$dst" "$expected_date"; then
688
        return 1
689
    fi
690

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

            
696
    return 0
697
}
698

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
917
    echo "$create_date|$date_source"
918
    return 0
919
}
920

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

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

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

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

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

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored a week ago
1110
# Extract first valid GPS fix from a GoPro video or LRV file using exiftool -ee.
1111
#
1112
# IMPORTANT: specific tag filters (-GPSLatitude etc.) cause exiftool to emit all
1113
# values of each tag grouped together (all MeasureModes, then all Latitudes…),
1114
# not interleaved per sample. The -a flag (allow duplicates) with NO tag filter
1115
# produces interleaved per-block output:
1116
#   GPSMeasureMode : 3          <- block-level, once per DEVC
1117
#   GPSLatitude    : 47.1…      <- GPS5 sample 1
1118
#   GPSLongitude   : 27.5…
1119
#   GPSAltitude    : 185…
1120
#   GPSLatitude    : 47.1…      <- GPS5 sample 2
1121
#   …
1122
#   GPSMeasureMode : 3          <- next DEVC block
1123
#
1124
# awk exits on first valid fix → SIGPIPE terminates exiftool early.
1125
# Outputs: "lat lon alt" decimal degrees / metres, signed (S/W negative).
Bogdan Timofte authored a week ago
1126
extract_gps_first_fix() {
1127
    local file="$1"
Bogdan Timofte authored a week ago
1128
    exiftool -ee -a -n -s "$file" 2>/dev/null | \
1129
    awk -F ' *: *' '
1130
        $1 == "GPSMeasureMode" { mode = $2+0; lat = ""; lon = "" }
1131
        $1 == "GPSLatitude"    { lat = $2+0 }
1132
        $1 == "GPSLongitude"   { lon = $2+0 }
1133
        $1 == "GPSAltitude"    {
1134
            if (mode >= 2 && (lat+0 > 0.001 || lat+0 < -0.001) \
1135
                          && (lon+0 > 0.001 || lon+0 < -0.001)) {
1136
                printf "%.7f %.7f %.3f\n", lat+0, lon+0, $2+0; exit
1137
            }
1138
        }
1139
    '
Bogdan Timofte authored a week ago
1140
}
1141

            
1142
# Write GPS coordinates into a file using exiftool QuickTime GPS tags.
1143
embed_gps_into_file() {
1144
    local file="$1"
1145
    local lat="$2" lon="$3" alt="$4"
1146

            
1147
    local ref_lat="N" ref_lon="E" ref_alt="0"
1148
    if awk "BEGIN{exit !($lat < 0)}" 2>/dev/null; then
1149
        ref_lat="S"; lat="${lat#-}"
1150
    fi
1151
    if awk "BEGIN{exit !($lon < 0)}" 2>/dev/null; then
1152
        ref_lon="W"; lon="${lon#-}"
1153
    fi
1154
    if awk "BEGIN{exit !($alt < 0)}" 2>/dev/null; then
1155
        ref_alt="1"   # below sea level
1156
    fi
1157

            
1158
    exiftool -overwrite_original \
1159
        "-GPSLatitude=$lat"   "-GPSLatitudeRef=$ref_lat" \
1160
        "-GPSLongitude=$lon"  "-GPSLongitudeRef=$ref_lon" \
1161
        "-GPSAltitude=$alt"   "-GPSAltitudeRef=$ref_alt" \
1162
        "$file" >/dev/null 2>&1
1163
}
1164

            
Bogdan Timofte authored 2 weeks ago
1165
cleanup_media_sidecars() {
Bogdan Timofte authored 2 weeks ago
1166
    local file="$1"
1167
    local dir base stem ext sidecar_ext sidecar
1168
    dir=$(dirname "$file")
1169
    base=$(basename "$file")
1170
    stem="${base%.*}"
1171
    ext="${base##*.}"
1172

            
1173
    if [[ ! "$ext" =~ ^([Mm][Pp]4)$ ]]; then
1174
        return 0
1175
    fi
1176

            
Bogdan Timofte authored 2 weeks ago
1177
    local sidecar_exts
1178
    sidecar_exts=$(get_device_sidecar_extensions "$file")
1179
    [[ -z "$sidecar_exts" ]] && return 0
1180

            
1181
    for sidecar_ext in $sidecar_exts; do
Bogdan Timofte authored 2 weeks ago
1182
        sidecar="$dir/$stem.$sidecar_ext"
1183
        if [[ -f "$sidecar" ]]; then
1184
            if rm -f "$sidecar"; then
Bogdan Timofte authored 2 weeks ago
1185
                log_message "Deleted sidecar: $sidecar" "INFO"
Bogdan Timofte authored 2 weeks ago
1186
            else
Bogdan Timofte authored 2 weeks ago
1187
                log_message "Failed to delete sidecar: $sidecar" "WARNING"
Bogdan Timofte authored 2 weeks ago
1188
            fi
1189
        fi
1190
    done
1191
}
1192

            
Bogdan Timofte authored 9 months ago
1193
# Function to process a single file
1194
process_file() {
1195
    local file="$1"
1196
    local file_size=$(get_file_size "$file")
Bogdan Timofte authored 2 weeks ago
1197
    local file_label
1198
    file_label="$(basename "$file")"
Bogdan Timofte authored 9 months ago
1199
    TOTAL_SIZE=$((TOTAL_SIZE + file_size))
1200

            
Bogdan Timofte authored 2 weeks ago
1201
    if [[ $TOTAL_FILES -gt 0 && $CURRENT_FILE_INDEX -gt 0 ]]; then
1202
        print_color "$BLUE" "Processing [$CURRENT_FILE_INDEX/$TOTAL_FILES]: $file_label ($(format_size "$file_size"))"
1203
    else
1204
        print_color "$BLUE" "Processing: $file_label ($(format_size "$file_size"))"
1205
    fi
Bogdan Timofte authored 9 months ago
1206
    log_message "Processing: $file" "INFO"
1207

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

            
1274
    local desired_dest_path="$dest_path"
1275
    local conflict_status
1276
    resolve_destination_conflict "$dest_path" "$file"
1277
    conflict_status=$?
1278
    if [[ $conflict_status -eq 0 ]]; then
1279
        dest_path="$RESOLVED_DESTINATION_PATH"
1280
    elif [[ $conflict_status -eq 3 ]]; then
1281
        log_message "Destination conflict skipped: $desired_dest_path" "WARNING"
1282
        SKIPPED_FILES=$((SKIPPED_FILES + 1))
1283
        return 1
1284
    elif [[ $conflict_status -eq 4 ]]; then
1285
        log_message "Import aborted by user at destination conflict: $desired_dest_path" "ERROR"
1286
        ERROR_FILES=$((ERROR_FILES + 1))
1287
        FATAL_ERROR=1
1288
        return 2
1289
    else
1290
        log_message "Could not resolve a unique destination path for $file (wanted: $desired_dest_path)" "ERROR"
1291
        ERROR_FILES=$((ERROR_FILES + 1))
1292
        return 1
Bogdan Timofte authored 9 months ago
1293
    fi
Bogdan Timofte authored 2 weeks ago
1294
    if [[ "$dest_path" != "$desired_dest_path" ]]; then
1295
        log_message "Destination already exists or is already planned: $desired_dest_path - using: $dest_path" "WARNING"
1296
    fi
1297

            
1298
    local dest_dir
1299
    dest_dir=$(dirname "$dest_path")
Bogdan Timofte authored 9 months ago
1300

            
1301
    if [[ $DRY_RUN -eq 1 ]]; then
1302
        if [[ $KEEP_ORIGINALS -eq 1 ]]; then
1303
            print_color "$BLUE" "Would copy: $file -> $dest_path"
1304
        else
1305
            print_color "$BLUE" "Would move: $file -> $dest_path"
1306
        fi
Bogdan Timofte authored 2 weeks ago
1307
        if should_sync_imported_metadata "$original_basename" "$date_source"; then
1308
            print_color "$BLUE" "Would sync metadata date: $dest_path -> $date_str"
1309
        fi
Bogdan Timofte authored 9 months ago
1310
        PROCESSED_FILES=$((PROCESSED_FILES + 1))
1311
        PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
1312
        return 0
1313
    fi
1314

            
Bogdan Timofte authored a week ago
1315
    # Pre-extract GPS from source before the move (source may be on faster local media).
1316
    # LRV companion preferred: same GPMF data, smaller file, faster extraction.
1317
    local pre_gps_lat="" pre_gps_lon="" pre_gps_alt=""
1318
    if [[ $SYNC_GPS -eq 1 ]]; then
1319
        local lrv_companion
1320
        lrv_companion=$(find_companion_lrv "$file")
1321
        local gps_src="${lrv_companion:-$file}"
1322
        read -r pre_gps_lat pre_gps_lon pre_gps_alt < <(extract_gps_first_fix "$gps_src")
1323
        if [[ -n "$pre_gps_lat" ]]; then
1324
            log_message "GPS: first fix at ($pre_gps_lat, $pre_gps_lon, ${pre_gps_alt}m) from $(basename "$gps_src")" "INFO"
1325
        else
1326
            log_message "GPS: no valid fix found in $(basename "$gps_src")" "INFO"
1327
        fi
1328
    fi
1329

            
Bogdan Timofte authored 9 months ago
1330
    # Create destination directory
1331
    if ! mkdir -p "$dest_dir"; then
1332
        log_message "Could not create directory: $dest_dir" "ERROR"
1333
        ERROR_FILES=$((ERROR_FILES + 1))
1334
        return 1
1335
    fi
Bogdan Timofte authored a week ago
1336

            
Bogdan Timofte authored 2 weeks ago
1337
    local sync_metadata_after_copy=0
1338
    local verification_date="$date_str"
1339
    if should_sync_imported_metadata "$original_basename" "$date_source"; then
1340
        sync_metadata_after_copy=1
1341
        verification_date=""
1342
    fi
1343

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

            
1436
# Function to display final report
1437
show_report() {
1438
    local end_time=$(date +%s)
1439
    local elapsed_time=$((end_time - START_TIME))
1440
    local hours=$((elapsed_time / 3600))
1441
    local minutes=$(((elapsed_time % 3600) / 60))
1442
    local seconds=$((elapsed_time % 60))
1443

            
1444
    echo ""
1445
    print_color "$GREEN" "=========================================="
1446
    print_color "$GREEN" "           PROCESSING REPORT"
1447
    print_color "$GREEN" "=========================================="
1448
    echo ""
Bogdan Timofte authored 2 weeks ago
1449

            
1450
    echo "Files Summary:"
1451
    report_line "Total files found:" "$TOTAL_FILES"
1452
    report_line "Successfully processed:" "$PROCESSED_FILES"
1453
    report_line "Skipped:" "$SKIPPED_FILES"
1454
    report_line "Errors:" "$ERROR_FILES"
1455
    echo ""
1456

            
Bogdan Timofte authored 9 months ago
1457
    echo "Size Summary:"
Bogdan Timofte authored 2 weeks ago
1458
    report_line "Total size found:" "$(format_size $TOTAL_SIZE)"
1459
    report_line "Successfully processed:" "$(format_size $PROCESSED_SIZE)"
Bogdan Timofte authored 9 months ago
1460
    echo ""
Bogdan Timofte authored 2 weeks ago
1461

            
1462
    echo "Time Summary:"
1463
    report_line "Time elapsed:" "$(printf "%02d:%02d:%02d" $hours $minutes $seconds)"
1464
    if [[ $elapsed_time -gt 0 && $PROCESSED_SIZE -gt 0 ]]; then
1465
        local data_rate
1466
        data_rate=$(format_data_rate "$PROCESSED_SIZE" "$elapsed_time")
1467
        if [[ -n "$data_rate" ]]; then
1468
            report_line "Data rate:" "$data_rate"
1469
        fi
1470
    fi
1471

            
1472
    echo ""
1473

            
Bogdan Timofte authored 9 months ago
1474
    if [[ $DRY_RUN -eq 1 ]]; then
1475
        print_color "$YELLOW" "DRY RUN MODE - No files were actually moved/copied"
1476
    elif [[ $KEEP_ORIGINALS -eq 1 ]]; then
1477
        print_color "$BLUE" "COPY MODE - Original files were preserved"
1478
    else
1479
        print_color "$GREEN" "MOVE MODE - Files were moved to destination"
1480
    fi
1481

            
1482
    echo ""
1483
    print_color "$GREEN" "=========================================="
1484
}
1485

            
Bogdan Timofte authored 2 weeks ago
1486
if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then
1487
    return 0
1488
fi
1489

            
Bogdan Timofte authored 9 months ago
1490
# Parse command line arguments
1491
while [[ $# -gt 0 ]]; do
1492
    case $1 in
1493
        -o|--organization)
1494
            ORGANIZATION="$2"
Bogdan Timofte authored 8 months ago
1495
            # Accept new patterns: ym, ymd as well as single-letter ones
1496
            if [[ ! "$ORGANIZATION" =~ ^(y|m|d|h|ym|ymd)$ ]]; then
Bogdan Timofte authored 9 months ago
1497
                print_color "$RED" "Error: Invalid organization pattern. Must be one of: y, m, d, h, ym, ymd"
Bogdan Timofte authored 9 months ago
1498
                exit 1
1499
            fi
1500
            shift 2
1501
            ;;
Bogdan Timofte authored 9 months ago
1502

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

            
Bogdan Timofte authored 2 weeks ago
1592
# Non-interactive execution cannot safely ask conflict questions.
1593
if [[ $UNATTENDED -eq 0 && ( ! -t 0 || ! -t 1 ) ]]; then
1594
    UNATTENDED=1
1595
fi
1596

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

            
Bogdan Timofte authored 3 weeks ago
1599
# If no source specified, default to current directory. Refuse to run when cwd is unsafe.
1600
if [[ ${#SOURCE_PATTERNS[@]} -eq 0 ]]; then
1601
    cwd=$(pwd)
1602
    # Resolve home and root paths
1603
    home_dir="$HOME"
1604
    case "$cwd" in
1605
        "$home_dir"|"/"|"/etc"|"/var"|"/bin"|"/sbin"|"/dev"|"/proc"|"/sys"|"/run"|"/usr"|"/lib"|"/lib64"|"/boot"|"/snap")
1606
            print_color "$RED" "Refusing to run with default source in unsafe directory: $cwd"
1607
            print_color "$RED" "Please specify $cwd as source explicitly with -s or run from a safer directory."
1608
            exit 1
1609
            ;;
1610
        *)
1611
            SOURCE_PATTERNS+=("$cwd")
1612
            ;;
1613
    esac
1614
fi
1615

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

            
1624
    if [[ ${#SOURCE_PATTERNS[@]} -gt 0 ]]; then
1625
        first_source="${SOURCE_PATTERNS[0]}"
Bogdan Timofte authored 3 weeks ago
1626
        if [[ -d "$first_source" ]]; then
1627
            DESTINATION="$first_source/sorted"
1628
        elif [[ -f "$first_source" ]]; then
1629
            DESTINATION="$(dirname "$first_source")/sorted"
Bogdan Timofte authored 9 months ago
1630
        else
1631
            print_color "$YELLOW" "Warning: first source does not exist; defaulting destination to ./sorted"
1632
            DESTINATION="./sorted"
1633
        fi
1634
    else
1635
        DESTINATION="./sorted"
1636
    fi
Bogdan Timofte authored 9 months ago
1637
fi
1638

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

            
1642
# Display configuration
1643
print_color "$GREEN" "$SCRIPT_NAME v$VERSION"
1644
echo ""
1645
echo "Configuration:"
1646
echo "  Organization pattern: $ORGANIZATION"
1647
echo "  Destination:         $DESTINATION"
1648
echo "  Keep originals:      $([ $KEEP_ORIGINALS -eq 1 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored 3 weeks ago
1649
echo "  Verify mode:         $VERIFY_MODE"
Bogdan Timofte authored 2 weeks ago
1650
echo "  Date source:         $DATE_SOURCE"
1651
echo "  Sync metadata:       $([ $SYNC_METADATA -eq 1 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored a week ago
1652
echo "  QuickTime UTC:       $([ $QUICKTIME_UTC -eq 1 ] && echo "Yes" || echo "No (local time assumed)")"
Bogdan Timofte authored a week ago
1653
echo "  Sync GPS:            $([ $SYNC_GPS -eq 1 ] && echo "Yes" || echo "No (--no-sync-gps)")"
Bogdan Timofte authored 2 weeks ago
1654
echo "  Unattended:          $([ $UNATTENDED -eq 1 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored 9 months ago
1655
echo "  Dry run:             $([ $DRY_RUN -eq 1 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored 8 months ago
1656
echo "  Keep empty dirs:     $([ $CLEANUP_EMPTY_DIRS -eq 0 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored 9 months ago
1657
echo "  Verbose:             $([ $VERBOSE -eq 1 ] && echo "Yes" || echo "No")"
1658

            
1659
if [[ ${#SOURCE_PATTERNS[@]} -gt 0 ]]; then
1660
    echo "  Source patterns:"
1661
    for pattern in "${SOURCE_PATTERNS[@]}"; do
1662
        echo "    - $pattern"
1663
    done
1664
else
1665
    echo "  Source patterns:     All media files in current directory"
1666
fi
1667

            
1668
echo ""
1669

            
1670
# Check dependencies
1671
check_dependencies
1672

            
1673
# Create destination directory if it doesn't exist (unless dry run)
1674
if [[ $DRY_RUN -eq 0 ]]; then
1675
    if ! mkdir -p "$DESTINATION"; then
1676
        print_color "$RED" "Error: Cannot create destination directory: $DESTINATION"
1677
        exit 1
1678
    fi
1679
fi
1680

            
1681
# Find all source files
1682

            
1683
print_color "$BLUE" "Scanning for media files..."
1684
files=()
1685
while IFS= read -r file; do
1686
    files+=("$file")
1687
done < <(find_source_files)
Bogdan Timofte authored 2 weeks ago
1688
if [[ ${#files[@]} -gt 0 ]]; then
1689
    IFS=$'\n' files=($(printf '%s\n' "${files[@]}" | sort))
1690
    unset IFS
1691
fi
Bogdan Timofte authored 9 months ago
1692
TOTAL_FILES=${#files[@]}
1693

            
1694
if [[ $TOTAL_FILES -eq 0 ]]; then
1695
    print_color "$YELLOW" "No media files found matching the specified patterns."
1696
    exit 0
1697
fi
1698

            
1699
print_color "$BLUE" "Found $TOTAL_FILES media files to process"
1700
echo ""
1701

            
1702
# Process each file
1703

            
1704
FATAL_ERROR=0
1705
for file in "${files[@]}"; do
1706
    if [[ -f "$file" ]]; then
Bogdan Timofte authored 2 weeks ago
1707
        CURRENT_FILE_INDEX=$((CURRENT_FILE_INDEX + 1))
Bogdan Timofte authored 9 months ago
1708
        process_file "$file"
1709
        if [[ $FATAL_ERROR -eq 1 ]]; then
1710
            print_color "$RED" "Fatal error encountered. Stopping further processing."
1711
            break
1712
        fi
1713
    fi
1714
done
1715

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

            
Bogdan Timofte authored 9 months ago
1724
# Show final report
1725
show_report
1726

            
1727
# Exit with appropriate code
1728
if [[ $ERROR_FILES -gt 0 ]]; then
1729
    exit 1
1730
else
1731
    exit 0
1732
fi