#!/usr/bin/env python3 """Set a video 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. Usage: ./set_mtime_from_metadata.py video1.mov video2.mp4 ... ./set_mtime_from_metadata.py --dry-run *.mov Requirements: - ffprobe on PATH """ 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. TAG_PRIORITY = ["com.apple.quicktime.creationdate", "creation_time"] def parse_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) 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) 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 TAG_PRIORITY: raw = tags.get(f"TAG:{tag}") if raw: dt = parse_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 continue try: dt = fetch_creation_time(f) except RuntimeError as e: print(f"skip: {f}: ffprobe failed ({e})", file=sys.stderr) exit_code = 1 continue if dt is None: print(f"skip: {f}: no creation-time metadata found", file=sys.stderr) exit_code = 1 continue 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) sys.exit(exit_code) if __name__ == "__main__": main()