Initial media batch tools

This commit is contained in:
ajp_anton
2026-06-03 13:01:39 +00:00
commit 2cdb75b926
4 changed files with 1443 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
old/
tests/
PLAN.md
__pycache__/
+81
View File
@@ -0,0 +1,81 @@
# Media Batch Tools
Small Python scripts for organizing photo collections, with videos handled as companion files when they are part of the same folder of photos.
## Scripts
### `photo_metadata.py`
Interactive metadata cleanup for photo collections:
```bash
python3 photo_metadata.py path/to/file/or/directory [...]
```
Directory inputs are scanned recursively. The script reads metadata, asks for the choices it needs, prints a preview, and only then applies changes in place after confirmation.
Current features:
- supports `.jpg`, `.jpeg`, `.heic`, `.arw`, `.mp4`, `.mov`, and `.mts`
- reads and writes metadata through `exiftool` from `PATH`
- optional direct time shifts, such as `+1:30`, `-02:00:00`, or `+1h 2m`
- optional reference-clock correction using a source photo timestamp or filename plus the correct time
- timezone handling for photos and local-time video filenames
- artist/author set, clear, or leave unchanged
- timestamp-based renaming to `YYYYMMDD_HHMMSS.ext`
- video filenames use the beginning timestamp by subtracting rounded duration from the video metadata timestamp
- Sony-style video sidecars like `C0011.MP4` plus `C0011M01.XML` are moved and renamed with the video
- automatic photo burst/HDR grouping per camera
- inferred subsecond metadata for grouped same-second photo bursts
The working directory is determined from the dragged items. A single dragged folder is the working directory. For multiple dragged files/folders, their last common ancestor is the working directory. Automatic burst/HDR group folders are created inside that working directory.
If dragged folders contain files in subdirectories, `photo_metadata.py` asks whether ungrouped files should be moved into the working directory. The default is to leave them in their existing subdirectories.
Reference-clock correction is useful when one photo shows a reliable clock. For example, if `DSC01234.JPG` has camera metadata `2026:06:02 22:10:00`, but the clock in the photo shows `22:13:25`, choose reference mode, enter `DSC01234.JPG` as the source, and enter `22:13:25` as the correct time. The script computes a `+00:03:25` shift and applies that correction to the selected files. If you omit the date in the correct time, the script chooses the date closest to the source timestamp, within 12 hours.
### `grouping.py`
Fast manual grouping for selected files:
```bash
python3 grouping.py path/to/file/or/directory [...]
```
It does not ask questions. It sorts the selected filesystem items by name and moves them into one folder named `[first stem]-[last stem]`.
Manual grouping uses filesystem items, not media-file detection:
- one file creates `[file stem]-[file stem]` and moves that file into it
- one directory acts as if its contents were given; if it contains only one subdirectory, that rule repeats
- one empty directory does nothing
- multiple files/folders are grouped as selected, without flattening folder contents
## Requirements
- Python 3.10 or newer
- ExifTool available as `exiftool` on `PATH`
On Debian/Ubuntu-like Linux systems, ExifTool is commonly installed with:
```bash
sudo apt install libimage-exiftool-perl
```
On Windows, install ExifTool and make sure the `exiftool` command is available in a normal terminal.
No virtual environment is required. The scripts use only the Python standard library plus external command-line tools.
## Safety
`photo_metadata.py` edits files in place, but it prints a preview and asks for confirmation before writing metadata or moving files. Test media should be copied to a temporary directory before running destructive checks.
`tests/`, `old/`, and `PLAN.md` are intentionally ignored by git. The `tests/` directory is for reusable source fixtures that should not be modified directly.
## Later Work
Separate video tools may be added later for remuxing, re-encoding, audio extraction/conversion, and timecode handling. Those features are intentionally not mixed into `photo_metadata.py`.
## AI Note
This code was written with AI assistance and should be reviewed and tested before relying on it for important media archives.
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""
Fast manual grouping helper.
Drag files or directories onto this script, or run:
python3 grouping.py path/to/file/or/directory [...]
Files are sorted by their current path and moved into one folder named
[first stem]-[last stem]. Multiple files/folders are grouped as selected.
"""
from __future__ import annotations
import os
import shutil
import sys
from pathlib import Path
def unique_path(path: Path) -> Path:
if not path.exists():
return path
for index in range(1, 10000):
candidate = path.with_name(f"{path.stem}-{index}{path.suffix}")
if not candidate.exists():
return candidate
raise RuntimeError(f"Could not find an available name for {path}")
def existing_input_paths(args: list[str]) -> list[Path]:
paths: list[Path] = []
for arg in args:
path = Path(arg).expanduser()
if path.exists():
paths.append(path.resolve())
else:
print(f"Skipping missing path: {arg}")
return paths
def resolve_single_directory(path: Path) -> list[Path]:
current = path
while current.is_dir():
children = sorted(current.iterdir(), key=lambda item: item.name.lower())
if not children:
return []
if len(children) == 1 and children[0].is_dir():
current = children[0]
continue
return [child.resolve() for child in children]
return [current.resolve()]
def resolve_group_items(args: list[str]) -> list[Path]:
paths = existing_input_paths(args)
if len(paths) == 1:
path = paths[0]
if path.is_dir():
return resolve_single_directory(path)
return [path]
return sorted(paths, key=lambda item: item.name.lower())
def working_dir_for_items(items: list[Path]) -> Path:
if len(items) == 1:
item = items[0]
return item if item.is_dir() else item.parent
parents = {item.parent for item in items}
if len(parents) == 1:
return next(iter(parents))
try:
common = Path(os.path.commonpath([str(item) for item in items]))
except ValueError as exc:
raise RuntimeError("Input paths do not have a common ancestor.") from exc
if str(common) == common.anchor:
raise RuntimeError("Input paths only share the filesystem root; choose items closer together.")
return common
def group_items(items: list[Path]) -> Path | None:
if not items:
return None
sorted_items = sorted(items, key=lambda item: item.name.lower())
first = sorted_items[0].stem
last = sorted_items[-1].stem
target_dir = unique_path(working_dir_for_items(sorted_items) / f"{first}-{last}")
target_dir.mkdir(parents=True)
for source in sorted_items:
destination = unique_path(target_dir / source.name)
shutil.move(str(source), str(destination))
return target_dir
def main(argv: list[str]) -> int:
if len(argv) < 2:
print("Usage: python3 grouping.py path/to/file/or/directory [...]")
return 2
items = resolve_group_items(argv[1:])
if not items:
print("No items to group.")
return 0
try:
target_dir = group_items(items)
except RuntimeError as exc:
print(exc)
return 1
if target_dir is not None:
print(f"Moved {len(items)} item(s) into {target_dir}")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
+1234
View File
File diff suppressed because it is too large Load Diff