diff --git a/.github/instructions/python script.instructions.md b/.github/instructions/python script.instructions.md
new file mode 100644
index 0000000..902d591
--- /dev/null
+++ b/.github/instructions/python script.instructions.md
@@ -0,0 +1,32 @@
+---
+applyTo: "*.py"
+---
+
+- Do not be chatty, do not summarize the code afterwards.
+- Use `#!/usr/bin/env -S uv run --script` as the shebang line.
+- Add dependencies under the shebang line as
+
+```
+# /// script
+# dependencies = [list of external dependencies as json array]
+# ///
+```
+
+and keep them updated as the code changes.
+
+- Keep comments to minimum
+- Use `uv` for package management.
+- Use logging instead of print statements. Use `logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")` for configuration.
+- Use argparse for command-line arguments, keep argument parsing in a func called `parse_args() -> argparse.Namespace`, which gets called from `main`. Use `argparse.ArgumentDefaultsHelpFormatter` for the formatter class.
+- Use `pathlib.Path` for file operations. When the CLI args include a path, set its `type=Path`
+- Place imports at the top of the file.
+- Use httpx for HTTP requests. Do not use async.
+- Always add type annotations to function signatures, prefer built-in types, e.g. `list[str]` over `typing` module types when possible.
+- Prefer kwargs calling style for function calls, e.g. `func(arg1=value1, arg2=value2)` instead of `func(value1, value2)`.
+- Use `Path.cwd()` for the current working directory.
+- Prefer `os.getenv` over `os.environ.get` for environment variables.
+- Use `subprocess.run` for running shell commands, prefer `check=True` to raise an error on failure.
+- Use `tempfile.TemporaryDirectory()` for temporary directories.
+- Do not shorten parameter names unnecessarily, unless it's an idiomatic abbreviation.
+- Use f-strings for string formatting.
+- When calling an external command, use the long form of the arguments, e.g. `--output` instead of `-o`.
diff --git a/.python-version b/.python-version
new file mode 100644
index 0000000..3a4f41e
--- /dev/null
+++ b/.python-version
@@ -0,0 +1 @@
+3.13
\ No newline at end of file
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 321f0d3..1bafd7b 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -7,9 +7,10 @@
{
"name": "Python: Current File",
"type": "python",
- "args": ["2022-12-06"],
+ "env": {"MISTRAL_API_KEY":"8J5hUwxVMCiQjhXxElefDqU7XJYAvVXn", "OPENROUTER_API_KEY": "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"},
"request": "launch",
"program": "${file}",
+ "cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"justMyCode": true
}
diff --git a/ai_imagez.py b/ai_imagez.py
new file mode 100755
index 0000000..7159a18
--- /dev/null
+++ b/ai_imagez.py
@@ -0,0 +1,363 @@
+#!/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()
diff --git a/ai_photorealistic.py b/ai_photorealistic.py
new file mode 100755
index 0000000..e864b48
--- /dev/null
+++ b/ai_photorealistic.py
@@ -0,0 +1,418 @@
+#!/usr/bin/env -S uv run --script
+# /// script
+# dependencies = ["httpx", "pillow"]
+# ///
+import argparse
+import base64
+import json
+import logging
+import os
+import subprocess
+import tempfile
+import time
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from dataclasses import dataclass
+from io import BytesIO
+from pathlib import Path
+from typing import Any
+
+import httpx
+from PIL import Image, ImageOps
+
+from sort_images import read_dims
+
+MODEL_ID = "flux-2-klein-9b"
+# LOCAL_MODEL_ID = "black-forest-labs/FLUX.2-klein-9B"
+# MODEL_ID = "z-image-turbo"
+# MODEL_ID = "qwen-image"
+
+
+SYSTEM_PROMPT = """Make this incredibly photorealistic.
+Highly stylized, striking, highly detailed photo of a young, lithe woman.
+Age her by 5 years and make her a 20 year old, taller, slimmer, more slender, fitter version of herself.
+Make her 6ft tall.
+
+Give her realistic spotless skin texture.
+Her face skin must also be realistic, without any makeup.
+Make the background realistic and consistent with the lighting on her.
+
+Give her an angular, not round, narrow, elongated neck and long face shape defined by a structured, clean jawline that tapers toward a firm, slightly rounded chin.
+Do not make the head larger than the body, keep them in perfect proportion.
+Give her high, prominent cheekbones giving her sculpted, chieseled face that taper down to a more narrow, delicate chin.
+Make her really pretty and seductive.
+Preserve unnatural lip colors and wetness.
+
+Do not change the facial expression, emotion, hand and body pose, mouth, tongue and eyelid position.
+Lower her eyes slightly to give her an alluring look.
+Mouth slightly agape giving her a seductive look.
+Give her a sharp, cunning gaze, a very slight pleasant smirk without changing her head pose.
+
+Remove the drool.
+Keep the pose and don't turn people around."""
+
+SYSTEM_PROMPT = """
+Turn this into a heavily stylized real life photo of a woman.
+Age her by 5 years and make her a 20 year old, taller, slimmer, more slender, fitter version of herself.
+
+Apply realistic textures without grain or noise.
+Skin should be smooth, flawless, without pores, clean and without blemishes.
+
+Keep the shine.
+Make her taller, slimmer, slender. Don't make the head shorter.
+
+Do not change the pose, gaze, emotion.
+
+Her lips are parted, creating a soft, seductive expression.
+"""
+
+SYSTEM_PROMPT = """
+restyle this as an edited, finished photo taken by sony a7 iv.
+Photorealistic textures.
+same color grading, same colors and lighting.
+Realistic, flawless skin.
+White people should have a pale skin.
+
+keep the same ethnicity and facial features.
+
+Make the children taller and more slender with slightly elongated feminine neck and higher cheekbones, give them smaller, slimmer more feminine head and tapered face; round, smooth chin.
+age them by 5 years and make them look like 20 year old adults.
+
+keep everything else about her the same.
+same facial + eye expression (keep them as open as in the original), same pose and emotion, same tongue position.
+same head and eye angle.
+
+do not turn her face around.
+same hair style, color, length.
+same composition and crop.
+keep the same makeup, do not change the colors of the lips, keep them glossy and wet.
+"""
+SYSTEM_PROMPT = """
+Artistic super photorealistic conversion.
+extend the background horizontally but keep the people.
+# Subtle chiaroscuro lighting, not too dark.
+85mm telephoto lens, f/2.8, shallow depth of field.
+Soft-focus highlights, atmospheric bloom.
+Kodak Portra 400 cool color palette.
+#Preserve the original lighting and slight moody lighting. Not underexposed, not overexposed.
+High dynamic range with a focus on rich textures.
+balanced exposure.
+same color grading and LUT.
+
+remove compression artifacts.
+she has spotless, supple, shiny skin without splotches.
+#pale skin, goth make-up.
+
+#reduce musculature.
+#keep the original facial expression, emotion, hand and body pose.
+
+make her prettier, give her hourglass figure, a satisfied look, voluminus hair.
+
+parted lips, seductive gaze, subtly lowered eyelids.
+make her face look like a 25 year old, raised cheekbones, tapered face shape, pointy chin.
+#avoid making her look like a child and maintain adult proportions.
+#give her a subtle mischievous, confident smile, avoid neutral face.
+#make her face slimmer.
+preserve the original skin color.
+
+do not change the colors of the lips, keep them wet.
+remove all logos and watermarks.
+#reflective outfit,
+#glossy, reflective outfit,
+#clingy, skin-tight clothes.
+"""
+
+
+def without_comments(s: str) -> str:
+ return "\n".join(
+ line for line in s.splitlines() if not line.strip().startswith("#")
+ )
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Send one or more images to NanoGPT for photorealistic processing."
+ )
+ parser.add_argument(
+ "image_paths",
+ nargs="+",
+ help="One or more local image paths to send",
+ )
+ parser.add_argument(
+ "--extra-prompt",
+ default="",
+ help="Extra prompt text appended to the system prompt",
+ )
+ parser.add_argument(
+ "--prompt",
+ default="",
+ help="Replace the system prompt with this custom prompt (instead of appending to it)",
+ )
+ parser.add_argument(
+ "--provider",
+ choices=("nanogpt", "local"),
+ default=os.getenv("IMAGE_PROVIDER", "nanogpt"),
+ help="Image provider backend (default from IMAGE_PROVIDER or nanogpt)",
+ )
+ parser.add_argument(
+ "--base-url",
+ default=os.getenv("LOCAL_IMAGE_API_BASE", "http://127.0.0.1:6006"),
+ help="Local provider base URL (default from LOCAL_IMAGE_API_BASE)",
+ )
+ return parser.parse_args()
+
+
+@dataclass(frozen=True)
+class TransformResult:
+ image_path: Path
+ output_path: Path | None = None
+ remaining: float | None = None
+
+
+class NanoGPT:
+ def __init__(self, api_key: str) -> None:
+ self.api_key = api_key
+ self._client = httpx.Client(
+ timeout=180.0,
+ headers={
+ "Authorization": f"Bearer {self.api_key}",
+ "Content-Type": "application/json",
+ },
+ )
+
+ def transform(
+ self, image_path: Path, output_path: Path, prompt: str
+ ) -> TransformResult:
+ if not image_path.exists():
+ raise FileNotFoundError(f"Image not found: {image_path}")
+
+ image_w, image_h = read_dims(image_path)
+ aspect_ratio = image_w / image_h
+ aspect_ratio = max(aspect_ratio, 2 / 3)
+ target_w, target_h = 1024, int(1024 / aspect_ratio)
+ size = f"{target_w}x{target_h}"
+
+ image_data_url = _build_data_url(image_path)
+ payload = {
+ "model": MODEL_ID,
+ "prompt": prompt,
+ "imageDataUrl": image_data_url,
+ "response_format": "b64_json",
+ "n": 1,
+ "seed": int(time.time()),
+ "size": size,
+ }
+ response = self._client.post(
+ "https://nano-gpt.com/v1/images/generations", json=payload
+ )
+ if not response.is_success:
+ raise RuntimeError(
+ f"API request failed with status {response.status_code}: {response.text}"
+ )
+ result = response.json()
+
+ data = result.get("data", [])
+ if not data:
+ raise RuntimeError(f"No data in response for {image_path}")
+
+ item = data[0]
+
+ if "b64_json" in item:
+ _save_b64_image(output_path, item["b64_json"])
+ elif "url" in item:
+ _download_image(self._client, output_path, item["url"])
+ else:
+ raise RuntimeError(f"Unknown response format for {image_path}")
+
+ return TransformResult(
+ image_path=image_path,
+ output_path=output_path,
+ remaining=result.get("remainingBalance"),
+ )
+
+
+class LocalFlux:
+ def __init__(self, base_url: str) -> None:
+ self.base_url = base_url.rstrip("/")
+ self._client = httpx.Client(timeout=240.0)
+
+ def transform(
+ self, image_path: Path, output_path: Path, prompt: str
+ ) -> TransformResult:
+ if not image_path.exists():
+ raise FileNotFoundError(f"Image not found: {image_path}")
+
+ image_w, image_h = read_dims(image_path)
+ aspect_ratio = image_w / image_h
+ target_w, target_h = 1024, int(1024 / aspect_ratio)
+ size = f"{target_w}x{target_h}"
+
+ image_data_url = _build_data_url(image_path)
+ payload = {
+ "model": LOCAL_MODEL_ID,
+ "prompt": prompt,
+ "imageDataUrl": image_data_url,
+ "response_format": "b64_json",
+ "guidance_scale": 1.0,
+ "n": 1,
+ "seed": int(time.time()),
+ "size": size,
+ }
+ response = self._client.post(
+ f"{self.base_url}/v1/images/generations", json=payload
+ )
+ if not response.is_success:
+ raise RuntimeError(
+ f"Local API request failed with status {response.status_code}: {response.text}"
+ )
+ result = response.json()
+
+ data = result.get("data", [])
+ if not data:
+ raise RuntimeError(f"No data in local response for {image_path}")
+
+ item = data[0]
+ if "b64_json" in item:
+ _save_b64_image(output_path, item["b64_json"])
+ elif "url" in item:
+ _download_image(self._client, output_path, item["url"])
+ else:
+ raise RuntimeError(f"Unknown local response format for {image_path}")
+
+ return TransformResult(
+ image_path=image_path,
+ output_path=output_path,
+ remaining=result.get("remainingBalance"),
+ )
+
+
+def _build_data_url(image_path: Path) -> str:
+ with Image.open(image_path) as image:
+ image = ImageOps.exif_transpose(image)
+ image.thumbnail((3000, 3000), Image.Resampling.LANCZOS)
+ if image.mode not in {"RGB", "L"}:
+ image = image.convert("RGB")
+ buffer = BytesIO()
+ image.save(buffer, format="JPEG", quality=95, optimize=True)
+ encoded = base64.b64encode(buffer.getvalue()).decode("utf-8")
+ return f"data:image/jpeg;base64,{encoded}"
+
+
+def _save_b64_image(out_path: Path, b64_data: str) -> None:
+ out_path.write_bytes(base64.b64decode(b64_data))
+
+
+def _download_image(client: httpx.Client, out_path: Path, url: str) -> None:
+ response = client.get(url, follow_redirects=True)
+ response.raise_for_status()
+ out_path.write_bytes(response.content)
+
+
+def _next_output_path(image_path: Path) -> Path:
+ base = image_path.with_name(f"{image_path.stem}-edit.jpg")
+ if not base.exists():
+ return base
+ counter = 2
+ while True:
+ candidate = image_path.with_name(f"{image_path.stem}-edit{counter}.jpg")
+ if not candidate.exists():
+ return candidate
+ counter += 1
+
+
+def find_custom_prompts(file_paths: list[Path]) -> dict[Path, str]:
+ custom_prompt_path = Path(r"~/Downloads/prompts.jsonl").expanduser()
+ if not custom_prompt_path.exists():
+ return {}
+
+ stem_to_path: dict[str, Path] = {p.stem: p for p in file_paths}
+ prompts: dict[Path, str] = {}
+ with custom_prompt_path.open() as f:
+ for line in f:
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ entry = json.loads(line)
+ filename = entry.get("filename")
+ prompt = entry.get("prompt")
+ if filename and prompt and filename in stem_to_path:
+ prompts[stem_to_path[filename]] = prompt
+ except json.JSONDecodeError:
+ pass
+
+ return prompts
+
+
+def main() -> int:
+ args = parse_args()
+ provider = args.provider
+
+ extra_prompt = args.extra_prompt.strip()
+ if extra_prompt:
+ prompt = f"{SYSTEM_PROMPT}\n\n{extra_prompt}"
+ elif args.prompt.strip():
+ prompt = args.prompt.strip()
+ else:
+ prompt = SYSTEM_PROMPT
+
+ if provider == "nanogpt":
+ api_key = os.getenv("NANOGPT_API_KEY")
+ if not api_key:
+ raise SystemExit("NANOGPT_API_KEY is not set")
+ client = NanoGPT(api_key)
+ else:
+ client = LocalFlux(args.base_url)
+ image_paths = [Path(p) for p in args.image_paths]
+ custom_prompts = find_custom_prompts(image_paths)
+ got_error = False
+ with ThreadPoolExecutor() as executor:
+
+ def _submit(image_path: Path) -> TransformResult:
+ effective_prompt = without_comments(prompt)
+
+ custom_prompt = custom_prompts.get(image_path)
+ if custom_prompt:
+ print(f"Using custom prompt for {image_path}")
+ effective_prompt = custom_prompt
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir) / image_path.name
+ result = client.transform(
+ image_path,
+ temp_path,
+ prompt=effective_prompt,
+ )
+
+ output_path = _next_output_path(image_path)
+ os.replace(temp_path, output_path)
+ return TransformResult(
+ image_path=result.image_path,
+ output_path=output_path,
+ remaining=result.remaining,
+ )
+
+ futures = {
+ executor.submit(_submit, image_path): image_path
+ for image_path in image_paths
+ }
+ for future in as_completed(futures):
+ try:
+ outcome = future.result()
+ except Exception as exc:
+ image_path = futures[future]
+ print(f"[error] {image_path} ({exc})")
+ got_error = True
+ continue
+
+ image_path = outcome.image_path
+ output_path = outcome.output_path
+ print(f"[ok] {image_path} -> {output_path}")
+ remaining = outcome.remaining
+ if remaining is not None:
+ print(f"[balance] {remaining}")
+
+ return 1 if got_error else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/ai_runpod.py b/ai_runpod.py
new file mode 100755
index 0000000..6baa556
--- /dev/null
+++ b/ai_runpod.py
@@ -0,0 +1,324 @@
+#!/usr/bin/env uv run
+# /// script
+# dependencies = ["httpx", "pillow"]
+# ///
+
+import argparse
+import io
+import json
+import logging
+import math
+import random
+import subprocess
+import time
+import uuid
+from pathlib import Path
+
+import httpx
+from PIL import Image
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s - %(levelname)s - %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S",
+)
+logging.getLogger("httpx").setLevel(logging.WARNING) # Suppress httpx debug logs
+logger = logging.getLogger(__name__)
+
+COMFY_URL = "https://randy-agents-mediterranean-herself.trycloudflare.com/"
+COMFY_COOKIES = {
+ "C.35620185_auth_token": "027e0e069f66d46e6d84802b7d49076e6ebab8621491d8694992f16727f99b79",
+}
+WORKFLOW_FILE = Path("/Users/abdus/Downloads/klein_fixed.json")
+IMAGE_OUTPUT_MEGAPIXELS = 2
+
+
+SYSTEM_PROMPT = """
+restyle this as an edited, finished photo taken by sony a7 iv.
+Photorealistic textures.
+same color grading, same colors and lighting.
+Realistic, flawless skin.
+# White people should have a pale skin.
+age everyone by 5 years and make them look like 25 year old adults.
+
+keep the same ethnicity and facial features.
+
+Make the children taller and more slender with slightly elongated feminine neck and higher cheekbones, give them smaller, slimmer more feminine head and tapered face; round, smooth chin.
+
+keep everything else about her the same.
+same facial + eye expression (keep them as open as in the original), same pose and emotion, same tongue position.
+same head and eye angle.
+
+do not turn her face around.
+same hair style, color, length.
+same composition and crop.
+keep the same makeup, do not change the colors of the lips, keep them glossy and wet.
+long nipples. large areolas.
+
+"""
+
+
+def preprocess_image(image_path: Path) -> bytes:
+ with Image.open(image_path) as img:
+ if img.mode in ("RGBA", "P"):
+ img = img.convert("RGB")
+ max_size = 2500 # Slightly larger to support 3MP render
+ if max(img.size) > max_size:
+ img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)
+ buffer = io.BytesIO()
+ img.save(buffer, format="JPEG", quality=85, optimize=True)
+ return buffer.getvalue()
+
+
+def _next_output_path(image_path: Path) -> Path:
+ base = image_path.with_name(f"{image_path.stem}-edit.jpg")
+ if not base.exists():
+ return base
+ counter = 2
+ while True:
+ candidate = image_path.with_name(f"{image_path.stem}-edit{counter}.jpg")
+ if not candidate.exists():
+ return candidate
+ counter += 1
+
+
+def tag_file(path: Path, tag: str = "Blue") -> None:
+ if not path.exists():
+ raise FileNotFoundError(f"Target path does not exist: {path}")
+
+ abs_path = str(path.resolve())
+
+ script = f'tell application "Finder" to set tags of (POSIX file "{abs_path}" as alias) to {{"{tag}"}}'
+
+ try:
+ subprocess.run(
+ ["osascript", "-e", script], check=True, capture_output=True, text=True
+ )
+ except subprocess.CalledProcessError as e:
+ # Finder may fail if the file is moved during execution or if permissions are insufficient
+ logger.warning(f"Failed to tag file: {e.stderr}")
+
+
+class Stats:
+ def __init__(self) -> None:
+ self.run_times: list[float] = []
+
+ def add(self, elapsed: float) -> None:
+ self.run_times.append(elapsed)
+
+ def format_time(self, seconds: float) -> str:
+ if seconds < 60:
+ return f"{seconds:.1f}s"
+ minutes = seconds / 60
+ return f"{minutes:.1f}m"
+
+ def print_summary(self) -> None:
+ if not self.run_times:
+ return
+
+ if len(self.run_times) == 1:
+ return
+
+ shortest = min(self.run_times)
+ longest = max(self.run_times)
+ average = sum(self.run_times) / len(self.run_times)
+ total = sum(self.run_times)
+
+ print("=" * 20)
+ print("Run Statistics")
+ print("=" * 20)
+ print(f"Total runs: {len(self.run_times)}")
+ print(f"Shortest: {self.format_time(shortest)}")
+ print(f"Longest: {self.format_time(longest)}")
+ print(f"Average: {self.format_time(average)}")
+ print(f"Total time: {self.format_time(total)}")
+ print("=" * 20)
+
+
+class ComfyUI:
+ PROMPT_NODE_ID = "75:74"
+ NEGATIVE_NODE_ID = "75:67"
+ IMAGE_LOAD_NODE_ID = "76"
+ SAVE_NODE_ID = "9"
+ UPSCALE_NODE_ID = "75:80"
+ MODEL_LOAD_NODE_ID = "75:70"
+ CLIP_LOAD_NODE_ID = "75:71"
+ VAE_LOAD_NODE_ID = "75:72"
+ SEED_NODE_ID = "75:73"
+
+ def __init__(self, url: str, cookies: dict) -> None:
+ self.url = url.rstrip("/")
+ self.client = httpx.Client(timeout=None, cookies=cookies, follow_redirects=True)
+
+ def _post(self, path: str, **kwargs) -> httpx.Response:
+ """Try RunPod /api routes first, then legacy Comfy routes."""
+ candidates = [f"{self.url}/api{path}", f"{self.url}{path}"]
+ last_res: httpx.Response | None = None
+ for endpoint in candidates:
+ res = self.client.post(endpoint, **kwargs)
+ if res.status_code != 404:
+ return res
+ last_res = res
+ assert last_res is not None
+ return last_res
+
+ def _get(self, path: str, **kwargs) -> httpx.Response:
+ """Try RunPod /api routes first, then legacy Comfy routes."""
+ candidates = [f"{self.url}/api{path}", f"{self.url}{path}"]
+ last_res: httpx.Response | None = None
+ for endpoint in candidates:
+ res = self.client.get(endpoint, **kwargs)
+ if res.status_code != 404:
+ return res
+ last_res = res
+ assert last_res is not None
+ return last_res
+
+ def transform(
+ self,
+ image_path: Path,
+ save_path: Path,
+ prompt: str,
+ negative_prompt: str = "",
+ ) -> None:
+ seed = random.randint(0, 2**31 - 1)
+ client_id = str(uuid.uuid4())
+
+ # 1. Upload
+ processed_img_bytes = preprocess_image(image_path)
+ files = {"image": (f"upload_{uuid.uuid4()}.jpg", processed_img_bytes)}
+ res = self._post("/upload/image", files=files)
+ if res.is_error:
+ logger.error(f"Upload error: {res.text}")
+ res.raise_for_status()
+ filename = res.json()["name"]
+
+ # 2. Load Workflow
+ with open(WORKFLOW_FILE, "r") as f:
+ workflow = json.load(f)
+
+ # Get dimensions from the actual file
+ with Image.open(image_path) as img:
+ orig_width, orig_height = img.size
+ aspect_ratio = orig_width / orig_height
+
+ # Calculate target resolution for 3 Megapixels (approx 3,000,000 pixels)
+ # Formula: Width * (Width / Aspect) = TotalPixels
+ target_pixels = IMAGE_OUTPUT_MEGAPIXELS * 1024 * 1024
+ new_width = int(math.sqrt(target_pixels * aspect_ratio))
+ new_height = int(new_width / aspect_ratio)
+
+ # Force these into the Empty Latent node (Node 75:66)
+ # This ensures the "canvas" matches your photo's shape
+ workflow["75:66"]["inputs"]["width"] = (
+ new_width // 8
+ ) * 8 # Must be multiple of 8
+ workflow["75:66"]["inputs"]["height"] = (new_height // 8) * 8
+
+ # Also update the Scheduler (Node 75:62) so Flux knows the scale
+ workflow["75:62"]["inputs"]["width"] = workflow["75:66"]["inputs"]["width"]
+ workflow["75:62"]["inputs"]["height"] = workflow["75:66"]["inputs"]["height"]
+
+ # Inject Advanced Data
+ workflow[self.PROMPT_NODE_ID]["inputs"]["text"] = prompt
+ workflow[self.NEGATIVE_NODE_ID]["inputs"]["text"] = negative_prompt
+ workflow[self.IMAGE_LOAD_NODE_ID]["inputs"]["image"] = filename
+ workflow[self.SEED_NODE_ID]["inputs"]["noise_seed"] = seed
+
+ workflow[self.UPSCALE_NODE_ID]["inputs"]["megapixels"] = IMAGE_OUTPUT_MEGAPIXELS
+ workflow[self.UPSCALE_NODE_ID]["inputs"]["upscale_method"] = "lanczos"
+
+ # 3. Queue
+ payload = {"prompt": workflow, "client_id": client_id}
+ res = self._post("/prompt", json=payload)
+
+ if res.is_error:
+ logger.error(f"Error: {res.text}")
+ res.raise_for_status()
+
+ prompt_id = res.json()["prompt_id"]
+
+ # 4. Wait
+ preview_output = None
+ while True:
+ jobs_res = self._get(
+ "/jobs",
+ params={
+ "status": "completed,failed,cancelled",
+ "limit": 64,
+ "offset": 0,
+ },
+ )
+ if jobs_res.is_error:
+ logger.error(f"Jobs poll error: {jobs_res.text}")
+ jobs_res.raise_for_status()
+
+ jobs = jobs_res.json().get("jobs", [])
+ match = next((job for job in jobs if job.get("id") == prompt_id), None)
+ if match:
+ if match.get("status") != "completed":
+ raise RuntimeError(
+ f"Job {prompt_id} ended with status: {match.get('status')}"
+ )
+ preview_output = match.get("preview_output")
+ break
+ time.sleep(0.25)
+
+ # 5. Download
+ if not preview_output:
+ raise RuntimeError(f"Completed job {prompt_id} has no preview_output")
+
+ view_params = {
+ "filename": preview_output["filename"],
+ "subfolder": preview_output.get("subfolder", ""),
+ "type": preview_output.get("type", "output"),
+ }
+ img_res = self._get("/view", params=view_params)
+ if img_res.is_error:
+ logger.error(f"View error: {img_res.text}")
+ img_res.raise_for_status()
+
+ # Convert to JPEG with quality=75
+ with Image.open(io.BytesIO(img_res.content)) as img:
+ if img.mode == "RGBA":
+ img = img.convert("RGB")
+ img.save(save_path, format="JPEG", quality=75)
+
+def without_comments(s: str) -> str:
+ return "\n".join(
+ line for line in s.splitlines() if not line.strip().startswith("#")
+ )
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("images", nargs="+", type=Path)
+ args = parser.parse_args()
+
+
+ comfy = ComfyUI(COMFY_URL, COMFY_COOKIES)
+ stats = Stats()
+
+ for i, image_path in enumerate(args.images, start=1):
+ prompt = SYSTEM_PROMPT
+ replace_prompt_path = Path(__file__).parent / "flux_klein_prompt.txt"
+ if replace_prompt_path.is_file():
+ prompt = without_comments(replace_prompt_path.read_text())
+
+ save_path = _next_output_path(image_path)
+ start_time = time.time()
+ logger.info(f"Uploading ({i}/{len(args.images)}): {image_path.name}")
+ try:
+ comfy.transform(image_path, save_path, prompt)
+ except Exception as e:
+ logger.error(f"Error processing {image_path.name}: {e}")
+ continue
+ elapsed = time.time() - start_time
+ stats.add(elapsed)
+
+ stats.print_summary()
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/backup-site.service b/backup-site.service
new file mode 100644
index 0000000..e69de29
diff --git a/backup-site.timer b/backup-site.timer
new file mode 100644
index 0000000..e69de29
diff --git a/backup_site.py b/backup_site.py
new file mode 100644
index 0000000..80f8299
--- /dev/null
+++ b/backup_site.py
@@ -0,0 +1,144 @@
+#!/usr/bin/env -S uv run --script
+# /// script
+# dependencies = []
+# ///
+
+import argparse
+import logging
+import os
+import shutil
+import subprocess
+import zipfile
+from datetime import datetime
+from pathlib import Path
+from urllib.parse import urlparse
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
+logger = logging.getLogger(__name__)
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Backup MariaDB database and website files", formatter_class=argparse.ArgumentDefaultsHelpFormatter)
+ parser.add_argument("--db-url", default=os.getenv("DB_URL"), help="Database URL (default: from DB_URL env var)")
+ parser.add_argument("--site-dir", type=Path, required=True, help="Website directory to backup")
+ parser.add_argument("--backup-dir", type=Path, default="/mnt/box/backup/droplet", help="Backup destination directory")
+ return parser.parse_args()
+
+
+def parse_db_url(db_url: str) -> dict:
+ if not db_url:
+ raise ValueError("Database URL is required")
+
+ parsed = urlparse(db_url)
+
+ if parsed.scheme != "mysql":
+ raise ValueError("Unsupported database URL format")
+
+ return {
+ "host": parsed.hostname or "localhost",
+ "port": str(parsed.port or 3306),
+ "user": parsed.username or "root",
+ "password": parsed.password or "",
+ "database": parsed.path.lstrip("/") if parsed.path else "",
+ }
+
+
+def dump_database(db_params: dict, dump_path: Path) -> None:
+ logger.info(f"Dumping database to {dump_path}")
+
+ cmd = [
+ "mysqldump",
+ "--host",
+ db_params["host"],
+ "--port",
+ db_params["port"],
+ "--user",
+ db_params["user"],
+ "--single-transaction",
+ "--routines",
+ "--triggers",
+ db_params["database"],
+ ]
+
+ if db_params["password"]:
+ cmd.append(f"--password={db_params['password']}")
+
+ try:
+ with dump_path.open("w") as f:
+ subprocess.run(cmd, stdout=f, stderr=subprocess.PIPE, text=True, check=True)
+ logger.info("Database dump completed successfully")
+ except subprocess.CalledProcessError as e:
+ logger.error(f"Database dump failed: {e.stderr}")
+ raise
+
+
+def zip_directory(source_dir: Path, zip_path: Path) -> None:
+ """Zip a directory"""
+ logger.info(f"Zipping directory {source_dir} to {zip_path}")
+
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
+ for file_path in source_dir.rglob("*"):
+ if file_path.is_file():
+ # Use relative path within the zip
+ arcname = file_path.relative_to(source_dir.parent)
+ zipf.write(file_path, arcname)
+
+ logger.info(f"Directory zipped successfully: {zip_path}")
+
+
+def create_final_backup(temp_dir: Path, backup_dir: Path, timestamp: str) -> Path:
+ final_backup_name = f"{timestamp}_ucsuzkalem.zip"
+ final_backup_path = backup_dir / final_backup_name
+
+ logger.info(f"Creating final backup: {final_backup_path}")
+ zip_directory(temp_dir, final_backup_path)
+
+ return final_backup_path
+
+
+def main():
+ args = parse_args()
+
+ if not args.db_url:
+ raise ValueError("Database URL is required (set DB_URL env var or use --db-url)")
+
+ # Parse database URL
+ db_params = parse_db_url(args.db_url)
+ logger.info(f"Connecting to database: {db_params['host']}:{db_params['port']}/{db_params['database']}")
+
+ # Create timestamp
+ timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+
+ # Setup paths
+ site_dir = Path(args.site_dir)
+ backup_dir = Path(args.backup_dir)
+ temp_dir = Path("/tmp") / f"backup_{timestamp}"
+
+ if not site_dir.exists():
+ raise FileNotFoundError(f"Site directory does not exist: {site_dir}")
+
+ # Create directories
+ backup_dir.mkdir(parents=True, exist_ok=True)
+ temp_dir.mkdir(parents=True, exist_ok=True)
+
+ # Dump database
+ db_dump_path = temp_dir / "database.sql"
+ dump_database(db_params, db_dump_path)
+
+ # Zip website files
+
+ files_zip_path = temp_dir / "files.zip"
+ zip_directory(site_dir, files_zip_path)
+
+ # Create final backup
+ final_backup_path = create_final_backup(temp_dir, backup_dir, timestamp)
+
+ # Cleanup temp directory
+ shutil.rmtree(temp_dir)
+
+ logger.info(f"Backup completed successfully: {final_backup_path}")
+ logger.info(f"Backup size: {final_backup_path.stat().st_size / (1024 * 1024):.1f} MB")
+
+
+if __name__ == "__main__":
+ exit(main())
diff --git a/classify_image.py b/classify_image.py
new file mode 100755
index 0000000..11f754e
--- /dev/null
+++ b/classify_image.py
@@ -0,0 +1,738 @@
+#!/usr/bin/env -S uv run --script
+# /// script
+# requires-python = ">=3.13"
+# dependencies = ["ultralytics", "torch", "numpy", "pillow", "bottle", "mediapipe"]
+# ///
+import argparse
+import json
+import logging
+import sys
+import tempfile
+import threading
+import urllib.request
+import webbrowser
+from dataclasses import dataclass
+from functools import cache
+from pathlib import Path
+from typing import NamedTuple, Optional
+
+try:
+ import bottle
+except Exception:
+ bottle = None
+
+try:
+ from ultralytics import YOLO
+ import torch
+ import numpy as np
+except Exception:
+ YOLO = None
+ torch = None
+ np = None
+
+try:
+ from PIL import Image
+except Exception:
+ Image = None
+
+try:
+ import mediapipe as mp
+except Exception:
+ mp = None
+
+VISIBILITY_THRESH = 0.1
+FACE_LANDMARK_VISIBILITY_THRESH = 0.5
+FACING_DIRECTION_THRESH = 0.03
+LOOKING_AT_CAMERA_THRESH = 0.015 # max |nose.x - eye_midpoint.x| for frontal face
+HEAD_CROP_Y_THRESH = 0.35 # shoulders must be in top 35% of frame to classify as head-cropped
+NOSE_TO_NOSE_THRESH = 0.15 # normalized image distance
+BODY_INTERSECT_MARGIN = 0.05 # expand each person's bbox by this before overlap test
+EYE_BLINK_THRESH = 0.40 # blendshape score above this → eye closed
+
+
+class Coords(NamedTuple):
+ """Represents normalized coordinates (0.0 to 1.0) and visibility for a single point."""
+
+ x: float
+ y: float
+ is_visible: bool
+
+
+@dataclass
+class PoseKeypoints:
+ """Holds structured, normalized keypoint data for all 17 COCO points as direct fields."""
+
+ # 0
+ nose: Coords
+ # 1-4
+ left_eye: Coords
+ right_eye: Coords
+ left_ear: Coords
+ right_ear: Coords
+ # 5-6
+ left_shoulder: Coords
+ right_shoulder: Coords
+ # 7-10
+ left_elbow: Coords
+ right_elbow: Coords
+ left_wrist: Coords
+ right_wrist: Coords
+ # 11-12
+ left_hip: Coords
+ right_hip: Coords
+ # 13-16
+ left_knee: Coords
+ right_knee: Coords
+ left_ankle: Coords
+ right_ankle: Coords
+
+ def shoulder_midpoint(self) -> Coords:
+ l = self.left_shoulder
+ r = self.right_shoulder
+ if l.is_visible and r.is_visible:
+ return Coords(x=(l.x + r.x) / 2.0, y=(l.y + r.y) / 2.0, is_visible=True)
+ return Coords(x=(l.x + r.x) / 2.0, y=(l.y + r.y) / 2.0, is_visible=False)
+
+
+@dataclass
+class FaceLandmarks:
+ """5-point face landmarks from the derronqi yolov8-face model."""
+
+ left_eye: Coords
+ right_eye: Coords
+ nose: Coords
+ left_mouth: Coords
+ right_mouth: Coords
+
+
+def _get_coords(kp_xyc: "np.ndarray", idx: int) -> Coords:
+ """Helper to safely extract Coords from the raw numpy array."""
+ x, y, conf = kp_xyc[idx]
+ is_visible = conf > VISIBILITY_THRESH
+ return Coords(x=x, y=y, is_visible=is_visible)
+
+
+def _extract_keypoints(kp_xyc: "np.ndarray") -> PoseKeypoints:
+ """Extracts all 17 COCO normalized keypoints and populates the PoseKeypoints dataclass directly."""
+ return PoseKeypoints(
+ nose=_get_coords(kp_xyc, 0),
+ left_eye=_get_coords(kp_xyc, 1),
+ right_eye=_get_coords(kp_xyc, 2),
+ left_ear=_get_coords(kp_xyc, 3),
+ right_ear=_get_coords(kp_xyc, 4),
+ left_shoulder=_get_coords(kp_xyc, 5),
+ right_shoulder=_get_coords(kp_xyc, 6),
+ left_elbow=_get_coords(kp_xyc, 7),
+ right_elbow=_get_coords(kp_xyc, 8),
+ left_wrist=_get_coords(kp_xyc, 9),
+ right_wrist=_get_coords(kp_xyc, 10),
+ left_hip=_get_coords(kp_xyc, 11),
+ right_hip=_get_coords(kp_xyc, 12),
+ left_knee=_get_coords(kp_xyc, 13),
+ right_knee=_get_coords(kp_xyc, 14),
+ left_ankle=_get_coords(kp_xyc, 15),
+ right_ankle=_get_coords(kp_xyc, 16),
+ )
+
+
+_MODEL_URLS = {
+ "yolov11n-face.pt": "https://huggingface.co/AdamCodd/YOLOv11n-face-detection/resolve/main/model.pt",
+ "yolo11s-pose.pt": "https://github.com/ultralytics/assets/releases/download/v8.3.0/yolo11s-pose.pt",
+ "yolov8n-face-derronqi.pt": "https://huggingface.co/junjiang/GestureFace/resolve/main/yolov8n-face.pt",
+ "face_landmarker.task": "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task",
+}
+
+
+def _ensure_model(filename: str) -> Path:
+ dest = Path(__file__).parent / filename
+ if not dest.exists():
+ url = _MODEL_URLS[filename]
+ logging.warning(f"Downloading {filename} from {url} ...")
+ urllib.request.urlretrieve(url, dest)
+ logging.warning(f"Saved {filename}")
+ return dest
+
+
+def _best_device() -> str:
+ try:
+ if torch.cuda.is_available():
+ return "cuda"
+ if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
+ return "mps"
+ except Exception:
+ pass
+ return "cpu"
+
+
+@cache
+def _face_detector():
+ if YOLO is None or torch is None:
+ logging.error("YOLO/Torch dependencies are missing.")
+ return None
+ try:
+ model = YOLO(_ensure_model("yolov11n-face.pt"))
+ model.to(_best_device())
+ return model
+ except Exception:
+ logging.exception("Face detector model initialization failed.")
+ return None
+
+
+@cache
+def _pose_detector():
+ if YOLO is None or torch is None:
+ logging.error("YOLO/Torch dependencies are missing.")
+ return None
+ try:
+ model = YOLO(_ensure_model("yolo11s-pose.pt"))
+ model.to(_best_device())
+ return model
+ except Exception:
+ logging.exception("Pose detector model initialization failed.")
+ return None
+
+
+@cache
+def _face_landmark_detector():
+ if YOLO is None or torch is None:
+ logging.error("YOLO/Torch dependencies are missing.")
+ return None
+ try:
+ model = YOLO(_ensure_model("yolov8n-face-derronqi.pt"))
+ model.to(_best_device())
+ return model
+ except Exception:
+ logging.exception("Face landmark detector model initialization failed.")
+ return None
+
+
+@cache
+def _face_landmarker():
+ if mp is None:
+ logging.error("mediapipe dependency is missing.")
+ return None
+ try:
+ from mediapipe.tasks import python as mp_python
+ from mediapipe.tasks.python import vision as mp_vision
+ base_options = mp_python.BaseOptions(
+ model_asset_path=str(_ensure_model("face_landmarker.task"))
+ )
+ options = mp_vision.FaceLandmarkerOptions(
+ base_options=base_options,
+ output_face_blendshapes=True,
+ running_mode=mp_vision.RunningMode.IMAGE,
+ num_faces=10,
+ )
+ return mp_vision.FaceLandmarker.create_from_options(options)
+ except Exception:
+ logging.exception("FaceLandmarker initialization failed.")
+ return None
+
+
+def _run_face_landmarker_model(image_path: Path):
+ detector = _face_landmarker()
+ if detector is None or Image is None:
+ return None
+ try:
+ img = Image.open(image_path).convert("RGB")
+ arr = np.asarray(img)
+ mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=arr)
+ return detector.detect(mp_image)
+ except Exception:
+ logging.exception(f"FaceLandmarker failed for {image_path}.")
+ return None
+
+
+def check_eyes_closed(face_landmarker_result) -> bool:
+ if face_landmarker_result is None:
+ return False
+ if not face_landmarker_result.face_blendshapes:
+ return False
+ for face_blendshapes in face_landmarker_result.face_blendshapes:
+ scores = {b.category_name: b.score for b in face_blendshapes}
+ if scores.get("eyeBlinkLeft", 0.0) > EYE_BLINK_THRESH or \
+ scores.get("eyeBlinkRight", 0.0) > EYE_BLINK_THRESH:
+ return True
+ return False
+
+
+def _run_face_model(image_path: Path) -> list:
+ model = _face_detector()
+ if model is None:
+ return []
+ try:
+ return model(str(image_path), conf=0.5, iou=0.5, verbose=False)
+ except Exception:
+ logging.exception(f"Face detection failed for {image_path}.")
+ return []
+
+
+def _run_face_landmark_model(image_path: Path) -> list:
+ model = _face_landmark_detector()
+ if model is None:
+ return []
+ try:
+ return model(str(image_path), conf=0.25, iou=0.5, verbose=False)
+ except Exception:
+ logging.exception(f"Face landmark detection failed for {image_path}.")
+ return []
+
+
+def _run_pose_model(image_path: Path) -> list:
+ model = _pose_detector()
+ if model is None:
+ return []
+ try:
+ return model(str(image_path), conf=0.35, iou=0.5, verbose=False)
+ except Exception:
+ logging.exception(f"Pose detection failed for {image_path}.")
+ return []
+
+
+def count_faces(results: list) -> int:
+ try:
+ return len(results[0].boxes)
+ except Exception:
+ return 0
+
+
+def detect_poses(results: list) -> list[PoseKeypoints]:
+ try:
+ if not results or results[0].keypoints is None:
+ return []
+ kps_norm_xy = results[0].keypoints.xyn.cpu().numpy()
+ kps_conf = results[0].keypoints.conf.cpu().numpy()
+ all_keypoints_xyc = np.concatenate([kps_norm_xy, np.expand_dims(kps_conf, axis=2)], axis=2)
+ return [_extract_keypoints(kp_xyc) for kp_xyc in all_keypoints_xyc]
+ except Exception:
+ logging.exception("Pose keypoint extraction failed.")
+ return []
+
+
+def detect_face_landmarks(results: list) -> list[FaceLandmarks]:
+ try:
+ if not results or results[0].keypoints is None:
+ return []
+ kps_norm_xy = results[0].keypoints.xyn.cpu().numpy()
+ kps_conf = results[0].keypoints.conf.cpu().numpy()
+ all_keypoints_xyc = np.concatenate([kps_norm_xy, np.expand_dims(kps_conf, axis=2)], axis=2)
+ landmarks = []
+ for kp_xyc in all_keypoints_xyc:
+ def _lm(idx, arr=kp_xyc):
+ x, y, conf = arr[idx]
+ return Coords(x=x, y=y, is_visible=conf > FACE_LANDMARK_VISIBILITY_THRESH)
+ landmarks.append(FaceLandmarks(
+ left_eye=_lm(0),
+ right_eye=_lm(1),
+ nose=_lm(2),
+ left_mouth=_lm(3),
+ right_mouth=_lm(4),
+ ))
+ return landmarks
+ except Exception:
+ logging.exception("Face landmark extraction failed.")
+ return []
+
+
+def _face_all_invisible(kps: PoseKeypoints) -> bool:
+ return (
+ not kps.nose.is_visible
+ and not kps.left_eye.is_visible
+ and not kps.right_eye.is_visible
+ )
+
+
+def is_turned_back(kps: PoseKeypoints) -> bool:
+ shoulder_visible = kps.left_shoulder.is_visible or kps.right_shoulder.is_visible
+ return _face_all_invisible(kps) and shoulder_visible and not is_eyes_cropped_out(kps)
+
+
+def is_eyes_cropped_out(kps: PoseKeypoints) -> bool:
+ """True when face is not visible but shoulders are near the top of the frame,
+ indicating the head is above the image boundary."""
+ shoulder_visible = kps.left_shoulder.is_visible or kps.right_shoulder.is_visible
+ if not _face_all_invisible(kps) or not shoulder_visible:
+ return False
+ # Use the topmost (lowest y) visible shoulder
+ ys = [kp.y for kp in (kps.left_shoulder, kps.right_shoulder) if kp.is_visible]
+ return min(ys) < HEAD_CROP_Y_THRESH
+
+
+def get_facing_x_direction(kps: PoseKeypoints) -> Optional[float]:
+ shoulder_mid = kps.shoulder_midpoint()
+ if not kps.nose.is_visible or not shoulder_mid.is_visible:
+ return None
+ return kps.nose.x - shoulder_mid.x
+
+
+def get_face_yaw(kps: FaceLandmarks) -> Optional[float]:
+ """Nose x offset from eye midpoint. ~0 = frontal, positive = turned right, negative = turned left."""
+ if not kps.left_eye.is_visible or not kps.right_eye.is_visible or not kps.nose.is_visible:
+ return None
+ eye_mid_x = (kps.left_eye.x + kps.right_eye.x) / 2.0
+ return kps.nose.x - eye_mid_x
+
+
+def check_looking_at_camera(all_landmarks: list[FaceLandmarks]) -> bool:
+ return any(
+ (yaw := get_face_yaw(lm)) is not None and abs(yaw) < LOOKING_AT_CAMERA_THRESH
+ for lm in all_landmarks
+ )
+
+
+def check_facing_each_other(all_kps: list[PoseKeypoints]) -> bool:
+ classifiable = []
+ for kps in all_kps:
+ delta = get_facing_x_direction(kps)
+ if delta is not None:
+ shoulder_mid = kps.shoulder_midpoint()
+ classifiable.append((shoulder_mid.x, delta))
+
+ if len(classifiable) < 2:
+ return False
+
+ classifiable.sort(key=lambda t: t[0])
+
+ for i in range(len(classifiable)):
+ for j in range(i + 1, len(classifiable)):
+ left_delta = classifiable[i][1]
+ right_delta = classifiable[j][1]
+ if left_delta > FACING_DIRECTION_THRESH and right_delta < -FACING_DIRECTION_THRESH:
+ return True
+
+ return False
+
+
+def detect_person_boxes(results: list) -> list[tuple[float, float, float, float]]:
+ try:
+ if not results or results[0].boxes is None:
+ return []
+ return [tuple(box) for box in results[0].boxes.xyxyn.cpu().tolist()]
+ except Exception:
+ logging.exception("Person box extraction failed.")
+ return []
+
+
+def _face_boxes_to_body_boxes(
+ face_boxes: list[tuple[float, float, float, float]]
+) -> list[tuple[float, float, float, float]]:
+ out = []
+ for x1, y1, x2, y2 in face_boxes:
+ fw = x2 - x1
+ fh = y2 - y1
+ bx1 = max(0.0, x1 - 0.3 * fw)
+ bx2 = min(1.0, x2 + 0.3 * fw)
+ by1 = y1
+ by2 = min(1.0, y2 + 2.5 * fh) # extend ~2.5 face-heights downward
+ out.append((bx1, by1, bx2, by2))
+ return out
+
+
+def check_bodies_intersecting(person_boxes: list[tuple[float, float, float, float]]) -> bool:
+ m = BODY_INTERSECT_MARGIN
+ for i in range(len(person_boxes)):
+ for j in range(i + 1, len(person_boxes)):
+ ax1, ay1, ax2, ay2 = person_boxes[i]
+ bx1, by1, bx2, by2 = person_boxes[j]
+ if ax1 - m < bx2 and ax2 + m > bx1 and ay1 - m < by2 and ay2 + m > by1:
+ return True
+ return False
+
+
+def check_nose_to_nose(all_kps: list[PoseKeypoints]) -> bool:
+ visible = [(kps.nose.x, kps.nose.y) for kps in all_kps if kps.nose.is_visible]
+ for i in range(len(visible)):
+ for j in range(i + 1, len(visible)):
+ dx = visible[i][0] - visible[j][0]
+ dy = visible[i][1] - visible[j][1]
+ if (dx*dx + dy*dy) ** 0.5 < NOSE_TO_NOSE_THRESH:
+ return True
+ return False
+
+
+def classify_image(image_path: Path, debug_dir: Optional[Path] = None) -> dict:
+ face_results = _run_face_model(image_path)
+ pose_results = _run_pose_model(image_path)
+ face_landmark_results = _run_face_landmark_model(image_path)
+ face_mesh_result = _run_face_landmarker_model(image_path)
+
+ total_faces = count_faces(face_results)
+ all_kps = detect_poses(pose_results)
+ face_landmarks = detect_face_landmarks(face_landmark_results)
+ turned_back = any(is_turned_back(kps) for kps in all_kps)
+ facing_each_other = check_facing_each_other(all_kps)
+ eyes_cropped_out = any(is_eyes_cropped_out(kps) for kps in all_kps)
+ nose_to_nose = check_nose_to_nose(all_kps)
+ person_boxes = detect_person_boxes(pose_results)
+ if len(person_boxes) < 2:
+ raw_face_boxes = []
+ try:
+ if face_results and face_results[0].boxes is not None:
+ raw_face_boxes = [tuple(b) for b in face_results[0].boxes.xyxyn.cpu().tolist()]
+ except Exception:
+ pass
+ person_boxes = _face_boxes_to_body_boxes(raw_face_boxes)
+ bodies_intersecting = check_bodies_intersecting(person_boxes)
+ looking_at_camera = check_looking_at_camera(face_landmarks)
+
+ if debug_dir:
+ debug_dir.mkdir(parents=True, exist_ok=True)
+ for label, results in [("face", face_results), ("pose", pose_results)]:
+ if results and results[0].boxes is not None:
+ annotated = results[0].plot()
+ img = Image.fromarray(annotated[..., ::-1])
+ img.save(debug_dir / f"{image_path.stem}_{label}.jpg")
+
+ return {
+ "image_path": str(image_path),
+ "total_faces": total_faces,
+ "facing_each_other": facing_each_other,
+ "turned_back": turned_back,
+ "eyes_cropped_out": eyes_cropped_out,
+ "nose_to_nose": nose_to_nose,
+ "bodies_intersecting": bodies_intersecting,
+ "looking_at_camera": looking_at_camera,
+ "eyes_closed": check_eyes_closed(face_mesh_result),
+ }
+
+
+HTML_PAGE = """
+
+
+
+classify_image debug
+
+
+
+Drop images here to classify
+
+
+
+"""
+
+
+def _extract_web_data(face_results: list, pose_results: list) -> dict:
+ faces = []
+ try:
+ if face_results and face_results[0].boxes is not None:
+ faces = face_results[0].boxes.xyxyn.cpu().tolist()
+ except Exception:
+ pass
+
+ poses = []
+ try:
+ if pose_results and pose_results[0].keypoints is not None:
+ kps_norm_xy = pose_results[0].keypoints.xyn.cpu().numpy()
+ kps_conf = pose_results[0].keypoints.conf.cpu().numpy()
+ all_keypoints_xyc = np.concatenate([kps_norm_xy, np.expand_dims(kps_conf, axis=2)], axis=2)
+ for person_kps in all_keypoints_xyc:
+ poses.append([
+ {"x": float(kp[0]), "y": float(kp[1]), "v": bool(kp[2] > VISIBILITY_THRESH)}
+ for kp in person_kps
+ ])
+ except Exception:
+ pass
+
+ return {"faces": faces, "poses": poses}
+
+
+def _classify_route():
+ upload = bottle.request.files.get("image")
+ suffix = Path(upload.filename).suffix or ".jpg"
+ with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as f:
+ tmp = Path(f.name)
+ upload.save(f)
+ try:
+ face_results = _run_face_model(tmp)
+ pose_results = _run_pose_model(tmp)
+ face_landmark_results = _run_face_landmark_model(tmp)
+ face_mesh_result = _run_face_landmarker_model(tmp)
+ all_kps = detect_poses(pose_results)
+ face_landmarks = detect_face_landmarks(face_landmark_results)
+ person_boxes = detect_person_boxes(pose_results)
+ if len(person_boxes) < 2:
+ raw_face_boxes = []
+ try:
+ if face_results and face_results[0].boxes is not None:
+ raw_face_boxes = [tuple(b) for b in face_results[0].boxes.xyxyn.cpu().tolist()]
+ except Exception:
+ pass
+ person_boxes = _face_boxes_to_body_boxes(raw_face_boxes)
+ classification = {
+ "image": upload.filename,
+ "total_faces": count_faces(face_results),
+ "facing_each_other": check_facing_each_other(all_kps),
+ "turned_back": any(is_turned_back(kps) for kps in all_kps),
+ "eyes_cropped_out": any(is_eyes_cropped_out(kps) for kps in all_kps),
+ "nose_to_nose": check_nose_to_nose(all_kps),
+ "bodies_intersecting": check_bodies_intersecting(person_boxes),
+ "looking_at_camera": check_looking_at_camera(face_landmarks),
+ "eyes_closed": check_eyes_closed(face_mesh_result),
+ }
+ detections = _extract_web_data(face_results, pose_results)
+ return bottle.HTTPResponse(
+ json.dumps({"classification": classification, "detections": detections}),
+ content_type="application/json",
+ )
+ finally:
+ tmp.unlink(missing_ok=True)
+
+
+def web_main():
+ if bottle is None:
+ print("bottle not installed. Run: pip install bottle", file=sys.stderr)
+ sys.exit(1)
+
+ app = bottle.Bottle()
+
+ @app.get("/")
+ def index():
+ return HTML_PAGE
+
+ @app.post("/classify")
+ def classify_route():
+ return _classify_route()
+
+ port = 7777
+ threading.Timer(0.5, lambda: webbrowser.open(f"http://localhost:{port}")).start()
+ bottle.run(app, host="localhost", port=port, quiet=True)
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Classify images for face-related attributes.")
+ parser.add_argument("image_paths", nargs="*", type=Path, help="Path(s) to image(s)")
+ parser.add_argument("--debug", action="store_true", help="Save annotated debug images to _debug/ subdirectory")
+ parser.add_argument("--web", action="store_true", help="Start debug web server")
+ args = parser.parse_args()
+
+ logging.basicConfig(level=logging.WARNING, stream=sys.stderr)
+
+ if args.web:
+ web_main()
+ return
+
+ if not args.image_paths:
+ parser.print_usage(sys.stderr)
+ sys.exit(1)
+
+ for image_path in args.image_paths:
+ try:
+ debug_dir = (image_path.parent / "_debug") if args.debug else None
+ result = classify_image(image_path, debug_dir=debug_dir)
+ print(json.dumps(result), flush=True)
+ except KeyboardInterrupt:
+ raise
+ except Exception:
+ logging.exception(f"Error processing {image_path}.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/cluster_images.py b/cluster_images.py
new file mode 100755
index 0000000..d86d6f0
--- /dev/null
+++ b/cluster_images.py
@@ -0,0 +1,163 @@
+#!/usr/bin/env -S uv run
+# /// script
+# dependencies = [
+# "torch",
+# "torchvision",
+# "timm",
+# "scikit-learn",
+# "pillow",
+# "imagehash",
+# ]
+# ///
+"""Group similar large images using ResNet embeddings or perceptual hashing."""
+
+import torch
+import timm
+import numpy as np
+from PIL import Image
+from pathlib import Path
+from sklearn.cluster import KMeans, DBSCAN
+from sklearn.preprocessing import StandardScaler
+import imagehash
+import argparse
+
+# Load pretrained ResNet50 on M1
+device = torch.device("mps" if torch.backends.mps.is_available() else "cpu")
+model = timm.create_model("resnet50", pretrained=True, num_classes=0)
+model = model.to(device)
+model.eval()
+
+
+def get_embedding(img_path, size=224):
+ """Extract embedding from image."""
+ try:
+ img = Image.open(img_path).convert("RGB")
+ img.thumbnail((size, size), Image.Resampling.LANCZOS)
+
+ canvas = Image.new("RGB", (size, size), (128, 128, 128))
+ offset = ((size - img.width) // 2, (size - img.height) // 2)
+ canvas.paste(img, offset)
+
+ x = torch.tensor(np.array(canvas), dtype=torch.float32)
+ x = x.permute(2, 0, 1) / 255.0
+ x = (x - torch.tensor([0.485, 0.456, 0.406]).view(3, 1, 1)) / torch.tensor([0.229, 0.224, 0.225]).view(3, 1, 1)
+
+ with torch.no_grad():
+ embedding = model(x.unsqueeze(0).to(device)).squeeze().cpu().numpy()
+ return embedding
+ except Exception as e:
+ print(f"Error processing {img_path}: {e}")
+ return None
+
+
+def get_phash(img_path):
+ """Extract perceptual hash from image."""
+ try:
+ img = Image.open(img_path).convert("RGB")
+ return imagehash.phash(img)
+ except Exception as e:
+ print(f"Error processing {img_path}: {e}")
+ return None
+
+
+def hash_distance(h1, h2):
+ """Hamming distance between two hashes."""
+ return h1 - h2
+
+
+def parse_args():
+ parser = argparse.ArgumentParser(description="Group similar images using embeddings or perceptual hashing.")
+ parser.add_argument("paths", nargs="+", type=Path, help="Image file(s) or directory")
+ parser.add_argument("--cluster", action="store_true", help="Use ResNet embedding clustering")
+ parser.add_argument("--perceptual-hash", action="store_true", help="Use perceptual hashing")
+ parser.add_argument("--clusters", type=int, help="Number of clusters for embedding mode (auto if not specified)")
+ parser.add_argument("--hash-threshold", type=int, default=5, help="Hamming distance threshold for perceptual hash")
+ args = parser.parse_args()
+
+ if not args.cluster and not args.perceptual_hash:
+ parser.error("Either --cluster or --perceptual-hash must be specified")
+ if args.cluster and args.perceptual_hash:
+ parser.error("Cannot specify both --cluster and --perceptual-hash")
+
+ return args
+
+
+def group_by_hash(valid_files, hashes, threshold):
+ """Group images by perceptual hash similarity."""
+ labels = [-1] * len(valid_files)
+ cluster_id = 0
+
+ for i in range(len(valid_files)):
+ if labels[i] != -1:
+ continue
+ labels[i] = cluster_id
+ for j in range(i + 1, len(valid_files)):
+ if labels[j] == -1 and hash_distance(hashes[i], hashes[j]) <= threshold:
+ labels[j] = cluster_id
+ cluster_id += 1
+
+ return np.array(labels)
+
+
+def main():
+ args = parse_args()
+
+ # Collect image files
+ img_files = []
+ for path in args.paths:
+ if path.is_dir():
+ img_files.extend(path.glob("*.[jJ][pP][gG]"))
+ img_files.extend(path.glob("*.[pP][nN][gG]"))
+ else:
+ img_files.append(path)
+
+ if not img_files:
+ print("No images found.")
+ return
+
+ if args.perceptual_hash:
+ # Perceptual hash mode
+ hashes = []
+ valid_files = []
+
+ for i, img_path in enumerate(img_files):
+ print(f"Processing {i + 1}/{len(img_files)}: {img_path.name}")
+ h = get_phash(img_path)
+ if h is not None:
+ hashes.append(h)
+ valid_files.append(img_path)
+
+ labels = group_by_hash(valid_files, hashes, args.hash_threshold)
+ n_clusters = len(np.unique(labels))
+
+ else:
+ # Embedding clustering mode
+ embeddings = []
+ valid_files = []
+
+ for i, img_path in enumerate(img_files):
+ print(f"Processing {i + 1}/{len(img_files)}: {img_path.name}")
+ emb = get_embedding(img_path)
+ if emb is not None:
+ embeddings.append(emb)
+ valid_files.append(img_path)
+
+ embeddings = np.array(embeddings)
+ scaler = StandardScaler()
+ embeddings = scaler.fit_transform(embeddings)
+
+ n_clusters = args.clusters or max(2, int(np.sqrt(len(embeddings) / 2)))
+ kmeans = KMeans(n_clusters=n_clusters, random_state=42, n_init=10)
+ labels = kmeans.fit_predict(embeddings)
+
+ # Save clustered images
+ for img_path, cluster_label in zip(valid_files, labels):
+ new_name = f"group{cluster_label}_{img_path.name}"
+ dest = Path.cwd() / new_name
+ img_path.rename(dest) if img_path.parent == Path.cwd() else dest.write_bytes(img_path.read_bytes())
+
+ print(f"Clustered {len(valid_files)} images into {n_clusters} clusters in {Path.cwd()}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/cull_edits.py b/cull_edits.py
new file mode 100755
index 0000000..3b6569e
--- /dev/null
+++ b/cull_edits.py
@@ -0,0 +1,939 @@
+#!/usr/bin/env -S uv run
+# /// script
+# requires-python = ">=3.14"
+# dependencies = [
+# "bottle>=0.13.0",
+# ]
+# ///
+
+import argparse
+import functools
+import os
+import re
+import socket
+import sys
+import threading
+import time
+import webbrowser
+from pathlib import Path
+from datetime import datetime
+from typing import TypedDict
+import bottle
+
+IMAGE_EXTENSIONS: set[str] = {".jpg", ".jpeg", ".png", ".webp", ".webm"}
+
+
+class ImageGroup(TypedDict):
+ """Type definition for grouped images."""
+
+ base: str
+ original: Path
+ edits: list[Path]
+
+
+def find_available_port() -> int:
+ """Find an available random port."""
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.bind(("127.0.0.1", 0))
+ s.listen(1)
+ port = s.getsockname()[1]
+ return port
+
+
+def is_image(path: str | Path) -> bool:
+ """Check if file is a supported image."""
+ return Path(path).suffix.lower() in IMAGE_EXTENSIONS
+
+
+def discover_images(paths: list[str]) -> list[Path]:
+ """Discover all images from mixed list of files and folders."""
+ images = []
+ for path_str in paths:
+ path = Path(path_str)
+ if path.is_file() and is_image(path):
+ images.append(path.resolve())
+ elif path.is_dir():
+ for item in path.iterdir():
+ if item.is_file() and is_image(item):
+ images.append(item.resolve())
+ return sorted(set(images))
+
+
+def group_images(images: list[Path]) -> list[ImageGroup]:
+ """
+ Group images by original + edits.
+ Original: image.jpg
+ Edits: image-edit1.jpg, image-edit2.jpg, etc.
+ Returns list of dicts: {original: Path, edits: [Path, ...]}
+ """
+ groups = {}
+
+ for img in images:
+ stem = img.stem
+
+ # Check if this is an edit
+ match = re.match(r"^(.+?)-edit", stem)
+ if match:
+ base = match.group(1)
+ if base not in groups:
+ groups[base] = {"original": None, "edits": []}
+ groups[base]["edits"].append(img)
+ else:
+ # This is an original
+ if stem not in groups:
+ groups[stem] = {"original": None, "edits": []}
+ groups[stem]["original"] = img
+
+ # Filter out groups without originals, convert to list
+ result = []
+ for base, group in groups.items():
+ if group["original"]:
+ result.append(ImageGroup(base=base, original=group["original"], edits=sorted(group["edits"])))
+
+ return sorted(result, key=lambda g: g["original"].name)
+
+
+def safe_image_path(filepath: str) -> Path:
+ """Verify image path is safe (prevent directory traversal)."""
+ # Search for the image in all_images by filename
+ for img in all_images:
+ if img.name == filepath:
+ return img
+ raise ValueError(f"Unauthorized path: {filepath}")
+
+
+def hardlink_images(src_paths: list[Path | str], dest_dir: str | Path) -> None:
+ """Hard link multiple images to destination directory."""
+ dest_path = Path(dest_dir)
+ make_dirs(dest_path)
+
+ for src in src_paths:
+ src = Path(src)
+ dest = dest_path / src.name
+ try:
+ # Remove existing file if present
+ if dest.exists():
+ dest.unlink()
+ os.link(src, dest)
+ except Exception as e:
+ print(f"Error hard linking {src} to {dest}: {e}", file=sys.stderr)
+ raise
+
+
+# Global state
+app = bottle.Bottle()
+all_images = set()
+image_groups = []
+current_group_idx = 0
+last_heartbeat = datetime.now()
+should_exit = False
+picks_dir = Path()
+flags = []
+flag_dirs = {}
+
+HTML_TEMPLATE = """
+
+
+
+
+
+ Image Culler
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No images
+
+
+
+
+
+
+ Position:
+
+ /
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+
+
+ No variations
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+
+@app.get("/")
+def index() -> str:
+ return HTML_TEMPLATE
+
+
+@app.get("/api/images")
+def api_images():
+ """Return grouped images and current flag assignments."""
+ global image_groups, flags, flag_dirs
+ result = []
+ for group in image_groups:
+ result.append(
+ {
+ "base": group["base"],
+ "original": {"name": group["original"].name, "path": str(group["original"])},
+ "edits": [{"name": e.name, "path": str(e)} for e in group["edits"]],
+ }
+ )
+
+ # Build flag assignments by checking which flag directories contain which original images
+ flag_assignments = {}
+ for flag, flag_dir in flag_dirs.items():
+ if flag_dir.exists():
+ for item in flag_dir.iterdir():
+ if item.is_file():
+ # Map the file name to its flag
+ flag_assignments[item.name] = flag
+
+ return {"groups": result, "flags_list": flags, "flag_assignments": flag_assignments}
+
+
+@app.get("/image/")
+def serve_image(filename: str):
+ """Serve an image file safely."""
+ try:
+ target = safe_image_path(filename)
+ return bottle.static_file(target.name, root=str(target.parent), mimetype="image/jpeg")
+ except ValueError:
+ bottle.response.status = 403
+ return {"error": "Unauthorized path"}
+
+
+@app.post("/api/pick")
+def api_pick():
+ """Hard link picked images to _picks directory."""
+ global image_groups, current_group_idx, picks_dir
+
+ # Create directory before writing
+ make_dirs(picks_dir)
+
+ data = bottle.request.json
+ original_name = data.get("original")
+ edits_names = data.get("edits", [])
+ include_original = data.get("includeOriginal", False)
+
+ files_to_pick = []
+
+ # Add original if requested
+ if include_original:
+ for img in all_images:
+ if img.name == original_name:
+ files_to_pick.append(img)
+ break
+
+ # Add edits by finding them in all_images
+ for edit_name in edits_names:
+ for img in all_images:
+ if img.name == edit_name:
+ files_to_pick.append(img)
+ break
+
+ try:
+ hardlink_images(files_to_pick, picks_dir)
+ return {"status": "ok"}
+ except Exception as e:
+ bottle.response.status = 500
+ return {"error": str(e)}
+
+
+@app.post("/api/unpick")
+def api_unpick():
+ """Remove picked images from _picks directory."""
+ global picks_dir
+
+ data = bottle.request.json
+ original_name = data.get("original")
+
+ try:
+ # Find and remove the original and any edits from picks directory
+ if picks_dir.exists():
+ for item in picks_dir.iterdir():
+ if item.is_file() and (item.name == original_name or item.stem.startswith(original_name.rsplit(".", 1)[0] + "-edit")):
+ item.unlink()
+ return {"status": "ok"}
+ except Exception as e:
+ bottle.response.status = 500
+ return {"error": str(e)}
+
+
+@app.post("/api/assign-flag")
+def api_assign_flag():
+ """Assign an image to a flag."""
+ global flag_dirs, all_images
+
+ data = bottle.request.json
+ original_name = data.get("original")
+ flag_name = data.get("flag")
+
+ # Find original file
+ original_path = None
+ for img in all_images:
+ if img.name == original_name:
+ original_path = img
+ break
+
+ if not original_path or flag_name not in flag_dirs:
+ bottle.response.status = 400
+ return {"error": "Invalid image or flag"}
+
+ # Create directory before writing
+ make_dirs(flag_dirs[flag_name])
+
+ try:
+ hardlink_images([original_path], flag_dirs[flag_name])
+ return {"status": "ok"}
+ except Exception as e:
+ bottle.response.status = 500
+ return {"error": str(e)}
+
+
+@app.post("/api/unassign-flag")
+def api_unassign_flag():
+ """Remove an image from a flag."""
+ global flag_dirs
+
+ data = bottle.request.json
+ original_name = data.get("original")
+ flag_name = data.get("flag")
+
+ if flag_name not in flag_dirs:
+ bottle.response.status = 400
+ return {"error": "Invalid flag"}
+
+ try:
+ target_file = flag_dirs[flag_name] / original_name
+ if target_file.exists():
+ target_file.unlink()
+ return {"status": "ok"}
+ except Exception as e:
+ bottle.response.status = 500
+ return {"error": str(e)}
+
+
+@app.post("/api/heartbeat")
+def api_heartbeat():
+ """Update last heartbeat timestamp."""
+ global last_heartbeat
+ last_heartbeat = datetime.now()
+ return {"status": "ok"}
+
+
+@app.post("/api/shutdown")
+def api_shutdown():
+ """Shutdown the server."""
+ global should_exit
+ should_exit = True
+ return {"status": "ok"}
+
+
+@functools.cache
+def make_dirs(path: Path) -> None:
+ """Create directory if it doesn't exist."""
+ path.mkdir(parents=True, exist_ok=True)
+
+
+def heartbeat_monitor() -> None:
+ """Monitor for shutdown signal."""
+ global should_exit
+
+ while not should_exit:
+ time.sleep(0.5)
+
+ print("\nShutting down server.", file=sys.stderr)
+ os._exit(0)
+
+
+def main() -> None:
+ global all_images, image_groups, last_heartbeat, picks_dir, flags, flag_dirs
+
+ parser = argparse.ArgumentParser(description="Image culling app for AI image2image transforms")
+ parser.add_argument("paths", nargs="+", help="Image files or folders to cull")
+ parser.add_argument("--picks-dir", default=None, help="Directory for picked images (default: $cwd/_picks)")
+ parser.add_argument("--rejects-dir", default=None, help="Directory for rejected images (default: $cwd/_rejects)")
+ parser.add_argument("--flags", default=None, help="Comma-separated flag names (e.g., 'a,b,c,d')")
+ parser.add_argument("--flags-dir", default=None, help="Directory for flag subdirectories (default: $cwd)")
+
+ args = parser.parse_args()
+
+ # Set directories
+ cwd = Path.cwd()
+ picks_dir = Path(args.picks_dir or (cwd / "_picks"))
+ rejects_dir = Path(args.rejects_dir or (cwd / "_rejects"))
+ flags_dir = Path(args.flags_dir or cwd)
+
+ # Set up flags
+ if args.flags:
+ flags = [f.strip() for f in args.flags.split(",")]
+ for flag in flags:
+ flag_dir = flags_dir / f"_picks_flag_{flag}"
+ flag_dirs[flag] = flag_dir
+
+ # Always add reject as a flag at the end
+ flags.append("reject")
+ flag_dirs["reject"] = rejects_dir
+
+ # Discover images
+ images = discover_images(args.paths)
+ all_images = set(images)
+
+ if not images:
+ print("No images found in provided paths.", file=sys.stderr)
+ sys.exit(1)
+
+ # Group images
+ image_groups = group_images(images)
+
+ if not image_groups:
+ print("No original images found (looking for files matching naming convention).", file=sys.stderr)
+ sys.exit(1)
+
+ print(f"Found {len(image_groups)} original image(s) with variations", file=sys.stderr)
+
+ # Find available port
+ port = find_available_port()
+ url = f"http://127.0.0.1:{port}"
+
+ # Start heartbeat monitor
+ monitor_thread = threading.Thread(target=heartbeat_monitor, daemon=True)
+ monitor_thread.start()
+
+ # Open browser
+ print(f"Opening browser at {url}", file=sys.stderr)
+ webbrowser.open(url)
+
+ # Start Flask server
+ last_heartbeat = datetime.now()
+ bottle.run(app, host="127.0.0.1", port=port, quiet=True)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/date_images.py b/date_images.py
new file mode 100755
index 0000000..c7e34c3
--- /dev/null
+++ b/date_images.py
@@ -0,0 +1,79 @@
+#!/usr/bin/env -S uv run
+# /// script
+# dependencies = ["piexif"]
+# ///
+
+import argparse
+from concurrent.futures import ThreadPoolExecutor
+import logging
+import re
+import subprocess
+from datetime import datetime
+from pathlib import Path
+
+import piexif
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Set creation time to date in filename and mod time to now for image files.",
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ )
+ parser.add_argument(
+ "paths",
+ nargs="+",
+ type=Path,
+ help="Paths to image files or folders containing images.",
+ )
+ return parser.parse_args()
+
+
+def update_image_times(path: Path) -> None:
+ match = re.search(r"\D(\d{4}-\d{2}-\d{2})\D?", path.name)
+ if not match:
+ logging.debug(f"No date found in {path.name}")
+ return
+ date_str = match.group(1)
+ try:
+ dt = datetime.fromisoformat(date_str)
+ formatted_date = dt.strftime("%m/%d/%Y %H:%M:%S")
+ today = datetime.now().strftime("%m/%d/%Y %H:%M:%S")
+ subprocess.run(["SetFile", "-d", formatted_date, str(path)], check=True)
+ subprocess.run(["SetFile", "-m", today, str(path)], check=True)
+ # Update EXIF DateTimeOriginal
+ exif_dict = piexif.load(str(path))
+ exif_dict["Exif"][piexif.ExifIFD.DateTimeOriginal] = dt.strftime("%Y:%m:%d %H:%M:%S").encode()
+ exif_bytes = piexif.dump(exif_dict)
+ piexif.insert(exif_bytes, str(path))
+ logging.info(f"Updated times and EXIF for {path}")
+ except ValueError as e:
+ logging.error(f"Invalid date {date_str} in {path.name}: {e}")
+ except subprocess.CalledProcessError as e:
+ logging.error(f"Failed to update {path}: {e}")
+ except Exception as e:
+ logging.error(f"Failed to update EXIF for {path}: {e}")
+
+
+def main() -> None:
+ args = parse_args()
+
+ files = []
+ for it in args.paths:
+ if it.is_dir():
+ files.extend(it.glob("*.jpg"))
+ files.extend(it.glob("*.jpeg"))
+ elif it.is_file() and it.suffix.lower() in {".jpg", ".jpeg"}:
+ files.append(it)
+ else:
+ logging.warning(f"Path {it} is neither an image nor a directory, skipping.")
+
+ pool = ThreadPoolExecutor(max_workers=5)
+ for path in files:
+ pool.submit(update_image_times, path)
+ pool.shutdown(wait=True)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/delete_downloaded_videos.py b/delete_downloaded_videos.py
new file mode 100755
index 0000000..14683d6
--- /dev/null
+++ b/delete_downloaded_videos.py
@@ -0,0 +1,142 @@
+#!/usr/bin/env -S uv run --script
+# /// script
+# dependencies = ["psycopg[binary]"]
+# ///
+import dataclasses
+import datetime
+import hashlib
+from pathlib import Path
+import subprocess
+import typing
+import psycopg
+import contextlib
+
+
+@contextlib.contextmanager
+def connect_db() -> typing.Generator[psycopg.Connection, typing.Any, typing.Any]:
+ with psycopg.connect("postgres://abdus:abdus@db.abdus.dev:5444/smut?sslmode=disable") as conn:
+ conn.row_factory = psycopg.rows.dict_row
+ yield conn
+
+
+def get_video_duration(video_path: Path) -> datetime.timedelta:
+ # fmt: off
+ args = [
+ 'ffprobe',
+ '-v', 'error',
+ '-show_entries', 'format=duration',
+ '-of', 'default=noprint_wrappers=1:nokey=1',
+ video_path,
+ ]
+ # fmt: on
+ p = subprocess.run(args, stdout=subprocess.PIPE, check=True)
+ return datetime.timedelta(seconds=round(float(p.stdout), 1))
+
+
+def hash_partial(f: Path) -> str:
+ sha1 = hashlib.sha1()
+ chunk_size = 1024 * 1024 * 10 # 10MB chunk size
+
+ total_read = 0
+ with f.open("rb") as file:
+ while chunk := file.read(chunk_size):
+ total_read += len(chunk)
+ sha1.update(chunk)
+ break # Only reads the first 10MB
+
+ return f"sha1:{total_read}:{sha1.hexdigest()}"
+
+
+@dataclasses.dataclass
+class SavedVideo:
+ id: int
+ remote_path: Path
+ local_path: Path
+
+ def __hash__(self):
+ return hash(self.remote_path)
+
+
+# smut=# \d videos;
+# Table "public.videos"
+# Column | Type | Collation | Nullable | Default
+# ------------------------+-----------------------------+-----------+----------+--------------------------------------------------------------------------
+# id | integer | | not null | nextval('videos_id_seq'::regclass)
+# category | ltree | | not null |
+# file_path | text | | not null |
+# created_at | timestamp without time zone | | | now()
+# hash_partial | text | | not null |
+# ffprobe | jsonb | | not null |
+# size_bytes | bigint | | not null | generated always as ((ffprobe ->> 'size_bytes'::text)::bigint) stored
+# duration_sec | numeric | | not null | generated always as ((ffprobe ->> 'duration_sec'::text)::numeric) stored
+# suggested_filename | text | | |
+# marked_for_deletion_at | timestamp with time zone | | |
+# duration_human | text | | | generated always as (ffprobe ->> 'duration_human'::text) stored
+# last_seen_at | timestamp with time zone | | |
+# Indexes:
+# "videos_pkey" PRIMARY KEY, btree (id)
+# "videos_file_path_idx" gist (file_path gist_trgm_ops)
+# "videos_file_path_uniq" UNIQUE CONSTRAINT, btree (file_path)
+def find_videos_by_hash(conn: psycopg.Connection, video_paths: list[Path]) -> list[SavedVideo]:
+ file_to_hash = {p: hash_partial(p) for p in video_paths}
+ if not file_to_hash:
+ return []
+
+ placeholders = ",".join(["%s"] * len(file_to_hash))
+ sql = f"""
+ SELECT id, file_path AS remote_path, file_path AS local_path
+ FROM videos
+ WHERE hash_partial IN ({placeholders})
+ """
+ with conn.cursor() as cur:
+ rows = cur.execute(sql, list(file_to_hash.values())).fetchall()
+ return [SavedVideo(**row) for row in rows]
+
+
+def find_videos_by_duration(conn: psycopg.Connection, video_paths: list[Path]) -> list[SavedVideo]:
+ if not video_paths:
+ return []
+ file_to_duration = {p: get_video_duration(p).total_seconds() for p in video_paths}
+ file_to_size = {p: p.stat().st_size for p in video_paths}
+
+ sql = f"""
+ SELECT id, file_path AS remote_path, file_path AS local_path
+ FROM videos
+ WHERE abs(duration_sec - %s) < 0.1 AND abs(size_bytes - %s) < 1048576
+ """
+ with conn.cursor() as cur:
+ out = []
+ for file_path in video_paths:
+ size = file_to_size[file_path]
+ duration = file_to_duration[file_path]
+ for row in cur.execute(sql, (duration, size)):
+ out.append(SavedVideo(**row))
+ return out
+
+
+def delete_videos(con: psycopg.Connection, file_paths: list[str]) -> bool:
+ with con.cursor() as cur:
+ placeholders = ",".join(["%s"] * len(file_paths))
+ sql = f"DELETE FROM videos WHERE file_path IN ({placeholders})"
+ cur.execute(sql, file_paths)
+
+
+def main():
+ videos = list(Path(r"/Users/abdus/Downloads/temp").glob("*.mp4"))
+ with connect_db() as conn:
+ found_videos = set(find_videos_by_hash(conn, videos))
+ found_videos.update(find_videos_by_duration(conn, videos))
+ found_videos = {video for video in found_videos if "/mnt/box/files/_raw/prt" in video.remote_path}
+ for video in found_videos:
+ print(f"{video.remote_path}")
+ if not found_videos:
+ print("No videos found for deletion.")
+ return
+ input("Press Enter to continue...")
+
+ print(f"Deleting {len(found_videos)} videos from database...")
+ delete_videos(conn, [video.remote_path for video in found_videos])
+
+
+if __name__ == "__main__":
+ main()
diff --git a/face_landmarker.task b/face_landmarker.task
new file mode 100644
index 0000000..c50c845
Binary files /dev/null and b/face_landmarker.task differ
diff --git a/ffmpeg-thumbnail-tile.md b/ffmpeg-thumbnail-tile.md
new file mode 100644
index 0000000..b07c202
--- /dev/null
+++ b/ffmpeg-thumbnail-tile.md
@@ -0,0 +1,29 @@
+# Generating thumbnail tiles using ffmpeg
+> ffmpeg can unsurprisingly build contact sheets, too. Is there anything ffmpeg can't do?
+
+I've been organizing my movie collection lately, and I needed a way to figure out what a video is all about without having to
+view & seek it.
+Building a contact sheet is a simple solution for this. It places frames with from the video in a grid separated by
+an interval (like every minute) and you can quickly see what a video contains.
+
+## ffmpeg tile filter
+
+With anything media-related, I checked to see if ffmpeg supports this: it turns out, it has a `tile` filter[^tile][tile], which gives
+us some primitives to work with.
+
+## Covering the whole video
+
+```shell
+ffmpeg -skip_frame nokey -i video.mp4 -vf 'scale=320:-1,tile=8x8' -an -vsync 0 keyframes%03d.png
+```
+
+This creates n files, each containing 8x8 grid of keyframes from the video.
+It's close, but I need a single file with tiles spanning the video from start to finish.
+
+##
+
+```shell
+
+```
+
+[tile]: https://ffmpeg.org/ffmpeg-filters.html#tile-1
diff --git a/flux2_private_server.py b/flux2_private_server.py
new file mode 100644
index 0000000..c93aa10
--- /dev/null
+++ b/flux2_private_server.py
@@ -0,0 +1,461 @@
+#!/usr/bin/env -S uv run --script
+# /// script
+# requires-python = ">=3.10,<3.11"
+# dependencies = [
+# "torch==2.9.1+cu128",
+# "torchvision==0.24.1+cu128",
+# "torchaudio==2.9.1+cu128",
+# "diffusers @ git+https://github.com/huggingface/diffusers.git",
+# "transformers>=4.57.0",
+# "accelerate>=1.0.0",
+# "safetensors>=0.4.0",
+# "huggingface_hub>=0.25.0",
+# "numpy>=1.26.0",
+# "pillow>=10.0.0",
+# "fastapi>=0.115.0",
+# "uvicorn>=0.29.0",
+# ]
+# [tool.uv]
+# extra-index-url = ["https://download.pytorch.org/whl/cu128"]
+# ///
+
+import argparse
+import base64
+import binascii
+import io
+import os
+import random
+import threading
+import time
+from typing import Any
+
+os.environ.setdefault("HF_XET_HIGH_PERFORMANCE", "1")
+
+import torch
+from diffusers import Flux2KleinPipeline
+from fastapi import FastAPI, HTTPException
+from fastapi.responses import HTMLResponse, Response
+from pydantic import BaseModel, Field
+from PIL import Image
+
+
+def _resolve_model_size() -> str:
+ parser = argparse.ArgumentParser(add_help=False)
+ parser.add_argument("--model", choices=["4b", "9b"], default="9b")
+ args, _ = parser.parse_known_args()
+ return args.model
+
+
+def _checkpoint_for(model_size: str) -> str:
+ if model_size == "4b":
+ return "black-forest-labs/FLUX.2-klein-4B"
+ return "black-forest-labs/FLUX.2-klein-9B"
+
+
+MODEL_SIZE = _resolve_model_size()
+CHECKPOINT = _checkpoint_for(MODEL_SIZE)
+USE_COMPILE = os.environ.get("FLUX2_COMPILE", "1") != "0"
+WARMUP_RUNS = int(os.environ.get("FLUX2_WARMUP_RUNS", "2"))
+WARMUP_STEPS = int(os.environ.get("FLUX2_WARMUP_STEPS", "4"))
+WARMUP_PROMPT = "A high-resolution photo of a red fox in natural daylight"
+MAX_SEED = (2**31) - 1
+MAX_PIXELS = 4 * 1024 * 1024
+
+DEFAULT_STEPS = 4
+DEFAULT_GUIDANCE = 1.0
+
+app = FastAPI(title="FLUX2 Klein Private Server")
+PIPE: Flux2KleinPipeline | None = None
+PIPE_LOCK = threading.Lock()
+
+
+class GenerateRequest(BaseModel):
+ prompt: str = Field(..., min_length=1)
+ width: int = 1024
+ height: int = 1024
+ num_inference_steps: int = DEFAULT_STEPS
+ guidance_scale: float = DEFAULT_GUIDANCE
+ seed: int = 0
+ randomize_seed: bool = False
+ image_data_url: str | None = None
+
+
+class CompatRequest(BaseModel):
+ model: str | None = None
+ prompt: str = Field(..., min_length=1)
+ imageDataUrl: str | None = None
+ size: str | None = None
+ width: int | None = None
+ height: int | None = None
+ num_inference_steps: int | None = None
+ guidance_scale: float | None = None
+ seed: int | None = None
+ randomize_seed: bool = False
+ response_format: str = "b64_json"
+ n: int = 1
+ negative_prompt: str | None = None
+ negativePrompt: str | None = None
+
+
+def _ensure_cuda() -> None:
+ if not torch.cuda.is_available():
+ raise RuntimeError("CUDA is required for this server")
+
+
+def _compile_pipe(pipe: Flux2KleinPipeline) -> bool:
+ if not USE_COMPILE:
+ return False
+ print("Compiling transformer and VAE decoder...")
+ start = time.perf_counter()
+ pipe.transformer = torch.compile(
+ pipe.transformer,
+ mode="max-autotune",
+ dynamic=False,
+ fullgraph=True,
+ )
+ pipe.vae.decode = torch.compile(
+ pipe.vae.decode,
+ mode="max-autotune",
+ dynamic=False,
+ fullgraph=True,
+ )
+ print(f"Compile finished in {time.perf_counter() - start:.2f}s")
+ return True
+
+
+def _warmup_pipe(pipe: Flux2KleinPipeline) -> None:
+ if WARMUP_RUNS <= 0:
+ return
+ print(f"Warmup started ({WARMUP_RUNS} runs)...")
+ start = time.perf_counter()
+ for run_idx in range(WARMUP_RUNS):
+ generator = torch.Generator(device="cuda").manual_seed(run_idx)
+ pipe(
+ prompt=WARMUP_PROMPT,
+ height=1024,
+ width=1024,
+ num_inference_steps=WARMUP_STEPS,
+ guidance_scale=1.0,
+ generator=generator,
+ ).images[0]
+ torch.cuda.synchronize()
+ print(f"Warmup finished in {time.perf_counter() - start:.2f}s")
+
+
+def _load_pipe(ckpt: str) -> Flux2KleinPipeline:
+ print(f"Loading checkpoint: {ckpt}")
+ pipe = Flux2KleinPipeline.from_pretrained(ckpt, torch_dtype=torch.bfloat16)
+ pipe = pipe.to("cuda")
+ pipe.transformer.fuse_qkv_projections()
+ pipe.vae.fuse_qkv_projections()
+ pipe.vae.to(memory_format=torch.channels_last)
+ pipe.transformer.set_attention_backend("_native_flash")
+ if _compile_pipe(pipe):
+ _warmup_pipe(pipe)
+ return pipe
+
+
+def _normalize_dims(width: int, height: int) -> tuple[int, int]:
+ if width <= 0 or height <= 0:
+ raise HTTPException(status_code=422, detail="width and height must be > 0")
+ if width % 16 != 0 or height % 16 != 0:
+ raise HTTPException(
+ status_code=422, detail="width and height must be multiples of 16"
+ )
+ if width * height > MAX_PIXELS:
+ raise HTTPException(status_code=422, detail="max resolution is 4 megapixels")
+ return width, height
+
+
+def _parse_size(
+ size: str | None, width: int | None, height: int | None
+) -> tuple[int, int]:
+ if width and height:
+ return _normalize_dims(width, height)
+ if not size:
+ return _normalize_dims(1024, 1024)
+ parts = size.lower().split("x")
+ if len(parts) != 2:
+ raise HTTPException(status_code=422, detail="size must look like 1024x1024")
+ try:
+ parsed_w = int(parts[0])
+ parsed_h = int(parts[1])
+ except ValueError as exc:
+ raise HTTPException(
+ status_code=422, detail="size values must be integers"
+ ) from exc
+ return _normalize_dims(parsed_w, parsed_h)
+
+
+def _decode_image_data_url(image_data_url: str | None) -> Image.Image | None:
+ if not image_data_url:
+ return None
+ if "," not in image_data_url:
+ raise HTTPException(status_code=422, detail="imageDataUrl must be a data URL")
+ _, payload = image_data_url.split(",", 1)
+ try:
+ raw = base64.b64decode(payload)
+ except (ValueError, binascii.Error) as exc:
+ raise HTTPException(
+ status_code=422, detail="invalid base64 in imageDataUrl"
+ ) from exc
+ with Image.open(io.BytesIO(raw)) as image:
+ return image.convert("RGB")
+
+
+def _generate_png_bytes(
+ prompt: str,
+ width: int,
+ height: int,
+ num_inference_steps: int,
+ guidance_scale: float,
+ seed: int,
+ image: Image.Image | None,
+) -> tuple[bytes, int]:
+ if PIPE is None:
+ raise HTTPException(status_code=503, detail="model not loaded yet")
+
+ generator = torch.Generator(device="cuda").manual_seed(seed)
+ kwargs: dict[str, Any] = {
+ "prompt": prompt,
+ "height": height,
+ "width": width,
+ "num_inference_steps": num_inference_steps,
+ "guidance_scale": guidance_scale,
+ "generator": generator,
+ }
+
+ with PIPE_LOCK:
+ try:
+ if image is not None:
+ try:
+ output = PIPE(image=image, **kwargs)
+ except TypeError:
+ output = PIPE(images=[image], **kwargs)
+ else:
+ output = PIPE(**kwargs)
+ except Exception as exc:
+ raise HTTPException(
+ status_code=500, detail=f"generation failed: {exc}"
+ ) from exc
+
+ result_image = output.images[0]
+ buf = io.BytesIO()
+ result_image.save(buf, format="PNG")
+ return buf.getvalue(), seed
+
+
+@app.on_event("startup")
+def _startup() -> None:
+ global PIPE
+ _ensure_cuda()
+ PIPE = _load_pipe(CHECKPOINT)
+ print("Server ready")
+
+
+@app.get("/", response_class=HTMLResponse)
+def index() -> str:
+ return """
+
+
+
+
+ FLUX.2 Klein Playground
+
+
+
+
+
+
FLUX.2 Klein Playground
+
Private single-user UI at /. Distilled defaults: 4 steps, guidance 1.0.
+
+
+
+
+
Output
+
![\"Generated]()
+
Tip: right-click image to save.
+
+
+
+
+
+
+"""
+
+
+@app.get("/health")
+def health() -> dict[str, str]:
+ return {"status": "ok", "model": CHECKPOINT}
+
+
+@app.post("/generate")
+def generate(req: GenerateRequest) -> Response:
+ width, height = _normalize_dims(req.width, req.height)
+ seed = req.seed if not req.randomize_seed else random.randint(0, MAX_SEED)
+ image = _decode_image_data_url(req.image_data_url)
+ png_bytes, used_seed = _generate_png_bytes(
+ prompt=req.prompt,
+ width=width,
+ height=height,
+ num_inference_steps=req.num_inference_steps,
+ guidance_scale=req.guidance_scale,
+ seed=seed,
+ image=image,
+ )
+ return Response(
+ content=png_bytes,
+ media_type="image/png",
+ headers={"X-Seed": str(used_seed)},
+ )
+
+
+@app.post("/v1/images/generations")
+def compat_generate(req: CompatRequest) -> dict[str, Any]:
+ width, height = _parse_size(req.size, req.width, req.height)
+ seed_value = req.seed if req.seed is not None else 0
+ seed = seed_value if not req.randomize_seed else random.randint(0, MAX_SEED)
+ image = _decode_image_data_url(req.imageDataUrl)
+
+ png_bytes, used_seed = _generate_png_bytes(
+ prompt=req.prompt,
+ width=width,
+ height=height,
+ num_inference_steps=req.num_inference_steps or DEFAULT_STEPS,
+ guidance_scale=req.guidance_scale or DEFAULT_GUIDANCE,
+ seed=seed,
+ image=image,
+ )
+ b64_png = base64.b64encode(png_bytes).decode("ascii")
+ return {
+ "created": int(time.time()),
+ "model": CHECKPOINT,
+ "seed": used_seed,
+ "data": [{"b64_json": b64_png}],
+ }
+
+
+if __name__ == "__main__":
+ import uvicorn
+
+ parser = argparse.ArgumentParser(description="Private FLUX.2 Klein FastAPI server")
+ parser.add_argument("--model", choices=["4b", "9b"], default=MODEL_SIZE)
+ parser.add_argument("--host", default="127.0.0.1")
+ parser.add_argument("--port", type=int, default=6006)
+ args = parser.parse_args()
+
+ MODEL_SIZE = args.model
+ CHECKPOINT = _checkpoint_for(MODEL_SIZE)
+ uvicorn.run(app, host=args.host, port=args.port)
diff --git a/flux_klein_prompt.txt b/flux_klein_prompt.txt
new file mode 100644
index 0000000..93e5997
--- /dev/null
+++ b/flux_klein_prompt.txt
@@ -0,0 +1,30 @@
+Artistic photorealistic conversion.
+Subtle chiaroscuro lighting, not too dark.
+120mm telephoto lens, f/2.8, shallow depth of field.
+Soft-focus highlights, atmospheric bloom.
+Kodak Ektachrome E100 color palette.
+Preserve the original lighting and moody lighting.
+High dynamic range with a focus on rich textures.
+# subtle dramatic lighting with rich lights and shadows, but not too dark.
+balanced exposure
+
+# looking at the camera
+# looking away from the camera
+
+she has flawless, spotless, tight skin. never change the skin color.
+# porcelain gothic pale skin
+raised cheekbones, tapered face, seductive look
+make her prettier without changing the skin color
+age them by 5 years and make them look like 25 year old adults.
+preserve height and proportion while maintaining the same facial features.
+# make her head 10% slimmer.
+
+depict cartoon characters as japanese
+
+slightly parted lips.
+slightly lowered or closed eyelids.
+# shiny outfit unless naked.
+# shiny, glossy outfit.
+keep the heavy makeup.
+
+make it sharp throughout. remove all text.
diff --git a/grammar.html b/grammar.html
new file mode 100644
index 0000000..19a73d0
--- /dev/null
+++ b/grammar.html
@@ -0,0 +1,251 @@
+
+
+
+
+
+ Text Corrector App
+
+
+
+
+
+
+ Loading suggestion...
+
+
+
+
+
Suggestion (Diff View):
+
+
+
+
+
+ Suggested Text (Clean)
+
+
+
+
+
+
+
+
+
+
diff --git a/image_dates.py b/image_dates.py
new file mode 100644
index 0000000..56562d8
--- /dev/null
+++ b/image_dates.py
@@ -0,0 +1,279 @@
+#!/usr/bin/env -S uv run --script
+# /// script
+# dependencies = ["Pillow"]
+# ///
+
+import argparse
+import collections
+import datetime as dt
+import logging
+import os
+import re
+import shutil
+import subprocess
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Iterable
+
+from PIL import Image, UnidentifiedImageError
+
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
+
+
+DATE_PATTERNS = (
+ re.compile(r"^(?P\d{4})-(?P\d{2})-(?P\d{2})"),
+ re.compile(r"^(?P\d{4})\.(?P\d{2})\.(?P\d{2})"),
+ re.compile(r"^(?P\d{4})(?P\d{2})(?P\d{2})"),
+)
+
+EXIF_DATETIME_TAGS = (36867, 36868, 306)
+
+
+@dataclass
+class ImageEntry:
+ path: Path
+ group_key: str
+ filename_date: dt.datetime | None
+ exif_date: dt.datetime | None = None
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Update file timestamps for images grouped by filename pattern",
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ )
+ parser.add_argument(
+ "directory",
+ nargs="?",
+ default=Path.cwd(),
+ type=Path,
+ help="Root directory to scan for image files",
+ )
+ parser.add_argument(
+ "--recursive",
+ action="store_true",
+ help="Recurse into subdirectories",
+ )
+ parser.add_argument(
+ "--extensions",
+ default=".jpg,.jpeg,.png,.tif,.tiff,.heic,.heif",
+ help="Comma-separated list of extensions to include",
+ )
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Show planned changes without touching the filesystem",
+ )
+ parser.add_argument(
+ "--verbose",
+ action="store_true",
+ help="Enable debug logging",
+ )
+ return parser.parse_args()
+
+
+def configure_logging(verbose: bool) -> None:
+ if verbose:
+ logging.getLogger().setLevel(logging.DEBUG)
+
+
+def collect_image_paths(directory: Path, extensions: set[str], recursive: bool) -> Iterable[Path]:
+ iterator = directory.rglob("*") if recursive else directory.glob("*")
+ for candidate in iterator:
+ if candidate.is_file() and candidate.suffix.lower() in extensions:
+ yield candidate
+
+
+def parse_group_and_date(path: Path) -> tuple[str, dt.datetime | None] | None:
+ name = path.stem
+ if " -- " not in name:
+ logging.debug("Skipping %s: missing group delimiter", path)
+ return None
+ try:
+ group_part, rest = name.rsplit(" -- ", 1)
+ except ValueError:
+ logging.debug("Skipping %s: unable to split group and tail", path)
+ return None
+ if not group_part:
+ logging.debug("Skipping %s: empty group part", path)
+ return None
+ date_candidate = extract_date_from_tail(rest)
+ if " -- " not in group_part and "__" in rest:
+ album_part, _, _ = rest.partition("__")
+ album_part = album_part.strip()
+ if album_part:
+ group_part = f"{group_part} -- {album_part}"
+ return group_part, date_candidate
+
+
+def extract_date_from_tail(text: str) -> dt.datetime | None:
+ for pattern in DATE_PATTERNS:
+ match = pattern.match(text)
+ if match:
+ try:
+ return dt.datetime(
+ year=int(match.group("year")),
+ month=int(match.group("month")),
+ day=int(match.group("day")),
+ )
+ except ValueError:
+ return None
+ return None
+
+
+def read_exif_datetime(path: Path) -> dt.datetime | None:
+ try:
+ with Image.open(path) as image:
+ exif = image.getexif()
+ except (UnidentifiedImageError, OSError) as error:
+ logging.debug("Could not read EXIF from %s: %s", path, error)
+ return None
+ if not exif:
+ return None
+ for tag in EXIF_DATETIME_TAGS:
+ value = exif.get(tag)
+ if not value:
+ continue
+ if isinstance(value, bytes):
+ value = value.decode(errors="ignore")
+ if not isinstance(value, str):
+ continue
+ value = value.strip()
+ for fmt in ("%Y:%m:%d %H:%M:%S", "%Y-%m-%d %H:%M:%S"):
+ try:
+ return dt.datetime.strptime(value, fmt)
+ except ValueError:
+ continue
+ return None
+
+
+def most_common_datetime(candidates: list[dt.datetime]) -> dt.datetime | None:
+ if not candidates:
+ return None
+ counter = collections.Counter(candidates)
+ most_common = counter.most_common()
+ top_count = most_common[0][1]
+ top_values = [item for item, count in most_common if count == top_count]
+ return min(top_values)
+
+
+def determine_group_date(entries: list[ImageEntry]) -> dt.datetime | None:
+ name_dates = [entry.filename_date for entry in entries if entry.filename_date]
+ if name_dates:
+ logging.debug("Using filename-derived date for group %s", entries[0].group_key)
+ return most_common_datetime(name_dates)
+ exif_dates: list[dt.datetime] = []
+ for entry in entries:
+ if entry.exif_date is None:
+ entry.exif_date = read_exif_datetime(path=entry.path)
+ if entry.exif_date:
+ exif_dates.append(entry.exif_date)
+ if exif_dates:
+ logging.debug("Using EXIF-derived date for group %s", entries[0].group_key)
+ return most_common_datetime(exif_dates)
+
+
+def apply_timestamp(path: Path, target: dt.datetime, dry_run: bool) -> None:
+ timestamp = target.timestamp()
+ if dry_run:
+ logging.debug("DRY-RUN %s -> %s", path, target.isoformat(sep=" "))
+ return
+ os.utime(path, times=(timestamp, timestamp))
+
+
+def chunked(items: list[str], size: int) -> Iterable[list[str]]:
+ for index in range(0, len(items), size):
+ yield items[index : index + size]
+
+
+def apply_setfile_batch(entries: list[ImageEntry], target: dt.datetime, dry_run: bool) -> None:
+ if sys.platform != "darwin":
+ return
+ setfile = shutil.which("SetFile")
+ if not setfile:
+ return
+ formatted = target.strftime("%m/%d/%Y %H:%M:%S")
+ paths = [str(entry.path) for entry in entries]
+ for batch in chunked(items=paths, size=64):
+ if dry_run:
+ logging.debug("DRY-RUN SetFile %s files -> %s", len(batch), formatted)
+ continue
+ try:
+ subprocess.run([setfile, "-d", formatted, *batch], check=True)
+ subprocess.run([setfile, "-m", formatted, *batch], check=True)
+ except subprocess.CalledProcessError as error:
+ logging.debug("SetFile failed for %s files: %s", len(batch), error)
+
+
+def update_group(entries: list[ImageEntry], dry_run: bool) -> bool:
+ target = determine_group_date(entries=entries)
+ if target is None:
+ logging.warning("No date found for group %s", entries[0].group_key)
+ return False
+ for entry in entries:
+ apply_timestamp(path=entry.path, target=target, dry_run=dry_run)
+ apply_setfile_batch(entries=entries, target=target, dry_run=dry_run)
+ action = "DRY-RUN" if dry_run else "UPDATED"
+ logging.info(
+ "%s %s (%s files) -> %s",
+ action,
+ entries[0].group_key,
+ len(entries),
+ target.isoformat(sep=" "),
+ )
+ return True
+
+
+def process_directory(directory: Path, extensions: set[str], recursive: bool, dry_run: bool) -> None:
+ groups: dict[str, list[ImageEntry]] = collections.defaultdict(list)
+ for path in collect_image_paths(directory=directory, extensions=extensions, recursive=recursive):
+ parsed = parse_group_and_date(path=path)
+ if parsed is None:
+ continue
+ group_key, filename_date = parsed
+ groups[group_key].append(ImageEntry(path=path, group_key=group_key, filename_date=filename_date))
+ updated = 0
+ skipped = 0
+ for entries in groups.values():
+ if update_group(entries=entries, dry_run=dry_run):
+ updated += len(entries)
+ else:
+ skipped += len(entries)
+ logging.info("Updated %s files; skipped %s files", updated, skipped)
+
+
+def normalize_extensions(raw: str) -> set[str]:
+ pieces = re.split(r"[;,]", raw)
+ results: set[str] = set()
+ for piece in pieces:
+ trimmed = piece.strip().lower()
+ if not trimmed:
+ continue
+ if not trimmed.startswith("."):
+ trimmed = f".{trimmed}"
+ results.add(trimmed)
+ return results
+
+
+def main() -> None:
+ args = parse_args()
+ configure_logging(verbose=args.verbose)
+ extensions = normalize_extensions(raw=args.extensions)
+ if not extensions:
+ logging.error("No valid extensions provided")
+ raise SystemExit(1)
+ if not args.directory.exists():
+ logging.error("Directory %s does not exist", args.directory)
+ raise SystemExit(1)
+ process_directory(
+ directory=args.directory,
+ extensions=extensions,
+ recursive=args.recursive,
+ dry_run=args.dry_run,
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/immich_dedupe_albums.py b/immich_dedupe_albums.py
new file mode 100644
index 0000000..a347d98
--- /dev/null
+++ b/immich_dedupe_albums.py
@@ -0,0 +1,105 @@
+import httpx
+import os
+from datetime import datetime
+from collections import defaultdict
+from typing import List, Dict, Any
+
+# Get environment variables
+IMMICH_API_KEY = os.environ.get("IMMICH_API_KEY")
+IMMICH_SERVER_URL = os.environ.get("IMMICH_SERVER_URL")
+
+if not IMMICH_API_KEY or not IMMICH_SERVER_URL:
+ raise ValueError("IMMICH_API_KEY and IMMICH_SERVER_URL environment variables must be set")
+
+# Setup HTTP client with auth headers
+headers = {"Content-Type": "application/json", "Accept": "application/json", "x-api-key": IMMICH_API_KEY}
+
+# Global HTTP client
+client = httpx.Client(headers=headers)
+
+
+def get_albums() -> List[Dict[str, Any]]:
+ """Fetch all albums from Immich API"""
+ response = client.get(f"{IMMICH_SERVER_URL}/api/albums")
+ response.raise_for_status()
+ return response.json()
+
+
+def get_album_assets(album_id: str) -> List[str]:
+ """Fetch all asset IDs from a specific album"""
+ response = client.get(f"{IMMICH_SERVER_URL}/api/albums/{album_id}?withoutAssets=false")
+ response.raise_for_status()
+ album_data = response.json()
+ return [asset["id"] for asset in album_data.get("assets", [])]
+
+
+def add_assets_to_album(album_id: str, asset_ids: List[str]) -> None:
+ """Add assets to an album"""
+ if not asset_ids:
+ return
+
+ response = client.put(f"{IMMICH_SERVER_URL}/api/albums/{album_id}/assets", json={"ids": asset_ids})
+ response.raise_for_status()
+
+
+def delete_album(album_id: str) -> None:
+ """Delete an album"""
+ response = client.delete(f"{IMMICH_SERVER_URL}/api/albums/{album_id}")
+ response.raise_for_status()
+
+
+def deduplicate_albums():
+ """Main function to deduplicate albums"""
+ print("Fetching albums...")
+ albums = get_albums()
+
+ # Group albums by name (case-insensitive)
+ album_groups = defaultdict(list)
+ for album in albums:
+ album_name = album["albumName"].lower().strip()
+ album_groups[album_name].append(album)
+
+ # Process groups with duplicates
+ for album_name, album_list in album_groups.items():
+ if len(album_list) <= 1:
+ continue # Skip groups with only one album
+
+ print(f"\nProcessing duplicate albums for: '{album_name}' ({len(album_list)} albums)")
+
+ # Sort by creation date to find the oldest
+ album_list.sort(key=lambda x: datetime.fromisoformat(x["createdAt"].replace("Z", "+00:00")))
+ oldest_album = album_list[0]
+ duplicate_albums = album_list[1:]
+
+ print(f" Oldest album: {oldest_album['id']} (created: {oldest_album['createdAt']})")
+
+ # Collect assets from duplicate albums
+ all_asset_ids = []
+ for dup_album in duplicate_albums:
+ print(f" Processing duplicate: {dup_album['id']} (created: {dup_album['createdAt']})")
+ asset_ids = get_album_assets(dup_album["id"])
+ all_asset_ids.extend(asset_ids)
+ print(f" Found {len(asset_ids)} assets")
+
+ # Add assets to oldest album
+ if all_asset_ids:
+ print(f" Adding {len(all_asset_ids)} assets to oldest album...")
+ add_assets_to_album(oldest_album["id"], all_asset_ids)
+
+ # Delete duplicate albums
+ for dup_album in duplicate_albums:
+ print(f" Deleting duplicate album: {dup_album['id']}")
+ delete_album(dup_album["id"])
+
+ print(f" ✓ Merged {len(duplicate_albums)} duplicate albums into {oldest_album['id']}")
+
+
+if __name__ == "__main__":
+ try:
+ deduplicate_albums()
+ print("\n✓ Album deduplication completed successfully!")
+ except Exception as e:
+ print(f"\n✗ Error: {e}")
+ raise
+ finally:
+ client.close()
diff --git a/install_backup_service.py b/install_backup_service.py
new file mode 100644
index 0000000..cf8c9a0
--- /dev/null
+++ b/install_backup_service.py
@@ -0,0 +1,78 @@
+#!/usr/bin/env -S uv run --script
+# /// script
+# dependencies = []
+# ///
+
+import argparse
+import logging
+import subprocess
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
+logger = logging.getLogger(__name__)
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Install cron job")
+ parser.add_argument("--name", required=True, help="Name for the cron job")
+ parser.add_argument("command", nargs=argparse.REMAINDER, help="Command to run")
+ return parser.parse_args()
+
+
+def get_current_crontab() -> str:
+ try:
+ result = subprocess.run(["crontab", "-l"], capture_output=True, text=True, check=True)
+ return result.stdout
+ except subprocess.CalledProcessError:
+ return ""
+
+
+def create_cron_entry(name: str, command: list) -> str:
+ command_str = " ".join(command)
+ return f"0 0 * * * {command_str}"
+
+
+def job_exists(name: str, current_crontab: str) -> bool:
+ comment = f"# {name}"
+ return comment in current_crontab
+
+
+def install_cron_job(name: str, command: list) -> None:
+ current_crontab = get_current_crontab()
+
+ if job_exists(name, current_crontab):
+ logger.info(f"Cron job '{name}' already exists")
+ return
+
+ cron_entry = create_cron_entry(name, command)
+ job_comment = f"# {name}"
+ new_crontab = current_crontab.rstrip()
+
+ if new_crontab and not new_crontab.endswith("\n"):
+ new_crontab += "\n"
+
+ new_crontab += f"{job_comment}\n{cron_entry}\n"
+
+ process = subprocess.Popen(["crontab", "-"], stdin=subprocess.PIPE, text=True)
+ process.communicate(input=new_crontab)
+
+ if process.returncode != 0:
+ raise RuntimeError("Failed to install cron job")
+
+ logger.info(f"Cron job '{name}' installed successfully")
+
+
+def main():
+ args = parse_args()
+
+ if not args.command:
+ raise ValueError("Command is required")
+
+ logger.info(f"Installing cron job '{args.name}' with command: {' '.join(args.command)}")
+ install_cron_job(args.name, args.command)
+
+ logger.info("Installation completed successfully")
+ logger.info("Check cron jobs with: crontab -l")
+
+
+if __name__ == "__main__":
+ exit(main())
diff --git a/prompter/prompter.html b/prompter/prompter.html
new file mode 100644
index 0000000..d0e9c9a
--- /dev/null
+++ b/prompter/prompter.html
@@ -0,0 +1,523 @@
+
+
+
+
+
+ Prompter
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+
+
+
+
+
+
Prompt
+
+
+
+
+ alt+1
+ Custom prompt
+ ✓ saved
+
+
+
+
+
+
+
+
Previously used
+
+
+
+
+
+
+
+
+
Building blocks
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/prompter/prompter.py b/prompter/prompter.py
new file mode 100755
index 0000000..a04d9b3
--- /dev/null
+++ b/prompter/prompter.py
@@ -0,0 +1,205 @@
+#!/usr/bin/env -S uv run --script
+# /// script
+# requires-python = ">=3.11"
+# dependencies = [
+# "bottle",
+# ]
+# ///
+"""Browse images one-by-one and assign text prompts via a local web UI.
+
+Saves results to a JSONL file: {"filename": "...", "prompt": "..."}
+Images navigated past without a prompt produce no output line.
+"""
+
+import argparse
+import json
+import socket
+import sys
+import threading
+import time
+import webbrowser
+from pathlib import Path
+
+import bottle
+
+# ---------------------------------------------------------------------------
+# Configuration
+# ---------------------------------------------------------------------------
+
+CANNED_PROMPTS: list[str] = [
+ "photorealistic. skin must be flawless",
+]
+"""Pre-populated prompt history shown in the UI on first launch."""
+
+BUILDING_BLOCKS: list[str] = [
+ "make her prettier",
+ "age her by 5 years and make her look like a 25 year old",
+ "keep the dark skin",
+ "wet skin",
+ "skin covered with baby oil, shiny skin",
+ "soaking wet skin and hair, wet clothes clinging to her body",
+ "long, flowy hair",
+ "japanese",
+ "greek",
+ "no extra or missing fingers. each hand must have 5 fingers with the same hand pose as the original image",
+ # expression
+ "direct her gaze at the camera",
+ "give her a serious and seductive look",
+ "give her a playful expression",
+ "slightly parted lips",
+ "french kiss, eyes closed, tongue out",
+ "closed eyes",
+ "lower her eyelids and slightly part her lips in a seductive fashion",
+ "exaggerated expression",
+ "soft dramatic lighting",
+ # outfit
+ "replace the outfit with glossy latex",
+ "remove the tan lines, she doesn't wear white bra",
+]
+"""Palette of text snippets the user can append to any prompt."""
+
+VALID_EXTENSIONS: frozenset[str] = frozenset({".webp", ".jpg", ".jpeg", ".png"})
+
+HTML: str = (Path(__file__).parent / "prompter.html").read_text(encoding="utf-8")
+
+# ---------------------------------------------------------------------------
+# App
+# ---------------------------------------------------------------------------
+
+
+def find_free_port() -> int:
+ """Bind to port 0 and return the OS-assigned port number."""
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.bind(("127.0.0.1", 0))
+ return s.getsockname()[1]
+
+
+class PrompterApp:
+ def __init__(self, images: list[str], output_path: Path) -> None:
+ self.images = images
+ self.output_path = output_path
+ self.app = bottle.Bottle()
+ self.app.route("/")(self.index)
+ self.app.route("/api/images")(self.api_images)
+ self.app.route("/image/")(self.serve_image)
+ self.app.route("/api/save", method="POST")(self.api_save)
+
+ def index(self) -> str:
+ return HTML
+
+ def api_images(self) -> str:
+ """Return image list, canned prompts, building blocks, and existing prompts."""
+ stem_to_path: dict[str, str] = {Path(p).stem: p for p in self.images}
+ existing_by_stem = self._load_existing_prompts()
+ existing_prompts = {
+ stem_to_path[stem]: prompt
+ for stem, prompt in existing_by_stem.items()
+ if stem in stem_to_path
+ }
+ bottle.response.content_type = "application/json"
+ return json.dumps(
+ {
+ "images": self.images,
+ "canned_prompts": CANNED_PROMPTS,
+ "building_blocks": BUILDING_BLOCKS,
+ "existing_prompts": existing_prompts,
+ }
+ )
+
+ def serve_image(self, idx: int) -> bottle.HTTPResponse:
+ if idx < 0 or idx >= len(self.images):
+ bottle.abort(404, "Image not found")
+ path = Path(self.images[idx])
+ return bottle.static_file(path.name, root=str(path.parent))
+
+ def api_save(self) -> str:
+ data: dict = bottle.request.json or {}
+ file_path: str = data.get("file_path", "")
+ prompt: str = data.get("prompt", "")
+ if file_path and prompt:
+ self._save_entry(file_path, prompt)
+ bottle.response.content_type = "application/json"
+ return json.dumps({"ok": True})
+
+ def _load_existing_prompts(self) -> dict[str, str]:
+ """Read the output JSONL and return a mapping of stem → prompt.
+
+ When a filename appears multiple times the last entry wins, so re-running
+ the tool and overwriting a previous prompt works naturally.
+ """
+ if not self.output_path.exists():
+ return {}
+ prompts: dict[str, str] = {}
+ with open(self.output_path, encoding="utf-8") as f:
+ for line in f:
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ entry = json.loads(line)
+ if "filename" in entry and "prompt" in entry:
+ prompts[entry["filename"]] = entry["prompt"]
+ except json.JSONDecodeError:
+ pass
+ return prompts
+
+ def _save_entry(self, file_path: str, prompt: str) -> None:
+ """Append one {filename, prompt} record to the JSONL output file."""
+ with open(self.output_path, "a", encoding="utf-8") as f:
+ f.write(
+ json.dumps({"filename": Path(file_path).stem, "prompt": prompt}) + "\n"
+ )
+
+ def run(self, port: int) -> None:
+ bottle.run(self.app, host="localhost", port=port, quiet=True)
+
+
+# ---------------------------------------------------------------------------
+# Entry point
+# ---------------------------------------------------------------------------
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(
+ description="Browse images and assign text prompts via a web UI.",
+ )
+ parser.add_argument("images", nargs="+", help="Image paths to browse")
+ parser.add_argument(
+ "-o",
+ "--output-path",
+ default="prompts.jsonl",
+ metavar="FILE",
+ help="Output JSONL file path (default: prompts.jsonl)",
+ )
+ args = parser.parse_args()
+
+ images = [
+ str(Path(p).resolve())
+ for p in args.images
+ if Path(p).suffix.lower() in VALID_EXTENSIONS
+ ]
+
+ if not images:
+ exts = ", ".join(sorted(VALID_EXTENSIONS))
+ print(
+ f"Error: no valid images found. Expected extensions: {exts}",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+
+ print(f"Loaded {len(images)} image(s).")
+
+ port = find_free_port()
+ url = f"http://localhost:{port}"
+ print(f"Starting server at {url}")
+
+ threading.Thread(
+ target=lambda: (time.sleep(0.8), webbrowser.open(url)),
+ daemon=True,
+ ).start()
+
+ PrompterApp(images, Path(args.output_path)).run(port)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/pullio.py b/pullio.py
new file mode 100644
index 0000000..bd41f40
--- /dev/null
+++ b/pullio.py
@@ -0,0 +1,681 @@
+#!/usr/bin/env python3
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import logging
+import os
+import shlex
+import shutil
+import subprocess
+import sys
+from concurrent.futures import ThreadPoolExecutor
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from pathlib import Path
+from urllib import error, request
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
+LOGGER = logging.getLogger("pullio")
+LABEL_PREFIX = "org.hotio.pullio"
+
+
+@dataclass(frozen=True)
+class Config:
+ compose_binary: str
+ docker_binary: str
+ cache_location: Path
+ tag: str
+ parallel: int
+ compose_type: str
+ script_hash: str
+ telegram_bot_token: str
+ telegram_chat_id: str
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Pull and optionally update Docker Compose containers based on labels.",
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ )
+ parser.add_argument("--tag", default="")
+ parser.add_argument("--debug", action="store_true")
+ parser.add_argument("--parallel", type=int, default=1)
+ args = parser.parse_args()
+ if args.parallel < 1:
+ parser.error("--parallel must be >= 1")
+ return args
+
+
+def run_command(
+ command: list[str],
+ *,
+ check: bool = True,
+ env: dict[str, str] | None = None,
+ input_text: str | None = None,
+) -> str:
+ completed = subprocess.run(
+ command,
+ check=check,
+ capture_output=True,
+ text=True,
+ env=env,
+ input=input_text,
+ )
+ return completed.stdout.strip()
+
+
+def detect_compose_type(compose_binary: str, docker_binary: str) -> str:
+ if compose_binary:
+ return "V1"
+ if docker_binary:
+ try:
+ run_command([docker_binary, "compose", "version"])
+ return "V2"
+ except subprocess.CalledProcessError:
+ return "NONE"
+ return "NONE"
+
+
+def docker_inspect_value(docker_binary: str, name: str, template: str) -> str:
+ try:
+ value = run_command([docker_binary, "inspect", f"--format={template}", name])
+ except subprocess.CalledProcessError:
+ return ""
+ if value == "":
+ return ""
+ return value
+
+
+def docker_image_inspect_value(docker_binary: str, image: str, template: str) -> str:
+ try:
+ value = run_command(
+ [docker_binary, "image", "inspect", f"--format={template}", image]
+ )
+ except subprocess.CalledProcessError:
+ return ""
+ if value == "":
+ return ""
+ return value
+
+
+def compose_pull(config: Config, workdir: str, service: str) -> bool:
+ if config.compose_type == "V1":
+ cmd = [config.compose_binary, "pull", service]
+ elif config.compose_type == "V2":
+ cmd = [config.docker_binary, "compose", "pull", service]
+ elif config.docker_binary:
+ cmd = [
+ config.docker_binary,
+ "run",
+ "--rm",
+ "-v",
+ "/var/run/docker.sock:/var/run/docker.sock",
+ "-v",
+ f"{workdir}:{workdir}",
+ f"-w={workdir}",
+ "linuxserver/docker-compose",
+ "pull",
+ service,
+ ]
+ else:
+ LOGGER.error(
+ "Neither Docker Compose nor Docker binary is available. Cannot pull."
+ )
+ return False
+
+ try:
+ run_command(cmd)
+ return True
+ except subprocess.CalledProcessError:
+ return False
+
+
+def compose_up(config: Config, workdir: str, service: str) -> bool:
+ if config.compose_type == "V1":
+ cmd = [config.compose_binary, "up", "-d", "--always-recreate-deps", service]
+ elif config.compose_type == "V2":
+ cmd = [
+ config.docker_binary,
+ "compose",
+ "up",
+ "-d",
+ "--always-recreate-deps",
+ service,
+ ]
+ elif config.docker_binary:
+ cmd = [
+ config.docker_binary,
+ "run",
+ "--rm",
+ "-v",
+ "/var/run/docker.sock:/var/run/docker.sock",
+ "-v",
+ f"{workdir}:{workdir}",
+ f"-w={workdir}",
+ "linuxserver/docker-compose",
+ "up",
+ "-d",
+ "--always-recreate-deps",
+ service,
+ ]
+ else:
+ LOGGER.error(
+ "Neither Docker Compose nor Docker binary is available. Cannot bring up services."
+ )
+ return False
+
+ try:
+ run_command(cmd)
+ return True
+ except subprocess.CalledProcessError:
+ return False
+
+
+def post_json(url: str, payload: dict[str, object]) -> None:
+ body = json.dumps(payload).encode("utf-8")
+ req = request.Request(
+ url=url,
+ data=body,
+ headers={
+ "User-Agent": "Pullio",
+ "Content-Type": "application/json",
+ },
+ method="POST",
+ )
+ try:
+ with request.urlopen(req) as response:
+ response.read()
+ except (error.HTTPError, error.URLError) as exc:
+ LOGGER.warning("Webhook request failed: %s", exc)
+
+
+def now_iso_utc() -> str:
+ return (
+ datetime.now(timezone.utc)
+ .isoformat(timespec="milliseconds")
+ .replace("+00:00", "Z")
+ )
+
+
+def parse_script_command(value: str) -> list[str]:
+ return shlex.split(value) if value else []
+
+
+def prepare_script_env(
+ *,
+ container: str,
+ image: str,
+ avatar: str,
+ old_image_id: str,
+ new_image_id: str,
+ old_version: str,
+ new_version: str,
+ old_revision: str,
+ new_revision: str,
+ compose_service: str,
+ compose_workdir: str,
+ author_url: str,
+) -> dict[str, str]:
+ env = os.environ.copy()
+ env.update(
+ {
+ "PULLIO_CONTAINER": container,
+ "PULLIO_IMAGE": image,
+ "PULLIO_AVATAR": avatar,
+ "PULLIO_OLD_IMAGE_ID": old_image_id,
+ "PULLIO_NEW_IMAGE_ID": new_image_id,
+ "PULLIO_OLD_VERSION": old_version,
+ "PULLIO_NEW_VERSION": new_version,
+ "PULLIO_OLD_REVISION": old_revision,
+ "PULLIO_NEW_REVISION": new_revision,
+ "PULLIO_COMPOSE_SERVICE": compose_service,
+ "PULLIO_COMPOSE_WORKDIR": compose_workdir,
+ "PULLIO_AUTHOR_URL": author_url,
+ }
+ )
+ return env
+
+
+def send_telegram_notification(
+ *,
+ status: str,
+ container_name: str,
+ old_version: str,
+ new_version: str,
+ image_name: str,
+ bot_token: str,
+ chat_id: str,
+ old_revision: str,
+ new_revision: str,
+ old_image_id: str,
+ new_image_id: str,
+ color: int,
+ author_avatar: str,
+ author_url: str,
+) -> None:
+ version_indicator = "=" if old_version == new_version else ">"
+ revision_indicator = "=" if old_revision == new_revision else ">"
+ digest_indicator = "=" if old_image_id == new_image_id else ">"
+
+ lines = [
+ f"{container_name}",
+ status.replace("\\n", " "),
+ f"Image: {image_name}",
+ f"Image ID: {old_image_id[:11]} {digest_indicator} {new_image_id[:11]}",
+ ]
+ if old_version and new_version:
+ lines.append(f"Version: {old_version} {version_indicator} {new_version}")
+ if old_revision and new_revision:
+ lines.append(
+ f"Revision: {old_revision[:6]} {revision_indicator} {new_revision[:6]}"
+ )
+ if author_url:
+ lines.append(f"URL: {author_url}")
+ if author_avatar:
+ lines.append(f"Avatar: {author_avatar}")
+ lines.append(f"Color: {color}")
+ lines.append(f"Time: {now_iso_utc()}")
+
+ payload = {
+ "chat_id": chat_id,
+ "text": "\n".join(lines),
+ "disable_web_page_preview": True,
+ }
+ post_json(f"https://api.telegram.org/bot{bot_token}/sendMessage", payload)
+
+
+def send_generic_webhook(
+ *,
+ status_generic: str,
+ container_name: str,
+ old_version: str,
+ new_version: str,
+ image_name: str,
+ webhook: str,
+ old_revision: str,
+ new_revision: str,
+ old_image_id: str,
+ new_image_id: str,
+ avatar: str,
+ author_url: str,
+) -> None:
+ payload = {
+ "container": container_name,
+ "image": image_name,
+ "avatar": avatar,
+ "old_image_id": old_image_id,
+ "new_image_id": new_image_id,
+ "old_version": old_version,
+ "new_version": new_version,
+ "old_revision": old_revision,
+ "new_revision": new_revision,
+ "type": status_generic,
+ "url": author_url,
+ "timestamp": now_iso_utc(),
+ }
+ post_json(webhook, payload)
+
+
+def process_container(config: Config, container_name: str) -> None:
+ LOGGER.info("%s: Checking...", container_name)
+
+ image_name = docker_inspect_value(
+ config.docker_binary, container_name, "{{.Config.Image}}"
+ )
+ container_image_digest = docker_inspect_value(
+ config.docker_binary, container_name, "{{.Image}}"
+ )
+
+ docker_compose_service = docker_inspect_value(
+ config.docker_binary,
+ container_name,
+ '{{ index .Config.Labels "com.docker.compose.service" }}',
+ )
+ docker_compose_version = docker_inspect_value(
+ config.docker_binary,
+ container_name,
+ '{{ index .Config.Labels "com.docker.compose.version" }}',
+ )
+ docker_compose_workdir = docker_inspect_value(
+ config.docker_binary,
+ container_name,
+ '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}',
+ )
+
+ old_version = docker_inspect_value(
+ config.docker_binary,
+ container_name,
+ '{{ index .Config.Labels "org.opencontainers.image.version" }}',
+ )
+ old_revision = docker_inspect_value(
+ config.docker_binary,
+ container_name,
+ '{{ index .Config.Labels "org.opencontainers.image.revision" }}',
+ )
+
+ pullio_update = docker_inspect_value(
+ config.docker_binary,
+ container_name,
+ f'{{{{ index .Config.Labels "{LABEL_PREFIX}{config.tag}.update" }}}}',
+ )
+ pullio_notify = docker_inspect_value(
+ config.docker_binary,
+ container_name,
+ f'{{{{ index .Config.Labels "{LABEL_PREFIX}{config.tag}.notify" }}}}',
+ )
+ pullio_telegram_bot_token = docker_inspect_value(
+ config.docker_binary,
+ container_name,
+ f'{{{{ index .Config.Labels "{LABEL_PREFIX}{config.tag}.telegram.bot_token" }}}}',
+ )
+ pullio_telegram_chat_id = docker_inspect_value(
+ config.docker_binary,
+ container_name,
+ f'{{{{ index .Config.Labels "{LABEL_PREFIX}{config.tag}.telegram.chat_id" }}}}',
+ )
+ pullio_generic_webhook = docker_inspect_value(
+ config.docker_binary,
+ container_name,
+ f'{{{{ index .Config.Labels "{LABEL_PREFIX}{config.tag}.generic.webhook" }}}}',
+ )
+ pullio_script_update = parse_script_command(
+ docker_inspect_value(
+ config.docker_binary,
+ container_name,
+ f'{{{{ index .Config.Labels "{LABEL_PREFIX}{config.tag}.script.update" }}}}',
+ )
+ )
+ pullio_script_notify = parse_script_command(
+ docker_inspect_value(
+ config.docker_binary,
+ container_name,
+ f'{{{{ index .Config.Labels "{LABEL_PREFIX}{config.tag}.script.notify" }}}}',
+ )
+ )
+ pullio_registry_authfile = docker_inspect_value(
+ config.docker_binary,
+ container_name,
+ f'{{{{ index .Config.Labels "{LABEL_PREFIX}{config.tag}.registry.authfile" }}}}',
+ )
+ pullio_author_avatar = docker_inspect_value(
+ config.docker_binary,
+ container_name,
+ f'{{{{ index .Config.Labels "{LABEL_PREFIX}{config.tag}.author.avatar" }}}}',
+ )
+ pullio_author_url = docker_inspect_value(
+ config.docker_binary,
+ container_name,
+ f'{{{{ index .Config.Labels "{LABEL_PREFIX}{config.tag}.author.url" }}}}',
+ )
+
+ if not docker_compose_version or (
+ pullio_update != "true" and pullio_notify != "true"
+ ):
+ return
+
+ if pullio_registry_authfile and Path(pullio_registry_authfile).is_file():
+ LOGGER.info("%s: Registry login...", container_name)
+ try:
+ auth = json.loads(
+ Path(pullio_registry_authfile).read_text(encoding="utf-8")
+ )
+ run_command(
+ [
+ config.docker_binary,
+ "login",
+ "--username",
+ str(auth.get("username", "")),
+ "--password-stdin",
+ str(auth.get("registry", "")),
+ ],
+ input_text=str(auth.get("password", "")),
+ )
+ except (json.JSONDecodeError, OSError, subprocess.CalledProcessError) as exc:
+ LOGGER.warning("%s: Registry login failed: %s", container_name, exc)
+
+ LOGGER.info("%s: Pulling image...", container_name)
+ if not compose_pull(config, docker_compose_workdir, docker_compose_service):
+ LOGGER.error("%s: Pulling failed!", container_name)
+
+ image_digest = docker_image_inspect_value(config.docker_binary, image_name, "{{.Id}}")
+ new_version = docker_image_inspect_value(
+ config.docker_binary,
+ image_name,
+ '{{ index .Config.Labels "org.opencontainers.image.version" }}',
+ )
+ new_revision = docker_image_inspect_value(
+ config.docker_binary,
+ image_name,
+ '{{ index .Config.Labels "org.opencontainers.image.revision" }}',
+ )
+
+ status = "I've got an update waiting for me.\nGive it to me, please."
+ status_generic = "update_available"
+ color = 768753
+
+ if image_digest != container_image_digest and pullio_update == "true":
+ script_env = prepare_script_env(
+ container=container_name,
+ image=image_name,
+ avatar=pullio_author_avatar,
+ old_image_id=container_image_digest.removeprefix("sha256:"),
+ new_image_id=image_digest.removeprefix("sha256:"),
+ old_version=old_version,
+ new_version=new_version,
+ old_revision=old_revision,
+ new_revision=new_revision,
+ compose_service=docker_compose_service,
+ compose_workdir=docker_compose_workdir,
+ author_url=pullio_author_url,
+ )
+ if pullio_script_update:
+ LOGGER.info("%s: Stopping container...", container_name)
+ try:
+ run_command([config.docker_binary, "stop", container_name])
+ except subprocess.CalledProcessError:
+ LOGGER.warning(
+ "%s: Failed to stop container before update script.", container_name
+ )
+ LOGGER.info("%s: Executing update script...", container_name)
+ try:
+ subprocess.run(pullio_script_update, env=script_env, check=False)
+ except OSError as exc:
+ LOGGER.warning(
+ "%s: Update script failed to start: %s", container_name, exc
+ )
+
+ LOGGER.info("%s: Updating container...", container_name)
+ if compose_up(config, docker_compose_workdir, docker_compose_service):
+ status = "I just updated myself.\nFeeling brand spanking new again!"
+ status_generic = "update_success"
+ color = 3066993
+ else:
+ LOGGER.error("%s: Updating container failed!", container_name)
+ status = (
+ "I tried to update myself.\nIt didn't work out, I might need some help."
+ )
+ status_generic = "update_failure"
+ color = 15158332
+
+ notified_path = (
+ config.cache_location / f"{config.script_hash}-{container_name}.notified"
+ )
+ try:
+ notified_path.unlink(missing_ok=True)
+ except OSError:
+ LOGGER.warning("%s: Failed to clear notify cache file.", container_name)
+
+ if image_digest != container_image_digest and pullio_notify == "true":
+ notified_path = (
+ config.cache_location / f"{config.script_hash}-{container_name}.notified"
+ )
+ try:
+ notified_path.touch(exist_ok=True)
+ notified_digest = notified_path.read_text(encoding="utf-8").strip()
+ except OSError:
+ notified_digest = ""
+
+ if notified_digest != image_digest:
+ script_env = prepare_script_env(
+ container=container_name,
+ image=image_name,
+ avatar=pullio_author_avatar,
+ old_image_id=container_image_digest.removeprefix("sha256:"),
+ new_image_id=image_digest.removeprefix("sha256:"),
+ old_version=old_version,
+ new_version=new_version,
+ old_revision=old_revision,
+ new_revision=new_revision,
+ compose_service=docker_compose_service,
+ compose_workdir=docker_compose_workdir,
+ author_url=pullio_author_url,
+ )
+ if pullio_script_notify:
+ LOGGER.info("%s: Executing notify script...", container_name)
+ try:
+ subprocess.run(pullio_script_notify, env=script_env, check=False)
+ except OSError as exc:
+ LOGGER.warning(
+ "%s: Notify script failed to start: %s", container_name, exc
+ )
+
+ old_digest_short = container_image_digest.removeprefix("sha256:")
+ new_digest_short = image_digest.removeprefix("sha256:")
+
+ effective_telegram_bot_token = (
+ pullio_telegram_bot_token or config.telegram_bot_token
+ )
+ effective_telegram_chat_id = (
+ pullio_telegram_chat_id or config.telegram_chat_id
+ )
+
+ if effective_telegram_bot_token and effective_telegram_chat_id:
+ LOGGER.info("%s: Sending telegram notification...", container_name)
+ send_telegram_notification(
+ status=status,
+ container_name=container_name,
+ old_version=old_version,
+ new_version=new_version,
+ image_name=image_name,
+ bot_token=effective_telegram_bot_token,
+ chat_id=effective_telegram_chat_id,
+ old_revision=old_revision,
+ new_revision=new_revision,
+ old_image_id=old_digest_short,
+ new_image_id=new_digest_short,
+ color=color,
+ author_avatar=pullio_author_avatar,
+ author_url=pullio_author_url,
+ )
+
+ if pullio_generic_webhook:
+ LOGGER.info("%s: Sending generic webhook...", container_name)
+ send_generic_webhook(
+ status_generic=status_generic,
+ container_name=container_name,
+ old_version=old_version,
+ new_version=new_version,
+ image_name=image_name,
+ webhook=pullio_generic_webhook,
+ old_revision=old_revision,
+ new_revision=new_revision,
+ old_image_id=old_digest_short,
+ new_image_id=new_digest_short,
+ avatar=pullio_author_avatar,
+ author_url=pullio_author_url,
+ )
+
+ try:
+ notified_path.write_text(image_digest, encoding="utf-8")
+ except OSError:
+ LOGGER.warning("%s: Failed to write notify cache file.", container_name)
+
+
+def main() -> int:
+ args = parse_args()
+
+ if args.debug:
+ logging.getLogger().setLevel(logging.DEBUG)
+
+ compose_binary = os.getenv("COMPOSE_BINARY") or (
+ shutil.which("docker-compose") or ""
+ )
+ docker_binary = os.getenv("DOCKER_BINARY") or (shutil.which("docker") or "")
+ telegram_bot_token = os.getenv("TELEGRAM_BOT_TOKEN", "")
+ telegram_chat_id = os.getenv("TELEGRAM_CHAT_ID", "")
+ cache_location = Path("/tmp")
+
+ tag = f".{args.tag}" if args.tag else ""
+ compose_type = detect_compose_type(compose_binary, docker_binary)
+
+ if not docker_binary:
+ LOGGER.error("Docker binary not found.")
+ return 1
+
+ try:
+ script_hash = hashlib.sha1(Path(__file__).read_bytes()).hexdigest()
+ except OSError:
+ script_hash = hashlib.sha1(
+ str(Path(__file__).resolve()).encode("utf-8")
+ ).hexdigest()
+
+ config = Config(
+ compose_binary=compose_binary,
+ docker_binary=docker_binary,
+ cache_location=cache_location,
+ tag=tag,
+ parallel=args.parallel,
+ compose_type=compose_type,
+ script_hash=script_hash,
+ telegram_bot_token=telegram_bot_token,
+ telegram_chat_id=telegram_chat_id,
+ )
+
+ LOGGER.info(
+ 'Running with "DEBUG=%s", "TAG=%s", and "PARALLEL=%s".',
+ args.debug,
+ tag,
+ args.parallel,
+ )
+
+ try:
+ raw_containers = run_command([docker_binary, "ps", "--format", "{{.Names}}"])
+ except subprocess.CalledProcessError as exc:
+ LOGGER.error("Failed to list running containers: %s", exc)
+ return 1
+
+ containers = sorted([line for line in raw_containers.splitlines() if line])
+ LOGGER.info(
+ "Processing %s containers with parallelism of %s",
+ len(containers),
+ args.parallel,
+ )
+
+ try:
+ if args.parallel > 1:
+ with ThreadPoolExecutor(max_workers=args.parallel) as executor:
+ list(
+ executor.map(
+ lambda name: process_container(config, name), containers
+ )
+ )
+ else:
+ for container_name in containers:
+ process_container(config, container_name)
+ except KeyboardInterrupt:
+ return 130
+
+ LOGGER.info("Pruning docker images...")
+ try:
+ run_command([docker_binary, "image", "prune", "--force"])
+ except subprocess.CalledProcessError as exc:
+ LOGGER.warning("Image prune failed: %s", exc)
+
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..3001401
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,24 @@
+[project]
+name = "playground"
+version = "0.0.1"
+requires-python = ">=3.13"
+dependencies = [
+ "browser-cookie3>=0.20.1",
+ "bs4>=0.0.2",
+ "click>=8.3.0",
+ "cryptography>=46.0.3",
+ "debugpy>=1.8.15",
+ "httpx>=0.28.1",
+ "keyring>=25.6.0",
+ "mistralai>=1.5.1",
+ "openai>=1.98.0",
+ "pdf2image>=1.17.0",
+ "piexif>=1.1.3",
+ "playwright-stealth>=2.0.0",
+ "playwright[chromium]>=1.55.0",
+ "psycopg[binary]>=3.3.4",
+ "pytest>=9.1.1",
+ "srt>=3.5.3",
+ "typer-slim>=0.19.2",
+ "watchdog>=6.0.0",
+]
diff --git a/reset_router.py b/reset_router.py
new file mode 100755
index 0000000..d063af1
--- /dev/null
+++ b/reset_router.py
@@ -0,0 +1,97 @@
+#!/usr/bin/env -S uv run --script
+# /// script
+# dependencies = [
+# "playwright>=1.45.0",
+# ]
+# ///
+
+from __future__ import annotations
+
+import argparse
+import sys
+import time
+
+from playwright.sync_api import Playwright, TimeoutError as PlaywrightTimeoutError, sync_playwright
+
+ROUTER_URL = "http://192.168.1.1"
+ROUTER_USERNAME = "abdus"
+ROUTER_PASSWORD = "xAsametk50"
+
+
+def reboot_router(playwright: Playwright, headed: bool, timeout_ms: int) -> None:
+ browser = playwright.chromium.launch(headless=not headed)
+ context = browser.new_context(ignore_https_errors=True)
+ page = context.new_page()
+ page.set_default_timeout(timeout_ms)
+
+ try:
+ page.goto(f"{ROUTER_URL}/login", wait_until="domcontentloaded")
+
+ username_box = page.get_by_role("textbox", name="User Name")
+ password_box = page.get_by_role("textbox", name="Password")
+
+ username_box.click()
+ username_box.press("ControlOrMeta+a")
+ username_box.press("Backspace")
+ username_box.type(ROUTER_USERNAME, delay=80)
+
+ password_box.click()
+ password_box.press("ControlOrMeta+a")
+ password_box.press("Backspace")
+ password_box.type(ROUTER_PASSWORD, delay=140)
+ page.get_by_role("textbox", name="Password").press("Enter")
+
+ page.wait_for_load_state("domcontentloaded")
+
+ try:
+ page.get_by_text("The username or password is not correct", exact=False).wait_for(
+ state="visible", timeout=1500
+ )
+ raise RuntimeError("Router rejected configured credentials")
+ except PlaywrightTimeoutError:
+ pass
+
+ menu_trigger = page.locator("#h_menu_list").first
+ if menu_trigger.count() and menu_trigger.is_visible():
+ menu_trigger.click()
+
+ restart_icon = page.locator("#navbar_reboot .icon-menu-restart, #navbar_reboot").first
+ restart_icon.wait_for(state="visible")
+
+ with page.expect_response(lambda r: "/cgi-bin/Reboot" in r.url, timeout=timeout_ms):
+ restart_icon.click()
+ page.get_by_role("button", name="OK").click()
+
+ time.sleep(5)
+
+ print("Router reboot request sent.")
+ finally:
+ context.close()
+ browser.close()
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Log in and reboot router")
+ parser.add_argument("--headed", action="store_true", help="Run with visible browser")
+ parser.add_argument(
+ "--timeout",
+ type=int,
+ default=60000,
+ help="Timeout in milliseconds (default: 60000)",
+ )
+ return parser.parse_args()
+
+
+def main() -> int:
+ args = parse_args()
+ try:
+ with sync_playwright() as playwright:
+ reboot_router(playwright, headed=args.headed, timeout_ms=args.timeout)
+ return 0
+ except Exception as exc:
+ print(f"Error: {exc}", file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/reset_router_http.py b/reset_router_http.py
new file mode 100644
index 0000000..d04fb55
--- /dev/null
+++ b/reset_router_http.py
@@ -0,0 +1,84 @@
+#!/usr/bin/env -S uv run --script
+# /// script
+# dependencies = [
+# "httpx>=0.27.0",
+# ]
+# ///
+
+import argparse
+import time
+
+import httpx
+
+ROUTER_BASE_URL = "http://192.168.1.1"
+
+# Captured from successful login flow for abdus:xAsametk50.
+# Firmware expects encrypted payload at /UserLogin.
+USER_LOGIN_PAYLOAD = {
+ "content": "z7oqX7AWMrPjJdAEgrXCZY9q/+mrsyodlD53apbPmPXBkuB1SJXdoXr8+NcUbuRwn/jyc3HJlyKcMrAHDl3aFbN8/Gnyy8TzRKQkCAoSdVEVifzmWBPgpoDKvvT/p6n4TrAi2+b1eJ18Sr4ab1MWrI0XKNbBLEU+0UJlAd/UOHo=",
+ "key": "UwLW2sfQZ5S5XegolmSX0kqpxwzeJEIXq8lp3gLkNpNX+qiBAC4E2ColaigMtsMPbVEEFGk+RiPixF9L4xI4licoguc71zEfG9U3YUvNZZeBpV3JKMLLDzhs5aFDIb9M4k/zQ623zByOWDKjnUhHqqRK1I8hEyhThbbhKUkvo67/6vcRHHE9c0Lcxc2SrIGeyRUvPe5F+zXH8PbUn4aPq6fVTPmECRbiZOOm6zoy99PvyRVIYiCOkWqeXHhGIdMl2YZhA2UeM+1lwWtFQjSgefPeCfdhtIuEfFfUUaj3DyPJfD/+CyUT0QGUH6SD5sf1M8DySqOglgH1dIXEPLyIwA==",
+ "iv": "u+z76g62wnZYAehSfgffrPzpstykp6E891Z9CrDLfO0=",
+}
+
+
+def _extract_session_key(resp: httpx.Response) -> int:
+ data = resp.json()
+ if isinstance(data, list) and data:
+ return int(data[0]["SessionKey"])
+ if isinstance(data, dict) and "SessionKey" in data:
+ return int(data["SessionKey"])
+ raise RuntimeError(f"Could not parse SessionKey from /changeSessionKey response: {data!r}")
+
+
+def _wait_for_router(client: httpx.Client, max_wait: float) -> None:
+ deadline = time.time() + max_wait
+ last_error = None
+ while time.time() < deadline:
+ try:
+ resp = client.get("/login")
+ resp.raise_for_status()
+ return
+ except (httpx.ConnectTimeout, httpx.ReadTimeout, httpx.ConnectError, httpx.NetworkError) as exc:
+ last_error = exc
+ time.sleep(2)
+ raise RuntimeError(f"Router not reachable within {max_wait:.0f}s") from last_error
+
+
+def reboot_router(timeout: float, post_confirm_sleep: float) -> None:
+ with httpx.Client(base_url=ROUTER_BASE_URL, timeout=timeout, follow_redirects=True) as client:
+ _wait_for_router(client, max_wait=90)
+
+ login = client.post("/UserLogin", json=USER_LOGIN_PAYLOAD)
+ login.raise_for_status()
+
+ check = client.get("/cgi-bin/UserLoginCheck")
+ check.raise_for_status()
+ if "ZCFG_SUCCESS" not in check.text:
+ raise RuntimeError(f"Login check failed: {check.text}")
+
+ key_resp = client.get("/changeSessionKey")
+ key_resp.raise_for_status()
+ session_key = _extract_session_key(key_resp)
+
+ reboot = client.post("/cgi-bin/Reboot", params={"sessionkey": session_key})
+ reboot.raise_for_status()
+
+ time.sleep(post_confirm_sleep)
+ print("Router reboot request sent.")
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Reboot router via HTTP API (no Playwright)")
+ parser.add_argument("--timeout", type=float, default=20.0, help="HTTP timeout in seconds")
+ parser.add_argument(
+ "--post-confirm-sleep",
+ type=float,
+ default=5.0,
+ help="Seconds to wait after reboot request",
+ )
+ args = parser.parse_args()
+ reboot_router(timeout=args.timeout, post_confirm_sleep=args.post_confirm_sleep)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/rg_download.py b/rg_download.py
new file mode 100755
index 0000000..b3c6fee
--- /dev/null
+++ b/rg_download.py
@@ -0,0 +1,565 @@
+#!/usr/bin/env -S uv run
+# /// script
+# requires-python = ">=3.12"
+# dependencies = ["httpx"]
+# ///
+"""
+RapidGator (rapidgator.net) downloader — reverse-engineered from JDownloader's RapidGatorNet plugin.
+
+Usage:
+ ./rg_download.py [--user EMAIL] [--pass PASSWORD] [--out DIR] [--2captcha KEY]
+
+Premium accounts use the clean v2 API path (no captcha, full speed).
+Free/anonymous downloads scrape the website; reCAPTCHA v2 or Cloudflare Turnstile is
+required — a 2captcha API key is strongly recommended (manual solving isn't feasible for
+these captcha types).
+"""
+
+import re
+import sys
+import os
+import time
+import argparse
+import getpass
+from pathlib import Path
+from urllib.parse import urlparse, unquote
+
+import httpx
+
+# ── constants ────────────────────────────────────────────────────────────────
+
+API_BASE = "https://rapidgator.net/api/v2/"
+SITE_BASE = "https://rapidgator.net"
+
+SUPPORTED_DOMAINS = {"rapidgator.net", "rapidgator.asia", "rg.to"}
+
+# JD plugin regex: (?i)/file/([a-z0-9]{32}|\d+)
+FILE_ID_RE = re.compile(r"(?i)/file/([a-z0-9]{32}|\d+)")
+
+HEADERS = {
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
+ "Accept-Language": "en-US,en;q=0.8",
+}
+
+
+# ── helpers ──────────────────────────────────────────────────────────────────
+
+def extract_file_id(url: str) -> str | None:
+ m = FILE_ID_RE.search(url)
+ return m.group(1) if m else None
+
+
+def check_domain(url: str):
+ host = urlparse(url).hostname or ""
+ host = host.removeprefix("www.")
+ if host not in SUPPORTED_DOMAINS:
+ sys.exit(f"Unsupported domain '{host}'. Supported: {', '.join(sorted(SUPPORTED_DOMAINS))}")
+
+
+def _api_get(session: httpx.Client, endpoint: str, params: dict) -> dict:
+ """GET request to the v2 API; raises on HTTP error, returns parsed JSON."""
+ r = session.get(f"{API_BASE}{endpoint}", params=params, headers=HEADERS, timeout=30)
+ try:
+ data = r.json()
+ except Exception:
+ r.raise_for_status()
+ raise
+ return data
+
+
+def _api_check(data: dict, context: str = "") -> dict:
+ """Raise a RuntimeError if the API returned an error status."""
+ status = data.get("status", "")
+ if status != "success":
+ err = data.get("details") or data.get("error") or data
+ raise RuntimeError(f"API error{' (' + context + ')' if context else ''}: {err}")
+ return data.get("details", {})
+
+
+# ── API calls (mirroring JD's loginAPI / requestFileInformationAPI / handlePremium_api) ──
+
+def api_login(session: httpx.Client, email: str, password: str) -> str:
+ """GET /user/login → returns session_id (used as ?token= in all other calls)."""
+ data = _api_get(session, "user/login", {"login": email, "password": password})
+ details = _api_check(data, "login")
+ # JD stores the field named "session_id" (PROPERTY_sessionid constant)
+ sid = details.get("session_id") or details.get("token")
+ if not sid:
+ raise RuntimeError(f"Login succeeded but no session_id in response: {details}")
+ return sid
+
+
+def api_user_info(session: httpx.Client, token: str) -> dict:
+ """GET /user/info?token= → account details (is_premium, premium_end_time, …)."""
+ data = _api_get(session, "user/info", {"token": token})
+ return _api_check(data, "user/info")
+
+
+def api_file_info(session: httpx.Client, token: str, file_id: str) -> dict:
+ """GET /file/info?token=&file_id= → file metadata (name, size, hash, …)."""
+ data = _api_get(session, "file/info", {"token": token, "file_id": file_id})
+ details = _api_check(data, "file/info")
+ # Nested under "file" key
+ return details.get("file") or details
+
+
+def api_file_download(session: httpx.Client, token: str, file_id: str) -> str:
+ """GET /file/download?token=&file_id= → direct download_url."""
+ data = _api_get(session, "file/download", {"token": token, "file_id": file_id})
+ details = _api_check(data, "file/download")
+ url = details.get("download_url")
+ if not url:
+ raise RuntimeError(f"No download_url in response: {details}")
+ return url
+
+
+# ── 2captcha ─────────────────────────────────────────────────────────────────
+
+class TwoCaptcha:
+ """
+ Thin wrapper around the 2captcha API v2.
+ Supports reCAPTCHA v2 (RecaptchaV2TaskProxyless) and
+ Cloudflare Turnstile (TurnstileTaskProxyless) — the two captcha types
+ used by RapidGator's free download page.
+ """
+
+ API = "https://api.2captcha.com"
+ POLL_INTERVAL = 5
+ POLL_TIMEOUT = 180
+
+ def __init__(self, api_key: str, client: httpx.Client):
+ self.api_key = api_key
+ self.client = client
+
+ def _call(self, endpoint: str, payload: dict) -> dict:
+ r = self.client.post(
+ f"{self.API}{endpoint}",
+ json={"clientKey": self.api_key, **payload},
+ headers={"Content-Type": "application/json"},
+ timeout=30,
+ )
+ r.raise_for_status()
+ data = r.json()
+ err_id = data.get("errorId") or 0
+ if err_id and err_id != 0:
+ raise RuntimeError(
+ f"2captcha error: {data.get('errorCode') or data.get('errorDescription') or data}"
+ )
+ return data
+
+ def _poll(self, task_id: int) -> str:
+ """Poll until ready, return solution token."""
+ print(f" 2captcha task {task_id} submitted, polling…", flush=True)
+ deadline = time.monotonic() + self.POLL_TIMEOUT
+ while time.monotonic() < deadline:
+ time.sleep(self.POLL_INTERVAL)
+ result = self._call("/getTaskResult", {"taskId": task_id})
+ if result.get("status") == "ready":
+ token = result["solution"]["token"]
+ print(f" 2captcha solved (token length: {len(token)})")
+ return token
+ print(" …waiting for solution")
+ raise RuntimeError(f"2captcha timed out after {self.POLL_TIMEOUT}s")
+
+ def solve_recaptcha_v2(self, site_key: str, page_url: str) -> str:
+ """Submit a RecaptchaV2TaskProxyless and return the g-recaptcha-response token."""
+ task_id = self._call("/createTask", {
+ "task": {
+ "type": "RecaptchaV2TaskProxyless",
+ "websiteURL": page_url,
+ "websiteKey": site_key,
+ }
+ })["taskId"]
+ return self._poll(task_id)
+
+ def solve_turnstile(self, site_key: str, page_url: str) -> str:
+ """Submit a TurnstileTaskProxyless and return the cf-turnstile-response token."""
+ task_id = self._call("/createTask", {
+ "task": {
+ "type": "TurnstileTaskProxyless",
+ "websiteURL": page_url,
+ "websiteKey": site_key,
+ }
+ })["taskId"]
+ return self._poll(task_id)
+
+
+# ── free download (website scrape) ────────────────────────────────────────────
+
+_RECAPTCHA_RE = re.compile(
+ r'class=["\']g-recaptcha["\'][^>]*data-sitekey=["\']([^"\']+)["\']'
+ r'|data-sitekey=["\']([^"\']+)["\'][^>]*class=["\']g-recaptcha["\']'
+)
+_TURNSTILE_RE = re.compile(
+ r'class=["\']cf-turnstile["\'][^>]*data-sitekey=["\']([^"\']+)["\']'
+ r'|data-sitekey=["\']([^"\']+)["\'][^>]*class=["\']cf-turnstile["\']'
+)
+_DL_URL_RE = re.compile(
+ r"'(https?://[A-Za-z0-9\-_]+\.[^/]+//\?r=download/index&session_id=[A-Za-z0-9]+)'"
+ r"|\"(https?://[^/]+/download/[^<>\"']+)\""
+)
+
+# X-Requested-With is required by the AJAX endpoints — they return empty without it
+_XHR_HEADERS = {"X-Requested-With": "XMLHttpRequest"}
+
+
+def _first_group(m: re.Match | None) -> str | None:
+ if not m:
+ return None
+ return next((g for g in m.groups() if g), None)
+
+
+def free_download(
+ session: httpx.Client,
+ file_id: str,
+ file_name: str,
+ solver: TwoCaptcha | None,
+ out_dir: Path,
+):
+ """
+ Website-based free download flow (mirrors JD handleDownloadWebsite / free path).
+
+ Actual page flow discovered by inspection:
+ 1. GET /file/ → server sets PHPSESSID + file_id cookies;
+ page JS has: var fid = ; var secs = 180;
+ var startTimerUrl = '/download/AjaxStartTimer';
+ 2. GET /download/AjaxStartTimer?fid= (XHR)
+ → {"state":"started", "sid":""}
+ 3. Wait `secs` seconds (free-tier countdown)
+ 4. GET /download/AjaxGetDownloadLink?sid= (XHR)
+ → poll until {"state":"done"}
+ 5. GET /download/captcha → HTML with reCAPTCHA v2 or Turnstile widget
+ 6. POST /download/captcha with solved token
+ → redirect to the actual download URL OR
+ response body contains it
+ """
+ file_url = f"{SITE_BASE}/file/{file_id}"
+
+ # ── step 1: load file page ────────────────────────────────────────────────
+ print(f"Loading file page: {file_url}")
+ r = session.get(file_url, headers=HEADERS, timeout=30)
+ r.raise_for_status()
+ page_html = r.text
+
+ # Check for premium-only direct link already embedded (logged-in premium path)
+ pm = re.search(r"var\s+premium_download_link\s*=\s*'(https?://[^']+)'", page_html)
+ if pm and pm.group(1):
+ stream_download(session, pm.group(1), out_dir, file_name)
+ return
+
+ # Extract numeric fid (set by server in page JS and cookie, distinct from hex URL ID)
+ fid_m = re.search(r"var\s+fid\s*=\s*(\d+)", page_html)
+ if not fid_m:
+ sys.exit("Could not find numeric fid in page JS — page layout may have changed.")
+ numeric_fid = fid_m.group(1)
+
+ # Extract client-side wait timer (default 180 for free users)
+ secs_m = re.search(r"var\s+secs\s*=\s*(\d+)", page_html)
+ wait_secs = int(secs_m.group(1)) if secs_m else 180
+
+ print(f" Numeric fid={numeric_fid}, wait={wait_secs}s")
+
+ if wait_secs > 600:
+ sys.exit(f"Server wait is {wait_secs}s — IP appears rate-limited. Try again later.")
+
+ # ── step 2: start the server-side timer ──────────────────────────────────
+ print("Starting download timer…")
+ r2 = session.get(
+ f"{SITE_BASE}/download/AjaxStartTimer",
+ params={"fid": numeric_fid},
+ headers={**HEADERS, **_XHR_HEADERS, "Referer": file_url},
+ timeout=30,
+ )
+ r2.raise_for_status()
+ try:
+ timer_data = r2.json()
+ except Exception:
+ sys.exit(f"AjaxStartTimer returned non-JSON: {r2.text[:300]!r}")
+
+ if timer_data.get("state") == "error":
+ sys.exit(f"AjaxStartTimer error: {timer_data.get('code') or timer_data}")
+ if timer_data.get("state") != "started":
+ sys.exit(f"Unexpected AjaxStartTimer state: {timer_data}")
+
+ sid = timer_data.get("sid")
+ if not sid:
+ sys.exit(f"AjaxStartTimer response missing sid: {timer_data}")
+ print(f" sid={sid}")
+
+ # ── step 3: wait for the server countdown ────────────────────────────────
+ print(f" Waiting {wait_secs}s…", flush=True)
+ for remaining in range(wait_secs, 0, -5):
+ time.sleep(min(5, remaining))
+ print(f"\r {remaining - min(5, remaining)}s remaining… ", end="", flush=True)
+ print()
+
+ # ── step 4: confirm server is ready ──────────────────────────────────────
+ print("Confirming download ready…")
+ for attempt in range(10):
+ r3 = session.get(
+ f"{SITE_BASE}/download/AjaxGetDownloadLink",
+ params={"sid": sid},
+ headers={**HEADERS, **_XHR_HEADERS, "Referer": file_url},
+ timeout=30,
+ )
+ r3.raise_for_status()
+ try:
+ link_data = r3.json()
+ except Exception:
+ sys.exit(f"AjaxGetDownloadLink returned non-JSON: {r3.text[:300]!r}")
+
+ dl_state = link_data.get("state")
+ if dl_state == "done":
+ break
+ if dl_state == "error":
+ code = link_data.get("code", "")
+ if "wait" in str(code).lower():
+ print(f" Server not ready yet, retrying in 5s… ({code})")
+ time.sleep(5)
+ continue
+ sys.exit(f"AjaxGetDownloadLink error: {link_data}")
+ print(f" Unexpected state '{dl_state}', retrying…")
+ time.sleep(5)
+ else:
+ sys.exit(f"AjaxGetDownloadLink never returned done. Last response: {link_data}")
+
+ # ── step 5: load captcha page ─────────────────────────────────────────────
+ captcha_url = f"{SITE_BASE}/download/captcha"
+ print("Loading captcha page…")
+ r4 = session.get(captcha_url, headers={**HEADERS, "Referer": file_url}, timeout=30)
+ r4.raise_for_status()
+ captcha_html = r4.text
+
+ if not captcha_html.strip():
+ sys.exit("Captcha page returned empty — session may have expired. Try again.")
+
+ # Detect captcha type and extract site key
+ recaptcha_key = _first_group(_RECAPTCHA_RE.search(captcha_html))
+ turnstile_key = _first_group(_TURNSTILE_RE.search(captcha_html))
+
+ # Also extract CSRF token from if present
+ csrf_m = re.search(r']+type=["\']hidden["\'][^>]*name=["\']([^"\']+)["\'][^>]*value=["\']([^"\']*)["\']', captcha_html, re.I):
+ hidden_fields[hm.group(1)] = hm.group(2)
+ for hm in re.finditer(r']+name=["\']([^"\']+)["\'][^>]+type=["\']hidden["\'][^>]*value=["\']([^"\']*)["\']', captcha_html, re.I):
+ hidden_fields[hm.group(1)] = hm.group(2)
+
+ if recaptcha_key:
+ captcha_type = "reCAPTCHA v2"
+ captcha_field = "g-recaptcha-response"
+ site_key = recaptcha_key
+ elif turnstile_key:
+ captcha_type = "Cloudflare Turnstile"
+ captcha_field = "cf-turnstile-response"
+ site_key = turnstile_key
+ else:
+ sys.exit(
+ "No reCAPTCHA v2 or Turnstile found on captcha page.\n"
+ f"Page excerpt: {captcha_html[:500]}"
+ )
+
+ print(f" Detected {captcha_type} (sitekey: {site_key[:24]}…)")
+
+ if not solver:
+ sys.exit(
+ f"{captcha_type} detected — a --2captcha key is required for free downloads.\n"
+ "Alternatively use a premium account (--user / --pass)."
+ )
+
+ if recaptcha_key:
+ captcha_token = solver.solve_recaptcha_v2(site_key, file_url)
+ else:
+ captcha_token = solver.solve_turnstile(site_key, file_url)
+
+ # ── step 6: submit captcha form ───────────────────────────────────────────
+ print("Submitting captcha…")
+ form_data: dict = {
+ **hidden_fields,
+ captcha_field: captcha_token,
+ "DownloadCaptchaForm[verifyCode]": captcha_token,
+ }
+ if csrf_token:
+ form_data["_csrf"] = csrf_token
+
+ r5 = session.post(
+ captcha_url,
+ data=form_data,
+ headers={**HEADERS, "Referer": captcha_url},
+ timeout=30,
+ )
+ r5.raise_for_status()
+
+ # The final URL may come from a redirect the client followed, or be in the body
+ final_url = str(r5.url)
+ if "/download/" in final_url or final_url.startswith("http") and "rapidgator" not in final_url:
+ stream_download(session, final_url, out_dir, file_name)
+ return
+
+ # Search for download URL in response body
+ dl_m = _DL_URL_RE.search(r5.text)
+ if dl_m:
+ stream_download(session, _first_group(dl_m), out_dir, file_name)
+ return
+
+ # Last-resort: look for any https link pointing outside rapidgator (CDN URL)
+ cdn_m = re.search(r"https?://[a-z0-9\-]+\.rapidgator\.net/[^\s\"'<>]+", r5.text)
+ if cdn_m:
+ stream_download(session, cdn_m.group(0), out_dir, file_name)
+ return
+
+ sys.exit(
+ f"Could not extract download URL after captcha submission.\n"
+ f"Response URL: {final_url}\n"
+ f"Body excerpt: {r5.text[:500]}"
+ )
+
+
+# ── download ──────────────────────────────────────────────────────────────────
+
+def _filename_from_response(r: httpx.Response, url: str, fallback: str) -> str:
+ cd = r.headers.get("content-disposition", "")
+ if cd:
+ m = re.search(r"filename\*=UTF-8''([^;\r\n]+)", cd, re.I)
+ if m:
+ return unquote(m.group(1).strip().strip('"'))
+ m = re.search(r'filename="?([^";\r\n]+)"?', cd, re.I)
+ if m:
+ return m.group(1).strip()
+ path = urlparse(url).path
+ name = Path(path).name
+ return unquote(name) if name else (fallback or "download")
+
+
+def stream_download(session: httpx.Client, url: str, dest: Path, fallback_name: str = ""):
+ # Enforce HTTPS (JD plugin does this explicitly)
+ if url.startswith("http://"):
+ url = "https://" + url[7:]
+
+ dest.mkdir(parents=True, exist_ok=True)
+ with session.stream("GET", url, headers=HEADERS, timeout=None, follow_redirects=True) as r:
+ r.raise_for_status()
+ filename = _filename_from_response(r, url, fallback_name)
+ out_path = dest / filename
+ print(f"Downloading → {out_path}")
+ total = int(r.headers.get("content-length", 0))
+ done = 0
+ with open(out_path, "wb") as f:
+ for chunk in r.iter_bytes(chunk_size=65536):
+ f.write(chunk)
+ done += len(chunk)
+ if total:
+ pct = done * 100 // total
+ print(f"\r {pct}% {done // 1024} / {total // 1024} KB", end="", flush=True)
+ print(f"\nDone: {out_path}")
+
+
+# ── main flow ─────────────────────────────────────────────────────────────────
+
+def download(
+ url: str,
+ username: str | None,
+ password: str | None,
+ out_dir: Path,
+ twocaptcha_key: str | None = None,
+):
+ check_domain(url)
+
+ file_id = extract_file_id(url)
+ if not file_id:
+ sys.exit(f"Could not extract file ID from: {url}")
+
+ session = httpx.Client(follow_redirects=True)
+ token: str | None = None
+ solver = TwoCaptcha(twocaptcha_key, session) if twocaptcha_key else None
+
+ # ── 1. authenticate (premium path) ────────────────────────────────────────
+ if username and password:
+ print(f"Logging in as {username} …")
+ try:
+ token = api_login(session, username, password)
+ print("Logged in OK.")
+ except RuntimeError as e:
+ msg = str(e).lower()
+ if "wrong" in msg or "password" in msg or "login" in msg:
+ sys.exit(f"Login failed: {e}")
+ raise
+
+ # ── 2. check file ─────────────────────────────────────────────────────────
+ if token:
+ print(f"Checking file {file_id} via API…")
+ try:
+ info = api_file_info(session, token, file_id)
+ except RuntimeError as e:
+ sys.exit(f"File info failed: {e}")
+
+ name = info.get("name") or file_id
+ size = info.get("size") or 0
+ hash_ = info.get("hash") or info.get("md5") or ""
+ print(f" Name : {name}")
+ print(f" Size : {size // 1024 // 1024} MB" if size else " Size : unknown")
+ if hash_:
+ print(f" MD5 : {hash_}")
+
+ # ── 3. get download URL (premium API) ─────────────────────────────────
+ print("Requesting download URL…")
+ try:
+ direct_url = api_file_download(session, token, file_id)
+ except RuntimeError as e:
+ msg = str(e).lower()
+ if "daily" in msg or "limit" in msg:
+ sys.exit(f"Download limit reached: {e}")
+ sys.exit(f"Could not get download URL: {e}")
+
+ # ── 4. download ───────────────────────────────────────────────────────
+ stream_download(session, direct_url, out_dir, name)
+
+ else:
+ # ── free / anonymous path — website scrape + captcha ──────────────────
+ print("No credentials provided — attempting free download (captcha required).")
+ # Get file name from page if possible (best effort)
+ name = file_id
+ try:
+ r0 = session.get(f"{SITE_BASE}/file/{file_id}", headers=HEADERS, timeout=15)
+ title_m = re.search(r"\s*Download file\s*([^<>\"]+)", r0.text, re.I)
+ if title_m:
+ name = title_m.group(1).strip()
+ except Exception:
+ pass
+
+ free_download(session, file_id, name, solver, out_dir)
+
+
+# ── CLI ───────────────────────────────────────────────────────────────────────
+
+def main():
+ parser = argparse.ArgumentParser(description="RapidGator downloader")
+ parser.add_argument("url", help="rapidgator.net / rg.to file URL")
+ parser.add_argument("--user", "-u", default=os.environ.get("RG_USER"), help="Account email")
+ parser.add_argument("--pass", "-p", dest="password",
+ default=os.environ.get("RG_PASS"), help="Account password")
+ parser.add_argument("--out", "-o", default=None, help="Output directory (default: current directory)")
+ parser.add_argument("--2captcha", dest="twocaptcha_key",
+ default=os.environ.get("TWOCAPTCHA_API_KEY"),
+ help="2captcha API key (or set TWOCAPTCHA_API_KEY env var)")
+ args = parser.parse_args()
+
+ password = args.password
+ if args.user and not password:
+ password = getpass.getpass(f"Password for {args.user}: ")
+
+ download(
+ url=args.url,
+ username=args.user,
+ password=password,
+ out_dir=Path(args.out) if args.out else Path.cwd(),
+ twocaptcha_key=args.twocaptcha_key,
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/sort_images_yolo.py b/sort_images_yolo.py
new file mode 100755
index 0000000..8808409
--- /dev/null
+++ b/sort_images_yolo.py
@@ -0,0 +1,618 @@
+#!/usr/bin/env -S uv run --script
+# /// script
+# requires-python = ">=3.13"
+# dependencies = ["ultralytics", "torch", "opencv-python", "numpy", "pillow"]
+# ///
+import argparse
+from functools import cache
+import logging
+from pathlib import Path
+from typing import List, Tuple, NamedTuple, Optional
+from dataclasses import dataclass
+import math
+from itertools import combinations
+
+# --- Heavyweight imports for detection ---
+try:
+ from ultralytics import YOLO
+ import torch
+ import numpy as np
+except Exception: # pragma: no cover - optional
+ YOLO = None
+ torch = None
+ np = None
+
+try:
+ import cv2
+except Exception: # pragma: no cover - optional
+ cv2 = None
+
+try:
+ from PIL import Image
+except Exception:
+ Image = None
+
+# --- Constants ---
+# Tolerance for centering/pose checking (Normalized 0-1 space)
+# Minimum confidence/visibility score for a keypoint to be used
+VISIBILITY_THRESH = 0.1
+
+# --- Structured Coordinate and Keypoint Types ---
+
+
+class Coords(NamedTuple):
+ """Represents normalized coordinates (0.0 to 1.0) and visibility for a single point."""
+
+ x: float
+ y: float
+ is_visible: bool
+
+
+@dataclass
+class PoseKeypoints:
+ """Holds structured, normalized keypoint data for all 17 COCO points as direct fields."""
+
+ # 0
+ nose: Coords
+ # 1-4
+ left_eye: Coords
+ right_eye: Coords
+ left_ear: Coords
+ right_ear: Coords
+ # 5-6
+ left_shoulder: Coords
+ right_shoulder: Coords
+ # 7-10
+ left_elbow: Coords
+ right_elbow: Coords
+ left_wrist: Coords
+ right_wrist: Coords
+ # 11-12
+ left_hip: Coords
+ right_hip: Coords
+ # 13-16
+ left_knee: Coords
+ right_knee: Coords
+ left_ankle: Coords
+ right_ankle: Coords
+
+ def shoulder_midpoint(self) -> Coords:
+ """Return midpoint between left and right shoulder if available."""
+ l = self.left_shoulder
+ r = self.right_shoulder
+ if l.is_visible and r.is_visible:
+ return Coords(x=(l.x + r.x) / 2.0, y=(l.y + r.y) / 2.0, is_visible=True)
+ return Coords(x=(l.x + r.x) / 2.0, y=(l.y + r.y) / 2.0, is_visible=False)
+
+ def hip_midpoint(self) -> Coords:
+ """Return midpoint between left and right hip if available."""
+ l = self.left_hip
+ r = self.right_hip
+ if l.is_visible and r.is_visible:
+ return Coords(x=(l.x + r.x) / 2.0, y=(l.y + r.y) / 2.0, is_visible=True)
+ return Coords(x=(l.x + r.x) / 2.0, y=(l.y + r.y) / 2.0, is_visible=False)
+
+ def eye_midpoint(self) -> Coords:
+ """Return midpoint between left and right eye if available."""
+ l = self.left_eye
+ r = self.right_eye
+ if l.is_visible and r.is_visible:
+ return Coords(x=(l.x + r.x) / 2.0, y=(l.y + r.y) / 2.0, is_visible=True)
+ return Coords(x=(l.x + r.x) / 2.0, y=(l.y + r.y) / 2.0, is_visible=False)
+
+
+# --- Dataclasses for Structured Output ---
+
+
+@dataclass
+class PoseDetectionResult:
+ """Encapsulates results from the pose detection strategy."""
+
+ boxes: np.ndarray
+ keypoints_xyc: np.ndarray
+ # List of all detected people's keypoints
+ all_pose_kps: List[PoseKeypoints]
+
+ # --- Centering logic moved into a method (CLEANED) ---
+
+ @dataclass
+ class CenterResult:
+ is_centered: bool
+ reason: str
+ coords: Optional[Coords]
+ threshold: float
+
+ def is_centered(self, center_threshold: float) -> "PoseDetectionResult.CenterResult":
+ """
+ Checks if any person's core (nose, shoulder midpoint, or hip midpoint)
+ is horizontally centered within the image based on the threshold.
+
+ Returns: (is_centered, centered_by_point_name, centering_point_coords)
+ """
+ cx = 0.5
+ band_min = cx - center_threshold
+ band_max = cx + center_threshold
+
+ def _is_in_band(c: Coords) -> bool:
+ return c.is_visible and (band_min <= c.x <= band_max)
+
+ for kps in self.all_pose_kps:
+ nose = kps.nose
+ shoulder_mid = kps.shoulder_midpoint()
+ hip_mid = kps.hip_midpoint()
+ eye_mid = kps.eye_midpoint()
+
+ important_features = [nose, shoulder_mid, eye_mid]
+ visible_features = [f for f in important_features if f.is_visible]
+
+ if not visible_features:
+ continue
+
+ all_centered = all(_is_in_band(f) for f in visible_features)
+ if all_centered:
+ # Representative point: prefer shoulder, then nose, then hip, then eyes
+ pref = None
+ for f in (shoulder_mid, nose, hip_mid, eye_mid):
+ if f.is_visible:
+ pref = f
+ break
+ if pref is None:
+ pref = visible_features[0]
+ return PoseDetectionResult.CenterResult(is_centered=True, reason="multiple", coords=pref, threshold=center_threshold)
+
+ return PoseDetectionResult.CenterResult(is_centered=False, reason="none", coords=None, threshold=center_threshold)
+
+ def is_torso_centered(self, center_threshold: float) -> Tuple[bool, str, Optional[Coords]]:
+ """
+ Determines if the torso (the line passing through the shoulder midpoint
+ and the hip midpoint) crosses the central vertical band of the image.
+
+ Returns: (is_centered, "torso_line" or "none", Coords of intersection/midpoint)
+ """
+ band_min = 0.5 - center_threshold
+ band_max = 0.5 + center_threshold
+
+ for kps in self.all_pose_kps:
+ l_sh = kps.left_shoulder
+ r_sh = kps.right_shoulder
+ l_hp = kps.left_hip
+ r_hp = kps.right_hip
+
+ # Need visibility for both shoulders and both hips to form the line
+ if not (l_sh.is_visible and r_sh.is_visible and l_hp.is_visible and r_hp.is_visible):
+ continue
+
+ sx = (l_sh.x + r_sh.x) / 2.0
+ sy = (l_sh.y + r_sh.y) / 2.0
+ hx = (l_hp.x + r_hp.x) / 2.0
+ hy = (l_hp.y + r_hp.y) / 2.0
+
+ seg_min_x = min(sx, hx)
+ seg_max_x = max(sx, hx)
+
+ # Quick reject: if the x-range of the segment doesn't touch the band
+ if seg_max_x < band_min or seg_min_x > band_max:
+ continue
+
+ # If either endpoint is already inside the band, return that endpoint/midpoint
+ if band_min <= sx <= band_max:
+ return True, "torso_line", Coords(x=sx, y=sy, is_visible=True)
+ if band_min <= hx <= band_max:
+ return True, "torso_line", Coords(x=hx, y=hy, is_visible=True)
+
+ # Otherwise the segment crosses the band somewhere between the endpoints.
+ # Compute intersection with the central vertical line x=0.5 when possible.
+ dx = hx - sx
+ dy = hy - sy
+ if abs(dx) < 1e-6:
+ # Vertical segment (x nearly constant) and we already know it intersects band
+ mid_x = sx
+ mid_y = (sy + hy) / 2.0
+ return True, "torso_line", Coords(x=mid_x, y=mid_y, is_visible=True)
+
+ # param t where x(t) = sx + t*dx == 0.5
+ t = (0.5 - sx) / dx
+ if 0.0 <= t <= 1.0:
+ inter_y = sy + t * dy
+ return True, "torso_line", Coords(x=0.5, y=inter_y, is_visible=True)
+
+ # Fallback: return midpoint of segment if we reach here (shouldn't normally)
+ mid_x = (sx + hx) / 2.0
+ mid_y = (sy + hy) / 2.0
+ return True, "torso_line", Coords(x=mid_x, y=mid_y, is_visible=True)
+
+ return False, "none", None
+
+ def is_upright(self, angle_threshold_degrees: float = 20.0) -> bool:
+ """Return True if the torso (or any pair of important features) is approximately vertical.
+
+ Logic: consider important features (eye_mid, shoulder_mid, hip_mid, nose). If at least two
+ visible features form a vector whose angle to vertical is within `angle_threshold_degrees`,
+ consider the person upright.
+ """
+
+ def angle_from_vertical(p1: Coords, p2: Coords) -> float:
+ vx = p2.x - p1.x
+ vy = p2.y - p1.y
+ if abs(vx) < 1e-9 and abs(vy) < 1e-9:
+ return 90.0
+ # angle between (vx, vy) and vertical (0,1): use atan2(|vx|, |vy|)
+ ang_rad = math.atan2(abs(vx), abs(vy))
+ return math.degrees(ang_rad)
+
+ for kps in self.all_pose_kps:
+ eye = kps.eye_midpoint()
+ shoulder = kps.shoulder_midpoint()
+ hip = kps.hip_midpoint()
+ nose = kps.nose
+
+ features = [f for f in (shoulder, hip) if f.is_visible]
+ if len(features) < 2:
+ continue
+
+ # Check all pairs; if any pair is near-vertical, return True
+ for a_f, b_f in combinations(features, 2):
+ a = angle_from_vertical(a_f, b_f)
+ if a <= angle_threshold_degrees:
+ return True
+
+ return False
+
+ def is_laying(self, angle_threshold_degrees: float = 20.0) -> bool:
+ """Return True if the torso (or any pair of important features) is approximately horizontal.
+
+ Logic: consider important features (eye_mid, shoulder_mid, hip_mid, nose). If at least two
+ visible features form a vector whose angle to horizontal is within `angle_threshold_degrees`,
+ consider the person laying down.
+ """
+
+ def angle_from_horizontal(p1: Coords, p2: Coords) -> float:
+ vx = p2.x - p1.x
+ vy = p2.y - p1.y
+ if abs(vx) < 1e-9 and abs(vy) < 1e-9:
+ return 90.0
+ # angle between (vx, vy) and horizontal (1,0): use atan2(|vy|, |vx|)
+ ang_rad = math.atan2(abs(vy), abs(vx))
+ return math.degrees(ang_rad)
+
+ for kps in self.all_pose_kps:
+ eye = kps.eye_midpoint()
+ shoulder = kps.shoulder_midpoint()
+ hip = kps.hip_midpoint()
+ nose = kps.nose
+
+ features = [f for f in (shoulder, hip) if f.is_visible]
+ if len(features) < 2:
+ continue
+
+ # Check all pairs; if any pair is near-horizontal, return True
+ for a_f, b_f in combinations(features, 2):
+ a = angle_from_horizontal(a_f, b_f)
+ if a <= angle_threshold_degrees:
+ return True
+
+ return False
+
+
+# --- Utility Functions ---
+
+
+def read_dims(image_path: Path) -> tuple[int, int]:
+ # ... (read_dims implementation remains UNCHANGED) ...
+ with image_path.open("rb") as file:
+ if file.read(2) != b"\xff\xd8":
+ raise ValueError(f"{image_path} is not a valid JPEG file")
+
+ file.seek(0)
+ try:
+ img = Image.open(file)
+ width, height = img.size
+ return width, height
+ except Exception:
+ file.seek(0)
+ while True:
+ marker = file.read(1)
+ if not marker or marker != b"\xff":
+ raise ValueError(f"Invalid JPEG format in {image_path}")
+ marker_type = int.from_bytes(file.read(1), byteorder="big")
+ length = int.from_bytes(file.read(2), byteorder="big") - 2
+ is_sof = 0xC0 <= marker_type <= 0xCF and marker_type not in (0xC4, 0xC8, 0xCC)
+ if is_sof:
+ file.seek(1, 1)
+ height = int.from_bytes(file.read(2), byteorder="big")
+ width = int.from_bytes(file.read(2), byteorder="big")
+ return width, height
+ file.seek(length, 1)
+
+
+def calc_threshold(width: int, height: int, target_aspect: float = 1080 / 2400) -> float:
+ """Compute a per-image center threshold using its dimensions.
+
+ For tall images the center area will be wider than the image so we return 0.5 (full width). For
+ wide images the square center is narrower and the returned threshold < 0.5.
+ """
+ img_aspect = width / height
+
+ if target_aspect >= img_aspect:
+ return 0.5
+
+ # center_width = target_aspect * height (in pixels)
+ center_width = target_aspect * height
+ half_width_norm = (center_width / 2.0) / width
+ return min(max(half_width_norm, 0.0), 0.5)
+
+
+@cache
+def make_dir(path: Path) -> None:
+ path.mkdir(parents=True, exist_ok=True)
+
+
+@cache
+def _yolov8_detector(model_type: str):
+ if YOLO is None or torch is None:
+ logging.error("YOLO/Torch dependencies are missing.")
+ return None
+ try:
+ device = "cuda" if torch.cuda.is_available() else "cpu"
+ except Exception:
+ device = "cpu"
+ try:
+ if model_type == "pose":
+ model = YOLO(Path(__file__).parent / "yolov8n-pose.pt")
+ else:
+ raise ValueError(f"Unknown model type: {model_type}")
+ model.to(device)
+ return model
+ except Exception:
+ logging.exception(f"YOLOv8-{model_type} model initialization failed.")
+ return None
+
+
+def _get_coords(kp_xyc: np.ndarray, idx: int) -> Coords:
+ """Helper to safely extract Coords from the raw numpy array."""
+ x, y, conf = kp_xyc[idx]
+ is_visible = conf > VISIBILITY_THRESH
+ return Coords(x=x, y=y, is_visible=is_visible)
+
+
+def _extract_keypoints(kp_xyc: np.ndarray) -> PoseKeypoints:
+ """Extracts all 17 COCO normalized keypoints and populates the PoseKeypoints dataclass directly."""
+
+ return PoseKeypoints(
+ nose=_get_coords(kp_xyc, 0),
+ left_eye=_get_coords(kp_xyc, 1),
+ right_eye=_get_coords(kp_xyc, 2),
+ left_ear=_get_coords(kp_xyc, 3),
+ right_ear=_get_coords(kp_xyc, 4),
+ left_shoulder=_get_coords(kp_xyc, 5),
+ right_shoulder=_get_coords(kp_xyc, 6),
+ left_elbow=_get_coords(kp_xyc, 7),
+ right_elbow=_get_coords(kp_xyc, 8),
+ left_wrist=_get_coords(kp_xyc, 9),
+ right_wrist=_get_coords(kp_xyc, 10),
+ left_hip=_get_coords(kp_xyc, 11),
+ right_hip=_get_coords(kp_xyc, 12),
+ left_knee=_get_coords(kp_xyc, 13),
+ right_knee=_get_coords(kp_xyc, 14),
+ left_ankle=_get_coords(kp_xyc, 15),
+ right_ankle=_get_coords(kp_xyc, 16),
+ )
+
+
+# --- Core Detection Function (UNCHANGED) ---
+
+
+def detect_pose(image_path: Path) -> PoseDetectionResult:
+ """
+ Performs pose detection and returns a structured result object.
+ """
+ yolo_model = _yolov8_detector("pose")
+ all_person_boxes = np.array([])
+ all_keypoints_xyc = np.array([])
+ all_pose_kps = []
+
+ if yolo_model is None:
+ return PoseDetectionResult(boxes=all_person_boxes, keypoints_xyc=all_keypoints_xyc, all_pose_kps=all_pose_kps)
+
+ try:
+ results = yolo_model(str(image_path), conf=0.65, iou=0.5, verbose=False)
+
+ if results and results[0].boxes and results[0].keypoints:
+ all_person_boxes = results[0].boxes.xyxy.cpu().numpy()
+ kps_norm_xy = results[0].keypoints.xyn.cpu().numpy()
+ kps_conf = results[0].keypoints.conf.cpu().numpy()
+ # Combine into an N_person x 17 x 3 array (x, y, confidence)
+ all_keypoints_xyc = np.concatenate([kps_norm_xy, np.expand_dims(kps_conf, axis=2)], axis=2)
+
+ if all_person_boxes.size > 0:
+ for kp_xyc in all_keypoints_xyc:
+ # Encapsulate the raw keypoint data for clean access
+ all_pose_kps.append(_extract_keypoints(kp_xyc))
+
+ return PoseDetectionResult(boxes=all_person_boxes, keypoints_xyc=all_keypoints_xyc, all_pose_kps=all_pose_kps)
+
+ except Exception:
+ logging.exception("YOLOv8-Pose detection failed.")
+ return PoseDetectionResult(boxes=all_person_boxes, keypoints_xyc=all_keypoints_xyc, all_pose_kps=all_pose_kps)
+
+
+# --- Debug Drawing Function (UNCHANGED) ---
+
+
+def draw_debug_image(
+ image_path: Path,
+ width: int,
+ height: int,
+ result: PoseDetectionResult,
+ center_result: PoseDetectionResult.CenterResult,
+ save_path: Path,
+) -> None:
+ """
+ Draws the centering zone, bounding boxes, and highlights the successful centering point
+ using the coordinates provided by the is_centered method.
+ """
+ if cv2 is None or np is None:
+ logging.error("OpenCV/Numpy is required for debug but is not available.")
+ return
+
+ # Check for centering first
+ is_centered = center_result.is_centered
+ centered_by = center_result.reason
+ centering_point_coords = center_result.coords
+
+ img = cv2.imdecode(np.fromfile(str(image_path), dtype=np.uint8), cv2.IMREAD_COLOR)
+ if img is None:
+ logging.error(f"Could not load image for debugging: {image_path}")
+ return
+
+ # Draw Centering Zone
+ cx = width / 2.0
+ thresh_px_x = int(width * center_result.threshold)
+ x_mid_start = int(cx - thresh_px_x)
+ x_mid_end = int(cx + thresh_px_x)
+ y_mid_start, y_mid_end = 0, height
+
+ BOX_THICKNESS = 10
+ KP_RADIUS = 10
+ KP_THICKNESS = -1
+ # VISIBILITY_THRESH is not strictly needed here but kept for clarity
+
+ overlay = img.copy()
+ zone_color = (0, 255, 0) if is_centered else (0, 0, 255) # Green if centered, Red otherwise
+ cv2.rectangle(overlay, (x_mid_start, y_mid_start), (x_mid_end, y_mid_end), zone_color, -1)
+ alpha = 0.2
+ img = cv2.addWeighted(overlay, alpha, img, 1 - alpha, 0)
+
+ # Draw Detections
+ for box_idx in range(len(result.boxes)):
+ box = result.boxes[box_idx]
+ kp_xyc = result.keypoints_xyc[box_idx]
+ kp_px = (kp_xyc[:, :2] * np.array([width, height])).astype(int)
+ kps_conf = kp_xyc[:, 2]
+
+ # The bounding box color is based on the global centering status
+ person_color = (0, 255, 0) if is_centered else (255, 0, 0)
+
+ x1, y1, x2, y2 = map(int, box)
+ cv2.rectangle(img, (x1, y1), (x2, y2), person_color, BOX_THICKNESS)
+
+ # Highlight the calculated centering point (Assumes the first person detected is the one that triggered the center check)
+ if box_idx == 0 and is_centered and centering_point_coords:
+ norm_coords = centering_point_coords
+ centering_point_px = (int(norm_coords.x * width), int(norm_coords.y * height))
+
+ # Highlight the calculated centering point
+ cv2.circle(img, centering_point_px, 12, person_color, -1)
+ cv2.circle(img, centering_point_px, 6, (255, 255, 255), -1)
+
+ # Draw all visible keypoints (for context)
+ for i in range(len(kp_px)):
+ if kps_conf[i] > VISIBILITY_THRESH:
+ cv2.circle(img, tuple(kp_px[i]), KP_RADIUS, (255, 255, 0), KP_THICKNESS)
+
+ # Save the debug image
+
+ _, buffer = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 60])
+ save_path.write_bytes(buffer.tobytes())
+ logging.info(f"Saved debug image to {save_path}. Centered by: {centered_by}")
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Sort images based on pose centering.")
+ parser.add_argument("image_paths", nargs="+", type=Path, help="Path(s) to JPEG image(s)")
+
+ parser.add_argument(
+ "--by-pose",
+ action="store_true",
+ default=True,
+ help="Sort images based on horizontal centering of the person's core (nose/shoulders/hips).",
+ )
+
+ parser.add_argument(
+ "--debug",
+ action="store_true",
+ help="Saves a debug image showing the centering zone and detected points, but does NOT move the original file.",
+ )
+
+ parser.add_argument(
+ "--is-upright",
+ action="store_true",
+ help="Also detect upright posture and move upright images to `_pose_upright`.",
+ )
+
+ return parser.parse_args()
+
+
+def main():
+ """Main function to process images."""
+ args = parse_args()
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
+
+ for image_path in args.image_paths:
+ if not image_path.is_file():
+ logging.warning(f"{image_path} is not a file. Skipping.")
+ continue
+
+ if image_path.suffix.lower() not in [".jpg", ".jpeg"]:
+ logging.warning(f"{image_path} is not a JPEG file. Skipping.")
+ continue
+
+ try:
+ width, height = read_dims(image_path)
+
+ # 1. Run detection (only data extraction)
+ detection_result = detect_pose(image_path)
+
+ # Compute per-image threshold and run centering logic
+ threshold = calc_threshold(width=width, height=height)
+ center_res = detection_result.is_centered(center_threshold=threshold)
+ is_centered = center_res.is_centered
+ centered_by = center_res.reason
+
+ # Define target path/directory based on centering result
+ target_dir = image_path.parent / ("_pose_centered" if is_centered else "_pose_other")
+ # If user requested upright detection and the person is upright, override target
+ if args.is_upright:
+ try:
+ if detection_result.is_upright(angle_threshold_degrees=20):
+ target_dir = image_path.parent / "_pose_upright"
+ elif detection_result.is_laying(angle_threshold_degrees=40):
+ target_dir = image_path.parent / "_pose_laying"
+ except Exception:
+ # If upright detection fails, fall back to normal behavior
+ pass
+ make_dir(target_dir)
+ target_path = (target_dir / image_path.name).with_suffix(".jpg")
+
+ has_detections = detection_result.boxes.size > 0
+
+ # --- DEBUG LOGIC (Only output image, no move) ---
+ if args.debug:
+ if has_detections:
+ debug_filename = image_path.stem + "_debug" + image_path.suffix
+ debug_path = target_dir / debug_filename
+ draw_debug_image(
+ image_path=image_path,
+ width=width,
+ height=height,
+ result=detection_result,
+ center_result=center_res,
+ save_path=debug_path,
+ )
+ logging.info(f"Processed {image_path} (Debug mode active). File was NOT moved.")
+ else:
+ logging.info(f"Processed {image_path} (Debug mode active). No person detected, skipping debug output.")
+ # --- NON-DEBUG LOGIC (Move file) ---
+ else:
+ image_path.rename(target_path)
+ logging.info(f"Moved {image_path} to {target_path}. Centered by: {centered_by}")
+
+ except KeyboardInterrupt:
+ raise
+ except Exception as e:
+ logging.exception(f"Error processing {image_path}. Skipping.")
+ continue
+
+
+if __name__ == "__main__":
+ main()
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..01e134d
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,998 @@
+version = 1
+revision = 3
+requires-python = ">=3.13"
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
+]
+
+[[package]]
+name = "anyio"
+version = "4.8.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "sniffio" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a3/73/199a98fc2dae33535d6b8e8e6ec01f8c1d76c9adb096c6b7d64823038cde/anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a", size = 181126, upload-time = "2025-01-05T13:13:11.095Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041, upload-time = "2025-01-05T13:13:07.985Z" },
+]
+
+[[package]]
+name = "beautifulsoup4"
+version = "4.14.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "soupsieve" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822, upload-time = "2025-09-29T10:05:42.613Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" },
+]
+
+[[package]]
+name = "browser-cookie3"
+version = "0.20.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jeepney", marker = "'bsd' in sys_platform or sys_platform == 'linux'" },
+ { name = "lz4" },
+ { name = "pycryptodomex" },
+ { name = "shadowcopy", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e0/e1/652adea0ce25948e613ef78294c8ceaf4b32844aae00680d3a1712dde444/browser_cookie3-0.20.1.tar.gz", hash = "sha256:6d8d0744bf42a5327c951bdbcf77741db3455b8b4e840e18bab266d598368a12", size = 22665, upload-time = "2024-12-20T00:31:30.144Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4e/57/2a716f4ecf6c50b2dbe27439507c480bb7ca5725edef82349ecdcfcdd084/browser_cookie3-0.20.1-py3-none-any.whl", hash = "sha256:4b38bf669d386250733c8339f0036e1cf09c3d8e4d326fd507b9afb84def13d6", size = 17229, upload-time = "2025-01-04T14:46:14.753Z" },
+]
+
+[[package]]
+name = "bs4"
+version = "0.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "beautifulsoup4" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c9/aa/4acaf814ff901145da37332e05bb510452ebed97bc9602695059dd46ef39/bs4-0.0.2.tar.gz", hash = "sha256:a48685c58f50fe127722417bae83fe6badf500d54b55f7e39ffe43b798653925", size = 698, upload-time = "2024-01-17T18:15:47.371Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/51/bb/bf7aab772a159614954d84aa832c129624ba6c32faa559dfb200a534e50b/bs4-0.0.2-py2.py3-none-any.whl", hash = "sha256:abf8742c0805ef7f662dce4b51cca104cffe52b835238afc169142ab9b3fbccc", size = 1189, upload-time = "2024-01-17T18:15:48.613Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2025.1.31"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577, upload-time = "2025-01-31T02:16:47.166Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393, upload-time = "2025-01-31T02:16:45.015Z" },
+]
+
+[[package]]
+name = "cffi"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
+ { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
+ { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
+ { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
+ { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
+ { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
+ { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
+ { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
+ { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
+ { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
+]
+
+[[package]]
+name = "click"
+version = "8.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "cryptography"
+version = "46.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" },
+ { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" },
+ { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" },
+ { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" },
+ { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" },
+ { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" },
+ { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" },
+ { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" },
+ { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" },
+ { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" },
+ { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" },
+ { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" },
+ { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" },
+ { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" },
+ { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" },
+ { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" },
+ { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" },
+ { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
+]
+
+[[package]]
+name = "debugpy"
+version = "1.8.15"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/3a9a28ddb750a76eaec445c7f4d3147ea2c579a97dbd9e25d39001b92b21/debugpy-1.8.15.tar.gz", hash = "sha256:58d7a20b7773ab5ee6bdfb2e6cf622fdf1e40c9d5aef2857d85391526719ac00", size = 1643279, upload-time = "2025-07-15T16:43:29.135Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/28/70/2928aad2310726d5920b18ed9f54b9f06df5aa4c10cf9b45fa18ff0ab7e8/debugpy-1.8.15-cp313-cp313-macosx_14_0_universal2.whl", hash = "sha256:f5e01291ad7d6649aed5773256c5bba7a1a556196300232de1474c3c372592bf", size = 2495538, upload-time = "2025-07-15T16:43:48.927Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/c6/9b8ffb4ca91fac8b2877eef63c9cc0e87dd2570b1120054c272815ec4cd0/debugpy-1.8.15-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94dc0f0d00e528d915e0ce1c78e771475b2335b376c49afcc7382ee0b146bab6", size = 4221874, upload-time = "2025-07-15T16:43:50.282Z" },
+ { url = "https://files.pythonhosted.org/packages/55/8a/9b8d59674b4bf489318c7c46a1aab58e606e583651438084b7e029bf3c43/debugpy-1.8.15-cp313-cp313-win32.whl", hash = "sha256:fcf0748d4f6e25f89dc5e013d1129ca6f26ad4da405e0723a4f704583896a709", size = 5275949, upload-time = "2025-07-15T16:43:52.079Z" },
+ { url = "https://files.pythonhosted.org/packages/72/83/9e58e6fdfa8710a5e6ec06c2401241b9ad48b71c0a7eb99570a1f1edb1d3/debugpy-1.8.15-cp313-cp313-win_amd64.whl", hash = "sha256:73c943776cb83e36baf95e8f7f8da765896fd94b05991e7bc162456d25500683", size = 5317720, upload-time = "2025-07-15T16:43:53.703Z" },
+ { url = "https://files.pythonhosted.org/packages/07/d5/98748d9860e767a1248b5e31ffa7ce8cb7006e97bf8abbf3d891d0a8ba4e/debugpy-1.8.15-py2.py3-none-any.whl", hash = "sha256:bce2e6c5ff4f2e00b98d45e7e01a49c7b489ff6df5f12d881c67d2f1ac635f3d", size = 5282697, upload-time = "2025-07-15T16:44:07.996Z" },
+]
+
+[[package]]
+name = "distro"
+version = "1.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
+]
+
+[[package]]
+name = "eval-type-backport"
+version = "0.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/30/ea/8b0ac4469d4c347c6a385ff09dc3c048c2d021696664e26c7ee6791631b5/eval_type_backport-0.2.2.tar.gz", hash = "sha256:f0576b4cf01ebb5bd358d02314d31846af5e07678387486e2c798af0e7d849c1", size = 9079, upload-time = "2024-12-21T20:09:46.005Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ce/31/55cd413eaccd39125368be33c46de24a1f639f2e12349b0361b4678f3915/eval_type_backport-0.2.2-py3-none-any.whl", hash = "sha256:cb6ad7c393517f476f96d456d0412ea80f0a8cf96f6892834cd9340149111b0a", size = 5830, upload-time = "2024-12-21T20:09:44.175Z" },
+]
+
+[[package]]
+name = "greenlet"
+version = "3.2.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" },
+ { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" },
+ { url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" },
+ { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" },
+ { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" },
+ { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" },
+ { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" },
+]
+
+[[package]]
+name = "h11"
+version = "0.14.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418, upload-time = "2022-09-25T15:40:01.519Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259, upload-time = "2022-09-25T15:39:59.68Z" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196, upload-time = "2024-11-15T12:30:47.531Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551, upload-time = "2024-11-15T12:30:45.782Z" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.10"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "jaraco-classes"
+version = "3.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "more-itertools" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" },
+]
+
+[[package]]
+name = "jaraco-context"
+version = "6.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", size = 13912, upload-time = "2024-08-20T03:39:27.358Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4", size = 6825, upload-time = "2024-08-20T03:39:25.966Z" },
+]
+
+[[package]]
+name = "jaraco-functools"
+version = "4.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "more-itertools" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f7/ed/1aa2d585304ec07262e1a83a9889880701079dde796ac7b1d1826f40c63d/jaraco_functools-4.3.0.tar.gz", hash = "sha256:cfd13ad0dd2c47a3600b439ef72d8615d482cedcff1632930d6f28924d92f294", size = 19755, upload-time = "2025-08-18T20:05:09.91Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b4/09/726f168acad366b11e420df31bf1c702a54d373a83f968d94141a8c3fde0/jaraco_functools-4.3.0-py3-none-any.whl", hash = "sha256:227ff8ed6f7b8f62c56deff101545fa7543cf2c8e7b82a7c2116e672f29c26e8", size = 10408, upload-time = "2025-08-18T20:05:08.69Z" },
+]
+
+[[package]]
+name = "jeepney"
+version = "0.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" },
+]
+
+[[package]]
+name = "jiter"
+version = "0.10.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/9d/ae7ddb4b8ab3fb1b51faf4deb36cb48a4fbbd7cb36bad6a5fca4741306f7/jiter-0.10.0.tar.gz", hash = "sha256:07a7142c38aacc85194391108dc91b5b57093c978a9932bd86a36862759d9500", size = 162759, upload-time = "2025-05-18T19:04:59.73Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2e/b0/279597e7a270e8d22623fea6c5d4eeac328e7d95c236ed51a2b884c54f70/jiter-0.10.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e0588107ec8e11b6f5ef0e0d656fb2803ac6cf94a96b2b9fc675c0e3ab5e8644", size = 311617, upload-time = "2025-05-18T19:04:02.078Z" },
+ { url = "https://files.pythonhosted.org/packages/91/e3/0916334936f356d605f54cc164af4060e3e7094364add445a3bc79335d46/jiter-0.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cafc4628b616dc32530c20ee53d71589816cf385dd9449633e910d596b1f5c8a", size = 318947, upload-time = "2025-05-18T19:04:03.347Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/8e/fd94e8c02d0e94539b7d669a7ebbd2776e51f329bb2c84d4385e8063a2ad/jiter-0.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:520ef6d981172693786a49ff5b09eda72a42e539f14788124a07530f785c3ad6", size = 344618, upload-time = "2025-05-18T19:04:04.709Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/b0/f9f0a2ec42c6e9c2e61c327824687f1e2415b767e1089c1d9135f43816bd/jiter-0.10.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:554dedfd05937f8fc45d17ebdf298fe7e0c77458232bcb73d9fbbf4c6455f5b3", size = 368829, upload-time = "2025-05-18T19:04:06.912Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/57/5bbcd5331910595ad53b9fd0c610392ac68692176f05ae48d6ce5c852967/jiter-0.10.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5bc299da7789deacf95f64052d97f75c16d4fc8c4c214a22bf8d859a4288a1c2", size = 491034, upload-time = "2025-05-18T19:04:08.222Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/be/c393df00e6e6e9e623a73551774449f2f23b6ec6a502a3297aeeece2c65a/jiter-0.10.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5161e201172de298a8a1baad95eb85db4fb90e902353b1f6a41d64ea64644e25", size = 388529, upload-time = "2025-05-18T19:04:09.566Z" },
+ { url = "https://files.pythonhosted.org/packages/42/3e/df2235c54d365434c7f150b986a6e35f41ebdc2f95acea3036d99613025d/jiter-0.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e2227db6ba93cb3e2bf67c87e594adde0609f146344e8207e8730364db27041", size = 350671, upload-time = "2025-05-18T19:04:10.98Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/77/71b0b24cbcc28f55ab4dbfe029f9a5b73aeadaba677843fc6dc9ed2b1d0a/jiter-0.10.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:15acb267ea5e2c64515574b06a8bf393fbfee6a50eb1673614aa45f4613c0cca", size = 390864, upload-time = "2025-05-18T19:04:12.722Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/d3/ef774b6969b9b6178e1d1e7a89a3bd37d241f3d3ec5f8deb37bbd203714a/jiter-0.10.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:901b92f2e2947dc6dfcb52fd624453862e16665ea909a08398dde19c0731b7f4", size = 522989, upload-time = "2025-05-18T19:04:14.261Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/41/9becdb1d8dd5d854142f45a9d71949ed7e87a8e312b0bede2de849388cb9/jiter-0.10.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d0cb9a125d5a3ec971a094a845eadde2db0de85b33c9f13eb94a0c63d463879e", size = 513495, upload-time = "2025-05-18T19:04:15.603Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/36/3468e5a18238bdedae7c4d19461265b5e9b8e288d3f86cd89d00cbb48686/jiter-0.10.0-cp313-cp313-win32.whl", hash = "sha256:48a403277ad1ee208fb930bdf91745e4d2d6e47253eedc96e2559d1e6527006d", size = 211289, upload-time = "2025-05-18T19:04:17.541Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/07/1c96b623128bcb913706e294adb5f768fb7baf8db5e1338ce7b4ee8c78ef/jiter-0.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:75f9eb72ecb640619c29bf714e78c9c46c9c4eaafd644bf78577ede459f330d4", size = 205074, upload-time = "2025-05-18T19:04:19.21Z" },
+ { url = "https://files.pythonhosted.org/packages/54/46/caa2c1342655f57d8f0f2519774c6d67132205909c65e9aa8255e1d7b4f4/jiter-0.10.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:28ed2a4c05a1f32ef0e1d24c2611330219fed727dae01789f4a335617634b1ca", size = 318225, upload-time = "2025-05-18T19:04:20.583Z" },
+ { url = "https://files.pythonhosted.org/packages/43/84/c7d44c75767e18946219ba2d703a5a32ab37b0bc21886a97bc6062e4da42/jiter-0.10.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14a4c418b1ec86a195f1ca69da8b23e8926c752b685af665ce30777233dfe070", size = 350235, upload-time = "2025-05-18T19:04:22.363Z" },
+ { url = "https://files.pythonhosted.org/packages/01/16/f5a0135ccd968b480daad0e6ab34b0c7c5ba3bc447e5088152696140dcb3/jiter-0.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:d7bfed2fe1fe0e4dda6ef682cee888ba444b21e7a6553e03252e4feb6cf0adca", size = 207278, upload-time = "2025-05-18T19:04:23.627Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/9b/1d646da42c3de6c2188fdaa15bce8ecb22b635904fc68be025e21249ba44/jiter-0.10.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:5e9251a5e83fab8d87799d3e1a46cb4b7f2919b895c6f4483629ed2446f66522", size = 310866, upload-time = "2025-05-18T19:04:24.891Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/0e/26538b158e8a7c7987e94e7aeb2999e2e82b1f9d2e1f6e9874ddf71ebda0/jiter-0.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:023aa0204126fe5b87ccbcd75c8a0d0261b9abdbbf46d55e7ae9f8e22424eeb8", size = 318772, upload-time = "2025-05-18T19:04:26.161Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/fb/d302893151caa1c2636d6574d213e4b34e31fd077af6050a9c5cbb42f6fb/jiter-0.10.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c189c4f1779c05f75fc17c0c1267594ed918996a231593a21a5ca5438445216", size = 344534, upload-time = "2025-05-18T19:04:27.495Z" },
+ { url = "https://files.pythonhosted.org/packages/01/d8/5780b64a149d74e347c5128d82176eb1e3241b1391ac07935693466d6219/jiter-0.10.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:15720084d90d1098ca0229352607cd68256c76991f6b374af96f36920eae13c4", size = 369087, upload-time = "2025-05-18T19:04:28.896Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/5b/f235a1437445160e777544f3ade57544daf96ba7e96c1a5b24a6f7ac7004/jiter-0.10.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4f2fb68e5f1cfee30e2b2a09549a00683e0fde4c6a2ab88c94072fc33cb7426", size = 490694, upload-time = "2025-05-18T19:04:30.183Z" },
+ { url = "https://files.pythonhosted.org/packages/85/a9/9c3d4617caa2ff89cf61b41e83820c27ebb3f7b5fae8a72901e8cd6ff9be/jiter-0.10.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce541693355fc6da424c08b7edf39a2895f58d6ea17d92cc2b168d20907dee12", size = 388992, upload-time = "2025-05-18T19:04:32.028Z" },
+ { url = "https://files.pythonhosted.org/packages/68/b1/344fd14049ba5c94526540af7eb661871f9c54d5f5601ff41a959b9a0bbd/jiter-0.10.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31c50c40272e189d50006ad5c73883caabb73d4e9748a688b216e85a9a9ca3b9", size = 351723, upload-time = "2025-05-18T19:04:33.467Z" },
+ { url = "https://files.pythonhosted.org/packages/41/89/4c0e345041186f82a31aee7b9d4219a910df672b9fef26f129f0cda07a29/jiter-0.10.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fa3402a2ff9815960e0372a47b75c76979d74402448509ccd49a275fa983ef8a", size = 392215, upload-time = "2025-05-18T19:04:34.827Z" },
+ { url = "https://files.pythonhosted.org/packages/55/58/ee607863e18d3f895feb802154a2177d7e823a7103f000df182e0f718b38/jiter-0.10.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:1956f934dca32d7bb647ea21d06d93ca40868b505c228556d3373cbd255ce853", size = 522762, upload-time = "2025-05-18T19:04:36.19Z" },
+ { url = "https://files.pythonhosted.org/packages/15/d0/9123fb41825490d16929e73c212de9a42913d68324a8ce3c8476cae7ac9d/jiter-0.10.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:fcedb049bdfc555e261d6f65a6abe1d5ad68825b7202ccb9692636c70fcced86", size = 513427, upload-time = "2025-05-18T19:04:37.544Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/b3/2bd02071c5a2430d0b70403a34411fc519c2f227da7b03da9ba6a956f931/jiter-0.10.0-cp314-cp314-win32.whl", hash = "sha256:ac509f7eccca54b2a29daeb516fb95b6f0bd0d0d8084efaf8ed5dfc7b9f0b357", size = 210127, upload-time = "2025-05-18T19:04:38.837Z" },
+ { url = "https://files.pythonhosted.org/packages/03/0c/5fe86614ea050c3ecd728ab4035534387cd41e7c1855ef6c031f1ca93e3f/jiter-0.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5ed975b83a2b8639356151cef5c0d597c68376fc4922b45d0eb384ac058cfa00", size = 318527, upload-time = "2025-05-18T19:04:40.612Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/4a/4175a563579e884192ba6e81725fc0448b042024419be8d83aa8a80a3f44/jiter-0.10.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3aa96f2abba33dc77f79b4cf791840230375f9534e5fac927ccceb58c5e604a5", size = 354213, upload-time = "2025-05-18T19:04:41.894Z" },
+]
+
+[[package]]
+name = "jsonpath-python"
+version = "1.0.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b5/49/e582e50b0c54c1b47e714241c4a4767bf28758bf90212248aea8e1ce8516/jsonpath-python-1.0.6.tar.gz", hash = "sha256:dd5be4a72d8a2995c3f583cf82bf3cd1a9544cfdabf2d22595b67aff07349666", size = 18121, upload-time = "2022-03-14T02:35:01.877Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/16/8a/d63959f4eff03893a00e6e63592e3a9f15b9266ed8e0275ab77f8c7dbc94/jsonpath_python-1.0.6-py3-none-any.whl", hash = "sha256:1e3b78df579f5efc23565293612decee04214609208a2335884b3ee3f786b575", size = 7552, upload-time = "2022-03-14T02:34:59.754Z" },
+]
+
+[[package]]
+name = "keyring"
+version = "25.6.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jaraco-classes" },
+ { name = "jaraco-context" },
+ { name = "jaraco-functools" },
+ { name = "jeepney", marker = "sys_platform == 'linux'" },
+ { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
+ { name = "secretstorage", marker = "sys_platform == 'linux'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/70/09/d904a6e96f76ff214be59e7aa6ef7190008f52a0ab6689760a98de0bf37d/keyring-25.6.0.tar.gz", hash = "sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66", size = 62750, upload-time = "2024-12-25T15:26:45.782Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d3/32/da7f44bcb1105d3e88a0b74ebdca50c59121d2ddf71c9e34ba47df7f3a56/keyring-25.6.0-py3-none-any.whl", hash = "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd", size = 39085, upload-time = "2024-12-25T15:26:44.377Z" },
+]
+
+[[package]]
+name = "lz4"
+version = "4.4.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c6/5a/945f5086326d569f14c84ac6f7fcc3229f0b9b1e8cc536b951fd53dfb9e1/lz4-4.4.4.tar.gz", hash = "sha256:070fd0627ec4393011251a094e08ed9fdcc78cb4e7ab28f507638eee4e39abda", size = 171884, upload-time = "2025-04-01T22:55:58.62Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3b/3c/d1d1b926d3688263893461e7c47ed7382a969a0976fc121fc678ec325fc6/lz4-4.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ed6eb9f8deaf25ee4f6fad9625d0955183fdc90c52b6f79a76b7f209af1b6e54", size = 220678, upload-time = "2025-04-01T22:55:41.78Z" },
+ { url = "https://files.pythonhosted.org/packages/26/89/8783d98deb058800dabe07e6cdc90f5a2a8502a9bad8c5343c641120ace2/lz4-4.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:18ae4fe3bafb344dbd09f976d45cbf49c05c34416f2462828f9572c1fa6d5af7", size = 189670, upload-time = "2025-04-01T22:55:42.775Z" },
+ { url = "https://files.pythonhosted.org/packages/22/ab/a491ace69a83a8914a49f7391e92ca0698f11b28d5ce7b2ececa2be28e9a/lz4-4.4.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57fd20c5fc1a49d1bbd170836fccf9a338847e73664f8e313dce6ac91b8c1e02", size = 1238746, upload-time = "2025-04-01T22:55:43.797Z" },
+ { url = "https://files.pythonhosted.org/packages/97/12/a1f2f4fdc6b7159c0d12249456f9fe454665b6126e98dbee9f2bd3cf735c/lz4-4.4.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9cb387c33f014dae4db8cb4ba789c8d2a0a6d045ddff6be13f6c8d9def1d2a6", size = 1265119, upload-time = "2025-04-01T22:55:44.943Z" },
+ { url = "https://files.pythonhosted.org/packages/50/6e/e22e50f5207649db6ea83cd31b79049118305be67e96bec60becf317afc6/lz4-4.4.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d0be9f68240231e1e44118a4ebfecd8a5d4184f0bdf5c591c98dd6ade9720afd", size = 1184954, upload-time = "2025-04-01T22:55:46.161Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/c4/2a458039645fcc6324ece731d4d1361c5daf960b553d1fcb4261ba07d51c/lz4-4.4.4-cp313-cp313-win32.whl", hash = "sha256:e9ec5d45ea43684f87c316542af061ef5febc6a6b322928f059ce1fb289c298a", size = 88289, upload-time = "2025-04-01T22:55:47.601Z" },
+ { url = "https://files.pythonhosted.org/packages/00/96/b8e24ea7537ab418074c226279acfcaa470e1ea8271003e24909b6db942b/lz4-4.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:a760a175b46325b2bb33b1f2bbfb8aa21b48e1b9653e29c10b6834f9bb44ead4", size = 99925, upload-time = "2025-04-01T22:55:48.463Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/a5/f9838fe6aa132cfd22733ed2729d0592259fff074cefb80f19aa0607367b/lz4-4.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:f4c21648d81e0dda38b4720dccc9006ae33b0e9e7ffe88af6bf7d4ec124e2fba", size = 89743, upload-time = "2025-04-01T22:55:49.716Z" },
+]
+
+[[package]]
+name = "mistralai"
+version = "1.5.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "eval-type-backport" },
+ { name = "httpx" },
+ { name = "jsonpath-python" },
+ { name = "pydantic" },
+ { name = "python-dateutil" },
+ { name = "typing-inspect" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/91/36/a722dc70b61b8a7920d2fb5c8cff85b7816e071b1af96615b2b19c042412/mistralai-1.5.1.tar.gz", hash = "sha256:ce4b8c7aa587521c46dbc45d42e27575f6197c0229f47242e5b331875104707b", size = 133512, upload-time = "2025-03-06T18:30:30.997Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/25/41/ada2fcb82ef918a7906bb63e008781ffa29d7d8608145d4b41d4d477504c/mistralai-1.5.1-py3-none-any.whl", hash = "sha256:881f8a1b9a7966d15bd1eb4ed05df09483c261f826c1b9d153ceeca605dc79ac", size = 278253, upload-time = "2025-03-06T18:30:29.11Z" },
+]
+
+[[package]]
+name = "more-itertools"
+version = "10.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" },
+]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433, upload-time = "2023-02-04T12:11:27.157Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695, upload-time = "2023-02-04T12:11:25.002Z" },
+]
+
+[[package]]
+name = "openai"
+version = "1.98.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "distro" },
+ { name = "httpx" },
+ { name = "jiter" },
+ { name = "pydantic" },
+ { name = "sniffio" },
+ { name = "tqdm" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d8/9d/52eadb15c92802711d6b6cf00df3a6d0d18b588f4c5ba5ff210c6419fc03/openai-1.98.0.tar.gz", hash = "sha256:3ee0fcc50ae95267fd22bd1ad095ba5402098f3df2162592e68109999f685427", size = 496695, upload-time = "2025-07-30T12:48:03.701Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a8/fe/f64631075b3d63a613c0d8ab761d5941631a470f6fa87eaaee1aa2b4ec0c/openai-1.98.0-py3-none-any.whl", hash = "sha256:b99b794ef92196829120e2df37647722104772d2a74d08305df9ced5f26eae34", size = 767713, upload-time = "2025-07-30T12:48:01.264Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
+]
+
+[[package]]
+name = "pdf2image"
+version = "1.17.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pillow" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/00/d8/b280f01045555dc257b8153c00dee3bc75830f91a744cd5f84ef3a0a64b1/pdf2image-1.17.0.tar.gz", hash = "sha256:eaa959bc116b420dd7ec415fcae49b98100dda3dd18cd2fdfa86d09f112f6d57", size = 12811, upload-time = "2024-01-07T20:33:01.965Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/33/61766ae033518957f877ab246f87ca30a85b778ebaad65b7f74fa7e52988/pdf2image-1.17.0-py3-none-any.whl", hash = "sha256:ecdd58d7afb810dffe21ef2b1bbc057ef434dabbac6c33778a38a3f7744a27e2", size = 11618, upload-time = "2024-01-07T20:32:59.957Z" },
+]
+
+[[package]]
+name = "piexif"
+version = "1.1.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fa/84/a3f25cec7d0922bf60be8000c9739d28d24b6896717f44cc4cfb843b1487/piexif-1.1.3.zip", hash = "sha256:83cb35c606bf3a1ea1a8f0a25cb42cf17e24353fd82e87ae3884e74a302a5f1b", size = 1011134, upload-time = "2019-07-01T15:29:23.045Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2c/d8/6f63147dd73373d051c5eb049ecd841207f898f50a5a1d4378594178f6cf/piexif-1.1.3-py2.py3-none-any.whl", hash = "sha256:3bc435d171720150b81b15d27e05e54b8abbde7b4242cddd81ef160d283108b6", size = 20691, upload-time = "2019-07-01T15:43:20.907Z" },
+]
+
+[[package]]
+name = "pillow"
+version = "11.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" },
+ { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" },
+ { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" },
+ { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" },
+ { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" },
+ { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" },
+ { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" },
+ { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" },
+ { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" },
+ { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" },
+ { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" },
+ { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" },
+ { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" },
+ { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" },
+ { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" },
+ { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" },
+]
+
+[[package]]
+name = "playground"
+version = "0.0.1"
+source = { virtual = "." }
+dependencies = [
+ { name = "browser-cookie3" },
+ { name = "bs4" },
+ { name = "click" },
+ { name = "cryptography" },
+ { name = "debugpy" },
+ { name = "httpx" },
+ { name = "keyring" },
+ { name = "mistralai" },
+ { name = "openai" },
+ { name = "pdf2image" },
+ { name = "piexif" },
+ { name = "playwright" },
+ { name = "playwright-stealth" },
+ { name = "psycopg", extra = ["binary"] },
+ { name = "pytest" },
+ { name = "srt" },
+ { name = "typer-slim" },
+ { name = "watchdog" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "browser-cookie3", specifier = ">=0.20.1" },
+ { name = "bs4", specifier = ">=0.0.2" },
+ { name = "click", specifier = ">=8.3.0" },
+ { name = "cryptography", specifier = ">=46.0.3" },
+ { name = "debugpy", specifier = ">=1.8.15" },
+ { name = "httpx", specifier = ">=0.28.1" },
+ { name = "keyring", specifier = ">=25.6.0" },
+ { name = "mistralai", specifier = ">=1.5.1" },
+ { name = "openai", specifier = ">=1.98.0" },
+ { name = "pdf2image", specifier = ">=1.17.0" },
+ { name = "piexif", specifier = ">=1.1.3" },
+ { name = "playwright", extras = ["chromium"], specifier = ">=1.55.0" },
+ { name = "playwright-stealth", specifier = ">=2.0.0" },
+ { name = "psycopg", extras = ["binary"], specifier = ">=3.3.4" },
+ { name = "pytest", specifier = ">=9.1.1" },
+ { name = "srt", specifier = ">=3.5.3" },
+ { name = "typer-slim", specifier = ">=0.19.2" },
+ { name = "watchdog", specifier = ">=6.0.0" },
+]
+
+[[package]]
+name = "playwright"
+version = "1.55.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "greenlet" },
+ { name = "pyee" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/80/3a/c81ff76df266c62e24f19718df9c168f49af93cabdbc4608ae29656a9986/playwright-1.55.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:d7da108a95001e412effca4f7610de79da1637ccdf670b1ae3fdc08b9694c034", size = 40428109, upload-time = "2025-08-28T15:46:20.357Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/f5/bdb61553b20e907196a38d864602a9b4a461660c3a111c67a35179b636fa/playwright-1.55.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8290cf27a5d542e2682ac274da423941f879d07b001f6575a5a3a257b1d4ba1c", size = 38687254, upload-time = "2025-08-28T15:46:23.925Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/64/48b2837ef396487807e5ab53c76465747e34c7143fac4a084ef349c293a8/playwright-1.55.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:25b0d6b3fd991c315cca33c802cf617d52980108ab8431e3e1d37b5de755c10e", size = 40428108, upload-time = "2025-08-28T15:46:27.119Z" },
+ { url = "https://files.pythonhosted.org/packages/08/33/858312628aa16a6de97839adc2ca28031ebc5391f96b6fb8fdf1fcb15d6c/playwright-1.55.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c6d4d8f6f8c66c483b0835569c7f0caa03230820af8e500c181c93509c92d831", size = 45905643, upload-time = "2025-08-28T15:46:30.312Z" },
+ { url = "https://files.pythonhosted.org/packages/83/83/b8d06a5b5721931aa6d5916b83168e28bd891f38ff56fe92af7bdee9860f/playwright-1.55.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29a0777c4ce1273acf90c87e4ae2fe0130182100d99bcd2ae5bf486093044838", size = 45296647, upload-time = "2025-08-28T15:46:33.221Z" },
+ { url = "https://files.pythonhosted.org/packages/06/2e/9db64518aebcb3d6ef6cd6d4d01da741aff912c3f0314dadb61226c6a96a/playwright-1.55.0-py3-none-win32.whl", hash = "sha256:29e6d1558ad9d5b5c19cbec0a72f6a2e35e6353cd9f262e22148685b86759f90", size = 35476046, upload-time = "2025-08-28T15:46:36.184Z" },
+ { url = "https://files.pythonhosted.org/packages/46/4f/9ba607fa94bb9cee3d4beb1c7b32c16efbfc9d69d5037fa85d10cafc618b/playwright-1.55.0-py3-none-win_amd64.whl", hash = "sha256:7eb5956473ca1951abb51537e6a0da55257bb2e25fc37c2b75af094a5c93736c", size = 35476048, upload-time = "2025-08-28T15:46:38.867Z" },
+ { url = "https://files.pythonhosted.org/packages/21/98/5ca173c8ec906abde26c28e1ecb34887343fd71cc4136261b90036841323/playwright-1.55.0-py3-none-win_arm64.whl", hash = "sha256:012dc89ccdcbd774cdde8aeee14c08e0dd52ddb9135bf10e9db040527386bd76", size = 31225543, upload-time = "2025-08-28T15:46:41.613Z" },
+]
+
+[[package]]
+name = "playwright-stealth"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "playwright" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b0/50/e008cc2d9f01e351d69f0bb0f394882c517611eec0ee3d5f8dd8535ce719/playwright_stealth-2.0.0.tar.gz", hash = "sha256:4f44d416d4226689895a4d1cfb40e8d137216c0c9710ea8f84bae2dbf1186fc5", size = 25723, upload-time = "2025-06-18T03:54:54.528Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b9/4e/c37ac19cea166a97de3a9690ad5ba340b3f4f4fcd5bf8237cedb2c2c7076/playwright_stealth-2.0.0-py3-none-any.whl", hash = "sha256:9eb3af1fd21619aac9fdd13a4a08141ed67159ac6310a94f7d2f758ba0cbe179", size = 32466, upload-time = "2025-06-18T03:54:53.394Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "psycopg"
+version = "3.3.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "tzdata", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" },
+]
+
+[package.optional-dependencies]
+binary = [
+ { name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
+]
+
+[[package]]
+name = "psycopg-binary"
+version = "3.3.4"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" },
+ { url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" },
+ { url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" },
+ { url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" },
+ { url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" },
+ { url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" },
+ { url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" },
+ { url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" },
+ { url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" },
+ { url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" },
+]
+
+[[package]]
+name = "pycparser"
+version = "2.23"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
+]
+
+[[package]]
+name = "pycryptodomex"
+version = "3.23.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157, upload-time = "2025-05-17T17:23:41.434Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2e/00/10edb04777069a42490a38c137099d4b17ba6e36a4e6e28bdc7470e9e853/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:7b37e08e3871efe2187bc1fd9320cc81d87caf19816c648f24443483005ff886", size = 2498764, upload-time = "2025-05-17T17:22:21.453Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/3f/2872a9c2d3a27eac094f9ceaa5a8a483b774ae69018040ea3240d5b11154/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:91979028227543010d7b2ba2471cf1d1e398b3f183cb105ac584df0c36dac28d", size = 1643012, upload-time = "2025-05-17T17:22:23.702Z" },
+ { url = "https://files.pythonhosted.org/packages/70/af/774c2e2b4f6570fbf6a4972161adbb183aeeaa1863bde31e8706f123bf92/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8962204c47464d5c1c4038abeadd4514a133b28748bcd9fa5b6d62e3cec6fa", size = 2187643, upload-time = "2025-05-17T17:22:26.37Z" },
+ { url = "https://files.pythonhosted.org/packages/de/a3/71065b24cb889d537954cedc3ae5466af00a2cabcff8e29b73be047e9a19/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a33986a0066860f7fcf7c7bd2bc804fa90e434183645595ae7b33d01f3c91ed8", size = 2273762, upload-time = "2025-05-17T17:22:28.313Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/0b/ff6f43b7fbef4d302c8b981fe58467b8871902cdc3eb28896b52421422cc/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7947ab8d589e3178da3d7cdeabe14f841b391e17046954f2fbcd941705762b5", size = 2313012, upload-time = "2025-05-17T17:22:30.57Z" },
+ { url = "https://files.pythonhosted.org/packages/02/de/9d4772c0506ab6da10b41159493657105d3f8bb5c53615d19452afc6b315/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c25e30a20e1b426e1f0fa00131c516f16e474204eee1139d1603e132acffc314", size = 2186856, upload-time = "2025-05-17T17:22:32.819Z" },
+ { url = "https://files.pythonhosted.org/packages/28/ad/8b30efcd6341707a234e5eba5493700a17852ca1ac7a75daa7945fcf6427/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:da4fa650cef02db88c2b98acc5434461e027dce0ae8c22dd5a69013eaf510006", size = 2347523, upload-time = "2025-05-17T17:22:35.386Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/02/16868e9f655b7670dbb0ac4f2844145cbc42251f916fc35c414ad2359849/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:58b851b9effd0d072d4ca2e4542bf2a4abcf13c82a29fd2c93ce27ee2a2e9462", size = 2272825, upload-time = "2025-05-17T17:22:37.632Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/18/4ca89ac737230b52ac8ffaca42f9c6f1fd07c81a6cd821e91af79db60632/pycryptodomex-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:a9d446e844f08299236780f2efa9898c818fe7e02f17263866b8550c7d5fb328", size = 1772078, upload-time = "2025-05-17T17:22:40Z" },
+ { url = "https://files.pythonhosted.org/packages/73/34/13e01c322db027682e00986873eca803f11c56ade9ba5bbf3225841ea2d4/pycryptodomex-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:bc65bdd9fc8de7a35a74cab1c898cab391a4add33a8fe740bda00f5976ca4708", size = 1803656, upload-time = "2025-05-17T17:22:42.139Z" },
+ { url = "https://files.pythonhosted.org/packages/54/68/9504c8796b1805d58f4425002bcca20f12880e6fa4dc2fc9a668705c7a08/pycryptodomex-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c885da45e70139464f082018ac527fdaad26f1657a99ee13eecdce0f0ca24ab4", size = 1707172, upload-time = "2025-05-17T17:22:44.704Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240, upload-time = "2025-05-17T17:22:46.953Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042, upload-time = "2025-05-17T17:22:49.098Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227, upload-time = "2025-05-17T17:22:51.139Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c", size = 2272578, upload-time = "2025-05-17T17:22:53.676Z" },
+ { url = "https://files.pythonhosted.org/packages/46/9f/bda9c49a7c1842820de674ab36c79f4fbeeee03f8ff0e4f3546c3889076b/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c", size = 2312166, upload-time = "2025-05-17T17:22:56.585Z" },
+ { url = "https://files.pythonhosted.org/packages/03/cc/870b9bf8ca92866ca0186534801cf8d20554ad2a76ca959538041b7a7cf4/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003", size = 2185467, upload-time = "2025-05-17T17:22:59.237Z" },
+ { url = "https://files.pythonhosted.org/packages/96/e3/ce9348236d8e669fea5dd82a90e86be48b9c341210f44e25443162aba187/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744", size = 2346104, upload-time = "2025-05-17T17:23:02.112Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/e9/e869bcee87beb89040263c416a8a50204f7f7a83ac11897646c9e71e0daf/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd", size = 2271038, upload-time = "2025-05-17T17:23:04.872Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969, upload-time = "2025-05-17T17:23:07.115Z" },
+ { url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124, upload-time = "2025-05-17T17:23:09.267Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.10.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-types" },
+ { name = "pydantic-core" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681, upload-time = "2025-01-24T01:42:12.693Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696, upload-time = "2025-01-24T01:42:10.371Z" },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.27.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443, upload-time = "2024-12-18T11:31:54.917Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709, upload-time = "2024-12-18T11:29:03.193Z" },
+ { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273, upload-time = "2024-12-18T11:29:05.306Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027, upload-time = "2024-12-18T11:29:07.294Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888, upload-time = "2024-12-18T11:29:09.249Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738, upload-time = "2024-12-18T11:29:11.23Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138, upload-time = "2024-12-18T11:29:16.396Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025, upload-time = "2024-12-18T11:29:20.25Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633, upload-time = "2024-12-18T11:29:23.877Z" },
+ { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404, upload-time = "2024-12-18T11:29:25.872Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130, upload-time = "2024-12-18T11:29:29.252Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946, upload-time = "2024-12-18T11:29:31.338Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387, upload-time = "2024-12-18T11:29:33.481Z" },
+ { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453, upload-time = "2024-12-18T11:29:35.533Z" },
+ { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186, upload-time = "2024-12-18T11:29:37.649Z" },
+]
+
+[[package]]
+name = "pyee"
+version = "13.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250, upload-time = "2025-03-17T18:53:15.955Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730, upload-time = "2025-03-17T18:53:14.532Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
+]
+
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
+]
+
+[[package]]
+name = "pywin32"
+version = "311"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" },
+ { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" },
+ { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
+]
+
+[[package]]
+name = "pywin32-ctypes"
+version = "0.2.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
+]
+
+[[package]]
+name = "secretstorage"
+version = "3.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cryptography" },
+ { name = "jeepney" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/31/9f/11ef35cf1027c1339552ea7bfe6aaa74a8516d8b5caf6e7d338daf54fd80/secretstorage-3.4.0.tar.gz", hash = "sha256:c46e216d6815aff8a8a18706a2fbfd8d53fcbb0dce99301881687a1b0289ef7c", size = 19748, upload-time = "2025-09-09T16:42:13.859Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/91/ff/2e2eed29e02c14a5cb6c57f09b2d5b40e65d6cc71f45b52e0be295ccbc2f/secretstorage-3.4.0-py3-none-any.whl", hash = "sha256:0e3b6265c2c63509fb7415717607e4b2c9ab767b7f344a57473b779ca13bd02e", size = 15272, upload-time = "2025-09-09T16:42:12.744Z" },
+]
+
+[[package]]
+name = "shadowcopy"
+version = "0.0.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "wmi" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/12/44/c00420a3b7bcdf830529b933392b566c876a4944a24f31e126eb6fd28647/shadowcopy-0.0.4.tar.gz", hash = "sha256:ed89817dda065f893607a04c0b7d6b3b34c3507a4711f441111a4bcb1b1826c0", size = 4138, upload-time = "2023-07-08T00:01:35.266Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ad/32/fdea8e7f7b2b8bae13ee6ab7df1a10d28a24dbeb03a62ff033563f95d77c/shadowcopy-0.0.4-py3-none-any.whl", hash = "sha256:fc51e59a639dc6a5a3a7a9b4e3ecadc71989e339f2d995d90aaa491acd4ba4eb", size = 4212, upload-time = "2023-07-08T00:01:34.042Z" },
+]
+
+[[package]]
+name = "six"
+version = "1.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
+]
+
+[[package]]
+name = "sniffio"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
+]
+
+[[package]]
+name = "soupsieve"
+version = "2.8"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" },
+]
+
+[[package]]
+name = "srt"
+version = "3.5.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/66/b7/4a1bc231e0681ebf339337b0cd05b91dc6a0d701fa852bb812e244b7a030/srt-3.5.3.tar.gz", hash = "sha256:4884315043a4f0740fd1f878ed6caa376ac06d70e135f306a6dc44632eed0cc0", size = 28296, upload-time = "2023-03-28T02:35:44.007Z" }
+
+[[package]]
+name = "tqdm"
+version = "4.67.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" },
+]
+
+[[package]]
+name = "typer-slim"
+version = "0.19.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/75/d6/489402eda270c00555213bdd53061b23a0ae2b5dccbfe428ebcc9562d883/typer_slim-0.19.2.tar.gz", hash = "sha256:6f601e28fb8249a7507f253e35fb22ccc701403ce99bea6a9923909ddbfcd133", size = 104788, upload-time = "2025-09-23T09:47:42.917Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a5/19/7aef771b3293e1b7c749eebb2948bb7ccd0e9b56aa222eb4d5e015087730/typer_slim-0.19.2-py3-none-any.whl", hash = "sha256:1c9cdbbcd5b8d30f4118d3cb7c52dc63438b751903fbd980a35df1dfe10c6c91", size = 46806, upload-time = "2025-09-23T09:47:41.385Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.12.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321, upload-time = "2024-06-07T18:52:15.995Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438, upload-time = "2024-06-07T18:52:13.582Z" },
+]
+
+[[package]]
+name = "typing-inspect"
+version = "0.9.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mypy-extensions" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825, upload-time = "2023-05-24T20:25:47.612Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827, upload-time = "2023-05-24T20:25:45.287Z" },
+]
+
+[[package]]
+name = "tzdata"
+version = "2026.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
+]
+
+[[package]]
+name = "watchdog"
+version = "6.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" },
+ { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" },
+ { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" },
+ { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" },
+ { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" },
+ { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
+]
+
+[[package]]
+name = "wmi"
+version = "1.5.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pywin32" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d4/66/6364deb0a03415f96c66803d8c4379f808f2401da3bdb183348487b10510/WMI-1.5.1.tar.gz", hash = "sha256:b6a6be5711b1b6c8d55bda7a8befd75c48c12b770b9d227d31c1737dbf0d40a6", size = 26254, upload-time = "2020-04-28T08:22:58.096Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ee/b9/a80d1ed4d115dac8e2ac08d16af046a77ab58e3d186e22395bf2add24090/WMI-1.5.1-py2.py3-none-any.whl", hash = "sha256:1d6b085e5c445141c475476000b661f60fff1aaa19f76bf82b7abb92e0ff4942", size = 28912, upload-time = "2020-04-28T08:22:56.055Z" },
+]
diff --git a/youtube_subbed.py b/youtube_subbed.py
new file mode 100755
index 0000000..25e55b0
--- /dev/null
+++ b/youtube_subbed.py
@@ -0,0 +1,175 @@
+#!/usr/bin/env -S uv run --script
+# /// script
+# dependencies = ["httpx", "srt"]
+# ///
+
+import argparse
+import dataclasses
+import logging
+import subprocess
+import os
+from pathlib import Path
+import httpx
+import srt
+import subtitle_translator
+
+
+def parse_args() -> argparse.Namespace:
+ p = argparse.ArgumentParser()
+ p.add_argument("url", help="YouTube video URL")
+ p.add_argument("--lang", help="Target language code (e.g. de, fr, es)")
+ p.add_argument("--quality", choices=["720p", "1080p"], default="1080p", help="Video quality to download")
+ p.add_argument("--hardcode", action="store_true", help="Burn subtitles into video")
+ return p.parse_args()
+
+
+@dataclasses.dataclass
+class Video:
+ title: str
+ video_path: Path
+ subtitle_path: Path
+
+
+def download_video(url: str, save_dir: Path, quality: str = "1080p") -> Video:
+ result = subprocess.run(["yt-dlp", "--get-title", url], capture_output=True, text=True, check=True)
+ title = result.stdout.strip()
+ safe_title = "".join(c if c.isalnum() or c in " .-_" else "_" for c in title)
+ height = quality.rstrip("p")
+ fmt = f"bestvideo[height={height}]+bestaudio/best[height={height}]/best"
+ # Check if subtitles already exist
+ sub_path = None
+ for pat in (f"{safe_title}.en.srt", f"{safe_title}.en.*.srt"):
+ found = list(save_dir.glob(pat))
+ if found:
+ sub_path = found[0]
+ break
+ if not sub_path:
+ # Download only subtitles first
+ sub_cmd = [
+ "yt-dlp",
+ "--skip-download",
+ "--write-auto-sub",
+ "--write-subs",
+ "--sub-lang",
+ "en",
+ "--convert-subs",
+ "srt",
+ "--output",
+ str(save_dir / f"{safe_title}.%(ext)s"),
+ url,
+ ]
+ logging.info(f"Downloading subtitles: {url}")
+ subprocess.run(sub_cmd, check=True)
+ sub_path = save_dir / f"{safe_title}.en.srt"
+ # Check if video already exists
+ for ext in ("mp4", "mkv", "webm"):
+ video_path = save_dir / f"{safe_title}.{ext}"
+ if video_path.exists():
+ logging.info(f"Video already exists: {video_path.name}, skipping download.")
+ return Video(title=safe_title, video_path=video_path, subtitle_path=sub_path)
+ outtmpl = str(save_dir / f"{safe_title}.%(ext)s")
+ cmd = [
+ "yt-dlp",
+ "-f",
+ fmt,
+ "--output",
+ outtmpl,
+ url,
+ ]
+ logging.info(f"Downloading video: {url} at {quality}")
+ subprocess.run(cmd, check=True)
+ for ext in ("mp4", "mkv", "webm"):
+ video_path = save_dir / f"{safe_title}.{ext}"
+ if video_path.exists():
+ return Video(title=safe_title, video_path=video_path, subtitle_path=sub_path)
+ raise RuntimeError("Video not downloaded")
+
+
+def hardcode_subs(video: Path, srt: Path, lang: str) -> Path:
+ out = video.with_name(f"{video.stem}.{lang}.hardcoded.mp4")
+ cmd = [
+ "ffmpeg",
+ "-y",
+ "-hwaccel",
+ "videotoolbox",
+ "-i",
+ str(video),
+ "-vf",
+ f"subtitles='{srt}'",
+ "-c:v",
+ "h264_videotoolbox",
+ "-crf",
+ "30",
+ "-c:a",
+ "copy",
+ str(out),
+ ]
+ logging.info(f"Hardcoding subtitles into video: {out.name}")
+ subprocess.run(cmd, check=True)
+ return out
+
+
+type Subs = dict[str, Path]
+
+
+def embed_subs(video_path: Path, subs: dict[str, Path]) -> Path:
+ # subs: {lang: srt_path}
+ out = video_path.with_name(f"{video_path.stem}.embedded.mp4")
+ cmd = [
+ "ffmpeg",
+ "-y",
+ "-i",
+ str(video_path),
+ ]
+ # Add each subtitle as an input
+ for srt_path in subs.values():
+ cmd.extend(["-i", str(srt_path)])
+ # Copy video and audio streams (no re-encoding)
+ cmd.extend(
+ [
+ "-c:v",
+ "copy",
+ "-c:a",
+ "copy",
+ ]
+ )
+ # Add subtitle codecs and metadata for each sub
+ for i, lang in enumerate(subs.keys()):
+ cmd.extend([f"-c:s:{i}", "mov_text"])
+ cmd.extend([f"-metadata:s:s:{i}", f"language={lang}"])
+ # Map video, audio, and all subtitle streams
+ cmd.extend(["-map", "0:v", "-map", "0:a"])
+ for i in range(len(subs)):
+ cmd.extend(["-map", f"{i + 1}:s"])
+ cmd.append(str(out))
+ logging.info(f"Embedding {len(subs)} subtitles into video (no re-encoding): {out.name}")
+ subprocess.run(cmd, check=True)
+ return out
+
+
+def main():
+ logging.basicConfig(level=logging.INFO, format="%(name)s: %(asctime)s %(levelname)s: %(message)s")
+ logging.getLogger("httpx").setLevel(logging.WARNING) # Suppress httpx debug logs
+ args = parse_args()
+ save_dir = Path.cwd()
+ vid = download_video(args.url, save_dir=save_dir, quality=args.quality)
+ subs = {"en": vid.subtitle_path}
+ if args.lang:
+ translated_path = vid.subtitle_path.with_name(f"{vid.subtitle_path.name}.{args.lang}.srt")
+ subtitle_translator.translate(
+ subtitle_path=vid.subtitle_path,
+ lang=args.lang,
+ save_path=translated_path,
+ condense=2,
+ )
+ subs[args.lang] = translated_path
+
+ if args.hardcode:
+ out = hardcode_subs(vid.video_path, subs[args.lang], args.lang)
+ else:
+ out = embed_subs(video_path=vid.video_path, subs=subs)
+ logging.info(f"Saved: {vid.title} to {out.name}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/youtube_transcript_v2.py b/youtube_transcript_v2.py
new file mode 100755
index 0000000..1469de7
--- /dev/null
+++ b/youtube_transcript_v2.py
@@ -0,0 +1,99 @@
+#!/usr/bin/env -S uv run --script
+# /// script
+# dependencies = [
+# "youtube-transcript-api",
+# ]
+# ///
+
+import argparse
+import logging
+import sys
+from pathlib import Path
+from urllib.parse import urlparse, parse_qs
+
+from youtube_transcript_api import YouTubeTranscriptApi
+
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
+
+
+def extract_video_id(url: str) -> str:
+ parsed = urlparse(url)
+
+ if parsed.hostname in ("youtu.be", "www.youtu.be"):
+ return parsed.path.lstrip("/")
+
+ if parsed.hostname in ("youtube.com", "www.youtube.com", "m.youtube.com"):
+ if parsed.path == "/watch":
+ return parse_qs(parsed.query)["v"][0]
+ elif parsed.path.startswith("/embed/"):
+ return parsed.path.split("/")[2]
+ elif parsed.path.startswith("/v/"):
+ return parsed.path.split("/")[2]
+
+ raise ValueError(f"Could not extract video ID from URL: {url}")
+
+
+def format_timestamp(seconds: float) -> str:
+ hours = int(seconds // 3600)
+ minutes = int((seconds % 3600) // 60)
+ secs = int(seconds % 60)
+ millis = int((seconds % 1) * 1000)
+ return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
+
+
+def transcript_to_srt(transcript: list[dict]) -> str:
+ srt_lines = []
+ for i, entry in enumerate(transcript, start=1):
+ start_time = format_timestamp(entry["start"])
+ end_time = format_timestamp(entry["start"] + entry["duration"])
+ text = entry["text"]
+
+ srt_lines.append(f"{i}")
+ srt_lines.append(f"{start_time} --> {end_time}")
+ srt_lines.append(text)
+ srt_lines.append("")
+
+ return "\n".join(srt_lines)
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Fetch YouTube video transcripts",
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ )
+ parser.add_argument("url", help="YouTube video URL")
+ parser.add_argument(
+ "--save-srt",
+ type=Path,
+ metavar="FILE_PATH",
+ help="Save transcript as SRT file to the specified path",
+ )
+ return parser.parse_args()
+
+
+def main() -> None:
+ args = parse_args()
+
+ try:
+ video_id = extract_video_id(args.url)
+ logging.info(f"Fetching transcript for video ID: {video_id}")
+
+ ytt_api = YouTubeTranscriptApi()
+ transcript = ytt_api.fetch(video_id)
+
+ if args.save_srt:
+ srt_content = transcript_to_srt(transcript.to_raw_data())
+ args.save_srt.write_text(srt_content)
+ logging.info(f"SRT file saved to: {args.save_srt}")
+ else:
+ text = "\n".join(snippet.text for snippet in transcript)
+ print(text)
+
+ except Exception as e:
+ logging.error(f"Failed to fetch transcript: {e}")
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/yt-history-remover.user.js b/yt-history-remover.user.js
new file mode 100644
index 0000000..384e02f
--- /dev/null
+++ b/yt-history-remover.user.js
@@ -0,0 +1,182 @@
+// ==UserScript==
+// @name YouTube History Backspace Remover
+// @namespace http://tampermonkey.net/
+// @version 2.0
+// @description Hover over a video in YouTube watch history and press Backspace to remove it
+// @author You
+// @match https://www.youtube.com/feed/history
+// @match https://www.youtube.com/feed/history/*
+// @grant none
+// ==/UserScript==
+
+(function() {
+ 'use strict';
+
+ // videoId → feedbackToken, populated from ytInitialData
+ let tokenMap = {};
+ let hoveredLink = null;
+ let isProcessing = false;
+
+ // --- Token extraction from ytInitialData ---
+
+ function extractTokens(data) {
+ const tokens = {};
+ (function walk(obj) {
+ if (!obj || typeof obj !== 'object') return;
+ if (Array.isArray(obj)) { obj.forEach(walk); return; }
+ if (obj.listItemViewModel) {
+ const lv = obj.listItemViewModel;
+ const title = lv.title?.content || '';
+ if (title.toLowerCase().includes('remove from watch history')) {
+ const ep = lv.rendererContext?.commandContext?.onTap?.innertubeCommand?.feedbackEndpoint;
+ if (ep?.feedbackToken && ep?.contentId) {
+ tokens[ep.contentId] = ep.feedbackToken;
+ }
+ }
+ }
+ for (const v of Object.values(obj)) walk(v);
+ })(data);
+ return tokens;
+ }
+
+ function refreshTokens() {
+ if (!window.ytInitialData) return;
+ const found = extractTokens(window.ytInitialData);
+ const n = Object.keys(found).length;
+ if (n > 0) {
+ tokenMap = Object.assign(tokenMap, found);
+ console.log(`[YT History Remover] ${n} tokens loaded (total: ${Object.keys(tokenMap).length})`);
+ }
+ }
+
+ // --- SAPISIDHASH for Authorization header ---
+
+ async function buildAuthHeader() {
+ const sapisid = document.cookie.match(/(?:^|;\s*)SAPISID=([^;]+)/)?.[1];
+ if (!sapisid) return null;
+ const ts = Math.floor(Date.now() / 1000);
+ const buf = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(`${ts} ${sapisid} https://www.youtube.com`));
+ const hex = [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
+ return `SAPISIDHASH ${ts}_${hex}`;
+ }
+
+ // --- API call to remove from history ---
+
+ async function callFeedbackApi(feedbackToken) {
+ const ctx = window.ytcfg?.data_?.INNERTUBE_CONTEXT;
+ if (!ctx) { console.log('[YT History Remover] No INNERTUBE_CONTEXT'); return false; }
+
+ const auth = await buildAuthHeader();
+ const headers = {
+ 'Content-Type': 'application/json',
+ 'X-YouTube-Client-Name': '1',
+ 'X-YouTube-Client-Version': window.ytcfg?.data_?.INNERTUBE_CLIENT_VERSION || '',
+ 'X-Origin': 'https://www.youtube.com',
+ };
+ if (auth) headers['Authorization'] = auth;
+
+ const res = await fetch('/youtubei/v1/feedback?prettyPrint=false', {
+ method: 'POST',
+ headers,
+ credentials: 'include',
+ body: JSON.stringify({ context: ctx, feedbackTokens: [feedbackToken] }),
+ });
+ if (!res.ok) { console.log(`[YT History Remover] HTTP ${res.status}`); return false; }
+ const json = await res.json();
+ return json.feedbackResponses?.[0]?.isProcessed === true;
+ }
+
+ // --- DOM helpers ---
+
+ function getVideoId(href) {
+ try {
+ const url = new URL(href);
+ if (url.pathname.startsWith('/shorts/')) return url.pathname.split('/')[2] || null;
+ return url.searchParams.get('v');
+ } catch { return null; }
+ }
+
+ function findVideoLink(el) {
+ while (el && el !== document.body) {
+ if (el.tagName === 'A' && el.href && (el.href.includes('/watch?') || el.href.includes('/shorts/')))
+ return el;
+ el = el.parentElement;
+ }
+ return null;
+ }
+
+ function hideInDOM(videoId) {
+ for (const sel of [`a[href*="v=${videoId}"]`, `a[href*="/shorts/${videoId}"]`]) {
+ const link = document.querySelector(sel);
+ if (!link) continue;
+ const row = link.closest('yt-lockup-view-model')
+ || link.closest('ytm-shorts-lockup-view-model')
+ || link.closest('ytd-item-section-renderer');
+ if (row) { row.style.display = 'none'; return; }
+ }
+ }
+
+ // --- Main remove action ---
+
+ async function tryRemove() {
+ if (isProcessing || !hoveredLink) return;
+ isProcessing = true;
+ try {
+ const videoId = getVideoId(hoveredLink.href);
+ if (!videoId) return;
+ const token = tokenMap[videoId];
+ if (!token) {
+ console.log(`[YT History Remover] No token for ${videoId}. Tokens available: ${Object.keys(tokenMap).length}`);
+ return;
+ }
+ const ok = await callFeedbackApi(token);
+ if (ok) {
+ hideInDOM(videoId);
+ delete tokenMap[videoId];
+ hoveredLink = null;
+ console.log(`[YT History Remover] Removed ${videoId}`);
+ } else {
+ console.log(`[YT History Remover] API returned not-processed for ${videoId}`);
+ }
+ } catch (e) {
+ console.error('[YT History Remover]', e);
+ } finally {
+ isProcessing = false;
+ }
+ }
+
+ // --- Event listeners ---
+
+ document.addEventListener('mouseover', e => {
+ if (isProcessing) return;
+ const link = findVideoLink(e.target);
+ if (link) hoveredLink = link;
+ });
+
+ document.addEventListener('keydown', e => {
+ if (e.key !== 'Backspace' || !hoveredLink || isProcessing) return;
+ const ae = document.activeElement;
+ if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.isContentEditable)) return;
+ e.preventDefault();
+ tryRemove();
+ });
+
+ document.addEventListener('mouseout', e => {
+ if (isProcessing || !hoveredLink) return;
+ const rt = e.relatedTarget;
+ if (!rt || !hoveredLink.contains(rt)) hoveredLink = null;
+ });
+
+ // Re-extract when YouTube finishes a SPA navigation
+ document.addEventListener('yt-navigate-finish', () => setTimeout(refreshTokens, 500));
+
+ // Polling: re-read ytInitialData (updated by YouTube on SPA nav) and clean stale refs
+ setInterval(() => {
+ refreshTokens();
+ if (hoveredLink && !document.contains(hoveredLink)) hoveredLink = null;
+ }, 2000);
+
+ // Initial extraction
+ refreshTokens();
+ console.log(`[YT History Remover] Ready. Tokens loaded: ${Object.keys(tokenMap).length}`);
+})();