Sort images and video by location

Project: Python project for sorting large folder of iPhone images and videos.

Example Hipstamatic image lacking gps information.
Example Hipstamatic image lacking gps information, which means just sorting på gps will not group it with normal iPhone photos taken in the same time period. The python script handles this well.

I use Dropbox for backing up my iPhone photos, and I usually have 3000 plus images and videos pr year. There is also quite a few screenshots and downloads thrown into the mix, videos (which lack geolocation), and the sorting by gps is also complicated by my use of Hipstamatic for black and white photography, which seems to ignore gps location. Which is a longwinded way of saying that grouping images and video by location in subfolders gets a bit complicated. This python script does a fairly good job at organizing large numbers of images, an quickly.

It has a dry run mode for testing before actually moving anything. I also suggest copying a few hundred images into a test folder for testing it.

Readme.md:

Installation
pip install exifread requests

Always do a dry run first

python3 sort_by_location.py /path/to/your/photos --dry-run

This prints the full folder plan without touching any files.
Then run for real:
python3 sort_by_location.py /path/to/your/photos


What it does, specifically:

Reads GPS EXIF from JPEGs, HEICs, PNGs, MOVs, MP4s
Clusters files within ~30km of each other into one location group
Reverse geocodes the cluster center via OpenStreetMap (free, no API key needed) — gets city name + natural feature if available
Folders named like 2023-07 Roma – Colosseo Italy or 2023-08 Stryn – Jostedalsbreen Norway
Hipstamatic photos (and other no-GPS files) are matched to a nearby cluster if they were shot within 4 hours of it — you can adjust this window at the top of the script
Everything else goes to _Screenshots_and_Downloads
Geocode results are cached in a .geocode_cache.json file so re-runs are fast
Handles filename collisions automatically

sort_by_location.py

#!/usr/bin/env python3
"""
sort_by_location.py
-------------------
Groups iPhone photos, screenshots, and .mov files into named subfolders
based on GPS EXIF data and reverse geocoding via OpenStreetMap Nominatim.

Features:
  - Reads GPS from EXIF (JPEG, HEIC, MOV)
  - Reverse geocodes to city + natural feature level
  - Folder names: "YYYY-MM Location Country"
  - Files without GPS are matched to nearest cluster by timestamp
  - True screenshots (no timestamp, or PNG named "Screenshot") go to _Screenshots_and_Downloads

Usage:
  python3 sort_by_location.py /path/to/your/photos [--dry-run]

Options:
  --dry-run    Print what would happen without moving any files
"""

import os
import sys
import re
import shutil
import time
import argparse
import json
import io
import struct
import binascii
from contextlib import redirect_stdout, redirect_stderr
from datetime import datetime, timedelta
from pathlib import Path
from collections import defaultdict
from math import radians, cos, sin, asin, sqrt

try:
    import exifread
except ImportError:
    print("Missing dependency: pip install exifread requests")
    sys.exit(1)

# ── Configuration ─────────────────────────────────────────────────────────────

# Nominatim rate limit (seconds between requests)
NOMINATIM_DELAY = 1.1

# Cache file so we don't re-geocode on re-runs
GEOCODE_CACHE_FILE = ".geocode_cache.json"

# Supported extensions
PHOTO_EXTENSIONS = {".jpg", ".jpeg", ".heic", ".png", ".gif", ".tiff", ".tif"}
VIDEO_EXTENSIONS = {".mov", ".mp4", ".m4v"}
ALL_EXTENSIONS = PHOTO_EXTENSIONS | VIDEO_EXTENSIONS

# ── EXIF Reading ──────────────────────────────────────────────────────────────

def dms_to_decimal(dms_values, ref):
    try:
        d = float(dms_values[0].num) / float(dms_values[0].den)
        m = float(dms_values[1].num) / float(dms_values[1].den)
        s = float(dms_values[2].num) / float(dms_values[2].den)
        decimal = d + m / 60 + s / 3600
        if ref in ("S", "W"):
            decimal = -decimal
        return decimal
    except Exception:
        return None


def parse_int_tag(value):
    try:
        return int(str(value).strip())
    except Exception:
        return None


def read_png_dimensions(filepath):
    try:
        with open(filepath, "rb") as f:
            header = f.read(24)
        if header.startswith(b"\x89PNG\r\n\x1a\n") and header[12:16] == b"IHDR":
            return int.from_bytes(header[16:20], "big"), int.from_bytes(header[20:24], "big")
    except Exception:
        pass
    return None, None


def read_jpeg_dimensions(filepath):
    try:
        with open(filepath, "rb") as f:
            if f.read(2) != b"\xff\xd8":
                return None, None
            while True:
                marker_start = f.read(1)
                if not marker_start:
                    return None, None
                if marker_start != b"\xff":
                    continue
                marker = f.read(1)
                while marker == b"\xff":
                    marker = f.read(1)
                if marker in (b"\xc0", b"\xc1", b"\xc2", b"\xc3", b"\xc5", b"\xc6", b"\xc7",
                              b"\xc9", b"\xca", b"\xcb", b"\xcd", b"\xce", b"\xcf"):
                    f.read(3)
                    height = int.from_bytes(f.read(2), "big")
                    width = int.from_bytes(f.read(2), "big")
                    return width, height
                if marker in (b"\xd8", b"\xd9"):
                    continue
                length_bytes = f.read(2)
                if len(length_bytes) != 2:
                    return None, None
                length = int.from_bytes(length_bytes, "big")
                if length < 2:
                    return None, None
                f.seek(length - 2, os.SEEK_CUR)
    except Exception:
        pass
    return None, None


def read_image_dimensions(filepath):
    suffix = filepath.suffix.lower()
    if suffix == ".png":
        return read_png_dimensions(filepath)
    if suffix in {".jpg", ".jpeg"}:
        return read_jpeg_dimensions(filepath)
    return None, None


def read_exif(filepath):
    result = {
        "lat": None,
        "lon": None,
        "datetime": None,
        "datetime_source": "none",
        "software": None,
        "make": None,
        "model": None,
        "user_comment": None,
        "width": None,
        "height": None,
    }

    try:
        with open(filepath, "rb") as f:
            with io.StringIO() as sink, redirect_stdout(sink), redirect_stderr(sink):
                tags = exifread.process_file(f, details=True)

        lat_tag = tags.get("GPS GPSLatitude")
        lat_ref = tags.get("GPS GPSLatitudeRef")
        lon_tag = tags.get("GPS GPSLongitude")
        lon_ref = tags.get("GPS GPSLongitudeRef")

        if lat_tag and lat_ref and lon_tag and lon_ref:
            result["lat"] = dms_to_decimal(lat_tag.values, str(lat_ref))
            result["lon"] = dms_to_decimal(lon_tag.values, str(lon_ref))

        for dt_tag in ("EXIF DateTimeOriginal", "EXIF DateTimeDigitized", "Image DateTime"):
            if dt_tag in tags:
                try:
                    result["datetime"] = datetime.strptime(str(tags[dt_tag]), "%Y:%m:%d %H:%M:%S")
                    result["datetime_source"] = "exif"
                    break
                except ValueError:
                    pass

        if "Image Software" in tags:
            result["software"] = str(tags["Image Software"]).lower()
        if "Image Make" in tags:
            result["make"] = str(tags["Image Make"]).strip()
        if "Image Model" in tags:
            result["model"] = str(tags["Image Model"]).strip()
        if "EXIF UserComment" in tags:
            result["user_comment"] = str(tags["EXIF UserComment"]).strip()

        width = tags.get("EXIF ExifImageWidth") or tags.get("Image ImageWidth")
        height = tags.get("EXIF ExifImageLength") or tags.get("Image ImageLength")
        result["width"] = parse_int_tag(width) if width else None
        result["height"] = parse_int_tag(height) if height else None

    except Exception:
        pass

    if result["width"] is None or result["height"] is None:
        width, height = read_image_dimensions(filepath)
        result["width"] = result["width"] or width
        result["height"] = result["height"] or height

    # For MOVs and files exifread can't parse: try to extract date from filename
    # Format seen in your files: "2025-03-22 23.19.57.mov"
    if result["datetime"] is None:
        name = filepath.stem
        m = re.match(r"(\d{4}-\d{2}-\d{2})[. _](\d{2})[.\:](\d{2})[.\:](\d{2})", name)
        if m:
            try:
                result["datetime"] = datetime.strptime(
                    f"{m.group(1)} {m.group(2)}:{m.group(3)}:{m.group(4)}",
                    "%Y-%m-%d %H:%M:%S"
                )
                result["datetime_source"] = "filename"
            except ValueError:
                pass

    # Final fallback: file modification time
    if result["datetime"] is None:
        try:
            result["datetime"] = datetime.fromtimestamp(os.path.getmtime(filepath))
            result["datetime_source"] = "mtime"
        except Exception:
            pass

    return result


def has_camera_metadata(exif_data):
    make = (exif_data.get("make") or "").lower()
    model = (exif_data.get("model") or "").lower()
    software = (exif_data.get("software") or "").lower()
    camera_apps = ("hipstamatic",)
    return bool(
        make or
        model or
        "iphone" in model or
        "apple" in make or
        re.match(r"^\d+(\.\d+)*$", software) or
        any(app in software for app in camera_apps)
    )


def has_known_non_camera_software(exif_data):
    software = (exif_data.get("software") or "").lower()
    non_camera_apps = ("facebook", "instagram", "whatsapp", "snapchat", "photoshop")
    return any(app in software for app in non_camera_apps)


def has_camera_like_dimensions(exif_data):
    width = exif_data.get("width")
    height = exif_data.get("height")
    if not width or not height:
        return False

    short_side = min(width, height)
    long_side = max(width, height)
    megapixels = width * height
    aspect = long_side / short_side

    return megapixels >= 8_000_000 and short_side >= 2000 and long_side >= 3000 and 1.0 <= aspect <= 1.8


def has_screen_like_dimensions(exif_data):
    width = exif_data.get("width")
    height = exif_data.get("height")
    if not width or not height:
        return False

    short_side = min(width, height)
    long_side = max(width, height)
    aspect = long_side / short_side

    if short_side in {640, 750, 828, 1080, 1125, 1170, 1242, 1284, 1290, 1320} and 1.45 <= aspect <= 2.35:
        return True
    if short_side == 1170 and 1.2 <= aspect <= 2.35:
        return True
    return False


def is_screenshot(filepath, exif_data):
    """Detect screenshots by metadata first; filename is only a fallback."""
    comment = (exif_data.get("user_comment") or "").lower()
    if "screenshot" in comment:
        return True

    if not has_camera_metadata(exif_data) and has_screen_like_dimensions(exif_data):
        return True

    name = filepath.stem.lower()
    if name.startswith("screenshot") or name.startswith("screen shot"):
        return True
    return False


def is_download_or_non_camera_image(filepath, exif_data):
    """Non-camera images should not be inferred into a location by timestamp."""
    if filepath.suffix.lower() not in PHOTO_EXTENSIONS:
        return False
    if exif_data.get("lat") is not None and exif_data.get("lon") is not None:
        return False
    if has_camera_metadata(exif_data):
        return False
    if not has_known_non_camera_software(exif_data) and has_camera_like_dimensions(exif_data):
        return False
    return True


# ── Diagnostics ───────────────────────────────────────────────────────────────

def read_file_header(filepath, size=32):
    try:
        with open(filepath, "rb") as f:
            return f.read(size), None
    except Exception as e:
        return b"", str(e)


def expected_signature(filepath, header):
    suffix = filepath.suffix.lower()
    if suffix in {".jpg", ".jpeg"}:
        return header.startswith(b"\xff\xd8")
    if suffix == ".png":
        return header.startswith(b"\x89PNG\r\n\x1a\n")
    if suffix == ".gif":
        return header.startswith((b"GIF87a", b"GIF89a"))
    if suffix in {".tif", ".tiff"}:
        return header.startswith((b"II*\x00", b"MM\x00*"))
    if suffix in {".heic", ".mov", ".mp4", ".m4v"}:
        return len(header) >= 12 and header[4:8] == b"ftyp"
    return True


def validate_jpeg_integrity(filepath):
    try:
        with open(filepath, "rb") as f:
            data = f.read()
    except Exception as e:
        return f"read failed during JPEG validation: {e}"

    if not data.startswith(b"\xff\xd8"):
        return "JPEG does not start with SOI marker"
    if not data.rstrip().endswith(b"\xff\xd9"):
        return "JPEG does not end with EOI marker"
    return None


def validate_png_integrity(filepath):
    try:
        with open(filepath, "rb") as f:
            data = f.read()
    except Exception as e:
        return f"read failed during PNG validation: {e}"

    if not data.startswith(b"\x89PNG\r\n\x1a\n"):
        return "PNG signature is invalid"

    pos = 8
    saw_iend = False
    try:
        while pos + 12 <= len(data):
            length = struct.unpack(">I", data[pos:pos + 4])[0]
            chunk_type = data[pos + 4:pos + 8]
            chunk_start = pos + 8
            chunk_end = chunk_start + length
            crc_end = chunk_end + 4
            if crc_end > len(data):
                return f"PNG chunk {chunk_type.decode('latin1', errors='replace')} is truncated"
            expected_crc = struct.unpack(">I", data[chunk_end:crc_end])[0]
            actual_crc = binascii.crc32(data[pos + 4:chunk_end]) & 0xffffffff
            if expected_crc != actual_crc:
                return f"PNG chunk {chunk_type.decode('latin1', errors='replace')} has invalid CRC"
            pos = crc_end
            if chunk_type == b"IEND":
                saw_iend = True
                break
    except Exception as e:
        return f"PNG validation failed: {e}"

    if not saw_iend:
        return "PNG is missing IEND chunk"
    return None


def validate_quicktime_integrity(filepath):
    try:
        with open(filepath, "rb") as f:
            data = f.read()
    except Exception as e:
        return f"read failed during movie validation: {e}"

    if len(data) < 12 or data[4:8] != b"ftyp":
        return "movie file is missing ftyp atom"

    pos = 0
    atoms = set()
    try:
        while pos + 8 <= len(data):
            size = struct.unpack(">I", data[pos:pos + 4])[0]
            atom_type = data[pos + 4:pos + 8]
            header_size = 8
            if size == 1:
                if pos + 16 > len(data):
                    return f"movie atom {atom_type.decode('latin1', errors='replace')} has truncated extended size"
                size = struct.unpack(">Q", data[pos + 8:pos + 16])[0]
                header_size = 16
            elif size == 0:
                size = len(data) - pos
            if size < header_size or pos + size > len(data):
                return f"movie atom {atom_type.decode('latin1', errors='replace')} is truncated"
            atoms.add(atom_type)
            pos += size
    except Exception as e:
        return f"movie validation failed: {e}"

    if b"moov" not in atoms:
        return "movie is missing moov atom"
    if b"mdat" not in atoms:
        return "movie is missing mdat atom"
    return None


def validate_media_integrity(filepath):
    suffix = filepath.suffix.lower()
    if suffix in {".jpg", ".jpeg"}:
        return validate_jpeg_integrity(filepath)
    if suffix == ".png":
        return validate_png_integrity(filepath)
    if suffix in {".mov", ".mp4", ".m4v"}:
        return validate_quicktime_integrity(filepath)
    return None


def diagnose_file(filepath):
    reasons = []
    try:
        file_size = filepath.stat().st_size
    except Exception as e:
        return [f"stat failed: {e}"], None

    if file_size == 0:
        reasons.append("zero-byte file")

    header, read_error = read_file_header(filepath)
    if read_error:
        reasons.append(f"read failed: {read_error}")
    elif header and not expected_signature(filepath, header):
        reasons.append("extension does not match file signature")
    elif not header:
        reasons.append("empty or unreadable header")

    exif = read_exif(filepath)
    suffix = filepath.suffix.lower()
    if suffix in {".jpg", ".jpeg", ".png"} and file_size > 0:
        if exif.get("width") is None or exif.get("height") is None:
            reasons.append("image dimensions could not be read")

    integrity_error = validate_media_integrity(filepath)
    if integrity_error:
        reasons.append(integrity_error)

    if exif.get("datetime_source") == "mtime":
        reasons.append("timestamp only from filesystem mtime")
    elif exif.get("datetime_source") == "filename":
        reasons.append("timestamp only from filename")

    if (
        suffix in PHOTO_EXTENSIONS and
        exif.get("lat") is None and
        exif.get("lon") is None and
        not has_camera_metadata(exif) and
        not is_screenshot(filepath, exif) and
        not is_download_or_non_camera_image(filepath, exif)
    ):
        reasons.append("no GPS and weak camera/download classification")

    if suffix in PHOTO_EXTENSIONS and exif.get("lat") is None and exif.get("lon") is None:
        if is_screenshot(filepath, exif):
            reasons.append("classified as screenshot")
        elif is_download_or_non_camera_image(filepath, exif):
            reasons.append("classified as download/non-camera image")

    return reasons, exif


def diagnose_files(source_dir):
    all_files = collect_files(source_dir)
    print(f"\nDiagnosing supported files in: {source_dir}")
    print(f"Found {len(all_files)} supported files\n")

    problem_count = 0
    for filepath in all_files:
        reasons, exif = diagnose_file(filepath)
        if not reasons:
            continue
        problem_count += 1
        size = filepath.stat().st_size if filepath.exists() else 0
        width = exif.get("width") if exif else None
        height = exif.get("height") if exif else None
        dt_source = exif.get("datetime_source") if exif else "unknown"
        dimensions = f"{width}x{height}" if width and height else "unknown"
        print(f"{filepath.name}")
        print(f"  size={size} bytes  dimensions={dimensions}  datetime_source={dt_source}")
        print(f"  reason: {'; '.join(reasons)}")

    print("\n" + "─" * 50)
    print(f"Diagnostics found {problem_count} files to review")
    print("No files were moved or modified.")


# ── Reverse Geocoding ─────────────────────────────────────────────────────────

def load_geocode_cache(source_dir):
    cache_path = source_dir / GEOCODE_CACHE_FILE
    if cache_path.exists():
        try:
            with open(cache_path) as f:
                return json.load(f)
        except Exception:
            pass
    return {}


def save_geocode_cache(source_dir, cache):
    cache_path = source_dir / GEOCODE_CACHE_FILE
    try:
        with open(cache_path, "w") as f:
            json.dump(cache, f, indent=2)
    except Exception:
        pass


def round_coord(v, decimals=2):
    return round(v, decimals)


def reverse_geocode(lat, lon, cache, source_dir):
    try:
        import requests
    except ImportError:
        print("Missing dependency: pip install requests")
        sys.exit(1)

    key = f"{round_coord(lat)},{round_coord(lon)}"
    if key in cache:
        return cache[key]

    url = "https://nominatim.openstreetmap.org/reverse"
    params = {
        "lat": lat, "lon": lon,
        "format": "json", "zoom": 14,
        "addressdetails": 1, "extratags": 1, "namedetails": 1,
    }
    headers = {"User-Agent": "photo-location-sorter/1.0 (personal use)"}

    try:
        time.sleep(NOMINATIM_DELAY)
        resp = requests.get(url, params=params, headers=headers, timeout=10)
        data = resp.json()
        addr = data.get("address", {})

        natural_keys = ["peak", "water", "lake", "reservoir", "bay", "cape",
                        "nature_reserve", "national_park", "forest", "mountain",
                        "fell", "valley", "glacier", "island"]
        natural = None
        for k in natural_keys:
            if k in addr:
                natural = addr[k]
                break
        if not natural and data.get("type") in natural_keys:
            natural = data.get("name")

        result = {
            "city": (addr.get("city") or addr.get("town") or
                     addr.get("village") or addr.get("municipality") or
                     addr.get("county") or "Unknown"),
            "country": addr.get("country") or "",
            "natural": natural,
        }
        cache[key] = result
        save_geocode_cache(source_dir, cache)
        return result

    except Exception as e:
        print(f"  ⚠ Geocoding failed for ({lat:.4f}, {lon:.4f}): {e}")
        result = {"city": "Unknown", "country": "", "natural": None}
        cache[key] = result
        return result


# ── Folder naming ─────────────────────────────────────────────────────────────

def make_folder_name(dt, geo):
    date_prefix = dt.strftime("%Y-%m") if dt else "Unknown-Date"
    city = geo.get("city", "Unknown")
    country = geo.get("country", "")
    natural = geo.get("natural")

    location = f"{city} – {natural}" if natural else city
    name = f"{date_prefix} {location} {country}".strip() if country else f"{date_prefix} {location}"

    name = re.sub(r'[<>:"/\\|?*]', "", name)
    name = re.sub(r"\s+", " ", name).strip()
    return name


# ── Clustering ────────────────────────────────────────────────────────────────

def haversine(lat1, lon1, lat2, lon2):
    R = 6371
    dlat = radians(lat2 - lat1)
    dlon = radians(lon2 - lon1)
    a = sin(dlat/2)**2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon/2)**2
    return 2 * R * asin(sqrt(a))


def cluster_geotagged(file_exifs):
    CITY_RADIUS_KM = 30
    clusters = []

    for filepath, exif in file_exifs:
        lat, lon, dt = exif["lat"], exif["lon"], exif["datetime"]
        placed = False
        for cluster in clusters:
            dist = haversine(lat, lon, cluster["center_lat"], cluster["center_lon"])
            if dist <= CITY_RADIUS_KM:
                cluster["files"].append(filepath)
                cluster["datetimes"].append(dt)
                n = len(cluster["files"])
                cluster["center_lat"] = (cluster["center_lat"] * (n-1) + lat) / n
                cluster["center_lon"] = (cluster["center_lon"] * (n-1) + lon) / n
                placed = True
                break
        if not placed:
            clusters.append({
                "center_lat": lat, "center_lon": lon,
                "files": [filepath], "datetimes": [dt],
            })

    return clusters


def assign_no_gps_files(no_gps_files, clusters):
    """
    Match each no-GPS file to the nearest cluster by timestamp.
    Files with no timestamp at all are left unmatched.
    """
    assigned = {}   # filepath -> cluster index
    unmatched = []

    for filepath, exif in no_gps_files:
        dt = exif["datetime"]
        if dt is None:
            unmatched.append((filepath, exif))
            continue

        if not clusters:
            unmatched.append((filepath, exif))
            continue

        # Find nearest cluster purely by time distance
        best_cluster = None
        best_gap = None

        for i, cluster in enumerate(clusters):
            cluster_dts = [d for d in cluster["datetimes"] if d is not None]
            if not cluster_dts:
                continue
            gap = min(abs(dt - d) for d in cluster_dts)
            if best_gap is None or gap < best_gap:
                best_gap = gap
                best_cluster = i

        if best_cluster is not None:
            assigned[filepath] = best_cluster
        else:
            unmatched.append((filepath, exif))

    return assigned, unmatched


# ── Main ──────────────────────────────────────────────────────────────────────

def collect_files(source_dir):
    files = []
    for f in source_dir.iterdir():
        if f.is_file() and f.suffix.lower() in ALL_EXTENSIONS:
            files.append(f)
    return sorted(files)


def main():
    parser = argparse.ArgumentParser(description="Sort photos by geolocation into named folders.")
    parser.add_argument("source", nargs="?", default=".", help="Source folder path")
    parser.add_argument("--dry-run", action="store_true", help="Preview without moving files")
    parser.add_argument("--diagnose", action="store_true", help="Inspect suspicious or unreadable files without moving anything")
    args = parser.parse_args()

    source_dir = Path(args.source).resolve()
    dry_run = args.dry_run

    if not source_dir.is_dir():
        print(f"Error: {source_dir} is not a directory.")
        sys.exit(1)

    if args.diagnose:
        diagnose_files(source_dir)
        return

    print(f"\n{'🔍 DRY RUN — no files will be moved' if dry_run else '📁 Sorting photos by location'}")
    print(f"Source: {source_dir}\n")

    # Step 1: Collect files
    print("Step 1/5: Scanning files...")
    all_files = collect_files(source_dir)
    print(f"  Found {len(all_files)} supported files\n")
    if not all_files:
        print("No files found. Done.")
        return

    # Step 2: Read EXIF
    print("Step 2/5: Reading EXIF data...")
    geotagged = []
    no_gps_photos = []   # real camera photos, no GPS
    screenshots = []     # screenshots and downloaded/non-camera images

    for i, filepath in enumerate(all_files):
        if i % 100 == 0:
            print(f"  {i}/{len(all_files)}...", end="\r")
        exif = read_exif(filepath)
        if exif["lat"] is not None and exif["lon"] is not None:
            geotagged.append((filepath, exif))
        elif is_screenshot(filepath, exif) or is_download_or_non_camera_image(filepath, exif):
            screenshots.append((filepath, exif))
        else:
            no_gps_photos.append((filepath, exif))

    print(f"  {len(geotagged)} geotagged, {len(no_gps_photos)} camera photos without GPS, {len(screenshots)} screenshots/downloads\n")

    # Step 3: Cluster geotagged files
    print("Step 3/5: Clustering by location...")
    clusters = cluster_geotagged(geotagged)
    print(f"  Found {len(clusters)} location clusters\n")

    # Step 4: Assign no-GPS photos to nearest cluster by time
    print("Step 4/5: Matching no-GPS photos by timestamp...")
    assigned_to_cluster, truly_unmatched = assign_no_gps_files(no_gps_photos, clusters)
    print(f"  {len(assigned_to_cluster)} matched to a cluster by timestamp")
    print(f"  {len(truly_unmatched)} could not be matched → _Other_Photos")
    print(f"  {len(screenshots)} screenshots/downloads → _Screenshots_and_Downloads\n")

    # Step 5: Reverse geocode + build move plan
    print("Step 5/5: Reverse geocoding and moving files...")
    geocode_cache = load_geocode_cache(source_dir)
    move_plan = []

    for i, cluster in enumerate(clusters):
        lat = cluster["center_lat"]
        lon = cluster["center_lon"]
        dts = sorted([d for d in cluster["datetimes"] if d is not None])
        rep_dt = dts[len(dts)//2] if dts else None

        print(f"  Cluster {i+1}/{len(clusters)}: {len(cluster['files'])} files near ({lat:.3f}, {lon:.3f})")
        geo = reverse_geocode(lat, lon, geocode_cache, source_dir)
        folder_name = make_folder_name(rep_dt, geo)
        print(f"    → {folder_name}")

        for filepath in cluster["files"]:
            move_plan.append((filepath, folder_name))

        # Add timestamp-matched no-GPS files
        for filepath, _ in no_gps_photos:
            if assigned_to_cluster.get(filepath) == i:
                move_plan.append((filepath, folder_name))

    # Unmatched real photos
    for filepath, _ in truly_unmatched:
        move_plan.append((filepath, "_Other_Photos"))

    # Screenshots and downloaded/non-camera images
    for filepath, _ in screenshots:
        move_plan.append((filepath, "_Screenshots_and_Downloads"))

    # Execute
    print(f"\n{'Preview' if dry_run else 'Moving'} {len(move_plan)} files...")
    folder_counts = defaultdict(int)

    for source_path, folder_name in move_plan:
        dest_dir = source_dir / folder_name
        dest_path = dest_dir / source_path.name
        folder_counts[folder_name] += 1

        if not dry_run:
            dest_dir.mkdir(parents=True, exist_ok=True)
            if dest_path.exists():
                stem, suffix = source_path.stem, source_path.suffix
                counter = 1
                while dest_path.exists():
                    dest_path = dest_dir / f"{stem}_{counter}{suffix}"
                    counter += 1
            shutil.move(str(source_path), str(dest_path))

    print("\n" + "─" * 50)
    print("SUMMARY" + (" (dry run)" if dry_run else ""))
    print("─" * 50)
    for folder, count in sorted(folder_counts.items()):
        print(f"  {count:4d} files  →  {folder}")
    print("─" * 50)
    print(f"  {len(move_plan):4d} files total")
    if dry_run:
        print("\nRun without --dry-run to actually move the files.")
    else:
        print("\n✓ Done.")


if __name__ == "__main__":
    main()

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *