Created
July 13, 2026 10:55
-
-
Save Xatpy/ff79e52b953089a759ee470414902ce9 to your computer and use it in GitHub Desktop.
Organise media by date
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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