From cdf33fd7771464e0a538b1854fc6b7b30ba01a0a Mon Sep 17 00:00:00 2001 From: ergosteur Date: Sun, 26 Jul 2026 15:47:33 -0400 Subject: [PATCH] Extend set_mtime_from_metadata.py to photos, AVI, and recursive batch mode Generalizes the tool beyond QuickTime video containers, based on what we found fixing a Synology Photos mass-wrong-date bug: the indexer only reads photo-EXIF fields and never video container metadata, even though most cameras embed a real timestamp there. - .avi via exiftool's RIFF DateTimeOriginal (local time, no offset) - .jpg/.jpeg/.png/.tif/.tiff/.heic/.heif via exiftool's EXIF DateTimeOriginal - exiftool fallback for .mp4/.mov when ffprobe finds neither QuickTime tag - placeholder "clock never set" timestamps (e.g. 0000:00:00 00:00:00) are treated as no metadata, not a real date - --dir for recursive directory batch mode - interactive y/N confirmation by default; --yes for unattended runs Verified against real files: a 2007 camcorder AVI, a synthetic UTC-tagged MP4, and an EXIF-tagged JPEG all round-trip correctly, including DST handling. Co-Authored-By: Claude Sonnet 5 --- README.md | 36 +++++-- set_mtime_from_metadata.py | 214 +++++++++++++++++++++++++++++-------- 2 files changed, 197 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index fd603cc..3ab1fff 100644 --- a/README.md +++ b/README.md @@ -126,19 +126,33 @@ Continuous runs (copy-paste to re-run on just that subset): ## Bonus utility: set_mtime_from_metadata.py -A standalone script (no `numpy` dependency, just `ffprobe`) that sets a video -file's filesystem mtime to match its own embedded creation-time metadata. -Not specific to Live Photos or this repo's main script — useful for any -video whose filesystem timestamp doesn't match its metadata (e.g. after -copying, downloading, or exporting), for tools like Synology Photos that -sort/date videos by mtime instead of parsing the embedded metadata. +A standalone script (no `numpy` dependency; needs `ffprobe` and `exiftool`) +that sets a media file's filesystem mtime to match its own embedded +creation-time metadata. Not specific to Live Photos or this repo's main +script — useful for any photo or video whose filesystem timestamp doesn't +match its metadata (e.g. after copying, downloading, exporting, or a bad +backup restore), for tools like Synology Photos that sort/date videos by +mtime instead of parsing embedded metadata. ```bash -./set_mtime_from_metadata.py video1.mov video2.mp4 ... -./set_mtime_from_metadata.py --dry-run *.mov # preview without changing anything +./set_mtime_from_metadata.py video1.mov photo1.jpg ... +./set_mtime_from_metadata.py --dry-run *.mov # preview without changing anything or prompting +./set_mtime_from_metadata.py --dir /path/to/library # recursive batch mode, prompts before applying +./set_mtime_from_metadata.py --dir /path/to/library --yes # recursive batch mode, unattended ``` -Prefers the Apple `com.apple.quicktime.creationdate` tag (local time) when -present, falling back to the generic `creation_time` tag (usually UTC). +Handles `.mov`/`.mp4`/`.m4v` (ffprobe container tags, preferring Apple's +`com.apple.quicktime.creationdate`, local time, over the generic +`creation_time`, usually UTC; falls back to exiftool's QuickTime atoms if +neither ffprobe tag is present), `.avi` (exiftool's RIFF `DateTimeOriginal`), +and photo formats — `.jpg`/`.jpeg`/`.png`/`.tif`/`.tiff`/`.heic`/`.heif` +(exiftool's EXIF `DateTimeOriginal`). Values with no timezone offset are +interpreted as this machine's local time (DST-aware) — run it somewhere +with the same system timezone the footage was actually shot in. Placeholder +"clock never set" timestamps some cameras write (e.g. literal +`0000:00:00 00:00:00`) are treated as no metadata, not a real date. + Files with no usable timestamp, or that don't exist, are skipped with a -warning and a non-zero exit code; the rest of the batch still runs. +warning and a non-zero exit code; the rest of the batch still runs. Without +`--dry-run` or `--yes`, it prints the full plan and asks for a single y/N +confirmation before touching anything. diff --git a/set_mtime_from_metadata.py b/set_mtime_from_metadata.py index 27c5cf3..c0b81b0 100755 --- a/set_mtime_from_metadata.py +++ b/set_mtime_from_metadata.py @@ -1,19 +1,48 @@ #!/usr/bin/env python3 -"""Set a video file's filesystem modification time to match its embedded +"""Set a media file's filesystem modification time to match its embedded creation-time metadata. Some tools (e.g. Synology Photos, for videos) sort/date by filesystem mtime -instead of parsing embedded video metadata, so a video with correct -metadata but a wrong mtime (common after copying, downloading, or exporting -a file) can show up filed under the wrong date. This resets mtime (and -atime) to match the video's own embedded creation time. +instead of parsing embedded metadata, so a file with correct metadata but a +wrong mtime (common after copying, downloading, exporting, or a bad backup +restore) can show up filed under the wrong date. This resets mtime (and +atime) to match the file's own embedded creation time. + +Handles, in priority order per format: + .mov/.mp4/.m4v -- ffprobe container tags: com.apple.quicktime.creationdate + (local time, explicit UTC offset -- preferred when + present) or creation_time (usually UTC, generic + encoder tag). Falls back to exiftool's CreateDate / + MediaCreateDate (QuickTime atoms, UTC per spec) if + ffprobe finds neither -- some non-Apple encoders only + populate those. + .avi -- exiftool's RIFF DateTimeOriginal (IDIT chunk). This is + the camera's own local wall-clock time with no + timezone info, unlike the QuickTime tags above. + .jpg/.jpeg/.png/.tif/.tiff/.heic/.heif + -- exiftool's EXIF DateTimeOriginal. Also local wall-clock + time with no timezone info. + +Values with no timezone offset (AVI, EXIF, and ffprobe's bare creation_time +fallback path is UTC-aware so it's unaffected) are interpreted using this +machine's system timezone, DST included -- same as GNU `touch -d` or Python's +datetime.timestamp() on a naive datetime. If you're running this somewhere +other than the timezone the footage was actually shot in, the result will be +wrong; there is no metadata to disambiguate that. + +Placeholder/unset timestamps some cameras write when their clock was never +set (e.g. the literal "0000:00:00 00:00:00", or years before MIN_YEAR) are +treated as "no metadata found", not a real timestamp. Usage: - ./set_mtime_from_metadata.py video1.mov video2.mp4 ... + ./set_mtime_from_metadata.py video1.mov photo1.jpg ... ./set_mtime_from_metadata.py --dry-run *.mov + ./set_mtime_from_metadata.py --dir /path/to/library # recursive, prompts before applying + ./set_mtime_from_metadata.py --dir /path/to/library --yes # recursive, unattended Requirements: - - ffprobe on PATH + - ffprobe on PATH (for .mov/.mp4/.m4v) + - exiftool on PATH (for .avi and photo formats, and as an mp4/mov fallback) """ import argparse import os @@ -24,24 +53,33 @@ from pathlib import Path # Preference order: the Apple tag is local time (matches what Photos/Finder # display); the generic tag is whatever the encoder wrote, usually UTC. -TAG_PRIORITY = ["com.apple.quicktime.creationdate", "creation_time"] +QUICKTIME_TAG_PRIORITY = ["com.apple.quicktime.creationdate", "creation_time"] + +QUICKTIME_EXTS = {".mov", ".mp4", ".m4v"} +AVI_EXTS = {".avi"} +EXIF_PHOTO_EXTS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".heic", ".heif"} +ALL_SUPPORTED_EXTS = QUICKTIME_EXTS | AVI_EXTS | EXIF_PHOTO_EXTS + +# Reject anything dated before this as a clock-never-set placeholder rather +# than a real timestamp (observed in the wild: "0000:00:00 00:00:00"). +MIN_YEAR = 1990 -def parse_timestamp(raw): +def parse_quicktime_timestamp(raw): raw = raw.strip().replace("Z", "+0000") for fmt in ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S.%f%z"): try: - return datetime.strptime(raw, fmt) + dt = datetime.strptime(raw, fmt) + return dt if dt.year >= MIN_YEAR else None except ValueError: continue return None -def fetch_creation_time(path): - """Best-effort embedded creation time for a video, checking - container-level tags first and falling back to the first video - stream's tags. Returns None if nothing usable is found.""" - tag_list = ",".join(TAG_PRIORITY) +def fetch_via_ffprobe(path): + """QuickTime container tags via ffprobe, container-level first then the + first video stream's tags. Returns a tz-aware datetime, or None.""" + tag_list = ",".join(QUICKTIME_TAG_PRIORITY) for scope, extra_args in (("format_tags", []), ("stream_tags", ["-select_streams", "v:0"])): try: out = subprocess.run( @@ -53,46 +91,138 @@ def fetch_creation_time(path): except subprocess.CalledProcessError as e: raise RuntimeError(e.stderr.strip() or str(e)) from e tags = dict(line.split("=", 1) for line in out.strip().splitlines() if "=" in line) - for tag in TAG_PRIORITY: + for tag in QUICKTIME_TAG_PRIORITY: raw = tags.get(f"TAG:{tag}") if raw: - dt = parse_timestamp(raw) + dt = parse_quicktime_timestamp(raw) if dt: return dt return None -def main(): - parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument("files", nargs="+", type=Path, help="Video files to fix") - parser.add_argument("--dry-run", action="store_true", help="Show what would change without modifying anything") - args = parser.parse_args() - - exit_code = 0 - for f in args.files: - if not f.is_file(): - print(f"skip: {f}: not found", file=sys.stderr) - exit_code = 1 +def fetch_via_exiftool(path, tags, assume_utc=False): + """First non-empty value among `tags` (in order) via exiftool, as + printed with -d for a stable "%Y:%m:%d %H:%M:%S" format. Returns a + datetime (tz-aware UTC if assume_utc, else naive/local), or None.""" + try: + out = subprocess.run( + ["exiftool", "-s3", "-d", "%Y:%m:%d %H:%M:%S", *[f"-{t}" for t in tags], str(path)], + check=True, capture_output=True, text=True, + ).stdout + except subprocess.CalledProcessError as e: + raise RuntimeError(e.stderr.strip() or str(e)) from e + except FileNotFoundError as e: + raise RuntimeError("exiftool not found on PATH") from e + for line in out.splitlines(): + raw = line.strip() + if not raw: continue try: - dt = fetch_creation_time(f) - except RuntimeError as e: - print(f"skip: {f}: ffprobe failed ({e})", file=sys.stderr) - exit_code = 1 + dt = datetime.strptime(raw, "%Y:%m:%d %H:%M:%S") + except ValueError: continue - if dt is None: - print(f"skip: {f}: no creation-time metadata found", file=sys.stderr) - exit_code = 1 + if dt.year < MIN_YEAR: continue + if assume_utc: + from datetime import timezone + dt = dt.replace(tzinfo=timezone.utc) + return dt + return None - if args.dry_run: - current = datetime.fromtimestamp(f.stat().st_mtime, tz=dt.tzinfo) - print(f"would set: {f}: {current.isoformat()} -> {dt.isoformat()}", file=sys.stderr) - else: - ts = dt.timestamp() - os.utime(f, (ts, ts)) - print(f"set: {f}: mtime -> {dt.isoformat()}", file=sys.stderr) +def fetch_creation_time(path): + """Best-effort embedded creation time for a media file, dispatched by + extension. Returns a datetime (naive = interpret as this machine's + local time; aware = absolute, tz conversion handled automatically by + .timestamp()), or None if nothing usable was found.""" + ext = path.suffix.lower() + + if ext in QUICKTIME_EXTS: + dt = fetch_via_ffprobe(path) + if dt: + return dt + # Fallback: some non-Apple encoders don't populate either ffprobe + # tag but do have exiftool-readable QuickTime atoms. These are UTC + # per the QuickTime spec. + return fetch_via_exiftool(path, ["MediaCreateDate", "CreateDate"], assume_utc=True) + + if ext in AVI_EXTS: + return fetch_via_exiftool(path, ["DateTimeOriginal"]) + + if ext in EXIF_PHOTO_EXTS: + return fetch_via_exiftool(path, ["DateTimeOriginal", "CreateDate"]) + + return None + + +def iter_batch_files(root): + for dirpath, _dirnames, filenames in os.walk(root): + for name in filenames: + if Path(name).suffix.lower() in ALL_SUPPORTED_EXTS: + yield Path(dirpath) / name + + +def plan_for(path): + """Returns (path, datetime, error_message). Exactly one of + (datetime, error_message) is set.""" + if not path.is_file(): + return path, None, "not found" + try: + dt = fetch_creation_time(path) + except RuntimeError as e: + return path, None, f"metadata read failed ({e})" + if dt is None: + return path, None, "no creation-time metadata found" + return path, dt, None + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("files", nargs="*", type=Path, help="Media files to fix") + parser.add_argument("--dir", type=Path, help="Recursively scan this directory for supported files instead of (or in addition to) explicit FILES") + parser.add_argument("--dry-run", action="store_true", help="Show what would change without modifying anything or prompting") + parser.add_argument("--yes", "-y", "--unattended", dest="yes", action="store_true", help="Apply changes without an interactive confirmation prompt") + args = parser.parse_args() + + targets = list(args.files) + if args.dir: + if not args.dir.is_dir(): + print(f"error: --dir {args.dir}: not a directory", file=sys.stderr) + sys.exit(2) + targets.extend(sorted(iter_batch_files(args.dir))) + if not targets: + parser.error("no files given -- pass FILES and/or --dir") + + plans = [plan_for(f) for f in targets] + ok_plans = [(p, dt) for p, dt, err in plans if err is None] + skipped = [(p, err) for p, dt, err in plans if err is not None] + + for p, err in skipped: + print(f"skip: {p}: {err}", file=sys.stderr) + + if not ok_plans: + print("nothing to do: no files had usable creation-time metadata", file=sys.stderr) + sys.exit(1 if skipped else 0) + + for p, dt in ok_plans: + current = datetime.fromtimestamp(p.stat().st_mtime, tz=dt.tzinfo) + verb = "would set" if args.dry_run else "set" + print(f"{verb}: {p}: {current.isoformat()} -> {dt.isoformat()}", file=sys.stderr) + + if args.dry_run: + sys.exit(1 if skipped else 0) + + if not args.yes: + answer = input(f"\nApply {len(ok_plans)} mtime change(s)? [y/N] ").strip().lower() + if answer not in ("y", "yes"): + print("aborted, no changes made", file=sys.stderr) + sys.exit(1) + + exit_code = 1 if skipped else 0 + for p, dt in ok_plans: + ts = dt.timestamp() + os.utime(p, (ts, ts)) + print(f"done: {len(ok_plans)} file(s) updated, {len(skipped)} skipped", file=sys.stderr) sys.exit(exit_code)