Files
live-photo-concat/set_mtime_from_metadata.py
T
ergosteurandClaude Sonnet 5 6fd83d2b1c Add --only-photo/--only-video filters, skip NAS thumbnail dirs
Verified against real files pulled from are-nas: confirmed the tool
correctly leaves already-correct EXIF-dated photos untouched and only
flags the ones actually missing real metadata (validates the earlier
manual fix was precisely targeted). Recursive --dir walk now prunes
@eaDir/#recycle/#snapshot/.SynologyWorkingDirectory instead of wasting
time on generated thumbnails. Fixed a misleading "no files given"
error when --dir was given but --only-photo/--only-video filtered
everything out.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 16:01:24 -04:00

257 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
-- 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 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"}
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)")
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()