338 lines
12 KiB
Python
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()
|