Refactor shared script helpers

This commit is contained in:
ajp_anton
2026-06-08 02:55:31 +00:00
parent ae05bc9710
commit b6024d8b65
10 changed files with 855 additions and 356 deletions
+1
View File
@@ -0,0 +1 @@
"""Shared helpers for the media batch scripts."""
+18
View File
@@ -0,0 +1,18 @@
from __future__ import annotations
import sys
def prompt_input(prompt: str) -> str:
try:
return input(prompt)
except EOFError as exc:
raise RuntimeError("Interactive input ended before choices were complete.") from exc
def pause_if_interactive() -> None:
if sys.stdin.isatty():
try:
input("\nPress Enter to close...")
except EOFError:
pass
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
import json
import subprocess
from pathlib import Path
from shutil import which
def require_exiftool() -> str:
executable = which("exiftool")
if executable:
return executable
raise RuntimeError(
"exiftool was not found on PATH. Install ExifTool and make sure the "
"'exiftool' command works before running this script."
)
def run_exiftool_json(executable: str, paths: list[Path]) -> list[dict]:
results: list[dict] = []
for start in range(0, len(paths), 80):
chunk = paths[start : start + 80]
command = [
executable,
"-j",
"-G",
"-api",
"QuickTimeUTC=1",
"-charset",
"filename=UTF8",
*[str(path) for path in chunk],
]
completed = subprocess.run(command, check=True, capture_output=True, text=True)
results.extend(json.loads(completed.stdout or "[]"))
return results
def run_exiftool_write(executable: str, path: Path, args: list[str]) -> None:
if not args:
return
command = [
executable,
"-overwrite_original",
"-charset",
"filename=UTF8",
*args,
str(path),
]
subprocess.run(command, check=True)
+32
View File
@@ -0,0 +1,32 @@
from __future__ import annotations
import re
from datetime import datetime
from pathlib import Path
ORDERED_TIMESTAMP_STEM_RE = re.compile(r"^(?P<timestamp>\d{8}_\d{6})-[1-9]\d*$")
def naive_wall_time(dt: datetime) -> datetime:
return dt.replace(tzinfo=None)
def format_filename_stem(dt: datetime) -> str:
return naive_wall_time(dt).strftime("%Y%m%d_%H%M%S")
def normalized_extension(path: Path) -> str:
return path.suffix.lower()
def group_stem_from_name(name: str) -> str:
stem = Path(name).stem
match = ORDERED_TIMESTAMP_STEM_RE.match(stem)
if match:
return match.group("timestamp")
return stem
def group_stem(path: Path) -> str:
return group_stem_from_name(path.name)
+150
View File
@@ -0,0 +1,150 @@
from __future__ import annotations
import os
from pathlib import Path
def existing_input_paths(args: list[str], *, print_missing: bool = False) -> list[Path]:
paths: list[Path] = []
for arg in args:
path = Path(arg).expanduser()
if path.exists():
paths.append(path.resolve())
elif print_missing:
print(f"Skipping missing path: {arg}")
return paths
def determine_working_dir(args: list[str]) -> Path:
paths = existing_input_paths(args)
if not paths:
raise RuntimeError("No existing input paths were provided.")
if len(paths) == 1:
path = paths[0]
return path if path.is_dir() else path.parent
try:
common = Path(os.path.commonpath([str(path) for path in paths]))
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 collect_files(
args: list[str],
*,
allowed_exts: set[str] | None = None,
print_missing: bool = False,
sort_by_name: bool = False,
) -> list[Path]:
files: list[Path] = []
seen: set[Path] = set()
for arg in args:
path = Path(arg).expanduser()
if path.is_file():
candidates = [path] if allowed_exts is None or path.suffix.lower() in allowed_exts else []
elif path.is_dir():
candidates = [
child
for child in path.rglob("*")
if child.is_file()
and (allowed_exts is None or child.suffix.lower() in allowed_exts)
]
else:
if print_missing:
print(f"Skipping missing path: {arg}")
continue
for candidate in candidates:
resolved = candidate.resolve()
if resolved not in seen:
seen.add(resolved)
files.append(resolved)
if sort_by_name:
return sorted(files, key=lambda path: path.name.lower())
return sorted(files, key=lambda path: str(path).lower())
def first_file_from_path(path: Path) -> Path | None:
path = path.expanduser()
if path.is_file():
return path.resolve()
if path.is_dir():
for child in sorted(path.rglob("*"), key=lambda item: str(item).lower()):
if child.is_file():
return child.resolve()
return None
def cleanup_roots_from_args(args: list[str]) -> list[Path]:
roots: list[Path] = []
seen: set[Path] = set()
for arg in args:
path = Path(arg).expanduser()
if path.is_dir():
resolved = path.resolve()
if resolved not in seen:
seen.add(resolved)
roots.append(resolved)
return roots
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 unique_existing_target(path: Path, planned_sources: set[Path]) -> Path:
if not path.exists() or path.resolve() in planned_sources:
return path
for index in range(1, 10000):
candidate = path.with_name(f"{path.stem}-{index}{path.suffix}")
if not candidate.exists() or candidate.resolve() in planned_sources:
return candidate
raise RuntimeError(f"Could not find available target name for {path}")
def remove_empty_dirs(cleanup_roots: list[Path], keep_dirs: set[Path]) -> list[Path]:
removed: list[Path] = []
candidates: set[Path] = set()
for root in cleanup_roots:
if not root.exists() or not root.is_dir():
continue
for directory, subdirs, _files in os.walk(root, topdown=False):
path = Path(directory).resolve()
candidates.add(path)
for subdir in subdirs:
child = (Path(directory) / subdir).resolve()
if child.exists() and child.is_dir():
candidates.add(child)
for directory in sorted(candidates, key=lambda path: len(path.parts), reverse=True):
if directory in keep_dirs:
continue
try:
directory.rmdir()
removed.append(directory)
except OSError:
pass
return removed
def display_path_from_working_dir(path: Path, working_dir: Path) -> str:
try:
relative = path.resolve().relative_to(working_dir.resolve())
except ValueError:
return str(path)
return "." if str(relative) == "." else str(relative)