#!/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'''
Left: Files from /2024/01/ | Right: Matching files in other folders
Green border = has matches (safe to delete)
Red border = NO matches (keep!)
{''.join(rows)} ''' 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()