Verified via exiftool against real Sony ARW, Canon CR2, and Pixel DNG files pulled from are-nas -- same EXIF DateTimeOriginal/CreateDate tags as JPEG, no new codepath needed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
261 lines
11 KiB
Python
Executable File
261 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""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 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/.arw/.cr2/.dng/.raw
|
|
-- exiftool's EXIF DateTimeOriginal (the .arw/.cr2/.dng/
|
|
.raw camera-raw formats carry standard EXIF too).
|
|
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 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
|
|
./set_mtime_from_metadata.py --dir /path/to/library --only-photo --dry-run
|
|
./set_mtime_from_metadata.py --dir /path/to/library --only-video --yes
|
|
|
|
Requirements:
|
|
- 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
|
|
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.
|
|
QUICKTIME_TAG_PRIORITY = ["com.apple.quicktime.creationdate", "creation_time"]
|
|
|
|
QUICKTIME_EXTS = {".mov", ".mp4", ".m4v"}
|
|
AVI_EXTS = {".avi"}
|
|
VIDEO_EXTS = QUICKTIME_EXTS | AVI_EXTS
|
|
EXIF_PHOTO_EXTS = {
|
|
".jpg", ".jpeg", ".png", ".tif", ".tiff", ".heic", ".heif",
|
|
".arw", ".cr2", ".dng", ".raw", # camera raw formats -- standard EXIF via exiftool
|
|
}
|
|
ALL_SUPPORTED_EXTS = VIDEO_EXTS | EXIF_PHOTO_EXTS
|
|
|
|
# Synology (and other NAS/sync tool) internal housekeeping directories that
|
|
# only ever contain generated thumbnails/cache, never real source media --
|
|
# pruned from --dir's recursive walk so they don't show up as noisy skips.
|
|
SKIP_DIR_NAMES = {"@eaDir", "#recycle", "#snapshot", ".SynologyWorkingDirectory"}
|
|
|
|
# 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_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:
|
|
dt = datetime.strptime(raw, fmt)
|
|
return dt if dt.year >= MIN_YEAR else None
|
|
except ValueError:
|
|
continue
|
|
return None
|
|
|
|
|
|
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(
|
|
["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 QUICKTIME_TAG_PRIORITY:
|
|
raw = tags.get(f"TAG:{tag}")
|
|
if raw:
|
|
dt = parse_quicktime_timestamp(raw)
|
|
if dt:
|
|
return dt
|
|
return None
|
|
|
|
|
|
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 = 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, allowed_exts):
|
|
for dirpath, dirnames, filenames in os.walk(root):
|
|
dirnames[:] = [d for d in dirnames if d not in SKIP_DIR_NAMES]
|
|
for name in filenames:
|
|
if Path(name).suffix.lower() in allowed_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")
|
|
type_group = parser.add_mutually_exclusive_group()
|
|
type_group.add_argument("--only-photo", action="store_true", help="Only consider photo formats (jpg/jpeg/png/tif/tiff/heic/heif/arw/cr2/dng/raw)")
|
|
type_group.add_argument("--only-video", action="store_true", help="Only consider video formats (mov/mp4/m4v/avi)")
|
|
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()
|
|
|
|
if not args.files and not args.dir:
|
|
parser.error("no files given -- pass FILES and/or --dir")
|
|
|
|
allowed_exts = EXIF_PHOTO_EXTS if args.only_photo else VIDEO_EXTS if args.only_video else ALL_SUPPORTED_EXTS
|
|
|
|
targets = []
|
|
excluded_by_type = []
|
|
for f in args.files:
|
|
if f.suffix.lower() not in allowed_exts:
|
|
excluded_by_type.append((f, "excluded by --only-photo/--only-video"))
|
|
continue
|
|
targets.append(f)
|
|
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, allowed_exts)))
|
|
if not targets:
|
|
for f, err in excluded_by_type:
|
|
print(f"skip: {f}: {err}", file=sys.stderr)
|
|
print("nothing to do: no files matched (check --only-photo/--only-video and the extensions under --dir)", file=sys.stderr)
|
|
sys.exit(1 if excluded_by_type else 0)
|
|
|
|
plans = [plan_for(f) for f in targets]
|
|
ok_plans = [(p, dt) for p, dt, err in plans if err is None]
|
|
skipped = excluded_by_type + [(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)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|