immich-compare/preview-tool/app.py

338 lines
12 KiB
Python

#!/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()