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:
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user