Compare commits

...
10 Commits
45 changed files with 11143 additions and 101 deletions
@@ -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`.
+1
View File
@@ -0,0 +1 @@
3.13
+2 -1
View File
@@ -7,9 +7,10 @@
{ {
"name": "Python: Current File", "name": "Python: Current File",
"type": "python", "type": "python",
"args": ["2022-12-06"], "env": {"MISTRAL_API_KEY":"8J5hUwxVMCiQjhXxElefDqU7XJYAvVXn", "OPENROUTER_API_KEY": "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"},
"request": "launch", "request": "launch",
"program": "${file}", "program": "${file}",
"cwd": "${workspaceFolder}",
"console": "integratedTerminal", "console": "integratedTerminal",
"justMyCode": true "justMyCode": true
} }
Executable
+363
View File
@@ -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()
+418
View File
@@ -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())
Executable
+324
View File
@@ -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())
View File
View File
+144
View File
@@ -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())
+5 -6
View File
@@ -92,7 +92,7 @@
color: var(--text-user); color: var(--text-user);
} }
.user-message > div { .user-message .text {
color: var(--text-user); color: var(--text-user);
margin-left: auto; margin-left: auto;
white-space: pre-wrap; white-space: pre-wrap;
@@ -282,7 +282,7 @@
<template x-for="img in (msg.images || [])" :key="img.dataUrl"> <template x-for="img in (msg.images || [])" :key="img.dataUrl">
<img :src="img.dataUrl" style="max-width: 100%; border-radius: 0.3rem; display: block; margin-bottom: 0.3rem;" /> <img :src="img.dataUrl" style="max-width: 100%; border-radius: 0.3rem; display: block; margin-bottom: 0.3rem;" />
</template> </template>
<div x-text="msg.content"></div> <div class="text" x-text="msg.content"></div>
</div> </div>
</template> </template>
<template x-if="msg.role === 'assistant'"> <template x-if="msg.role === 'assistant'">
@@ -367,7 +367,7 @@
messages: [{ role: "system", content: systemPrompt }], messages: [{ role: "system", content: systemPrompt }],
pendingImages: [], pendingImages: [],
promptText: window.env.PROMPT ? `${window.env.PROMPT}\n\n` : "", promptText: window.env.PROMPT ? `${window.env.PROMPT}\n\n` : "",
activeModelName: "gemini-3-flash-preview", activeModelName: "gemini-3.5-flash-lite",
historyItems: [], historyItems: [],
isBusy: false, isBusy: false,
abortController: null, abortController: null,
@@ -375,9 +375,8 @@
sessionId: null, sessionId: null,
models: [ models: [
{ name: "gpt-4.1", chat: chatWithOpenAI, key: "OPENAI_API_KEY" }, { name: "gpt-5.4-mini", chat: chatWithOpenAI, key: "OPENAI_API_KEY" },
{ name: "gpt-4.1-mini", chat: chatWithOpenAI, key: "OPENAI_API_KEY" }, { name: "gemini-3.5-flash-lite", chat: chatWithGemini, key: "GEMINI_API_KEY" },
{ name: "gemini-3-flash-preview", chat: chatWithGemini, key: "GEMINI_API_KEY" },
{ name: "claude-haiku-4-5-20251001", chat: chatWithClaude, key: "ANTHROPIC_API_KEY" }, { name: "claude-haiku-4-5-20251001", chat: chatWithClaude, key: "ANTHROPIC_API_KEY" },
], ],
+738
View File
@@ -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 = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>classify_image debug</title>
<style>
body { background: #111; color: #eee; font-family: monospace; margin: 0; padding: 16px; transition: outline 0.1s; }
body.over { outline: 3px dashed #4f4; outline-offset: -6px; }
#drop {
border: 2px dashed #555; border-radius: 8px; padding: 40px;
text-align: center; color: #888; margin-bottom: 20px;
transition: border-color 0.1s, color 0.1s;
}
body.over #drop { border-color: #4f4; color: #4f4; }
#results { display: flex; flex-wrap: wrap; gap: 20px; }
.card {
display: flex; flex-direction: row; align-items: flex-start;
max-width: 960px; background: #1a1a1a; border-radius: 8px; padding: 12px; gap: 16px;
}
.img-wrap { position: relative; display: inline-block; flex-shrink: 0; }
.img-wrap img { display: block; max-width: 560px; max-height: 600px; border-radius: 4px; }
canvas { position: absolute; top: 0; left: 0; pointer-events: none; }
pre.data { font-size: 12px; margin: 0; overflow: auto; white-space: pre-wrap; color: #afa; }
.spinner { color: #888; padding: 20px; }
</style>
</head>
<body>
<div id="drop">Drop images here to classify</div>
<div id="results"></div>
<script>
const EDGES = [
[0,1],[0,2],[1,3],[2,4],
[5,7],[7,9],[6,8],[8,10],
[5,6],[5,11],[6,12],[11,12],
[11,13],[13,15],[12,14],[14,16]
];
const COLORS = ["#f55","#5af","#ff5","#5f5","#f5f","#fa5","#5ff"];
const drop = document.getElementById("drop");
const results = document.getElementById("results");
let dragCounter = 0;
document.addEventListener("dragenter", e => { e.preventDefault(); if (++dragCounter === 1) document.body.classList.add("over"); });
document.addEventListener("dragleave", () => { if (--dragCounter === 0) document.body.classList.remove("over"); });
document.addEventListener("dragover", e => e.preventDefault());
document.addEventListener("drop", e => {
e.preventDefault();
dragCounter = 0;
document.body.classList.remove("over");
for (const file of e.dataTransfer.files) processFile(file);
});
function processFile(file) {
const card = document.createElement("div");
card.className = "card";
card.innerHTML = '<div class="spinner">Processing...</div>';
results.prepend(card);
const fd = new FormData();
fd.append("image", file, file.name);
fetch("/classify", { method: "POST", body: fd })
.then(r => r.json())
.then(data => {
const wrap = document.createElement("div");
wrap.className = "img-wrap";
const img = document.createElement("img");
const canvas = document.createElement("canvas");
wrap.appendChild(img);
wrap.appendChild(canvas);
const pre = document.createElement("pre");
pre.className = "data";
pre.textContent = JSON.stringify(data.classification, null, 2);
card.innerHTML = "";
card.appendChild(wrap);
card.appendChild(pre);
img.onload = () => {
canvas.width = img.offsetWidth;
canvas.height = img.offsetHeight;
const ctx = canvas.getContext("2d");
drawOverlays(ctx, data.detections, img.offsetWidth, img.offsetHeight);
};
img.src = URL.createObjectURL(file);
})
.catch(err => { card.innerHTML = '<div class="spinner">Error: ' + err + '</div>'; });
}
function drawOverlays(ctx, detections, W, H) {
ctx.lineWidth = 2;
ctx.strokeStyle = "#0f0";
for (const [x1n, y1n, x2n, y2n] of detections.faces) {
ctx.strokeRect(x1n * W, y1n * H, (x2n - x1n) * W, (y2n - y1n) * H);
}
detections.poses.forEach((kps, pi) => {
const col = COLORS[pi % COLORS.length];
ctx.strokeStyle = col;
ctx.fillStyle = col;
for (const [a, b] of EDGES) {
const ka = kps[a], kb = kps[b];
if (ka.v && kb.v) {
ctx.beginPath();
ctx.moveTo(ka.x * W, ka.y * H);
ctx.lineTo(kb.x * W, kb.y * H);
ctx.stroke();
}
}
for (const kp of kps) {
if (kp.v) {
ctx.beginPath();
ctx.arc(kp.x * W, kp.y * H, 4, 0, 2 * Math.PI);
ctx.fill();
}
}
});
}
</script>
</body>
</html>"""
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()
+163
View File
@@ -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()
Executable
+939
View File
@@ -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 = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Image Culler</title>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #1e1e1e;
color: #e0e0e0;
overflow: hidden;
height: 100vh;
}
.container {
display: flex;
height: calc(100vh - 46px);
gap: 20px;
padding: 20px;
}
.panel {
display: flex;
flex-direction: column;
gap: 15px;
}
.left-panel {
flex: 0 0 40%;
}
.right-panel {
flex: 0 0 60%;
overflow-y: auto;
}
.image-display {
width: 100%;
height: 100%;
background: #2a2a2a;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border: 2px solid transparent;
transition: border-color 0.2s;
}
.image-display:hover {
border-color: #4a9eff;
}
.image-display.picked {
border-color: #4ade80;
background: rgba(74, 222, 128, 0.1);
}
.image-display img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.image-info {
background: #2a2a2a;
padding: 12px;
border-radius: 8px;
font-size: 14px;
}
dialog {
background: #2a2a2a;
color: #e0e0e0;
border: 1px solid #404040;
border-radius: 8px;
padding: 20px;
max-width: 400px;
}
dialog::backdrop {
background: rgba(0, 0, 0, 0.5);
}
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.grid[data-image-count="1"] {
grid-template-columns: 1fr;
}
.grid-item {
position: relative;
background: #2a2a2a;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.2s;
}
.grid-item:hover {
border-color: #4a9eff;
}
.grid-item img {
width: 100%;
height: auto;
display: block;
object-fit: contain;
}
.grid[data-image-count="1"] .grid-item img {
max-height: 90vh;
}
.grid-item.picked {
border-color: #4ade80;
background: rgba(74, 222, 128, 0.1);
}
.flag-pill {
background: #404040;
color: #e0e0e0;
border: 1px solid #555;
}
.flag-pill:hover {
background: #505050;
border-color: #666;
}
.flag-pill-active {
background: #4a9eff;
color: #000;
border: 1px solid #4a9eff;
font-weight: 500;
}
.flag-pill-active:hover {
background: #6ab0ff;
border-color: #6ab0ff;
}
</style>
</head>
<body>
<div x-data="app()" x-init="init()" @keydown.window="handleKeydown($event)">
<div style="background: #2a2a2a; padding: 12px 20px; border-bottom: 1px solid #404040;">
<div style="font-size: 16px; font-weight: 500; color: #e0e0e0; word-break: break-all;">
<span x-text="currentGroup ? currentGroup.original.name : 'No images'"></span>
</div>
</div>
<div class="container">
<!-- Left Panel: Original Image -->
<div class="panel left-panel">
<div class="image-display" :class="{ picked: originalPicked }" style="position: relative;">
<template x-if="currentGroup">
<img :src="`/image/${currentGroup.original.name}`" :alt="currentGroup.original.name">
<template x-if="imageFlagAssignments[currentGroup.original.name]">
<div style="position: absolute; bottom: 12px; left: 12px; background: #4a9eff; color: #000; padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">
<span x-text="imageFlagAssignments[currentGroup.original.name]"></span>
</div>
</template>
</template>
<template x-if="!currentGroup">
<div style="text-align: center; color: #888;">No images</div>
</template>
</div>
<div class="image-info">
<template x-if="currentGroup">
<div>
<div style="display: flex; align-items: center; gap: 8px;">
<strong>Position:</strong>
<input
type="number"
:value="currentGroupIdx + 1"
@keyup.enter="jumpToIndex($event)"
@change="jumpToIndex($event)"
min="1"
:max="imageGroups.length"
style="width: 90px; background: #404040; color: #e0e0e0; border: 1px solid #555; padding: 4px 8px; border-radius: 4px; font-size: 14px;"
>
<span style="color: #888;">/ <span x-text="imageGroups.length"></span></span>
</div>
<template x-if="availableFlags.length > 0">
<div style="margin-top: 10px;">
<strong style="font-size: 13px;">Flags:</strong>
<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px;">
<template x-for="(flag, idx) in availableFlags" :key="idx">
<div
@click="handleFlagAssignment(flag)"
:class="imageFlagAssignments[currentGroup.original.name] === flag ? 'flag-pill-active' : 'flag-pill'"
style="cursor: pointer; padding: 4px 8px; border-radius: 4px; font-size: 12px; white-space: nowrap; transition: all 0.2s;"
>
<span x-text="`${flag} (${idx + 1})`"></span>
</div>
</template>
</div>
</div>
</template>
</div>
</template>
</div>
</div>
<!-- Right Panel: Edit Variations Grid -->
<div class="panel right-panel">
<template x-if="currentGroup && currentGroup.edits.length > 0">
<div class="grid" :data-image-count="currentGroup.edits.length">
<template x-for="(edit, idx) in currentGroup.edits" :key="idx">
<div
class="grid-item"
:class="{ picked: pickedEdits.includes(idx) }"
@click="toggleEditPick(idx)"
>
<img :src="`/image/${edit.name}`" :alt="`Edit ${idx + 1}`">
</div>
</template>
</div>
</template>
<template x-if="!currentGroup || currentGroup.edits.length === 0">
<div style="color: #888; text-align: center; padding: 40px;">No variations</div>
</template>
</div>
</div>
<!-- Help Dialog -->
<dialog id="helpDialog">
<h2>Keyboard Shortcuts</h2>
<div style="margin-top: 15px; font-size: 14px; line-height: 1.8;">
<div style="margin-bottom: 10px;">
<span style="background: #404040; padding: 2px 6px; border-radius: 3px; font-weight: bold;">P</span>
Toggle pick (all edits or hover one)
</div>
<div style="margin-bottom: 10px;">
<span style="background: #404040; padding: 2px 6px; border-radius: 3px; font-weight: bold;">←→</span>
Navigate any image
</div>
<div style="margin-bottom: 10px;">
<span style="background: #404040; padding: 2px 6px; border-radius: 3px; font-weight: bold;">K</span>
Skip to next pending
</div>
<div style="margin-top: 15px; font-size: 13px; color: #aaa;">
<strong>Flags:</strong>
</div>
<div>
<span style="background: #404040; padding: 2px 6px; border-radius: 3px; font-weight: bold;">1-9</span>
Assign/unassign to flag
</div>
<div>
<span style="background: #404040; padding: 2px 6px; border-radius: 3px; font-weight: bold;">/</span>
Toggle reject flag
</div>
</div>
<div style="margin-top: 20px; text-align: right;">
<button @click="closeHelp()" style="background: #404040; color: #e0e0e0; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer;">Close</button>
</div>
</dialog>
</div>
<script>
function app() {
return {
imageGroups: [],
currentGroupIdx: 0,
currentGroup: null,
pickedEdits: [],
originalPicked: false,
groupStatus: {},
availableFlags: [],
imageFlagAssignments: {},
async init() {
// Start heartbeat
this.startHeartbeat();
// Track mouse position globally for hover detection
document.addEventListener('mousemove', (e) => {
window.lastMouseEvent = e;
});
// Notify server when tab/window is closed
window.addEventListener('beforeunload', () => {
fetch('/api/shutdown', { method: 'POST' }).catch(() => {});
});
// Load images
await this.loadImages();
// Load first group
this.loadGroup(0);
},
async loadImages() {
try {
const resp = await fetch('/api/images');
const data = await resp.json();
this.imageGroups = data.groups;
this.availableFlags = data.flags_list || [];
this.groupStatus = {};
this.imageFlagAssignments = data.flag_assignments || {};
this.imageGroups.forEach((_, idx) => {
this.groupStatus[idx] = 'pending';
});
} catch (err) {
console.error('Error loading images:', err.message);
}
},
loadGroup(idx) {
if (idx < 0 || idx >= this.imageGroups.length) return;
this.currentGroupIdx = idx;
this.currentGroup = this.imageGroups[idx];
this.pickedEdits = [];
this.originalPicked = false;
},
getGroupStatus(idx) {
return this.groupStatus[idx] || 'pending';
},
jumpToIndex(event) {
const value = parseInt(event.target.value);
if (!isNaN(value) && value >= 1 && value <= this.imageGroups.length) {
this.loadGroup(value - 1);
}
},
toggleEditPick(idx) {
if (this.pickedEdits.includes(idx)) {
this.pickedEdits = this.pickedEdits.filter(i => i !== idx);
} else {
this.pickedEdits.push(idx);
}
},
getHoveredElement() {
// Find the element currently under the mouse pointer
const mouseEvent = window.lastMouseEvent;
if (!mouseEvent) return null;
return document.elementFromPoint(mouseEvent.clientX, mouseEvent.clientY);
},
async handleKeydown(e) {
// Skip keybindings if focused on input/textarea
const target = e.target;
const isInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.contentEditable === 'true';
if (isInput) {
return;
}
const key = e.key.toLowerCase();
if (key === 'p') {
e.preventDefault();
await this.handlePick();
} else if (key === '?') {
e.preventDefault();
this.toggleHelp();
} else if (key === '/') {
e.preventDefault();
await this.handleFlagAssignment('reject');
} else if (key === 'arrowleft') {
e.preventDefault();
let nextIdx = this.currentGroupIdx - 1;
if (nextIdx < 0) nextIdx = this.imageGroups.length - 1;
this.loadGroup(nextIdx);
} else if (key === 'arrowright') {
e.preventDefault();
let nextIdx = this.currentGroupIdx + 1;
if (nextIdx >= this.imageGroups.length) nextIdx = 0;
this.loadGroup(nextIdx);
} else if (key === 'k') {
e.preventDefault();
for (let i = this.currentGroupIdx + 1; i < this.imageGroups.length; i++) {
if (this.getGroupStatus(i) === 'pending') {
this.loadGroup(i);
return;
}
}
} else if (key >= '1' && key <= '9') {
e.preventDefault();
const flagIdx = parseInt(key) - 1;
if (flagIdx < this.availableFlags.length) {
const flagName = this.availableFlags[flagIdx];
await this.handleFlagAssignment(flagName);
}
}
},
async handlePick() {
if (!this.currentGroup) return;
const currentStatus = this.getGroupStatus(this.currentGroupIdx);
// If already picked, unpick it
if (currentStatus === 'picked') {
try {
const resp = await fetch('/api/unpick', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
original: this.currentGroup.original.name
})
});
if (resp.ok) {
this.groupStatus[this.currentGroupIdx] = 'pending';
this.originalPicked = false;
this.pickedEdits = [];
}
} catch (err) {
console.error('Error unpicking:', err);
}
return;
}
const hoveredEl = this.getHoveredElement();
const isHoveringOriginal = hoveredEl?.closest('.image-display:not(.right-panel *)');
// If hovering over original, pick just the original
if (isHoveringOriginal) {
try {
const resp = await fetch('/api/pick', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
original: this.currentGroup.original.name,
edits: [],
includeOriginal: true
})
});
if (resp.ok) {
this.originalPicked = true;
}
} catch (err) {
console.error('Error picking original:', err);
}
return;
}
// If hovering on edit, pick just that edit
const hoveredGridItem = hoveredEl?.closest('.grid-item');
if (hoveredGridItem && this.currentGroup.edits.length > 0) {
const idx = Array.from(hoveredGridItem.parentElement.querySelectorAll('.grid-item')).indexOf(hoveredGridItem);
if (idx !== -1) {
const editName = this.currentGroup.edits[idx].name;
try {
const resp = await fetch('/api/pick', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
original: this.currentGroup.original.name,
edits: [editName],
includeOriginal: false
})
});
if (resp.ok) {
this.pickedEdits.push(idx);
}
} catch (err) {
console.error('Error picking edit:', err);
}
return;
}
}
// If not hovering, pick all selected edits and advance
let editsToPick = this.pickedEdits.map(i => this.currentGroup.edits[i].name);
// If no edits selected but there's exactly 1 edit, pick it automatically
if (editsToPick.length === 0 && this.currentGroup.edits.length === 1) {
editsToPick = [this.currentGroup.edits[0].name];
}
// Only proceed if there are edits to pick
if (editsToPick.length === 0) return;
try {
const resp = await fetch('/api/pick', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
original: this.currentGroup.original.name,
edits: editsToPick,
includeOriginal: false
})
});
if (resp.ok) {
this.groupStatus[this.currentGroupIdx] = 'picked';
this.loadGroup(this.currentGroupIdx + 1);
}
} catch (err) {
console.error('Error picking edits:', err);
}
},
startHeartbeat() {
setInterval(async () => {
try {
await fetch('/api/heartbeat', { method: 'POST' });
} catch (err) {
console.error('Heartbeat failed:', err);
}
}, 1000);
},
toggleHelp() {
const dialog = document.getElementById('helpDialog');
if (dialog.open) {
dialog.close();
} else {
dialog.showModal();
}
},
closeHelp() {
document.getElementById('helpDialog').close();
},
async handleFlagAssignment(flagName) {
if (!this.currentGroup) return;
const imageKey = this.currentGroup.original.name;
// Toggle: if already assigned to this flag, unassign
if (this.imageFlagAssignments[imageKey] === flagName) {
try {
await fetch('/api/unassign-flag', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
original: imageKey,
flag: flagName
})
});
delete this.imageFlagAssignments[imageKey];
} catch (err) {
console.error('Error unassigning flag:', err);
}
} else {
// Assign to new flag
try {
const resp = await fetch('/api/assign-flag', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
original: imageKey,
flag: flagName
})
});
if (resp.ok) {
this.imageFlagAssignments[imageKey] = flagName;
}
} catch (err) {
console.error('Error assigning flag:', err);
}
}
}
};
}
</script>
</body>
</html>
"""
@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/<filename>")
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()
Executable
+79
View File
@@ -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()
+142
View File
@@ -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()
+7 -3
View File
@@ -435,7 +435,11 @@ def main() -> None:
p = sub.add_parser(cmd) p = sub.add_parser(cmd)
p.add_argument("urls", nargs="+") p.add_argument("urls", nargs="+")
p.add_argument("--name", default=None) p.add_argument("--name", default=None)
p.add_argument("--cwd", default=None, type=Path) p.add_argument(
"--cwd",
default=Path("~/Downloads/_temp/_imagesets").expanduser(),
type=Path,
)
p.add_argument("--token", default=os.getenv("ALLDEBRID_TOKEN")) p.add_argument("--token", default=os.getenv("ALLDEBRID_TOKEN"))
proc = sub.add_parser("process") proc = sub.add_parser("process")
@@ -447,11 +451,11 @@ def main() -> None:
case "gallery": case "gallery":
cmd_gallery(args.urls, args.name, args.cwd) cmd_gallery(args.urls, args.name, args.cwd)
case "filehost": case "filehost":
cmd_filehost(args.urls, args.name, args.token, args.cwd) cmd_filehost(args.urls, args.name, args.token, cwd=args.cwd)
case "process": case "process":
cmd_process(args.dirs) cmd_process(args.dirs)
case "download": case "download":
cmd_download(args.urls, args.name, args.token, args.cwd) cmd_download(args.urls, args.name, args.token, cwd=args.cwd)
if __name__ == "__main__": if __name__ == "__main__":
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env -S uv run --script
# /// script
# dependencies = ["playwright", "httpx"]
# ///
import argparse
import json
import logging
import re
import sys
from pathlib import Path
from typing import Optional
from urllib.parse import urljoin, urlparse
import httpx
from playwright.sync_api import sync_playwright
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
logger = logging.getLogger(__name__)
def str_to_bool(value: str) -> bool:
"""Convert string to boolean."""
if isinstance(value, bool):
return value
return value.lower() in ("true", "1", "yes", "on")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Download JavaScript files and reconstruct sources from source maps",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument("url", help="URL of the page to scrape")
parser.add_argument(
"--output-dir",
"-o",
type=Path,
default=Path("output"),
help="Output directory for reconstructed sources",
)
parser.add_argument(
"--headless",
type=str_to_bool,
default=True,
help="Run browser in headless mode",
)
return parser.parse_args()
def get_script_urls(page_url: str, headless: bool = True) -> list[str]:
"""Extract all script URLs from a page using Playwright."""
script_urls = []
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=headless)
page = browser.new_page()
def handle_response(response):
if response.request.resource_type == "script":
script_urls.append(response.url)
logger.debug(f"Detected script: {response.url}")
page.on("response", handle_response)
logger.info("Navigating to page...")
page.goto(url=page_url)
logger.info("Waiting for DOM ready...")
page.wait_for_load_state(state="domcontentloaded")
logger.info("Waiting for network idle (30s timeout)...")
try:
page.wait_for_load_state(state="networkidle", timeout=30000)
except Exception as e:
logger.warning(f"Network idle timeout: {e}")
browser.close()
return script_urls
def download_file(url: str, timeout: int = 30) -> bytes:
"""Download a file from a URL."""
try:
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
response = client.get(url=url)
response.raise_for_status()
return response.content
except httpx.HTTPError as e:
logger.error(f"Failed to download {url}: {e}")
raise
def get_sourcemap_url(script_content: str, script_url: str) -> Optional[str]:
"""Extract source map URL from script content."""
match = re.search(r"//# sourceMappingURL=(.+?)(?:\n|$)", script_content)
if not match:
return None
sourcemap_ref = match.group(1).strip()
if sourcemap_ref.startswith("http"):
return sourcemap_ref
return urljoin(base=script_url, url=sourcemap_ref)
def decode_sourcemap(sourcemap_data: dict) -> dict:
"""Extract sources and content from source map."""
sources = sourcemap_data.get("sources", [])
sources_content = sourcemap_data.get("sourcesContent", [])
return {
"sources": sources,
"sources_content": sources_content,
}
def reconstruct_sources(
script_content: str,
sourcemap_data: dict,
output_dir: Path,
script_name: str,
) -> None:
"""Reconstruct original sources from source map."""
sources = sourcemap_data.get("sources", [])
sources_content = sourcemap_data.get("sourcesContent", [])
logger.info(f"Source map has {len(sources)} source files")
if sources:
logger.debug(f"First few sources: {sources[:3]}")
if not sources_content or all(c is None for c in sources_content):
logger.error(f"No source content found in source map for {script_name}")
sys.exit(1)
for i, source_file in enumerate(sources):
if i < len(sources_content) and sources_content[i]:
source_path = output_dir / source_file
source_path.parent.mkdir(parents=True, exist_ok=True)
with open(source_path, mode="w", encoding="utf-8") as f:
f.write(sources_content[i])
logger.info(f"Reconstructed {source_file}")
def main() -> None:
args = parse_args()
args.output_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Fetching page: {args.url}")
script_urls = get_script_urls(page_url=args.url, headless=args.headless)
logger.info(f"Found {len(script_urls)} script(s)")
if not script_urls:
logger.error("No scripts found on page")
sys.exit(1)
for script_url in script_urls:
logger.info(f"Processing {script_url}")
try:
script_content = download_file(url=script_url).decode(encoding="utf-8")
except Exception as e:
logger.error(f"Failed to get script {script_url}: {e}")
continue
sourcemap_url = get_sourcemap_url(script_content=script_content, script_url=script_url)
if not sourcemap_url:
logger.error(f"No source map found for {script_url}")
sys.exit(1)
logger.info(f"Downloading source map: {sourcemap_url}")
try:
sourcemap_content = download_file(url=sourcemap_url).decode(encoding="utf-8")
sourcemap_data = json.loads(sourcemap_content)
except Exception as e:
logger.error(f"Failed to download/parse source map: {e}")
sys.exit(1)
script_name = urlparse(script_url).path.split("/")[-1]
reconstruct_sources(
script_content=script_content,
sourcemap_data=sourcemap_data,
output_dir=args.output_dir,
script_name=script_name,
)
logger.info(f"All sources reconstructed to {args.output_dir}")
if __name__ == "__main__":
main()
Binary file not shown.
+29
View File
@@ -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
File diff suppressed because it is too large Load Diff
+29 -29
View File
@@ -62,7 +62,9 @@ def parse_date(filename: str) -> ParsedDate | None:
def from_us_format(): def from_us_format():
match = re_date_us.search(filename) match = re_date_us.search(filename)
m, d, y = map(int, match.groups()) m, d, y = map(int, match.groups())
return ParsedDate(datetime.date(y + 2000, m, d), start=match.start(), end=match.end()) return ParsedDate(
datetime.date(y + 2000, m, d), start=match.start(), end=match.end()
)
def from_iso(): def from_iso():
match = re_date_iso.search(filename) match = re_date_iso.search(filename)
@@ -72,7 +74,9 @@ def parse_date(filename: str) -> ParsedDate | None:
def from_iso_short(): def from_iso_short():
match = re_date_iso_short.search(filename) match = re_date_iso_short.search(filename)
y, m, d = map(int, match.groups()) y, m, d = map(int, match.groups())
return ParsedDate(datetime.date(y + 2000, m, d), start=match.start(), end=match.end()) return ParsedDate(
datetime.date(y + 2000, m, d), start=match.start(), end=match.end()
)
def from_iso_reversed(): def from_iso_reversed():
match = re_date_iso_rev.search(filename) match = re_date_iso_rev.search(filename)
@@ -82,7 +86,9 @@ def parse_date(filename: str) -> ParsedDate | None:
def from_iso_reversed_short(): def from_iso_reversed_short():
match = re_date_iso_rev_short.search(filename) match = re_date_iso_rev_short.search(filename)
d, m, y = map(int, match.groups()) d, m, y = map(int, match.groups())
return ParsedDate(datetime.date(y + 2000, m, d), start=match.start(), end=match.end()) return ParsedDate(
datetime.date(y + 2000, m, d), start=match.start(), end=match.end()
)
candidates = [] candidates = []
for fn in [ for fn in [
@@ -101,7 +107,9 @@ def parse_date(filename: str) -> ParsedDate | None:
today = datetime.date.today() today = datetime.date.today()
future_threshold = today + datetime.timedelta(days=60) future_threshold = today + datetime.timedelta(days=60)
past_threshold = datetime.date(2010, 1, 1) past_threshold = datetime.date(2010, 1, 1)
candidates = [it for it in candidates if past_threshold < it.date < future_threshold] candidates = [
it for it in candidates if past_threshold < it.date < future_threshold
]
if not candidates: if not candidates:
return return
@@ -139,23 +147,9 @@ def trash(path: Path):
subprocess.run(cmd).check_returncode() subprocess.run(cmd).check_returncode()
def filenames_to_actors(): def parse_release(
sources = [ filename: str, known_actors: set[str] | None = None
Path(r"/Users/abdus/Downloads/temp/"), ) -> Release | None:
Path(r"/Volumes/BANDAID/_temp/__reenc/"),
Path(r"/Volumes/BANDAID/_temp/"),
]
actors = set()
for it in sources:
for f in it.glob("*.mp4"):
if not f.is_file():
continue
if r := parse_release(f.name):
actors.update(r.actors)
return actors
def parse_release(filename: str, known_actors: set[str] | None = None) -> Release | None:
if not known_actors: if not known_actors:
known_actors = set() known_actors = set()
if any(filename.lower().endswith(ext) for ext in [".mp4", ".mkv"]): if any(filename.lower().endswith(ext) for ext in [".mp4", ".mkv"]):
@@ -174,7 +168,9 @@ def parse_release(filename: str, known_actors: set[str] | None = None) -> Releas
title=title, title=title,
released_at=datetime.date.fromisoformat(date), released_at=datetime.date.fromisoformat(date),
) )
case [actors, studio, date] if studio.startswith("@") and (parsed := parse_date(date)): case [actors, studio, date] if studio.startswith("@") and (
parsed := parse_date(date)
):
return Release( return Release(
actors=sorted(actors.split(", ")), actors=sorted(actors.split(", ")),
studio=studio.removeprefix("@"), studio=studio.removeprefix("@"),
@@ -210,14 +206,20 @@ def parse_release(filename: str, known_actors: set[str] | None = None) -> Releas
# assert '.PRT' in filename # assert '.PRT' in filename
remaining = re.sub(r"\.(720p|1080p|HEVC|x265|PRT|XXX)", " ", filename) remaining = re.sub(r"\.(720p|1080p|HEVC|x265|PRT|XXX)", " ", filename)
studio = remaining[: remaining.index(".")] studio = remaining[: remaining.index(".")]
remaining = remaining[len(studio) :]
parsed_date = parse_date(remaining) parsed_date = parse_date(remaining)
remaining = remaining[parsed_date.end :] if parsed_date:
remaining = remaining[parsed_date.end :]
remaining = re.sub(r"[. ]+", " ", remaining).strip() remaining = re.sub(r"[. ]+", " ", remaining).strip()
actors = [] actors = []
title = None title = None
# PornFidelity.E1109.Uma.Jolie
match remaining.split(" "): match remaining.split(" "):
case [episode, *names] if episode.startswith("E") and episode[1:].isdigit():
title = episode
actors = [" ".join(names)]
case [a_first, a_last, "And", b_first, b_last]: case [a_first, a_last, "And", b_first, b_last]:
actors = [f"{a_first} {a_last}", f"{b_first} {b_last}"] actors = [f"{a_first} {a_last}", f"{b_first} {b_last}"]
case [a_first, a_last, "And", b_first, b_last, *rest]: case [a_first, a_last, "And", b_first, b_last, *rest]:
@@ -235,16 +237,12 @@ def parse_release(filename: str, known_actors: set[str] | None = None) -> Releas
studio=studio, studio=studio,
actors=sorted(actors), actors=sorted(actors),
title=title, title=title,
released_at=parsed_date.date, released_at=parsed_date.date if parsed_date else None,
) )
def from_galaxxxy():
pass
for fn in [ for fn in [
from_own, from_own,
from_prt, from_prt,
from_galaxxxy,
]: ]:
try: try:
if res := fn(): if res := fn():
@@ -255,7 +253,9 @@ def parse_release(filename: str, known_actors: set[str] | None = None) -> Releas
def parse_args(): def parse_args():
arger = argparse.ArgumentParser() arger = argparse.ArgumentParser()
arger.add_argument("filenames", nargs="+", type=lambda v: Path(v).resolve(), help="Filenames") arger.add_argument(
"filenames", nargs="+", type=lambda v: Path(v).resolve(), help="Filenames"
)
return arger.parse_args() return arger.parse_args()
+148 -62
View File
@@ -43,8 +43,8 @@ from file_renamer import Release
], ],
[ [
"DadCrush.21.10.09.Ailee.Anne.My.Stepdaughters.Hot.XXX.1080p.HEVC.x265.PRT.mp4", "DadCrush.21.10.09.Ailee.Anne.My.Stepdaughters.Hot.XXX.1080p.HEVC.x265.PRT.mp4",
datetime.date(2021, 10, 9) datetime.date(2021, 10, 9),
] ],
], ],
) )
def test_parse_date(filename: str, date: datetime.date | None): def test_parse_date(filename: str, date: datetime.date | None):
@@ -57,68 +57,154 @@ def test_parse_date(filename: str, date: datetime.date | None):
assert parsed.date == date assert parsed.date == date
@pytest.mark.parametrize(['filename', 'expected'], [ @pytest.mark.parametrize(
["filename", "expected"],
[ [
'PenthouseGold.23.02.05.Tiffany.Tatum.XXX.720p.HEVC.x265.PRT', [
Release(studio='DoctorAdventures', actors=['Jamie Michelle'], title='Nurse Jamie Knows Best', released_at=datetime.date(2021, 5, 26)), "PornFidelity.E1109.Uma.Jolie.XXX.720p.HEVC.x265.PRT",
Release(
studio="PornFidelity",
actors=["Uma Jolie"],
title="E1109",
),
],
[
"PenthouseGold.23.02.05.Tiffany.Tatum.XXX.720p.HEVC.x265.PRT",
Release(
studio="DoctorAdventures",
actors=["Jamie Michelle"],
title="Nurse Jamie Knows Best",
released_at=datetime.date(2021, 5, 26),
),
],
[
"Rebecca Volpetti -- @RealityKings -- Driving Him Crazy [1080p, x265] -- 2019-04-10",
Release(
actors=["Rebecca Volpetti"],
studio="RealityKings",
title="Driving Him Crazy",
released_at=datetime.date(2019, 4, 10),
),
],
[
"Liz Jordan -- @Lubed -- Sopping Oil -- 2023-02-07 [1080p, x265]",
Release(
actors=["Liz Jordan"],
studio="Lubed",
title="Sopping Oil",
released_at=datetime.date(2023, 2, 7),
),
],
[
"Liz Jordan--@Lubed--Sopping Oil--2023-02-07 [1080p, x265]",
Release(
actors=["Liz Jordan"],
studio="Lubed",
title="Sopping Oil",
released_at=datetime.date(2023, 2, 7),
),
],
[
"Liz Jordan -- @Lubed -- 2023-02-07 [1080p, x265]",
Release(
actors=["Liz Jordan"],
studio="Lubed",
title=None,
released_at=datetime.date(2023, 2, 7),
),
],
[
"Liz Jordan -- Sopping Oil -- 2023-02-07 [1080p, x265]",
Release(
actors=["Liz Jordan"],
studio=None,
title="Sopping Oil",
released_at=datetime.date(2023, 2, 7),
),
],
[
"Liz Jordan -- Sopping Oil",
Release(
actors=["Liz Jordan"],
studio=None,
title="Sopping Oil",
released_at=None,
),
],
[
"DadCrush.21.10.09.Ailee.Anne.My.Stepdaughters.Hot.XXX.1080p.HEVC.x265.PRT.mp4",
Release(
studio="DadCrush",
actors=["Ailee Anne"],
title="My Stepdaughters Hot",
released_at=datetime.date(2021, 10, 9),
),
],
[
"Deeper.21.11.11.Kenzie.Anne.XXX.1080p.HEVC.x265.PRT.mkv",
Release(
studio="Deeper",
actors=["Kenzie Anne"],
title=None,
released_at=datetime.date(2021, 11, 11),
),
],
[
"DevilsFilm.22.03.19.Kira.Noir.Wife.Swap.Schemes.2.XXX.1080p.HEVC.x265.PRT.mkv",
Release(
studio="DevilsFilm",
actors=["Kira Noir"],
title="Wife Swap Schemes 2",
released_at=datetime.date(2022, 3, 19),
),
],
[
"DogHouseDigital.22.04.06.Maddy.May.Full.Service.Massage.XXX.1080p.HEVC.x265.PRT.mkv",
Release(
studio="DogHouseDigital",
actors=["Maddy May"],
title="Full Service Massage",
released_at=datetime.date(2022, 4, 6),
),
],
[
"EvilAngel.22.04.05.Diana.Grace.XXX.1080p.HEVC.x265.PRT.mkv",
Release(
studio="EvilAngel",
actors=["Diana Grace"],
title=None,
released_at=datetime.date(2022, 4, 5),
),
],
[
"Slayed.21.10.07.Izzy.Lush.And.Aidra.Fox.XXX.1080p.HEVC.x265.PRT.mkv",
Release(
studio="Slayed",
actors=["Aidra Fox", "Izzy Lush"],
title=None,
released_at=datetime.date(2021, 10, 7),
),
],
[
"ExploitedCollegeGirls.22.06.30.Gaby.XXX.1080p.HEVC.x265.PRT.mkv",
Release(
studio="ExploitedCollegeGirls",
actors=["Gaby"],
title=None,
released_at=datetime.date(2022, 6, 30),
),
],
[
"DoctorAdventures.21.05.26.Jamie.Michelle.Nurse.Jamie.Knows.Best.PRT.mp4",
Release(
studio="DoctorAdventures",
actors=["Jamie Michelle"],
title="Nurse Jamie Knows Best",
released_at=datetime.date(2021, 5, 26),
),
],
], ],
[ )
'Rebecca Volpetti -- @RealityKings -- Driving Him Crazy [1080p, x265] -- 2019-04-10',
Release(actors=['Rebecca Volpetti'], studio='RealityKings', title='Driving Him Crazy', released_at=datetime.date(2019, 4, 10)),
],
[
'Liz Jordan -- @Lubed -- Sopping Oil -- 2023-02-07 [1080p, x265]',
Release(actors=['Liz Jordan'], studio='Lubed', title='Sopping Oil', released_at=datetime.date(2023, 2, 7)),
],
[
'Liz Jordan--@Lubed--Sopping Oil--2023-02-07 [1080p, x265]',
Release(actors=['Liz Jordan'], studio='Lubed', title='Sopping Oil', released_at=datetime.date(2023, 2, 7)),
],
[
'Liz Jordan -- @Lubed -- 2023-02-07 [1080p, x265]',
Release(actors=['Liz Jordan'], studio='Lubed', title=None, released_at=datetime.date(2023, 2, 7)),
],
[
'Liz Jordan -- Sopping Oil -- 2023-02-07 [1080p, x265]',
Release(actors=['Liz Jordan'], studio=None, title='Sopping Oil', released_at=datetime.date(2023, 2, 7)),
],
[
'Liz Jordan -- Sopping Oil',
Release(actors=['Liz Jordan'], studio=None, title='Sopping Oil', released_at=None),
],
[
'DadCrush.21.10.09.Ailee.Anne.My.Stepdaughters.Hot.XXX.1080p.HEVC.x265.PRT.mp4',
Release(studio='DadCrush', actors=['Ailee Anne'], title='My Stepdaughters Hot', released_at=datetime.date(2021, 10, 9))
],
[
'Deeper.21.11.11.Kenzie.Anne.XXX.1080p.HEVC.x265.PRT.mkv',
Release(studio='Deeper', actors=['Kenzie Anne'], title=None, released_at=datetime.date(2021, 11, 11)),
],
[
'DevilsFilm.22.03.19.Kira.Noir.Wife.Swap.Schemes.2.XXX.1080p.HEVC.x265.PRT.mkv',
Release(studio='DevilsFilm', actors=['Kira Noir'], title='Wife Swap Schemes 2', released_at=datetime.date(2022, 3, 19)),
],
[
'DogHouseDigital.22.04.06.Maddy.May.Full.Service.Massage.XXX.1080p.HEVC.x265.PRT.mkv',
Release(studio='DogHouseDigital', actors=['Maddy May'], title='Full Service Massage', released_at=datetime.date(2022, 4, 6)),
],
[
'EvilAngel.22.04.05.Diana.Grace.XXX.1080p.HEVC.x265.PRT.mkv',
Release(studio='EvilAngel', actors=['Diana Grace'], title=None, released_at=datetime.date(2022, 4, 5)),
],
[
'Slayed.21.10.07.Izzy.Lush.And.Aidra.Fox.XXX.1080p.HEVC.x265.PRT.mkv',
Release(studio='Slayed', actors=['Aidra Fox', 'Izzy Lush'], title=None, released_at=datetime.date(2021, 10, 7)),
],
[
'ExploitedCollegeGirls.22.06.30.Gaby.XXX.1080p.HEVC.x265.PRT.mkv',
Release(studio='ExploitedCollegeGirls', actors=['Gaby'], title=None, released_at=datetime.date(2022, 6, 30)),
],
[
'DoctorAdventures.21.05.26.Jamie.Michelle.Nurse.Jamie.Knows.Best.PRT.mp4',
Release(studio='DoctorAdventures', actors=['Jamie Michelle'], title='Nurse Jamie Knows Best', released_at=datetime.date(2021, 5, 26)),
],
])
def test_parse_release(filename: str, expected): def test_parse_release(filename: str, expected):
parsed = file_renamer.parse_release(filename) parsed = file_renamer.parse_release(filename)
assert parsed == expected assert parsed == expected
+461
View File
@@ -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 """<!doctype html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\" />
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
<title>FLUX.2 Klein Playground</title>
<style>
:root { --bg:#f4f0e8; --card:#fff9ee; --text:#1f1f1f; --accent:#125b50; --muted:#6b6b6b; }
* { box-sizing: border-box; }
body { margin:0; font-family: ui-sans-serif, -apple-system, Segoe UI, sans-serif; background: radial-gradient(circle at top left,#fff6df,var(--bg)); color:var(--text); }
.wrap { max-width: 980px; margin: 1.5rem auto; padding: 1rem; }
.card { background: var(--card); border: 1px solid #eadfc8; border-radius: 14px; padding: 1rem; box-shadow: 0 8px 20px rgba(0,0,0,.06); }
h1 { margin: 0 0 .8rem 0; font-size: 1.4rem; }
.grid { display:grid; gap:.8rem; grid-template-columns: 1fr 1fr; }
.full { grid-column: 1 / -1; }
label { display:block; font-weight:600; margin-bottom:.3rem; }
input, textarea, button { width:100%; border-radius:10px; border:1px solid #d8ccb7; padding:.65rem; font-size:.95rem; }
textarea { min-height: 110px; resize: vertical; }
button { background: var(--accent); color:white; border:none; font-weight:700; cursor:pointer; }
button:disabled { opacity:.6; cursor:default; }
.muted { color: var(--muted); font-size:.88rem; }
.out img { width:100%; border-radius:12px; border:1px solid #e4d8c3; background:white; }
@media (max-width: 760px) { .grid { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class=\"wrap\">
<div class=\"card\">
<h1>FLUX.2 Klein Playground</h1>
<p class=\"muted\">Private single-user UI at <code>/</code>. Distilled defaults: 4 steps, guidance 1.0.</p>
<form id=\"form\" class=\"grid\">
<div class=\"full\">
<label for=\"prompt\">Prompt</label>
<textarea id=\"prompt\" required>A cinematic photo portrait, natural skin texture, realistic lighting, high detail.</textarea>
</div>
<div>
<label for=\"width\">Width (multiple of 16)</label>
<input id=\"width\" type=\"number\" value=\"1024\" step=\"16\" min=\"16\" />
</div>
<div>
<label for=\"height\">Height (multiple of 16)</label>
<input id=\"height\" type=\"number\" value=\"1024\" step=\"16\" min=\"16\" />
</div>
<div>
<label for=\"steps\">Inference steps</label>
<input id=\"steps\" type=\"number\" value=\"4\" min=\"1\" max=\"100\" />
</div>
<div>
<label for=\"guidance\">Guidance scale</label>
<input id=\"guidance\" type=\"number\" value=\"1.0\" step=\"0.1\" />
</div>
<div>
<label for=\"seed\">Seed</label>
<input id=\"seed\" type=\"number\" value=\"0\" min=\"0\" />
</div>
<div>
<label for=\"edit\">Edit image (optional)</label>
<input id=\"edit\" type=\"file\" accept=\"image/*\" />
</div>
<div class=\"full\">
<button id=\"submit\" type=\"submit\">Generate</button>
<p id=\"status\" class=\"muted\">Idle</p>
</div>
</form>
</div>
<div class=\"card out\" style=\"margin-top:1rem\">
<h1>Output</h1>
<img id=\"preview\" alt=\"Generated image preview\" />
<p class=\"muted\">Tip: right-click image to save.</p>
</div>
</div>
<script>
const form = document.getElementById('form');
const statusEl = document.getElementById('status');
const preview = document.getElementById('preview');
const button = document.getElementById('submit');
async function fileToDataUrl(file) {
return await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
button.disabled = true;
statusEl.textContent = 'Generating...';
try {
const prompt = document.getElementById('prompt').value;
const width = Number(document.getElementById('width').value);
const height = Number(document.getElementById('height').value);
const steps = Number(document.getElementById('steps').value);
const guidance = Number(document.getElementById('guidance').value);
const seed = Number(document.getElementById('seed').value);
const file = document.getElementById('edit').files[0];
const imageDataUrl = file ? await fileToDataUrl(file) : null;
const payload = {
model: 'black-forest-labs/FLUX.2-klein-9B',
prompt,
size: `${width}x${height}`,
num_inference_steps: steps,
guidance_scale: guidance,
seed,
response_format: 'b64_json',
imageDataUrl,
};
const resp = await fetch('/v1/images/generations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(text || `HTTP ${resp.status}`);
}
const data = await resp.json();
const b64 = data?.data?.[0]?.b64_json;
if (!b64) throw new Error('No image returned');
preview.src = `data:image/png;base64,${b64}`;
statusEl.textContent = `Done. Seed: ${data.seed ?? seed}`;
} catch (error) {
statusEl.textContent = `Error: ${error.message}`;
} finally {
button.disabled = false;
}
});
</script>
</body>
</html>
"""
@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)
+30
View File
@@ -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.
+251
View File
@@ -0,0 +1,251 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Text Corrector App</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsdiff/5.1.0/diff.min.js"></script>
<style>
body {
font-family: "JetBrains Mono", Menlo, Consolas, monospace;
font-optical-sizing: auto;
font-size: 16px;
padding: 2rem;
margin: 0;
line-height: 1.5;
}
h2 {
font-size: 1rem;
font-weight: bold;
margin-bottom: 1rem;
line-height: 1.2;
}
details {
margin-bottom: 1rem;
}
summary > h2 {
display: inline;
}
button {
padding: 0.75rem 1.5rem;
-webkit-appearance: none;
appearance: none;
background-color: #222;
color: #f9f9f9;
border: none;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
font-size: inherit;
}
/* Minimal styling for diff readability */
#diffOutput ins {
background-color: #d4ffd4;
text-decoration: none;
color: #22863a;
}
#diffOutput del {
background-color: #ffd4d4;
text-decoration: none;
color: #cb2431;
}
.text-container {
border: 1px solid #eee;
margin-bottom: 1rem;
white-space: pre-wrap;
word-wrap: break-word;
}
#loadingMessage,
#errorMessage {
margin-bottom: 15px;
}
</style>
</head>
<body>
<div>
<details>
<summary><h2>Original Text</h2></summary>
<div id="originalText" class="text-container"></div>
</details>
</div>
<div id="loadingMessage">Loading suggestion...</div>
<div id="errorMessage" style="color: red; display: none"></div>
<div id="suggestionArea" style="display: none">
<div>
<h2>Suggestion (Diff View):</h2>
<div id="diffOutput" class="text-container"></div>
</div>
<div>
<details>
<summary><h2>Suggested Text (Clean)</h2></summary>
<div id="cleanSuggestionOutput" class="text-container"></div>
</details>
</div>
<button id="acceptButton" disabled>Accept</button>
</div>
<script>
// DOM Elements
const originalTextEl = document.getElementById("originalText");
const loadingMessageEl = document.getElementById("loadingMessage");
const errorMessageEl = document.getElementById("errorMessage");
const suggestionAreaEl = document.getElementById("suggestionArea");
const diffOutputEl = document.getElementById("diffOutput");
const cleanSuggestionOutputEl = document.getElementById("cleanSuggestionOutput");
const acceptButtonEl = document.getElementById("acceptButton");
let currentSuggestedText = null;
async function getChatGPTSuggestion(textToReview, apiKey) {
// System prompt: Instructions for the AI model
const systemPrompt = `You are an English language assistant. Your task is to help a user who is learning English.
When presented with their text, please:
1. Identify and correct grammatical errors. Pay specific attention to:
- Missing or incorrect articles (a, an, the).
- Incorrect preposition usage.
- Errors in subject-verb agreement.
- Past tense and past perfect tense mistakes.
- Awkward or unnatural phrasing.
2. Rephrase sentences to be simpler, clearer, and more natural, while preserving the original meaning.
3. Preserve the original formatting (new lines, punctuation, etc.) as much as possible, unless a change is necessary for clarity or correctness.
4. Provide ONLY the fully revised text. Do not include any explanations, apologies, preambles, or surrounding quotes like """ or \`\`\`. Just the corrected and improved plain text.
5. If the text is already grammatically perfect and clearly phrased according to these instructions, return the original text without any changes.`;
try {
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
// Assuming window.env.OPENAI_MODEL is available as in your snippet
// If not, replace with a hardcoded model or pass it as an argument
model: window.env && window.env.OPENAI_MODEL ? window.env.OPENAI_MODEL : "gpt-5.1-nano",
messages: [
{
role: "system",
content: systemPrompt,
},
{
role: "user",
content: textToReview,
},
],
temperature: 0.8,
max_tokens: Math.max(250, textToReview.length * 2 + 50),
}),
});
if (!response.ok) {
const errorData = await response.json();
console.error("ChatGPT API Error:", errorData);
return { error: `API Error: ${errorData.error?.message || response.statusText}` };
}
const data = await response.json();
if (data.choices && data.choices.length > 0 && data.choices[0].message) {
return { suggestion: data.choices[0].message.content.trim() };
} else {
console.error("ChatGPT API Error: No choices in response", data);
return { error: "No suggestion received from API (empty response)." };
}
} catch (error) {
console.error("Error fetching ChatGPT suggestion:", error);
return { error: `Network/Request Error: ${error.message}` };
}
}
function displayDiff(original, revised, diffContainer) {
if (typeof Diff === "undefined" || !Diff.diffWordsWithSpace) {
diffContainer.textContent = "jsdiff library not loaded or diffWordsWithSpace not working.";
console.error("jsdiff (Diff.diffWordsWithSpace) is not available.");
return;
}
const diffs = Diff.diffWordsWithSpace(original, revised, { ignoreCase: true, ignoreWhitespace: true });
const fragment = document.createDocumentFragment();
diffs.forEach(function (part) {
const textNode = document.createTextNode(part.value);
if (part.added) {
const ins = document.createElement("ins");
ins.appendChild(textNode);
fragment.appendChild(ins);
} else if (part.removed) {
const del = document.createElement("del");
del.appendChild(textNode);
fragment.appendChild(del);
} else {
fragment.appendChild(textNode);
}
});
diffContainer.innerHTML = "";
diffContainer.appendChild(fragment);
}
function showError(message) {
loadingMessageEl.style.display = "none";
errorMessageEl.textContent = message;
errorMessageEl.style.display = "block";
suggestionAreaEl.style.display = "none";
}
async function initializeApp() {
if (typeof window.env.USER_INPUT === "undefined" || window.env.USER_INPUT === null) {
showError("Error: window.USER_INPUT is not defined. Please set it before loading the app.");
return;
}
if (typeof window.env.OPENAI_TOKEN === "undefined" || !window.env.OPENAI_TOKEN) {
showError("Error: window.OPENAI_TOKEN is not defined. Please set your OpenAI API key.");
return;
}
if (typeof Diff === "undefined") {
showError("Error: jsdiff library could not be loaded. Check your internet connection or the CDN link.");
return;
}
const originalText = window.env.USER_INPUT;
originalTextEl.textContent = originalText;
const result = await getChatGPTSuggestion(originalText, window.env.OPENAI_TOKEN);
loadingMessageEl.style.display = "none";
if (result.error) {
showError(result.error);
} else if (result.suggestion) {
currentSuggestedText = result.suggestion;
suggestionAreaEl.style.display = "block";
displayDiff(originalText, currentSuggestedText, diffOutputEl);
cleanSuggestionOutputEl.textContent = currentSuggestedText;
acceptButtonEl.disabled = false;
}
}
acceptButtonEl.addEventListener("click", () => {
if (currentSuggestedText !== null) {
if (window.app && typeof window.app.finish === "function") {
window.app.finish(currentSuggestedText);
// Optionally, provide feedback or close the "app" view
acceptButtonEl.textContent = "Accepted!";
acceptButtonEl.disabled = true;
} else {
alert("Error: window.app.finish is not defined. Cannot complete action.");
console.error("window.app.finish is not defined.");
}
}
});
// Initialize the app when the DOM is ready
document.addEventListener("DOMContentLoaded", initializeApp);
</script>
</body>
</html>
+279
View File
@@ -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<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})"),
re.compile(r"^(?P<year>\d{4})\.(?P<month>\d{2})\.(?P<day>\d{2})"),
re.compile(r"^(?P<year>\d{4})(?P<month>\d{2})(?P<day>\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()
+105
View File
@@ -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()
+78
View File
@@ -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())
+344
View File
@@ -0,0 +1,344 @@
#!/usr/bin/env -S uv run
# /// script
# requires-python = ">=3.12"
# dependencies = ["httpx"]
# ///
"""
Keep2Share (k2s.cc) downloader — translated from JDownloader's K2SApi + Keep2ShareCc plugins.
Usage:
./k2s_download.py <url> [--user EMAIL] [--pass PASSWORD] [--out DIR]
Premium accounts skip captchas and get full speed.
Free/anonymous downloads require solving an image captcha (URL printed to terminal).
"""
import re
import sys
import os
import time
import argparse
import getpass
from pathlib import Path
import httpx
# ── constants ────────────────────────────────────────────────────────────────
API_BASE = "https://k2s.cc/api/v2"
# Alias domains all resolve to k2s.cc internally
SUPPORTED_DOMAINS = {
"k2s.cc", "keep2share.cc", "keep2.cc",
"fileboom.me", "fboom.me",
"tezfiles.com", "publish2.me",
}
FILE_ID_RE = re.compile(
r"(?i)/(?:f|file|preview)/(?:info/)?([a-z0-9_\-]{13,})"
)
FOLDER_ID_RE = re.compile(r"(?i)/folder(?:/info)?/([a-z0-9]{13,})")
HEADERS = {
"User-Agent": "JDownloader",
"Accept-Language": "en-gb, en;q=0.8",
"Content-Type": "application/json",
}
# ── helpers ──────────────────────────────────────────────────────────────────
def extract_file_id(url: str) -> str | None:
m = FILE_ID_RE.search(url)
if not m:
return None
fid = m.group(1)
# fixContentID: lowercase exactly-13-char IDs that aren't "special"
if len(fid) == 13 and fid.isalpha():
fid = fid.lower()
return fid
def is_folder(url: str) -> bool:
return bool(FOLDER_ID_RE.search(url))
def _post(session: httpx.Client, endpoint: str, payload: dict, auth_token: str | None) -> dict:
# auth_token goes in the JSON body, not as a header (per K2SApi.handleDownload bytecode)
if auth_token:
payload = {**payload, "auth_token": auth_token}
r = session.post(f"{API_BASE}{endpoint}", json=payload, headers=HEADERS, timeout=30)
try:
return r.json()
except Exception:
r.raise_for_status()
raise
# ── API calls (mirroring K2SApi methods) ─────────────────────────────────────
def api_login(session: httpx.Client, username: str, password: str) -> str:
"""POST /login → returns auth_token."""
data = _post(session, "/login", {"username": username, "password": password}, None)
token = data.get("auth_token") or data.get("accessToken")
if not token:
raise RuntimeError(f"Login failed: {data.get('message') or data}")
return token
def api_get_file_status(session: httpx.Client, file_id: str, auth_token: str | None) -> dict:
"""POST /getfilestatus → name, size, is_available, access, md5 …"""
return _post(session, "/getfilestatus", {"id": file_id}, auth_token)
def api_get_files_info(session: httpx.Client, file_ids: list[str], auth_token: str | None) -> dict:
"""POST /getfilesinfo → bulk file metadata."""
return _post(session, "/getfilesinfo", {"ids": file_ids}, auth_token)
def api_request_captcha(session: httpx.Client, file_id: str) -> dict:
"""POST /requestcaptcha → captcha_url + captcha_challenge."""
return _post(session, "/requestcaptcha", {"file_id": file_id}, None)
def api_request_recaptcha(session: httpx.Client, file_id: str) -> dict:
"""POST /requestrecaptcha → re_captcha_challenge (site key etc.)."""
return _post(session, "/requestrecaptcha", {"file_id": file_id}, None)
def api_get_url(
session: httpx.Client,
file_id: str,
auth_token: str | None,
captcha_challenge: str | None = None,
captcha_response: str | None = None,
free_download_key: str | None = None,
) -> dict:
"""POST /geturl → url (direct download link)."""
payload: dict = {"file_id": file_id}
if captcha_challenge:
payload["captcha_challenge"] = captcha_challenge
payload["captcha_response"] = captcha_response
if free_download_key:
payload["free_download_key"] = free_download_key
return _post(session, "/geturl", payload, auth_token)
# ── 2captcha ─────────────────────────────────────────────────────────────────
class TwoCaptcha:
"""
Thin wrapper around the 2captcha API v2.
Mirrors abstractPluginForCaptchaSolverTwoCaptchaAPIV2 from JDownloader:
POST /createTask → taskId
POST /getTaskResult (poll) → solution.text
"""
API = "https://api.2captcha.com"
POLL_INTERVAL = 5 # seconds between getTaskResult polls
POLL_TIMEOUT = 120 # seconds before giving up
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 = data.get("errorId") or data.get("errorCode")
if err and err != 0:
raise RuntimeError(f"2captcha error: {data.get('errorCode') or data.get('errorDescription') or data}")
return data
def solve_image(self, image_url: str) -> str:
"""Download image from url, submit as ImageToTextTask, return solution text."""
img_r = self.client.get(image_url, timeout=30)
img_r.raise_for_status()
b64 = __import__("base64").b64encode(img_r.content).decode()
task_id = self._call("/createTask", {"task": {"type": "ImageToTextTask", "body": b64}})["taskId"]
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":
text = result["solution"]["text"]
print(f" 2captcha solved: {text}")
return text
print(" …waiting for solution")
raise RuntimeError(f"2captcha timed out after {self.POLL_TIMEOUT}s")
# ── captcha handling ──────────────────────────────────────────────────────────
def solve_captcha(session: httpx.Client, file_id: str, solver: TwoCaptcha | None) -> tuple[str, str]:
"""
Request a K2S image captcha and return (challenge, response).
Uses 2captcha automatically when a solver is provided, otherwise prompts the user.
"""
cap = api_request_captcha(session, file_id)
captcha_url = cap.get("captcha_url") or cap.get("url")
challenge = cap.get("captcha_challenge") or cap.get("challenge")
if not captcha_url or not challenge:
raise RuntimeError(f"Unexpected captcha response: {cap}")
if solver:
response = solver.solve_image(captcha_url)
else:
print(f"\nCaptcha required. Open this URL in your browser and read the code:")
print(f" {captcha_url}\n")
response = input("Enter captcha text: ").strip()
return challenge, response
# ── download ──────────────────────────────────────────────────────────────────
def stream_download(session: httpx.Client, url: str, dest: Path, filename: str):
dest.mkdir(parents=True, exist_ok=True)
out_path = dest / filename
print(f"Downloading → {out_path}")
with session.stream("GET", url, headers=HEADERS, timeout=None) as r:
r.raise_for_status()
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):
if is_folder(url):
sys.exit("Folder URLs are not supported — add individual file links.")
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)
auth_token: str | None = None
solver = TwoCaptcha(twocaptcha_key, session) if twocaptcha_key else None
# ── 1. authenticate ───────────────────────────────────────────────────────
if username and password:
print(f"Logging in as {username}")
auth_token = api_login(session, username, password)
print("Logged in OK.")
# ── 2. check file ─────────────────────────────────────────────────────────
print(f"Checking file {file_id}")
info = api_get_file_status(session, file_id, auth_token)
if not info.get("is_available") and info.get("status") != "success":
err = info.get("message") or info.get("error") or info
sys.exit(f"File not available: {err}")
name = info.get("name") or file_id
size = info.get("size", 0)
access = info.get("access", "")
print(f" Name : {name}")
print(f" Size : {size // 1024 // 1024} MB" if size else " Size : unknown")
print(f" Access : {access}")
if access == "private" and not auth_token:
sys.exit("Private file — login required.")
# ── 3. get download URL ───────────────────────────────────────────────────
result: dict = {}
direct_url: str | None = None
if auth_token:
# Premium / logged-in path: no captcha needed
result = api_get_url(session, file_id, auth_token)
direct_url = result.get("url") or result.get("premlink") or result.get("freelink1")
else:
# Free / anonymous path — mirrors K2SApi.handleDownload free flow:
# 1. solve captcha
# 2. POST /geturl → may return free_download_key + time_wait instead of url
# 3. sleep time_wait, re-POST with free_download_key (no captcha) until key is gone
MAX_CAPTCHA_TRIES = 3
for attempt in range(MAX_CAPTCHA_TRIES):
challenge, cap_response = solve_captcha(session, file_id, solver)
result = api_get_url(session, file_id, None,
captcha_challenge=challenge,
captcha_response=cap_response)
err_code = result.get("errorCode") or result.get("code")
if err_code in (30, 33):
print("Wrong captcha, try again…")
continue
# Wait loop: server issues a free_download_key and a time_wait before
# it hands over the real url (JDownloader allows up to 4 re-polls).
MAX_WAITS = 4
for wait_count in range(MAX_WAITS + 1):
free_key = result.get("free_download_key")
if not free_key:
break # server ready — url should be present now
wait_secs = int((result.get("time_wait") or 0))
if wait_secs > 180:
sys.exit(f"Rate limited — server wants {wait_secs}s wait, too long.")
if wait_count >= MAX_WAITS:
sys.exit("Too many wait loops from server, giving up.")
print(f" Server asked to wait {wait_secs}s before download is ready…")
time.sleep(wait_secs)
result = api_get_url(session, file_id, None, free_download_key=free_key)
direct_url = result.get("url") or result.get("freelink1")
if direct_url:
break
sys.exit(f"Unexpected response after wait loop: {result}")
if not direct_url:
err = result.get("message") or result.get("error") or result
sys.exit(f"Could not get download URL: {err}")
# ── 4. download ───────────────────────────────────────────────────────────
stream_download(session, direct_url, out_dir, name)
# ── CLI ───────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="Keep2Share downloader")
parser.add_argument("url", help="k2s.cc / keep2share.cc file URL")
parser.add_argument("--user", "-u", default=os.environ.get("K2S_USER"), help="Account email")
parser.add_argument("--pass", "-p", dest="password",
default=os.environ.get("K2S_PASS"), help="Account password")
parser.add_argument("--out", "-o", default=".", help="Output directory (default: .)")
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),
twocaptcha_key=args.twocaptcha_key,
)
if __name__ == "__main__":
main()
+523
View File
@@ -0,0 +1,523 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Prompter</title>
<!-- Persist plugin must load before Alpine core -->
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/persist@3.x.x/dist/cdn.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
display: flex;
height: 100vh;
overflow: hidden;
font-family: system-ui, -apple-system, sans-serif;
background: #0f0f0f;
color: #e0e0e0;
}
/* ── Image panel (left 50%) ──────────────────────────────────────────── */
.image-panel {
width: 50%;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #111;
padding: 20px;
gap: 10px;
border-right: 1px solid #1e1e1e;
}
.image-panel img {
max-width: 100%;
max-height: calc(100vh - 72px);
object-fit: contain;
}
.image-meta {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.image-filename {
font-size: 12px;
color: #666;
word-break: break-all;
text-align: center;
max-width: 100%;
}
.image-counter {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #555;
}
.image-counter input[type="number"] {
background: transparent;
border: none;
border-bottom: 1px solid #444;
color: #999;
font-family: inherit;
font-size: 12px;
outline: none;
padding: 0 2px;
text-align: right;
width: 3.5ch;
-moz-appearance: textfield;
}
.image-counter input[type="number"]:focus {
border-bottom-color: #4a6cf7;
color: #e0e0e0;
}
.image-counter input::-webkit-outer-spin-button,
.image-counter input::-webkit-inner-spin-button { -webkit-appearance: none; }
/* ── Options panel (right 50%) ───────────────────────────────────────── */
.options-panel {
width: 50%;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-top,
.panel-bottom {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 8px;
}
.panel-bottom {
border-top: 1px solid #1e1e1e;
}
.section-title {
font-size: 11px;
color: #555;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 4px;
}
.option {
border: 1px solid #2a2a2a;
border-radius: 8px;
padding: 12px 14px;
cursor: pointer;
transition: border-color 0.1s;
}
.option:hover { border-color: #3a3a3a; }
.option.active { border-color: #4a6cf7; background: rgba(74, 108, 247, 0.06); }
.option-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
font-size: 12px;
color: #666;
}
.saved-badge { color: #4caf50; margin-left: auto; font-size: 11px; }
.kbd {
display: inline-block;
background: #1e1e1e;
border: 1px solid #333;
border-radius: 3px;
padding: 1px 6px;
font-size: 11px;
font-family: monospace;
color: #888;
white-space: nowrap;
}
textarea {
width: 100%;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 6px;
color: #e0e0e0;
font-size: 14px;
line-height: 1.5;
padding: 10px;
resize: vertical;
min-height: 90px;
font-family: inherit;
outline: none;
transition: border-color 0.1s;
}
textarea:focus { border-color: #4a6cf7; }
.btn-save {
margin-top: 10px;
padding: 8px 18px;
background: #4a6cf7;
color: #fff;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: background 0.1s;
}
.btn-save:hover { background: #3b5de8; }
.history-section { margin-top: 8px; }
.history-item { margin-top: 8px; }
.history-text {
font-size: 13px;
color: #bbb;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.4;
}
.blocks-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-top: 4px;
}
.block-pill {
background: #1e1e1e;
border: 1px solid #333;
border-radius: 8px;
color: #ccc;
cursor: pointer;
font-family: inherit;
font-size: 12px;
line-height: 1.4;
padding: 6px 10px;
text-align: left;
transition: border-color 0.1s, background 0.1s;
white-space: pre-wrap;
word-break: break-word;
}
.block-pill:hover {
background: #2a2a2a;
border-color: #4a6cf7;
color: #e0e0e0;
}
</style>
</head>
<body x-data="prompter" @keydown.window="handleKey($event)">
<!-- ── Left: image ───────────────────────────────────────────────────── -->
<div class="image-panel">
<img :src="`/image/${currentIndex}`" :alt="currentImage" />
<div class="image-meta">
<div class="image-filename" x-text="currentImage.split('/').pop()"></div>
<div class="image-counter">
<input
type="number"
min="1"
:max="images.length"
:value="currentIndex + 1"
@change="jumpTo($event.target.valueAsNumber - 1)"
@keydown.stop
@click.stop
/>
<span>/ <span x-text="images.length"></span></span>
</div>
</div>
</div>
<!-- ── Right: options ────────────────────────────────────────────────── -->
<div class="options-panel">
<!-- ── Top half: prompt + history ──────────────────────────────────── -->
<div class="panel-top">
<div class="section-title">Prompt</div>
<!-- Custom prompt -->
<div class="option" :class="{ active: selectedOption === 'custom' }" @click="selectCustom()">
<div class="option-meta">
<span class="kbd">alt+1</span>
<span>Custom prompt</span>
<span class="saved-badge" x-show="selections[currentImage]">&#10003;&nbsp;saved</span>
</div>
<textarea
x-ref="textarea"
x-model="customPrompt"
@focus="selectedOption = 'custom'"
placeholder="Enter a prompt&#8230;"
rows="5"
></textarea>
<button class="btn-save" @click.stop="saveAndNext()">Save &amp; Next</button>
</div>
<!-- History -->
<div class="history-section" x-show="promptHistory.length > 0">
<div class="section-title">Previously used</div>
<template x-for="(prompt, i) in promptHistory" :key="prompt">
<div
class="option history-item"
:class="{ active: selectedOption === i }"
@click="selectHistory(i)"
>
<div class="option-meta">
<span class="kbd" x-text="`alt+${i + 2}`"></span>
</div>
<div class="history-text" x-text="prompt"></div>
</div>
</template>
</div>
</div>
<!-- ── Bottom half: building blocks ────────────────────────────────── -->
<div class="panel-bottom" x-show="buildingBlocks.length > 0">
<div class="section-title">Building blocks</div>
<div class="blocks-grid">
<template x-for="block in buildingBlocks" :key="block">
<button class="block-pill" @click="appendBlock(block)" x-text="block"></button>
</template>
</div>
</div>
</div>
<script>
/**
* @typedef {{ images: string[], canned_prompts: string[], building_blocks: string[], existing_prompts: Record<string, string> }} ApiImages
*/
document.addEventListener('alpine:init', () => {
Alpine.data('prompter', () => ({
/** @type {string[]} Ordered list of absolute image paths from the server */
images: [],
/** @type {number} Zero-based index of the currently displayed image */
currentIndex: 0,
/** @type {Record<string, string>} Absolute image path → saved prompt text */
selections: {},
/** @type {string} Current value of the custom-prompt textarea */
customPrompt: '',
/** @type {'custom' | number | null} Which option row is highlighted */
selectedOption: null,
/** @type {string[]} Reusable text fragments loaded from the server */
buildingBlocks: [],
/**
* Prompt history persisted to localStorage via Alpine Persist.
* Ordered most-recent-first; no duplicates.
* @type {string[]}
*/
promptHistory: Alpine.$persist([]).as('prompter_history'),
/**
* Absolute path of the image currently on screen.
* @returns {string}
*/
get currentImage() {
return this.images[this.currentIndex] ?? '';
},
/**
* Fetch the image list from the server, merge canned prompts into
* history, and restore any previously saved prompt for the first image.
*/
async init() {
/** @type {ApiImages} */
const data = await fetch('/api/images').then(r => r.json());
this.images = data.images;
this.selections = { ...data.existing_prompts };
this.buildingBlocks = data.building_blocks ?? [];
// Append canned prompts not already present in the persisted history.
const inHistory = new Set(this.promptHistory);
for (const p of data.canned_prompts) {
if (!inHistory.has(p)) {
this.promptHistory.push(p);
}
}
// Restore a previously saved prompt for the first image (if any).
const saved = this.selections[this.currentImage];
if (saved) {
this.customPrompt = saved;
this.selectedOption = 'custom';
}
},
/**
* Append a building block to the textarea, separated by a blank line.
* If the textarea is empty the block becomes the entire content.
* @param {string} block
*/
appendBlock(block) {
const cur = this.customPrompt.trimEnd();
this.customPrompt = cur ? cur + '\n' + block : block;
this.selectedOption = 'custom';
this.$nextTick(() => this.$refs.textarea.focus());
},
/** Highlight the custom-prompt option and focus the textarea. */
selectCustom() {
this.selectedOption = 'custom';
this.$nextTick(() => this.$refs.textarea.focus());
},
/**
* Copy a history entry into the textarea and focus it.
* @param {number} i - Index into promptHistory
*/
selectHistory(i) {
this.customPrompt = this.promptHistory[i];
this.selectedOption = 'custom';
this.$nextTick(() => this.$refs.textarea.focus());
},
/**
* POST the current textarea content to the server and update local
* state. Does nothing if the textarea is blank.
*/
async saveCurrentIfNeeded() {
const prompt = this.customPrompt.trim();
if (!prompt) return;
const filePath = this.currentImage;
this.selections[filePath] = prompt;
this._addToHistory(prompt);
await fetch('/api/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: filePath, prompt }),
});
},
/**
* Prepend a prompt to history, removing any earlier duplicate entry.
* @param {string} prompt
*/
_addToHistory(prompt) {
this.promptHistory = [
prompt,
...this.promptHistory.filter(p => p !== prompt),
];
},
/**
* Populate the textarea with the saved prompt for the current image,
* or clear it if no prompt has been saved yet.
*/
_loadCurrentSelection() {
const saved = this.selections[this.currentImage];
this.customPrompt = saved ?? '';
this.selectedOption = saved ? 'custom' : null;
},
/** Save the current prompt (if any) then advance to the next image. */
async navigateNext() {
if (this.currentIndex >= this.images.length - 1) return;
await this.saveCurrentIfNeeded();
this.currentIndex++;
this._loadCurrentSelection();
},
/** Save the current prompt (if any) then go back to the previous image. */
async navigatePrev() {
if (this.currentIndex <= 0) return;
await this.saveCurrentIfNeeded();
this.currentIndex--;
this._loadCurrentSelection();
},
/** Save the current prompt (if any) and advance to the next image. */
async saveAndNext() {
await this.saveCurrentIfNeeded();
if (this.currentIndex < this.images.length - 1) {
this.currentIndex++;
this._loadCurrentSelection();
}
},
/**
* Save the current prompt (if any) then jump to a specific index.
* Out-of-range values are clamped silently.
* @param {number} idx - Zero-based target index
*/
async jumpTo(idx) {
const target = Math.max(0, Math.min(idx, this.images.length - 1));
if (target === this.currentIndex) return;
await this.saveCurrentIfNeeded();
this.currentIndex = target;
this._loadCurrentSelection();
},
/**
* Global keyboard handler (attached to window via @keydown.window).
*
* Shortcuts:
* Alt+1 → focus the custom-prompt textarea
* Alt+N (N≥2) → load history[N-2] into the textarea and focus it
* → / k → next image (k ignored while textarea is focused)
* ← / j → prev image (j ignored while textarea is focused)
*
* Arrow keys navigate from anywhere, including inside the textarea,
* and auto-save the current prompt if non-empty.
*
* @param {KeyboardEvent} e
*/
handleKey(e) {
const inTextarea =
this.$refs.textarea &&
document.activeElement === this.$refs.textarea;
if (inTextarea) return;
if (e.altKey) {
const m = e.code.match(/^Digit(\d)$/);
if (m) {
const n = parseInt(m[1], 10);
e.preventDefault();
if (n === 1) {
this.selectCustom();
} else if (n >= 2 && n - 2 < this.promptHistory.length) {
this.selectHistory(n - 2);
}
return;
}
}
if (e.key === 'ArrowRight' || e.key === 'k') {
e.preventDefault();
this.navigateNext();
} else if (e.key === 'ArrowLeft' || e.key === 'j') {
e.preventDefault();
this.navigatePrev();
}
},
}));
});
</script>
</body>
</html>
+205
View File
@@ -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/<idx:int>")(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()
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
import argparse
from collections import defaultdict
from datetime import datetime, timedelta
from pathlib import Path
import logging
import re
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
def apply_retention_policy(backup_files: list, now: datetime) -> list:
"""Apply grandfather-father-son retention policy"""
daily_files = []
weekly_files = []
monthly_files = []
for file, backup_date in backup_files:
age = now - backup_date
if age < timedelta(days=3):
daily_files.append((file, backup_date))
elif age < timedelta(days=30):
weekly_files.append((file, backup_date))
elif age < timedelta(days=90):
monthly_files.append((file, backup_date))
to_keep = set()
# Keep 1 backup per day
to_keep.update(file for file, _ in daily_files)
# Keep one backup per week for weekly period
grouped_by_week = defaultdict(list)
for file, backup_date in weekly_files:
week_key = backup_date.strftime("%Y-%W")
grouped_by_week[week_key].append((file, backup_date))
for week_backups in grouped_by_week.values():
to_keep.add(max(week_backups, key=lambda x: x[1])[0])
# Keep one backup per month for monthly period
grouped_by_month = defaultdict(list)
for file, backup_date in monthly_files:
month_key = backup_date.strftime("%Y-%m")
grouped_by_month[month_key].append((file, backup_date))
for month_backups in grouped_by_month.values():
to_keep.add(max(month_backups, key=lambda x: x[1])[0])
return [file for file, _ in backup_files if file not in to_keep]
def prune_backups(backup_dir: Path) -> None:
logger.info(f"Pruning old backups in {backup_dir}")
if not backup_dir.exists():
return
backup_files = []
for file in backup_dir.glob("*.zip"):
match = re.search(r"^(\d{4}\D\d{2}\D\d{2}\D\d{2}\D\d{2}\D\d{2})", file.name)
if match:
try:
date_str = match.group(1).replace("-", "_")
backup_date = datetime.strptime(date_str, "%Y_%m_%d_%H_%M_%S")
backup_files.append((file, backup_date))
except ValueError:
continue
backup_files.sort(key=lambda x: x[1])
now = datetime.now()
to_delete = apply_retention_policy(backup_files, now)
for file in to_delete:
logger.info(f"Deleting old backup: {file}")
file.unlink()
kept_count = len(backup_files) - len(to_delete)
logger.info(f"Kept {kept_count} backups, pruned {len(to_delete)} old backups")
def parse_args():
parser = argparse.ArgumentParser(description="Prune old backups based on retention policy", formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("--backup-dir", type=Path, required=True, help="Directory containing backup files")
return parser.parse_args()
def main():
args = parse_args()
backup_dir = args.backup_dir
if not backup_dir.exists():
logger.error(f"Backup directory does not exist: {backup_dir}")
return
prune_backups(backup_dir)
logger.info("Backup pruning completed successfully")
if __name__ == "__main__":
main()
+681
View File
@@ -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 == "<no 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 == "<no 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())
+24
View File
@@ -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",
]
+97
View File
@@ -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())
+84
View File
@@ -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()
Executable
+565
View File
@@ -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 <url> [--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/<hex_id> → server sets PHPSESSID + file_id cookies;
page JS has: var fid = <numeric>; var secs = 180;
var startTimerUrl = '/download/AjaxStartTimer';
2. GET /download/AjaxStartTimer?fid=<numeric> (XHR)
{"state":"started", "sid":"<token>"}
3. Wait `secs` seconds (free-tier countdown)
4. GET /download/AjaxGetDownloadLink?sid=<token> (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 <meta name="__token"> if present
csrf_m = re.search(r'<meta\s+name=["\']__token["\']\s+content=["\']([^"\']+)["\']', captcha_html)
csrf_token = csrf_m.group(1) if csrf_m else None
# Extract any hidden form fields
hidden_fields: dict[str, str] = {}
for hm in re.finditer(r'<input[^>]+type=["\']hidden["\'][^>]*name=["\']([^"\']+)["\'][^>]*value=["\']([^"\']*)["\']', captcha_html, re.I):
hidden_fields[hm.group(1)] = hm.group(2)
for hm in re.finditer(r'<input[^>]+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"<title>\s*Download file\s*([^<>\"]+)</title>", 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()
Executable
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13"
# dependencies = ["pillow"]
# ///
import argparse
import enum
from functools import cache
import logging
from pathlib import Path
import struct
from typing import Self
from PIL import Image
# --- Utility Functions ---
def read_dims_jpeg(image_path: Path) -> tuple[int, int]:
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 read_dims_png(image_path: Path) -> tuple[int, int]:
"""
Reads PNG dimensions by first attempting PIL, then falling back to manual
IHDR parsing if the library fails to initialize.
"""
with image_path.open("rb") as file:
signature = file.read(8)
if signature != b"\x89PNG\r\n\x1a\n":
raise ValueError(f"{image_path} is not a valid PNG file")
try:
file.seek(0)
with Image.open(file) as img:
return img.size
except Exception:
# PNG IHDR is always the first chunk.
# Offset 8: Chunk Length (4 bytes)
# Offset 12: Chunk Type "IHDR" (4 bytes)
# Offset 16: Width (4 bytes)
# Offset 20: Height (4 bytes)
file.seek(12)
if file.read(4) != b"IHDR":
raise ValueError(f"IHDR chunk not found in {image_path}")
# Using struct for clean fixed-width big-endian unpacking
dims = file.read(8)
if len(dims) < 8:
raise ValueError(f"Truncated IHDR in {image_path}")
width, height = struct.unpack(">II", dims)
return width, height
def read_dims_webp(image_path: Path) -> tuple[int, int]:
"""
Parses WebP dimensions directly from the RIFF container without
external dependencies. Handles VP8, VP8L, and VP8X bitstreams.
"""
with image_path.open("rb") as f:
header = f.read(12)
if len(header) < 12 or header[:4] != b"RIFF" or header[8:12] != b"WEBP":
raise ValueError(f"Not a valid WebP file: {image_path}")
while True:
chunk_header = f.read(8)
if len(chunk_header) < 8:
break
tag, length = struct.unpack("<4sI", chunk_header)
if tag == b"VP8X":
# Extended Format: Canvas width/height are 24-bit integers
# starting at offset 4 of the chunk data.
data = f.read(10)
width = (int.from_bytes(data[4:7], "little") & 0xFFFFFF) + 1
height = (int.from_bytes(data[7:10], "little") & 0xFFFFFF) + 1
return width, height
elif tag == b"VP8L":
# Lossless Format: 1 byte signature (0x2f), 14 bits width-1,
# 14 bits height-1, 1 bit alpha, 3 bits version.
data = f.read(5)
if data[0] != 0x2F:
raise ValueError("Invalid VP8L signature")
# Unpack the 4 bytes following the signature
bits = struct.unpack("<I", data[1:5])[0]
width = (bits & 0x3FFF) + 1
height = ((bits >> 14) & 0x3FFF) + 1
return width, height
elif tag == b"VP8 ":
# Lossy Format: Sync code 0x9d012a starts at offset 3.
# Width/Height are 16-bit values (14 bits used) at offset 6.
data = f.read(10)
if data[3:6] != b"\x9d\x01\x2a":
raise ValueError("Invalid VP8 sync code")
width, height = struct.unpack("<HH", data[6:10])
return width & 0x3FFF, height & 0x3FFF
# Skip chunk data + padding byte if length is odd
f.seek((length + 1) & ~1, 1)
raise ValueError(f"Could not find dimension chunks in {image_path}")
def read_dims(image_path: Path) -> tuple[int, int]:
if image_path.suffix.lower() in [".jpg", ".jpeg"]:
return read_dims_jpeg(image_path)
elif image_path.suffix.lower() == ".png":
return read_dims_png(image_path)
elif image_path.suffix.lower() == ".webp":
return read_dims_webp(image_path)
else:
raise ValueError(f"Unsupported image format: {image_path.suffix}")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Sort images based on size, aspect ratio, pose centering, or gaze direction.")
parser.add_argument("image_paths", nargs="+", type=Path, help="Path(s) to JPEG image(s)")
strategy = parser.add_mutually_exclusive_group(required=True)
strategy.add_argument("--by-size", action="store_true", help="Sort images by size")
strategy.add_argument("--by-aspect-ratio", action="store_true", help="Sort images by aspect ratio")
return parser.parse_args()
class AspectRatio(enum.Enum):
PORTRAIT = "portrait"
WIDE = "wide"
@classmethod
def from_dimensions(cls, width: int, height: int) -> Self:
return cls.PORTRAIT if height > width else cls.WIDE
class Size(enum.Enum):
SMALL = "small"
MEDIUM = "medium"
LARGE = "large"
@classmethod
def from_dimensions(cls, width: int, height: int) -> Self:
smaller = min(width, height)
if smaller < 1000:
return cls.SMALL
elif smaller < 2000:
return cls.MEDIUM
return cls.LARGE
@cache
def make_dir(path: Path) -> None:
path.mkdir(parents=True, exist_ok=True)
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", ".png", ".webp"]:
logging.warning(f"{image_path} is not a JPEG, PNG, or WebP file. Skipping.")
continue
try:
width, height = read_dims(image_path)
target_dir = None
if args.by_size:
size_category = Size.from_dimensions(width, height)
target_dir = image_path.parent / f"_size_{size_category.value}"
elif args.by_aspect_ratio:
aspect_ratio_category = AspectRatio.from_dimensions(width, height)
target_dir = image_path.parent / f"_aspect_{aspect_ratio_category.value}"
else:
raise ValueError("No sorting strategy specified.")
make_dir(target_dir)
target_path = target_dir / image_path.name
target_path = target_path.with_suffix(".jpg")
image_path.rename(target_path)
logging.info(f"Moved {image_path} to {target_path}")
except KeyboardInterrupt:
raise
except ValueError:
logging.exception(f"Error processing {image_path}. Skipping.")
continue
if __name__ == "__main__":
main()
+618
View File
@@ -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()
Executable
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
import argparse
import plistlib
import struct
import subprocess
from pathlib import Path
COLORS = {
"gray": 1,
"green": 2,
"purple": 3,
"blue": 4,
"yellow": 5,
"red": 6,
"orange": 7,
}
def tag_file(path: Path, color: str) -> None:
idx = COLORS[color]
new_tag = f"{color.capitalize()}\n{idx}"
# Read existing tags, then append if not already present
result = subprocess.run(["xattr", "-px", "com.apple.metadata:_kMDItemUserTags", path], capture_output=True, text=True)
if result.returncode == 0:
raw = bytes.fromhex(result.stdout.replace("\n", "").replace(" ", ""))
existing = plistlib.loads(raw)
else:
existing = []
if new_tag not in existing:
existing.append(new_tag)
plist_data = plistlib.dumps(existing, fmt=plistlib.FMT_BINARY)
subprocess.run(["xattr", "-wx", "com.apple.metadata:_kMDItemUserTags", plist_data.hex(), path], check=True)
# FinderInfo: color in bits 1-3 of fdFlags (offset 8, big-endian uint16)
result = subprocess.run(["xattr", "-px", "com.apple.FinderInfo", path], capture_output=True, text=True)
if result.returncode == 0:
raw = bytes.fromhex(result.stdout.replace("\n", "").replace(" ", ""))
fi = bytearray(raw) if len(raw) >= 32 else bytearray(32)
else:
fi = bytearray(32)
flags = struct.unpack_from(">H", fi, 8)[0]
flags = (flags & ~0x000E) | (idx << 1)
struct.pack_into(">H", fi, 8, flags)
subprocess.run(["xattr", "-wx", "com.apple.FinderInfo", bytes(fi).hex(), path], check=True)
def main() -> None:
parser = argparse.ArgumentParser(description="Tag files with Finder color labels")
parser.add_argument(
"--tag", required=True, choices=list(COLORS),
metavar="COLOR", help=f"One of: {', '.join(COLORS)}",
)
parser.add_argument("files", nargs="+", type=Path)
args = parser.parse_args()
for path in args.files:
if not path.exists():
print(f"skipping {path}: not found")
continue
tag_file(path, args.tag)
print(f"tagged {path}{args.tag}")
if __name__ == "__main__":
main()
Generated
+998
View File
@@ -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" },
]
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env -S uv run --script
# /// script
# dependencies = ["httpx", "srt"]
# ///
"""
# Whisper Subtitles
This script generates SRT subtitles for audio or video files using the OpenAI Whisper API.
## Features
- Extracts audio from video files.
- Speeds up the audio before transcription to reduce token usage and cost (transcription still works reliably).
- Stretches subtitle timings back to match the original speed.
- Saves the result as SRT subtitle files.
## Usage
- Make sure ffmpeg and [uv is installed](https://docs.astral.sh/uv/getting-started/installation/).
- Set your OpenAI API key in the environment variable `OPENAI_API_KEY`.
```bash
chmod +x whisper_subtitles.py
OPENAI_API_KEY=sk-123 ./whisper_subtitles.py input_file.mp4 -o output.srt
```
"""
import argparse
import logging
import os
import subprocess
import tempfile
from pathlib import Path
import httpx
import srt
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
logger = logging.getLogger(__name__)
def check_ffmpeg_installed():
try:
subprocess.run(["ffmpeg", "-version"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except FileNotFoundError:
raise RuntimeError("ffmpeg is not installed. Please install it to use this script.")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Generate SRT subtitles using OpenAI Whisper API.", formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("input", type=Path, help="Input audio or video file")
parser.add_argument("-o", "--output", type=Path, required=True, help="Output SRT file path")
parser.add_argument(
"--openai-api-key",
type=str,
default=os.getenv("OPENAI_API_KEY"),
required=True,
help="OpenAI API key (default: from OPENAI_API_KEY env var)",
)
def float_between(min: float, max: float) -> callable:
def parser(value: str) -> float:
fvalue = float(value)
if not (min <= fvalue <= max):
raise argparse.ArgumentTypeError(f"Value must be between {min} and {max}")
return value
return parser
parser.add_argument(
"--speed",
type=float_between(1, 10),
default=2.5,
help="Audio speed-up factor for transcription to reduce token usage at the cost of accuracy",
)
return parser.parse_args()
def extract_audio(input_path: Path, save_path: Path, speed: float) -> Path:
cmd = [
"ffmpeg",
"-y",
"-i",
str(input_path),
"-vn",
"-acodec",
"aac",
"-ar",
"16000",
"-ac",
"1",
"-b:a",
"32k",
"-filter:a",
f"atempo={speed}",
str(save_path),
]
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return save_path
def stretch_srt(srt_text: str, speed: float) -> str:
subs = list(srt.parse(srt_text))
for sub in subs:
sub.start = sub.start * speed
sub.end = sub.end * speed
return srt.compose(subs)
def read_audio_duration(audio_path: Path) -> float:
cmd = [
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
str(audio_path),
]
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
return float(result.stdout.strip())
def transcribe_as_srt(audio_path: Path, api_key: str) -> str:
client = httpx.Client(
base_url="https://api.openai.com/v1/",
headers={"Authorization": f"Bearer {api_key}"},
timeout=120,
)
with audio_path.open("rb") as f:
res = client.post(
"/audio/transcriptions",
data={"model": "whisper-1", "response_format": "srt"},
files={"file": f},
)
if res.is_error:
logger.error(f"Error during transcription: {res.text}")
res.raise_for_status()
if ms := res.headers.get("openai-processing-ms"):
logger.info(f"Transcription done in {ms} ms")
try:
price_per_min = 0.006
duration = read_audio_duration(audio_path)
cost = (duration / 60) * price_per_min
logger.info(f"Transcription cost: ${cost:.4f} for {duration:.2f} seconds")
except Exception:
pass
return res.text
def main() -> None:
args = parse_args()
check_ffmpeg_installed()
save_path: Path = args.output.resolve().with_name(f"{args.input.stem}.srt")
logger.info(f"Extracting audio from {args.input}")
with tempfile.TemporaryDirectory() as tmpdir:
audio_path = Path(tmpdir) / "audio.m4a"
speed = args.speed
extract_audio(input_path=args.input, save_path=audio_path, speed=speed)
try:
logger.info(f"Transcribing {audio_path}")
srt_text = transcribe_as_srt(audio_path=audio_path, api_key=args.api_key)
save_path.with_name(f"{args.input.stem}.txt").write_text(srt_text)
except Exception as e:
logger.error(f"Error during transcription: {e}")
raise SystemExit(1)
logger.debug("Stretching SRT timings to 1x")
srt_stretched = stretch_srt(srt_text=srt_text, speed=speed)
save_path.write_text(srt_stretched)
logger.info(f"SRT written to {save_path}")
if __name__ == "__main__":
main()
+175
View File
@@ -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()
+99
View File
@@ -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()
+182
View File
@@ -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}`);
})();