Initial commit

This commit is contained in:
Johan 2026-02-01 02:05:06 -05:00
commit bad6965414
18 changed files with 2064 additions and 0 deletions

21
.gitignore vendored Normal file
View File

@ -0,0 +1,21 @@
# Databases
*.db
*.sqlite
# Python
__pycache__/
*.py[cod]
*$py.class
.Python
venv/
.env
# Data files
*.csv
*.txt
!requirements.txt
# IDE
.idea/
.vscode/
*.swp

119
build_hash_index.py Executable file
View File

@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""Build hash index using first 64KB of files for fast duplicate detection."""
import sqlite3
import hashlib
import os
import sys
from pathlib import Path
DB_PATH = "/home/johan/immich-compare/hash_index.db"
EXTENSIONS = {'.jpg', '.jpeg', '.png', '.heic', '.gif', '.mp4', '.mov', '.avi', '.m4v', '.3gp'}
def hash_file_head(filepath, size=65536):
"""Hash first 64KB of file."""
try:
with open(filepath, 'rb') as f:
return hashlib.md5(f.read(size)).hexdigest()
except:
return None
def create_db():
if os.path.exists(DB_PATH):
os.remove(DB_PATH)
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''CREATE TABLE files (
id INTEGER PRIMARY KEY,
source TEXT,
filename TEXT,
filepath TEXT,
filesize INTEGER,
hash64k TEXT
)''')
c.execute('CREATE INDEX idx_hash ON files(hash64k)')
c.execute('CREATE INDEX idx_source ON files(source)')
c.execute('CREATE INDEX idx_filename ON files(filename)')
c.execute('CREATE INDEX idx_hash_source ON files(hash64k, source)')
conn.commit()
return conn
def scan_directory(conn, source_name, base_path):
"""Scan a directory and add files to database."""
c = conn.cursor()
count = 0
for root, dirs, files in os.walk(base_path):
for fname in files:
ext = os.path.splitext(fname)[1].lower()
if ext not in EXTENSIONS:
continue
filepath = os.path.join(root, fname)
try:
filesize = os.path.getsize(filepath)
if filesize == 0:
continue
hash64k = hash_file_head(filepath)
if hash64k:
c.execute('INSERT INTO files (source, filename, filepath, filesize, hash64k) VALUES (?, ?, ?, ?, ?)',
(source_name, fname, filepath, filesize, hash64k))
count += 1
if count % 1000 == 0:
print(f" {source_name}: {count} files...")
conn.commit()
except Exception as e:
pass
conn.commit()
print(f" {source_name}: {count} files total")
return count
def main():
print("Building hash index for all sources...")
conn = create_db()
sources = [
("mylio", "/tank/mylio-backup/Mylio"),
("immich", "/tank/immich/library/library/admin"),
("takeout", "/tank/staging/Data/Google Takeout 20210421/Takeout/Google Photos"),
]
total = 0
for name, path in sources:
if os.path.exists(path):
print(f"\nScanning {name}: {path}")
total += scan_directory(conn, name, path)
else:
print(f"\nSkipping {name}: {path} not found")
# Summary
c = conn.cursor()
print(f"\n{'='*50}")
print(f"Total files indexed: {total}")
for name, _ in sources:
c.execute("SELECT COUNT(*) FROM files WHERE source = ?", (name,))
cnt = c.fetchone()[0]
print(f" {name}: {cnt}")
# Find duplicates
c.execute("""
SELECT hash64k, COUNT(*) as cnt, GROUP_CONCAT(DISTINCT source) as sources
FROM files
GROUP BY hash64k
HAVING COUNT(DISTINCT source) > 1
LIMIT 10
""")
print(f"\nSample cross-source duplicates:")
for row in c.fetchall():
print(f" {row[0][:12]}... appears in: {row[2]}")
conn.close()
print(f"\nDone! Database: {DB_PATH}")
if __name__ == "__main__":
main()

177
build_mylio_db.py Executable file
View File

@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""Build a SQLite database from local Mylio backup with EXIF metadata."""
import subprocess
import sqlite3
import os
import json
import re
from pathlib import Path
DB_PATH = "/home/johan/immich-compare/mylio_index.db"
MYLIO_PATH = "/tank/mylio-backup/Mylio"
EXTENSIONS = {'.jpg', '.jpeg', '.png', '.heic', '.gif', '.mp4', '.mov', '.avi', '.m4v', '.3gp'}
def create_db():
if os.path.exists(DB_PATH):
os.remove(DB_PATH)
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''CREATE TABLE files (
id INTEGER PRIMARY KEY,
filename TEXT,
filepath TEXT,
filesize INTEGER,
year_folder TEXT,
date_original TEXT,
create_date TEXT,
make TEXT,
model TEXT,
software TEXT,
comment TEXT,
gps_lat REAL,
gps_lon REAL,
width INTEGER,
height INTEGER,
color_profile TEXT,
xmp_date TEXT
)''')
c.execute('CREATE INDEX idx_filename ON files(filename)')
c.execute('CREATE INDEX idx_filesize ON files(filesize)')
c.execute('CREATE INDEX idx_filename_size ON files(filename, filesize)')
c.execute('CREATE INDEX idx_date ON files(date_original)')
c.execute('CREATE INDEX idx_model ON files(model)')
c.execute('CREATE INDEX idx_software ON files(software)')
conn.commit()
return conn
def get_exif_batch(files):
"""Get EXIF data for multiple files using exiftool JSON output."""
if not files:
return []
cmd = ['exiftool', '-json', '-fast',
'-DateTimeOriginal', '-CreateDate', '-Make', '-Model',
'-Software', '-Comment', '-GPSLatitude', '-GPSLongitude',
'-ImageWidth', '-ImageHeight', '-ProfileDescription'] + files
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.stdout:
return json.loads(result.stdout)
except:
pass
return []
def get_xmp_date(filepath):
"""Read DateTimeOriginal from XMP sidecar if exists."""
for xmp_path in [filepath + '.xmp', re.sub(r'\.[^.]+$', '.xmp', filepath)]:
if os.path.exists(xmp_path):
try:
with open(xmp_path, 'r', errors='ignore') as f:
content = f.read()
match = re.search(r'DateTimeOriginal="([^"]+)"', content)
if match:
return match.group(1)[:10]
except:
pass
return None
def main():
print("Building Mylio database from /tank/mylio-backup/Mylio...")
conn = create_db()
c = conn.cursor()
# Collect all files
all_files = []
for root, dirs, files in os.walk(MYLIO_PATH):
for fname in files:
ext = os.path.splitext(fname)[1].lower()
if ext in EXTENSIONS:
all_files.append(os.path.join(root, fname))
print(f"Found {len(all_files)} media files")
# Process in batches
batch_size = 100
count = 0
for i in range(0, len(all_files), batch_size):
batch = all_files[i:i+batch_size]
exif_data = get_exif_batch(batch)
# Create lookup by source file
exif_lookup = {}
for item in exif_data:
src = item.get('SourceFile', '')
exif_lookup[src] = item
for filepath in batch:
filename = os.path.basename(filepath)
filesize = os.path.getsize(filepath)
# Extract year folder
year_match = re.search(r'/Mylio/(\d{4})/', filepath)
year_folder = year_match.group(1) if year_match else None
# Get EXIF data
exif = exif_lookup.get(filepath, {})
date_original = exif.get('DateTimeOriginal', '')
if date_original:
date_original = str(date_original)[:10].replace(':', '-')
create_date = exif.get('CreateDate', '')
if create_date:
create_date = str(create_date)[:10].replace(':', '-')
# Get XMP date
xmp_date = get_xmp_date(filepath)
# GPS coordinates
gps_lat = exif.get('GPSLatitude')
gps_lon = exif.get('GPSLongitude')
if isinstance(gps_lat, str):
gps_lat = None
if isinstance(gps_lon, str):
gps_lon = None
c.execute('''INSERT INTO files
(filename, filepath, filesize, year_folder, date_original, create_date,
make, model, software, comment, gps_lat, gps_lon, width, height,
color_profile, xmp_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''',
(filename, filepath, filesize, year_folder,
date_original or None, create_date or None,
exif.get('Make'), exif.get('Model'), exif.get('Software'),
exif.get('Comment'), gps_lat, gps_lon,
exif.get('ImageWidth'), exif.get('ImageHeight'),
exif.get('ProfileDescription'), xmp_date))
count += 1
if count % 1000 == 0:
print(f" Processed {count} files...")
conn.commit()
conn.commit()
# Print summary
c.execute("SELECT COUNT(*) FROM files")
total = c.fetchone()[0]
c.execute("SELECT COUNT(*) FROM files WHERE date_original IS NOT NULL OR xmp_date IS NOT NULL")
with_date = c.fetchone()[0]
c.execute("SELECT COUNT(DISTINCT model) FROM files WHERE model IS NOT NULL")
cameras = c.fetchone()[0]
print(f"\nDone! Created {DB_PATH}")
print(f" Total files: {total}")
print(f" Files with dates: {with_date}")
print(f" Unique cameras: {cameras}")
conn.close()
if __name__ == "__main__":
main()

190
check-corruption.py Executable file
View File

@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""
Check for corrupted files in Immich by comparing database records with actual file sizes on disk.
"""
import json
import os
import subprocess
import sys
from pathlib import Path
# Configuration
IMMICH_API_URL = "http://localhost:2283/api"
IMMICH_API_KEY = "GsWQUTR6EXlkKp1M82jDJ3KmzhM0fMAbbIbfHDyI"
IMMICH_LIBRARY_PATH = "/tank/immich/library"
# Minimum sizes for real media files (bytes)
MIN_SIZES = {
'IMAGE': 10_000, # 10 KB - real photos are larger
'VIDEO': 100_000, # 100 KB - real videos are larger
}
# Suspiciously small threshold
SUSPICIOUS_SIZE = 50_000 # 50 KB
def get_all_assets():
"""Fetch all assets from Immich API."""
print("Fetching assets from Immich API...")
all_assets = []
page = 1
page_size = 1000
while True:
cmd = [
"curl", "-s",
"-H", f"x-api-key: {IMMICH_API_KEY}",
"-H", "Content-Type: application/json",
f"{IMMICH_API_URL}/search/metadata",
"-d", json.dumps({"size": page_size, "page": page})
]
result = subprocess.run(cmd, capture_output=True, text=True)
data = json.loads(result.stdout)
items = data.get('assets', {}).get('items', [])
if not items:
break
all_assets.extend(items)
print(f" Fetched {len(all_assets)} assets...")
if len(items) < page_size:
break
page += 1
print(f" Total: {len(all_assets)} assets")
return all_assets
def check_file_on_disk(original_path: str) -> tuple[bool, int]:
"""
Check if file exists on disk and get its size.
Returns (exists, size_bytes)
"""
# Convert container path to host path
# /data/library/... -> /tank/immich/library/...
disk_path = original_path.replace("/data/library", IMMICH_LIBRARY_PATH + "/library")
disk_path = original_path.replace("/data/", IMMICH_LIBRARY_PATH + "/")
path = Path(disk_path)
if path.exists():
return True, path.stat().st_size
return False, 0
def format_size(size_bytes: int) -> str:
"""Format bytes as human-readable size."""
if size_bytes < 1024:
return f"{size_bytes} B"
elif size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.1f} KB"
elif size_bytes < 1024 * 1024 * 1024:
return f"{size_bytes / (1024 * 1024):.1f} MB"
else:
return f"{size_bytes / (1024 * 1024 * 1024):.1f} GB"
def analyze_assets(assets: list) -> dict:
"""Analyze all assets and categorize issues."""
results = {
'missing': [], # File doesn't exist on disk
'corrupted': [], # File exists but suspiciously small
'no_thumbnail': [], # File exists but no thumbnail generated
'ok': [], # File looks fine
}
print("\nAnalyzing files on disk...")
for i, asset in enumerate(assets):
if (i + 1) % 500 == 0:
print(f" Checked {i + 1}/{len(assets)}...")
asset_id = asset.get('id')
filename = asset.get('originalFileName', 'unknown')
original_path = asset.get('originalPath', '')
asset_type = asset.get('type', 'IMAGE')
thumbhash = asset.get('thumbhash')
exists, disk_size = check_file_on_disk(original_path)
info = {
'id': asset_id,
'filename': filename,
'path': original_path,
'type': asset_type,
'disk_size': disk_size,
'disk_size_human': format_size(disk_size),
'has_thumbnail': thumbhash is not None,
}
if not exists:
results['missing'].append(info)
elif disk_size < SUSPICIOUS_SIZE:
results['corrupted'].append(info)
elif not thumbhash:
results['no_thumbnail'].append(info)
else:
results['ok'].append(info)
return results
def main():
# Get all assets
assets = get_all_assets()
# Analyze
results = analyze_assets(assets)
# Report
print("\n" + "=" * 70)
print("RESULTS")
print("=" * 70)
print(f"Total assets: {len(assets):,}")
print(f"OK: {len(results['ok']):,}")
print(f"Missing from disk: {len(results['missing']):,}")
print(f"Corrupted (tiny): {len(results['corrupted']):,}")
print(f"No thumbnail: {len(results['no_thumbnail']):,}")
print("=" * 70)
# Write detailed reports
if results['corrupted']:
print(f"\n--- CORRUPTED FILES (< {format_size(SUSPICIOUS_SIZE)}) ---")
with open('corrupted_files.txt', 'w') as f:
for item in sorted(results['corrupted'], key=lambda x: x['disk_size']):
line = f"{item['disk_size_human']:>10} {item['filename']} ({item['path']})"
print(line)
f.write(f"{item['filename']}\t{item['disk_size']}\t{item['path']}\n")
print(f"\nList saved to: corrupted_files.txt")
if results['missing']:
print(f"\n--- MISSING FILES ---")
with open('missing_from_disk.txt', 'w') as f:
for item in results['missing'][:20]: # Show first 20
print(f" {item['filename']} ({item['path']})")
f.write(f"{item['filename']}\t{item['path']}\n")
if len(results['missing']) > 20:
print(f" ... and {len(results['missing']) - 20} more")
for item in results['missing'][20:]:
f.write(f"{item['filename']}\t{item['path']}\n")
print(f"\nList saved to: missing_from_disk.txt")
if results['no_thumbnail']:
print(f"\n--- NO THUMBNAIL (first 20) ---")
with open('no_thumbnail.txt', 'w') as f:
for item in results['no_thumbnail'][:20]:
print(f" {item['disk_size_human']:>10} {item['filename']}")
f.write(f"{item['filename']}\t{item['disk_size']}\t{item['path']}\n")
if len(results['no_thumbnail']) > 20:
print(f" ... and {len(results['no_thumbnail']) - 20} more")
for item in results['no_thumbnail'][20:]:
f.write(f"{item['filename']}\t{item['disk_size']}\t{item['path']}\n")
print(f"\nList saved to: no_thumbnail.txt")
if __name__ == "__main__":
main()

90
date_finder.py Executable file
View File

@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Find probable date range for a photo by looking at neighboring filenames."""
import sqlite3
import re
import sys
DB_PATH = "/home/johan/immich-compare/immich_index.db"
def get_neighbors(filename, conn):
"""Find files with similar names and show their dates."""
# Extract prefix and number from filename like IMG_0656.jpg
match = re.match(r'^([A-Za-z_]+)(\d+)', filename)
if not match:
return None
prefix = match.group(1)
num = int(match.group(2))
# Search for nearby numbers (±20)
results = []
c = conn.cursor()
for offset in range(-20, 21):
test_num = num + offset
if test_num < 0:
continue
# Try different formats for the number
for fmt in [f"{test_num:04d}", f"{test_num:05d}", f"{test_num}"]:
pattern = f"{prefix}{fmt}%"
c.execute("""SELECT filename, date, id FROM assets
WHERE filename LIKE ? AND date != '2024-01-28'
ORDER BY filename""", (pattern,))
for row in c.fetchall():
if row not in results:
results.append(row)
# Sort by filename
results.sort(key=lambda x: x[0])
return results
def show_context(filename):
conn = sqlite3.connect(DB_PATH)
# Check if this file exists and its current date
c = conn.cursor()
c.execute("SELECT id, date FROM assets WHERE filename = ?", (filename,))
current = c.fetchone()
print(f"\n=== {filename} ===")
if current:
print(f"Current date: {current[1]} (ID: {current[0]})")
else:
print("Not found in Immich")
neighbors = get_neighbors(filename, conn)
if neighbors:
print(f"\nNeighboring files with known dates:")
print("-" * 50)
dates = set()
for fname, date, fid in neighbors:
marker = " <--" if fname.upper().startswith(filename.upper().split('.')[0]) else ""
print(f" {fname:30} {date}{marker}")
if date != '2024-01-28':
dates.add(date)
if dates:
dates = sorted(dates)
print("-" * 50)
print(f"Date range: {dates[0]} to {dates[-1]}")
else:
print("No neighboring files found")
conn.close()
if __name__ == "__main__":
if len(sys.argv) > 1:
show_context(sys.argv[1])
else:
# Show all Jan 28, 2024 files that match IMG_xxxx pattern
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("""SELECT filename FROM assets
WHERE date = '2024-01-28'
AND filename LIKE 'IMG_%'
ORDER BY filename""")
print("Jan 28, 2024 files needing dates:")
for row in c.fetchall():
print(f" {row[0]}")
conn.close()

138
delete_corrupted.py Normal file
View File

@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Delete corrupted versions of files from Immich after good versions were uploaded."""
import requests
import sys
API_KEY = "GsWQUTR6EXlkKp1M82jDJ3KmzhM0fMAbbIbfHDyI"
BASE_URL = "http://localhost:2283/api"
# Map of filename -> corrupted path from corrupted_files.txt
CORRUPTED_PATHS = {
"2010-04-03_14-07-26_406.mp4": "/data/library/admin/2010/04/2010-04-03_14-07-26_406+1.mp4",
"2010-04-03_14-07-52_756_Utrecht.mp4": "/data/library/admin/2010/04/2010-04-03_14-07-52_756_Utrecht+1.mp4",
"2010-04-04_16-02-21_184_Noordoostpolder.mp4": "/data/library/admin/2010/04/2010-04-04_16-02-21_184_Noordoostpolder+1.mp4",
"2010-04-04_16-02-44_615_Noordoostpolder.mp4": "/data/library/admin/2010/04/2010-04-04_16-02-44_615_Noordoostpolder+1.mp4",
"2010-04-16_17-22-35_167_Noordoostpolder.mp4": "/data/library/admin/2010/04/2010-04-16_17-22-35_167_Noordoostpolder+1.mp4",
"des (1).AVI": "/data/library/admin/2012/09/des (1).avi",
"des (11).AVI": "/data/library/admin/2012/09/des (11).avi",
"des (12).AVI": "/data/library/admin/2012/09/des (12).avi",
"des (13).AVI": "/data/library/admin/2012/09/des (13).avi",
"des (14).AVI": "/data/library/admin/2012/09/des (14).avi",
"des (15).AVI": "/data/library/admin/2012/09/des (15).avi",
"des (16).AVI": "/data/library/admin/2012/09/des (16).avi",
"des (2).AVI": "/data/library/admin/2012/09/des (2).avi",
"des (6).AVI": "/data/library/admin/2012/09/des (6).avi",
"des (7).AVI": "/data/library/admin/2012/09/des (7).avi",
"IMG_0024.MOV": "/data/library/admin/2010/11/IMG_0024+1.mov",
"IMG_0067.MOV": "/data/library/admin/2010/11/IMG_0067+1.mov",
"IMG_0146.MOV": "/data/library/admin/2011/06/IMG_0146+1.mov",
"IMG_0148.MOV": "/data/library/admin/2011/06/IMG_0148+1.mov",
"IMG_0149.MOV": "/data/library/admin/2011/06/IMG_0149+1.mov",
"IMG_0156.MOV": "/data/library/admin/2011/06/IMG_0156+1.mov",
"IMG_0157.MOV": "/data/library/admin/2011/06/IMG_0157+1.mov",
"IMG_0164.MOV": "/data/library/admin/2011/06/IMG_0164+1.mov",
"IMG_0165.MOV": "/data/library/admin/2011/06/IMG_0165+1.mov",
"IMG_0172.MOV": "/data/library/admin/2010/08/IMG_0172+1.mov",
"IMG_0178.MOV": "/data/library/admin/2010/08/IMG_0178+1.mov",
"IMG_0179.MOV": "/data/library/admin/2011/07/IMG_0179+1.mov",
"IMG_0182.MOV": "/data/library/admin/2010/08/IMG_0182+1.mov",
"IMG_0183.MOV": "/data/library/admin/2010/08/IMG_0183+1.mov",
"IMG_0184.MOV": "/data/library/admin/2010/08/IMG_0184+1.mov",
"IMG_0185.MOV": "/data/library/admin/2010/08/IMG_0185+1.mov",
"IMG_0187.MOV": "/data/library/admin/2010/08/IMG_0187+1.mov",
"IMG_0554.MOV": "/data/library/admin/2012/09/IMG_0554+1.mov",
"IMG_0555.MOV": "/data/library/admin/2012/09/IMG_0555+1.mov",
"IMG_0558.MOV": "/data/library/admin/2012/09/IMG_0558+1.mov",
"IMG_0581.MOV": "/data/library/admin/2012/11/IMG_0581+1.mov",
"IMG_0584.MOV": "/data/library/admin/2012/11/IMG_0584+1.mov",
"IMG_0586.MOV": "/data/library/admin/2012/11/IMG_0586+1.mov",
"IMG_0591.MOV": "/data/library/admin/2012/11/IMG_0591+1.mov",
"MVI_1077.AVI": "/data/library/admin/2012/09/MVI_1077.avi",
"MVI_1079.AVI": "/data/library/admin/2012/09/MVI_1079.avi",
"MVI_1080.AVI": "/data/library/admin/2012/09/MVI_1080.avi",
"MVI_1085.AVI": "/data/library/admin/2012/09/MVI_1085.avi",
}
def search_file(filename):
"""Search for a file by name."""
resp = requests.post(
f"{BASE_URL}/search/metadata",
headers={"x-api-key": API_KEY, "Content-Type": "application/json"},
json={"originalFileName": filename}
)
if resp.status_code == 200:
data = resp.json()
return data.get("assets", {}).get("items", [])
return []
def delete_asset(asset_id, filename):
"""Delete an asset by ID."""
resp = requests.delete(
f"{BASE_URL}/assets",
headers={"x-api-key": API_KEY, "Content-Type": "application/json"},
json={"ids": [asset_id], "force": True}
)
if resp.status_code == 204:
print(f" DELETED: {filename}")
return True
else:
print(f" FAILED to delete {filename}: {resp.status_code} {resp.text}")
return False
def main():
deleted = 0
not_found = 0
errors = 0
for filename, corrupted_path in CORRUPTED_PATHS.items():
print(f"\nProcessing: {filename}")
# Search for all versions of this file
assets = search_file(filename)
if not assets:
print(f" No assets found for {filename}")
not_found += 1
continue
print(f" Found {len(assets)} matches")
# Find the one with the corrupted path (case-insensitive compare)
corrupted_path_lower = corrupted_path.lower()
found_corrupted = False
for asset in assets:
asset_path = asset.get("originalPath", "")
print(f" - {asset['id'][:8]}... path={asset_path}")
if asset_path.lower() == corrupted_path_lower:
print(f" Found corrupted version at: {asset_path}")
if delete_asset(asset["id"], filename):
deleted += 1
found_corrupted = True
else:
errors += 1
break
if not found_corrupted and len(assets) > 0:
# Maybe already deleted or path doesn't match exactly
print(f" WARNING: Could not find corrupted path {corrupted_path}")
# Check if any has +1 in path (corrupted marker)
for asset in assets:
asset_path = asset.get("originalPath", "")
if "+1" in asset_path or "/2012/09/" in asset_path:
print(f" Found likely corrupted version at: {asset_path}")
if delete_asset(asset["id"], filename):
deleted += 1
else:
errors += 1
break
print(f"\n\nSummary:")
print(f" Deleted: {deleted}")
print(f" Not found: {not_found}")
print(f" Errors: {errors}")
if __name__ == "__main__":
main()

92
delete_corrupted_plus1.py Normal file
View File

@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Delete corrupted +1 video files from Immich."""
import requests
API_KEY = "GsWQUTR6EXlkKp1M82jDJ3KmzhM0fMAbbIbfHDyI"
BASE_URL = "http://localhost:2283/api"
# Corrupted +1 files (path -> container path)
CORRUPTED_FILES = [
"/data/library/admin/2010/04/2010-04-14_07-38-01_290_Heerenveen+1.mp4",
"/data/library/admin/2012/01/IMG_0259+1.mov",
"/data/library/admin/2012/01/IMG_0262+1.mov",
"/data/library/admin/2010/08/IMG_0169+1.mov",
"/data/library/admin/2012/08/MVI_0342+1.mov",
"/data/library/admin/2012/08/MVI_0496+1.mov",
"/data/library/admin/2012/08/MVI_0462+1.mov",
"/data/library/admin/2012/08/MVI_0452+1.mov",
"/data/library/admin/2012/08/MVI_0448+1.mov",
"/data/library/admin/2012/08/MVI_0463+1.mov",
"/data/library/admin/2012/08/MVI_0468+1.mov",
"/data/library/admin/2012/08/MVI_0534+1.mov",
"/data/library/admin/2012/08/MVI_0446+1.mov",
"/data/library/admin/2012/08/MVI_0466+1.mov",
"/data/library/admin/2012/08/MVI_0335+1.mov",
"/data/library/admin/2012/08/MVI_0495+1.mov",
"/data/library/admin/2012/08/MVI_0531+1.mov",
"/data/library/admin/2012/08/MVI_0404+1.mov",
"/data/library/admin/2012/08/MVI_0467+1.mov",
"/data/library/admin/2012/08/MVI_0454+1.mov",
"/data/library/admin/2012/08/MVI_0389+1.mov",
"/data/library/admin/2012/08/MVI_0469+1.mov",
"/data/library/admin/2011/06/IMG_0147+1.mov",
"/data/library/admin/2012/08/MVI_0465+1.mov",
"/data/library/admin/2012/08/MVI_0444+1.mov",
"/data/library/admin/2012/08/MVI_0470+1.mov",
"/data/library/admin/2012/08/MVI_0440+1.mov",
"/data/library/admin/2010/04/2010-04-04_16-06-22_374_Noordoostpolder+1.mp4",
"/data/library/admin/2012/06/MVI_0060+1.mov",
"/data/library/admin/2012/11/IMG_0582+1.mov",
]
def get_all_assets():
"""Get all assets to find by path."""
assets = []
page = 1
while True:
resp = requests.get(
f"{BASE_URL}/assets",
headers={"x-api-key": API_KEY},
params={"page": page, "size": 1000}
)
if resp.status_code != 200:
break
data = resp.json()
if not data:
break
assets.extend(data)
page += 1
if len(data) < 1000:
break
return assets
def delete_asset(asset_id, path):
resp = requests.delete(
f"{BASE_URL}/assets",
headers={"x-api-key": API_KEY, "Content-Type": "application/json"},
json={"ids": [asset_id], "force": True}
)
if resp.status_code == 204:
print(f"DELETED: {path}")
return True
else:
print(f"FAILED: {path} - {resp.status_code}")
return False
print("Fetching all assets...")
assets = get_all_assets()
print(f"Found {len(assets)} assets")
# Build path -> id map
path_to_id = {a["originalPath"]: a["id"] for a in assets}
deleted = 0
for path in CORRUPTED_FILES:
if path in path_to_id:
if delete_asset(path_to_id[path], path):
deleted += 1
else:
print(f"NOT FOUND: {path}")
print(f"\nTotal deleted: {deleted}")

View File

@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Delete corrupted +1 video files from Immich."""
import requests
API_KEY = "GsWQUTR6EXlkKp1M82jDJ3KmzhM0fMAbbIbfHDyI"
BASE_URL = "http://localhost:2283/api"
# Corrupted files: (filename, corrupted path)
CORRUPTED_FILES = [
("2010-04-14_07-38-01_290_Heerenveen+1.mp4", "/data/library/admin/2010/04/2010-04-14_07-38-01_290_Heerenveen+1.mp4"),
("IMG_0259+1.mov", "/data/library/admin/2012/01/IMG_0259+1.mov"),
("IMG_0262+1.mov", "/data/library/admin/2012/01/IMG_0262+1.mov"),
("IMG_0169+1.mov", "/data/library/admin/2010/08/IMG_0169+1.mov"),
("MVI_0342+1.mov", "/data/library/admin/2012/08/MVI_0342+1.mov"),
("MVI_0496+1.mov", "/data/library/admin/2012/08/MVI_0496+1.mov"),
("MVI_0462+1.mov", "/data/library/admin/2012/08/MVI_0462+1.mov"),
("MVI_0452+1.mov", "/data/library/admin/2012/08/MVI_0452+1.mov"),
("MVI_0448+1.mov", "/data/library/admin/2012/08/MVI_0448+1.mov"),
("MVI_0463+1.mov", "/data/library/admin/2012/08/MVI_0463+1.mov"),
("MVI_0468+1.mov", "/data/library/admin/2012/08/MVI_0468+1.mov"),
("MVI_0534+1.mov", "/data/library/admin/2012/08/MVI_0534+1.mov"),
("MVI_0446+1.mov", "/data/library/admin/2012/08/MVI_0446+1.mov"),
("MVI_0466+1.mov", "/data/library/admin/2012/08/MVI_0466+1.mov"),
("MVI_0335+1.mov", "/data/library/admin/2012/08/MVI_0335+1.mov"),
("MVI_0495+1.mov", "/data/library/admin/2012/08/MVI_0495+1.mov"),
("MVI_0531+1.mov", "/data/library/admin/2012/08/MVI_0531+1.mov"),
("MVI_0404+1.mov", "/data/library/admin/2012/08/MVI_0404+1.mov"),
("MVI_0467+1.mov", "/data/library/admin/2012/08/MVI_0467+1.mov"),
("MVI_0454+1.mov", "/data/library/admin/2012/08/MVI_0454+1.mov"),
("MVI_0389+1.mov", "/data/library/admin/2012/08/MVI_0389+1.mov"),
("MVI_0469+1.mov", "/data/library/admin/2012/08/MVI_0469+1.mov"),
("IMG_0147+1.mov", "/data/library/admin/2011/06/IMG_0147+1.mov"),
("MVI_0465+1.mov", "/data/library/admin/2012/08/MVI_0465+1.mov"),
("MVI_0444+1.mov", "/data/library/admin/2012/08/MVI_0444+1.mov"),
("MVI_0470+1.mov", "/data/library/admin/2012/08/MVI_0470+1.mov"),
("MVI_0440+1.mov", "/data/library/admin/2012/08/MVI_0440+1.mov"),
("2010-04-04_16-06-22_374_Noordoostpolder+1.mp4", "/data/library/admin/2010/04/2010-04-04_16-06-22_374_Noordoostpolder+1.mp4"),
("MVI_0060+1.mov", "/data/library/admin/2012/06/MVI_0060+1.mov"),
("IMG_0582+1.mov", "/data/library/admin/2012/11/IMG_0582+1.mov"),
]
def search_file(filename):
resp = requests.post(
f"{BASE_URL}/search/metadata",
headers={"x-api-key": API_KEY, "Content-Type": "application/json"},
json={"originalFileName": filename}
)
if resp.status_code == 200:
return resp.json().get("assets", {}).get("items", [])
return []
def delete_asset(asset_id, path):
resp = requests.delete(
f"{BASE_URL}/assets",
headers={"x-api-key": API_KEY, "Content-Type": "application/json"},
json={"ids": [asset_id], "force": True}
)
if resp.status_code == 204:
print(f"DELETED: {path}")
return True
else:
print(f"FAILED: {path} - {resp.status_code}")
return False
deleted = 0
not_found = 0
for filename, expected_path in CORRUPTED_FILES:
# Search for this filename
# The filename in Immich might not have +1 in the originalFileName
# Try searching without the +1
base_name = filename.replace("+1", "")
assets = search_file(base_name)
found = False
for asset in assets:
if asset["originalPath"] == expected_path:
if delete_asset(asset["id"], expected_path):
deleted += 1
found = True
break
if not found:
# Try with +1 in filename
assets = search_file(filename)
for asset in assets:
if asset["originalPath"] == expected_path:
if delete_asset(asset["id"], expected_path):
deleted += 1
found = True
break
if not found:
print(f"NOT FOUND: {filename} at {expected_path}")
not_found += 1
print(f"\nTotal deleted: {deleted}")
print(f"Not found: {not_found}")

56
delete_sep10_corrupted.py Normal file
View File

@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Delete all corrupted files from 2012/09 folder in Immich."""
import requests
API_KEY = "GsWQUTR6EXlkKp1M82jDJ3KmzhM0fMAbbIbfHDyI"
BASE_URL = "http://localhost:2283/api"
# Files to delete (from 2012/09 folder)
CORRUPTED_FILES = [
"CIMG0057.avi", "MVI_0302.avi", "CIMG0066.avi", "MVI_0313.avi",
"MVI_0928.avi", "MVI_0872.avi", "MVI_0927.avi", "MVI_0174.avi",
"MVI_0312.avi", "MVI_1334.avi", "MVI_1253.avi", "CIMG0017.avi",
"MVI_0150.avi", "MVI_1333.avi", "MOV01161.avi", "MOV01027.avi",
"MOV00421.avi", "MVI_0068.avi", "MVI_0232.avi", "CIMG0067.avi",
"MOV00743.avi", "MOV00757.avi", "MOV00083.avi", "MVI_0314.avi",
"MVI_0088.avi", "MVI_0521.avi", "MVI_0074.avi", "MVI_0904.avi",
"MOV01159.avi", "MVI_0656.avi", "MVI_0087.avi", "MOV00689.avi",
"MVI_1502.avi", "MVI_1271.avi", "MOV00884.avi", "MVI_1473.avi",
"MVI_0192.avi", "MVI_0903.avi", "MVI_0190.avi", "MVI_0658.avi",
"MOV01026.avi", "DSCN0848.avi", "MVI_0657.avi", "MVI_0665.avi",
"MVI_0067.avi", "MVI_0191.avi", "MVI_0315.avi", "MVI_0193.avi",
"MOV00883.avi"
]
def search_file(filename):
resp = requests.post(
f"{BASE_URL}/search/metadata",
headers={"x-api-key": API_KEY, "Content-Type": "application/json"},
json={"originalFileName": filename}
)
if resp.status_code == 200:
return resp.json().get("assets", {}).get("items", [])
return []
def delete_asset(asset_id):
resp = requests.delete(
f"{BASE_URL}/assets",
headers={"x-api-key": API_KEY, "Content-Type": "application/json"},
json={"ids": [asset_id], "force": True}
)
return resp.status_code == 204
deleted = 0
for filename in CORRUPTED_FILES:
assets = search_file(filename)
for asset in assets:
path = asset.get("originalPath", "")
if "/2012/09/" in path:
if delete_asset(asset["id"]):
print(f"DELETED: {filename} from {path}")
deleted += 1
else:
print(f"FAILED: {filename}")
print(f"\nTotal deleted: {deleted}")

33
fix_mvi04_dates.py Normal file
View File

@ -0,0 +1,33 @@
#!/usr/bin/env python3
import subprocess, json
from urllib.request import Request, urlopen
API_KEY = "GsWQUTR6EXlkKp1M82jDJ3KmzhM0fMAbbIbfHDyI"
API_URL = "http://localhost:2283/api"
# Correct date: Aug 24, 2012
correct_date = "2012-08-24T12:00:00.000Z"
files = [
"MVI_0440.jpg", "MVI_0444.jpg", "MVI_0446.jpg", "MVI_0448.jpg",
"MVI_0454.jpg", "MVI_0462.jpg", "MVI_0463.jpg", "MVI_0465.jpg",
"MVI_0466.jpg", "MVI_0467.jpg", "MVI_0468.jpg", "MVI_0469.jpg",
"MVI_0470.jpg", "MVI_0495.jpg",
]
for fname in files:
# Find in 2015/02 where they were moved
result = subprocess.run([
"docker", "exec", "immich_postgres", "psql", "-U", "postgres", "-d", "immich", "-t", "-c",
f"SELECT id FROM asset WHERE \"originalFileName\" = '{fname}' AND \"deletedAt\" IS NULL;"
], capture_output=True, text=True)
asset_id = result.stdout.strip()
if asset_id:
data = json.dumps({"dateTimeOriginal": correct_date}).encode()
req = Request(f"{API_URL}/assets/{asset_id}", data=data, method="PUT",
headers={"x-api-key": API_KEY, "Content-Type": "application/json"})
urlopen(req)
print(f"Fixed {fname} -> 2012-08-24")
else:
print(f"NOT FOUND: {fname}")

241
immich-compare.py Executable file
View File

@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""
Photo Deduplication Tool: Compare old Windows drive against Immich library.
Identifies photos/videos on the old drive (via SSH to Mac Mini) that are NOT
already in Immich, using SHA-1 checksum comparison.
"""
import argparse
import subprocess
import sys
from collections import defaultdict
from pathlib import Path
# Configuration
IMMICH_DB_CONTAINER = "immich_postgres"
IMMICH_DB_NAME = "immich"
IMMICH_DB_USER = "postgres"
MAC_MINI_HOST = "macmini"
OLD_DRIVE_PATH = "/Volumes/Untitled/Users/Johan/Mylio/"
# File extensions to check (lowercase)
MEDIA_EXTENSIONS = {
'.jpg', '.jpeg', '.png', '.heic', '.heif',
'.mov', '.mp4', '.avi', '.gif', '.m4v', '.mkv', '.webp',
'.tiff', '.tif', '.bmp', '.raw', '.cr2', '.nef', '.arw', '.dng',
'.3gp', '.mts', '.webm'
}
# Directories to skip (Mylio-generated content, not actual photos)
SKIP_DIRECTORIES = {
'Generated Images',
}
def get_immich_checksums() -> set[str]:
"""Export all SHA-1 checksums from Immich's PostgreSQL database."""
print("Fetching checksums from Immich database...")
query = "SELECT encode(checksum, 'hex') FROM asset WHERE \"deletedAt\" IS NULL AND checksum IS NOT NULL;"
cmd = [
"docker", "exec", IMMICH_DB_CONTAINER,
"psql", "-U", IMMICH_DB_USER, "-d", IMMICH_DB_NAME,
"-t", "-A", "-c", query
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
checksums = set(line.strip().lower() for line in result.stdout.strip().split('\n') if line.strip())
print(f" Found {len(checksums):,} checksums in Immich")
return checksums
except subprocess.CalledProcessError as e:
print(f"Error querying Immich database: {e}", file=sys.stderr)
print(f"stderr: {e.stderr}", file=sys.stderr)
sys.exit(1)
def build_remote_scan_script(include_generated: bool = False) -> str:
"""Build the shell script to run on Mac Mini for scanning files."""
extensions_pattern = " -o ".join(f'-iname "*.{ext.lstrip(".")}"' for ext in MEDIA_EXTENSIONS)
# Build prune patterns for directories to skip
if include_generated:
prune_pattern = ""
else:
prune_dirs = " -o ".join(f'-name "{d}"' for d in SKIP_DIRECTORIES)
prune_pattern = f'\\( {prune_dirs} \\) -prune -o'
script = f'''
set -e
cd "{OLD_DRIVE_PATH}" 2>/dev/null || {{ echo "ERROR: Cannot access {OLD_DRIVE_PATH}" >&2; exit 1; }}
# Find all media files and calculate SHA-1
find . {prune_pattern} -type f \\( {extensions_pattern} \\) -print0 2>/dev/null | while IFS= read -r -d '' file; do
# Calculate SHA-1 checksum
checksum=$(shasum -a 1 "$file" 2>/dev/null | cut -d' ' -f1)
if [ -n "$checksum" ]; then
echo "$checksum $file"
fi
done
'''
return script
def scan_old_drive(checksums: set[str], include_generated: bool = False) -> tuple[list[str], int, int, int]:
"""
SSH to Mac Mini and scan the old drive, comparing against Immich checksums.
Returns: (missing_files, checked_count, found_count, skipped_count)
"""
print(f"\nScanning old drive via SSH to {MAC_MINI_HOST}...")
print(f" Path: {OLD_DRIVE_PATH}")
if not include_generated:
print(f" Skipping directories: {', '.join(SKIP_DIRECTORIES)}")
script = build_remote_scan_script(include_generated)
cmd = ["ssh", MAC_MINI_HOST, "bash -s"]
missing_files = []
checked_count = 0
found_count = 0
skipped_count = 0
try:
process = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
# Send script to remote bash
stdout, stderr = process.communicate(input=script, timeout=7200) # 2 hour timeout
if process.returncode != 0:
print(f"Error scanning remote drive: {stderr}", file=sys.stderr)
sys.exit(1)
# Process results
for line in stdout.strip().split('\n'):
if not line.strip():
continue
parts = line.split(' ', 1)
if len(parts) != 2:
continue
checksum, filepath = parts
checksum = checksum.lower().strip()
filepath = filepath.strip()
checked_count += 1
if checksum in checksums:
found_count += 1
else:
# Reconstruct full path
full_path = str(Path(OLD_DRIVE_PATH) / filepath.lstrip('./'))
missing_files.append(full_path)
# Progress update every 1000 files
if checked_count % 1000 == 0:
print(f" Processed {checked_count:,} files... ({found_count:,} in Immich, {len(missing_files):,} missing)")
return missing_files, checked_count, found_count, skipped_count
except subprocess.TimeoutExpired:
print("Error: Remote scan timed out after 2 hours", file=sys.stderr)
process.kill()
sys.exit(1)
except Exception as e:
print(f"Error during remote scan: {e}", file=sys.stderr)
sys.exit(1)
def analyze_missing_files(missing_files: list[str]) -> dict[str, list[str]]:
"""Group missing files by their parent directory for better analysis."""
by_folder = defaultdict(list)
for filepath in missing_files:
# Get the immediate parent folder name relative to Mylio root
rel_path = filepath.replace(OLD_DRIVE_PATH, '')
parts = rel_path.split('/')
if len(parts) >= 2:
folder = '/'.join(parts[:2]) # e.g., "Mylio/2020" or "Apple Photos"
else:
folder = parts[0] if parts else "root"
by_folder[folder].append(filepath)
return dict(by_folder)
def write_report(missing_files: list[str], output_file: Path):
"""Write the list of missing files to a text file."""
with open(output_file, 'w') as f:
for filepath in sorted(missing_files):
f.write(f"{filepath}\n")
print(f" Full list written to: {output_file}")
def main():
parser = argparse.ArgumentParser(
description="Compare photos/videos on old drive against Immich library"
)
parser.add_argument(
"--output", "-o",
type=Path,
default=Path("missing_files.txt"),
help="Output file for missing files list (default: missing_files.txt)"
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Only fetch Immich checksums, don't scan remote drive"
)
parser.add_argument(
"--include-generated",
action="store_true",
help="Include 'Generated Images' folder (Mylio thumbnails/previews)"
)
args = parser.parse_args()
# Step 1: Get Immich checksums
immich_checksums = get_immich_checksums()
if args.dry_run:
print("\n[DRY RUN] Skipping remote scan")
return
# Step 2: Scan old drive and compare
missing_files, checked_count, found_count, _ = scan_old_drive(
immich_checksums,
include_generated=args.include_generated
)
# Step 3: Report results
print("\n" + "=" * 60)
print("RESULTS")
print("=" * 60)
print(f"Total files checked: {checked_count:,}")
print(f"Already in Immich: {found_count:,}")
print(f"NOT in Immich: {len(missing_files):,}")
print("=" * 60)
if missing_files:
# Analyze by folder
by_folder = analyze_missing_files(missing_files)
print("\nBreakdown by folder:")
for folder in sorted(by_folder.keys()):
print(f" {folder}: {len(by_folder[folder]):,} files")
write_report(missing_files, args.output)
print(f"\nReview {args.output} to see files that need to be imported.")
else:
print("\nAll files from the old drive are already in Immich!")
if __name__ == "__main__":
main()

337
preview-tool/app.py Normal file
View File

@ -0,0 +1,337 @@
#!/usr/bin/env python3
"""Visual comparison tool for Jan 28, 2024 photos - no dependencies."""
from http.server import HTTPServer, BaseHTTPRequestHandler
import os
import subprocess
import glob
import urllib.parse
LIBRARY = "/tank/immich/library/library/admin"
JAN2024 = f"{LIBRARY}/2024/01"
def get_sample_files_from_immich(limit=10):
"""Get IMG files from 2024/01 that are actually in Immich."""
import json
from urllib.request import Request, urlopen
files = []
try:
# Query Immich for all assets, then filter
# Use search with a broad date range for 2024/01
search_data = json.dumps({
"takenAfter": "2024-01-01T00:00:00.000Z",
"takenBefore": "2024-02-01T00:00:00.000Z",
"size": 200
}).encode()
req = Request(f"{API_URL}/search/metadata", data=search_data,
headers={"x-api-key": API_KEY, "Content-Type": "application/json"})
resp = urlopen(req)
data = json.loads(resp.read())
for item in data.get("assets", {}).get("items", []):
path = item.get("originalPath", "")
fname = path.split("/")[-1]
if "/2024/01/" in path and fname.lower().startswith("img_") and fname.lower().endswith((".jpg", ".jpeg")):
files.append({
"filename": fname,
"immich_id": item["id"],
"path": path
})
if len(files) >= limit:
break
except Exception as e:
print(f"Error: {e}")
return files
def find_matches(filename):
"""Find all files with same name in other folders."""
base = filename.rsplit('.', 1)[0].lower()
# Only strip _1024 suffix (Mylio resize indicator), keep +1 and (1) as they indicate different photos
base_clean = base.replace('_1024', '')
matches = []
for root, dirs, files in os.walk(LIBRARY):
if "/2024/01" in root:
continue
for f in files:
f_base = f.rsplit('.', 1)[0].lower()
f_clean = f_base.replace('_1024', '')
if f_clean == base_clean and f.lower().endswith(('.jpg', '.jpeg')):
matches.append(os.path.join(root, f))
return matches
API_KEY = "GsWQUTR6EXlkKp1M82jDJ3KmzhM0fMAbbIbfHDyI"
API_URL = "http://localhost:2283/api"
def get_immich_id(filename):
"""Get Immich asset ID for a file in /2024/01/."""
import json
from urllib.request import Request, urlopen
# Try both cases and without extension
base = filename.rsplit('.', 1)[0]
searches = [filename, filename.upper(), f"{base}.JPG", f"{base}.jpg"]
for search_name in searches:
try:
search_data = json.dumps({"originalFileName": search_name}).encode()
req = Request(f"{API_URL}/search/metadata", data=search_data,
headers={"x-api-key": API_KEY, "Content-Type": "application/json"})
resp = urlopen(req)
data = json.loads(resp.read())
for item in data.get("assets", {}).get("items", []):
if "/2024/01/" in item.get("originalPath", ""):
return item["id"]
except:
pass
return None
def get_file_info(filepath):
stat = os.stat(filepath)
size_kb = stat.st_size / 1024
try:
result = subprocess.run(
['exiftool', '-s', '-s', '-s', '-ImageSize', filepath],
capture_output=True, text=True, timeout=5
)
dims = result.stdout.strip() or "unknown"
except:
dims = "unknown"
try:
result = subprocess.run(
['exiftool', '-s', '-s', '-s', '-DateTimeOriginal', filepath],
capture_output=True, text=True, timeout=5
)
date = result.stdout.strip() or "No EXIF date"
except:
date = "unknown"
rel_path = filepath.replace(LIBRARY + "/", "")
folder = '/'.join(rel_path.split('/')[:-1])
filename = filepath.split('/')[-1]
return {
'path': filepath,
'rel_path': rel_path,
'size_kb': round(size_kb),
'size_mb': round(size_kb/1024, 2),
'dims': dims,
'date': date,
'folder': folder,
'immich_id': get_immich_id(filename)
}
def generate_html():
sample_files = get_sample_files_from_immich(10)
rows = []
for file_data in sample_files:
filename = file_data["filename"]
immich_id = file_data["immich_id"]
filepath = f"{JAN2024}/{filename}"
if not os.path.exists(filepath):
continue
info = get_file_info(filepath)
info['immich_id'] = immich_id # Use the ID we already have
matches = find_matches(filename)
match_infos = [get_file_info(m) for m in sorted(matches)]
match_html = ""
if match_infos:
for m in match_infos:
match_filename = m['path'].split('/')[-1]
match_html += f'''
<div class="photo-card">
<img src="/image/{m['rel_path']}" loading="lazy">
<div class="info">
<strong>{match_filename}</strong><br>
<span class="folder">{m['folder']}</span><br>
<span class="size">{m['size_kb']} KB ({m['size_mb']} MB)</span><br>
<span class="dims">{m['dims']}</span><br>
<span class="date">{m['date']}</span>
</div>
</div>'''
else:
match_html = '<div class="no-match">No matching files found - KEEP THIS!</div>'
rows.append(f'''
<div class="comparison {'has-original' if matches else 'unique'}">
<div class="source">
<div class="photo-card">
<img src="/image/2024/01/{filename}">
<div class="info">
<strong>{filename}</strong><br>
<span class="size">{info['size_kb']} KB ({info['size_mb']} MB)</span><br>
<span class="dims">{info['dims']}</span><br>
<span class="date">{info['date']}</span>
</div>
<button class="delete-btn" onclick="deleteFile('{info['immich_id']}', this)" {'' if info['immich_id'] else 'disabled title="No Immich ID"'}>Delete</button>
</div>
</div>
<div class="matches">{match_html}</div>
</div>''')
return f'''<!DOCTYPE html>
<html>
<head>
<title>Photo Comparison - Jan 28, 2024</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 20px; background: #1a1a1a; color: #eee; }}
h1 {{ color: #4a9eff; }}
.comparison {{
display: flex;
border: 2px solid #444;
margin: 20px 0;
padding: 15px;
background: #2a2a2a;
border-radius: 8px;
}}
.has-original {{ border-color: #4a4; }}
.unique {{ border-color: #a44; }}
.source {{
flex: 0 0 350px;
border-right: 2px solid #666;
padding-right: 20px;
}}
.matches {{
flex: 1;
padding-left: 20px;
display: flex;
flex-wrap: wrap;
gap: 15px;
align-items: flex-start;
}}
.photo-card {{
text-align: center;
background: #333;
padding: 10px;
border-radius: 5px;
}}
.photo-card img {{
max-width: 300px;
max-height: 250px;
border: 2px solid #555;
border-radius: 4px;
}}
.info {{ font-size: 12px; margin-top: 8px; color: #aaa; line-height: 1.5; }}
.size {{ color: #ff9; }}
.dims {{ color: #9f9; }}
.date {{ color: #f99; }}
.folder {{ color: #99f; }}
.no-match {{ color: #f66; font-style: italic; padding: 20px; }}
h3 {{ margin: 0 0 10px 0; color: #4a9eff; font-size: 14px; }}
.status {{
padding: 5px 10px;
border-radius: 3px;
display: inline-block;
margin-bottom: 10px;
font-size: 12px;
}}
.delete-btn {{
margin-top: 10px;
padding: 8px 20px;
background: #c44;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}}
.delete-btn:hover {{ background: #a33; }}
.delete-btn:disabled {{ background: #666; cursor: not-allowed; }}
.deleted {{ opacity: 0.3; pointer-events: none; }}
</style>
</head>
<body>
<h1>Photo Comparison: Jan 28, 2024</h1>
<p>Left: Files from /2024/01/ | Right: Matching files in other folders</p>
<p style="color:#9f9">Green border = has matches (safe to delete)</p>
<p style="color:#f66">Red border = NO matches (keep!)</p>
{''.join(rows)}
<script>
function deleteFile(assetId, btn) {{
if (!confirm('Delete this photo from Immich?')) return;
fetch('/delete/' + assetId, {{method: 'POST'}})
.then(r => r.json())
.then(data => {{
if (data.success) {{
btn.closest('.comparison').classList.add('deleted');
btn.disabled = true;
btn.textContent = 'Deleted';
}} else {{
alert('Failed: ' + data.error);
}}
}});
}}
</script>
</body>
</html>'''
def delete_from_immich(asset_id):
"""Delete asset from Immich by ID."""
import json
from urllib.request import Request, urlopen
from urllib.error import HTTPError
try:
del_data = json.dumps({"ids": [asset_id], "force": True}).encode()
del_req = Request(f"{API_URL}/assets", data=del_data, method="DELETE",
headers={"x-api-key": API_KEY, "Content-Type": "application/json"})
urlopen(del_req)
return True, "Deleted"
except HTTPError as e:
return False, str(e)
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
path = urllib.parse.unquote(self.path)
if path == '/':
html = generate_html()
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
self.wfile.write(html.encode())
elif path.startswith('/image/'):
img_path = LIBRARY + "/" + path[7:]
if os.path.exists(img_path):
self.send_response(200)
self.send_header('Content-type', 'image/jpeg')
self.end_headers()
with open(img_path, 'rb') as f:
self.wfile.write(f.read())
else:
self.send_response(404)
self.end_headers()
else:
self.send_response(404)
self.end_headers()
def do_POST(self):
path = urllib.parse.unquote(self.path)
if path.startswith('/delete/'):
asset_id = path[8:]
success, msg = delete_from_immich(asset_id)
self.send_response(200)
self.send_header('Content-type', 'application/json')
self.end_headers()
import json
self.wfile.write(json.dumps({"success": success, "error": msg if not success else None}).encode())
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
pass # Suppress logging
if __name__ == '__main__':
port = 5000
print(f"Starting server at http://localhost:{port}")
print("Press Ctrl+C to stop")
HTTPServer(('0.0.0.0', port), Handler).serve_forever()

189
preview-tool/grid.py Normal file
View File

@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""Grid view of Jan 28, 2024 images for quick date assignment."""
from http.server import HTTPServer, BaseHTTPRequestHandler
import os
import json
import subprocess
import urllib.parse
LIBRARY = "/tank/immich/library/library/admin"
JAN2024 = f"{LIBRARY}/2024/01"
API_KEY = "GsWQUTR6EXlkKp1M82jDJ3KmzhM0fMAbbIbfHDyI"
API_URL = "http://localhost:2283/api"
def get_images_from_db(limit=500):
"""Get images from Immich database."""
result = subprocess.run([
"docker", "exec", "immich_postgres", "psql", "-U", "postgres", "-d", "immich", "-t", "-A", "-F", "|", "-c",
f"""SELECT id, "originalFileName", "originalPath"
FROM asset
WHERE DATE("fileCreatedAt") = '2024-01-28'
AND "deletedAt" IS NULL
AND "type" = 'IMAGE'
ORDER BY "originalFileName"
LIMIT {limit};"""
], capture_output=True, text=True)
images = []
for line in result.stdout.strip().split('\n'):
if '|' in line:
parts = line.split('|')
if len(parts) >= 3:
images.append({
'id': parts[0],
'filename': parts[1],
'path': parts[2].replace('/data/library/admin/', '')
})
return images
def generate_html(limit=20):
images = get_images_from_db(limit)
cards = []
for img in images:
cards.append(f'''
<div class="card" onclick="toggle(this)" data-id="{img['id']}" data-name="{img['filename']}">
<img src="/image/{img['path']}" loading="lazy">
<div class="name">{img['filename']}</div>
<div class="id">{img['id']}</div>
</div>''')
return f'''<!DOCTYPE html>
<html>
<head>
<title>Jan 28, 2024 - Grid View</title>
<style>
body {{ font-family: Arial; margin: 20px; background: #1a1a1a; color: #eee; }}
h1 {{ color: #4a9eff; }}
.grid {{ display: flex; flex-wrap: wrap; gap: 10px; }}
.card {{
background: #2a2a2a;
padding: 8px;
border-radius: 5px;
cursor: pointer;
border: 3px solid transparent;
width: 180px;
}}
.card:hover {{ border-color: #666; }}
.card.selected {{ border-color: #4a9; background: #234; }}
.card img {{ width: 100%; height: 140px; object-fit: cover; border-radius: 3px; }}
.name {{ font-size: 11px; margin-top: 5px; word-break: break-all; color: #aaa; }}
.id {{ font-size: 10px; color: #666; }}
.controls {{
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #333;
padding: 15px;
border-top: 2px solid #555;
}}
.output {{
background: #111;
padding: 10px;
font-family: monospace;
font-size: 12px;
margin-top: 10px;
min-height: 40px;
white-space: pre-wrap;
user-select: all;
}}
button {{ padding: 8px 15px; margin-right: 10px; cursor: pointer; }}
.count {{ color: #4a9; font-weight: bold; }}
</style>
</head>
<body>
<h1>Jan 28, 2024 Images ({len(images)} shown)</h1>
<p>Click images to select. Selected IDs appear below for copying.</p>
<div class="grid">
{''.join(cards)}
</div>
<div class="controls">
<button onclick="selectAll()">Select All</button>
<button onclick="clearAll()">Clear</button>
<button onclick="copyIds()">Copy IDs</button>
<button onclick="copyNames()">Copy Filenames</button>
<span class="count" id="count">0 selected</span>
<div class="output" id="output">Click images to select, then copy IDs or filenames</div>
</div>
<script>
function toggle(el) {{
el.classList.toggle('selected');
updateOutput();
}}
function selectAll() {{
document.querySelectorAll('.card').forEach(c => c.classList.add('selected'));
updateOutput();
}}
function clearAll() {{
document.querySelectorAll('.card').forEach(c => c.classList.remove('selected'));
updateOutput();
}}
function getSelected() {{
return Array.from(document.querySelectorAll('.card.selected'));
}}
function updateOutput() {{
const sel = getSelected();
document.getElementById('count').textContent = sel.length + ' selected';
const ids = sel.map(c => c.dataset.id).join('\\n');
document.getElementById('output').textContent = ids || 'Click images to select';
}}
function copyIds() {{
const ids = getSelected().map(c => c.dataset.id).join('\\n');
navigator.clipboard.writeText(ids);
alert('Copied ' + getSelected().length + ' IDs');
}}
function copyNames() {{
const names = getSelected().map(c => c.dataset.name).join('\\n');
navigator.clipboard.writeText(names);
alert('Copied ' + getSelected().length + ' filenames');
}}
</script>
</body>
</html>'''
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
path = urllib.parse.unquote(self.path)
if path == '/' or path.startswith('/?'):
# Parse limit from query string
limit = 500
if '?' in path:
params = urllib.parse.parse_qs(path.split('?')[1])
limit = int(params.get('limit', [500])[0])
html = generate_html(limit)
self.send_response(200)
self.send_header('Content-type', 'text/html; charset=utf-8')
self.end_headers()
self.wfile.write(html.encode())
elif path.startswith('/image/'):
img_path = LIBRARY + "/" + path[7:]
if os.path.exists(img_path):
self.send_response(200)
ext = img_path.lower().split('.')[-1]
ctype = 'image/jpeg' if ext in ['jpg', 'jpeg'] else f'image/{ext}'
self.send_header('Content-type', ctype)
self.end_headers()
with open(img_path, 'rb') as f:
self.wfile.write(f.read())
else:
self.send_response(404)
self.end_headers()
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
pass
if __name__ == '__main__':
port = 5001
print(f"Grid view at http://localhost:{port}")
print(f"Add ?limit=100 for more images")
HTTPServer(('0.0.0.0', port), Handler).serve_forever()

86
scan_4tb_mylio.py Normal file
View File

@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Scan 4TB Mylio folder and find files not in Immich."""
import subprocess
import hashlib
import sqlite3
import os
REMOTE = "macmini"
REMOTE_PATH = "/Volumes/4TB/Mylio"
LOCAL_DB = "/home/johan/immich-compare/hash_index.db"
EXTENSIONS = {'.jpg', '.jpeg', '.png', '.heic', '.gif', '.mp4', '.mov', '.avi', '.m4v', '.3gp'}
def get_immich_hashes():
"""Load all Immich hashes from existing database."""
conn = sqlite3.connect(LOCAL_DB)
c = conn.cursor()
hashes = set()
for row in c.execute("SELECT hash64k FROM files WHERE source='immich'"):
hashes.add(row[0])
conn.close()
print(f"Loaded {len(hashes)} Immich hashes")
return hashes
def scan_remote():
"""Find all media files on remote 4TB Mylio."""
print(f"\nFinding files on {REMOTE}:{REMOTE_PATH}...")
cmd = f"ssh {REMOTE} \"find '{REMOTE_PATH}' -type f 2>/dev/null\""
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
files = []
for line in result.stdout.strip().split('\n'):
if not line:
continue
ext = os.path.splitext(line)[1].lower()
if ext in EXTENSIONS:
files.append(line)
print(f"Found {len(files)} media files")
return files
def hash_remote_file(filepath):
"""Get 64KB hash of remote file."""
cmd = f"ssh {REMOTE} \"head -c 65536 '{filepath}' 2>/dev/null\" | md5sum"
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
if result.returncode == 0:
return result.stdout.split()[0]
return None
def main():
immich_hashes = get_immich_hashes()
files = scan_remote()
missing = []
matched = 0
print(f"\nHashing files and comparing...")
for i, filepath in enumerate(files):
if (i + 1) % 100 == 0:
print(f" {i+1}/{len(files)} - {matched} matched, {len(missing)} missing")
h = hash_remote_file(filepath)
if h is None:
continue
if h in immich_hashes:
matched += 1
else:
missing.append(filepath)
print(f"\n{'='*50}")
print(f"Results:")
print(f" Total scanned: {len(files)}")
print(f" Already in Immich: {matched}")
print(f" NOT in Immich: {len(missing)}")
# Save missing list
with open('/tmp/4tb_mylio_missing.txt', 'w') as f:
for p in missing:
f.write(p + '\n')
print(f"\nMissing files saved to /tmp/4tb_mylio_missing.txt")
return missing
if __name__ == "__main__":
main()

41
update_aut3_dates.py Normal file
View File

@ -0,0 +1,41 @@
#!/usr/bin/env python3
import subprocess, json
from urllib.request import Request, urlopen
API_KEY = "GsWQUTR6EXlkKp1M82jDJ3KmzhM0fMAbbIbfHDyI"
API_URL = "http://localhost:2283/api"
files = {
"AUT_3127.jpg": "2004-03-06T00:09:40.000Z",
"AUT_3128.jpg": "2004-03-06T00:09:38.000Z",
"AUT_3143.jpg": "2004-03-06T00:09:22.000Z",
"AUT_3260.jpg": "2007-08-20T21:46:09.000Z",
"AUT_3261.jpg": "2007-08-20T21:46:10.000Z",
"AUT_3263.jpg": "2007-08-20T21:46:19.000Z",
"AUT_3264.jpg": "2007-08-20T21:45:51.000Z",
"AUT_3265.jpg": "2007-08-20T21:45:53.000Z",
"AUT_3267.jpg": "2007-08-20T21:45:55.000Z",
"AUT_3269.jpg": "2007-08-20T21:45:56.000Z",
"AUT_3273.jpg": "2007-08-20T21:46:20.000Z",
"AUT_3274.jpg": "2007-08-20T21:46:22.000Z",
"AUT_3976.jpg": "2008-09-15T13:01:38.000Z",
"AUT_3977.jpg": "2008-09-15T13:01:39.000Z",
"AUT_3978.jpg": "2008-09-15T13:01:41.000Z",
"AUT_3990.jpg": "2008-09-15T13:01:58.000Z",
}
for fname, date in files.items():
result = subprocess.run([
"docker", "exec", "immich_postgres", "psql", "-U", "postgres", "-d", "immich", "-t", "-c",
f"SELECT id FROM asset WHERE \"originalPath\" LIKE '%/2024/01/{fname}' AND \"deletedAt\" IS NULL;"
], capture_output=True, text=True)
asset_id = result.stdout.strip()
if asset_id:
data = json.dumps({"dateTimeOriginal": date}).encode()
req = Request(f"{API_URL}/assets/{asset_id}", data=data, method="PUT",
headers={"x-api-key": API_KEY, "Content-Type": "application/json"})
urlopen(req)
print(f"Updated {fname} -> {date[:10]}")
else:
print(f"NOT FOUND: {fname}")

51
update_img8_dates.py Normal file
View File

@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Update dates for IMG_8xxx files in Immich."""
import subprocess
import json
from urllib.request import Request, urlopen
API_KEY = "GsWQUTR6EXlkKp1M82jDJ3KmzhM0fMAbbIbfHDyI"
API_URL = "http://localhost:2283/api"
files = {
"IMG_8007.jpg": "2010-09-20T12:16:54.000Z",
"IMG_8027.jpg": "2010-09-20T12:16:54.000Z",
"IMG_8028.jpg": "2010-09-20T12:17:00.000Z",
"IMG_8030.jpg": "2010-09-20T12:17:00.000Z",
"IMG_8625.jpg": "2009-08-28T13:48:22.000Z",
"IMG_8627.jpg": "2009-08-28T13:48:22.000Z",
"IMG_8629.jpg": "2009-08-28T13:48:22.000Z",
"IMG_8630.jpg": "2009-08-28T13:48:22.000Z",
"IMG_8631.jpg": "2009-08-28T13:48:22.000Z",
"IMG_8633.jpg": "2009-08-28T13:48:22.000Z",
"IMG_8634.jpg": "2009-08-28T13:48:22.000Z",
"IMG_8635.jpg": "2009-08-28T13:48:24.000Z",
"IMG_8636.jpg": "2009-08-28T13:48:24.000Z",
"IMG_8638.jpg": "2009-08-28T13:48:24.000Z",
}
def get_asset_id(filename):
result = subprocess.run([
"docker", "exec", "immich_postgres", "psql", "-U", "postgres", "-d", "immich", "-t", "-c",
f"SELECT id FROM asset WHERE \"originalPath\" LIKE '%/2024/01/{filename}' AND \"deletedAt\" IS NULL;"
], capture_output=True, text=True)
return result.stdout.strip()
def update_date(asset_id, date):
data = json.dumps({"dateTimeOriginal": date}).encode()
req = Request(f"{API_URL}/assets/{asset_id}", data=data, method="PUT",
headers={"x-api-key": API_KEY, "Content-Type": "application/json"})
urlopen(req)
updated = 0
for fname, date in files.items():
asset_id = get_asset_id(fname)
if asset_id:
update_date(asset_id, date)
print(f"Updated {fname} -> {date[:10]}")
updated += 1
else:
print(f"NOT FOUND: {fname}")
print(f"\nTotal updated: {updated}")

39
update_mvi04_dates.py Normal file
View File

@ -0,0 +1,39 @@
#!/usr/bin/env python3
import subprocess, json
from urllib.request import Request, urlopen
API_KEY = "GsWQUTR6EXlkKp1M82jDJ3KmzhM0fMAbbIbfHDyI"
API_URL = "http://localhost:2283/api"
files = {
"MVI_0440.jpg": "2015-02-07T02:06:37.000Z",
"MVI_0444.jpg": "2015-02-07T01:53:45.000Z",
"MVI_0446.jpg": "2015-02-07T02:03:32.000Z",
"MVI_0448.jpg": "2015-02-07T01:57:12.000Z",
"MVI_0454.jpg": "2015-02-07T01:53:36.000Z",
"MVI_0462.jpg": "2015-02-07T02:00:27.000Z",
"MVI_0463.jpg": "2015-02-07T01:57:01.000Z",
"MVI_0465.jpg": "2015-02-07T02:06:22.000Z",
"MVI_0466.jpg": "2015-02-07T02:03:18.000Z",
"MVI_0467.jpg": "2015-02-07T02:00:18.000Z",
"MVI_0468.jpg": "2015-02-07T01:56:56.000Z",
"MVI_0469.jpg": "2015-02-07T02:09:17.000Z",
"MVI_0470.jpg": "2015-02-07T02:06:17.000Z",
"MVI_0495.jpg": "2015-02-07T02:06:01.000Z",
}
for fname, date in files.items():
result = subprocess.run([
"docker", "exec", "immich_postgres", "psql", "-U", "postgres", "-d", "immich", "-t", "-c",
f"SELECT id FROM asset WHERE \"originalPath\" LIKE '%/2024/01/{fname}' AND \"deletedAt\" IS NULL;"
], capture_output=True, text=True)
asset_id = result.stdout.strip()
if asset_id:
data = json.dumps({"dateTimeOriginal": date}).encode()
req = Request(f"{API_URL}/assets/{asset_id}", data=data, method="PUT",
headers={"x-api-key": API_KEY, "Content-Type": "application/json"})
urlopen(req)
print(f"Updated {fname} -> {date[:10]}")
else:
print(f"NOT FOUND: {fname}")

65
update_uuid_dates.py Normal file
View File

@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Update dates for UUID-named files in Immich."""
import subprocess
import json
from urllib.request import Request, urlopen
API_KEY = "GsWQUTR6EXlkKp1M82jDJ3KmzhM0fMAbbIbfHDyI"
API_URL = "http://localhost:2283/api"
# UUID files with their correct dates from Mylio XMP
files = {
"09C298F7-2237-4372-A99C-089BA7AE76A8.jpg": "2021-09-25T04:03:24.000Z",
"1BF9EA26-225E-4AAE-B27D-3750C8AAF67A.jpg": "2021-09-25T04:04:06.000Z",
"3CEFC5D9-B100-4AA3-9CCE-121F9CBEFB8C.jpg": "2021-10-04T03:55:08.000Z",
"3F53A10E-8403-4C05-B180-32482554CD0F.jpg": "2021-09-11T03:05:17.000Z",
"41F7B9E3-C476-460D-9A61-AC7794524864.jpg": "2021-12-28T16:57:59.000Z",
"44C13A26-F430-4018-9CD5-5C64A84D9BE1.jpg": "2021-09-23T14:21:59.000Z",
"507C94F6-EB74-4F6E-ACAF-F3D424DB5C30.jpg": "2021-12-28T16:58:36.000Z",
"57E09983-E365-4D55-A6BD-FD48C4E93823.jpg": "2021-09-11T03:05:31.000Z",
"73149BC3-657B-45E1-9F14-AF344CC17B74.jpg": "2021-12-28T16:57:26.000Z",
"80262004-01C7-465F-BFC4-AA822A36EFF2.jpg": "2021-09-25T04:04:32.000Z",
"8068628A-DF39-403E-A4FE-6095B59908D3.jpg": "2021-09-11T02:59:17.000Z",
"81B12ED7-9C2A-457F-86A7-9EDA320607A0.jpg": "2021-09-23T14:22:00.000Z",
"91E396CA-3528-4EDC-951E-FF70FD8768FD.jpg": "2021-12-28T16:57:51.000Z",
"A6AEF278-BB86-49F9-BD93-EE728780213E.jpg": "2021-09-23T14:22:07.000Z",
"B2AA2B92-F554-4E3F-90D3-AE59E3C41751.jpg": "2021-10-04T03:55:33.000Z",
"B4899F17-6BA5-4516-81BD-04CC119D2C3D.jpg": "2021-10-04T03:54:09.000Z",
"B9AD2FBE-5D33-426C-9F0C-08F4EAC27440.jpg": "2021-12-28T16:58:14.000Z",
"BE71C547-F247-4CFD-BF66-65438C45C6C0.jpg": "2021-10-04T03:54:45.000Z",
"CB381DB7-8B95-4581-B53A-D03B5B7EF94C.jpg": "2021-12-28T16:57:14.000Z",
"E2A04304-643D-4569-839A-60A0E6D8071A.jpg": "2021-12-28T16:57:02.000Z",
"ED522636-3584-4DD0-B176-BC0E5F8CCB88.jpg": "2021-10-04T03:54:33.000Z",
"EEE55391-320C-4399-8378-6A42A99617BB.jpg": "2021-12-28T16:57:42.000Z",
"F38214B0-166E-47B7-A2AE-328FB5DC8D8A.jpg": "2021-12-28T16:58:46.000Z",
"F91246B7-039C-4E78-BF15-350E65FDEF44.jpg": "2021-12-28T16:58:27.000Z",
"FB2BCD81-AD35-4A23-8EC2-CC8DAB42714E.jpg": "2021-10-04T03:54:26.000Z",
}
def get_asset_id(filename):
"""Get Immich asset ID from database."""
result = subprocess.run([
"docker", "exec", "immich_postgres", "psql", "-U", "postgres", "-d", "immich", "-t", "-c",
f"SELECT id FROM asset WHERE \"originalPath\" LIKE '%/2024/01/{filename}' AND \"deletedAt\" IS NULL;"
], capture_output=True, text=True)
return result.stdout.strip()
def update_date(asset_id, date):
"""Update asset date via Immich API."""
data = json.dumps({"dateTimeOriginal": date}).encode()
req = Request(f"{API_URL}/assets/{asset_id}", data=data, method="PUT",
headers={"x-api-key": API_KEY, "Content-Type": "application/json"})
urlopen(req)
updated = 0
for fname, date in files.items():
asset_id = get_asset_id(fname)
if asset_id:
update_date(asset_id, date)
print(f"Updated {fname} -> {date[:10]}")
updated += 1
else:
print(f"NOT FOUND: {fname}")
print(f"\nTotal updated: {updated}")