#!/usr/bin/env python3 """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 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 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 ./set_mtime_from_metadata.py --dir /path/to/library --only-photo --dry-run ./set_mtime_from_metadata.py --dir /path/to/library --only-video --yes Requirements: - 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 import subprocess import sys from datetime import datetime 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. QUICKTIME_TAG_PRIORITY = ["com.apple.quicktime.creationdate", "creation_time"] QUICKTIME_EXTS = {".mov", ".mp4", ".m4v"} AVI_EXTS = {".avi"} VIDEO_EXTS = QUICKTIME_EXTS | AVI_EXTS EXIF_PHOTO_EXTS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".heic", ".heif"} ALL_SUPPORTED_EXTS = VIDEO_EXTS | EXIF_PHOTO_EXTS # Synology (and other NAS/sync tool) internal housekeeping directories that # only ever contain generated thumbnails/cache, never real source media -- # pruned from --dir's recursive walk so they don't show up as noisy skips. SKIP_DIR_NAMES = {"@eaDir", "#recycle", "#snapshot", ".SynologyWorkingDirectory"} # 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_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: dt = datetime.strptime(raw, fmt) return dt if dt.year >= MIN_YEAR else None except ValueError: continue return None 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( ["ffprobe", "-v", "error", *extra_args, "-show_entries", f"{scope}={tag_list}", "-of", "default=noprint_wrappers=1:nokey=0", 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 tags = dict(line.split("=", 1) for line in out.strip().splitlines() if "=" in line) for tag in QUICKTIME_TAG_PRIORITY: raw = tags.get(f"TAG:{tag}") if raw: dt = parse_quicktime_timestamp(raw) if dt: return dt return None 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 = datetime.strptime(raw, "%Y:%m:%d %H:%M:%S") except ValueError: continue if dt.year < MIN_YEAR: continue if assume_utc: from datetime import timezone dt = dt.replace(tzinfo=timezone.utc) return dt return None 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, allowed_exts): for dirpath, dirnames, filenames in os.walk(root): dirnames[:] = [d for d in dirnames if d not in SKIP_DIR_NAMES] for name in filenames: if Path(name).suffix.lower() in allowed_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") type_group = parser.add_mutually_exclusive_group() type_group.add_argument("--only-photo", action="store_true", help="Only consider photo formats (jpg/jpeg/png/tif/tiff/heic/heif)") type_group.add_argument("--only-video", action="store_true", help="Only consider video formats (mov/mp4/m4v/avi)") 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() if not args.files and not args.dir: parser.error("no files given -- pass FILES and/or --dir") allowed_exts = EXIF_PHOTO_EXTS if args.only_photo else VIDEO_EXTS if args.only_video else ALL_SUPPORTED_EXTS targets = [] excluded_by_type = [] for f in args.files: if f.suffix.lower() not in allowed_exts: excluded_by_type.append((f, "excluded by --only-photo/--only-video")) continue targets.append(f) 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, allowed_exts))) if not targets: for f, err in excluded_by_type: print(f"skip: {f}: {err}", file=sys.stderr) print("nothing to do: no files matched (check --only-photo/--only-video and the extensions under --dir)", file=sys.stderr) sys.exit(1 if excluded_by_type else 0) plans = [plan_for(f) for f in targets] ok_plans = [(p, dt) for p, dt, err in plans if err is None] skipped = excluded_by_type + [(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) if __name__ == "__main__": main()