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
+7
View File
@@ -139,6 +139,8 @@ mtime instead of parsing embedded metadata.
./set_mtime_from_metadata.py --dry-run *.mov # preview without changing anything or prompting ./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 # 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 --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 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 with the same system timezone the footage was actually shot in. Placeholder
"clock never set" timestamps some cameras write (e.g. literal "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. `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 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 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 --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 # 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 --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: Requirements:
- ffprobe on PATH (for .mov/.mp4/.m4v) - 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"} QUICKTIME_EXTS = {".mov", ".mp4", ".m4v"}
AVI_EXTS = {".avi"} AVI_EXTS = {".avi"}
VIDEO_EXTS = QUICKTIME_EXTS | AVI_EXTS
EXIF_PHOTO_EXTS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".heic", ".heif"} 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 # 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"). # 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 return None
def iter_batch_files(root): def iter_batch_files(root, allowed_exts):
for dirpath, _dirnames, filenames in os.walk(root): 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: 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 yield Path(dirpath) / name
@@ -180,22 +189,39 @@ def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("files", nargs="*", type=Path, help="Media files to fix") 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("--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("--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") parser.add_argument("--yes", "-y", "--unattended", dest="yes", action="store_true", help="Apply changes without an interactive confirmation prompt")
args = parser.parse_args() 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 args.dir:
if not args.dir.is_dir(): if not args.dir.is_dir():
print(f"error: --dir {args.dir}: not a directory", file=sys.stderr) print(f"error: --dir {args.dir}: not a directory", file=sys.stderr)
sys.exit(2) sys.exit(2)
targets.extend(sorted(iter_batch_files(args.dir))) targets.extend(sorted(iter_batch_files(args.dir, allowed_exts)))
if not targets: 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] plans = [plan_for(f) for f in targets]
ok_plans = [(p, dt) for p, dt, err in plans if err is None] 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: for p, err in skipped:
print(f"skip: {p}: {err}", file=sys.stderr) print(f"skip: {p}: {err}", file=sys.stderr)