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.
101 lines
3.5 KiB
Python
Executable File
101 lines
3.5 KiB
Python
Executable File
#!/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()
|