1 contributor
#!/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
VERIFY_MODE="size" # options: size, strict, none
DRY_RUN=0
VERBOSE=0
CLEANUP_EMPTY_DIRS=1
# 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
--verify-mode MODE size|strict|none (default: size)
--collect-unsortable Put files without dates into DEST/unsortable
--keep-empty-dirs Keep empty directories after processing
--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 $?
}
verify_copied_file() {
local src="$1"
local dst="$2"
local expected_date="$3"
if [[ ! -f "$dst" ]]; then
log_message "Verified copy missing at destination: $dst" "ERROR"
return 1
fi
local src_size dst_size
src_size=$(get_file_size "$src")
dst_size=$(get_file_size "$dst")
if [[ "$src_size" != "$dst_size" ]]; then
log_message "Size mismatch after copy: $src ($src_size) != $dst ($dst_size)" "ERROR"
return 1
fi
if [[ "$VERIFY_MODE" == "strict" ]]; then
if ! cmp -s "$src" "$dst"; then
log_message "Content mismatch after copy: $src -> $dst" "ERROR"
return 1
fi
elif [[ "$VERIFY_MODE" == "none" ]]; then
return 0
fi
if [[ -n "$expected_date" ]]; then
local destination_date_info
destination_date_info=$(extract_file_date "$dst")
local extract_status=$?
if [[ $extract_status -ne 0 || -z "$destination_date_info" ]]; then
log_message "Destination metadata validation failed: $dst" "ERROR"
return 1
fi
local destination_date="${destination_date_info%|*}"
if [[ "$destination_date" != "$expected_date" ]]; then
log_message "Destination metadata mismatch: expected $expected_date, got $destination_date for $dst" "ERROR"
return 1
fi
fi
return 0
}
remove_source_file() {
local src="$1"
rm -f "$src"
}
copy_with_verification() {
local src="$1"
local dst="$2"
local expected_date="$3"
if ! safe_cp "$src" "$dst"; then
return 1
fi
if ! verify_copied_file "$src" "$dst" "$expected_date"; then
rm -f "$dst"
return 1
fi
return 0
}
verified_move_file() {
local src="$1"
local dst="$2"
local expected_date="$3"
if ! copy_with_verification "$src" "$dst" "$expected_date"; then
return 1
fi
if ! remove_source_file "$src"; then
log_message "Copied and verified destination, but failed to remove source: $src" "ERROR"
return 1
fi
return 0
}
# 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")
# 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 verified_move_file "$file" "$unsortable_path" ""; then
log_message "Unsortable: $file -> $unsortable_path" "SUCCESS"
else
log_message "Failed to move unsortable file after verification: $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 copy_with_verification "$file" "$dest_path" "$date_str"; 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 or verify destination: $file -> $dest_path" "ERROR"
ERROR_FILES=$((ERROR_FILES + 1))
return 1
fi
else
if verified_move_file "$file" "$dest_path" "$date_str"; 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 after copy verification: $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 as well as single-letter ones
if [[ ! "$ORGANIZATION" =~ ^(y|m|d|h|ym|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
;;
--keep-empty-dirs)
CLEANUP_EMPTY_DIRS=0
shift
;;
-s|--source)
SOURCE_PATTERNS+=("$2")
shift 2
;;
-d|--destination)
DESTINATION="$2"
shift 2
;;
-k|--keep-originals)
KEEP_ORIGINALS=1
shift
;;
--verify-mode)
VERIFY_MODE="$2"
if [[ ! "$VERIFY_MODE" =~ ^(size|strict|none)$ ]]; then
print_color "$RED" "Error: Invalid verify mode. Must be one of: size, strict, none"
exit 1
fi
shift 2
;;
--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
# 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
# Set default destination: if user didn't provide -d and a source was given, use first source + /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
first_source="${SOURCE_PATTERNS[0]}"
if [[ -d "$first_source" ]]; then
DESTINATION="$first_source/sorted"
elif [[ -f "$first_source" ]]; then
DESTINATION="$(dirname "$first_source")/sorted"
else
print_color "$YELLOW" "Warning: first source does not exist; defaulting destination to ./sorted"
DESTINATION="./sorted"
fi
else
DESTINATION="./sorted"
fi
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 " Verify mode: $VERIFY_MODE"
echo " Dry run: $([ $DRY_RUN -eq 1 ] && echo "Yes" || echo "No")"
echo " Keep empty dirs: $([ $CLEANUP_EMPTY_DIRS -eq 0 ] && 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
# Clean up empty directories if requested (default behavior)
if [[ $CLEANUP_EMPTY_DIRS -eq 1 && $DRY_RUN -eq 0 ]]; then
print_color "$BLUE" "Cleaning up empty directories..."
# Find and remove empty directories under destination, but don't remove the destination itself
find "$DESTINATION" -type d -empty -not -path "$DESTINATION" -delete 2>/dev/null || true
print_color "$GREEN" "Empty directory cleanup completed"
fi
# Show final report
show_report
# Exit with appropriate code
if [[ $ERROR_FILES -gt 0 ]]; then
exit 1
else
exit 0
fi