#!/usr/bin/env -S uv run --script # /// script # dependencies = ["click"] # /// import logging import random import subprocess from pathlib import Path import click logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s", ) logger = logging.getLogger(name=__name__) VALID_EXTENSIONS = {".jpeg", ".jpg", ".png", ".webm", ".webp"} def is_valid_image(file_path: Path) -> bool: """Check if file is a valid image type.""" return file_path.suffix.lower() in VALID_EXTENSIONS def move_to_trash(file_paths: list[Path]) -> None: """Move files to Trash using trash CLI (single batched call).""" resolved: list[str] = [] for file_path in file_paths: if not file_path.is_file(): raise FileNotFoundError(f"File not found: {file_path}") resolved.append(str(file_path.resolve())) if resolved: subprocess.run(["trash", *resolved], check=True) def build_target_file_index(target_dirs: list[Path]) -> dict[str, Path]: """ Build a mapping of file stems to paths from target directories. Scans target directories once and returns a dict for efficient lookups. """ index: dict[str, Path] = {} for target_dir in target_dirs: if not target_dir.is_dir(): continue for candidate in target_dir.iterdir(): if not candidate.is_file(): continue if not is_valid_image(file_path=candidate): continue index[candidate.stem] = candidate return index def find_edited_version( image_path: Path, target_index: dict[str, Path], ) -> Path | None: """ Search target file index for edited version of image. An image is considered edited if its stem starts with the original stem followed by '-edit'. Matches by stem only, ignoring extension. Returns the path to the edited version or None if not found. """ original_stem = image_path.stem search_pattern = f"{original_stem}-edit" for indexed_stem, indexed_path in target_index.items(): if indexed_stem.startswith(search_pattern): return indexed_path return None def build_directory_index(directory: Path) -> set[str]: """ Build a set of all valid image stems in a directory. Args: directory: Path to the directory to scan Returns: Set of file stems that are valid images """ stems: set[str] = set() if not directory.is_dir(): return stems for candidate in directory.iterdir(): if not candidate.is_file(): continue if not is_valid_image(file_path=candidate): continue stems.add(candidate.stem) return stems def collect_images(paths: list[Path]) -> list[Path]: images: list[Path] = [] for path in paths: if path.is_file(): if is_valid_image(path): images.append(path) elif path.is_dir(): for candidate in sorted(path.iterdir()): if candidate.is_file() and is_valid_image(candidate): images.append(candidate) return images @click.group() def cli() -> None: """Manage and process image files.""" pass @cli.command() @click.argument( "image_paths", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path), ) @click.option( "--target-dir", multiple=True, required=True, type=click.Path(exists=True, path_type=Path), help="Directory to search for edited versions", ) @click.option( "--dry-run", is_flag=True, default=False, help="Preview matches without moving to trash", ) def dedupe( image_paths: tuple[Path, ...], target_dir: tuple[Path, ...], dry_run: bool, ) -> None: """ Remove original images when edited versions exist in target directories. IMAGE_PATHS: One or more image files to check (jpeg, jpg, png, webm, webp) """ target_dirs = list(target_dir) valid_images = [path for path in image_paths if is_valid_image(file_path=path)] if not valid_images: logger.warning("No valid image files found") return target_index = build_target_file_index(target_dirs=target_dirs) matches: list[tuple[Path, Path]] = [] for image_path in valid_images: edited_version = find_edited_version( image_path=image_path, target_index=target_index, ) if edited_version: matches.append((image_path, edited_version)) logger.info(f"Found edited version: {image_path} -> {edited_version}") if not matches: logger.info("No edited versions found") return if dry_run: click.echo( click.style( text="DRY RUN MODE - No files will be moved", fg="yellow", ) ) click.echo(f"Would move {len(matches)} image(s) to trash:") for original, edited in matches: click.echo(f" {original}") return originals = [original for original, _ in matches] try: move_to_trash(file_paths=originals) for original in originals: logger.info(f"Moved to trash: {original}") except (FileNotFoundError, subprocess.CalledProcessError) as e: logger.error(f"Failed to move files to trash: {e}") click.echo( click.style( text=f"Moved {len(matches)} image(s) to trash", fg="green", ) ) @cli.command() @click.argument( "paths", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path), ) @click.option( "--dry-run", is_flag=True, default=False, help="Preview matches without moving to trash", ) def clean(paths: tuple[Path, ...], dry_run: bool) -> None: """ Remove originals when upscaled versions exist in the same basket. PATHS: Image files and/or directories to scan (jpeg, jpg, png, webm, webp) Scans all provided paths into a collection. If a file and its -upscaled counterpart (stem starts with '{original}-upscaled', any extension) are both in the collection, the original is moved to trash. """ all_images = collect_images(list(paths)) if not all_images: logger.warning("No valid image files found") return image_index: dict[str, Path] = {img.stem: img for img in all_images} trashables: set[Path] = set() markers = ["-upscaled", "-edit"] for image in all_images: if any(marker in image.stem for marker in markers): continue upscaleds = [ p for s, p in image_index.items() if s.startswith(image.stem) and "-upscaled" in s ] editeds = [ p for s, p in image_index.items() if s.startswith(image.stem) and "-edit" in s ] if editeds or upscaleds: logger.info(f"Will delete {image}") trashables.add(image) for upscaled in upscaleds: low_res_edit = upscaled.with_stem( upscaled.stem.removesuffix("-upscaled") ) if low_res_edit in editeds: logger.info(f"Will also delete low-res edit {low_res_edit}") trashables.add(low_res_edit) if not trashables: logger.info("No upscaled or edited versions found") return if dry_run: click.echo(click.style("DRY RUN MODE - No files will be moved", fg="yellow")) click.echo(f"Would move {len(trashables)} image(s) to trash:") for original in trashables: click.echo(f" {original}") return try: move_to_trash(file_paths=list(trashables)) for original in trashables: logger.info(f"Moved to trash: {original}") except (FileNotFoundError, subprocess.CalledProcessError) as e: logger.error(f"Failed to move files to trash: {e}") click.echo(click.style(f"Moved {len(trashables)} image(s) to trash", fg="green")) @cli.command(name="list-editable") @click.argument( "image_paths", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path), ) @click.option( "--shuffle", is_flag=True, default=False, help="Shuffle the output filenames", ) @click.option( "--null", is_flag=True, default=False, help="Output null-terminated filenames for xargs -0", ) def list_editable(image_paths: tuple[Path, ...], null: bool, shuffle: bool) -> None: """ List image files that don't have edited versions. IMAGE_PATHS: One or more image files to check (jpeg, jpg, png, webm, webp) Skips files with '-edit' in their stem and lists those without matching edited versions (stem + '-edit' + optional suffix) in the same directory. """ valid_images = [path for path in image_paths if is_valid_image(file_path=path)] if not valid_images: logger.warning("No valid image files found") return dir_indices: dict[Path, set[str]] = {} editable_files: list[Path] = [] for image_path in valid_images: if "-edit" in image_path.stem: continue directory = image_path.parent if directory not in dir_indices: dir_indices[directory] = build_directory_index(directory=directory) dir_stems = dir_indices[directory] original_stem = image_path.stem search_pattern = f"{original_stem}-edit" has_edited = any(stem.startswith(search_pattern) for stem in dir_stems) if not has_edited: editable_files.append(image_path) if not editable_files: logger.info("No editable files found") return if shuffle: random.shuffle(editable_files) for file_path in editable_files: if null: click.echo(str(file_path), nl=False) click.echo("\0", nl=False) else: click.echo(str(file_path)) if __name__ == "__main__": cli()