#!/bin/bash # Standalone Media Importer # Version: 1.0 # A comprehensive media file organizer that sorts photos and videos by date # with various organization patterns and timezone handling VERSION="1.0" SCRIPT_NAME="Standalone Media Importer" # Default values # Default organization: 'ymd' (single folder per day yyyy-mm-dd). Override with -o/--organization. ORGANIZATION="ymd" FILENAME_MODE="full" # options: auto, full, orig COLLECT_UNSORTABLE=0 SOURCE_PATTERNS=() DESTINATION="" KEEP_ORIGINALS=0 DRY_RUN=0 VERBOSE=0 # Counters and statistics TOTAL_FILES=0 PROCESSED_FILES=0 SKIPPED_FILES=0 ERROR_FILES=0 TOTAL_SIZE=0 PROCESSED_SIZE=0 START_TIME=$(date +%s) # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' # BLUE is used for informational/verbose messages. Dark terminal themes may render the # default blue hard to read, so use a brighter cyan by default and allow users to # override via the VERBOSE_COLOR environment variable (must be a terminal escape seq). if [[ -n "${VERBOSE_COLOR:-}" ]]; then BLUE="$VERBOSE_COLOR" else BLUE=$'\033[1;36m' # bright cyan fi NC='\033[0m' # No Color # Function to print colored output print_color() { local color="$1" local message="$2" echo -e "${color}${message}${NC}" } # Function to log messages with timestamp log_message() { local message="$1" local level="${2:-INFO}" local timestamp=$(date '+%Y-%m-%d %H:%M:%S') case "$level" in "ERROR") print_color "$RED" "[$timestamp] ERROR: $message" >&2 ;; "WARNING") print_color "$YELLOW" "[$timestamp] WARNING: $message" ;; "SUCCESS") print_color "$GREEN" "[$timestamp] SUCCESS: $message" ;; "INFO") if [[ $VERBOSE -eq 1 ]]; then print_color "$BLUE" "[$timestamp] INFO: $message" fi ;; *) echo "[$timestamp] $message" ;; esac } # Function to display help show_help() { cat << EOF $SCRIPT_NAME v$VERSION Usage: $0 [OPTIONS] What it does: Sorts photos and videos into dated folders (by year/month/day/hour) and generates filenames from the file's creation timestamp or preserves the original name. Options: -o, --organization PATTERN y|m|d|h|ym|ymd (default: ymd) -F, --filename-mode MODE auto|full|orig (default: full) -s, --source PATH File or directory to process (repeatable). Default: cwd -d, --destination PATH Destination folder. Required when multiple -s are given. -k, --keep-originals Copy files instead of moving --collect-unsortable Put files without dates into DEST/unsortable --dry-run Show actions without changing files -v, --verbose Verbose output -h, --help Show this help --version Show version Examples: $0 -s /path/to/photos $0 -s /path/to/DCIM -d /mnt/sorted --dry-run Dependencies: exiftool (required). mediainfo and file are optional. EOF } # Central media extensions list (used by find functions) MEDIA_EXTENSIONS=("*.jpg" "*.jpeg" "*.png" "*.tiff" "*.tif" "*.cr2" "*.nef" "*.arw" "*.dng" "*.raw" "*.mp4" "*.mov" "*.avi" "*.mts" "*.m2ts" "*.mkv" "*.wmv" "*.3gp" "*.m4v") # Function to show version show_version() { echo "$SCRIPT_NAME v$VERSION" echo "A comprehensive media file organizer with timezone support" } # Function to check dependencies check_dependencies() { local missing_deps=() # Check for required dependencies if ! command -v exiftool &> /dev/null; then missing_deps+=("exiftool") fi # Check for optional dependencies local optional_missing=() if ! command -v mediainfo &> /dev/null; then optional_missing+=("mediainfo") fi if ! command -v file &> /dev/null; then optional_missing+=("file") fi if [[ ${#missing_deps[@]} -gt 0 ]]; then print_color "$RED" "ERROR: Missing required dependencies:" for dep in "${missing_deps[@]}"; do echo " - $dep" done echo "" echo "Installation instructions:" echo " macOS: brew install exiftool" echo " Ubuntu/Debian: sudo apt-get install libimage-exiftool-perl" echo " CentOS/RHEL: sudo yum install perl-Image-ExifTool" echo " Arch: sudo pacman -S perl-image-exiftool" exit 1 fi if [[ ${#optional_missing[@]} -gt 0 && $VERBOSE -eq 1 ]]; then log_message "Optional dependencies not found (functionality may be limited): ${optional_missing[*]}" "WARNING" fi log_message "All required dependencies found" "SUCCESS" } # Determine filesystem/device ID for a path (portable between Linux and macOS) get_dev() { # Return a device identifier for the supplied path (portable between GNU stat and BSD stat) local path="$1" if [[ -z "$path" ]]; then path="." fi # Prefer GNU stat if available if stat --version >/dev/null 2>&1; then stat -c %d "$path" 2>/dev/null || stat -c %i "$path" 2>/dev/null || echo "" else # BSD/macOS stat stat -f %d "$path" 2>/dev/null || stat -f %i "$path" 2>/dev/null || echo "" fi } # Determine the mount root for a path by walking up until device changes get_mountpoint() { local path="$1" if [[ -z "$path" ]]; then path="."; fi # Resolve to absolute local abs abs=$(cd "$path" 2>/dev/null && pwd) || abs="$path" # Walk up until device differs or we reach / local parent="$abs" local root_dev root_dev=$(get_dev "$parent") while [[ "$parent" != "/" ]]; do local next_parent next_parent=$(dirname "$parent") local next_dev next_dev=$(get_dev "$next_parent") if [[ "$next_dev" != "$root_dev" ]]; then break fi parent="$next_parent" done echo "$parent" } # Function to get file size in bytes (portable between Linux and macOS) get_file_size() { local file="$1" if [[ -f "$file" ]]; then # Try GNU stat if stat -c%s "$file" >/dev/null 2>&1; then stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null elif stat -f%z "$file" >/dev/null 2>&1; then stat -f%z "$file" 2>/dev/null else # Fallback to ls ls -ln "$file" | awk '{print $5}' fi else echo "0" fi } # (Removed checksum/prefix/conflict helper functions to revert to pre-conflict-resolution behavior) # Safe move/copy helpers: filter out benign "set flags (was: ...): Operation not supported" # which appears when moving files onto filesystems that don't support BSD file flags # (macOS mv may try to preserve flags and print this warning while still succeeding). safe_mv() { local src="$1" dst="$2" # Redirect stderr through a filter that removes the known benign message mv "$src" "$dst" 2> >(grep -v "set flags (was:" >&2) return $? } # Use simple safe_mv/safe_cp for moving/copying files. Removed atomic installer to let exiftool or filesystem handle renames. safe_cp() { local src="$1" dst="$2" cp "$src" "$dst" 2> >(grep -v "set flags (was:" >&2) return $? } # Function to format file size format_size() { local size=$1 if (( size < 1024 )); then echo "${size}B" elif (( size < 1048576 )); then echo "$(( size / 1024 ))KB" elif (( size < 1073741824 )); then echo "$(( size / 1048576 ))MB" else echo "$(( size / 1073741824 ))GB" fi } # Function to extract date from file extract_file_date() { local file="$1" local create_date="" local date_source="" local exif_found=0 # Try to get creation date from EXIF data local exif_output=$(exiftool -G1 -s -CreateDate -DateTimeOriginal -DateTime "$file" 2>/dev/null) if [[ -n "$exif_output" ]]; then # Parse the exiftool output to find the best date while IFS= read -r line; do if [[ "$line" =~ ^\[([^\]]+)\][[:space:]]*([^:]+)[[:space:]]*:[[:space:]]*(.+)$ ]]; then local group="${BASH_REMATCH[1]}" local tag="${BASH_REMATCH[2]}" local value="${BASH_REMATCH[3]}" # Trim spaces from tag name tag=$(echo "$tag" | sed 's/[[:space:]]*$//') # Prefer DateTimeOriginal, then CreateDate, then DateTime if [[ "$tag" == "DateTimeOriginal" && -z "$create_date" ]]; then create_date="$value" date_source="$group:$tag" exif_found=1 elif [[ "$tag" == "CreateDate" && "$date_source" != *"DateTimeOriginal"* ]]; then create_date="$value" date_source="$group:$tag" exif_found=1 elif [[ "$tag" == "DateTime" && -z "$create_date" ]]; then create_date="$value" date_source="$group:$tag" exif_found=1 fi fi done <<< "$exif_output" fi # If no EXIF date found, try mediainfo for video files if [[ -z "$create_date" ]] && command -v mediainfo &> /dev/null; then local media_date=$(mediainfo --Inform="General;%Recorded_Date%" "$file" 2>/dev/null) if [[ -n "$media_date" && "$media_date" != "0000-00-00 00:00:00" ]]; then create_date="$media_date" date_source="MediaInfo:Recorded_Date" fi fi # If no EXIF or mediainfo date found, return failure if [[ -z "$create_date" ]]; then return 2 # No date metadata found fi # Convert EXIF format (YYYY:MM:DD HH:MM:SS) to standard format # Always output as yyyy-mm-dd hh:mm:ss (pad single digits) 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 year="${BASH_REMATCH[1]}" month=$(printf "%02d" "$((10#${BASH_REMATCH[2]}))") day=$(printf "%02d" "$((10#${BASH_REMATCH[3]}))") hour=$(printf "%02d" "$((10#${BASH_REMATCH[4]}))") minute=$(printf "%02d" "$((10#${BASH_REMATCH[5]}))") second=$(printf "%02d" "$((10#${BASH_REMATCH[6]}))") create_date="$year-$month-$day $hour:$minute:$second" else # Try to convert yyyy-mm-dd hh:mm:ss (already correct) if [[ "$create_date" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})[[:space:]]*([0-9]{2}):([0-9]{2}):([0-9]{2})$ ]]; then # Already correct : else print_color "$RED" "Error: Cannot parse date '$create_date'" >&2 return 2 fi fi # For QuickTime files, the CreateDate is in UTC and needs conversion to local time if [[ "$date_source" == *"QuickTime"* ]]; then # Convert UTC time to local time if [[ "$OSTYPE" == "darwin"* ]]; then # On macOS, use TZ=UTC to interpret the input time as UTC local utc_timestamp=$(TZ=UTC date -j -f "%Y-%m-%d %H:%M:%S" "$create_date" "+%s" 2>/dev/null) if [[ -n "$utc_timestamp" ]]; then create_date=$(date -j -r "$utc_timestamp" "+%Y-%m-%d %H:%M:%S" 2>/dev/null) date_source="$date_source (converted from UTC)" fi else local utc_timestamp=$(date -d "$create_date UTC" "+%s" 2>/dev/null) if [[ -n "$utc_timestamp" ]]; then create_date=$(date -d "@$utc_timestamp" "+%Y-%m-%d %H:%M:%S" 2>/dev/null) date_source="$date_source (converted from UTC)" fi fi fi echo "$create_date|$date_source" return 0 } # Function to generate destination path based on organization pattern generate_destination_path() { local date_str="$1" local original_filename="$2" local base_destination="$3" # Extract date components - handle both GNU and BSD date local year month day hour minute second if [[ "$OSTYPE" == "darwin"* ]]; then # macOS (BSD date) - use more robust regex (allow single-digit month/day, tolerate extra spaces) 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 year="${BASH_REMATCH[1]}" month=$(printf "%02d" "$((10#${BASH_REMATCH[2]}))") day=$(printf "%02d" "$((10#${BASH_REMATCH[3]}))") hour=$(printf "%02d" "$((10#${BASH_REMATCH[4]}))") minute=$(printf "%02d" "$((10#${BASH_REMATCH[5]}))") second=$(printf "%02d" "$((10#${BASH_REMATCH[6]}))") else return 1 fi else # Linux (GNU date) year=$(date -d "$date_str" "+%Y" 2>/dev/null) month=$(date -d "$date_str" "+%m" 2>/dev/null) day=$(date -d "$date_str" "+%d" 2>/dev/null) hour=$(date -d "$date_str" "+%H" 2>/dev/null) minute=$(date -d "$date_str" "+%M" 2>/dev/null) second=$(date -d "$date_str" "+%S" 2>/dev/null) fi if [[ -z "$year" || -z "$month" || -z "$day" ]]; then return 1 fi # Get file extension local extension="${original_filename##*.}" local lowercase_ext=$(echo "$extension" | tr '[:upper:]' '[:lower:]') # Generate path and filename based on organization pattern local dir_path="" local filename="" # If no organization specified, use flat destination (base) and choose filename per mode if [[ -z "$ORGANIZATION" ]]; then dir_path="$base_destination" if [[ "$FILENAME_MODE" == "orig" ]]; then filename="$original_filename" else # full or auto both map to full date for flat layout filename="${year}-${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}" fi echo "$dir_path/$filename" return 0 fi case "$ORGANIZATION" in "y") dir_path="$base_destination/$year" filename="${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}" ;; "m") dir_path="$base_destination/$year/$month" filename="${day}_${hour}-${minute}-${second}.${lowercase_ext}" ;; "d") dir_path="$base_destination/$year/$month/$day" filename="${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}" ;; "h") dir_path="$base_destination/$year/$month/$day/$hour" filename="${minute}-${second}.${lowercase_ext}" ;; "ym") # Single folder per month named yyyy-mm; filename includes day and time dir_path="$base_destination/${year}-${month}" filename="${day}_${hour}-${minute}-${second}.${lowercase_ext}" ;; "ymd"|"ymd-") # Single folder per day named yyyy-mm-dd; filename is time dir_path="$base_destination/${year}-${month}-${day}" filename="${hour}-${minute}-${second}.${lowercase_ext}" ;; *) log_message "Invalid organization pattern: $ORGANIZATION" "ERROR" return 1 ;; esac # Apply filename mode overrides case "$FILENAME_MODE" in orig) filename="$original_filename" ;; full) filename="${year}-${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}" ;; auto) # keep the auto-generated filename from the organization case ;; *) # fallback to full filename="${year}-${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}" ;; esac echo "$dir_path/$filename" return 0 } # Function to find files matching patterns find_source_files() { # Emit absolute file paths for all media files under sources, excluding DESTINATION when it is inside a source local abs_dest="" if [[ -n "$DESTINATION" ]]; then abs_dest=$(cd "$DESTINATION" 2>/dev/null && pwd) || abs_dest="$DESTINATION" fi # Build -iname expression for find local ext_expr="" for ext in "${MEDIA_EXTENSIONS[@]}"; do if [[ -n "$ext_expr" ]]; then ext_expr="$ext_expr -o" fi ext_expr="$ext_expr -iname $ext" done if [[ ${#SOURCE_PATTERNS[@]} -eq 0 ]]; then # Default: scan current directory local start_dot="." local abs_current abs_current=$(pwd) local find_cmd=(find -L "$start_dot" -type f) # If dest is inside cwd, add exclusion if [[ -n "$abs_dest" && "$abs_dest" == "$abs_current"* ]]; then find_cmd+=( ! -path "$abs_dest/*" ! -path "$abs_dest" ) fi # Add expression # shellcheck disable=SC2068 "${find_cmd[@]}" \( $ext_expr \) 2>/dev/null || true else # Scan each provided source for src in "${SOURCE_PATTERNS[@]}"; do if [[ -f "$src" ]]; then # single file - skip if it's inside dest local abs_file abs_file=$(cd "$(dirname "$src")" 2>/dev/null && pwd)/$(basename "$src") if [[ -n "$abs_dest" && "$abs_file" == "$abs_dest"* ]]; then continue fi echo "$abs_file" elif [[ -d "$src" ]]; then local abs_src abs_src=$(cd "$src" 2>/dev/null && pwd) if [[ -n "$abs_src" ]]; then if [[ -n "$abs_dest" && "$abs_dest" == "$abs_src"* ]]; then find -L "$abs_src" -type f \( $ext_expr \) ! -path "$abs_dest/*" ! -path "$abs_dest" 2>/dev/null || true else find -L "$abs_src" -type f \( $ext_expr \) 2>/dev/null || true fi else print_color "$YELLOW" "Warning: Could not resolve source directory: $src" fi else print_color "$YELLOW" "Warning: Source path does not exist or is not a file/directory: $src" fi done fi } # Function to process a single file process_file() { local file="$1" local file_size=$(get_file_size "$file") TOTAL_SIZE=$((TOTAL_SIZE + file_size)) log_message "Processing: $file" "INFO" # Extract date information local date_info=$(extract_file_date "$file") local extract_status=$? if [[ $extract_status -eq 2 ]]; then if [[ $COLLECT_UNSORTABLE -eq 1 ]]; then local unsortable_dir="$DESTINATION/unsortable" mkdir -p "$unsortable_dir" local unsortable_path="$unsortable_dir/$(basename "$file")" if [[ $DRY_RUN -eq 1 ]]; then print_color "$BLUE" "Would move unsortable: $file -> $unsortable_path" else if mv "$file" "$unsortable_path"; then log_message "Unsortable: $file -> $unsortable_path" "SUCCESS" else log_message "Failed to move unsortable file: $file" "ERROR" fi fi SKIPPED_FILES=$((SKIPPED_FILES + 1)) else log_message "Could not extract date from $file - skipping" "WARNING" SKIPPED_FILES=$((SKIPPED_FILES + 1)) fi return 1 elif [[ $extract_status -ne 0 ]] || [[ -z "$date_info" ]]; then log_message "Could not extract date from $file - skipping" "WARNING" SKIPPED_FILES=$((SKIPPED_FILES + 1)) return 1 fi local date_str="${date_info%|*}" local date_source="${date_info#*|}" log_message "Date: $date_str (from $date_source)" "INFO" # Generate destination path local dest_path=$(generate_destination_path "$date_str" "$(basename "$file")" "$DESTINATION") if [[ $? -ne 0 ]] || [[ -z "$dest_path" ]]; then log_message "Could not generate destination path for $file" "ERROR" ERROR_FILES=$((ERROR_FILES + 1)) FATAL_ERROR=1 return 2 fi # If destination exists, do not attempt complex conflict resolution here. # Let external tools (exiftool) or filesystem semantics handle renames/overwrites. if [[ -f "$dest_path" ]]; then log_message "Destination already exists: $dest_path - proceeding to move/copy and letting external tools handle conflicts" "WARNING" fi local dest_dir=$(dirname "$dest_path") if [[ $DRY_RUN -eq 1 ]]; then if [[ $KEEP_ORIGINALS -eq 1 ]]; then print_color "$BLUE" "Would copy: $file -> $dest_path" else print_color "$BLUE" "Would move: $file -> $dest_path" fi PROCESSED_FILES=$((PROCESSED_FILES + 1)) PROCESSED_SIZE=$((PROCESSED_SIZE + file_size)) return 0 fi # Create destination directory if ! mkdir -p "$dest_dir"; then log_message "Could not create directory: $dest_dir" "ERROR" ERROR_FILES=$((ERROR_FILES + 1)) return 1 fi # Copy or move file using safe helpers (filter benign stderr). Let external tools handle renaming conflicts. if [[ $KEEP_ORIGINALS -eq 1 ]]; then if safe_cp "$file" "$dest_path"; then log_message "Copied: $file -> $dest_path" "SUCCESS" PROCESSED_FILES=$((PROCESSED_FILES + 1)) PROCESSED_SIZE=$((PROCESSED_SIZE + file_size)) return 0 else log_message "Failed to copy: $file -> $dest_path" "ERROR" ERROR_FILES=$((ERROR_FILES + 1)) return 1 fi else if safe_mv "$file" "$dest_path"; then log_message "Moved: $file -> $dest_path" "SUCCESS" PROCESSED_FILES=$((PROCESSED_FILES + 1)) PROCESSED_SIZE=$((PROCESSED_SIZE + file_size)) return 0 else log_message "Failed to move: $file -> $dest_path" "ERROR" ERROR_FILES=$((ERROR_FILES + 1)) return 1 fi fi } # Function to display final report show_report() { local end_time=$(date +%s) local elapsed_time=$((end_time - START_TIME)) local hours=$((elapsed_time / 3600)) local minutes=$(((elapsed_time % 3600) / 60)) local seconds=$((elapsed_time % 60)) echo "" print_color "$GREEN" "==========================================" print_color "$GREEN" " PROCESSING REPORT" print_color "$GREEN" "==========================================" echo "" echo "Files Summary:" echo " Total files found: $TOTAL_FILES" echo " Successfully processed: $PROCESSED_FILES" echo " Skipped (no date): $SKIPPED_FILES" echo " Errors: $ERROR_FILES" echo "" echo "Size Summary:" echo " Total size found: $(format_size $TOTAL_SIZE)" echo " Successfully processed: $(format_size $PROCESSED_SIZE)" echo "" echo "Time Summary:" printf " Time elapsed: %02d:%02d:%02d\n" $hours $minutes $seconds if [[ $elapsed_time -gt 0 && $PROCESSED_FILES -gt 0 ]]; then local files_per_second=$((PROCESSED_FILES / elapsed_time)) local mb_per_second=$((PROCESSED_SIZE / elapsed_time / 1048576)) echo " Speed: $files_per_second files/sec, ${mb_per_second}MB/sec" fi echo "" if [[ $DRY_RUN -eq 1 ]]; then print_color "$YELLOW" "DRY RUN MODE - No files were actually moved/copied" elif [[ $KEEP_ORIGINALS -eq 1 ]]; then print_color "$BLUE" "COPY MODE - Original files were preserved" else print_color "$GREEN" "MOVE MODE - Files were moved to destination" fi echo "" print_color "$GREEN" "==========================================" } # Parse command line arguments while [[ $# -gt 0 ]]; do case $1 in -o|--organization) ORGANIZATION="$2" # Accept new patterns: ym, ymd and ymd- as well as single-letter ones if [[ ! "$ORGANIZATION" =~ ^(y|m|d|h|ym|ymd|ymd-)$ ]]; then print_color "$RED" "Error: Invalid organization pattern. Must be one of: y, m, d, h, ym, ymd" exit 1 fi shift 2 ;; -F|--filename-mode) FILENAME_MODE="$2" if [[ ! "$FILENAME_MODE" =~ ^(auto|full|orig)$ ]]; then print_color "$RED" "Error: Invalid filename mode. Must be one of: auto, full, orig" exit 1 fi shift 2 ;; # (conflict resolution option removed; let exiftool/filesystem handle naming conflicts) --collect-unsortable) COLLECT_UNSORTABLE=1 shift ;; -s|--source) SOURCE_PATTERNS+=("$2") shift 2 ;; -d|--destination) DESTINATION="$2" shift 2 ;; -k|--keep-originals) KEEP_ORIGINALS=1 shift ;; --dry-run) DRY_RUN=1 shift ;; -v|--verbose) VERBOSE=1 shift ;; -h|--help) show_help exit 0 ;; --version) show_version exit 0 ;; *) print_color "$RED" "Error: Unknown option: $1" echo "Use -h or --help for usage information." exit 1 ;; esac done # If no organization is provided, leave ORGANIZATION empty and filename mode will decide naming # Set default destination: if user didn't provide -d and a source was given, use first source's directory + /sorted if [[ -z "$DESTINATION" ]]; then if [[ ${#SOURCE_PATTERNS[@]} -gt 1 ]]; then print_color "$RED" "Error: Multiple sources specified - destination (-d|--destination) is required when using multiple sources." echo "Use -h for help." exit 1 fi if [[ ${#SOURCE_PATTERNS[@]} -gt 0 ]]; then # If exactly one source provided, place 'sorted' in the source's mount root first_source="${SOURCE_PATTERNS[0]}" if [[ -e "$first_source" ]]; then mount_root=$(get_mountpoint "$first_source") if [[ -n "$mount_root" ]]; then DESTINATION="$mount_root/sorted" else # Fallback to dirname of source DESTINATION="$(dirname "$first_source")/sorted" fi else # Source doesn't exist; fallback to ./sorted but warn print_color "$YELLOW" "Warning: first source does not exist; defaulting destination to ./sorted" DESTINATION="./sorted" fi else DESTINATION="./sorted" fi fi # If no source specified, default to current directory. Refuse to run when cwd is unsafe. if [[ ${#SOURCE_PATTERNS[@]} -eq 0 ]]; then cwd=$(pwd) # Resolve home and root paths home_dir="$HOME" case "$cwd" in "$home_dir"|"/"|"/etc"|"/var"|"/bin"|"/sbin"|"/dev"|"/proc"|"/sys"|"/run"|"/usr"|"/lib"|"/lib64"|"/boot"|"/snap") print_color "$RED" "Refusing to run with default source in unsafe directory: $cwd" print_color "$RED" "Please specify $cwd as source explicitly with -s or run from a safer directory." exit 1 ;; *) SOURCE_PATTERNS+=("$cwd") ;; esac fi # Convert destination to absolute path DESTINATION=$(cd "$(dirname "$DESTINATION")" 2>/dev/null && pwd)/$(basename "$DESTINATION") || DESTINATION=$(realpath "$DESTINATION" 2>/dev/null) || DESTINATION="$DESTINATION" # Display configuration print_color "$GREEN" "$SCRIPT_NAME v$VERSION" echo "" echo "Configuration:" echo " Organization pattern: $ORGANIZATION" echo " Destination: $DESTINATION" echo " Keep originals: $([ $KEEP_ORIGINALS -eq 1 ] && echo "Yes" || echo "No")" echo " Dry run: $([ $DRY_RUN -eq 1 ] && echo "Yes" || echo "No")" echo " Verbose: $([ $VERBOSE -eq 1 ] && echo "Yes" || echo "No")" if [[ ${#SOURCE_PATTERNS[@]} -gt 0 ]]; then echo " Source patterns:" for pattern in "${SOURCE_PATTERNS[@]}"; do echo " - $pattern" done else echo " Source patterns: All media files in current directory" fi echo "" # Check dependencies check_dependencies # Create destination directory if it doesn't exist (unless dry run) if [[ $DRY_RUN -eq 0 ]]; then if ! mkdir -p "$DESTINATION"; then print_color "$RED" "Error: Cannot create destination directory: $DESTINATION" exit 1 fi fi # Find all source files print_color "$BLUE" "Scanning for media files..." files=() while IFS= read -r file; do files+=("$file") done < <(find_source_files) TOTAL_FILES=${#files[@]} if [[ $TOTAL_FILES -eq 0 ]]; then print_color "$YELLOW" "No media files found matching the specified patterns." exit 0 fi print_color "$BLUE" "Found $TOTAL_FILES media files to process" echo "" # Process each file FATAL_ERROR=0 for file in "${files[@]}"; do if [[ -f "$file" ]]; then process_file "$file" if [[ $FATAL_ERROR -eq 1 ]]; then print_color "$RED" "Fatal error encountered. Stopping further processing." break fi fi done # Show final report show_report # Exit with appropriate code if [[ $ERROR_FILES -gt 0 ]]; then exit 1 else exit 0 fi