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

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

Leave a Reply