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
+172 -42
View File
@@ -1,19 +1,48 @@
#!/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.
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.
instead of parsing embedded metadata, so a file with correct metadata but a
wrong mtime (common after copying, downloading, exporting, or a bad backup
restore) can show up filed under the wrong date. This resets mtime (and
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:
./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 --dir /path/to/library # recursive, prompts before applying
./set_mtime_from_metadata.py --dir /path/to/library --yes # recursive, unattended
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 os
@@ -24,24 +53,33 @@ 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"]
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")
for fmt in ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S.%f%z"):
try:
return datetime.strptime(raw, fmt)
dt = datetime.strptime(raw, fmt)
return dt if dt.year >= MIN_YEAR else None
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)
def fetch_via_ffprobe(path):
"""QuickTime container tags via ffprobe, container-level first then the
first video stream's tags. Returns a tz-aware datetime, or None."""
tag_list = ",".join(QUICKTIME_TAG_PRIORITY)
for scope, extra_args in (("format_tags", []), ("stream_tags", ["-select_streams", "v:0"])):
try:
out = subprocess.run(
@@ -53,46 +91,138 @@ def fetch_creation_time(path):
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:
for tag in QUICKTIME_TAG_PRIORITY:
raw = tags.get(f"TAG:{tag}")
if raw:
dt = parse_timestamp(raw)
dt = parse_quicktime_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
def fetch_via_exiftool(path, tags, assume_utc=False):
"""First non-empty value among `tags` (in order) via exiftool, as
printed with -d for a stable "%Y:%m:%d %H:%M:%S" format. Returns a
datetime (tz-aware UTC if assume_utc, else naive/local), or None."""
try:
out = subprocess.run(
["exiftool", "-s3", "-d", "%Y:%m:%d %H:%M:%S", *[f"-{t}" for t in tags], 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
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
try:
dt = fetch_creation_time(f)
except RuntimeError as e:
print(f"skip: {f}: ffprobe failed ({e})", file=sys.stderr)
exit_code = 1
dt = datetime.strptime(raw, "%Y:%m:%d %H:%M:%S")
except ValueError:
continue
if dt is None:
print(f"skip: {f}: no creation-time metadata found", file=sys.stderr)
exit_code = 1
if dt.year < MIN_YEAR:
continue
if assume_utc:
from datetime import timezone
dt = dt.replace(tzinfo=timezone.utc)
return dt
return None
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)
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:
return path, None, f"metadata read failed ({e})"
if dt is None:
return path, None, "no creation-time metadata found"
return path, dt, None
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:
sys.exit(1 if skipped else 0)
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)