"""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)