From 6fd83d2b1cc1f896bf22af48c52df1aa602f6fa5 Mon Sep 17 00:00:00 2001 From: ergosteur Date: Sun, 26 Jul 2026 16:01:24 -0400 Subject: [PATCH] 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 --- README.md | 13 +++++++++--- set_mtime_from_metadata.py | 42 ++++++++++++++++++++++++++++++-------- 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 3ab1fff..f02f507 100644 --- a/README.md +++ b/README.md @@ -136,9 +136,11 @@ mtime instead of parsing embedded metadata. ```bash ./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 --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 ``` Handles `.mov`/`.mp4`/`.m4v` (ffprobe container tags, preferring Apple's @@ -151,6 +153,11 @@ 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. Without diff --git a/set_mtime_from_metadata.py b/set_mtime_from_metadata.py index c0b81b0..cf2c88f 100755 --- a/set_mtime_from_metadata.py +++ b/set_mtime_from_metadata.py @@ -39,6 +39,8 @@ Usage: ./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) @@ -57,8 +59,14 @@ 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 = QUICKTIME_EXTS | AVI_EXTS | EXIF_PHOTO_EXTS +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"). @@ -155,10 +163,11 @@ def fetch_creation_time(path): return None -def iter_batch_files(root): - for dirpath, _dirnames, filenames in os.walk(root): +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 ALL_SUPPORTED_EXTS: + if Path(name).suffix.lower() in allowed_exts: yield Path(dirpath) / name @@ -180,22 +189,39 @@ 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() - targets = list(args.files) + 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))) + targets.extend(sorted(iter_batch_files(args.dir, allowed_exts))) if not targets: - parser.error("no files given -- pass FILES and/or --dir") + 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 = [(p, err) for p, dt, err in plans if err is not 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)