Extend set_mtime_from_metadata.py to photos, AVI, and recursive batch mode

Generalizes the tool beyond QuickTime video containers, based on what
we found fixing a Synology Photos mass-wrong-date bug: the indexer
only reads photo-EXIF fields and never video container metadata, even
though most cameras embed a real timestamp there.

- .avi via exiftool's RIFF DateTimeOriginal (local time, no offset)
- .jpg/.jpeg/.png/.tif/.tiff/.heic/.heif via exiftool's EXIF DateTimeOriginal
- exiftool fallback for .mp4/.mov when ffprobe finds neither QuickTime tag
- placeholder "clock never set" timestamps (e.g. 0000:00:00 00:00:00)
  are treated as no metadata, not a real date
- --dir for recursive directory batch mode
- interactive y/N confirmation by default; --yes for unattended runs

Verified against real files: a 2007 camcorder AVI, a synthetic UTC-tagged
MP4, and an EXIF-tagged JPEG all round-trip correctly, including DST
handling.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 15:47:33 -04:00
co-authored by Claude Sonnet 5
parent e1cd699b79
commit cdf33fd777
2 changed files with 197 additions and 53 deletions
+25 -11
View File
@@ -126,19 +126,33 @@ Continuous runs (copy-paste to re-run on just that subset):
## Bonus utility: set_mtime_from_metadata.py ## Bonus utility: set_mtime_from_metadata.py
A standalone script (no `numpy` dependency, just `ffprobe`) that sets a video A standalone script (no `numpy` dependency; needs `ffprobe` and `exiftool`)
file's filesystem mtime to match its own embedded creation-time metadata. that sets a media file's filesystem mtime to match its own embedded
Not specific to Live Photos or this repo's main script — useful for any creation-time metadata. Not specific to Live Photos or this repo's main
video whose filesystem timestamp doesn't match its metadata (e.g. after script — useful for any photo or video whose filesystem timestamp doesn't
copying, downloading, or exporting), for tools like Synology Photos that match its metadata (e.g. after copying, downloading, exporting, or a bad
sort/date videos by mtime instead of parsing the embedded metadata. backup restore), for tools like Synology Photos that sort/date videos by
mtime instead of parsing embedded metadata.
```bash ```bash
./set_mtime_from_metadata.py video1.mov video2.mp4 ... ./set_mtime_from_metadata.py video1.mov photo1.jpg ...
./set_mtime_from_metadata.py --dry-run *.mov # preview without changing anything ./set_mtime_from_metadata.py --dry-run *.mov # preview without changing anything or prompting
./set_mtime_from_metadata.py --dir /path/to/library # recursive batch mode, prompts before applying
./set_mtime_from_metadata.py --dir /path/to/library --yes # recursive batch mode, unattended
``` ```
Prefers the Apple `com.apple.quicktime.creationdate` tag (local time) when Handles `.mov`/`.mp4`/`.m4v` (ffprobe container tags, preferring Apple's
present, falling back to the generic `creation_time` tag (usually UTC). `com.apple.quicktime.creationdate`, local time, over the generic
`creation_time`, usually UTC; falls back to exiftool's QuickTime atoms if
neither ffprobe tag is present), `.avi` (exiftool's RIFF `DateTimeOriginal`),
and photo formats — `.jpg`/`.jpeg`/`.png`/`.tif`/`.tiff`/`.heic`/`.heif`
(exiftool's EXIF `DateTimeOriginal`). Values with no timezone offset are
interpreted as this machine's local time (DST-aware) — run it somewhere
with the same system timezone the footage was actually shot in. Placeholder
"clock never set" timestamps some cameras write (e.g. literal
`0000:00:00 00:00:00`) are treated as no metadata, not a real date.
Files with no usable timestamp, or that don't exist, are skipped with a 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. warning and a non-zero exit code; the rest of the batch still runs. Without
`--dry-run` or `--yes`, it prints the full plan and asks for a single y/N
confirmation before touching anything.
+171 -41
View File
@@ -1,19 +1,48 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Set a video file's filesystem modification time to match its embedded """Set a media file's filesystem modification time to match its embedded
creation-time metadata. creation-time metadata.
Some tools (e.g. Synology Photos, for videos) sort/date by filesystem mtime Some tools (e.g. Synology Photos, for videos) sort/date by filesystem mtime
instead of parsing embedded video metadata, so a video with correct instead of parsing embedded metadata, so a file with correct metadata but a
metadata but a wrong mtime (common after copying, downloading, or exporting wrong mtime (common after copying, downloading, exporting, or a bad backup
a file) can show up filed under the wrong date. This resets mtime (and restore) can show up filed under the wrong date. This resets mtime (and
atime) to match the video's own embedded creation time. atime) to match the file's own embedded creation time.
Handles, in priority order per format:
.mov/.mp4/.m4v -- ffprobe container tags: com.apple.quicktime.creationdate
(local time, explicit UTC offset -- preferred when
present) or creation_time (usually UTC, generic
encoder tag). Falls back to exiftool's CreateDate /
MediaCreateDate (QuickTime atoms, UTC per spec) if
ffprobe finds neither -- some non-Apple encoders only
populate those.
.avi -- exiftool's RIFF DateTimeOriginal (IDIT chunk). This is
the camera's own local wall-clock time with no
timezone info, unlike the QuickTime tags above.
.jpg/.jpeg/.png/.tif/.tiff/.heic/.heif
-- exiftool's EXIF DateTimeOriginal. Also local wall-clock
time with no timezone info.
Values with no timezone offset (AVI, EXIF, and ffprobe's bare creation_time
fallback path is UTC-aware so it's unaffected) are interpreted using this
machine's system timezone, DST included -- same as GNU `touch -d` or Python's
datetime.timestamp() on a naive datetime. If you're running this somewhere
other than the timezone the footage was actually shot in, the result will be
wrong; there is no metadata to disambiguate that.
Placeholder/unset timestamps some cameras write when their clock was never
set (e.g. the literal "0000:00:00 00:00:00", or years before MIN_YEAR) are
treated as "no metadata found", not a real timestamp.
Usage: Usage:
./set_mtime_from_metadata.py video1.mov video2.mp4 ... ./set_mtime_from_metadata.py video1.mov photo1.jpg ...
./set_mtime_from_metadata.py --dry-run *.mov ./set_mtime_from_metadata.py --dry-run *.mov
./set_mtime_from_metadata.py --dir /path/to/library # recursive, prompts before applying
./set_mtime_from_metadata.py --dir /path/to/library --yes # recursive, unattended
Requirements: Requirements:
- ffprobe on PATH - ffprobe on PATH (for .mov/.mp4/.m4v)
- exiftool on PATH (for .avi and photo formats, and as an mp4/mov fallback)
""" """
import argparse import argparse
import os import os
@@ -24,24 +53,33 @@ from pathlib import Path
# Preference order: the Apple tag is local time (matches what Photos/Finder # Preference order: the Apple tag is local time (matches what Photos/Finder
# display); the generic tag is whatever the encoder wrote, usually UTC. # display); the generic tag is whatever the encoder wrote, usually UTC.
TAG_PRIORITY = ["com.apple.quicktime.creationdate", "creation_time"] QUICKTIME_TAG_PRIORITY = ["com.apple.quicktime.creationdate", "creation_time"]
QUICKTIME_EXTS = {".mov", ".mp4", ".m4v"}
AVI_EXTS = {".avi"}
EXIF_PHOTO_EXTS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".heic", ".heif"}
ALL_SUPPORTED_EXTS = QUICKTIME_EXTS | AVI_EXTS | EXIF_PHOTO_EXTS
# Reject anything dated before this as a clock-never-set placeholder rather
# than a real timestamp (observed in the wild: "0000:00:00 00:00:00").
MIN_YEAR = 1990
def parse_timestamp(raw): def parse_quicktime_timestamp(raw):
raw = raw.strip().replace("Z", "+0000") raw = raw.strip().replace("Z", "+0000")
for fmt in ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S.%f%z"): for fmt in ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S.%f%z"):
try: try:
return datetime.strptime(raw, fmt) dt = datetime.strptime(raw, fmt)
return dt if dt.year >= MIN_YEAR else None
except ValueError: except ValueError:
continue continue
return None return None
def fetch_creation_time(path): def fetch_via_ffprobe(path):
"""Best-effort embedded creation time for a video, checking """QuickTime container tags via ffprobe, container-level first then the
container-level tags first and falling back to the first video first video stream's tags. Returns a tz-aware datetime, or None."""
stream's tags. Returns None if nothing usable is found.""" tag_list = ",".join(QUICKTIME_TAG_PRIORITY)
tag_list = ",".join(TAG_PRIORITY)
for scope, extra_args in (("format_tags", []), ("stream_tags", ["-select_streams", "v:0"])): for scope, extra_args in (("format_tags", []), ("stream_tags", ["-select_streams", "v:0"])):
try: try:
out = subprocess.run( out = subprocess.run(
@@ -53,46 +91,138 @@ def fetch_creation_time(path):
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
raise RuntimeError(e.stderr.strip() or str(e)) from 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) tags = dict(line.split("=", 1) for line in out.strip().splitlines() if "=" in line)
for tag in TAG_PRIORITY: for tag in QUICKTIME_TAG_PRIORITY:
raw = tags.get(f"TAG:{tag}") raw = tags.get(f"TAG:{tag}")
if raw: if raw:
dt = parse_timestamp(raw) dt = parse_quicktime_timestamp(raw)
if dt: if dt:
return dt return dt
return None return None
def main(): def fetch_via_exiftool(path, tags, assume_utc=False):
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) """First non-empty value among `tags` (in order) via exiftool, as
parser.add_argument("files", nargs="+", type=Path, help="Video files to fix") printed with -d for a stable "%Y:%m:%d %H:%M:%S" format. Returns a
parser.add_argument("--dry-run", action="store_true", help="Show what would change without modifying anything") datetime (tz-aware UTC if assume_utc, else naive/local), or None."""
args = parser.parse_args() try:
out = subprocess.run(
exit_code = 0 ["exiftool", "-s3", "-d", "%Y:%m:%d %H:%M:%S", *[f"-{t}" for t in tags], str(path)],
for f in args.files: check=True, capture_output=True, text=True,
if not f.is_file(): ).stdout
print(f"skip: {f}: not found", file=sys.stderr) except subprocess.CalledProcessError as e:
exit_code = 1 raise RuntimeError(e.stderr.strip() or str(e)) from e
except FileNotFoundError as e:
raise RuntimeError("exiftool not found on PATH") from e
for line in out.splitlines():
raw = line.strip()
if not raw:
continue continue
try: try:
dt = fetch_creation_time(f) dt = datetime.strptime(raw, "%Y:%m:%d %H:%M:%S")
except ValueError:
continue
if dt.year < MIN_YEAR:
continue
if assume_utc:
from datetime import timezone
dt = dt.replace(tzinfo=timezone.utc)
return dt
return None
def fetch_creation_time(path):
"""Best-effort embedded creation time for a media file, dispatched by
extension. Returns a datetime (naive = interpret as this machine's
local time; aware = absolute, tz conversion handled automatically by
.timestamp()), or None if nothing usable was found."""
ext = path.suffix.lower()
if ext in QUICKTIME_EXTS:
dt = fetch_via_ffprobe(path)
if dt:
return dt
# Fallback: some non-Apple encoders don't populate either ffprobe
# tag but do have exiftool-readable QuickTime atoms. These are UTC
# per the QuickTime spec.
return fetch_via_exiftool(path, ["MediaCreateDate", "CreateDate"], assume_utc=True)
if ext in AVI_EXTS:
return fetch_via_exiftool(path, ["DateTimeOriginal"])
if ext in EXIF_PHOTO_EXTS:
return fetch_via_exiftool(path, ["DateTimeOriginal", "CreateDate"])
return None
def iter_batch_files(root):
for dirpath, _dirnames, filenames in os.walk(root):
for name in filenames:
if Path(name).suffix.lower() in ALL_SUPPORTED_EXTS:
yield Path(dirpath) / name
def plan_for(path):
"""Returns (path, datetime, error_message). Exactly one of
(datetime, error_message) is set."""
if not path.is_file():
return path, None, "not found"
try:
dt = fetch_creation_time(path)
except RuntimeError as e: except RuntimeError as e:
print(f"skip: {f}: ffprobe failed ({e})", file=sys.stderr) return path, None, f"metadata read failed ({e})"
exit_code = 1
continue
if dt is None: if dt is None:
print(f"skip: {f}: no creation-time metadata found", file=sys.stderr) return path, None, "no creation-time metadata found"
exit_code = 1 return path, dt, None
continue
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("files", nargs="*", type=Path, help="Media files to fix")
parser.add_argument("--dir", type=Path, help="Recursively scan this directory for supported files instead of (or in addition to) explicit FILES")
parser.add_argument("--dry-run", action="store_true", help="Show what would change without modifying anything or prompting")
parser.add_argument("--yes", "-y", "--unattended", dest="yes", action="store_true", help="Apply changes without an interactive confirmation prompt")
args = parser.parse_args()
targets = list(args.files)
if args.dir:
if not args.dir.is_dir():
print(f"error: --dir {args.dir}: not a directory", file=sys.stderr)
sys.exit(2)
targets.extend(sorted(iter_batch_files(args.dir)))
if not targets:
parser.error("no files given -- pass FILES and/or --dir")
plans = [plan_for(f) for f in targets]
ok_plans = [(p, dt) for p, dt, err in plans if err is None]
skipped = [(p, err) for p, dt, err in plans if err is not None]
for p, err in skipped:
print(f"skip: {p}: {err}", file=sys.stderr)
if not ok_plans:
print("nothing to do: no files had usable creation-time metadata", file=sys.stderr)
sys.exit(1 if skipped else 0)
for p, dt in ok_plans:
current = datetime.fromtimestamp(p.stat().st_mtime, tz=dt.tzinfo)
verb = "would set" if args.dry_run else "set"
print(f"{verb}: {p}: {current.isoformat()} -> {dt.isoformat()}", file=sys.stderr)
if args.dry_run: if args.dry_run:
current = datetime.fromtimestamp(f.stat().st_mtime, tz=dt.tzinfo) sys.exit(1 if skipped else 0)
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)
if not args.yes:
answer = input(f"\nApply {len(ok_plans)} mtime change(s)? [y/N] ").strip().lower()
if answer not in ("y", "yes"):
print("aborted, no changes made", file=sys.stderr)
sys.exit(1)
exit_code = 1 if skipped else 0
for p, dt in ok_plans:
ts = dt.timestamp()
os.utime(p, (ts, ts))
print(f"done: {len(ok_plans)} file(s) updated, {len(skipped)} skipped", file=sys.stderr)
sys.exit(exit_code) sys.exit(exit_code)