Add standalone set_mtime_from_metadata.py utility

Extracted the mtime-stamping logic added to concat_live_clips.py into
its own dependency-free script (just needs ffprobe), so it can be run
against any video file -- not just Live Photo clips -- to fix a
mismatched filesystem mtime for tools like Synology Photos that sort
videos by mtime instead of parsing embedded metadata.
This commit is contained in:
2026-07-26 13:01:50 -04:00
parent 20c5216afc
commit 5fa68dac76
2 changed files with 119 additions and 0 deletions
+100
View File
@@ -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()