Skip to content

Instantly share code, notes, and snippets.

@Xatpy
Created July 13, 2026 10:55
Show Gist options
  • Select an option

  • Save Xatpy/ff79e52b953089a759ee470414902ce9 to your computer and use it in GitHub Desktop.

Select an option

Save Xatpy/ff79e52b953089a759ee470414902ce9 to your computer and use it in GitHub Desktop.
Organise media by date
#!/usr/bin/env bash
# Organize copies of photos and videos by their capture date.
# Requires: ExifTool (https://exiftool.org/)
#
# Examples:
# ./organize-media-by-date.sh --dry-run "/Volumes/Backup/iPhone dump" "/Volumes/Backup/Organized"
# ./organize-media-by-date.sh --overwrite-existing "/Volumes/Backup/iPhone dump" "/Volumes/Backup/Organized"
#
# It never modifies or removes files in the source folder.
set -u
set -o pipefail
PROGRAM="$(basename "$0")"
USAGE="Usage: $PROGRAM [--dry-run] [--overwrite-existing] [--all-files] [--flat] SOURCE_FOLDER DESTINATION_FOLDER"
DRY_RUN=0
OVERWRITE_EXISTING=0
ALL_FILES=0
FLAT_OUTPUT=0
while [[ ${1:-} == --* ]]; do
case $1 in
--dry-run) DRY_RUN=1 ;;
--overwrite-existing) OVERWRITE_EXISTING=1 ;;
--all-files) ALL_FILES=1 ;;
--flat) FLAT_OUTPUT=1 ;;
--help)
echo "$USAGE"
exit 0
;;
*)
echo "Error: unknown option: $1" >&2
echo "$USAGE" >&2
exit 64
;;
esac
shift
done
if [[ $# -ne 2 ]]; then
echo "$USAGE" >&2
exit 64
fi
if ! command -v exiftool >/dev/null 2>&1; then
cat >&2 <<'EOF'
Error: exiftool, which is required to read capture dates, was not found.
On macOS, install it with: brew install exiftool
Then run this script again.
EOF
exit 69
fi
SOURCE_INPUT=$1
DESTINATION_INPUT=$2
if [[ ! -d "$SOURCE_INPUT" ]]; then
echo "Error: the source folder does not exist or is not a folder: $SOURCE_INPUT" >&2
exit 66
fi
# Converting to absolute paths avoids errors if the working directory changes.
SOURCE=$(cd "$SOURCE_INPUT" && pwd -P)
mkdir -p "$DESTINATION_INPUT" || { echo "Error: unable to create the destination folder." >&2; exit 73; }
DESTINATION=$(cd "$DESTINATION_INPUT" && pwd -P)
# Never allow the destination inside the source: find would process the copies too.
case "$DESTINATION/" in
"$SOURCE/"*)
echo "Error: the destination folder cannot be inside the source folder." >&2
exit 64
;;
esac
START_ISO=$(date '+%Y-%m-%d %H:%M:%S %z')
STAMP=$(date '+%Y%m%d_%H%M%S')
RUN_ID="${STAMP}_$$"
REPORT="$DESTINATION/organization_report_$RUN_ID.csv"
ERROR_LOG="$DESTINATION/organization_errors_$RUN_ID.log"
TEMP_DIR="$DESTINATION/.organize_by_date_tmp_$RUN_ID"
DATE_FORMAT='%Y-%m-%d__%H-%M-%S'
TOTAL=0
COPIED=0
NO_CAPTURE_DATE=0
FILE_DATE_FALLBACK=0
FAILURES=0
INTERRUPTED=0
NON_MEDIA_SKIPPED=0
csv() {
# Valid CSV even with quotes, commas, or line breaks in file names.
local field
local first=1
for field in "$@"; do
field=${field//\"/\"\"}
(( first )) || printf ',' >> "$REPORT"
printf '"%s"' "$field" >> "$REPORT"
first=0
done
printf '\n' >> "$REPORT"
}
write_summary() {
{
echo
echo "Summary"
echo "Started: $START_ISO"
echo "Finished: $(date '+%Y-%m-%d %H:%M:%S %z')"
echo "Files examined: $TOTAL"
echo "Copies created: $COPIED"
echo "No usable date (copied with an undated__ prefix): $NO_CAPTURE_DATE"
echo "Dates taken from file timestamps: $FILE_DATE_FALLBACK"
echo "Read/copy failures: $FAILURES"
echo "Non-media files skipped: $NON_MEDIA_SKIPPED"
echo "Run interrupted: $INTERRUPTED"
} >> "$ERROR_LOG"
csv "SUMMARY" "" "" "files_examined=$TOTAL; copies=$COPIED; no_usable_date=$NO_CAPTURE_DATE; file_timestamp_fallbacks=$FILE_DATE_FALLBACK; failures=$FAILURES; non_media_skipped=$NON_MEDIA_SKIPPED; interrupted=$INTERRUPTED"
}
interrupted() {
INTERRUPTED=1
echo >&2
echo "Interrupted by user. A partial report has been saved." >&2
write_summary
rm -rf "$TEMP_DIR"
exit 130
}
trap interrupted INT TERM
mkdir -p "$TEMP_DIR" || { echo "Error: unable to create the temporary folder." >&2; exit 73; }
printf 'status,source,destination,date_used,date_source,details\n' > "$REPORT"
{
echo "Error log and summary — $START_ISO"
echo "Source: $SOURCE"
echo "Destination: $DESTINATION"
[[ $DRY_RUN -eq 1 ]] && echo "Mode: dry run (nothing will be copied)"
[[ $OVERWRITE_EXISTING -eq 1 ]] && echo "Mode: existing destination files will be overwritten"
[[ $ALL_FILES -eq 1 ]] || echo "Mode: only recognized photo and video formats will be processed"
[[ $FLAT_OUTPUT -eq 1 ]] && echo "Mode: flat output (no year/month folders)" || echo "Mode: year/month folder structure"
echo
} > "$ERROR_LOG"
capture_date() {
local file=$1 tag value
# Preferred order: original photo date, followed by common video date tags.
for tag in DateTimeOriginal CreateDate MediaCreateDate TrackCreateDate; do
value=$(exiftool -s3 -d "$DATE_FORMAT" "-$tag" -- "$file" 2>>"$ERROR_LOG") || return 2
# Only accept the expected format, preventing partial or invalid values.
if [[ $value =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}__[0-9]{2}-[0-9]{2}-[0-9]{2}$ ]]; then
printf '%s\tcapture_metadata' "$value"
return 0
fi
done
return 1
}
file_timestamp_date() {
local file=$1 epoch source formatted
# macOS: %B is the filesystem creation (birth) time; zero means unavailable.
if epoch=$(stat -f '%B' -- "$file" 2>/dev/null) && [[ $epoch =~ ^[0-9]+$ && $epoch -gt 0 ]]; then
source="file_creation_time"
# GNU/Linux fallback for the same timestamp, if available.
elif epoch=$(stat -c '%W' -- "$file" 2>/dev/null) && [[ $epoch =~ ^[0-9]+$ && $epoch -gt 0 ]]; then
source="file_creation_time"
# Last resort: the last modification time. Every readable regular file should have one.
elif epoch=$(stat -f '%m' -- "$file" 2>/dev/null) && [[ $epoch =~ ^[0-9]+$ && $epoch -gt 0 ]]; then
source="file_modification_time"
elif epoch=$(stat -c '%Y' -- "$file" 2>/dev/null) && [[ $epoch =~ ^[0-9]+$ && $epoch -gt 0 ]]; then
source="file_modification_time"
else
return 1
fi
if ! formatted=$(date -r "$epoch" "+$DATE_FORMAT" 2>/dev/null); then
return 1
fi
printf '%s\t%s' "$formatted" "$source"
}
is_media_file() {
local name=${1##*/}
local extension=${name##*.}
extension=$(printf '%s' "$extension" | tr '[:upper:]' '[:lower:]')
case $extension in
# Common image formats, including popular camera RAW formats.
jpg|jpeg|heic|heif|png|gif|webp|tif|tiff|dng|cr2|cr3|nef|arw|raf|orf|rw2|srw|pef|3fr|iiq)
return 0
;;
# Common video formats, including iPhone and AVCHD formats.
mov|mp4|m4v|avi|mkv|webm|3gp|3g2|mts|m2ts|mpg|mpeg)
return 0
;;
*) return 1 ;;
esac
}
echo "Processing files. The report will be saved to: $REPORT"
echo "You can press Ctrl+C: completed copies and a partial report will be kept."
while IFS= read -r -d '' FILE; do
((TOTAL+=1))
if (( TOTAL % 100 == 0 )); then
echo "Examined: $TOTAL | Copied: $COPIED | No usable date: $NO_CAPTURE_DATE | Failures: $FAILURES"
fi
if (( ! ALL_FILES )) && ! is_media_file "$FILE"; then
((NON_MEDIA_SKIPPED+=1))
csv "skipped_non_media" "$FILE" "" "" "" "Use --all-files to include this file type"
continue
fi
NAME=$(basename "$FILE")
if [[ $NAME == *.* && $NAME != .* ]]; then
EXTENSION=".${NAME##*.}"
NAME_STEM="${NAME%.*}"
else
EXTENSION=""
NAME_STEM="$NAME"
fi
DATE_RESULT=$(capture_date "$FILE")
status=$?
if [[ $status -ne 0 ]]; then
if [[ $status -eq 2 ]]; then
# Even if ExifTool cannot read a file, its filesystem timestamp may still be usable.
echo "METADATA READ WARNING (using filesystem timestamp if available): $FILE" >> "$ERROR_LOG"
fi
DATE_RESULT=$(file_timestamp_date "$FILE")
status=$?
if [[ $status -ne 0 ]]; then
((NO_CAPTURE_DATE+=1))
CAPTURE_DATE="undated__${NAME_STEM}"
DATE_USED=""
DATE_SOURCE="no_usable_date"
COPY_STATUS="copied_without_date"
COPY_DETAILS="No capture metadata or usable filesystem timestamp found; copied with original name"
else
((FILE_DATE_FALLBACK+=1))
fi
fi
if [[ $status -eq 0 ]]; then
CAPTURE_DATE=${DATE_RESULT%%$'\t'*}
DATE_USED=$CAPTURE_DATE
DATE_SOURCE=${DATE_RESULT#*$'\t'}
COPY_STATUS="copied"
COPY_DETAILS=""
fi
if (( FLAT_OUTPUT )); then
TARGET_DIRECTORY="$DESTINATION"
elif [[ $DATE_SOURCE == "no_usable_date" ]]; then
TARGET_DIRECTORY="$DESTINATION/undated"
else
YEAR=${CAPTURE_DATE:0:4}
MONTH=${CAPTURE_DATE:5:2}
TARGET_DIRECTORY="$DESTINATION/$YEAR/$MONTH"
fi
CANDIDATE="$TARGET_DIRECTORY/$CAPTURE_DATE$EXTENSION"
TARGET_EXISTS=0
[[ -e "$CANDIDATE" ]] && TARGET_EXISTS=1
if (( TARGET_EXISTS && ! OVERWRITE_EXISTING )); then
NUMBER=1
BASE_CANDIDATE="$CANDIDATE"
while [[ -e "$CANDIDATE" ]]; do
CANDIDATE="${BASE_CANDIDATE%$EXTENSION}_$(printf '%02d' "$NUMBER")$EXTENSION"
((NUMBER+=1))
done
TARGET_EXISTS=0
fi
if (( DRY_RUN )); then
((COPIED+=1))
if (( TARGET_EXISTS )); then
COPY_STATUS="overwritten"
[[ $DATE_SOURCE == "no_usable_date" ]] && COPY_STATUS="overwritten_without_date"
COPY_DETAILS="Would overwrite the existing destination file. $COPY_DETAILS"
fi
csv "dry_run" "$FILE" "$CANDIDATE" "$DATE_USED" "$DATE_SOURCE" "Would be $COPY_STATUS. $COPY_DETAILS"
continue
fi
if ! mkdir -p -- "$TARGET_DIRECTORY"; then
((FAILURES+=1))
echo "DESTINATION DIRECTORY ERROR: $TARGET_DIRECTORY" >> "$ERROR_LOG"
csv "destination_directory_error" "$FILE" "$CANDIDATE" "$DATE_USED" "$DATE_SOURCE" "Unable to create destination directory"
continue
fi
TMP="$TEMP_DIR/$TOTAL.$RANDOM.part"
if cp -p -- "$FILE" "$TMP" && mv -- "$TMP" "$CANDIDATE"; then
((COPIED+=1))
if (( TARGET_EXISTS )); then
COPY_STATUS="overwritten"
[[ $DATE_SOURCE == "no_usable_date" ]] && COPY_STATUS="overwritten_without_date"
COPY_DETAILS="Replaced an existing destination file. $COPY_DETAILS"
fi
csv "$COPY_STATUS" "$FILE" "$CANDIDATE" "$DATE_USED" "$DATE_SOURCE" "$COPY_DETAILS"
else
((FAILURES+=1))
rm -f -- "$TMP"
echo "COPY ERROR: $FILE -> $CANDIDATE" >> "$ERROR_LOG"
csv "copy_error" "$FILE" "$CANDIDATE" "$DATE_USED" "$DATE_SOURCE" "The copy did not complete"
fi
done < <(find "$SOURCE" -type f -print0 2>>"$ERROR_LOG")
rm -rf "$TEMP_DIR"
write_summary
echo
echo "Finished. Examined: $TOTAL; copied: $COPIED; no usable date (still copied): $NO_CAPTURE_DATE; failures: $FAILURES."
echo "Complete report: $REPORT"
echo "Error log: $ERROR_LOG"
exit 0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment