Refactor shared script helpers
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Shared helpers for the media batch scripts."""
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user