diff options
Diffstat (limited to 'scripts/immich-upload')
| -rwxr-xr-x | scripts/immich-upload | 262 |
1 files changed, 262 insertions, 0 deletions
diff --git a/scripts/immich-upload b/scripts/immich-upload new file mode 100755 index 0000000..82e9c3a --- /dev/null +++ b/scripts/immich-upload @@ -0,0 +1,262 @@ +#!/usr/bin/env bash +# Upload images to Immich, skipping duplicates via SHA1 checksum. +# Usage: immich-upload <file_or_directory> +# +# If <file_or_directory> 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") <file_or_directory>" + 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 "$@" |
