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:
@@ -136,9 +136,11 @@ mtime instead of parsing embedded metadata.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
./set_mtime_from_metadata.py video1.mov photo1.jpg ...
|
./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 --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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user