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:
Executable
+100
@@ -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()
|
||||
Reference in New Issue
Block a user