MediaImporter / media-importer.sh
Newer Older
952 lines | 32.902kb
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 9 months ago
20
DRY_RUN=0
21
VERBOSE=0
Bogdan Timofte authored 8 months ago
22
CLEANUP_EMPTY_DIRS=1
Bogdan Timofte authored 9 months ago
23

            
24
# Counters and statistics
25
TOTAL_FILES=0
26
PROCESSED_FILES=0
27
SKIPPED_FILES=0
28
ERROR_FILES=0
29
TOTAL_SIZE=0
30
PROCESSED_SIZE=0
31
START_TIME=$(date +%s)
32

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

            
47
# Function to print colored output
48
print_color() {
49
    local color="$1"
50
    local message="$2"
51
    echo -e "${color}${message}${NC}"
52
}
53

            
54
# Function to log messages with timestamp
55
log_message() {
56
    local message="$1"
57
    local level="${2:-INFO}"
58
    local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
59

            
60
    case "$level" in
61
        "ERROR")
62
            print_color "$RED" "[$timestamp] ERROR: $message" >&2
63
            ;;
64
        "WARNING")
65
            print_color "$YELLOW" "[$timestamp] WARNING: $message"
66
            ;;
67
        "SUCCESS")
68
            print_color "$GREEN" "[$timestamp] SUCCESS: $message"
69
            ;;
70
        "INFO")
71
            if [[ $VERBOSE -eq 1 ]]; then
72
                print_color "$BLUE" "[$timestamp] INFO: $message"
73
            fi
74
            ;;
75
        *)
76
            echo "[$timestamp] $message"
77
            ;;
78
    esac
79
}
80

            
81
# Function to display help
82
show_help() {
Bogdan Timofte authored 9 months ago
83
        cat << EOF
84
    $SCRIPT_NAME v$VERSION
Bogdan Timofte authored 9 months ago
85

            
Bogdan Timofte authored 9 months ago
86
Usage:
Bogdan Timofte authored 9 months ago
87
    $0 [OPTIONS]
88

            
Bogdan Timofte authored 9 months ago
89
What it does:
90
    Sorts photos and videos into dated folders (by year/month/day/hour) and
91
    generates filenames from the file's creation timestamp or preserves the
92
    original name.
93

            
94
Options:
95
    -o, --organization PATTERN   y|m|d|h|ym|ymd   (default: ymd)
96
    -F, --filename-mode MODE     auto|full|orig    (default: full)
97
    -s, --source PATH            File or directory to process (repeatable). Default: cwd
98
    -d, --destination PATH       Destination folder. Required when multiple -s are given.
Bogdan Timofte authored 8 months ago
99
    -k, --keep-originals         Copy files instead of moving
Bogdan Timofte authored 3 weeks ago
100
    --verify-mode MODE           size|strict|none (default: size)
Bogdan Timofte authored 9 months ago
101
    --collect-unsortable         Put files without dates into DEST/unsortable
Bogdan Timofte authored 8 months ago
102
    --keep-empty-dirs            Keep empty directories after processing
103
    --dry-run                    Show actions without changing files
Bogdan Timofte authored 9 months ago
104
    -v, --verbose                Verbose output
105
    -h, --help                   Show this help
106
    --version                    Show version
107

            
108
Examples:
109
    $0 -s /path/to/photos
110
    $0 -s /path/to/DCIM -d /mnt/sorted --dry-run
111

            
112
Dependencies:
113
    exiftool (required). mediainfo and file are optional.
Bogdan Timofte authored 9 months ago
114
EOF
115
}
116

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

            
Bogdan Timofte authored 9 months ago
120
# Function to show version
121
show_version() {
122
    echo "$SCRIPT_NAME v$VERSION"
123
    echo "A comprehensive media file organizer with timezone support"
124
}
125

            
126
# Function to check dependencies
127
check_dependencies() {
128
    local missing_deps=()
129

            
130
    # Check for required dependencies
131
    if ! command -v exiftool &> /dev/null; then
132
        missing_deps+=("exiftool")
133
    fi
134

            
135
    # Check for optional dependencies
136
    local optional_missing=()
137
    if ! command -v mediainfo &> /dev/null; then
138
        optional_missing+=("mediainfo")
139
    fi
140

            
141
    if ! command -v file &> /dev/null; then
142
        optional_missing+=("file")
143
    fi
144

            
145
    if [[ ${#missing_deps[@]} -gt 0 ]]; then
146
        print_color "$RED" "ERROR: Missing required dependencies:"
147
        for dep in "${missing_deps[@]}"; do
148
            echo "  - $dep"
149
        done
150
        echo ""
151
        echo "Installation instructions:"
152
        echo "  macOS: brew install exiftool"
153
        echo "  Ubuntu/Debian: sudo apt-get install libimage-exiftool-perl"
154
        echo "  CentOS/RHEL: sudo yum install perl-Image-ExifTool"
155
        echo "  Arch: sudo pacman -S perl-image-exiftool"
156
        exit 1
157
    fi
158

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

            
163
    log_message "All required dependencies found" "SUCCESS"
164
}
165

            
Bogdan Timofte authored 9 months ago
166
# Determine filesystem/device ID for a path (portable between Linux and macOS)
167
get_dev() {
168
    # Return a device identifier for the supplied path (portable between GNU stat and BSD stat)
169
    local path="$1"
170
    if [[ -z "$path" ]]; then
171
        path="."
172
    fi
173

            
174
    # Prefer GNU stat if available
175
    if stat --version >/dev/null 2>&1; then
176
        stat -c %d "$path" 2>/dev/null || stat -c %i "$path" 2>/dev/null || echo ""
177
    else
178
        # BSD/macOS stat
179
        stat -f %d "$path" 2>/dev/null || stat -f %i "$path" 2>/dev/null || echo ""
180
    fi
181
}
182

            
183
# Determine the mount root for a path by walking up until device changes
184
get_mountpoint() {
185
    local path="$1"
186
    if [[ -z "$path" ]]; then path="."; fi
187
    # Resolve to absolute
188
    local abs
189
    abs=$(cd "$path" 2>/dev/null && pwd) || abs="$path"
190
    # Walk up until device differs or we reach /
191
    local parent="$abs"
192
    local root_dev
193
    root_dev=$(get_dev "$parent")
194
    while [[ "$parent" != "/" ]]; do
195
        local next_parent
196
        next_parent=$(dirname "$parent")
197
        local next_dev
198
        next_dev=$(get_dev "$next_parent")
199
        if [[ "$next_dev" != "$root_dev" ]]; then
200
            break
201
        fi
202
        parent="$next_parent"
203
    done
204
    echo "$parent"
205
}
206

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

            
Bogdan Timofte authored 9 months ago
225
# (Removed checksum/prefix/conflict helper functions to revert to pre-conflict-resolution behavior)
226

            
227
# Safe move/copy helpers: filter out benign "set flags (was: ...): Operation not supported"
228
# which appears when moving files onto filesystems that don't support BSD file flags
229
# (macOS mv may try to preserve flags and print this warning while still succeeding).
230
safe_mv() {
231
    local src="$1" dst="$2"
232
    # Redirect stderr through a filter that removes the known benign message
233
    mv "$src" "$dst" 2> >(grep -v "set flags (was:" >&2)
234
    return $?
235
}
236

            
237
# Use simple safe_mv/safe_cp for moving/copying files. Removed atomic installer to let exiftool or filesystem handle renames.
238

            
239
safe_cp() {
240
    local src="$1" dst="$2"
241
    cp "$src" "$dst" 2> >(grep -v "set flags (was:" >&2)
242
    return $?
243
}
244

            
Bogdan Timofte authored 3 weeks ago
245
verify_copied_file() {
246
    local src="$1"
247
    local dst="$2"
248
    local expected_date="$3"
249

            
250
    if [[ ! -f "$dst" ]]; then
251
        log_message "Verified copy missing at destination: $dst" "ERROR"
252
        return 1
253
    fi
254

            
255
    local src_size dst_size
256
    src_size=$(get_file_size "$src")
257
    dst_size=$(get_file_size "$dst")
258
    if [[ "$src_size" != "$dst_size" ]]; then
259
        log_message "Size mismatch after copy: $src ($src_size) != $dst ($dst_size)" "ERROR"
260
        return 1
261
    fi
262

            
263
    if [[ "$VERIFY_MODE" == "strict" ]]; then
264
        if ! cmp -s "$src" "$dst"; then
265
            log_message "Content mismatch after copy: $src -> $dst" "ERROR"
266
            return 1
267
        fi
268
    elif [[ "$VERIFY_MODE" == "none" ]]; then
269
        return 0
270
    fi
271

            
272
    if [[ -n "$expected_date" ]]; then
273
        local destination_date_info
274
        destination_date_info=$(extract_file_date "$dst")
275
        local extract_status=$?
276
        if [[ $extract_status -ne 0 || -z "$destination_date_info" ]]; then
277
            log_message "Destination metadata validation failed: $dst" "ERROR"
278
            return 1
279
        fi
280

            
281
        local destination_date="${destination_date_info%|*}"
282
        if [[ "$destination_date" != "$expected_date" ]]; then
283
            log_message "Destination metadata mismatch: expected $expected_date, got $destination_date for $dst" "ERROR"
284
            return 1
285
        fi
286
    fi
287

            
288
    return 0
289
}
290

            
291
remove_source_file() {
292
    local src="$1"
293
    rm -f "$src"
294
}
295

            
296
copy_with_verification() {
297
    local src="$1"
298
    local dst="$2"
299
    local expected_date="$3"
300

            
301
    if ! safe_cp "$src" "$dst"; then
302
        return 1
303
    fi
304

            
305
    if ! verify_copied_file "$src" "$dst" "$expected_date"; then
306
        rm -f "$dst"
307
        return 1
308
    fi
309

            
310
    return 0
311
}
312

            
313
verified_move_file() {
314
    local src="$1"
315
    local dst="$2"
316
    local expected_date="$3"
317

            
318
    if ! copy_with_verification "$src" "$dst" "$expected_date"; then
319
        return 1
320
    fi
321

            
322
    if ! remove_source_file "$src"; then
323
        log_message "Copied and verified destination, but failed to remove source: $src" "ERROR"
324
        return 1
325
    fi
326

            
327
    return 0
328
}
329

            
Bogdan Timofte authored 9 months ago
330
# Function to format file size
331
format_size() {
332
    local size=$1
333
    if (( size < 1024 )); then
334
        echo "${size}B"
335
    elif (( size < 1048576 )); then
336
        echo "$(( size / 1024 ))KB"
337
    elif (( size < 1073741824 )); then
338
        echo "$(( size / 1048576 ))MB"
339
    else
340
        echo "$(( size / 1073741824 ))GB"
341
    fi
342
}
343

            
344
# Function to extract date from file
345
extract_file_date() {
346
    local file="$1"
347
    local create_date=""
348
    local date_source=""
349
    local exif_found=0
350
    # Try to get creation date from EXIF data
351
    local exif_output=$(exiftool -G1 -s -CreateDate -DateTimeOriginal -DateTime "$file" 2>/dev/null)
352
    if [[ -n "$exif_output" ]]; then
353
        # Parse the exiftool output to find the best date
354
        while IFS= read -r line; do
355
            if [[ "$line" =~ ^\[([^\]]+)\][[:space:]]*([^:]+)[[:space:]]*:[[:space:]]*(.+)$ ]]; then
356
                local group="${BASH_REMATCH[1]}"
357
                local tag="${BASH_REMATCH[2]}"
358
                local value="${BASH_REMATCH[3]}"
359
                # Trim spaces from tag name
360
                tag=$(echo "$tag" | sed 's/[[:space:]]*$//')
361
                # Prefer DateTimeOriginal, then CreateDate, then DateTime
362
                if [[ "$tag" == "DateTimeOriginal" && -z "$create_date" ]]; then
363
                    create_date="$value"
364
                    date_source="$group:$tag"
365
                    exif_found=1
366
                elif [[ "$tag" == "CreateDate" && "$date_source" != *"DateTimeOriginal"* ]]; then
367
                    create_date="$value"
368
                    date_source="$group:$tag"
369
                    exif_found=1
370
                elif [[ "$tag" == "DateTime" && -z "$create_date" ]]; then
371
                    create_date="$value"
372
                    date_source="$group:$tag"
373
                    exif_found=1
374
                fi
375
            fi
376
        done <<< "$exif_output"
377
    fi
378
    # If no EXIF date found, try mediainfo for video files
379
    if [[ -z "$create_date" ]] && command -v mediainfo &> /dev/null; then
380
        local media_date=$(mediainfo --Inform="General;%Recorded_Date%" "$file" 2>/dev/null)
381
        if [[ -n "$media_date" && "$media_date" != "0000-00-00 00:00:00" ]]; then
382
            create_date="$media_date"
383
            date_source="MediaInfo:Recorded_Date"
384
        fi
385
    fi
386
    # If no EXIF or mediainfo date found, return failure
387
    if [[ -z "$create_date" ]]; then
388
        return 2  # No date metadata found
389
    fi
390

            
391
    # Convert EXIF format (YYYY:MM:DD HH:MM:SS) to standard format
392
    # Always output as yyyy-mm-dd hh:mm:ss (pad single digits)
393
    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
394
        year="${BASH_REMATCH[1]}"
395
        month=$(printf "%02d" "$((10#${BASH_REMATCH[2]}))")
396
        day=$(printf "%02d" "$((10#${BASH_REMATCH[3]}))")
397
        hour=$(printf "%02d" "$((10#${BASH_REMATCH[4]}))")
398
        minute=$(printf "%02d" "$((10#${BASH_REMATCH[5]}))")
399
        second=$(printf "%02d" "$((10#${BASH_REMATCH[6]}))")
400
        create_date="$year-$month-$day $hour:$minute:$second"
401
    else
402
        # Try to convert yyyy-mm-dd hh:mm:ss (already correct)
403
        if [[ "$create_date" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})[[:space:]]*([0-9]{2}):([0-9]{2}):([0-9]{2})$ ]]; then
404
            # Already correct
405
            :
406
        else
407
            print_color "$RED" "Error: Cannot parse date '$create_date'" >&2
408
            return 2
409
        fi
410
    fi
411

            
412
    # For QuickTime files, the CreateDate is in UTC and needs conversion to local time
413
    if [[ "$date_source" == *"QuickTime"* ]]; then
414
        # Convert UTC time to local time
415
        if [[ "$OSTYPE" == "darwin"* ]]; then
416
            # On macOS, use TZ=UTC to interpret the input time as UTC
417
            local utc_timestamp=$(TZ=UTC date -j -f "%Y-%m-%d %H:%M:%S" "$create_date" "+%s" 2>/dev/null)
418
            if [[ -n "$utc_timestamp" ]]; then
419
                create_date=$(date -j -r "$utc_timestamp" "+%Y-%m-%d %H:%M:%S" 2>/dev/null)
420
                date_source="$date_source (converted from UTC)"
421
            fi
422
        else
423
            local utc_timestamp=$(date -d "$create_date UTC" "+%s" 2>/dev/null)
424
            if [[ -n "$utc_timestamp" ]]; then
425
                create_date=$(date -d "@$utc_timestamp" "+%Y-%m-%d %H:%M:%S" 2>/dev/null)
426
                date_source="$date_source (converted from UTC)"
427
            fi
428
        fi
429
    fi
430

            
431
    echo "$create_date|$date_source"
432
    return 0
433
}
434

            
435
# Function to generate destination path based on organization pattern
436
generate_destination_path() {
437
    local date_str="$1"
438
    local original_filename="$2"
439
    local base_destination="$3"
440

            
441
    # Extract date components - handle both GNU and BSD date
442
    local year month day hour minute second
443
    if [[ "$OSTYPE" == "darwin"* ]]; then
444
        # macOS (BSD date) - use more robust regex (allow single-digit month/day, tolerate extra spaces)
445
        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
446
            year="${BASH_REMATCH[1]}"
447
            month=$(printf "%02d" "$((10#${BASH_REMATCH[2]}))")
448
            day=$(printf "%02d" "$((10#${BASH_REMATCH[3]}))")
449
            hour=$(printf "%02d" "$((10#${BASH_REMATCH[4]}))")
450
            minute=$(printf "%02d" "$((10#${BASH_REMATCH[5]}))")
451
            second=$(printf "%02d" "$((10#${BASH_REMATCH[6]}))")
452
        else
453
            return 1
454
        fi
455
    else
456
        # Linux (GNU date)
457
        year=$(date -d "$date_str" "+%Y" 2>/dev/null)
458
        month=$(date -d "$date_str" "+%m" 2>/dev/null)
459
        day=$(date -d "$date_str" "+%d" 2>/dev/null)
460
        hour=$(date -d "$date_str" "+%H" 2>/dev/null)
461
        minute=$(date -d "$date_str" "+%M" 2>/dev/null)
462
        second=$(date -d "$date_str" "+%S" 2>/dev/null)
463
    fi
464

            
465
    if [[ -z "$year" || -z "$month" || -z "$day" ]]; then
466
        return 1
467
    fi
468

            
469
    # Get file extension
470
    local extension="${original_filename##*.}"
471
    local lowercase_ext=$(echo "$extension" | tr '[:upper:]' '[:lower:]')
472

            
473
    # Generate path and filename based on organization pattern
474
    local dir_path=""
475
    local filename=""
Bogdan Timofte authored 9 months ago
476

            
477
    # If no organization specified, use flat destination (base) and choose filename per mode
478
    if [[ -z "$ORGANIZATION" ]]; then
Bogdan Timofte authored 9 months ago
479
        dir_path="$base_destination"
Bogdan Timofte authored 9 months ago
480
        if [[ "$FILENAME_MODE" == "orig" ]]; then
481
            filename="$original_filename"
482
        else
483
            # full or auto both map to full date for flat layout
484
            filename="${year}-${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
485
        fi
486
        echo "$dir_path/$filename"
487
        return 0
488
    fi
489

            
490
    case "$ORGANIZATION" in
Bogdan Timofte authored 9 months ago
491
            "y")
492
                dir_path="$base_destination/$year"
493
                filename="${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
494
                ;;
495
            "m")
496
                dir_path="$base_destination/$year/$month"
497
                filename="${day}_${hour}-${minute}-${second}.${lowercase_ext}"
498
                ;;
499
            "d")
500
                dir_path="$base_destination/$year/$month/$day"
501
                filename="${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
502
                ;;
503
            "h")
504
                dir_path="$base_destination/$year/$month/$day/$hour"
505
                filename="${minute}-${second}.${lowercase_ext}"
506
                ;;
Bogdan Timofte authored 9 months ago
507
            "ym")
508
                # Single folder per month named yyyy-mm; filename includes day and time
509
                dir_path="$base_destination/${year}-${month}"
510
                filename="${day}_${hour}-${minute}-${second}.${lowercase_ext}"
511
                ;;
Bogdan Timofte authored 8 months ago
512
            "ymd")
Bogdan Timofte authored 9 months ago
513
                # Single folder per day named yyyy-mm-dd; filename is time
514
                dir_path="$base_destination/${year}-${month}-${day}"
515
                filename="${hour}-${minute}-${second}.${lowercase_ext}"
516
                ;;
Bogdan Timofte authored 9 months ago
517
            *)
518
                log_message "Invalid organization pattern: $ORGANIZATION" "ERROR"
519
                return 1
520
                ;;
521
        esac
Bogdan Timofte authored 9 months ago
522

            
523
    # Apply filename mode overrides
524
    case "$FILENAME_MODE" in
525
        orig)
526
            filename="$original_filename"
527
            ;;
528
        full)
529
            filename="${year}-${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
530
            ;;
531
        auto)
532
            # keep the auto-generated filename from the organization case
533
            ;;
534
        *)
535
            # fallback to full
536
            filename="${year}-${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
537
            ;;
538
    esac
539

            
Bogdan Timofte authored 9 months ago
540
    echo "$dir_path/$filename"
541
    return 0
542
}
543

            
544
# Function to find files matching patterns
545
find_source_files() {
Bogdan Timofte authored 9 months ago
546
    # Emit absolute file paths for all media files under sources, excluding DESTINATION when it is inside a source
547
    local abs_dest=""
548
    if [[ -n "$DESTINATION" ]]; then
549
        abs_dest=$(cd "$DESTINATION" 2>/dev/null && pwd) || abs_dest="$DESTINATION"
550
    fi
551

            
552
    # Build -iname expression for find
553
    local ext_expr=""
554
    for ext in "${MEDIA_EXTENSIONS[@]}"; do
555
        if [[ -n "$ext_expr" ]]; then
556
            ext_expr="$ext_expr -o"
557
        fi
558
        ext_expr="$ext_expr -iname $ext"
559
    done
560

            
Bogdan Timofte authored 9 months ago
561
    if [[ ${#SOURCE_PATTERNS[@]} -eq 0 ]]; then
Bogdan Timofte authored 9 months ago
562
        # Default: scan current directory
563
        local start_dot="."
564
        local abs_current
565
        abs_current=$(pwd)
566
        local find_cmd=(find -L "$start_dot" -type f)
567
        # If dest is inside cwd, add exclusion
568
        if [[ -n "$abs_dest" && "$abs_dest" == "$abs_current"* ]]; then
569
            find_cmd+=( ! -path "$abs_dest/*" ! -path "$abs_dest" )
Bogdan Timofte authored 9 months ago
570
        fi
Bogdan Timofte authored 9 months ago
571
        # Add expression
572
        # shellcheck disable=SC2068
573
        "${find_cmd[@]}" \( $ext_expr \) 2>/dev/null || true
Bogdan Timofte authored 9 months ago
574
    else
Bogdan Timofte authored 9 months ago
575
        # Scan each provided source
576
        for src in "${SOURCE_PATTERNS[@]}"; do
577
            if [[ -f "$src" ]]; then
578
                # single file - skip if it's inside dest
579
                local abs_file
580
                abs_file=$(cd "$(dirname "$src")" 2>/dev/null && pwd)/$(basename "$src")
581
                if [[ -n "$abs_dest" && "$abs_file" == "$abs_dest"* ]]; then
582
                    continue
583
                fi
584
                echo "$abs_file"
585
            elif [[ -d "$src" ]]; then
586
                local abs_src
587
                abs_src=$(cd "$src" 2>/dev/null && pwd)
588
                if [[ -n "$abs_src" ]]; then
589
                    if [[ -n "$abs_dest" && "$abs_dest" == "$abs_src"* ]]; then
590
                        find -L "$abs_src" -type f \( $ext_expr \) ! -path "$abs_dest/*" ! -path "$abs_dest" 2>/dev/null || true
591
                    else
592
                        find -L "$abs_src" -type f \( $ext_expr \) 2>/dev/null || true
Bogdan Timofte authored 9 months ago
593
                    fi
Bogdan Timofte authored 9 months ago
594
                else
595
                    print_color "$YELLOW" "Warning: Could not resolve source directory: $src"
Bogdan Timofte authored 9 months ago
596
                fi
Bogdan Timofte authored 9 months ago
597
            else
598
                print_color "$YELLOW" "Warning: Source path does not exist or is not a file/directory: $src"
Bogdan Timofte authored 9 months ago
599
            fi
600
        done
601
    fi
602
}
603

            
604
# Function to process a single file
605
process_file() {
606
    local file="$1"
607
    local file_size=$(get_file_size "$file")
608
    TOTAL_SIZE=$((TOTAL_SIZE + file_size))
609

            
610
    log_message "Processing: $file" "INFO"
611

            
612
    # Extract date information
613
    local date_info=$(extract_file_date "$file")
614
        local extract_status=$?
615
        if [[ $extract_status -eq 2 ]]; then
616
            if [[ $COLLECT_UNSORTABLE -eq 1 ]]; then
617
                local unsortable_dir="$DESTINATION/unsortable"
618
                mkdir -p "$unsortable_dir"
619
                local unsortable_path="$unsortable_dir/$(basename "$file")"
620
                if [[ $DRY_RUN -eq 1 ]]; then
621
                    print_color "$BLUE" "Would move unsortable: $file -> $unsortable_path"
622
                else
Bogdan Timofte authored 3 weeks ago
623
                    if verified_move_file "$file" "$unsortable_path" ""; then
Bogdan Timofte authored 9 months ago
624
                        log_message "Unsortable: $file -> $unsortable_path" "SUCCESS"
625
                    else
Bogdan Timofte authored 3 weeks ago
626
                        log_message "Failed to move unsortable file after verification: $file" "ERROR"
Bogdan Timofte authored 9 months ago
627
                    fi
628
                fi
629
                SKIPPED_FILES=$((SKIPPED_FILES + 1))
630
            else
631
                log_message "Could not extract date from $file - skipping" "WARNING"
632
                SKIPPED_FILES=$((SKIPPED_FILES + 1))
633
            fi
634
            return 1
635
        elif [[ $extract_status -ne 0 ]] || [[ -z "$date_info" ]]; then
636
            log_message "Could not extract date from $file - skipping" "WARNING"
637
            SKIPPED_FILES=$((SKIPPED_FILES + 1))
638
            return 1
639
        fi
640
        local date_str="${date_info%|*}"
641
        local date_source="${date_info#*|}"
642
        log_message "Date: $date_str (from $date_source)" "INFO"
643
        # Generate destination path
644
        local dest_path=$(generate_destination_path "$date_str" "$(basename "$file")" "$DESTINATION")
645
        if [[ $? -ne 0 ]] || [[ -z "$dest_path" ]]; then
646
            log_message "Could not generate destination path for $file" "ERROR"
647
            ERROR_FILES=$((ERROR_FILES + 1))
648
            FATAL_ERROR=1
649
            return 2
650
    fi
651

            
Bogdan Timofte authored 9 months ago
652
    # If destination exists, do not attempt complex conflict resolution here.
653
    # Let external tools (exiftool) or filesystem semantics handle renames/overwrites.
654
    if [[ -f "$dest_path" ]]; then
655
        log_message "Destination already exists: $dest_path - proceeding to move/copy and letting external tools handle conflicts" "WARNING"
656
    fi
Bogdan Timofte authored 9 months ago
657

            
658
    local dest_dir=$(dirname "$dest_path")
659

            
660
    if [[ $DRY_RUN -eq 1 ]]; then
661
        if [[ $KEEP_ORIGINALS -eq 1 ]]; then
662
            print_color "$BLUE" "Would copy: $file -> $dest_path"
663
        else
664
            print_color "$BLUE" "Would move: $file -> $dest_path"
665
        fi
666
        PROCESSED_FILES=$((PROCESSED_FILES + 1))
667
        PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
668
        return 0
669
    fi
670

            
671
    # Create destination directory
672
    if ! mkdir -p "$dest_dir"; then
673
        log_message "Could not create directory: $dest_dir" "ERROR"
674
        ERROR_FILES=$((ERROR_FILES + 1))
675
        return 1
676
    fi
677

            
Bogdan Timofte authored 9 months ago
678
    # Copy or move file using safe helpers (filter benign stderr). Let external tools handle renaming conflicts.
Bogdan Timofte authored 9 months ago
679
    if [[ $KEEP_ORIGINALS -eq 1 ]]; then
Bogdan Timofte authored 3 weeks ago
680
        if copy_with_verification "$file" "$dest_path" "$date_str"; then
Bogdan Timofte authored 9 months ago
681
            log_message "Copied: $file -> $dest_path" "SUCCESS"
682
            PROCESSED_FILES=$((PROCESSED_FILES + 1))
683
            PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
684
            return 0
685
        else
Bogdan Timofte authored 3 weeks ago
686
            log_message "Failed to copy or verify destination: $file -> $dest_path" "ERROR"
Bogdan Timofte authored 9 months ago
687
            ERROR_FILES=$((ERROR_FILES + 1))
688
            return 1
689
        fi
690
    else
Bogdan Timofte authored 3 weeks ago
691
        if verified_move_file "$file" "$dest_path" "$date_str"; then
Bogdan Timofte authored 9 months ago
692
            log_message "Moved: $file -> $dest_path" "SUCCESS"
693
            PROCESSED_FILES=$((PROCESSED_FILES + 1))
694
            PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
695
            return 0
696
        else
Bogdan Timofte authored 3 weeks ago
697
            log_message "Failed to move after copy verification: $file -> $dest_path" "ERROR"
Bogdan Timofte authored 9 months ago
698
            ERROR_FILES=$((ERROR_FILES + 1))
699
            return 1
700
        fi
701
    fi
702
}
703

            
704
# Function to display final report
705
show_report() {
706
    local end_time=$(date +%s)
707
    local elapsed_time=$((end_time - START_TIME))
708
    local hours=$((elapsed_time / 3600))
709
    local minutes=$(((elapsed_time % 3600) / 60))
710
    local seconds=$((elapsed_time % 60))
711

            
712
    echo ""
713
    print_color "$GREEN" "=========================================="
714
    print_color "$GREEN" "           PROCESSING REPORT"
715
    print_color "$GREEN" "=========================================="
716
    echo ""
717

            
718
    echo "Files Summary:"
719
    echo "  Total files found:     $TOTAL_FILES"
720
    echo "  Successfully processed: $PROCESSED_FILES"
721
    echo "  Skipped (no date):     $SKIPPED_FILES"
722
    echo "  Errors:                $ERROR_FILES"
723
    echo ""
724

            
725
    echo "Size Summary:"
726
    echo "  Total size found:      $(format_size $TOTAL_SIZE)"
727
    echo "  Successfully processed: $(format_size $PROCESSED_SIZE)"
728
    echo ""
729

            
730
    echo "Time Summary:"
731
    printf "  Time elapsed:          %02d:%02d:%02d\n" $hours $minutes $seconds
732

            
733
    if [[ $elapsed_time -gt 0 && $PROCESSED_FILES -gt 0 ]]; then
734
        local files_per_second=$((PROCESSED_FILES / elapsed_time))
735
        local mb_per_second=$((PROCESSED_SIZE / elapsed_time / 1048576))
736
        echo "  Speed:                 $files_per_second files/sec, ${mb_per_second}MB/sec"
737
    fi
738

            
739
    echo ""
740

            
741
    if [[ $DRY_RUN -eq 1 ]]; then
742
        print_color "$YELLOW" "DRY RUN MODE - No files were actually moved/copied"
743
    elif [[ $KEEP_ORIGINALS -eq 1 ]]; then
744
        print_color "$BLUE" "COPY MODE - Original files were preserved"
745
    else
746
        print_color "$GREEN" "MOVE MODE - Files were moved to destination"
747
    fi
748

            
749
    echo ""
750
    print_color "$GREEN" "=========================================="
751
}
752

            
753
# Parse command line arguments
754
while [[ $# -gt 0 ]]; do
755
    case $1 in
756
        -o|--organization)
757
            ORGANIZATION="$2"
Bogdan Timofte authored 8 months ago
758
            # Accept new patterns: ym, ymd as well as single-letter ones
759
            if [[ ! "$ORGANIZATION" =~ ^(y|m|d|h|ym|ymd)$ ]]; then
Bogdan Timofte authored 9 months ago
760
                print_color "$RED" "Error: Invalid organization pattern. Must be one of: y, m, d, h, ym, ymd"
Bogdan Timofte authored 9 months ago
761
                exit 1
762
            fi
763
            shift 2
764
            ;;
Bogdan Timofte authored 9 months ago
765

            
766
        -F|--filename-mode)
767
            FILENAME_MODE="$2"
768
            if [[ ! "$FILENAME_MODE" =~ ^(auto|full|orig)$ ]]; then
769
                print_color "$RED" "Error: Invalid filename mode. Must be one of: auto, full, orig"
770
                exit 1
771
            fi
772
            shift 2
Bogdan Timofte authored 9 months ago
773
            ;;
Bogdan Timofte authored 9 months ago
774
    # (conflict resolution option removed; let exiftool/filesystem handle naming conflicts)
Bogdan Timofte authored 9 months ago
775
        --collect-unsortable)
776
            COLLECT_UNSORTABLE=1
777
            shift
778
            ;;
Bogdan Timofte authored 8 months ago
779
        --keep-empty-dirs)
780
            CLEANUP_EMPTY_DIRS=0
781
            shift
782
            ;;
Bogdan Timofte authored 9 months ago
783
        -s|--source)
784
            SOURCE_PATTERNS+=("$2")
785
            shift 2
786
            ;;
787
        -d|--destination)
788
            DESTINATION="$2"
789
            shift 2
790
            ;;
791
        -k|--keep-originals)
792
            KEEP_ORIGINALS=1
793
            shift
794
            ;;
Bogdan Timofte authored 3 weeks ago
795
        --verify-mode)
796
            VERIFY_MODE="$2"
797
            if [[ ! "$VERIFY_MODE" =~ ^(size|strict|none)$ ]]; then
798
                print_color "$RED" "Error: Invalid verify mode. Must be one of: size, strict, none"
799
                exit 1
800
            fi
801
            shift 2
802
            ;;
Bogdan Timofte authored 9 months ago
803
        --dry-run)
804
            DRY_RUN=1
805
            shift
806
            ;;
807
        -v|--verbose)
808
            VERBOSE=1
809
            shift
810
            ;;
811
        -h|--help)
812
            show_help
813
            exit 0
814
            ;;
815
        --version)
816
            show_version
817
            exit 0
818
            ;;
819
        *)
820
            print_color "$RED" "Error: Unknown option: $1"
821
            echo "Use -h or --help for usage information."
822
            exit 1
823
            ;;
824
    esac
825
done
826

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

            
Bogdan Timofte authored 3 weeks ago
829
# If no source specified, default to current directory. Refuse to run when cwd is unsafe.
830
if [[ ${#SOURCE_PATTERNS[@]} -eq 0 ]]; then
831
    cwd=$(pwd)
832
    # Resolve home and root paths
833
    home_dir="$HOME"
834
    case "$cwd" in
835
        "$home_dir"|"/"|"/etc"|"/var"|"/bin"|"/sbin"|"/dev"|"/proc"|"/sys"|"/run"|"/usr"|"/lib"|"/lib64"|"/boot"|"/snap")
836
            print_color "$RED" "Refusing to run with default source in unsafe directory: $cwd"
837
            print_color "$RED" "Please specify $cwd as source explicitly with -s or run from a safer directory."
838
            exit 1
839
            ;;
840
        *)
841
            SOURCE_PATTERNS+=("$cwd")
842
            ;;
843
    esac
844
fi
845

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

            
854
    if [[ ${#SOURCE_PATTERNS[@]} -gt 0 ]]; then
855
        first_source="${SOURCE_PATTERNS[0]}"
Bogdan Timofte authored 3 weeks ago
856
        if [[ -d "$first_source" ]]; then
857
            DESTINATION="$first_source/sorted"
858
        elif [[ -f "$first_source" ]]; then
859
            DESTINATION="$(dirname "$first_source")/sorted"
Bogdan Timofte authored 9 months ago
860
        else
861
            print_color "$YELLOW" "Warning: first source does not exist; defaulting destination to ./sorted"
862
            DESTINATION="./sorted"
863
        fi
864
    else
865
        DESTINATION="./sorted"
866
    fi
Bogdan Timofte authored 9 months ago
867
fi
868

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

            
872
# Display configuration
873
print_color "$GREEN" "$SCRIPT_NAME v$VERSION"
874
echo ""
875
echo "Configuration:"
876
echo "  Organization pattern: $ORGANIZATION"
877
echo "  Destination:         $DESTINATION"
878
echo "  Keep originals:      $([ $KEEP_ORIGINALS -eq 1 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored 3 weeks ago
879
echo "  Verify mode:         $VERIFY_MODE"
Bogdan Timofte authored 9 months ago
880
echo "  Dry run:             $([ $DRY_RUN -eq 1 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored 8 months ago
881
echo "  Keep empty dirs:     $([ $CLEANUP_EMPTY_DIRS -eq 0 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored 9 months ago
882
echo "  Verbose:             $([ $VERBOSE -eq 1 ] && echo "Yes" || echo "No")"
883

            
884
if [[ ${#SOURCE_PATTERNS[@]} -gt 0 ]]; then
885
    echo "  Source patterns:"
886
    for pattern in "${SOURCE_PATTERNS[@]}"; do
887
        echo "    - $pattern"
888
    done
889
else
890
    echo "  Source patterns:     All media files in current directory"
891
fi
892

            
893
echo ""
894

            
895
# Check dependencies
896
check_dependencies
897

            
898
# Create destination directory if it doesn't exist (unless dry run)
899
if [[ $DRY_RUN -eq 0 ]]; then
900
    if ! mkdir -p "$DESTINATION"; then
901
        print_color "$RED" "Error: Cannot create destination directory: $DESTINATION"
902
        exit 1
903
    fi
904
fi
905

            
906
# Find all source files
907

            
908
print_color "$BLUE" "Scanning for media files..."
909
files=()
910
while IFS= read -r file; do
911
    files+=("$file")
912
done < <(find_source_files)
913
TOTAL_FILES=${#files[@]}
914

            
915
if [[ $TOTAL_FILES -eq 0 ]]; then
916
    print_color "$YELLOW" "No media files found matching the specified patterns."
917
    exit 0
918
fi
919

            
920
print_color "$BLUE" "Found $TOTAL_FILES media files to process"
921
echo ""
922

            
923
# Process each file
924

            
925
FATAL_ERROR=0
926
for file in "${files[@]}"; do
927
    if [[ -f "$file" ]]; then
928
        process_file "$file"
929
        if [[ $FATAL_ERROR -eq 1 ]]; then
930
            print_color "$RED" "Fatal error encountered. Stopping further processing."
931
            break
932
        fi
933
    fi
934
done
935

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

            
Bogdan Timofte authored 9 months ago
944
# Show final report
945
show_report
946

            
947
# Exit with appropriate code
948
if [[ $ERROR_FILES -gt 0 ]]; then
949
    exit 1
950
else
951
    exit 0
952
fi