MediaImporter / media-importer.sh
Newer Older
b2ed8c6 9 months ago History
847 lines | 29.817kb
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
21

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

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

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

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

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

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

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

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

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

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

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

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

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

            
122
# Function to check dependencies
123
check_dependencies() {
124
    local missing_deps=()
125

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

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

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

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

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

            
159
    log_message "All required dependencies found" "SUCCESS"
160
}
161

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

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

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

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

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

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

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

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

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

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

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

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

            
342
    echo "$create_date|$date_source"
343
    return 0
344
}
345

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

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

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

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

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

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

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

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

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

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

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

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

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

            
521
    log_message "Processing: $file" "INFO"
522

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

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

            
569
    local dest_dir=$(dirname "$dest_path")
570

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

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

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

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

            
623
    echo ""
624
    print_color "$GREEN" "=========================================="
625
    print_color "$GREEN" "           PROCESSING REPORT"
626
    print_color "$GREEN" "=========================================="
627
    echo ""
628

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

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

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

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

            
650
    echo ""
651

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

            
660
    echo ""
661
    print_color "$GREEN" "=========================================="
662
}
663

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

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

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

            
728
# 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
729
if [[ -z "$DESTINATION" ]]; then
Bogdan Timofte authored 9 months ago
730
    if [[ ${#SOURCE_PATTERNS[@]} -gt 1 ]]; then
731
        print_color "$RED" "Error: Multiple sources specified - destination (-d|--destination) is required when using multiple sources."
732
        echo "Use -h for help."
733
        exit 1
734
    fi
735

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

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

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

            
777
# Display configuration
778
print_color "$GREEN" "$SCRIPT_NAME v$VERSION"
779
echo ""
780
echo "Configuration:"
781
echo "  Organization pattern: $ORGANIZATION"
782
echo "  Destination:         $DESTINATION"
783
echo "  Keep originals:      $([ $KEEP_ORIGINALS -eq 1 ] && echo "Yes" || echo "No")"
784
echo "  Dry run:             $([ $DRY_RUN -eq 1 ] && echo "Yes" || echo "No")"
785
echo "  Verbose:             $([ $VERBOSE -eq 1 ] && echo "Yes" || echo "No")"
786

            
787
if [[ ${#SOURCE_PATTERNS[@]} -gt 0 ]]; then
788
    echo "  Source patterns:"
789
    for pattern in "${SOURCE_PATTERNS[@]}"; do
790
        echo "    - $pattern"
791
    done
792
else
793
    echo "  Source patterns:     All media files in current directory"
794
fi
795

            
796
echo ""
797

            
798
# Check dependencies
799
check_dependencies
800

            
801
# Create destination directory if it doesn't exist (unless dry run)
802
if [[ $DRY_RUN -eq 0 ]]; then
803
    if ! mkdir -p "$DESTINATION"; then
804
        print_color "$RED" "Error: Cannot create destination directory: $DESTINATION"
805
        exit 1
806
    fi
807
fi
808

            
809
# Find all source files
810

            
811
print_color "$BLUE" "Scanning for media files..."
812
files=()
813
while IFS= read -r file; do
814
    files+=("$file")
815
done < <(find_source_files)
816
TOTAL_FILES=${#files[@]}
817

            
818
if [[ $TOTAL_FILES -eq 0 ]]; then
819
    print_color "$YELLOW" "No media files found matching the specified patterns."
820
    exit 0
821
fi
822

            
823
print_color "$BLUE" "Found $TOTAL_FILES media files to process"
824
echo ""
825

            
826
# Process each file
827

            
828
FATAL_ERROR=0
829
for file in "${files[@]}"; do
830
    if [[ -f "$file" ]]; then
831
        process_file "$file"
832
        if [[ $FATAL_ERROR -eq 1 ]]; then
833
            print_color "$RED" "Fatal error encountered. Stopping further processing."
834
            break
835
        fi
836
    fi
837
done
838

            
839
# Show final report
840
show_report
841

            
842
# Exit with appropriate code
843
if [[ $ERROR_FILES -gt 0 ]]; then
844
    exit 1
845
else
846
    exit 0
847
fi