Files
photo-pipeline/merge_orphans.py

162 lines
5.8 KiB
Python

"""photo-pipeline: merge_orphans — dedup + EXIF-date routing into by_date.
Phase 2-3 of the consolidation plan. IMPORTANT: only merges photos with
status='approved' (reviewed in the dashboard). Unreviewed (keep) and
rejected photos are NOT moved.
Flow:
1. quality-scan → machine verdicts (keep / delete_candidate)
2. dashboard review → approved / rejected
3. merge_orphans → moves ONLY approved into by_date (EXIF-date routed)
4. immich-import → uploads (checksum dedup = no doubling)
Modes:
dry-run (default): report what WOULD move — nothing touched
--apply: actually move (after backup confirmed + user reviewed)
"""
import shutil
from pathlib import Path
from PIL import Image
import photo_db as db
ORPHAN_ROOTS = [
"/mnt/ubuntu_storage_3TB/archive/03_photos/temp_transfer",
"/mnt/ubuntu_storage_3TB/archive/03_photos/Pictures",
"/mnt/ubuntu_storage_3TB/archive/03_photos/phone_dumps",
]
BY_DATE_ROOT = Path("/mnt/ubuntu_storage_3TB/archive/03_photos/by_date/sorted_pictures_holder")
MONTH_NAMES = {1:"01-Jan",2:"02-Feb",3:"03-Mar",4:"04-Apr",5:"05-May",6:"06-Jun",
7:"07-Jul",8:"08-Aug",9:"09-Sep",10:"10-Oct",11:"11-Nov",12:"12-Dec"}
EXT_IMAGES = {".jpg", ".jpeg", ".png", ".heic", ".webp", ".gif", ".tif", ".tiff", ".bmp"}
REQUIRED_STATUS = "approved" # only merge photos reviewed+approved in dashboard
def _exif_date(path: Path) -> str | None:
"""Return 'YYYY-MM' from EXIF DateTime, or None."""
try:
with Image.open(path) as im:
ex = im.getexif()
dt = ex.get(306) or ex.get(36867)
if dt and len(dt) >= 7:
return f"{dt[:4]}-{dt[5:7]}"
except Exception:
pass
return None
def _mtime_date(path: Path) -> str | None:
"""Fallback: file mtime → YYYY-MM."""
import datetime
try:
ts = path.stat().st_mtime
d = datetime.datetime.fromtimestamp(ts)
return f"{d.year:04d}-{d.month:02d}"
except Exception:
return None
def _route_target(path: Path) -> Path:
"""Compute the by_date target (EXIF → mtime → unknown)."""
ym = _exif_date(path) or _mtime_date(path)
if not ym:
return BY_DATE_ROOT / "unknown-date" / path.name
year, month = ym.split("-")
month_dir = MONTH_NAMES.get(int(month), f"{month}-??")
return BY_DATE_ROOT / year / month_dir / path.name
def scan_orphans(conn) -> dict:
"""Walk orphans; classify approved-merge vs skipped. Dry-run ready."""
results = {"approved": 0, "keep_unreviewed": 0, "rejected": 0,
"dup_in_bydate": 0, "no_target": 0, "collision": 0, "errors": 0}
moves = [] # (src, dest) — approved only
skipped = []
for root in ORPHAN_ROOTS:
r = Path(root)
if not r.exists():
continue
for p in r.rglob("*"):
if not p.is_file() or p.suffix.lower() not in EXT_IMAGES:
continue
try:
# 1. review status gate
row = conn.execute(
"SELECT status, flag_reason FROM image_hashes WHERE path=? LIMIT 1", (str(p),)
).fetchone()
status = row[0] if row else "NOT_IN_DB"
if status == "rejected":
results["rejected"] += 1
skipped.append((str(p), "rejected"))
continue
if status != REQUIRED_STATUS:
results["keep_unreviewed"] += 1
skipped.append((str(p), status or "unreviewed"))
continue
# 2. already in by_date? (content match)
sha = db.sha256_file(p)
match = conn.execute(
"SELECT path FROM image_hashes WHERE sha256=? AND source='archive-date' LIMIT 1",
(sha,)).fetchone()
if match:
results["dup_in_bydate"] += 1
continue
# 3. route target
dest = _route_target(p)
if dest == p:
results["no_target"] += 1
continue
if dest.exists():
results["collision"] += 1
continue
results["approved"] += 1
moves.append((str(p), str(dest)))
except Exception:
results["errors"] += 1
return results, moves, skipped
def apply_moves(moves: list[tuple[str, str]], dry_run: bool = True) -> int:
"""Move files (or report in dry-run). Returns count."""
moved = 0
for src, dest in moves:
d = Path(dest)
d.parent.mkdir(parents=True, exist_ok=True)
if dry_run:
print(f" MOVE {Path(src).name}{d.parent.parent.name}/{d.parent.name}/")
moved += 1
continue
try:
shutil.move(src, dest)
moved += 1
except Exception as e:
print(f" FAIL {Path(src).name}: {e}")
return moved
def merge_orphans(dry_run: bool = True) -> dict:
"""Main entry: scan + (dry-run or apply). Only approved photos move."""
db.init_db()
conn = db.get_db()
results, moves, skipped = scan_orphans(conn)
conn.close()
print(f"scan: approved={results['approved']} keep_unreviewed={results['keep_unreviewed']} "
f"rejected={results['rejected']} dup_in_bydate={results['dup_in_bydate']} "
f"collisions={results['collision']} errors={results['errors']}")
moved = apply_moves(moves, dry_run=dry_run)
results["moves"] = moved
if dry_run:
print(f"[dry-run] {moved} APPROVED photos would move — nothing touched")
else:
print(f"[applied] {moved} approved photos moved")
return results
if __name__ == "__main__":
import sys
apply = "--apply" in sys.argv
r = merge_orphans(dry_run=not apply)
print(r)