Compare commits
3
Commits
e1cd699b79
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c077c535b3 | ||
|
|
6fd83d2b1c | ||
|
|
cdf33fd777 |
@@ -126,19 +126,41 @@ Continuous runs (copy-paste to re-run on just that subset):
|
||||
|
||||
## Bonus utility: set_mtime_from_metadata.py
|
||||
|
||||
A standalone script (no `numpy` dependency, just `ffprobe`) that sets a video
|
||||
file's filesystem mtime to match its own embedded creation-time metadata.
|
||||
Not specific to Live Photos or this repo's main script — useful for any
|
||||
video whose filesystem timestamp doesn't match its metadata (e.g. after
|
||||
copying, downloading, or exporting), for tools like Synology Photos that
|
||||
sort/date videos by mtime instead of parsing the embedded metadata.
|
||||
A standalone script (no `numpy` dependency; needs `ffprobe` and `exiftool`)
|
||||
that sets a media file's filesystem mtime to match its own embedded
|
||||
creation-time metadata. Not specific to Live Photos or this repo's main
|
||||
script — useful for any photo or video whose filesystem timestamp doesn't
|
||||
match its metadata (e.g. after copying, downloading, exporting, or a bad
|
||||
backup restore), for tools like Synology Photos that sort/date videos by
|
||||
mtime instead of parsing embedded metadata.
|
||||
|
||||
```bash
|
||||
./set_mtime_from_metadata.py video1.mov video2.mp4 ...
|
||||
./set_mtime_from_metadata.py --dry-run *.mov # preview without changing anything
|
||||
./set_mtime_from_metadata.py video1.mov photo1.jpg ...
|
||||
./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
|
||||
./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
|
||||
```
|
||||
|
||||
Prefers the Apple `com.apple.quicktime.creationdate` tag (local time) when
|
||||
present, falling back to the generic `creation_time` tag (usually UTC).
|
||||
Handles `.mov`/`.mp4`/`.m4v` (ffprobe container tags, preferring Apple's
|
||||
`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`
|
||||
plus camera-raw `.arw`/`.cr2`/`.dng`/`.raw` (all via exiftool's EXIF
|
||||
`DateTimeOriginal` — raw formats carry standard EXIF too). 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.
|
||||
`--only-photo`/`--only-video` (mutually exclusive) restrict which formats
|
||||
are considered, for both explicit file args and `--dir`'s recursive walk.
|
||||
The recursive walk skips known NAS/sync-tool housekeeping directories
|
||||
(`@eaDir`, `#recycle`, `#snapshot`, `.SynologyWorkingDirectory`) so it
|
||||
doesn't waste time on generated thumbnails.
|
||||
|
||||
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.
|
||||
|
||||
+200
-40
@@ -1,19 +1,51 @@
|
||||
#!/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/.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 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
|
||||
./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
|
||||
- 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 +56,42 @@ 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"}
|
||||
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_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 +103,156 @@ 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)
|
||||
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:
|
||||
print(f"skip: {f}: ffprobe failed ({e})", file=sys.stderr)
|
||||
exit_code = 1
|
||||
continue
|
||||
return path, None, f"metadata read failed ({e})"
|
||||
if dt is None:
|
||||
print(f"skip: {f}: no creation-time metadata found", file=sys.stderr)
|
||||
exit_code = 1
|
||||
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:
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user