diff --git a/README.md b/README.md index be6173d..fd603cc 100644 --- a/README.md +++ b/README.md @@ -123,3 +123,22 @@ Continuous runs (copy-paste to re-run on just that subset): Run 3 (3 clips): IMG_2451.MOV IMG_2452.MOV IMG_2453.MOV Run 4 (1 clip): IMG_2454.MOV -- nothing to concatenate on its own ``` + +## 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. + +```bash +./set_mtime_from_metadata.py video1.mov video2.mp4 ... +./set_mtime_from_metadata.py --dry-run *.mov # preview without changing anything +``` + +Prefers the Apple `com.apple.quicktime.creationdate` tag (local time) when +present, falling back to the generic `creation_time` tag (usually UTC). +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. diff --git a/set_mtime_from_metadata.py b/set_mtime_from_metadata.py new file mode 100755 index 0000000..27c5cf3 --- /dev/null +++ b/set_mtime_from_metadata.py @@ -0,0 +1,100 @@ +#!/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()