Skip to content

Commit 2dbea65

Browse files
Merge pull request #3122 from StarStrucken/master
Add an automation script for photo renaming
2 parents 562dbf6 + 8da4e3e commit 2dbea65

File tree

1 file changed

+199
-0
lines changed

1 file changed

+199
-0
lines changed

photo_timestamp_renamer.py

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Author: Ivan Costa Neto
4+
Date: 13-01-26
5+
6+
Auto-rename photos by timestamp, so you can organize those vacation trip photos!!
7+
8+
Name format: YYYY-MM-DD_HH-MM-SS[_NN].ext
9+
10+
Uses EXIF DateTimeOriginal when available (best for JPEG),
11+
otherwise falls back to file modified time,
12+
13+
i.e.
14+
python rename_photos.py ~/Pictures/Trip --dry-run
15+
python rename_photos.py ~/Pictures/Trip --recursive
16+
python rename_photos.py . --prefix Japan --recursive
17+
"""
18+
19+
from __future__ import annotations
20+
import argparse
21+
from dataclasses import dataclass
22+
from datetime import datetime
23+
from pathlib import Path
24+
import re
25+
import sys
26+
27+
SUPPORTED_EXTS = {".jpg", ".jpeg", ".png", ".heic", ".webp", ".tif", ".tiff"}
28+
29+
# EXIF support is optional (w\ Pillow)
30+
try:
31+
from PIL import Image, ExifTags # type: ignore
32+
PIL_OK = True
33+
except Exception:
34+
PIL_OK = False
35+
36+
37+
def is_photo(p: Path) -> bool:
38+
return p.is_file() and p.suffix.lower() in SUPPORTED_EXTS
39+
40+
41+
def sanitize_prefix(s: str) -> str:
42+
s = s.strip()
43+
if not s:
44+
return ""
45+
s = re.sub(r"[^\w\-]+", "_", s)
46+
return s[:50]
47+
48+
49+
def exif_datetime_original(path: Path) -> datetime | None:
50+
"""
51+
Try to read EXIF DateTimeOriginal/DateTime from image.
52+
Returns None if unavailable.
53+
"""
54+
if not PIL_OK:
55+
return None
56+
try:
57+
img = Image.open(path)
58+
exif = img.getexif()
59+
if not exif:
60+
return None
61+
62+
# map EXIF tag ids -> names
63+
tag_map = {}
64+
for k, v in ExifTags.TAGS.items():
65+
tag_map[k] = v
66+
67+
# common EXIF datetime tags
68+
dto = None
69+
dt = None
70+
for tag_id, value in exif.items():
71+
name = tag_map.get(tag_id)
72+
if name == "DateTimeOriginal":
73+
dto = value
74+
elif name == "DateTime":
75+
dt = value
76+
77+
raw = dto or dt
78+
if not raw:
79+
return None
80+
81+
# EXIF datetime format: "YYYY:MM:DD HH:MM:SS"
82+
raw = str(raw).strip()
83+
return datetime.strptime(raw, "%Y:%m:%d %H:%M:%S")
84+
except Exception:
85+
return None
86+
87+
88+
def file_mtime(path: Path) -> datetime:
89+
return datetime.fromtimestamp(path.stat().st_mtime)
90+
91+
92+
def unique_name(dest_dir: Path, base: str, ext: str) -> Path:
93+
"""
94+
If base.ext exists, append _01, _02, ...
95+
"""
96+
cand = dest_dir / f"{base}{ext}"
97+
if not cand.exists():
98+
return cand
99+
i = 1
100+
while True:
101+
cand = dest_dir / f"{base}_{i:02d}{ext}"
102+
if not cand.exists():
103+
return cand
104+
i += 1
105+
106+
107+
@dataclass
108+
class Options:
109+
folder: Path
110+
recursive: bool
111+
dry_run: bool
112+
prefix: str
113+
keep_original: bool # if true, don't rename if it already matches our format
114+
115+
116+
def already_formatted(name: str) -> bool:
117+
# matches: YYYY-MM-DD_HH-MM-SS or with prefix and/or _NN
118+
pattern = r"^(?:[A-Za-z0-9_]+_)?\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}(?:_\d{2})?$"
119+
return re.match(pattern, Path(name).stem) is not None
120+
121+
122+
def gather_photos(folder: Path, recursive: bool) -> list[Path]:
123+
if recursive:
124+
return [p for p in folder.rglob("*") if is_photo(p)]
125+
return [p for p in folder.iterdir() if is_photo(p)]
126+
127+
128+
def rename_photos(opts: Options) -> int:
129+
photos = gather_photos(opts.folder, opts.recursive)
130+
photos.sort()
131+
132+
if not photos:
133+
print("No supported photo files found.")
134+
return 0
135+
136+
if opts.prefix:
137+
pref = sanitize_prefix(opts.prefix)
138+
else:
139+
pref = ""
140+
141+
renamed = 0
142+
for p in photos:
143+
if opts.keep_original and already_formatted(p.name):
144+
continue
145+
146+
dt = exif_datetime_original(p) or file_mtime(p)
147+
base = dt.strftime("%Y-%m-%d_%H-%M-%S")
148+
if pref:
149+
base = f"{pref}_{base}"
150+
151+
dest = unique_name(p.parent, base, p.suffix.lower())
152+
153+
if dest.name == p.name:
154+
continue
155+
156+
if opts.dry_run:
157+
print(f"[DRY] {p.relative_to(opts.folder)} -> {dest.name}")
158+
else:
159+
p.rename(dest)
160+
print(f"[OK ] {p.relative_to(opts.folder)} -> {dest.name}")
161+
renamed += 1
162+
163+
if not opts.dry_run:
164+
print(f"\nDone. Renamed {renamed} file(s).")
165+
return renamed
166+
167+
168+
def main(argv: list[str]) -> int:
169+
ap = argparse.ArgumentParser(description="Auto-rename photos using EXIF date (or file modified time).")
170+
ap.add_argument("folder", help="Folder containing photos")
171+
ap.add_argument("--recursive", action="store_true", help="Process subfolders too")
172+
ap.add_argument("--dry-run", action="store_true", help="Preview changes without renaming")
173+
ap.add_argument("--prefix", default="", help="Optional prefix (e.g., Japan, RWTH, Trip)")
174+
ap.add_argument("--keep-original", action="store_true",
175+
help="Skip files that already match YYYY-MM-DD_HH-MM-SS naming")
176+
args = ap.parse_args(argv)
177+
178+
folder = Path(args.folder).expanduser()
179+
if not folder.exists() or not folder.is_dir():
180+
print(f"Not a directory: {folder}", file=sys.stderr)
181+
return 2
182+
183+
if not PIL_OK:
184+
print("[Note] Pillow not installed; EXIF dates won't be read (mtime fallback only).")
185+
print(" Install for best results: pip install pillow")
186+
187+
opts = Options(
188+
folder=folder,
189+
recursive=args.recursive,
190+
dry_run=args.dry_run,
191+
prefix=args.prefix,
192+
keep_original=args.keep_original,
193+
)
194+
rename_photos(opts)
195+
return 0
196+
197+
198+
if __name__ == "__main__":
199+
raise SystemExit(main(sys.argv[1:]))

0 commit comments

Comments
 (0)