MediaImporter / media-importer.sh
Newer Older
1623 lines | 56.139kb
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 2 weeks ago
28
CONFLICT_APPLY_ALL=""  # suffix|skip after an interactive "all similar" choice
29
RESOLVED_DESTINATION_PATH=""
30
RESERVED_DESTINATION_PATHS=()
Bogdan Timofte authored 9 months ago
31

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

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

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

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

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

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

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

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

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

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

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

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

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

            
140
# Function to check dependencies
141
check_dependencies() {
142
    local missing_deps=()
143

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

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

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

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

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

            
177
    log_message "All required dependencies found" "SUCCESS"
178
}
179

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

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

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

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

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

            
238
ensure_unique_destination_path() {
239
    local desired_path="$1"
240

            
241
    if [[ -z "$desired_path" ]]; then
242
        return 1
243
    fi
244

            
245
    if ! destination_path_unavailable "$desired_path"; then
246
        echo "$desired_path"
247
        return 0
248
    fi
249

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

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

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

            
277
    return 1
278
}
279

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

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

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

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

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

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

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

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

            
362
    if [[ -z "$desired_path" ]]; then
363
        return 1
364
    fi
365

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

            
372
    if [[ "$CONFLICT_APPLY_ALL" == "skip" ]]; then
373
        return 3
374
    fi
375

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

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

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

            
421
    if [[ -z "$resolved_path" ]]; then
422
        return 1
423
    fi
424

            
425
    RESOLVED_DESTINATION_PATH="$resolved_path"
426
    reserve_destination_path "$RESOLVED_DESTINATION_PATH"
427
    return 0
428
}
429

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

            
439
    local epoch=""
440

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

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

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

            
472
    echo "$file"
473
}
474

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

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

            
487
get_device_sidecar_extensions() {
488
    local file="$1"
489
    local filename
490
    filename=$(basename "$file")
491

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

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

            
502
    is_gopro_media_file "$file"
503
}
504

            
505
filesystem_date_source_label() {
506
    local file="$1"
507
    local reference="$2"
508

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

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

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

            
532
    local exif_dt
533
    exif_dt=$(date_to_exiftool_format "$date_str") || return 1
534

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

            
548
verify_synced_metadata_date() {
549
    local file="$1"
550
    local expected_date="$2"
551

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

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

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

            
567
    return 0
568
}
569

            
570
should_sync_imported_metadata() {
571
    local original_filename="$1"
572
    local date_source="$2"
573

            
574
    if [[ $SYNC_METADATA -eq 1 ]]; then
575
        return 0
576
    fi
577

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

            
582
    return 1
Bogdan Timofte authored 9 months ago
583
}
584

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

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

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

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

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

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

            
628
    return 0
629
}
630

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

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

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

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

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

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

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

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

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

            
677
    return 0
678
}
679

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

            
685
    if ! copy_with_verification "$src" "$dst" "$expected_date"; then
686
        return 1
687
    fi
688

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

            
694
    return 0
695
}
696

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
915
    echo "$create_date|$date_source"
916
    return 0
917
}
918

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

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

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

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

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored 2 weeks ago
1091
cleanup_media_sidecars() {
Bogdan Timofte authored 2 weeks ago
1092
    local file="$1"
1093
    local dir base stem ext sidecar_ext sidecar
1094
    dir=$(dirname "$file")
1095
    base=$(basename "$file")
1096
    stem="${base%.*}"
1097
    ext="${base##*.}"
1098

            
1099
    if [[ ! "$ext" =~ ^([Mm][Pp]4)$ ]]; then
1100
        return 0
1101
    fi
1102

            
Bogdan Timofte authored 2 weeks ago
1103
    local sidecar_exts
1104
    sidecar_exts=$(get_device_sidecar_extensions "$file")
1105
    [[ -z "$sidecar_exts" ]] && return 0
1106

            
1107
    for sidecar_ext in $sidecar_exts; do
Bogdan Timofte authored 2 weeks ago
1108
        sidecar="$dir/$stem.$sidecar_ext"
1109
        if [[ -f "$sidecar" ]]; then
1110
            if rm -f "$sidecar"; then
Bogdan Timofte authored 2 weeks ago
1111
                log_message "Deleted sidecar: $sidecar" "INFO"
Bogdan Timofte authored 2 weeks ago
1112
            else
Bogdan Timofte authored 2 weeks ago
1113
                log_message "Failed to delete sidecar: $sidecar" "WARNING"
Bogdan Timofte authored 2 weeks ago
1114
            fi
1115
        fi
1116
    done
1117
}
1118

            
Bogdan Timofte authored 9 months ago
1119
# Function to process a single file
1120
process_file() {
1121
    local file="$1"
1122
    local file_size=$(get_file_size "$file")
Bogdan Timofte authored 2 weeks ago
1123
    local file_label
1124
    file_label="$(basename "$file")"
Bogdan Timofte authored 9 months ago
1125
    TOTAL_SIZE=$((TOTAL_SIZE + file_size))
1126

            
Bogdan Timofte authored 2 weeks ago
1127
    if [[ $TOTAL_FILES -gt 0 && $CURRENT_FILE_INDEX -gt 0 ]]; then
1128
        print_color "$BLUE" "Processing [$CURRENT_FILE_INDEX/$TOTAL_FILES]: $file_label ($(format_size "$file_size"))"
1129
    else
1130
        print_color "$BLUE" "Processing: $file_label ($(format_size "$file_size"))"
1131
    fi
Bogdan Timofte authored 9 months ago
1132
    log_message "Processing: $file" "INFO"
1133

            
1134
    # Extract date information
1135
    local date_info=$(extract_file_date "$file")
1136
        local extract_status=$?
1137
        if [[ $extract_status -eq 2 ]]; then
1138
            if [[ $COLLECT_UNSORTABLE -eq 1 ]]; then
1139
                local unsortable_dir="$DESTINATION/unsortable"
1140
                mkdir -p "$unsortable_dir"
1141
                local unsortable_path="$unsortable_dir/$(basename "$file")"
Bogdan Timofte authored 2 weeks ago
1142
                local desired_unsortable_path="$unsortable_path"
1143
                local unsortable_conflict_status
1144
                resolve_destination_conflict "$unsortable_path" "$file"
1145
                unsortable_conflict_status=$?
1146
                if [[ $unsortable_conflict_status -eq 0 ]]; then
1147
                    unsortable_path="$RESOLVED_DESTINATION_PATH"
1148
                    if [[ "$unsortable_path" != "$desired_unsortable_path" ]]; then
1149
                        log_message "Destination already exists or is already planned: $desired_unsortable_path - using: $unsortable_path" "WARNING"
1150
                    fi
1151
                elif [[ $unsortable_conflict_status -eq 3 ]]; then
1152
                    log_message "Destination conflict skipped: $desired_unsortable_path" "WARNING"
1153
                    SKIPPED_FILES=$((SKIPPED_FILES + 1))
1154
                    return 1
1155
                elif [[ $unsortable_conflict_status -eq 4 ]]; then
1156
                    log_message "Import aborted by user at destination conflict: $desired_unsortable_path" "ERROR"
1157
                    ERROR_FILES=$((ERROR_FILES + 1))
1158
                    FATAL_ERROR=1
1159
                    return 2
1160
                else
1161
                    log_message "Could not resolve a unique destination path for $file (wanted: $desired_unsortable_path)" "ERROR"
1162
                    ERROR_FILES=$((ERROR_FILES + 1))
1163
                    return 1
1164
                fi
Bogdan Timofte authored 9 months ago
1165
                if [[ $DRY_RUN -eq 1 ]]; then
1166
                    print_color "$BLUE" "Would move unsortable: $file -> $unsortable_path"
1167
                else
Bogdan Timofte authored 3 weeks ago
1168
                    if verified_move_file "$file" "$unsortable_path" ""; then
Bogdan Timofte authored 9 months ago
1169
                        log_message "Unsortable: $file -> $unsortable_path" "SUCCESS"
1170
                    else
Bogdan Timofte authored 3 weeks ago
1171
                        log_message "Failed to move unsortable file after verification: $file" "ERROR"
Bogdan Timofte authored 9 months ago
1172
                    fi
1173
                fi
1174
                SKIPPED_FILES=$((SKIPPED_FILES + 1))
1175
            else
1176
                log_message "Could not extract date from $file - skipping" "WARNING"
1177
                SKIPPED_FILES=$((SKIPPED_FILES + 1))
1178
            fi
1179
            return 1
1180
        elif [[ $extract_status -ne 0 ]] || [[ -z "$date_info" ]]; then
1181
            log_message "Could not extract date from $file - skipping" "WARNING"
1182
            SKIPPED_FILES=$((SKIPPED_FILES + 1))
1183
            return 1
1184
        fi
1185
        local date_str="${date_info%|*}"
1186
        local date_source="${date_info#*|}"
1187
        log_message "Date: $date_str (from $date_source)" "INFO"
1188
        # Generate destination path
Bogdan Timofte authored 2 weeks ago
1189
        local original_basename
1190
        original_basename="$(basename "$file")"
1191
        local dest_path
1192
        dest_path=$(generate_destination_path "$date_str" "$original_basename" "$DESTINATION")
Bogdan Timofte authored 9 months ago
1193
        if [[ $? -ne 0 ]] || [[ -z "$dest_path" ]]; then
1194
            log_message "Could not generate destination path for $file" "ERROR"
1195
            ERROR_FILES=$((ERROR_FILES + 1))
1196
            FATAL_ERROR=1
1197
            return 2
1198
    fi
Bogdan Timofte authored 2 weeks ago
1199

            
1200
    local desired_dest_path="$dest_path"
1201
    local conflict_status
1202
    resolve_destination_conflict "$dest_path" "$file"
1203
    conflict_status=$?
1204
    if [[ $conflict_status -eq 0 ]]; then
1205
        dest_path="$RESOLVED_DESTINATION_PATH"
1206
    elif [[ $conflict_status -eq 3 ]]; then
1207
        log_message "Destination conflict skipped: $desired_dest_path" "WARNING"
1208
        SKIPPED_FILES=$((SKIPPED_FILES + 1))
1209
        return 1
1210
    elif [[ $conflict_status -eq 4 ]]; then
1211
        log_message "Import aborted by user at destination conflict: $desired_dest_path" "ERROR"
1212
        ERROR_FILES=$((ERROR_FILES + 1))
1213
        FATAL_ERROR=1
1214
        return 2
1215
    else
1216
        log_message "Could not resolve a unique destination path for $file (wanted: $desired_dest_path)" "ERROR"
1217
        ERROR_FILES=$((ERROR_FILES + 1))
1218
        return 1
Bogdan Timofte authored 9 months ago
1219
    fi
Bogdan Timofte authored 2 weeks ago
1220
    if [[ "$dest_path" != "$desired_dest_path" ]]; then
1221
        log_message "Destination already exists or is already planned: $desired_dest_path - using: $dest_path" "WARNING"
1222
    fi
1223

            
1224
    local dest_dir
1225
    dest_dir=$(dirname "$dest_path")
Bogdan Timofte authored 9 months ago
1226

            
1227
    if [[ $DRY_RUN -eq 1 ]]; then
1228
        if [[ $KEEP_ORIGINALS -eq 1 ]]; then
1229
            print_color "$BLUE" "Would copy: $file -> $dest_path"
1230
        else
1231
            print_color "$BLUE" "Would move: $file -> $dest_path"
1232
        fi
Bogdan Timofte authored 2 weeks ago
1233
        if should_sync_imported_metadata "$original_basename" "$date_source"; then
1234
            print_color "$BLUE" "Would sync metadata date: $dest_path -> $date_str"
1235
        fi
Bogdan Timofte authored 9 months ago
1236
        PROCESSED_FILES=$((PROCESSED_FILES + 1))
1237
        PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
1238
        return 0
1239
    fi
1240

            
1241
    # Create destination directory
1242
    if ! mkdir -p "$dest_dir"; then
1243
        log_message "Could not create directory: $dest_dir" "ERROR"
1244
        ERROR_FILES=$((ERROR_FILES + 1))
1245
        return 1
1246
    fi
1247

            
Bogdan Timofte authored 2 weeks ago
1248
    local sync_metadata_after_copy=0
1249
    local verification_date="$date_str"
1250
    if should_sync_imported_metadata "$original_basename" "$date_source"; then
1251
        sync_metadata_after_copy=1
1252
        verification_date=""
1253
    fi
1254

            
1255
    # Copy or move file using safe helpers after destination conflicts are resolved.
Bogdan Timofte authored 9 months ago
1256
    if [[ $KEEP_ORIGINALS -eq 1 ]]; then
Bogdan Timofte authored 2 weeks ago
1257
        if copy_with_verification "$file" "$dest_path" "$verification_date"; then
1258
            if [[ $sync_metadata_after_copy -eq 1 ]]; then
1259
                if ! sync_destination_metadata_to_date "$dest_path" "$date_str"; then
1260
                    log_message "Failed to sync destination metadata timestamps: $dest_path" "ERROR"
1261
                    ERROR_FILES=$((ERROR_FILES + 1))
1262
                    return 1
1263
                elif ! verify_synced_metadata_date "$dest_path" "$date_str"; then
1264
                    ERROR_FILES=$((ERROR_FILES + 1))
1265
                    return 1
1266
                fi
1267
            fi
Bogdan Timofte authored 9 months ago
1268
            log_message "Copied: $file -> $dest_path" "SUCCESS"
Bogdan Timofte authored 2 weeks ago
1269
            if [[ $CLEANUP_MEDIA_SIDECARS -eq 1 ]]; then
1270
                cleanup_media_sidecars "$file"
Bogdan Timofte authored 2 weeks ago
1271
            fi
Bogdan Timofte authored 9 months ago
1272
            PROCESSED_FILES=$((PROCESSED_FILES + 1))
1273
            PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
1274
            return 0
1275
        else
Bogdan Timofte authored 3 weeks ago
1276
            log_message "Failed to copy or verify destination: $file -> $dest_path" "ERROR"
Bogdan Timofte authored 9 months ago
1277
            ERROR_FILES=$((ERROR_FILES + 1))
1278
            return 1
1279
        fi
1280
    else
Bogdan Timofte authored 2 weeks ago
1281
        if [[ $sync_metadata_after_copy -eq 1 ]]; then
1282
            if copy_with_verification "$file" "$dest_path" "$verification_date"; then
1283
                if ! sync_destination_metadata_to_date "$dest_path" "$date_str"; then
1284
                    log_message "Failed to sync destination metadata timestamps: $dest_path" "ERROR"
1285
                    ERROR_FILES=$((ERROR_FILES + 1))
1286
                    return 1
1287
                fi
1288
                if ! verify_synced_metadata_date "$dest_path" "$date_str"; then
1289
                    ERROR_FILES=$((ERROR_FILES + 1))
1290
                    return 1
1291
                fi
1292
                if ! remove_source_file "$file"; then
1293
                    log_message "Copied, verified, and synced destination, but failed to remove source: $file" "ERROR"
1294
                    ERROR_FILES=$((ERROR_FILES + 1))
1295
                    return 1
1296
                fi
1297
                log_message "Moved: $file -> $dest_path" "SUCCESS"
Bogdan Timofte authored 2 weeks ago
1298
            if [[ $CLEANUP_MEDIA_SIDECARS -eq 1 ]]; then
1299
                cleanup_media_sidecars "$file"
Bogdan Timofte authored 2 weeks ago
1300
            fi
Bogdan Timofte authored 2 weeks ago
1301
                PROCESSED_FILES=$((PROCESSED_FILES + 1))
1302
                PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
1303
                return 0
1304
            else
1305
                log_message "Failed to move after copy verification: $file -> $dest_path" "ERROR"
1306
                ERROR_FILES=$((ERROR_FILES + 1))
1307
                return 1
1308
            fi
1309
        elif verified_move_file "$file" "$dest_path" "$date_str"; then
Bogdan Timofte authored 9 months ago
1310
            log_message "Moved: $file -> $dest_path" "SUCCESS"
Bogdan Timofte authored 2 weeks ago
1311
            if [[ $CLEANUP_MEDIA_SIDECARS -eq 1 ]]; then
1312
                cleanup_media_sidecars "$file"
Bogdan Timofte authored 2 weeks ago
1313
            fi
Bogdan Timofte authored 2 weeks ago
1314
            if [[ $sync_metadata_after_copy -eq 1 ]]; then
1315
                if ! sync_destination_metadata_to_date "$dest_path" "$date_str"; then
1316
                    log_message "Failed to sync destination metadata timestamps: $dest_path" "WARNING"
1317
                elif ! verify_synced_metadata_date "$dest_path" "$date_str"; then
1318
                    log_message "Failed to verify synced destination metadata timestamps: $dest_path" "WARNING"
1319
                fi
1320
            fi
Bogdan Timofte authored 9 months ago
1321
            PROCESSED_FILES=$((PROCESSED_FILES + 1))
1322
            PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
1323
            return 0
1324
        else
Bogdan Timofte authored 3 weeks ago
1325
            log_message "Failed to move after copy verification: $file -> $dest_path" "ERROR"
Bogdan Timofte authored 9 months ago
1326
            ERROR_FILES=$((ERROR_FILES + 1))
1327
            return 1
1328
        fi
1329
    fi
1330
}
1331

            
1332
# Function to display final report
1333
show_report() {
1334
    local end_time=$(date +%s)
1335
    local elapsed_time=$((end_time - START_TIME))
1336
    local hours=$((elapsed_time / 3600))
1337
    local minutes=$(((elapsed_time % 3600) / 60))
1338
    local seconds=$((elapsed_time % 60))
1339

            
1340
    echo ""
1341
    print_color "$GREEN" "=========================================="
1342
    print_color "$GREEN" "           PROCESSING REPORT"
1343
    print_color "$GREEN" "=========================================="
1344
    echo ""
Bogdan Timofte authored 2 weeks ago
1345

            
1346
    echo "Files Summary:"
1347
    report_line "Total files found:" "$TOTAL_FILES"
1348
    report_line "Successfully processed:" "$PROCESSED_FILES"
1349
    report_line "Skipped:" "$SKIPPED_FILES"
1350
    report_line "Errors:" "$ERROR_FILES"
1351
    echo ""
1352

            
Bogdan Timofte authored 9 months ago
1353
    echo "Size Summary:"
Bogdan Timofte authored 2 weeks ago
1354
    report_line "Total size found:" "$(format_size $TOTAL_SIZE)"
1355
    report_line "Successfully processed:" "$(format_size $PROCESSED_SIZE)"
Bogdan Timofte authored 9 months ago
1356
    echo ""
Bogdan Timofte authored 2 weeks ago
1357

            
1358
    echo "Time Summary:"
1359
    report_line "Time elapsed:" "$(printf "%02d:%02d:%02d" $hours $minutes $seconds)"
1360
    if [[ $elapsed_time -gt 0 && $PROCESSED_SIZE -gt 0 ]]; then
1361
        local data_rate
1362
        data_rate=$(format_data_rate "$PROCESSED_SIZE" "$elapsed_time")
1363
        if [[ -n "$data_rate" ]]; then
1364
            report_line "Data rate:" "$data_rate"
1365
        fi
1366
    fi
1367

            
1368
    echo ""
1369

            
Bogdan Timofte authored 9 months ago
1370
    if [[ $DRY_RUN -eq 1 ]]; then
1371
        print_color "$YELLOW" "DRY RUN MODE - No files were actually moved/copied"
1372
    elif [[ $KEEP_ORIGINALS -eq 1 ]]; then
1373
        print_color "$BLUE" "COPY MODE - Original files were preserved"
1374
    else
1375
        print_color "$GREEN" "MOVE MODE - Files were moved to destination"
1376
    fi
1377

            
1378
    echo ""
1379
    print_color "$GREEN" "=========================================="
1380
}
1381

            
Bogdan Timofte authored 2 weeks ago
1382
if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then
1383
    return 0
1384
fi
1385

            
Bogdan Timofte authored 9 months ago
1386
# Parse command line arguments
1387
while [[ $# -gt 0 ]]; do
1388
    case $1 in
1389
        -o|--organization)
1390
            ORGANIZATION="$2"
Bogdan Timofte authored 8 months ago
1391
            # Accept new patterns: ym, ymd as well as single-letter ones
1392
            if [[ ! "$ORGANIZATION" =~ ^(y|m|d|h|ym|ymd)$ ]]; then
Bogdan Timofte authored 9 months ago
1393
                print_color "$RED" "Error: Invalid organization pattern. Must be one of: y, m, d, h, ym, ymd"
Bogdan Timofte authored 9 months ago
1394
                exit 1
1395
            fi
1396
            shift 2
1397
            ;;
Bogdan Timofte authored 9 months ago
1398

            
1399
        -F|--filename-mode)
1400
            FILENAME_MODE="$2"
1401
            if [[ ! "$FILENAME_MODE" =~ ^(auto|full|orig)$ ]]; then
1402
                print_color "$RED" "Error: Invalid filename mode. Must be one of: auto, full, orig"
1403
                exit 1
1404
            fi
1405
            shift 2
Bogdan Timofte authored 9 months ago
1406
            ;;
Bogdan Timofte authored 9 months ago
1407
    # (conflict resolution option removed; let exiftool/filesystem handle naming conflicts)
Bogdan Timofte authored 9 months ago
1408
        --collect-unsortable)
1409
            COLLECT_UNSORTABLE=1
1410
            shift
1411
            ;;
Bogdan Timofte authored 8 months ago
1412
        --keep-empty-dirs)
1413
            CLEANUP_EMPTY_DIRS=0
1414
            shift
1415
            ;;
Bogdan Timofte authored 9 months ago
1416
        -s|--source)
1417
            SOURCE_PATTERNS+=("$2")
1418
            shift 2
1419
            ;;
1420
        -d|--destination)
1421
            DESTINATION="$2"
1422
            shift 2
1423
            ;;
1424
        -k|--keep-originals)
1425
            KEEP_ORIGINALS=1
1426
            shift
1427
            ;;
Bogdan Timofte authored 3 weeks ago
1428
        --verify-mode)
1429
            VERIFY_MODE="$2"
1430
            if [[ ! "$VERIFY_MODE" =~ ^(size|strict|none)$ ]]; then
1431
                print_color "$RED" "Error: Invalid verify mode. Must be one of: size, strict, none"
1432
                exit 1
1433
            fi
1434
            shift 2
1435
            ;;
Bogdan Timofte authored 2 weeks ago
1436
        --date-source)
1437
            DATE_SOURCE="$2"
1438
            if [[ ! "$DATE_SOURCE" =~ ^(auto|exif|filesystem)$ ]]; then
1439
                print_color "$RED" "Error: Invalid date source. Must be one of: auto, exif, filesystem"
1440
                exit 1
1441
            fi
1442
            shift 2
1443
            ;;
1444
        --sync-metadata)
1445
            SYNC_METADATA=1
1446
            shift
1447
            ;;
Bogdan Timofte authored a week ago
1448
        --no-quicktime-utc)
1449
            QUICKTIME_UTC=0
1450
            shift
1451
            ;;
Bogdan Timofte authored 2 weeks ago
1452
        --unattended)
1453
            UNATTENDED=1
1454
            shift
1455
            ;;
Bogdan Timofte authored 2 weeks ago
1456
        --keep-sidecars)
1457
            CLEANUP_MEDIA_SIDECARS=0
Bogdan Timofte authored 2 weeks ago
1458
            shift
1459
            ;;
Bogdan Timofte authored 9 months ago
1460
        --dry-run)
1461
            DRY_RUN=1
1462
            shift
1463
            ;;
1464
        -v|--verbose)
1465
            VERBOSE=1
1466
            shift
1467
            ;;
1468
        -h|--help)
1469
            show_help
1470
            exit 0
1471
            ;;
1472
        --version)
1473
            show_version
1474
            exit 0
1475
            ;;
1476
        *)
1477
            print_color "$RED" "Error: Unknown option: $1"
1478
            echo "Use -h or --help for usage information."
1479
            exit 1
1480
            ;;
1481
    esac
1482
done
1483

            
Bogdan Timofte authored 2 weeks ago
1484
# Non-interactive execution cannot safely ask conflict questions.
1485
if [[ $UNATTENDED -eq 0 && ( ! -t 0 || ! -t 1 ) ]]; then
1486
    UNATTENDED=1
1487
fi
1488

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

            
Bogdan Timofte authored 3 weeks ago
1491
# If no source specified, default to current directory. Refuse to run when cwd is unsafe.
1492
if [[ ${#SOURCE_PATTERNS[@]} -eq 0 ]]; then
1493
    cwd=$(pwd)
1494
    # Resolve home and root paths
1495
    home_dir="$HOME"
1496
    case "$cwd" in
1497
        "$home_dir"|"/"|"/etc"|"/var"|"/bin"|"/sbin"|"/dev"|"/proc"|"/sys"|"/run"|"/usr"|"/lib"|"/lib64"|"/boot"|"/snap")
1498
            print_color "$RED" "Refusing to run with default source in unsafe directory: $cwd"
1499
            print_color "$RED" "Please specify $cwd as source explicitly with -s or run from a safer directory."
1500
            exit 1
1501
            ;;
1502
        *)
1503
            SOURCE_PATTERNS+=("$cwd")
1504
            ;;
1505
    esac
1506
fi
1507

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

            
1516
    if [[ ${#SOURCE_PATTERNS[@]} -gt 0 ]]; then
1517
        first_source="${SOURCE_PATTERNS[0]}"
Bogdan Timofte authored 3 weeks ago
1518
        if [[ -d "$first_source" ]]; then
1519
            DESTINATION="$first_source/sorted"
1520
        elif [[ -f "$first_source" ]]; then
1521
            DESTINATION="$(dirname "$first_source")/sorted"
Bogdan Timofte authored 9 months ago
1522
        else
1523
            print_color "$YELLOW" "Warning: first source does not exist; defaulting destination to ./sorted"
1524
            DESTINATION="./sorted"
1525
        fi
1526
    else
1527
        DESTINATION="./sorted"
1528
    fi
Bogdan Timofte authored 9 months ago
1529
fi
1530

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

            
1534
# Display configuration
1535
print_color "$GREEN" "$SCRIPT_NAME v$VERSION"
1536
echo ""
1537
echo "Configuration:"
1538
echo "  Organization pattern: $ORGANIZATION"
1539
echo "  Destination:         $DESTINATION"
1540
echo "  Keep originals:      $([ $KEEP_ORIGINALS -eq 1 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored 3 weeks ago
1541
echo "  Verify mode:         $VERIFY_MODE"
Bogdan Timofte authored 2 weeks ago
1542
echo "  Date source:         $DATE_SOURCE"
1543
echo "  Sync metadata:       $([ $SYNC_METADATA -eq 1 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored a week ago
1544
echo "  QuickTime UTC:       $([ $QUICKTIME_UTC -eq 1 ] && echo "Yes" || echo "No (local time assumed)")"
Bogdan Timofte authored 2 weeks ago
1545
echo "  Unattended:          $([ $UNATTENDED -eq 1 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored 9 months ago
1546
echo "  Dry run:             $([ $DRY_RUN -eq 1 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored 8 months ago
1547
echo "  Keep empty dirs:     $([ $CLEANUP_EMPTY_DIRS -eq 0 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored 9 months ago
1548
echo "  Verbose:             $([ $VERBOSE -eq 1 ] && echo "Yes" || echo "No")"
1549

            
1550
if [[ ${#SOURCE_PATTERNS[@]} -gt 0 ]]; then
1551
    echo "  Source patterns:"
1552
    for pattern in "${SOURCE_PATTERNS[@]}"; do
1553
        echo "    - $pattern"
1554
    done
1555
else
1556
    echo "  Source patterns:     All media files in current directory"
1557
fi
1558

            
1559
echo ""
1560

            
1561
# Check dependencies
1562
check_dependencies
1563

            
1564
# Create destination directory if it doesn't exist (unless dry run)
1565
if [[ $DRY_RUN -eq 0 ]]; then
1566
    if ! mkdir -p "$DESTINATION"; then
1567
        print_color "$RED" "Error: Cannot create destination directory: $DESTINATION"
1568
        exit 1
1569
    fi
1570
fi
1571

            
1572
# Find all source files
1573

            
1574
print_color "$BLUE" "Scanning for media files..."
1575
files=()
1576
while IFS= read -r file; do
1577
    files+=("$file")
1578
done < <(find_source_files)
Bogdan Timofte authored 2 weeks ago
1579
if [[ ${#files[@]} -gt 0 ]]; then
1580
    IFS=$'\n' files=($(printf '%s\n' "${files[@]}" | sort))
1581
    unset IFS
1582
fi
Bogdan Timofte authored 9 months ago
1583
TOTAL_FILES=${#files[@]}
1584

            
1585
if [[ $TOTAL_FILES -eq 0 ]]; then
1586
    print_color "$YELLOW" "No media files found matching the specified patterns."
1587
    exit 0
1588
fi
1589

            
1590
print_color "$BLUE" "Found $TOTAL_FILES media files to process"
1591
echo ""
1592

            
1593
# Process each file
1594

            
1595
FATAL_ERROR=0
1596
for file in "${files[@]}"; do
1597
    if [[ -f "$file" ]]; then
Bogdan Timofte authored 2 weeks ago
1598
        CURRENT_FILE_INDEX=$((CURRENT_FILE_INDEX + 1))
Bogdan Timofte authored 9 months ago
1599
        process_file "$file"
1600
        if [[ $FATAL_ERROR -eq 1 ]]; then
1601
            print_color "$RED" "Fatal error encountered. Stopping further processing."
1602
            break
1603
        fi
1604
    fi
1605
done
1606

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

            
Bogdan Timofte authored 9 months ago
1615
# Show final report
1616
show_report
1617

            
1618
# Exit with appropriate code
1619
if [[ $ERROR_FILES -gt 0 ]]; then
1620
    exit 1
1621
else
1622
    exit 0
1623
fi