Files
media-batch-tools/grouping.py
T

152 lines
3.9 KiB
Python

#!/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 re
import shutil
import sys
from pathlib import Path
ORDERED_TIMESTAMP_STEM_RE = re.compile(r"^(?P<timestamp>\d{8}_\d{6})-[1-9]\d*$")
def group_stem(path: Path) -> str:
stem = path.stem
match = ORDERED_TIMESTAMP_STEM_RE.match(stem)
if match:
return match.group("timestamp")
return stem
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 = group_stem(sorted_items[0])
last = group_stem(sorted_items[-1])
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 pause_if_interactive() -> None:
if sys.stdin.isatty():
try:
input("\nPress Enter to close...")
except EOFError:
pass
def run(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
def main(argv: list[str]) -> int:
try:
return run(argv)
finally:
pause_if_interactive()
if __name__ == "__main__":
raise SystemExit(main(sys.argv))