Initial media batch tools
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
old/
|
||||
tests/
|
||||
PLAN.md
|
||||
__pycache__/
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user