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>
This commit is contained in:
2026-07-26 16:01:24 -04:00
co-authored by Claude Sonnet 5
parent cdf33fd777
commit 6fd83d2b1c
2 changed files with 44 additions and 11 deletions
+10 -3
View File
@@ -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
+34 -8
View File
@@ -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)