Skip to content

Instantly share code, notes, and snippets.

@ArnyminerZ
Last active January 20, 2026 09:02
Show Gist options
  • Select an option

  • Save ArnyminerZ/6b3c4c3532cfae743885a937bde62cd3 to your computer and use it in GitHub Desktop.

Select an option

Save ArnyminerZ/6b3c4c3532cfae743885a937bde62cd3 to your computer and use it in GitHub Desktop.
Immich WhatsApp Fix

This is an script that searches all photos and videos from WhatsApp (name contains -WA), on your Immich library, and replaces their date with the one provided in the file name.

Usage:

API_KEY=XXXX BASE_URL=http://localhost:2283 ./immich_fix_whatsapp.sh

It adds a tag to the assets (WHATSAPP_FIXED) to identify those modified resources, and not update them every time the script is executed.

#!/bin/env bash
# Requires: bash >= 4.0, jq >= 1.5, curl >= 7.58.0
#
# This script uses the immich API to find images that have WhatsApp-like filenames,
# then extracts dates from filenames and timestamps, compares them, and updates the API
# if mismatches are found. It assumes that the filename is correct enough for your needs,
# and that the timestamp is not.
#
# --- Usage ---
# To run this script from the command line, ensure that it is executable and that you
# have the required environment variables set.
# API_KEY: The key used for authenticating API requests.
# BASE_URL: The base URL of the API.
# > API_KEY=XXXX BASE_URL=http://localhost:2283 ./whatsapp.sh
set -euo pipefail
# Ensure API_KEY and BASE_URL are set
if [[ -z "${API_KEY:-}" ]]; then
echo "Error: API_KEY is not set." >&2
exit 1
fi
if [[ -z "${BASE_URL:-}" ]]; then
echo "Error: BASE_URL is not set." >&2
exit 1
fi
# --- Functions ---
# icurl: A wrapper around curl for making API requests with JSON headers and
# authentication.
icurl() {
local path="$1"
shift
local response
response=$(/usr/bin/curl "$@" -s -w "\n%{http_code}" --header 'Content-Type: application/json' --header 'Accept: application/json' --header "X-Api-Key: ${API_KEY}" "${BASE_URL}/api${path}")
local http_code
http_code=$(echo "$response" | tail -n1)
local body
body=$(echo "$response" | head -n-1)
if [[ $http_code -ge 200 && $http_code -le 299 ]]; then
echo "$body" | /usr/bin/jq
else
echo -e "Error: API request failed with status code $http_code\n$body" >&2
exit 1
fi
}
# fetch_metadata: Fetches metadata from the API for a given page.
fetch_metadata() {
local page="$1"
local json_payload
json_payload=$(jq -n --argjson page "$page" '{page: $page, withExif: true, isVisible: true, language: "en-GB", originalFileName: "-WA"}')
icurl /search/metadata -d "$json_payload"
}
# Tagging constants
TAG_NAME="WHATSAPP_FIXED"
TAG_ID=""
ASSETS_TO_TAG=()
# get_or_create_tag: Retrieves the tag ID or creates it if it doesn't exist.
get_or_create_tag() {
local tags_response
tags_response=$(icurl /tags -X GET)
# Try to find existing tag
TAG_ID=$(echo "$tags_response" | jq -r --arg name "$TAG_NAME" '.[] | select(.name == $name) | .id')
if [[ -z "$TAG_ID" || "$TAG_ID" == "null" ]]; then
echo "Tag '$TAG_NAME' not found. Creating..."
local create_response
local json_payload
json_payload=$(jq -n --arg name "$TAG_NAME" '{"type":"CUSTOM","name":$name}')
create_response=$(icurl /tags -X POST -d "$json_payload")
TAG_ID=$(echo "$create_response" | jq -r '.id')
echo "Created tag '$TAG_NAME' with ID: $TAG_ID"
else
echo "Found tag '$TAG_NAME' with ID: $TAG_ID"
fi
}
# bulk_tag_assets: Adds the fixed tag to all collected assets.
bulk_tag_assets() {
if [[ ${#ASSETS_TO_TAG[@]} -eq 0 ]]; then
echo "No assets to tag."
return
fi
echo "Tagging ${#ASSETS_TO_TAG[@]} assets..."
# Construct JSON payload using jq to ensure correct formatting
local json_payload
# Generate a JSON array of strings from the bash array
local ids_json
ids_json=$(printf '%s\n' "${ASSETS_TO_TAG[@]}" | jq -R . | jq -s .)
# Construct the final object
json_payload=$(jq -n --argjson ids "$ids_json" '{"ids": $ids}')
icurl /tags/"${TAG_ID}"/assets -X PUT -d "$json_payload" > /dev/null
echo "Successfully tagged all assets."
}
# Initialize counters
inspected_count=0
changed_count=0
skipped_count=0
# process_asset: Processes an asset by comparing and updating date information if
# necessary. Checks for existing tag to skip.
process_asset() {
local asset_id="$1"
local file_name="$2"
local timestamp="$3"
local tags_str="$4"
local exif_date="$5"
# Check if already tagged
if [[ "$tags_str" == *"$TAG_ID"* ]]; then
skipped_count=$((skipped_count + 1))
return
fi
# Increment inspected count
inspected_count=$((inspected_count + 1))
# Extract date from filename
local date_from_filename=""
if [[ "$file_name" =~ ([0-9]{8})-WA ]]; then
date_from_filename="${BASH_REMATCH[1]}"
fi
if [[ -z "$date_from_filename" ]]; then
# echo "Skipping $file_name (no date in filename)"
return
fi
# Check against EXIF date first (source of truth)
local date_from_exif=""
if [[ -n "$exif_date" && "$exif_date" != "null" ]]; then
# EXIF format is usually YYYY:MM:DD HH:mm:ss or YYYY-MM-DDTHH:mm:ss
# Remove separators to get YYYYMMDD
date_from_exif=$(echo "$exif_date" | cut -c 1-10 | tr -d ':-')
fi
if [[ "$date_from_exif" == "$date_from_filename" ]]; then
# Date is already correct in EXIF, skip
return
fi
# Fallback check against localDateTime if EXIF was missing (or just mismatched)
# But technically if EXIF was missing, date_from_exif is empty != date_from_filename, so we proceed.
# If EXIF was present but mismatched, we modify.
local time_part
time_part=$(echo "$timestamp" | cut -d'T' -f2-)
local year=${date_from_filename:0:4}
local month=${date_from_filename:4:2}
local day=${date_from_filename:6:2}
local new_date_part="${year}-${month}-${day}"
local new_timestamp="${new_date_part}T${time_part}"
local json_payload
json_payload=$(jq -n --arg dt "$new_timestamp" '{"dateTimeOriginal": $dt}')
local new_date_time_original
# We only care if the command succeeds, capturing output for logging
new_date_time_original=$(icurl /assets/"${asset_id}" -X PUT -d "$json_payload" | jq -r '.exifInfo.dateTimeOriginal')
echo "Asset: $asset_id, $file_name (EXIF: $exif_date) -> $new_date_time_original"
# Queue for tagging
ASSETS_TO_TAG+=("$asset_id")
# Increment changed count
changed_count=$((changed_count + 1))
}
# --- Main Execution ---
# 1. Get or Create Tag
get_or_create_tag
if [[ -z "$TAG_ID" || "$TAG_ID" == "null" ]]; then
echo "Error: Failed to obtain TAG_ID for '$TAG_NAME'. Exiting." >&2
exit 1
fi
# --- Pagination ---
# PAGE: Tracks the current page of results.
# CONTINUE: Controls whether to continue fetching pages.
PAGE=1
CONTINUE=true
while [[ "$CONTINUE" == true ]]; do
# Fetch metadata from the API.
RESULT=$(fetch_metadata "$PAGE")
if [[ $(echo "$RESULT" | jq '.assets.items | length') -eq 0 ]]; then
CONTINUE=false
else
# Parse items, including tags and dateTimeOriginal.
while IFS=$'\t' read -r asset_id file_name timestamp tags exif_date; do
process_asset "$asset_id" "$file_name" "$timestamp" "$tags" "$exif_date"
done <<< "$(echo "$RESULT" | jq -r '.assets.items[] | [.id, .originalFileName, .localDateTime, ((.tags // []) | map(.id) | join(",")), .exifInfo.dateTimeOriginal] | @tsv')"
PAGE=$((PAGE + 1))
fi
done
# Bulk tag assets
bulk_tag_assets
# Print summary
echo "Summary: $inspected_count assets inspected, $changed_count dates changed, $skipped_count skipped."
@kueckk
Copy link

kueckk commented Jan 20, 2026

This worked great. But it ends with an error and the tag is not there. Looks like it is too many pictures. But everything has a date.

@kueckk
Copy link

kueckk commented Jan 20, 2026

I put line 210 "bulk_tag_assets" inside of the while-loop line 205 and get the tag after every 250 items (every page). Therefore it doesn't get too many items and confuses some library.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment