MediaImporter / media-importer.sh
Newer Older
02f71bb 8 months ago History
862 lines | 30.506kb
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
19
DRY_RUN=0
20
VERBOSE=0
Bogdan Timofte authored 8 months ago
21
CLEANUP_EMPTY_DIRS=1
Bogdan Timofte authored 9 months ago
22

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

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

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

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

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

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

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

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

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

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

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

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

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

            
124
# Function to check dependencies
125
check_dependencies() {
126
    local missing_deps=()
127

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

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

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

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

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

            
161
    log_message "All required dependencies found" "SUCCESS"
162
}
163

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored 9 months ago
243
# Function to format file size
244
format_size() {
245
    local size=$1
246
    if (( size < 1024 )); then
247
        echo "${size}B"
248
    elif (( size < 1048576 )); then
249
        echo "$(( size / 1024 ))KB"
250
    elif (( size < 1073741824 )); then
251
        echo "$(( size / 1048576 ))MB"
252
    else
253
        echo "$(( size / 1073741824 ))GB"
254
    fi
255
}
256

            
257
# Function to extract date from file
258
extract_file_date() {
259
    local file="$1"
260
    local create_date=""
261
    local date_source=""
262
    local exif_found=0
263
    # Try to get creation date from EXIF data
264
    local exif_output=$(exiftool -G1 -s -CreateDate -DateTimeOriginal -DateTime "$file" 2>/dev/null)
265
    if [[ -n "$exif_output" ]]; then
266
        # Parse the exiftool output to find the best date
267
        while IFS= read -r line; do
268
            if [[ "$line" =~ ^\[([^\]]+)\][[:space:]]*([^:]+)[[:space:]]*:[[:space:]]*(.+)$ ]]; then
269
                local group="${BASH_REMATCH[1]}"
270
                local tag="${BASH_REMATCH[2]}"
271
                local value="${BASH_REMATCH[3]}"
272
                # Trim spaces from tag name
273
                tag=$(echo "$tag" | sed 's/[[:space:]]*$//')
274
                # Prefer DateTimeOriginal, then CreateDate, then DateTime
275
                if [[ "$tag" == "DateTimeOriginal" && -z "$create_date" ]]; then
276
                    create_date="$value"
277
                    date_source="$group:$tag"
278
                    exif_found=1
279
                elif [[ "$tag" == "CreateDate" && "$date_source" != *"DateTimeOriginal"* ]]; then
280
                    create_date="$value"
281
                    date_source="$group:$tag"
282
                    exif_found=1
283
                elif [[ "$tag" == "DateTime" && -z "$create_date" ]]; then
284
                    create_date="$value"
285
                    date_source="$group:$tag"
286
                    exif_found=1
287
                fi
288
            fi
289
        done <<< "$exif_output"
290
    fi
291
    # If no EXIF date found, try mediainfo for video files
292
    if [[ -z "$create_date" ]] && command -v mediainfo &> /dev/null; then
293
        local media_date=$(mediainfo --Inform="General;%Recorded_Date%" "$file" 2>/dev/null)
294
        if [[ -n "$media_date" && "$media_date" != "0000-00-00 00:00:00" ]]; then
295
            create_date="$media_date"
296
            date_source="MediaInfo:Recorded_Date"
297
        fi
298
    fi
299
    # If no EXIF or mediainfo date found, return failure
300
    if [[ -z "$create_date" ]]; then
301
        return 2  # No date metadata found
302
    fi
303

            
304
    # Convert EXIF format (YYYY:MM:DD HH:MM:SS) to standard format
305
    # Always output as yyyy-mm-dd hh:mm:ss (pad single digits)
306
    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
307
        year="${BASH_REMATCH[1]}"
308
        month=$(printf "%02d" "$((10#${BASH_REMATCH[2]}))")
309
        day=$(printf "%02d" "$((10#${BASH_REMATCH[3]}))")
310
        hour=$(printf "%02d" "$((10#${BASH_REMATCH[4]}))")
311
        minute=$(printf "%02d" "$((10#${BASH_REMATCH[5]}))")
312
        second=$(printf "%02d" "$((10#${BASH_REMATCH[6]}))")
313
        create_date="$year-$month-$day $hour:$minute:$second"
314
    else
315
        # Try to convert yyyy-mm-dd hh:mm:ss (already correct)
316
        if [[ "$create_date" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})[[:space:]]*([0-9]{2}):([0-9]{2}):([0-9]{2})$ ]]; then
317
            # Already correct
318
            :
319
        else
320
            print_color "$RED" "Error: Cannot parse date '$create_date'" >&2
321
            return 2
322
        fi
323
    fi
324

            
325
    # For QuickTime files, the CreateDate is in UTC and needs conversion to local time
326
    if [[ "$date_source" == *"QuickTime"* ]]; then
327
        # Convert UTC time to local time
328
        if [[ "$OSTYPE" == "darwin"* ]]; then
329
            # On macOS, use TZ=UTC to interpret the input time as UTC
330
            local utc_timestamp=$(TZ=UTC date -j -f "%Y-%m-%d %H:%M:%S" "$create_date" "+%s" 2>/dev/null)
331
            if [[ -n "$utc_timestamp" ]]; then
332
                create_date=$(date -j -r "$utc_timestamp" "+%Y-%m-%d %H:%M:%S" 2>/dev/null)
333
                date_source="$date_source (converted from UTC)"
334
            fi
335
        else
336
            local utc_timestamp=$(date -d "$create_date UTC" "+%s" 2>/dev/null)
337
            if [[ -n "$utc_timestamp" ]]; then
338
                create_date=$(date -d "@$utc_timestamp" "+%Y-%m-%d %H:%M:%S" 2>/dev/null)
339
                date_source="$date_source (converted from UTC)"
340
            fi
341
        fi
342
    fi
343

            
344
    echo "$create_date|$date_source"
345
    return 0
346
}
347

            
348
# Function to generate destination path based on organization pattern
349
generate_destination_path() {
350
    local date_str="$1"
351
    local original_filename="$2"
352
    local base_destination="$3"
353

            
354
    # Extract date components - handle both GNU and BSD date
355
    local year month day hour minute second
356
    if [[ "$OSTYPE" == "darwin"* ]]; then
357
        # macOS (BSD date) - use more robust regex (allow single-digit month/day, tolerate extra spaces)
358
        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
359
            year="${BASH_REMATCH[1]}"
360
            month=$(printf "%02d" "$((10#${BASH_REMATCH[2]}))")
361
            day=$(printf "%02d" "$((10#${BASH_REMATCH[3]}))")
362
            hour=$(printf "%02d" "$((10#${BASH_REMATCH[4]}))")
363
            minute=$(printf "%02d" "$((10#${BASH_REMATCH[5]}))")
364
            second=$(printf "%02d" "$((10#${BASH_REMATCH[6]}))")
365
        else
366
            return 1
367
        fi
368
    else
369
        # Linux (GNU date)
370
        year=$(date -d "$date_str" "+%Y" 2>/dev/null)
371
        month=$(date -d "$date_str" "+%m" 2>/dev/null)
372
        day=$(date -d "$date_str" "+%d" 2>/dev/null)
373
        hour=$(date -d "$date_str" "+%H" 2>/dev/null)
374
        minute=$(date -d "$date_str" "+%M" 2>/dev/null)
375
        second=$(date -d "$date_str" "+%S" 2>/dev/null)
376
    fi
377

            
378
    if [[ -z "$year" || -z "$month" || -z "$day" ]]; then
379
        return 1
380
    fi
381

            
382
    # Get file extension
383
    local extension="${original_filename##*.}"
384
    local lowercase_ext=$(echo "$extension" | tr '[:upper:]' '[:lower:]')
385

            
386
    # Generate path and filename based on organization pattern
387
    local dir_path=""
388
    local filename=""
Bogdan Timofte authored 9 months ago
389

            
390
    # If no organization specified, use flat destination (base) and choose filename per mode
391
    if [[ -z "$ORGANIZATION" ]]; then
Bogdan Timofte authored 9 months ago
392
        dir_path="$base_destination"
Bogdan Timofte authored 9 months ago
393
        if [[ "$FILENAME_MODE" == "orig" ]]; then
394
            filename="$original_filename"
395
        else
396
            # full or auto both map to full date for flat layout
397
            filename="${year}-${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
398
        fi
399
        echo "$dir_path/$filename"
400
        return 0
401
    fi
402

            
403
    case "$ORGANIZATION" in
Bogdan Timofte authored 9 months ago
404
            "y")
405
                dir_path="$base_destination/$year"
406
                filename="${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
407
                ;;
408
            "m")
409
                dir_path="$base_destination/$year/$month"
410
                filename="${day}_${hour}-${minute}-${second}.${lowercase_ext}"
411
                ;;
412
            "d")
413
                dir_path="$base_destination/$year/$month/$day"
414
                filename="${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
415
                ;;
416
            "h")
417
                dir_path="$base_destination/$year/$month/$day/$hour"
418
                filename="${minute}-${second}.${lowercase_ext}"
419
                ;;
Bogdan Timofte authored 9 months ago
420
            "ym")
421
                # Single folder per month named yyyy-mm; filename includes day and time
422
                dir_path="$base_destination/${year}-${month}"
423
                filename="${day}_${hour}-${minute}-${second}.${lowercase_ext}"
424
                ;;
Bogdan Timofte authored 8 months ago
425
            "ymd")
Bogdan Timofte authored 9 months ago
426
                # Single folder per day named yyyy-mm-dd; filename is time
427
                dir_path="$base_destination/${year}-${month}-${day}"
428
                filename="${hour}-${minute}-${second}.${lowercase_ext}"
429
                ;;
Bogdan Timofte authored 9 months ago
430
            *)
431
                log_message "Invalid organization pattern: $ORGANIZATION" "ERROR"
432
                return 1
433
                ;;
434
        esac
Bogdan Timofte authored 9 months ago
435

            
436
    # Apply filename mode overrides
437
    case "$FILENAME_MODE" in
438
        orig)
439
            filename="$original_filename"
440
            ;;
441
        full)
442
            filename="${year}-${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
443
            ;;
444
        auto)
445
            # keep the auto-generated filename from the organization case
446
            ;;
447
        *)
448
            # fallback to full
449
            filename="${year}-${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}"
450
            ;;
451
    esac
452

            
Bogdan Timofte authored 9 months ago
453
    echo "$dir_path/$filename"
454
    return 0
455
}
456

            
457
# Function to find files matching patterns
458
find_source_files() {
Bogdan Timofte authored 9 months ago
459
    # Emit absolute file paths for all media files under sources, excluding DESTINATION when it is inside a source
460
    local abs_dest=""
461
    if [[ -n "$DESTINATION" ]]; then
462
        abs_dest=$(cd "$DESTINATION" 2>/dev/null && pwd) || abs_dest="$DESTINATION"
463
    fi
464

            
465
    # Build -iname expression for find
466
    local ext_expr=""
467
    for ext in "${MEDIA_EXTENSIONS[@]}"; do
468
        if [[ -n "$ext_expr" ]]; then
469
            ext_expr="$ext_expr -o"
470
        fi
471
        ext_expr="$ext_expr -iname $ext"
472
    done
473

            
Bogdan Timofte authored 9 months ago
474
    if [[ ${#SOURCE_PATTERNS[@]} -eq 0 ]]; then
Bogdan Timofte authored 9 months ago
475
        # Default: scan current directory
476
        local start_dot="."
477
        local abs_current
478
        abs_current=$(pwd)
479
        local find_cmd=(find -L "$start_dot" -type f)
480
        # If dest is inside cwd, add exclusion
481
        if [[ -n "$abs_dest" && "$abs_dest" == "$abs_current"* ]]; then
482
            find_cmd+=( ! -path "$abs_dest/*" ! -path "$abs_dest" )
Bogdan Timofte authored 9 months ago
483
        fi
Bogdan Timofte authored 9 months ago
484
        # Add expression
485
        # shellcheck disable=SC2068
486
        "${find_cmd[@]}" \( $ext_expr \) 2>/dev/null || true
Bogdan Timofte authored 9 months ago
487
    else
Bogdan Timofte authored 9 months ago
488
        # Scan each provided source
489
        for src in "${SOURCE_PATTERNS[@]}"; do
490
            if [[ -f "$src" ]]; then
491
                # single file - skip if it's inside dest
492
                local abs_file
493
                abs_file=$(cd "$(dirname "$src")" 2>/dev/null && pwd)/$(basename "$src")
494
                if [[ -n "$abs_dest" && "$abs_file" == "$abs_dest"* ]]; then
495
                    continue
496
                fi
497
                echo "$abs_file"
498
            elif [[ -d "$src" ]]; then
499
                local abs_src
500
                abs_src=$(cd "$src" 2>/dev/null && pwd)
501
                if [[ -n "$abs_src" ]]; then
502
                    if [[ -n "$abs_dest" && "$abs_dest" == "$abs_src"* ]]; then
503
                        find -L "$abs_src" -type f \( $ext_expr \) ! -path "$abs_dest/*" ! -path "$abs_dest" 2>/dev/null || true
504
                    else
505
                        find -L "$abs_src" -type f \( $ext_expr \) 2>/dev/null || true
Bogdan Timofte authored 9 months ago
506
                    fi
Bogdan Timofte authored 9 months ago
507
                else
508
                    print_color "$YELLOW" "Warning: Could not resolve source directory: $src"
Bogdan Timofte authored 9 months ago
509
                fi
Bogdan Timofte authored 9 months ago
510
            else
511
                print_color "$YELLOW" "Warning: Source path does not exist or is not a file/directory: $src"
Bogdan Timofte authored 9 months ago
512
            fi
513
        done
514
    fi
515
}
516

            
517
# Function to process a single file
518
process_file() {
519
    local file="$1"
520
    local file_size=$(get_file_size "$file")
521
    TOTAL_SIZE=$((TOTAL_SIZE + file_size))
522

            
523
    log_message "Processing: $file" "INFO"
524

            
525
    # Extract date information
526
    local date_info=$(extract_file_date "$file")
527
        local extract_status=$?
528
        if [[ $extract_status -eq 2 ]]; then
529
            if [[ $COLLECT_UNSORTABLE -eq 1 ]]; then
530
                local unsortable_dir="$DESTINATION/unsortable"
531
                mkdir -p "$unsortable_dir"
532
                local unsortable_path="$unsortable_dir/$(basename "$file")"
533
                if [[ $DRY_RUN -eq 1 ]]; then
534
                    print_color "$BLUE" "Would move unsortable: $file -> $unsortable_path"
535
                else
536
                    if mv "$file" "$unsortable_path"; then
537
                        log_message "Unsortable: $file -> $unsortable_path" "SUCCESS"
538
                    else
539
                        log_message "Failed to move unsortable file: $file" "ERROR"
540
                    fi
541
                fi
542
                SKIPPED_FILES=$((SKIPPED_FILES + 1))
543
            else
544
                log_message "Could not extract date from $file - skipping" "WARNING"
545
                SKIPPED_FILES=$((SKIPPED_FILES + 1))
546
            fi
547
            return 1
548
        elif [[ $extract_status -ne 0 ]] || [[ -z "$date_info" ]]; then
549
            log_message "Could not extract date from $file - skipping" "WARNING"
550
            SKIPPED_FILES=$((SKIPPED_FILES + 1))
551
            return 1
552
        fi
553
        local date_str="${date_info%|*}"
554
        local date_source="${date_info#*|}"
555
        log_message "Date: $date_str (from $date_source)" "INFO"
556
        # Generate destination path
557
        local dest_path=$(generate_destination_path "$date_str" "$(basename "$file")" "$DESTINATION")
558
        if [[ $? -ne 0 ]] || [[ -z "$dest_path" ]]; then
559
            log_message "Could not generate destination path for $file" "ERROR"
560
            ERROR_FILES=$((ERROR_FILES + 1))
561
            FATAL_ERROR=1
562
            return 2
563
    fi
564

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

            
571
    local dest_dir=$(dirname "$dest_path")
572

            
573
    if [[ $DRY_RUN -eq 1 ]]; then
574
        if [[ $KEEP_ORIGINALS -eq 1 ]]; then
575
            print_color "$BLUE" "Would copy: $file -> $dest_path"
576
        else
577
            print_color "$BLUE" "Would move: $file -> $dest_path"
578
        fi
579
        PROCESSED_FILES=$((PROCESSED_FILES + 1))
580
        PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
581
        return 0
582
    fi
583

            
584
    # Create destination directory
585
    if ! mkdir -p "$dest_dir"; then
586
        log_message "Could not create directory: $dest_dir" "ERROR"
587
        ERROR_FILES=$((ERROR_FILES + 1))
588
        return 1
589
    fi
590

            
Bogdan Timofte authored 9 months ago
591
    # Copy or move file using safe helpers (filter benign stderr). Let external tools handle renaming conflicts.
Bogdan Timofte authored 9 months ago
592
    if [[ $KEEP_ORIGINALS -eq 1 ]]; then
Bogdan Timofte authored 9 months ago
593
        if safe_cp "$file" "$dest_path"; then
Bogdan Timofte authored 9 months ago
594
            log_message "Copied: $file -> $dest_path" "SUCCESS"
595
            PROCESSED_FILES=$((PROCESSED_FILES + 1))
596
            PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
597
            return 0
598
        else
Bogdan Timofte authored 9 months ago
599
            log_message "Failed to copy: $file -> $dest_path" "ERROR"
Bogdan Timofte authored 9 months ago
600
            ERROR_FILES=$((ERROR_FILES + 1))
601
            return 1
602
        fi
603
    else
Bogdan Timofte authored 9 months ago
604
        if safe_mv "$file" "$dest_path"; then
Bogdan Timofte authored 9 months ago
605
            log_message "Moved: $file -> $dest_path" "SUCCESS"
606
            PROCESSED_FILES=$((PROCESSED_FILES + 1))
607
            PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
608
            return 0
609
        else
Bogdan Timofte authored 9 months ago
610
            log_message "Failed to move: $file -> $dest_path" "ERROR"
Bogdan Timofte authored 9 months ago
611
            ERROR_FILES=$((ERROR_FILES + 1))
612
            return 1
613
        fi
614
    fi
615
}
616

            
617
# Function to display final report
618
show_report() {
619
    local end_time=$(date +%s)
620
    local elapsed_time=$((end_time - START_TIME))
621
    local hours=$((elapsed_time / 3600))
622
    local minutes=$(((elapsed_time % 3600) / 60))
623
    local seconds=$((elapsed_time % 60))
624

            
625
    echo ""
626
    print_color "$GREEN" "=========================================="
627
    print_color "$GREEN" "           PROCESSING REPORT"
628
    print_color "$GREEN" "=========================================="
629
    echo ""
630

            
631
    echo "Files Summary:"
632
    echo "  Total files found:     $TOTAL_FILES"
633
    echo "  Successfully processed: $PROCESSED_FILES"
634
    echo "  Skipped (no date):     $SKIPPED_FILES"
635
    echo "  Errors:                $ERROR_FILES"
636
    echo ""
637

            
638
    echo "Size Summary:"
639
    echo "  Total size found:      $(format_size $TOTAL_SIZE)"
640
    echo "  Successfully processed: $(format_size $PROCESSED_SIZE)"
641
    echo ""
642

            
643
    echo "Time Summary:"
644
    printf "  Time elapsed:          %02d:%02d:%02d\n" $hours $minutes $seconds
645

            
646
    if [[ $elapsed_time -gt 0 && $PROCESSED_FILES -gt 0 ]]; then
647
        local files_per_second=$((PROCESSED_FILES / elapsed_time))
648
        local mb_per_second=$((PROCESSED_SIZE / elapsed_time / 1048576))
649
        echo "  Speed:                 $files_per_second files/sec, ${mb_per_second}MB/sec"
650
    fi
651

            
652
    echo ""
653

            
654
    if [[ $DRY_RUN -eq 1 ]]; then
655
        print_color "$YELLOW" "DRY RUN MODE - No files were actually moved/copied"
656
    elif [[ $KEEP_ORIGINALS -eq 1 ]]; then
657
        print_color "$BLUE" "COPY MODE - Original files were preserved"
658
    else
659
        print_color "$GREEN" "MOVE MODE - Files were moved to destination"
660
    fi
661

            
662
    echo ""
663
    print_color "$GREEN" "=========================================="
664
}
665

            
666
# Parse command line arguments
667
while [[ $# -gt 0 ]]; do
668
    case $1 in
669
        -o|--organization)
670
            ORGANIZATION="$2"
Bogdan Timofte authored 8 months ago
671
            # Accept new patterns: ym, ymd as well as single-letter ones
672
            if [[ ! "$ORGANIZATION" =~ ^(y|m|d|h|ym|ymd)$ ]]; then
Bogdan Timofte authored 9 months ago
673
                print_color "$RED" "Error: Invalid organization pattern. Must be one of: y, m, d, h, ym, ymd"
Bogdan Timofte authored 9 months ago
674
                exit 1
675
            fi
676
            shift 2
677
            ;;
Bogdan Timofte authored 9 months ago
678

            
679
        -F|--filename-mode)
680
            FILENAME_MODE="$2"
681
            if [[ ! "$FILENAME_MODE" =~ ^(auto|full|orig)$ ]]; then
682
                print_color "$RED" "Error: Invalid filename mode. Must be one of: auto, full, orig"
683
                exit 1
684
            fi
685
            shift 2
Bogdan Timofte authored 9 months ago
686
            ;;
Bogdan Timofte authored 9 months ago
687
    # (conflict resolution option removed; let exiftool/filesystem handle naming conflicts)
Bogdan Timofte authored 9 months ago
688
        --collect-unsortable)
689
            COLLECT_UNSORTABLE=1
690
            shift
691
            ;;
Bogdan Timofte authored 8 months ago
692
        --keep-empty-dirs)
693
            CLEANUP_EMPTY_DIRS=0
694
            shift
695
            ;;
Bogdan Timofte authored 9 months ago
696
        -s|--source)
697
            SOURCE_PATTERNS+=("$2")
698
            shift 2
699
            ;;
700
        -d|--destination)
701
            DESTINATION="$2"
702
            shift 2
703
            ;;
704
        -k|--keep-originals)
705
            KEEP_ORIGINALS=1
706
            shift
707
            ;;
708
        --dry-run)
709
            DRY_RUN=1
710
            shift
711
            ;;
712
        -v|--verbose)
713
            VERBOSE=1
714
            shift
715
            ;;
716
        -h|--help)
717
            show_help
718
            exit 0
719
            ;;
720
        --version)
721
            show_version
722
            exit 0
723
            ;;
724
        *)
725
            print_color "$RED" "Error: Unknown option: $1"
726
            echo "Use -h or --help for usage information."
727
            exit 1
728
            ;;
729
    esac
730
done
731

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

            
734
# Set default destination: if user didn't provide -d and a source was given, use first source's directory + /sorted
Bogdan Timofte authored 9 months ago
735
if [[ -z "$DESTINATION" ]]; then
Bogdan Timofte authored 9 months ago
736
    if [[ ${#SOURCE_PATTERNS[@]} -gt 1 ]]; then
737
        print_color "$RED" "Error: Multiple sources specified - destination (-d|--destination) is required when using multiple sources."
738
        echo "Use -h for help."
739
        exit 1
740
    fi
741

            
742
    if [[ ${#SOURCE_PATTERNS[@]} -gt 0 ]]; then
743
        # If exactly one source provided, place 'sorted' in the source's mount root
744
        first_source="${SOURCE_PATTERNS[0]}"
745
        if [[ -e "$first_source" ]]; then
746
            mount_root=$(get_mountpoint "$first_source")
747
            if [[ -n "$mount_root" ]]; then
748
                DESTINATION="$mount_root/sorted"
749
            else
750
                # Fallback to dirname of source
751
                DESTINATION="$(dirname "$first_source")/sorted"
752
            fi
753
        else
754
            # Source doesn't exist; fallback to ./sorted but warn
755
            print_color "$YELLOW" "Warning: first source does not exist; defaulting destination to ./sorted"
756
            DESTINATION="./sorted"
757
        fi
758
    else
759
        DESTINATION="./sorted"
760
    fi
Bogdan Timofte authored 9 months ago
761
fi
762

            
Bogdan Timofte authored 9 months ago
763
# If no source specified, default to current directory. Refuse to run when cwd is unsafe.
764
if [[ ${#SOURCE_PATTERNS[@]} -eq 0 ]]; then
765
    cwd=$(pwd)
766
    # Resolve home and root paths
767
    home_dir="$HOME"
768
    case "$cwd" in
769
        "$home_dir"|"/"|"/etc"|"/var"|"/bin"|"/sbin"|"/dev"|"/proc"|"/sys"|"/run"|"/usr"|"/lib"|"/lib64"|"/boot"|"/snap")
770
            print_color "$RED" "Refusing to run with default source in unsafe directory: $cwd"
771
            print_color "$RED" "Please specify $cwd as source explicitly with -s or run from a safer directory."
772
            exit 1
773
            ;;
774
        *)
775
            SOURCE_PATTERNS+=("$cwd")
776
            ;;
777
    esac
Bogdan Timofte authored 9 months ago
778
fi
779

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

            
783
# Display configuration
784
print_color "$GREEN" "$SCRIPT_NAME v$VERSION"
785
echo ""
786
echo "Configuration:"
787
echo "  Organization pattern: $ORGANIZATION"
788
echo "  Destination:         $DESTINATION"
789
echo "  Keep originals:      $([ $KEEP_ORIGINALS -eq 1 ] && echo "Yes" || echo "No")"
790
echo "  Dry run:             $([ $DRY_RUN -eq 1 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored 8 months ago
791
echo "  Keep empty dirs:     $([ $CLEANUP_EMPTY_DIRS -eq 0 ] && echo "Yes" || echo "No")"
Bogdan Timofte authored 9 months ago
792
echo "  Verbose:             $([ $VERBOSE -eq 1 ] && echo "Yes" || echo "No")"
793

            
794
if [[ ${#SOURCE_PATTERNS[@]} -gt 0 ]]; then
795
    echo "  Source patterns:"
796
    for pattern in "${SOURCE_PATTERNS[@]}"; do
797
        echo "    - $pattern"
798
    done
799
else
800
    echo "  Source patterns:     All media files in current directory"
801
fi
802

            
803
echo ""
804

            
805
# Check dependencies
806
check_dependencies
807

            
808
# Create destination directory if it doesn't exist (unless dry run)
809
if [[ $DRY_RUN -eq 0 ]]; then
810
    if ! mkdir -p "$DESTINATION"; then
811
        print_color "$RED" "Error: Cannot create destination directory: $DESTINATION"
812
        exit 1
813
    fi
814
fi
815

            
816
# Find all source files
817

            
818
print_color "$BLUE" "Scanning for media files..."
819
files=()
820
while IFS= read -r file; do
821
    files+=("$file")
822
done < <(find_source_files)
823
TOTAL_FILES=${#files[@]}
824

            
825
if [[ $TOTAL_FILES -eq 0 ]]; then
826
    print_color "$YELLOW" "No media files found matching the specified patterns."
827
    exit 0
828
fi
829

            
830
print_color "$BLUE" "Found $TOTAL_FILES media files to process"
831
echo ""
832

            
833
# Process each file
834

            
835
FATAL_ERROR=0
836
for file in "${files[@]}"; do
837
    if [[ -f "$file" ]]; then
838
        process_file "$file"
839
        if [[ $FATAL_ERROR -eq 1 ]]; then
840
            print_color "$RED" "Fatal error encountered. Stopping further processing."
841
            break
842
        fi
843
    fi
844
done
845

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

            
Bogdan Timofte authored 9 months ago
854
# Show final report
855
show_report
856

            
857
# Exit with appropriate code
858
if [[ $ERROR_FILES -gt 0 ]]; then
859
    exit 1
860
else
861
    exit 0
862
fi