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:
@@ -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 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
|
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.
|
||||||
|
|||||||
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