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
DATE_SOURCE="auto" # options: auto, exif, filesystem
SYNC_METADATA=0 # when 1, write reconstructed date into destination metadata
QUICKTIME_UTC=1 # when 0, treat QuickTime dates as already local (for cameras that store local time instead of UTC)
UNATTENDED=0 # when 1, never prompt; destination conflicts get numeric suffixes
DRY_RUN=0
VERBOSE=0
CLEANUP_EMPTY_DIRS=1
CLEANUP_MEDIA_SIDECARS=1 # when 1, delete device sidecars (GoPro THM/LRV, Garmin GLV) after successful import
SYNC_GPS=0 # when 1, embed first GPS fix from GPMF stream into destination file EXIF
CONFLICT_APPLY_ALL="" # suffix|skip after an interactive "all similar" choice
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RESOLVED_DESTINATION_PATH=""
RESERVED_DESTINATION_PATHS=()
# 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)
CURRENT_FILE_INDEX=0
# 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)
--date-source SOURCE auto|exif|filesystem (default: auto)
--sync-metadata Write chosen date into destination metadata (automatic for GoPro filesystem dates)
--no-quicktime-utc Treat QuickTime dates as already local time (for cameras that store local time instead of UTC)
--unattended Never prompt; resolve destination conflicts with numeric suffixes
--collect-unsortable Put files without dates into DEST/unsortable
--keep-sidecars Keep device sidecar files (default: delete after import)
--sync-gps Embed first valid GPS fix from GPMF stream into destination EXIF
--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() {
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
}
# 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
}
# 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"
if [[ -e "$dst" ]]; then
log_message "Refusing to overwrite existing destination: $dst" "ERROR"
return 1
fi
# Redirect stderr through a filter that removes the known benign message
mv "$src" "$dst" 2> >(grep -v "set flags (was:" >&2)
return $?
}
safe_cp() {
local src="$1" dst="$2"
if [[ -e "$dst" ]]; then
log_message "Refusing to overwrite existing destination: $dst" "ERROR"
return 1
fi
cp -p "$src" "$dst" 2> >(grep -v "set flags (was:" >&2)
return $?
}
ensure_unique_destination_path() {
local desired_path="$1"
if [[ -z "$desired_path" ]]; then
return 1
fi
if ! destination_path_unavailable "$desired_path"; then
echo "$desired_path"
return 0
fi
local dir_path filename ext stem candidate
dir_path=$(dirname "$desired_path")
filename=$(basename "$desired_path")
if [[ "$filename" == *.* ]]; then
ext="${filename##*.}"
stem="${filename%.*}"
else
ext=""
stem="$filename"
fi
local i
i=1
while [[ $i -le 9999 ]]; do
if [[ -n "$ext" ]]; then
candidate="$dir_path/${stem}_${i}.${ext}"
else
candidate="$dir_path/${stem}_${i}"
fi
if ! destination_path_unavailable "$candidate"; then
echo "$candidate"
return 0
fi
i=$((i + 1))
done
return 1
}
destination_path_reserved() {
local candidate="$1"
local reserved_path
for reserved_path in "${RESERVED_DESTINATION_PATHS[@]}"; do
if [[ "$reserved_path" == "$candidate" ]]; then
return 0
fi
done
return 1
}
destination_path_unavailable() {
local candidate="$1"
[[ -e "$candidate" ]] || destination_path_reserved "$candidate"
}
reserve_destination_path() {
local candidate="$1"
if [[ -n "$candidate" ]] && ! destination_path_reserved "$candidate"; then
RESERVED_DESTINATION_PATHS+=("$candidate")
fi
}
prompt_destination_conflict_choice() {
local source_file="$1"
local desired_path="$2"
local choice
if [[ ! -r /dev/tty || ! -w /dev/tty ]]; then
return 1
fi
{
print_color "$YELLOW" "Destination already exists:"
echo " Source: $source_file"
echo " Destination: $desired_path"
echo ""
echo "Choose conflict action:"
echo " [s] suffix once"
echo " [S] suffix for all similar conflicts"
echo " [k] skip once"
echo " [K] skip all similar conflicts"
echo " [a] abort import"
} > /dev/tty
while true; do
printf "Action [s/S/k/K/a]: " > /dev/tty
IFS= read -r choice < /dev/tty || return 1
case "$choice" in
s|"")
echo "suffix"
return 0
;;
S)
echo "suffix_all"
return 0
;;
k)
echo "skip"
return 0
;;
K)
echo "skip_all"
return 0
;;
a|A)
echo "abort"
return 0
;;
*)
print_color "$YELLOW" "Please choose s, S, k, K, or a." > /dev/tty
;;
esac
done
}
resolve_destination_conflict() {
local desired_path="$1"
local source_file="$2"
local resolved_path choice
RESOLVED_DESTINATION_PATH=""
if [[ -z "$desired_path" ]]; then
return 1
fi
if ! destination_path_unavailable "$desired_path"; then
RESOLVED_DESTINATION_PATH="$desired_path"
reserve_destination_path "$RESOLVED_DESTINATION_PATH"
return 0
fi
if [[ "$CONFLICT_APPLY_ALL" == "skip" ]]; then
return 3
fi
if [[ "$CONFLICT_APPLY_ALL" == "suffix" || $UNATTENDED -eq 1 ]]; then
resolved_path=$(ensure_unique_destination_path "$desired_path")
if [[ -z "$resolved_path" ]]; then
return 1
fi
RESOLVED_DESTINATION_PATH="$resolved_path"
reserve_destination_path "$RESOLVED_DESTINATION_PATH"
return 0
fi
choice=$(prompt_destination_conflict_choice "$source_file" "$desired_path")
if [[ $? -ne 0 || -z "$choice" ]]; then
log_message "Cannot prompt for destination conflict; using unattended numeric suffix mode" "WARNING"
resolved_path=$(ensure_unique_destination_path "$desired_path")
if [[ -z "$resolved_path" ]]; then
return 1
fi
RESOLVED_DESTINATION_PATH="$resolved_path"
reserve_destination_path "$RESOLVED_DESTINATION_PATH"
return 0
fi
case "$choice" in
suffix)
resolved_path=$(ensure_unique_destination_path "$desired_path")
;;
suffix_all)
CONFLICT_APPLY_ALL="suffix"
resolved_path=$(ensure_unique_destination_path "$desired_path")
;;
skip)
return 3
;;
skip_all)
CONFLICT_APPLY_ALL="skip"
return 3
;;
abort)
return 4
;;
*)
return 1
;;
esac
if [[ -z "$resolved_path" ]]; then
return 1
fi
RESOLVED_DESTINATION_PATH="$resolved_path"
reserve_destination_path "$RESOLVED_DESTINATION_PATH"
return 0
}
extract_filesystem_date() {
# Returns yyyy-mm-dd hh:mm:ss based on filesystem mtime.
# We intentionally use mtime (not birthtime) because birthtime isn't preserved by copies
# across filesystems, while mtime can be preserved via `cp -p`.
local file="$1"
if [[ ! -e "$file" ]]; then
return 2
fi
local epoch=""
if [[ "$OSTYPE" == "darwin"* ]]; then
epoch=$(stat -f %m "$file" 2>/dev/null || echo "")
[[ -n "$epoch" ]] || return 2
date -j -r "$epoch" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || return 2
return 0
else
epoch=$(stat -c %Y "$file" 2>/dev/null || echo "")
[[ -n "$epoch" ]] || return 2
date -d "@$epoch" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || return 2
return 0
fi
}
filesystem_date_reference() {
local file="$1"
local dir base stem ext sidecar_ext sidecar
dir=$(dirname "$file")
base=$(basename "$file")
stem="${base%.*}"
ext="${base##*.}"
if [[ "$ext" =~ ^([Mm][Pp]4)$ ]]; then
for sidecar_ext in THM thm LRV lrv; do
sidecar="$dir/$stem.$sidecar_ext"
if [[ -f "$sidecar" ]]; then
echo "$sidecar"
return 0
fi
done
fi
echo "$file"
}
is_gopro_media_file() {
local filename
filename=$(basename "$1")
[[ "$filename" =~ ^G[HPX][0-9]{6}\.[Mm][Pp]4$ ]]
}
is_garmin_media_file() {
local filename
filename=$(basename "$1")
[[ "$filename" =~ ^VIRB[0-9]{4}\.[Mm][Pp]4$ ]]
}
get_device_sidecar_extensions() {
local file="$1"
local filename
filename=$(basename "$file")
if is_gopro_media_file "$file"; then
echo "THM thm LRV lrv"
elif is_garmin_media_file "$file"; then
echo "GLV glv"
fi
}
should_prefer_gopro_filesystem_date() {
local file="$1"
is_gopro_media_file "$file"
}
filesystem_date_source_label() {
local file="$1"
local reference="$2"
if is_gopro_media_file "$file"; then
echo "Filesystem:$(basename "$reference")"
elif [[ "$reference" != "$file" ]]; then
echo "Filesystem:$(basename "$reference")"
else
echo "Filesystem"
fi
}
date_to_exiftool_format() {
# yyyy-mm-dd hh:mm:ss -> yyyy:mm:dd hh:mm:ss
local s="$1"
if [[ "$s" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})[[:space:]]+([0-9]{2}):([0-9]{2}):([0-9]{2})$ ]]; then
echo "${BASH_REMATCH[1]}:${BASH_REMATCH[2]}:${BASH_REMATCH[3]} ${BASH_REMATCH[4]}:${BASH_REMATCH[5]}:${BASH_REMATCH[6]}"
return 0
fi
return 1
}
sync_destination_metadata_to_date() {
local file="$1"
local date_str="$2" # yyyy-mm-dd hh:mm:ss
local exif_dt
exif_dt=$(date_to_exiftool_format "$date_str") || return 1
exiftool -overwrite_original \
"-CreateDate=$exif_dt" \
"-DateTimeOriginal=$exif_dt" \
"-DateTime=$exif_dt" \
"-ModifyDate=$exif_dt" \
"-MediaCreateDate=$exif_dt" \
"-TrackCreateDate=$exif_dt" \
"-QuickTime:CreateDate=$exif_dt" \
"-QuickTime:ModifyDate=$exif_dt" \
"$file" >/dev/null 2>&1
return 0
}
verify_synced_metadata_date() {
local file="$1"
local expected_date="$2"
local metadata_date
metadata_date=$(exiftool -api QuickTimeUTC=0 -s -s -s -QuickTime:CreateDate "$file" 2>/dev/null | head -1)
if [[ -z "$metadata_date" ]]; then
metadata_date=$(exiftool -api QuickTimeUTC=0 -s -s -s -CreateDate "$file" 2>/dev/null | head -1)
fi
if [[ "$metadata_date" =~ ^([0-9]{4}):([0-9]{2}):([0-9]{2})[[:space:]]+([0-9]{2}):([0-9]{2}):([0-9]{2}) ]]; then
metadata_date="${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]} ${BASH_REMATCH[4]}:${BASH_REMATCH[5]}:${BASH_REMATCH[6]}"
fi
if [[ "$metadata_date" != "$expected_date" ]]; then
log_message "Destination metadata sync mismatch: expected $expected_date, got ${metadata_date:-none} for $file" "ERROR"
return 1
fi
return 0
}
should_sync_imported_metadata() {
local original_filename="$1"
local date_source="$2"
if [[ $SYNC_METADATA -eq 1 ]]; then
return 0
fi
if [[ "$date_source" == Filesystem* ]] && is_gopro_media_file "$original_filename"; then
return 0
fi
return 1
}
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 [[ -e "$dst" ]]; then
log_message "Refusing to overwrite existing destination: $dst" "ERROR"
return 1
fi
local dst_dir tmp
dst_dir=$(dirname "$dst")
tmp=$(mktemp "$dst_dir/.media-importer.$(basename "$dst").tmp.XXXXXX") || return 1
rm -f "$tmp"
if ! safe_cp "$src" "$tmp"; then
rm -f "$tmp"
return 1
fi
if ! verify_copied_file "$src" "$tmp" "$expected_date"; then
rm -f "$tmp"
return 1
fi
if [[ -e "$dst" ]]; then
log_message "Destination appeared during copy, refusing to overwrite: $dst" "ERROR"
rm -f "$tmp"
return 1
fi
if ! safe_mv "$tmp" "$dst"; then
rm -f "$tmp"
return 1
fi
if [[ ! -f "$dst" ]]; then
log_message "Copied file missing after final move: $dst" "ERROR"
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
}
format_duration() {
local total_seconds=$1
local hours=$((total_seconds / 3600))
local minutes=$(((total_seconds % 3600) / 60))
local seconds=$((total_seconds % 60))
if (( hours > 0 )); then
printf "%dh %02dm %02ds" "$hours" "$minutes" "$seconds"
elif (( minutes > 0 )); then
printf "%dm %02ds" "$minutes" "$seconds"
else
printf "%ds" "$seconds"
fi
}
format_data_rate() {
local bytes_count="$1"
local elapsed_seconds="$2"
awk -v bytes="$bytes_count" -v seconds="$elapsed_seconds" '
BEGIN {
if (seconds <= 0 || bytes <= 0) {
exit
}
mb_per_second = bytes / seconds / 1048576
printf "%.2f MB/sec", mb_per_second
}
'
}
report_line() {
local label="$1"
local value="$2"
printf " %-24s %s\n" "$label" "$value"
}
# Function to extract date from file
extract_file_date() {
local file="$1"
local create_date=""
local date_source=""
local exif_found=0
# Timecode of first frame is the most reliable local-time source for video files:
# it is written by the camera's own clock at the moment recording starts, with no
# UTC-vs-local ambiguity. Try it first (requires mediainfo) before any other method.
if [[ "$DATE_SOURCE" != "filesystem" ]] && command -v mediainfo &>/dev/null; then
local raw_tc
raw_tc=$(mediainfo --Inform="Other;%TimeCode_FirstFrame%" "$file" 2>/dev/null | head -1)
if [[ "$raw_tc" =~ ^([0-9]{2}):([0-9]{2}):([0-9]{2}): ]]; then
local tc_hh="${BASH_REMATCH[1]}" tc_mm="${BASH_REMATCH[2]}" tc_ss="${BASH_REMATCH[3]}"
local dt_re='^([0-9]{4}-[0-9]{2}-[0-9]{2})[T ]([0-9]{2}):[0-9]{2}:[0-9]{2}'
local ref_date="" ref_hh=""
# Primary: Recorded_Date includes timezone offset so its date+hour are already local.
local recorded
recorded=$(mediainfo --Inform="General;%Recorded_Date%" "$file" 2>/dev/null | head -1)
if [[ "$recorded" =~ $dt_re ]]; then
ref_date="${BASH_REMATCH[1]}"
ref_hh="${BASH_REMATCH[2]}"
else
# Fallback: Encoded_Date is stored as UTC; convert to local to get the correct
# reference date and hour for midnight-crossing detection.
local encoded
encoded=$(mediainfo --Inform="General;%Encoded_Date%" "$file" 2>/dev/null | head -1)
encoded="${encoded% UTC}"
if [[ "$encoded" =~ $dt_re ]]; then
local enc_date="${BASH_REMATCH[1]}" enc_time_str
enc_time_str="${encoded#* }"
if [[ "$OSTYPE" == "darwin"* ]]; then
local utc_ts local_dt
utc_ts=$(TZ=UTC date -j -f "%Y-%m-%d %H:%M:%S" "$encoded" "+%s" 2>/dev/null)
local_dt=$(date -j -r "$utc_ts" "+%Y-%m-%d %H" 2>/dev/null)
ref_date="${local_dt% *}"
ref_hh="${local_dt#* }"
else
ref_date=$(date -d "$encoded UTC" "+%Y-%m-%d" 2>/dev/null)
ref_hh=$(date -d "$encoded UTC" "+%H" 2>/dev/null)
fi
fi
fi
if [[ -n "$ref_date" && -n "$ref_hh" ]]; then
local start_date="$ref_date"
# If timecode hour > end-time local hour, the clip crossed midnight:
# subtract one day to get the recording-start calendar date.
if (( 10#$tc_hh > 10#$ref_hh )); then
if [[ "$OSTYPE" == "darwin"* ]]; then
start_date=$(date -j -v-1d -f "%Y-%m-%d" "$ref_date" "+%Y-%m-%d" 2>/dev/null)
else
start_date=$(date -d "$ref_date - 1 day" "+%Y-%m-%d" 2>/dev/null)
fi
fi
if [[ -n "$start_date" ]]; then
echo "${start_date} ${tc_hh}:${tc_mm}:${tc_ss}|Timecode"
return 0
fi
fi
fi
fi
# Filesystem authoritative mode, and GoPro media in auto mode.
# GoPro fallback order is THM, LRV, then the MP4 filesystem timestamp.
if [[ "$DATE_SOURCE" == "filesystem" ]] || { [[ "$DATE_SOURCE" == "auto" ]] && should_prefer_gopro_filesystem_date "$file"; }; then
local filesystem_reference
filesystem_reference=$(filesystem_date_reference "$file")
create_date=$(extract_filesystem_date "$filesystem_reference") || return 2
echo "$create_date|$(filesystem_date_source_label "$file" "$filesystem_reference")"
return 0
fi
# 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
# In auto mode, if metadata is missing/unreliable, fall back to filesystem timestamps
if [[ -z "$create_date" && "$DATE_SOURCE" == "auto" ]]; then
local filesystem_reference
filesystem_reference=$(filesystem_date_reference "$file")
create_date=$(extract_filesystem_date "$filesystem_reference") || return 2
date_source=$(filesystem_date_source_label "$file" "$filesystem_reference")
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.
# Skip this conversion with --no-quicktime-utc for cameras that store local time in the UTC field
# (common on some GoPro models/firmware with incorrect timezone settings).
if [[ "$date_source" == *"QuickTime"* && $QUICKTIME_UTC -eq 1 ]]; 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[@]}" ! -name '._*' \( $ext_expr \) 2>/dev/null || true
else
# Scan each provided source
for src in "${SOURCE_PATTERNS[@]}"; do
if [[ -f "$src" ]]; then
if [[ "$(basename "$src")" == ._* ]]; then
continue
fi
# 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 ! -name '._*' \( $ext_expr \) ! -path "$abs_dest/*" ! -path "$abs_dest" 2>/dev/null || true
else
find -L "$abs_src" -type f ! -name '._*' \( $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
}
# Return the companion LRV path for a GoPro source file, or empty string.
# GoPro naming: GX/GH/GP chapter files have a matching GL*.LRV low-res proxy.
find_companion_lrv() {
local file="$1"
local filename dir stem num
filename=$(basename "$file")
dir=$(dirname "$file")
if [[ "$filename" =~ ^G[HPX]([0-9]{6})\.[Mm][Pp]4$ ]]; then
num="${BASH_REMATCH[1]}"
local lrv
for lrv in "$dir/GL${num}.LRV" "$dir/GL${num}.lrv"; do
[[ -f "$lrv" ]] && echo "$lrv" && return 0
done
fi
return 1
}
# Extract first valid GPS fix from a GoPro video or LRV file.
# Uses ffmpeg to pull the GPMF data stream (fast: reads index only, not video frames),
# then parses it with the bundled Python script.
# Outputs: "lat lon alt" in decimal degrees / metres. Exit 1 if no fix found.
extract_gps_first_fix() {
local file="$1"
local py="$SCRIPT_DIR/extract_gpmf_gps.py"
if [[ ! -f "$py" ]]; then
return 1
fi
python3 "$py" "$file" 2>/dev/null
}
# Write GPS coordinates into a file using exiftool QuickTime GPS tags.
embed_gps_into_file() {
local file="$1"
local lat="$2" lon="$3" alt="$4"
local ref_lat="N" ref_lon="E" ref_alt="0"
if awk "BEGIN{exit !($lat < 0)}" 2>/dev/null; then
ref_lat="S"; lat="${lat#-}"
fi
if awk "BEGIN{exit !($lon < 0)}" 2>/dev/null; then
ref_lon="W"; lon="${lon#-}"
fi
if awk "BEGIN{exit !($alt < 0)}" 2>/dev/null; then
ref_alt="1" # below sea level
fi
exiftool -overwrite_original \
"-GPSLatitude=$lat" "-GPSLatitudeRef=$ref_lat" \
"-GPSLongitude=$lon" "-GPSLongitudeRef=$ref_lon" \
"-GPSAltitude=$alt" "-GPSAltitudeRef=$ref_alt" \
"$file" >/dev/null 2>&1
}
cleanup_media_sidecars() {
local file="$1"
local dir base stem ext sidecar_ext sidecar
dir=$(dirname "$file")
base=$(basename "$file")
stem="${base%.*}"
ext="${base##*.}"
if [[ ! "$ext" =~ ^([Mm][Pp]4)$ ]]; then
return 0
fi
local sidecar_exts
sidecar_exts=$(get_device_sidecar_extensions "$file")
[[ -z "$sidecar_exts" ]] && return 0
for sidecar_ext in $sidecar_exts; do
sidecar="$dir/$stem.$sidecar_ext"
if [[ -f "$sidecar" ]]; then
if rm -f "$sidecar"; then
log_message "Deleted sidecar: $sidecar" "INFO"
else
log_message "Failed to delete sidecar: $sidecar" "WARNING"
fi
fi
done
}
# Function to process a single file
process_file() {
local file="$1"
local file_size=$(get_file_size "$file")
local file_label
file_label="$(basename "$file")"
TOTAL_SIZE=$((TOTAL_SIZE + file_size))
if [[ $TOTAL_FILES -gt 0 && $CURRENT_FILE_INDEX -gt 0 ]]; then
print_color "$BLUE" "Processing [$CURRENT_FILE_INDEX/$TOTAL_FILES]: $file_label ($(format_size "$file_size"))"
else
print_color "$BLUE" "Processing: $file_label ($(format_size "$file_size"))"
fi
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")"
local desired_unsortable_path="$unsortable_path"
local unsortable_conflict_status
resolve_destination_conflict "$unsortable_path" "$file"
unsortable_conflict_status=$?
if [[ $unsortable_conflict_status -eq 0 ]]; then
unsortable_path="$RESOLVED_DESTINATION_PATH"
if [[ "$unsortable_path" != "$desired_unsortable_path" ]]; then
log_message "Destination already exists or is already planned: $desired_unsortable_path - using: $unsortable_path" "WARNING"
fi
elif [[ $unsortable_conflict_status -eq 3 ]]; then
log_message "Destination conflict skipped: $desired_unsortable_path" "WARNING"
SKIPPED_FILES=$((SKIPPED_FILES + 1))
return 1
elif [[ $unsortable_conflict_status -eq 4 ]]; then
log_message "Import aborted by user at destination conflict: $desired_unsortable_path" "ERROR"
ERROR_FILES=$((ERROR_FILES + 1))
FATAL_ERROR=1
return 2
else
log_message "Could not resolve a unique destination path for $file (wanted: $desired_unsortable_path)" "ERROR"
ERROR_FILES=$((ERROR_FILES + 1))
return 1
fi
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 original_basename
original_basename="$(basename "$file")"
local dest_path
dest_path=$(generate_destination_path "$date_str" "$original_basename" "$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
local desired_dest_path="$dest_path"
local conflict_status
resolve_destination_conflict "$dest_path" "$file"
conflict_status=$?
if [[ $conflict_status -eq 0 ]]; then
dest_path="$RESOLVED_DESTINATION_PATH"
elif [[ $conflict_status -eq 3 ]]; then
log_message "Destination conflict skipped: $desired_dest_path" "WARNING"
SKIPPED_FILES=$((SKIPPED_FILES + 1))
return 1
elif [[ $conflict_status -eq 4 ]]; then
log_message "Import aborted by user at destination conflict: $desired_dest_path" "ERROR"
ERROR_FILES=$((ERROR_FILES + 1))
FATAL_ERROR=1
return 2
else
log_message "Could not resolve a unique destination path for $file (wanted: $desired_dest_path)" "ERROR"
ERROR_FILES=$((ERROR_FILES + 1))
return 1
fi
if [[ "$dest_path" != "$desired_dest_path" ]]; then
log_message "Destination already exists or is already planned: $desired_dest_path - using: $dest_path" "WARNING"
fi
local dest_dir
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
if should_sync_imported_metadata "$original_basename" "$date_source"; then
print_color "$BLUE" "Would sync metadata date: $dest_path -> $date_str"
fi
PROCESSED_FILES=$((PROCESSED_FILES + 1))
PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
return 0
fi
# Pre-extract GPS from source before the move (source may be on faster local media).
# LRV companion preferred: same GPMF data, smaller file, faster extraction.
local pre_gps_lat="" pre_gps_lon="" pre_gps_alt=""
if [[ $SYNC_GPS -eq 1 ]]; then
local lrv_companion
lrv_companion=$(find_companion_lrv "$file")
local gps_src="${lrv_companion:-$file}"
read -r pre_gps_lat pre_gps_lon pre_gps_alt < <(extract_gps_first_fix "$gps_src")
if [[ -n "$pre_gps_lat" ]]; then
log_message "GPS: first fix at ($pre_gps_lat, $pre_gps_lon, ${pre_gps_alt}m) from $(basename "$gps_src")" "INFO"
else
log_message "GPS: no valid fix found in $(basename "$gps_src")" "INFO"
fi
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
local sync_metadata_after_copy=0
local verification_date="$date_str"
if should_sync_imported_metadata "$original_basename" "$date_source"; then
sync_metadata_after_copy=1
verification_date=""
fi
# Copy or move file using safe helpers after destination conflicts are resolved.
if [[ $KEEP_ORIGINALS -eq 1 ]]; then
if copy_with_verification "$file" "$dest_path" "$verification_date"; then
if [[ $sync_metadata_after_copy -eq 1 ]]; then
if ! sync_destination_metadata_to_date "$dest_path" "$date_str"; then
log_message "Failed to sync destination metadata timestamps: $dest_path" "ERROR"
ERROR_FILES=$((ERROR_FILES + 1))
return 1
elif ! verify_synced_metadata_date "$dest_path" "$date_str"; then
ERROR_FILES=$((ERROR_FILES + 1))
return 1
fi
fi
log_message "Copied: $file -> $dest_path" "SUCCESS"
if [[ $SYNC_GPS -eq 1 && -n "$pre_gps_lat" ]]; then
embed_gps_into_file "$dest_path" "$pre_gps_lat" "$pre_gps_lon" "$pre_gps_alt" \
&& log_message "GPS: embedded into $dest_path" "INFO" \
|| log_message "GPS: failed to embed into $dest_path" "WARNING"
fi
if [[ $CLEANUP_MEDIA_SIDECARS -eq 1 ]]; then
cleanup_media_sidecars "$file"
fi
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 [[ $sync_metadata_after_copy -eq 1 ]]; then
if copy_with_verification "$file" "$dest_path" "$verification_date"; then
if ! sync_destination_metadata_to_date "$dest_path" "$date_str"; then
log_message "Failed to sync destination metadata timestamps: $dest_path" "ERROR"
ERROR_FILES=$((ERROR_FILES + 1))
return 1
fi
if ! verify_synced_metadata_date "$dest_path" "$date_str"; then
ERROR_FILES=$((ERROR_FILES + 1))
return 1
fi
if ! remove_source_file "$file"; then
log_message "Copied, verified, and synced destination, but failed to remove source: $file" "ERROR"
ERROR_FILES=$((ERROR_FILES + 1))
return 1
fi
log_message "Moved: $file -> $dest_path" "SUCCESS"
if [[ $SYNC_GPS -eq 1 && -n "$pre_gps_lat" ]]; then
embed_gps_into_file "$dest_path" "$pre_gps_lat" "$pre_gps_lon" "$pre_gps_alt" \
&& log_message "GPS: embedded into $dest_path" "INFO" \
|| log_message "GPS: failed to embed into $dest_path" "WARNING"
fi
if [[ $CLEANUP_MEDIA_SIDECARS -eq 1 ]]; then
cleanup_media_sidecars "$file"
fi
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
elif verified_move_file "$file" "$dest_path" "$date_str"; then
log_message "Moved: $file -> $dest_path" "SUCCESS"
if [[ $SYNC_GPS -eq 1 && -n "$pre_gps_lat" ]]; then
embed_gps_into_file "$dest_path" "$pre_gps_lat" "$pre_gps_lon" "$pre_gps_alt" \
&& log_message "GPS: embedded into $dest_path" "INFO" \
|| log_message "GPS: failed to embed into $dest_path" "WARNING"
fi
if [[ $CLEANUP_MEDIA_SIDECARS -eq 1 ]]; then
cleanup_media_sidecars "$file"
fi
if [[ $sync_metadata_after_copy -eq 1 ]]; then
if ! sync_destination_metadata_to_date "$dest_path" "$date_str"; then
log_message "Failed to sync destination metadata timestamps: $dest_path" "WARNING"
elif ! verify_synced_metadata_date "$dest_path" "$date_str"; then
log_message "Failed to verify synced destination metadata timestamps: $dest_path" "WARNING"
fi
fi
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:"
report_line "Total files found:" "$TOTAL_FILES"
report_line "Successfully processed:" "$PROCESSED_FILES"
report_line "Skipped:" "$SKIPPED_FILES"
report_line "Errors:" "$ERROR_FILES"
echo ""
echo "Size Summary:"
report_line "Total size found:" "$(format_size $TOTAL_SIZE)"
report_line "Successfully processed:" "$(format_size $PROCESSED_SIZE)"
echo ""
echo "Time Summary:"
report_line "Time elapsed:" "$(printf "%02d:%02d:%02d" $hours $minutes $seconds)"
if [[ $elapsed_time -gt 0 && $PROCESSED_SIZE -gt 0 ]]; then
local data_rate
data_rate=$(format_data_rate "$PROCESSED_SIZE" "$elapsed_time")
if [[ -n "$data_rate" ]]; then
report_line "Data rate:" "$data_rate"
fi
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" "=========================================="
}
if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then
return 0
fi
# 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
;;
--date-source)
DATE_SOURCE="$2"
if [[ ! "$DATE_SOURCE" =~ ^(auto|exif|filesystem)$ ]]; then
print_color "$RED" "Error: Invalid date source. Must be one of: auto, exif, filesystem"
exit 1
fi
shift 2
;;
--sync-metadata)
SYNC_METADATA=1
shift
;;
--no-quicktime-utc)
QUICKTIME_UTC=0
shift
;;
--unattended)
UNATTENDED=1
shift
;;
--keep-sidecars)
CLEANUP_MEDIA_SIDECARS=0
shift
;;
--sync-gps)
SYNC_GPS=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
# Non-interactive execution cannot safely ask conflict questions.
if [[ $UNATTENDED -eq 0 && ( ! -t 0 || ! -t 1 ) ]]; then
UNATTENDED=1
fi
# 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 " Date source: $DATE_SOURCE"
echo " Sync metadata: $([ $SYNC_METADATA -eq 1 ] && echo "Yes" || echo "No")"
echo " QuickTime UTC: $([ $QUICKTIME_UTC -eq 1 ] && echo "Yes" || echo "No (local time assumed)")"
echo " Sync GPS: $([ $SYNC_GPS -eq 1 ] && echo "Yes" || echo "No")"
echo " Unattended: $([ $UNATTENDED -eq 1 ] && echo "Yes" || echo "No")"
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)
if [[ ${#files[@]} -gt 0 ]]; then
IFS=$'\n' files=($(printf '%s\n' "${files[@]}" | sort))
unset IFS
fi
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
CURRENT_FILE_INDEX=$((CURRENT_FILE_INDEX + 1))
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