summaryrefslogtreecommitdiff
path: root/scripts/immich-upload
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-30 16:53:30 +0300
committerPaul Buetow <paul@buetow.org>2026-05-30 16:53:30 +0300
commitf2fecaa6ffef505da254b7083116ad840588634b (patch)
tree5662004b072ec15a509a45b87a31e811e7180b2f /scripts/immich-upload
parentcab9ae5285a140fab9f4341a8e20eb24b08adca5 (diff)
new
Diffstat (limited to 'scripts/immich-upload')
-rwxr-xr-xscripts/immich-upload262
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 "$@"