#!/usr/bin/env bash # Upload images to Immich, skipping duplicates via SHA1 checksum. # Usage: immich-upload # # If is a single file, upload just that file. # If it is a directory, recursively find all image files and upload them. # Duplicates are detected via the bulk-upload-check API before uploading. set -euo pipefail IMMICH_URL="http://immich.f3s.lan.buetow.org" API_KEY_FILE="$HOME/.immich_paul_key" # Supported image extensions (case-insensitive) IMAGE_EXTENSIONS='\.(jpg|jpeg|png|gif|webp|heic|heif|raw|cr2|nef|arw|dng|tiff|tif|bmp)$' # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ die() { echo "ERROR: $1" >&2; exit 1; } warn() { echo "WARN: $1" >&2; } info() { echo "==> $1"; } # Compute SHA1 (hex, lowercase) for a file. file_sha1() { sha1sum "$1" | awk '{print $1}' } # Extract fileCreatedAt and fileModifiedAt from exiftool if available, # otherwise fall back to filesystem timestamps. # Output is ISO 8601 with milliseconds, e.g. 2024-01-15T10:30:00.000Z file_timestamps() { local path="$1" local created modified if command -v exiftool &>/dev/null; then # Try to get DateTimeOriginal first, then CreateDate, then FileModifyDate created=$(exiftool -s3 -DateTimeOriginal "$path" 2>/dev/null || true) if [[ -z "$created" ]]; then created=$(exiftool -s3 -CreateDate "$path" 2>/dev/null || true) fi if [[ -z "$created" ]]; then created=$(exiftool -s3 -FileModifyDate "$path" 2>/dev/null || true) fi # exiftool returns something like "2024:01:15 10:30:00" or # "2024:01:15 10:30:00+03:00" with timezone offset. if [[ -n "$created" ]]; then # Strip timezone offset if present, replace colons with dashes for date part, # replace space with T, append .000Z created=$(echo "$created" | sed -E 's/([0-9]{4}):([0-9]{2}):([0-9]{2})/\1-\2-\3/; s/ /T/; s/[+-][0-9]{2}:[0-9]{2}$//; s/$/.000Z/') fi fi if [[ -z "$created" ]]; then created=$(date -r "$path" -u '+%Y-%m-%dT%H:%M:%S.000Z') fi modified=$(date -r "$path" -u '+%Y-%m-%dT%H:%M:%S.000Z') echo "$created" echo "$modified" } # ------------------------------------------------------------------ # Upload a single file # ------------------------------------------------------------------ upload_file() { local file="$1" local api_key="$2" local filename filename=$(basename "$file") local checksum checksum=$(file_sha1 "$file") local fileCreatedAt fileModifiedAt local timestamps timestamps=$(file_timestamps "$file") fileCreatedAt=$(echo "$timestamps" | sed -n '1p') fileModifiedAt=$(echo "$timestamps" | sed -n '2p') info "Uploading: $filename" local response tmpfile tmpfile=$(mktemp) trap "rm -f $tmpfile" RETURN local http_code http_code=$(curl -s -o "$tmpfile" -w '%{http_code}' -X POST \ -H "x-api-key: $api_key" \ -H "x-immich-checksum: $checksum" \ -F "assetData=@$file" \ -F "fileCreatedAt=$fileCreatedAt" \ -F "fileModifiedAt=$fileModifiedAt" \ -F "filename=$filename" \ -F "deviceAssetId=$checksum" \ -F "deviceId=immich-upload-cli" \ "$IMMICH_URL/api/assets" 2>/dev/null) || { http_code="000"; } if [[ "$http_code" == "200" ]]; then info " Skipped (duplicate): $filename" elif [[ "$http_code" == "201" ]]; then info " Uploaded: $filename" elif [[ "$http_code" == "000" ]]; then warn " Failed to upload: $filename (curl error)" else warn " Unexpected response $http_code for: $filename" cat "$tmpfile" >&2 || true fi rm -f "$tmpfile" } # ------------------------------------------------------------------ # Bulk upload with duplicate checking # ------------------------------------------------------------------ bulk_upload() { local api_key="$1" local files_list="$2" local total missing_count total=$(wc -l < "$files_list") # Compute SHA1 for each file and build the bulk check JSON local check_json tmp_json tmp_missing tmp_json=$(mktemp) tmp_missing=$(mktemp) info "Computing SHA1 checksums for $total file(s)..." while IFS= read -r file; do local checksum checksum=$(file_sha1 "$file") printf '%s\t%s\n' "$checksum" "$file" >> "$tmp_json" done < "$files_list" # Build the bulk-upload-check JSON { echo '{"assets":[' local first=1 while IFS=$'\t' read -r checksum file; do [[ "$first" -eq 1 ]] || echo ',' printf '{"id":"%s","checksum":"%s"}' "$file" "$checksum" first=0 done < "$tmp_json" echo ']}' } > "$tmp_json.json" info "Checking for duplicates on server..." local response response=$(curl -sf -X POST \ -H "x-api-key: $api_key" \ -H "Content-Type: application/json" \ -d @"$tmp_json.json" \ "$IMMICH_URL/api/assets/bulk-upload-check" 2>/dev/null) || die "bulk-upload-check failed" # Parse the response to find which files are duplicates. # Response: { "results": [{ "id": "...", "action": "reject", "reason": "duplicate", ... }] } local resp_file resp_file=$(mktemp) echo "$response" > "$resp_file" python3 -c " import json, sys try: with open('$resp_file') as f: d = json.load(f) for r in d.get('results', []): if r.get('action') == 'reject' and r.get('reason') == 'duplicate': print(r['id']) except Exception: pass " > "$tmp_missing.exists" rm -f "$resp_file" # Build the missing files list while IFS=$'\t' read -r checksum file; do if ! grep -Fxq "$file" "$tmp_missing.exists"; then echo "$file" >> "$tmp_missing" fi done < "$tmp_json" missing_count=$(wc -l < "$tmp_missing" | awk '{print $1}') local dup_count=$(( total - missing_count )) info "Found $dup_count duplicate(s), $missing_count file(s) to upload" if [[ "$missing_count" -eq 0 ]]; then info "Nothing to upload." rm -f "$tmp_json" "$tmp_json.json" "$tmp_missing" "$tmp_missing.exists" return fi # Upload missing files local n=0 while IFS= read -r file; do ((n++)) || true echo "[$n/$missing_count] $file" upload_file "$file" "$api_key" done < "$tmp_missing" rm -f "$tmp_json" "$tmp_json.json" "$tmp_missing" "$tmp_missing.exists" } # ------------------------------------------------------------------ # Main # ------------------------------------------------------------------ main() { if [[ $# -ne 1 ]]; then die "Usage: $(basename "$0") " fi local src="$1" if [[ ! -e "$src" ]]; then die "Path does not exist: $src" fi if [[ ! -r "$src" ]]; then die "Path is not readable: $src" fi if [[ ! -f "$API_KEY_FILE" ]]; then die "API key file not found: $API_KEY_FILE" fi local api_key api_key=$(cat "$API_KEY_FILE") if [[ -z "$api_key" ]]; then die "API key file is empty: $API_KEY_FILE" fi if [[ -f "$src" ]]; then # Single file upload info "Uploading single file: $src" upload_file "$src" "$api_key" elif [[ -d "$src" ]]; then # Directory: collect image files recursively local files_list files_list=$(mktemp) trap "rm -f $files_list" EXIT info "Scanning directory: $src" find "$src" -type f -regextype posix-extended -iregex ".*$IMAGE_EXTENSIONS" -print > "$files_list" local count count=$(wc -l < "$files_list" | awk '{print $1}') if [[ "$count" -eq 0 ]]; then die "No image files found in: $src" fi info "Found $count image file(s)" bulk_upload "$api_key" "$files_list" rm -f "$files_list" else die "Unsupported path type: $src" fi } main "$@"