#!/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 ORGANIZATION="" FORCE_FULL_DATE=0 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='\033[0;34m' 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] DESCRIPTION: Organizes media files (photos and videos) by date with various naming patterns. if [[ $exif_found -eq 0 ]]; then log_message "Warning: No EXIF date found for $file. Using filesystem modification time." -o, --organization PATTERN Organization pattern: y -> target/yyyy/mm-dd_hh-mm-ss.orig_ext m -> target/yyyy/mm/dd_hh-mm-ss.orig_ext d -> target/yyyy/mm/dd/mm-dd_hh-mm-ss.orig_ext h -> target/yyyy/mm/dd/hh/mm-ss.orig_ext --full-date Force all files to be named with full date (yyyy-mm-dd_hh-mm-ss.ext) in the destination folder, regardless of organization pattern. -s, --source PATTERN Source folder pattern(s) with simple regex support (*^$) Can be specified multiple times Examples: -s "/DCIM/*Video$" -s "/path/to/photos" -s "*.jpg" Default: all subfolders in current directory except destination -d, --destination PATH Destination folder (default: ./sorted) -k, --keep-originals Keep original files (copy instead of move) --dry-run Show what would be done without actually doing it -v, --verbose Enable verbose output -h, --help Show this help message --version Show version information EXAMPLES: # Basic usage - organize all media in current directory $0 # Organize with monthly folders, keep originals $0 -o m -k # Process specific folders with hourly organization # Return date and source (no warnings or debug output) echo "$create_date|$date_source" # Dry run with verbose output $0 --dry-run -v -s "*.mov" -d "/tmp/test" DEPENDENCIES: Required: exiftool Optional: mediainfo, file (for enhanced metadata detection) EOF } # 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" } # Function to get file size in bytes get_file_size() { local file="$1" if [[ -f "$file" ]]; then if command -v stat &> /dev/null; then # Try GNU stat first (Linux) stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null else # Fallback to ls ls -l "$file" | awk '{print $5}' fi else echo "0" fi } # 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 [[ $FORCE_FULL_DATE -eq 1 ]]; then dir_path="$base_destination" filename="${year}-${month}-${day}_${hour}-${minute}-${second}.${lowercase_ext}" else 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}" ;; *) log_message "Invalid organization pattern: $ORGANIZATION" "ERROR" return 1 ;; esac fi echo "$dir_path/$filename" return 0 } # Function to find files matching patterns find_source_files() { local files=() if [[ ${#SOURCE_PATTERNS[@]} -eq 0 ]]; then # Default: find all media files in current directory and subdirectories # Exclude destination directory if it's a subdirectory of current directory local find_cmd="find -L . -type f" # Add exclusion for destination if it's relative to current directory if [[ "$DESTINATION" =~ ^\./.*$ ]] || [[ "$DESTINATION" =~ ^[^/].*$ ]]; then local abs_dest=$(cd "$DESTINATION" 2>/dev/null && pwd) local abs_current=$(pwd) if [[ "$abs_dest" == "$abs_current"* ]]; then find_cmd="$find_cmd ! -path \"$DESTINATION/*\"" fi fi # Add media file extensions local extensions=("*.jpg" "*.jpeg" "*.png" "*.tiff" "*.tif" "*.cr2" "*.nef" "*.arw" "*.dng" "*.raw" "*.mp4" "*.mov" "*.avi" "*.mts" "*.m2ts" "*.mkv" "*.wmv" "*.3gp" "*.m4v") local ext_pattern="" for ext in "${extensions[@]}"; do if [[ -n "$ext_pattern" ]]; then ext_pattern="$ext_pattern -o" fi ext_pattern="$ext_pattern -iname $ext" done eval "$find_cmd \\( $ext_pattern \\)" | while IFS= read -r file; do echo "$file" done else # Use specified patterns for pattern in "${SOURCE_PATTERNS[@]}"; do # Handle different pattern types if [[ -d "$pattern" ]]; then # Directory pattern find -L "$pattern" -type f \( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.tiff" -o -iname "*.tif" -o -iname "*.cr2" -o -iname "*.nef" -o -iname "*.arw" -o -iname "*.dng" -o -iname "*.raw" -o -iname "*.mp4" -o -iname "*.mov" -o -iname "*.avi" -o -iname "*.mts" -o -iname "*.m2ts" -o -iname "*.mkv" -o -iname "*.wmv" -o -iname "*.3gp" -o -iname "*.m4v" \) 2>/dev/null elif [[ "$pattern" == *"*"* ]] || [[ "$pattern" == *"?"* ]]; then # Glob pattern for file in $pattern; do if [[ -f "$file" ]]; then echo "$file" fi done else # Exact file or directory if [[ -f "$pattern" ]]; then echo "$pattern" elif [[ -d "$pattern" ]]; then find -L "$pattern" -type f \( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.tiff" -o -iname "*.tif" -o -iname "*.cr2" -o -iname "*.nef" -o -iname "*.arw" -o -iname "*.dng" -o -iname "*.raw" -o -iname "*.mp4" -o -iname "*.mov" -o -iname "*.avi" -o -iname "*.mts" -o -iname "*.m2ts" -o -iname "*.mkv" -o -iname "*.wmv" -o -iname "*.3gp" -o -iname "*.m4v" \) 2>/dev/null fi 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 # Handle filename conflicts local counter=1 local original_dest_path="$dest_path" while [[ -f "$dest_path" ]]; do local dir_path=$(dirname "$original_dest_path") local filename=$(basename "$original_dest_path") local name_without_ext="${filename%.*}" local ext="${filename##*.}" dest_path="$dir_path/${name_without_ext}_${counter}.${ext}" counter=$((counter + 1)) done 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 if [[ $KEEP_ORIGINALS -eq 1 ]]; then if 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" "ERROR" ERROR_FILES=$((ERROR_FILES + 1)) return 1 fi else if 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" "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" if [[ ! "$ORGANIZATION" =~ ^[ymdh]$ ]]; then print_color "$RED" "Error: Invalid organization pattern. Must be one of: y, m, d, h" exit 1 fi shift 2 ;; --full-date) FORCE_FULL_DATE=1 shift ;; --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 # Set default destination if not specified if [[ -z "$DESTINATION" ]]; then DESTINATION="./sorted" fi # If no organization is provided, default to flat full-date naming if [[ -z "$ORGANIZATION" ]]; then FORCE_FULL_DATE=1 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