Compare commits
27
Commits
94523562c1
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de6771ac86 | ||
|
|
f3c20d8ed5 | ||
|
|
3e5d58ea25 | ||
|
|
5a3cbdde6a | ||
|
|
256b81c828 | ||
|
|
8c78f79720 | ||
|
|
5216362be7 | ||
|
|
69df91380c | ||
|
|
7ede67b98f | ||
|
|
d5cf81a919 | ||
|
|
3cba11f1df | ||
|
|
ec07e27924 | ||
|
|
3b4fc7159e | ||
|
|
eee4addd86 | ||
|
|
0ddf2c4db5 | ||
|
|
c9228ae4a7 | ||
|
|
051b7b12f0 | ||
|
|
6bd4d58373 | ||
|
|
5100ad8365 | ||
|
|
b0088aeeef | ||
|
|
d0c1f1ab8a | ||
|
|
441bec4401 | ||
|
|
8868e5a0b9 | ||
|
|
9ccb80c5af | ||
|
|
c296f8aeea | ||
|
|
83edb4a950 | ||
|
|
a172b55df7 |
@@ -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`.
|
||||
@@ -0,0 +1 @@
|
||||
3.13
|
||||
Vendored
+2
-1
@@ -7,9 +7,10 @@
|
||||
{
|
||||
"name": "Python: Current File",
|
||||
"type": "python",
|
||||
"args": ["2022-12-06"],
|
||||
"env": {"MISTRAL_API_KEY":"8J5hUwxVMCiQjhXxElefDqU7XJYAvVXn", "OPENROUTER_API_KEY": "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"},
|
||||
"request": "launch",
|
||||
"program": "${file}",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
"justMyCode": true
|
||||
}
|
||||
|
||||
Executable
+363
@@ -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()
|
||||
Executable
+359
@@ -0,0 +1,359 @@
|
||||
#!/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 = """
|
||||
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 noise and 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
@@ -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())
|
||||
+144
@@ -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())
|
||||
Executable
+319
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Mark files for backup, then copy them to an external drive and remove
|
||||
all their hardlinked siblings once the copy is verified.
|
||||
|
||||
Usage:
|
||||
backup_tool.py mark <file> [file...] stage a file for backup
|
||||
backup_tool.py unmark <file> [file...] drop a file from staging
|
||||
backup_tool.py list show what's staged
|
||||
backup_tool.py run [--yes] copy staged files to the drive,
|
||||
then delete originals + siblings
|
||||
|
||||
Config (override via environment):
|
||||
BACKUP_STAGING_DIR default: ~/.backup-staging
|
||||
|
||||
Destination routing is controlled by the RULES table below — edit it to
|
||||
change which drive/subdirectory a file backs up to based on its source path
|
||||
and type.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
STAGING_DIR = Path(
|
||||
os.environ.get("BACKUP_STAGING_DIR", str(Path.home() / ".backup-staging"))
|
||||
)
|
||||
|
||||
VIDEO_EXTS = {".mp4", ".mov", ".mkv", ".avi", ".m4v", ".webm"}
|
||||
|
||||
# Roots under which an external drive's mount point may appear. Native
|
||||
# drives (HFS+/APFS) mount under /Volumes; Mounty mounts NTFS drives
|
||||
# read-write under ~/.mounty instead.
|
||||
MOUNT_ROOTS = [Path("/Volumes"), Path.home() / ".mounty"]
|
||||
|
||||
|
||||
def is_video(path: Path) -> bool:
|
||||
return path.suffix.lower() in VIDEO_EXTS
|
||||
|
||||
|
||||
@dataclass
|
||||
class Rule:
|
||||
prefix: Path # source files under this path are matched
|
||||
volume: str # drive name as it appears under a mount root, e.g. "FIVER"
|
||||
subdir: str # path under the volume root to copy into, e.g. "_ingress"
|
||||
match_file: Callable[[Path], bool] | None = field(
|
||||
default=None
|
||||
) # None matches any file
|
||||
|
||||
def candidate_mount_points(self) -> list[Path]:
|
||||
"""Where this volume might be mounted: a regular /Volumes mount, or
|
||||
a Mounty read-write remount under ~/.mounty."""
|
||||
return [root / self.volume for root in MOUNT_ROOTS]
|
||||
|
||||
def mounted_at(self) -> Path | None:
|
||||
"""Whichever candidate mount point is actually mounted right now, or None."""
|
||||
for mount_point in self.candidate_mount_points():
|
||||
if is_mounted(mount_point):
|
||||
return mount_point
|
||||
return None
|
||||
|
||||
def matches(self, path: Path) -> bool:
|
||||
if not path.is_relative_to(self.prefix):
|
||||
return False
|
||||
if self.match_file is not None and not self.match_file(path):
|
||||
return False
|
||||
return True
|
||||
|
||||
def destination_for(self, path: Path, mount_point: Path) -> Path:
|
||||
rel = path.relative_to(self.prefix)
|
||||
return mount_point / self.subdir / rel
|
||||
|
||||
|
||||
# Order matters: first matching rule wins.
|
||||
RULES = [
|
||||
Rule(
|
||||
prefix=Path.home() / "Downloads/temp",
|
||||
volume="FIVER",
|
||||
subdir="_ingress",
|
||||
match_file=is_video,
|
||||
),
|
||||
Rule(
|
||||
prefix=Path.home() / "Movies",
|
||||
volume="FAST",
|
||||
subdir="_movies",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def rule_for(path: Path) -> Rule | None:
|
||||
for rule in RULES:
|
||||
if rule.matches(path):
|
||||
return rule
|
||||
return None
|
||||
|
||||
|
||||
def die(msg: str) -> None:
|
||||
print(f"error: {msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def staged_path_for(abs_path: Path) -> Path:
|
||||
return STAGING_DIR / abs_path.relative_to(abs_path.anchor)
|
||||
|
||||
|
||||
def original_path_for(staged_path: Path) -> Path:
|
||||
return Path("/") / staged_path.relative_to(STAGING_DIR)
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def mountpoint_of(path: Path) -> str:
|
||||
out = subprocess.run(
|
||||
["df", str(path)], capture_output=True, text=True, check=True
|
||||
).stdout
|
||||
return out.strip().splitlines()[-1].split()[-1]
|
||||
|
||||
|
||||
def mount_line(mount_point: Path) -> str | None:
|
||||
out = subprocess.run(["mount"], capture_output=True, text=True, check=True).stdout
|
||||
for line in out.splitlines():
|
||||
if f" on {mount_point} " in line:
|
||||
return line
|
||||
return None
|
||||
|
||||
|
||||
def is_mounted(mount_point: Path) -> bool:
|
||||
return mount_line(mount_point) is not None
|
||||
|
||||
|
||||
def is_read_only(mount_point: Path) -> bool:
|
||||
"""True if mounted read-only — the state NTFS drives auto-mount into
|
||||
before Mounty remounts them read-write via ntfs-3g."""
|
||||
line = mount_line(mount_point)
|
||||
return line is not None and "read-only" in line
|
||||
|
||||
|
||||
def find_siblings(path: Path) -> list[Path]:
|
||||
"""All paths sharing this file's inode, scoped to its own filesystem."""
|
||||
inode = path.stat().st_ino
|
||||
root = mountpoint_of(path)
|
||||
result = subprocess.run(
|
||||
["find", root, "-xdev", "-inum", str(inode)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return [Path(p) for p in result.stdout.splitlines() if p]
|
||||
|
||||
|
||||
def cmd_mark(args: argparse.Namespace) -> None:
|
||||
if not args.files:
|
||||
die("usage: mark <file> [file...]")
|
||||
for f in args.files:
|
||||
p = Path(f)
|
||||
if not p.is_file():
|
||||
print(f"skip (not a regular file): {f}", file=sys.stderr)
|
||||
continue
|
||||
abs_path = p.resolve()
|
||||
if rule_for(abs_path) is None:
|
||||
die(f"no backup rule matches '{abs_path}' — add one to RULES")
|
||||
dest = staged_path_for(abs_path)
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
if dest.exists():
|
||||
print(f"already marked: {abs_path}")
|
||||
continue
|
||||
try:
|
||||
os.link(abs_path, dest)
|
||||
except OSError as e:
|
||||
die(
|
||||
f"could not hardlink '{abs_path}' into staging "
|
||||
f"(staging dir must be on the same filesystem as the file): {e}"
|
||||
)
|
||||
print(f"marked: {abs_path}")
|
||||
|
||||
|
||||
def cmd_unmark(args: argparse.Namespace) -> None:
|
||||
if not args.files:
|
||||
die("usage: unmark <file> [file...]")
|
||||
for f in args.files:
|
||||
abs_path = Path(f).resolve()
|
||||
staged = staged_path_for(abs_path)
|
||||
if staged.exists():
|
||||
staged.unlink()
|
||||
print(f"unmarked: {abs_path}")
|
||||
else:
|
||||
print(f"not marked: {abs_path}", file=sys.stderr)
|
||||
|
||||
|
||||
def cmd_list(_args: argparse.Namespace) -> None:
|
||||
if not STAGING_DIR.is_dir():
|
||||
print("(nothing staged)")
|
||||
return
|
||||
staged = sorted(p for p in STAGING_DIR.rglob("*") if p.is_file())
|
||||
if not staged:
|
||||
print("(nothing staged)")
|
||||
return
|
||||
for p in staged:
|
||||
print("/" + str(p.relative_to(STAGING_DIR)))
|
||||
|
||||
|
||||
def cmd_run(args: argparse.Namespace) -> None:
|
||||
if not STAGING_DIR.is_dir():
|
||||
print("(nothing staged)")
|
||||
return
|
||||
staged = sorted(p for p in STAGING_DIR.rglob("*") if p.is_file())
|
||||
if not staged:
|
||||
print("(nothing staged)")
|
||||
return
|
||||
|
||||
# Resolve each staged file's destination; skip (with a message) anything
|
||||
# whose target rule can no longer be matched or whose volume isn't mounted.
|
||||
plan = []
|
||||
for src in staged:
|
||||
original = original_path_for(src)
|
||||
rule = rule_for(original)
|
||||
if rule is None:
|
||||
print(f"skipping (no rule matches): {original}", file=sys.stderr)
|
||||
continue
|
||||
mount_point = rule.mounted_at()
|
||||
if mount_point is None:
|
||||
print(f"skipping (drive '{rule.volume}' not mounted): {original}")
|
||||
continue
|
||||
if is_read_only(mount_point):
|
||||
print(
|
||||
f"skipping (drive '{rule.volume}' is mounted read-only — open Mounty "
|
||||
f"and choose 'Remount' to enable write access): {original}"
|
||||
)
|
||||
continue
|
||||
dest = rule.destination_for(original, mount_point)
|
||||
plan.append((src, dest))
|
||||
|
||||
if not plan:
|
||||
print("nothing to do — connect the relevant drive(s) and run again.")
|
||||
return
|
||||
|
||||
print("The following staged files will be copied to their destinations,")
|
||||
print(
|
||||
"then ALL hardlinked copies of each (including the originals) will be deleted:"
|
||||
)
|
||||
for _src, dest in plan:
|
||||
print(f" -> {dest}")
|
||||
print()
|
||||
|
||||
if not args.yes:
|
||||
reply = input("Proceed? [y/N] ").strip().lower()
|
||||
if reply not in ("y", "yes"):
|
||||
print("aborted.")
|
||||
sys.exit(1)
|
||||
|
||||
for src, dest in plan:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"copying: {original_path_for(src)} -> {dest}")
|
||||
shutil.copy2(src, dest)
|
||||
|
||||
if sha256(src) != sha256(dest):
|
||||
print(
|
||||
f" verification FAILED, leaving originals in place: {original_path_for(src)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
continue
|
||||
|
||||
siblings = find_siblings(src)
|
||||
print(f" verified. removing {len(siblings)} hardlinked path(s):")
|
||||
for sib in siblings:
|
||||
print(f" {sib}")
|
||||
for sib in siblings:
|
||||
try:
|
||||
sib.unlink()
|
||||
except OSError as e:
|
||||
print(f" failed to remove {sib}: {e}", file=sys.stderr)
|
||||
|
||||
# Clean up now-empty staging directories.
|
||||
for dirpath, dirnames, filenames in os.walk(STAGING_DIR, topdown=False):
|
||||
d = Path(dirpath)
|
||||
if d != STAGING_DIR and not any(d.iterdir()):
|
||||
d.rmdir()
|
||||
|
||||
print("done.")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p_mark = sub.add_parser("mark")
|
||||
p_mark.add_argument("files", nargs="+")
|
||||
p_mark.set_defaults(func=cmd_mark)
|
||||
|
||||
p_unmark = sub.add_parser("unmark")
|
||||
p_unmark.add_argument("files", nargs="+")
|
||||
p_unmark.set_defaults(func=cmd_unmark)
|
||||
|
||||
p_list = sub.add_parser("list")
|
||||
p_list.set_defaults(func=cmd_list)
|
||||
|
||||
p_run = sub.add_parser("run")
|
||||
p_run.add_argument("--yes", action="store_true", help="skip confirmation prompt")
|
||||
p_run.set_defaults(func=cmd_run)
|
||||
|
||||
args = parser.parse_args()
|
||||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+285
-35
@@ -74,6 +74,49 @@
|
||||
overflow-y: auto;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
.attachment-card {
|
||||
max-width: 85%;
|
||||
margin: 1rem 0;
|
||||
padding: 0.8rem 1.2rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.8rem;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.attachment-card__title {
|
||||
font-weight: 700;
|
||||
}
|
||||
.attachment-card__meta {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.attachment-card summary {
|
||||
cursor: pointer;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.attachment-card pre {
|
||||
max-height: 14rem;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 0.6rem;
|
||||
background: var(--bg-code);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
.suggested-questions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
.suggested-questions button {
|
||||
cursor: pointer;
|
||||
padding: 0.35rem 0.6rem;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
.message {
|
||||
padding: 0.8rem 1.2rem;
|
||||
border-radius: 0.8rem;
|
||||
@@ -92,11 +135,28 @@
|
||||
color: var(--text-user);
|
||||
}
|
||||
|
||||
.user-message > div {
|
||||
.user-message .text {
|
||||
color: var(--text-user);
|
||||
margin-left: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.undo-btn {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-top: 0.4rem;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: var(--text-user);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
border-radius: 0.3rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
font-size: 0.8em;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.undo-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.system-message {
|
||||
background: var(--bg-secondary);
|
||||
margin-right: auto;
|
||||
@@ -230,6 +290,22 @@
|
||||
.history-item:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
.clear-history-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.3rem;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.3rem;
|
||||
font-size: 0.9em;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.clear-history-btn:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
.history-time {
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
@@ -275,6 +351,21 @@
|
||||
<div class="chat-container">
|
||||
<!-- Messages -->
|
||||
<div class="messages" x-ref="msgBox" @click="onClickMessage" @scroll="handleScroll">
|
||||
<section class="attachment-card" x-show="attachment">
|
||||
<div class="attachment-card__title">Transcript attached</div>
|
||||
<div class="attachment-card__meta" x-text="`${attachment?.length.toLocaleString()} characters`"></div>
|
||||
<details>
|
||||
<summary>Show transcript</summary>
|
||||
<pre x-text="attachment?.value"></pre>
|
||||
</details>
|
||||
<template x-if="visibleMessages.length === 0 && !isBusy">
|
||||
<div class="suggested-questions">
|
||||
<button @click="useSuggestedQuestion('What are the key points?')">Key points</button>
|
||||
<button @click="useSuggestedQuestion('What are the most important quotes?')">Notable quotes</button>
|
||||
<button @click="useSuggestedQuestion('What should I challenge or verify?')">What to verify</button>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
<template x-for="(msg, idx) in visibleMessages" :key="idx">
|
||||
<div :class="msg.role === 'user' ? 'message user-message' : 'message system-message'">
|
||||
<template x-if="msg.role === 'user'">
|
||||
@@ -282,7 +373,8 @@
|
||||
<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;" />
|
||||
</template>
|
||||
<div x-text="msg.content"></div>
|
||||
<div class="text" x-text="msg.content"></div>
|
||||
<button class="undo-btn" title="Trim conversation back to before this message" @click="trimTo(msg)">Undo</button>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="msg.role === 'assistant'">
|
||||
@@ -295,7 +387,7 @@
|
||||
|
||||
<!-- Textarea -->
|
||||
<div class="prompt-wrapper">
|
||||
<div x-show="pendingImages.length > 0" style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: flex-start;">
|
||||
<div x-show="!attachment && pendingImages.length > 0" style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: flex-start;">
|
||||
<template x-for="(img, i) in pendingImages" :key="img.dataUrl">
|
||||
<div style="position: relative; display: inline-block;">
|
||||
<img :src="img.dataUrl" style="max-height: 72px; max-width: 120px; object-fit: cover; border-radius: 0.3rem; display: block;" />
|
||||
@@ -319,7 +411,7 @@
|
||||
<button class="submit" :class="isBusy && 'busy'" @click="isBusy ? abort() : handleSubmit()" x-text="isBusy ? 'Cancel' : 'Send'"></button>
|
||||
|
||||
<!-- Model Selector -->
|
||||
<details id="model-selector">
|
||||
<details id="model-selector" x-show="!attachment">
|
||||
<summary x-text="'Model: ' + activeModelName"></summary>
|
||||
<template x-for="m in models" :key="m.name">
|
||||
<label class="model-opt__label" style="display: block">
|
||||
@@ -330,7 +422,7 @@
|
||||
</details>
|
||||
|
||||
<!-- History -->
|
||||
<details id="history-panel" x-ref="historyPanel">
|
||||
<details id="history-panel" x-ref="historyPanel" x-show="!attachment">
|
||||
<summary>History</summary>
|
||||
<div class="history-list">
|
||||
<template x-if="historyItems.length === 0">
|
||||
@@ -342,6 +434,9 @@
|
||||
<span class="history-excerpt" x-text="getExcerpt(item.messages)"></span>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="historyItems.length > 0">
|
||||
<button class="clear-history-btn" @click="clearHistory">Clear</button>
|
||||
</template>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
@@ -355,19 +450,68 @@
|
||||
You are a helpful AI assistant.
|
||||
- Do not preach about implications, do not praise excessively.
|
||||
- Keep a critical mindset, do not assume blindly.
|
||||
- Answer in valid markdown only, no exceptions.
|
||||
|
||||
- When I ask something about code, or software development, keep in mind that I am a senior backend developer (but do not mention that explicitly), and do not explain excessively, but and challenge my statements with better alternatives if I don't sound right, especially in coding. Do not bring it up otherwise.
|
||||
- When I ask something about code, or software development, keep in mind that I am a senior backend developer (but do not mention that explicitly), and do not explain excessively, but and challenge my statements where needed with better alternatives if I don't sound right, especially in coding. Do not bring it up otherwise.
|
||||
- Do not give me code if the topic is not about programming or software development.
|
||||
`
|
||||
.replace(/^\s+/gm, "")
|
||||
.trim();
|
||||
|
||||
const defaultVideoSummaryPrompt = "Summarize this video or transcript. Cover the key points, necessary context, and notable quotes. Skip promotions.";
|
||||
|
||||
function getTranscriptAttachment(transcript) {
|
||||
const value = typeof transcript === "string" ? transcript.trim() : "";
|
||||
if (value) return { kind: "text", value, length: value.length };
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSystemInstruction(attachment) {
|
||||
if (!attachment) return systemPrompt;
|
||||
|
||||
const attachmentRules = `
|
||||
You are answering questions about the attached transcript.
|
||||
Base your answers on that source. State when the source does not establish an answer, do not invent details, and preserve exact wording when the user asks for quotes.
|
||||
`
|
||||
.replace(/^\s+/gm, "")
|
||||
.trim();
|
||||
|
||||
return `${attachmentRules}\n\nTranscript:\n${attachment.value}`;
|
||||
}
|
||||
|
||||
const initialAttachment = getTranscriptAttachment(window.env.YOUTUBE_TRANSCRIPT);
|
||||
const initialPromptText = window.env.PROMPT ? `${window.env.PROMPT}\n\n` : "";
|
||||
const geminiCachePromises = new Map();
|
||||
|
||||
async function getGeminiAttachmentCache({ model, apiKey, systemInstruction, attachment }) {
|
||||
if (!attachment) return null;
|
||||
const key = `${model}:${attachment.kind}:${attachment.value}`;
|
||||
if (!geminiCachePromises.has(key)) {
|
||||
geminiCachePromises.set(
|
||||
key,
|
||||
fetch(`https://generativelanguage.googleapis.com/v1beta/cachedContents?key=${apiKey}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: `models/${model}`,
|
||||
systemInstruction: { parts: [{ text: systemInstruction }] },
|
||||
contents: [],
|
||||
ttl: "3600s",
|
||||
}),
|
||||
})
|
||||
.then(async (response) => (response.ok ? response.json() : null))
|
||||
.then((cache) => cache?.name || null)
|
||||
.catch(() => null)
|
||||
);
|
||||
}
|
||||
return geminiCachePromises.get(key);
|
||||
}
|
||||
|
||||
const chatApp = () => ({
|
||||
messages: [{ role: "system", content: systemPrompt }],
|
||||
pendingImages: [],
|
||||
promptText: window.env.PROMPT ? `${window.env.PROMPT}\n\n` : "",
|
||||
activeModelName: "gemini-3-flash-preview",
|
||||
attachment: initialAttachment,
|
||||
promptText: initialPromptText,
|
||||
activeModelName: "gemini-3.5-flash-lite",
|
||||
historyItems: [],
|
||||
isBusy: false,
|
||||
abortController: null,
|
||||
@@ -375,16 +519,18 @@
|
||||
sessionId: null,
|
||||
|
||||
models: [
|
||||
{ name: "gpt-4.1", chat: chatWithOpenAI, key: "OPENAI_API_KEY" },
|
||||
{ name: "gpt-4.1-mini", chat: chatWithOpenAI, key: "OPENAI_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: "gpt-5.6-luna", chat: chatWithOpenAI, key: window.env.OPENAI_API_KEY },
|
||||
{ name: "gemini-3.5-flash-lite", chat: chatWithGemini, key: window.env.GEMINI_API_KEY },
|
||||
{ name: "claude-haiku-4-5-20251001", chat: chatWithClaude, key: window.env.ANTHROPIC_API_KEY },
|
||||
],
|
||||
|
||||
async init() {
|
||||
this.sessionId = +new Date();
|
||||
this.historyItems = loadHistory();
|
||||
this.historyItems = this.attachment ? [] : loadHistory();
|
||||
setTimeout(() => this.$refs.promptInput.focus(), 0);
|
||||
if (this.attachment && window.env.AUTO_SUBMIT === true) {
|
||||
setTimeout(() => this.useSuggestedQuestion(this.promptText.trim() || defaultVideoSummaryPrompt, true), 0);
|
||||
}
|
||||
},
|
||||
|
||||
async onClickMessage(e) {
|
||||
@@ -424,6 +570,12 @@
|
||||
getExcerpt: (msgs) => msgs.find((m) => m.role === "user")?.content.slice(0, 30) || "Empty chat",
|
||||
|
||||
renderMarkdown,
|
||||
useSuggestedQuestion(question, submit = false) {
|
||||
if (this.isBusy) return;
|
||||
this.promptText = question;
|
||||
this.$nextTick(() => this.$refs.promptInput.focus());
|
||||
if (submit) this.handleSubmit();
|
||||
},
|
||||
async handleSubmit() {
|
||||
const text = this.promptText.trim();
|
||||
if ((!text && !this.pendingImages.length) || this.isBusy) return;
|
||||
@@ -442,11 +594,13 @@
|
||||
this.scrollToBottom();
|
||||
|
||||
try {
|
||||
const modelObj = this.models.find((m) => m.name === this.activeModelName);
|
||||
const modelObj = this.models.find((m) => m.name === (this.attachment ? "gemini-3.5-flash-lite" : this.activeModelName));
|
||||
await modelObj.chat({
|
||||
model: this.activeModelName,
|
||||
apiKey: window.env[modelObj.key],
|
||||
model: modelObj.name,
|
||||
apiKey: modelObj.key,
|
||||
messages: this.messages,
|
||||
attachment: this.attachment,
|
||||
systemInstruction: getSystemInstruction(this.attachment),
|
||||
signal: this.abortController.signal,
|
||||
onContent: (chunk) => {
|
||||
fullResponse += chunk;
|
||||
@@ -470,7 +624,7 @@
|
||||
} finally {
|
||||
this.isBusy = false;
|
||||
this.abortController = null;
|
||||
updateSessionHistory(this.sessionId, this.messages);
|
||||
if (!this.attachment) updateSessionHistory(this.sessionId, this.messages);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -479,6 +633,7 @@
|
||||
},
|
||||
|
||||
handlePaste(e) {
|
||||
if (this.attachment) return;
|
||||
const items = [...(e.clipboardData?.items || [])];
|
||||
const imageItems = items.filter((item) => item.type.startsWith("image/"));
|
||||
if (!imageItems.length) return;
|
||||
@@ -493,12 +648,31 @@
|
||||
});
|
||||
},
|
||||
|
||||
trimTo(msg) {
|
||||
if (this.isBusy) return;
|
||||
const idx = this.messages.indexOf(msg);
|
||||
if (idx === -1) return;
|
||||
this.messages = this.messages.slice(0, idx);
|
||||
if (!this.attachment) updateSessionHistory(this.sessionId, this.messages);
|
||||
this.promptText = msg.content;
|
||||
this.pendingImages = msg.images ? JSON.parse(JSON.stringify(msg.images)) : [];
|
||||
this.$nextTick(() => this.$refs.promptInput.focus());
|
||||
},
|
||||
|
||||
restoreConversation(msgs) {
|
||||
if (this.attachment) return;
|
||||
this.messages = JSON.parse(JSON.stringify(msgs));
|
||||
this.userHasScrolledUp = false;
|
||||
this.$refs.historyPanel.removeAttribute("open");
|
||||
this.scrollToBottom();
|
||||
},
|
||||
|
||||
clearHistory() {
|
||||
if (this.attachment) return;
|
||||
if (!confirm("Clear all saved conversations?")) return;
|
||||
this.historyItems = clearAllHistory();
|
||||
this.$refs.historyPanel.removeAttribute("open");
|
||||
},
|
||||
});
|
||||
|
||||
function attachCopyButtons($el) {
|
||||
@@ -520,15 +694,28 @@
|
||||
return $placeholder.innerHTML;
|
||||
}
|
||||
|
||||
async function chatWithOpenAI({ baseUrl = "https://api.openai.com/v1", onContent, onError, messages, signal, apiKey, model, extraHeaders = {}, ...rest }) {
|
||||
async function chatWithOpenAI({ baseUrl = "https://api.openai.com/v1", onContent, onError, messages, signal, apiKey, model, extraHeaders = {}, systemInstruction, attachment, ...rest }) {
|
||||
const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user");
|
||||
const apiMessages = messages.map(({ role, content, images }, index) => {
|
||||
const textPart = {
|
||||
type: "text",
|
||||
text: content,
|
||||
...(index === lastUserIndex ? { prompt_cache_breakpoint: { mode: "explicit" } } : {}),
|
||||
};
|
||||
const parts = [textPart, ...(images || []).map((img) => ({ type: "image_url", image_url: { url: img.dataUrl } }))];
|
||||
return role === "system" || images?.length || index === lastUserIndex ? { role, content: parts } : { role, content };
|
||||
});
|
||||
const response = await fetch(`${baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}`, ...extraHeaders },
|
||||
body: JSON.stringify({ model, messages: messages.map(({ role, content, images }) =>
|
||||
images?.length
|
||||
? { role, content: [{ type: "text", text: content }, ...images.map((img) => ({ type: "image_url", image_url: { url: img.dataUrl } }))] }
|
||||
: { role, content }
|
||||
), stream: true, ...rest }),
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: apiMessages,
|
||||
prompt_cache_key: "chat:index:v1",
|
||||
prompt_cache_options: { mode: "explicit" },
|
||||
stream: true,
|
||||
...rest,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -557,26 +744,83 @@
|
||||
}
|
||||
|
||||
async function chatWithClaude(args) {
|
||||
return chatWithOpenAI({
|
||||
...args,
|
||||
baseUrl: "https://api.anthropic.com/v1",
|
||||
extraHeaders: {
|
||||
"x-api-key": args.apiKey,
|
||||
const { onContent, onError, messages, signal, apiKey, model } = args;
|
||||
const system = messages
|
||||
.filter((message) => message.role === "system")
|
||||
.map((message) => ({ type: "text", text: message.content }))
|
||||
.filter((part) => part.text);
|
||||
const conversation = messages
|
||||
.filter((message) => message.role !== "system")
|
||||
.map((message) => {
|
||||
const images = (message.images || []).map((image) => ({
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: image.mimeType, data: image.base64 },
|
||||
}));
|
||||
return {
|
||||
role: message.role,
|
||||
content: images.length ? [{ type: "text", text: message.content }, ...images] : message.content,
|
||||
};
|
||||
});
|
||||
|
||||
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"anthropic-dangerous-direct-browser-access": "true",
|
||||
},
|
||||
apiKey: window.env.ANTHROPIC_API_KEY,
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
max_tokens: 4096,
|
||||
system,
|
||||
messages: conversation,
|
||||
cache_control: { type: "ephemeral" },
|
||||
stream: true,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
await onError?.(new Error(`Anthropic API Error ${response.status}: ${text.trim()}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const events = buffer.split("\n\n");
|
||||
buffer = events.pop();
|
||||
for (const event of events) {
|
||||
const data = event
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("data:"))
|
||||
?.slice(5)
|
||||
.trim();
|
||||
if (!data) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
if (parsed.type === "content_block_delta" && parsed.delta?.type === "text_delta") await onContent(parsed.delta.text);
|
||||
} catch (error) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function chatWithGemini({ onContent, onError, messages, signal, apiKey = window.env.GEMINI_API_KEY, model, ...rest }) {
|
||||
const contents = messages.map((msg, i, arr) => {
|
||||
async function chatWithGemini({ onContent, onError, messages, signal, apiKey, model, systemInstruction, attachment, ...rest }) {
|
||||
const conversation = messages.filter((msg) => msg.role !== "system");
|
||||
const cachedContent = await getGeminiAttachmentCache({ model, apiKey, systemInstruction, attachment });
|
||||
const contents = conversation.map((msg, i) => {
|
||||
const role = msg.role === "assistant" ? "model" : "user";
|
||||
let parts = [{ text: msg.content }];
|
||||
|
||||
// Add YouTube URLs as file data for the final user message
|
||||
const isLastMessage = i === arr.length - 1;
|
||||
if (isLastMessage && role === "user") {
|
||||
// Preserve the existing ability to send a YouTube URL in a normal chat message.
|
||||
const isLastMessage = i === conversation.length - 1;
|
||||
if (!attachment && isLastMessage && role === "user") {
|
||||
const youtubeUrlPattern = /https?:\/\/(www\.)?youtube\.com\/watch\?v=[\w-]+|https?:\/\/youtu\.be\/[\w-]+/g;
|
||||
const youtubeUrls = msg.content.match(youtubeUrlPattern) || [];
|
||||
const fileParts = youtubeUrls.map((url) => ({ file_data: { file_uri: url } }));
|
||||
@@ -599,6 +843,7 @@
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...(cachedContent ? { cachedContent } : systemInstruction ? { system_instruction: { parts: [{ text: systemInstruction }] } } : {}),
|
||||
contents,
|
||||
generationConfig: rest,
|
||||
}),
|
||||
@@ -741,6 +986,11 @@
|
||||
return all[today];
|
||||
};
|
||||
|
||||
const clearAllHistory = () => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
return [];
|
||||
};
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("chatApp", chatApp);
|
||||
});
|
||||
|
||||
Executable
+738
@@ -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()
|
||||
Executable
+163
@@ -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
@@ -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
@@ -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()
|
||||
Executable
+142
@@ -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()
|
||||
+46
-58
@@ -1,9 +1,8 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = ["httpx", "pillow"]
|
||||
# dependencies = ["httpx", "pillow", "click"]
|
||||
# ///
|
||||
import argparse
|
||||
import dataclasses
|
||||
import logging
|
||||
import os
|
||||
@@ -16,6 +15,7 @@ from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
import click
|
||||
import httpx
|
||||
from PIL import Image
|
||||
|
||||
@@ -326,11 +326,24 @@ def is_image(f: Path) -> bool:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subcommands
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def cmd_gallery(urls: list[str], name: str | None, cwd: Path | None = None) -> None:
|
||||
_DEFAULT_CWD = Path("~/Downloads/temp/_imagesets").expanduser()
|
||||
|
||||
|
||||
@click.group()
|
||||
def cli() -> None:
|
||||
pass
|
||||
|
||||
|
||||
@cli.command("gallery")
|
||||
@click.argument("urls", nargs=-1, required=True)
|
||||
@click.option("--name", default=None)
|
||||
@click.option("--cwd", type=Path, default=None)
|
||||
def gallery_cmd(urls: tuple[str, ...], name: str | None, cwd: Path | None) -> None:
|
||||
urls = list(urls)
|
||||
resolved_name = name or _name_from_url(urls[0])
|
||||
output_dir = (cwd or Path(".")) / resolved_name
|
||||
|
||||
@@ -346,9 +359,12 @@ def cmd_gallery(urls: list[str], name: str | None, cwd: Path | None = None) -> N
|
||||
_extract_and_process(output_dir)
|
||||
|
||||
|
||||
def cmd_filehost(
|
||||
urls: list[str], name: str | None, token: str, cwd: Path | None = None
|
||||
) -> None:
|
||||
@cli.command("filehost")
|
||||
@click.argument("urls", nargs=-1, required=True)
|
||||
@click.option("--name", default=None)
|
||||
@click.option("--cwd", type=Path, default=_DEFAULT_CWD)
|
||||
@click.option("--token", default=os.getenv("ALLDEBRID_TOKEN"))
|
||||
def filehost_cmd(urls: tuple[str, ...], name: str | None, cwd: Path, token: str) -> None:
|
||||
resolved_name = name or _name_from_url(urls[0])
|
||||
output_dir = (cwd or Path(".")) / resolved_name
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -376,7 +392,26 @@ def cmd_filehost(
|
||||
_extract_and_process(output_dir)
|
||||
|
||||
|
||||
def cmd_process(dirs: list[str]) -> None:
|
||||
@cli.command("download")
|
||||
@click.argument("urls", nargs=-1, required=True)
|
||||
@click.option("--name", default=None)
|
||||
@click.option("--cwd", type=Path, default=_DEFAULT_CWD)
|
||||
@click.option("--token", default=os.getenv("ALLDEBRID_TOKEN"))
|
||||
@click.pass_context
|
||||
def download_cmd(
|
||||
ctx: click.Context, urls: tuple[str, ...], name: str | None, cwd: Path, token: str
|
||||
) -> None:
|
||||
filehost_urls = [u for u in urls if "rg.to" in u or "rapidgator.net" in u]
|
||||
gallery_urls = [u for u in urls if u not in set(filehost_urls)]
|
||||
if filehost_urls:
|
||||
ctx.invoke(filehost_cmd, urls=tuple(filehost_urls), name=name, cwd=cwd, token=token)
|
||||
if gallery_urls:
|
||||
ctx.invoke(gallery_cmd, urls=tuple(gallery_urls), name=name, cwd=cwd)
|
||||
|
||||
|
||||
@cli.command("process")
|
||||
@click.argument("dirs", nargs=-1, required=True)
|
||||
def process_cmd(dirs: tuple[str, ...]) -> None:
|
||||
for d in dirs:
|
||||
p = Path(d)
|
||||
if not p.exists():
|
||||
@@ -400,59 +435,12 @@ def cmd_process(dirs: list[str]) -> None:
|
||||
if originals:
|
||||
print(f"trashing {len(originals)} original(s)")
|
||||
move_to_trash(originals)
|
||||
remove_empty_dirs(p)
|
||||
else:
|
||||
print(f"skipping {p}: already within size limits")
|
||||
|
||||
|
||||
def cmd_download(
|
||||
urls: list[str], name: str | None, token: str, cwd: Path | None = None
|
||||
) -> None:
|
||||
filehost_urls = [u for u in urls if "rg.to" in u or "rapidgator.net" in u]
|
||||
gallery_urls = [u for u in urls if u not in set(filehost_urls)]
|
||||
if filehost_urls:
|
||||
cmd_filehost(filehost_urls, name, token, cwd)
|
||||
if gallery_urls:
|
||||
cmd_gallery(gallery_urls, name, cwd)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print(os.getenv("PATH"))
|
||||
parser = argparse.ArgumentParser(prog="download_imageset.py")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
gallery_p = sub.add_parser("gallery")
|
||||
gallery_p.add_argument("urls", nargs="+")
|
||||
gallery_p.add_argument("--name", default=None)
|
||||
gallery_p.add_argument("--cwd", type=Path, default=None)
|
||||
|
||||
for cmd in ("filehost", "download"):
|
||||
p = sub.add_parser(cmd)
|
||||
p.add_argument("urls", nargs="+")
|
||||
p.add_argument("--name", default=None)
|
||||
p.add_argument("--cwd", default=None, type=Path)
|
||||
p.add_argument("--token", default=os.getenv("ALLDEBRID_TOKEN"))
|
||||
|
||||
proc = sub.add_parser("process")
|
||||
proc.add_argument("dirs", nargs="+")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
match args.command:
|
||||
case "gallery":
|
||||
cmd_gallery(args.urls, args.name, args.cwd)
|
||||
case "filehost":
|
||||
cmd_filehost(args.urls, args.name, args.token, args.cwd)
|
||||
case "process":
|
||||
cmd_process(args.dirs)
|
||||
case "download":
|
||||
cmd_download(args.urls, args.name, args.token, args.cwd)
|
||||
rename_children(p, flatten=True)
|
||||
remove_empty_dirs(p)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
cli()
|
||||
|
||||
Executable
+194
@@ -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()
|
||||
Executable
+170
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["psycopg[binary]"]
|
||||
# ///
|
||||
"""
|
||||
export_pg.py — run an arbitrary Postgres query via psycopg and stream the
|
||||
results into a SQLite database in batches, without pulling the whole result
|
||||
set into memory.
|
||||
|
||||
This uses a server-side (named) cursor: the query is planned and executed
|
||||
by Postgres exactly once, and rows are then fetched from that single result
|
||||
set in --page-size batches. This is the same thing a GUI SQL client does
|
||||
when you "load more rows" — it is NOT the same as re-running the query with
|
||||
a different LIMIT/OFFSET each time, which would redo the scan/filter/sort
|
||||
from scratch on every page and be dramatically slower on a big query.
|
||||
|
||||
Example (the motivating case):
|
||||
|
||||
./export_pg.py \\
|
||||
--dsn "service=mydb" \\
|
||||
--query 'SELECT id, item_identifier FROM order_item_details' \\
|
||||
--table order_item_details \\
|
||||
-o export.sqlite3
|
||||
|
||||
Connection: --dsn is passed straight to psycopg.connect() as a conninfo
|
||||
string (e.g. "host=... dbname=... user=..." or a postgresql:// URL). If
|
||||
omitted, psycopg falls back to the standard PG* environment variables /
|
||||
libpq defaults.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import decimal
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import psycopg
|
||||
|
||||
|
||||
def sqlite_type_for(value) -> str:
|
||||
if isinstance(value, bool) or isinstance(value, int):
|
||||
return "INTEGER"
|
||||
if isinstance(value, float):
|
||||
return "REAL"
|
||||
if isinstance(value, (bytes, bytearray, memoryview)):
|
||||
return "BLOB"
|
||||
return "TEXT"
|
||||
|
||||
|
||||
def infer_sqlite_type(values: list) -> str:
|
||||
for v in values:
|
||||
if v is not None:
|
||||
return sqlite_type_for(v)
|
||||
return "TEXT"
|
||||
|
||||
|
||||
def adapt(value):
|
||||
if value is None or isinstance(value, (int, float, str, bytes)):
|
||||
return value
|
||||
if isinstance(value, memoryview):
|
||||
return bytes(value)
|
||||
if isinstance(value, decimal.Decimal):
|
||||
return str(value)
|
||||
if isinstance(value, (datetime.date, datetime.time, datetime.datetime)):
|
||||
return value.isoformat()
|
||||
if isinstance(value, uuid.UUID):
|
||||
return str(value)
|
||||
if isinstance(value, (list, dict)):
|
||||
return json.dumps(value)
|
||||
return str(value)
|
||||
|
||||
|
||||
def main():
|
||||
class Formatter(argparse.RawDescriptionHelpFormatter,
|
||||
argparse.ArgumentDefaultsHelpFormatter):
|
||||
pass
|
||||
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=Formatter)
|
||||
ap.add_argument("--query", required=True,
|
||||
help="query to run (a trailing ';' is stripped automatically)")
|
||||
ap.add_argument("-o", "--output", required=True, help="path to SQLite db file")
|
||||
ap.add_argument("--table", required=True, help="destination table name")
|
||||
ap.add_argument("--dsn", help="psycopg conninfo string / postgresql:// URL")
|
||||
ap.add_argument("--page-size", type=int, default=2_000,
|
||||
help="rows fetched from the server-side cursor per batch")
|
||||
ap.add_argument("--if-exists", choices=["fail", "replace", "append"],
|
||||
default="fail", help="behavior when --table already exists")
|
||||
args = ap.parse_args()
|
||||
|
||||
query = args.query.strip().rstrip(";").strip()
|
||||
|
||||
con = sqlite3.connect(args.output)
|
||||
cur = con.cursor()
|
||||
|
||||
exists = cur.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
|
||||
(args.table,),
|
||||
).fetchone()
|
||||
if exists:
|
||||
if args.if_exists == "fail":
|
||||
print(f"table {args.table!r} already exists in {args.output} "
|
||||
"(use --if-exists replace|append)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif args.if_exists == "replace":
|
||||
cur.execute(f'DROP TABLE "{args.table}"')
|
||||
exists = None
|
||||
|
||||
pg = psycopg.connect(args.dsn) if args.dsn else psycopg.connect()
|
||||
|
||||
total = 0
|
||||
columns = None
|
||||
col_types = None
|
||||
start = time.monotonic()
|
||||
show_progress = sys.stderr.isatty()
|
||||
|
||||
def report(done: bool):
|
||||
elapsed = time.monotonic() - start
|
||||
rate = total / elapsed if elapsed > 0 else 0
|
||||
msg = f"{total} rows written ({rate:,.0f} rows/s, {elapsed:.0f}s elapsed)"
|
||||
if show_progress and not done:
|
||||
print(f"\r{msg}", end="", file=sys.stderr, flush=True)
|
||||
else:
|
||||
print(msg, file=sys.stderr)
|
||||
|
||||
try:
|
||||
print(query, file=sys.stderr)
|
||||
with pg.cursor(name="export_pg") as pg_cur:
|
||||
pg_cur.itersize = args.page_size
|
||||
pg_cur.execute(query)
|
||||
|
||||
while True:
|
||||
rows = pg_cur.fetchmany(args.page_size)
|
||||
if not rows:
|
||||
break
|
||||
|
||||
if columns is None:
|
||||
columns = [d.name for d in pg_cur.description]
|
||||
col_types = [
|
||||
infer_sqlite_type([r[i] for r in rows])
|
||||
for i in range(len(columns))
|
||||
]
|
||||
if not exists:
|
||||
cols_sql = ", ".join(
|
||||
f'"{c}" {t}' for c, t in zip(columns, col_types)
|
||||
)
|
||||
cur.execute(f'CREATE TABLE "{args.table}" ({cols_sql})')
|
||||
|
||||
placeholders = ", ".join("?" for _ in columns)
|
||||
converted = [[adapt(v) for v in row] for row in rows]
|
||||
cur.executemany(
|
||||
f'INSERT INTO "{args.table}" VALUES ({placeholders})', converted
|
||||
)
|
||||
con.commit()
|
||||
|
||||
total += len(rows)
|
||||
report(done=len(rows) < args.page_size)
|
||||
finally:
|
||||
pg.close()
|
||||
con.close()
|
||||
|
||||
print(f"done: {total} rows written to {args.output}:{args.table}",
|
||||
file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
@@ -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
+30
-30
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env python3.10
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import dataclasses
|
||||
import datetime
|
||||
@@ -62,7 +62,9 @@ def parse_date(filename: str) -> ParsedDate | None:
|
||||
def from_us_format():
|
||||
match = re_date_us.search(filename)
|
||||
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():
|
||||
match = re_date_iso.search(filename)
|
||||
@@ -72,7 +74,9 @@ def parse_date(filename: str) -> ParsedDate | None:
|
||||
def from_iso_short():
|
||||
match = re_date_iso_short.search(filename)
|
||||
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():
|
||||
match = re_date_iso_rev.search(filename)
|
||||
@@ -82,7 +86,9 @@ def parse_date(filename: str) -> ParsedDate | None:
|
||||
def from_iso_reversed_short():
|
||||
match = re_date_iso_rev_short.search(filename)
|
||||
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 = []
|
||||
for fn in [
|
||||
@@ -101,7 +107,9 @@ def parse_date(filename: str) -> ParsedDate | None:
|
||||
today = datetime.date.today()
|
||||
future_threshold = today + datetime.timedelta(days=60)
|
||||
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:
|
||||
return
|
||||
@@ -139,23 +147,9 @@ def trash(path: Path):
|
||||
subprocess.run(cmd).check_returncode()
|
||||
|
||||
|
||||
def filenames_to_actors():
|
||||
sources = [
|
||||
Path(r"/Users/abdus/Downloads/temp/"),
|
||||
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:
|
||||
def parse_release(
|
||||
filename: str, known_actors: set[str] | None = None
|
||||
) -> Release | None:
|
||||
if not known_actors:
|
||||
known_actors = set()
|
||||
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,
|
||||
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(
|
||||
actors=sorted(actors.split(", ")),
|
||||
studio=studio.removeprefix("@"),
|
||||
@@ -210,14 +206,20 @@ def parse_release(filename: str, known_actors: set[str] | None = None) -> Releas
|
||||
# assert '.PRT' in filename
|
||||
remaining = re.sub(r"\.(720p|1080p|HEVC|x265|PRT|XXX)", " ", filename)
|
||||
studio = remaining[: remaining.index(".")]
|
||||
remaining = remaining[len(studio) :]
|
||||
parsed_date = parse_date(remaining)
|
||||
remaining = remaining[parsed_date.end :]
|
||||
if parsed_date:
|
||||
remaining = remaining[parsed_date.end :]
|
||||
remaining = re.sub(r"[. ]+", " ", remaining).strip()
|
||||
|
||||
actors = []
|
||||
title = None
|
||||
|
||||
# PornFidelity.E1109.Uma.Jolie
|
||||
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]:
|
||||
actors = [f"{a_first} {a_last}", f"{b_first} {b_last}"]
|
||||
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,
|
||||
actors=sorted(actors),
|
||||
title=title,
|
||||
released_at=parsed_date.date,
|
||||
released_at=parsed_date.date if parsed_date else None,
|
||||
)
|
||||
|
||||
def from_galaxxxy():
|
||||
pass
|
||||
|
||||
for fn in [
|
||||
from_own,
|
||||
from_prt,
|
||||
from_galaxxxy,
|
||||
]:
|
||||
try:
|
||||
if res := fn():
|
||||
@@ -255,7 +253,9 @@ def parse_release(filename: str, known_actors: set[str] | None = None) -> Releas
|
||||
|
||||
def parse_args():
|
||||
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()
|
||||
|
||||
|
||||
|
||||
+148
-62
@@ -43,8 +43,8 @@ from file_renamer import Release
|
||||
],
|
||||
[
|
||||
"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):
|
||||
@@ -57,68 +57,154 @@ def test_parse_date(filename: str, date: datetime.date | None):
|
||||
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):
|
||||
parsed = file_renamer.parse_release(filename)
|
||||
assert parsed == expected
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
Executable
+344
@@ -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()
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
MPV=/opt/homebrew/bin/mpv
|
||||
|
||||
if [[ $# -eq 0 ]]; then
|
||||
echo "Usage: $(basename "$0") <file|url> [file|url ...]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IFS=$'\n' read -d '' -r -a TARGETS < <(python3 -c '
|
||||
import sys, urllib.parse as u
|
||||
|
||||
for arg in sys.argv[1:]:
|
||||
for line in arg.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("mpv://"):
|
||||
try:
|
||||
parsed = u.urlparse(line)
|
||||
line = u.parse_qs(parsed.query)["url"][0]
|
||||
except (KeyError, IndexError):
|
||||
pass
|
||||
print(line)
|
||||
' "$@") || true
|
||||
|
||||
[[ ${#TARGETS[@]} -eq 0 ]] && exit 1
|
||||
|
||||
"$MPV" --ontop --on-all-workspaces --volume=0 "${TARGETS[@]}" &
|
||||
@@ -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]">✓ saved</span>
|
||||
</div>
|
||||
<textarea
|
||||
x-ref="textarea"
|
||||
x-model="customPrompt"
|
||||
@focus="selectedOption = 'custom'"
|
||||
placeholder="Enter a prompt…"
|
||||
rows="5"
|
||||
></textarea>
|
||||
<button class="btn-save" @click.stop="saveAndNext()">Save & 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>
|
||||
Executable
+205
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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",
|
||||
]
|
||||
+9
-1
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env python3.10
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import dataclasses
|
||||
@@ -56,6 +56,14 @@ def rename_children(source_dir: Path, flatten: bool) -> None:
|
||||
|
||||
write_undo_script(Path.cwd(), jobs)
|
||||
|
||||
remove_empty_dirs(source_dir)
|
||||
|
||||
|
||||
def remove_empty_dirs(root: Path) -> None:
|
||||
for path in sorted(root.rglob("*"), reverse=True):
|
||||
if path.is_dir() and not any(path.iterdir()):
|
||||
path.rmdir()
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class MoveOp:
|
||||
|
||||
Executable
+97
@@ -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())
|
||||
@@ -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
@@ -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()
|
||||
+22
-4
@@ -208,6 +208,7 @@
|
||||
|
||||
<div x-show="playlistLink">
|
||||
<a class="button" :href="playlistLink">Play all with mpv</a>
|
||||
<a class="button" :href="gridLink">Play all in a grid</a>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar gapped small">
|
||||
@@ -236,7 +237,7 @@
|
||||
},
|
||||
sort: { by: null, dir: "asc" },
|
||||
buildAlfredURL(url) {
|
||||
return `alfred://runtrigger/piracy/direct_link/?argument=${encodeURIComponent(url)}`;
|
||||
return `alfred://runtrigger/dev.abdus.alfred.media/video/?argument=${encodeURIComponent(url)}`;
|
||||
},
|
||||
toggleSort(field) {
|
||||
if (this.sort.by === field) {
|
||||
@@ -320,7 +321,7 @@
|
||||
await this.search();
|
||||
}
|
||||
},
|
||||
get playlistLink() {
|
||||
get playableURLs() {
|
||||
const playable = this.visibleResults.some((item) => item.file_path.startsWith("/mnt/box/files"));
|
||||
if (!playable) {
|
||||
return null;
|
||||
@@ -329,9 +330,26 @@
|
||||
.filter((item) => item.file_path.startsWith("/mnt/box/files"))
|
||||
.map((item) => item.download_url)
|
||||
.sort(() => Math.random() - 0.5);
|
||||
const playlistArg = encodeURIComponent(urls.join("\n"));
|
||||
return `alfred://runtrigger/dev.abdus.labs/mpv_playlist/?argument=${playlistArg}`;
|
||||
return urls;
|
||||
},
|
||||
get playlistLink() {
|
||||
const urls = this.playableURLs;
|
||||
if (!urls) {
|
||||
return null;
|
||||
}
|
||||
const playlistArg = encodeURIComponent(urls.join("\n"));
|
||||
return `alfred://runtrigger/dev.abdus.alfred.media/mpv/?argument=${playlistArg}`;
|
||||
},
|
||||
|
||||
get gridLink() {
|
||||
const urls = this.playableURLs;
|
||||
if (!urls) {
|
||||
return null;
|
||||
}
|
||||
const playlistArg = encodeURIComponent(urls.join("\n"));
|
||||
return `alfred://runtrigger/dev.abdus.alfred.media/gridvid/?argument=${playlistArg}`;
|
||||
},
|
||||
|
||||
async search() {
|
||||
const response = await fetch("/search", {
|
||||
method: "POST",
|
||||
|
||||
Executable
+245
@@ -0,0 +1,245 @@
|
||||
#!/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 ---
|
||||
|
||||
|
||||
class ImageFormat(enum.Enum):
|
||||
JPEG = "jpeg"
|
||||
PNG = "png"
|
||||
WEBP = "webp"
|
||||
|
||||
@property
|
||||
def suffix(self) -> str:
|
||||
return ".jpg" if self is self.JPEG else f".{self.value}"
|
||||
|
||||
|
||||
def sniff_image_format(image_path: Path) -> ImageFormat:
|
||||
"""Identify a supported image format from its file signature."""
|
||||
with image_path.open("rb") as file:
|
||||
header = file.read(12)
|
||||
|
||||
if header.startswith(b"\xff\xd8\xff"):
|
||||
return ImageFormat.JPEG
|
||||
if header.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
return ImageFormat.PNG
|
||||
if len(header) >= 12 and header[:4] == b"RIFF" and header[8:12] == b"WEBP":
|
||||
return ImageFormat.WEBP
|
||||
raise ValueError(f"Unsupported or unrecognized image format: {image_path}")
|
||||
|
||||
|
||||
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]:
|
||||
image_format = sniff_image_format(image_path)
|
||||
if image_format is ImageFormat.JPEG:
|
||||
return read_dims_jpeg(image_path)
|
||||
elif image_format is ImageFormat.PNG:
|
||||
return read_dims_png(image_path)
|
||||
elif image_format is ImageFormat.WEBP:
|
||||
return read_dims_webp(image_path)
|
||||
raise AssertionError(f"Unhandled image format: {image_format}")
|
||||
|
||||
|
||||
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
|
||||
|
||||
try:
|
||||
image_format = sniff_image_format(image_path)
|
||||
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(image_format.suffix)
|
||||
|
||||
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()
|
||||
Executable
+618
@@ -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
+472
@@ -0,0 +1,472 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create and restore a marked ZIP backup of Alfred preferences over WebDAV."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import stat
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
|
||||
PACKAGE_NAME = "Alfred.alfredpreferences"
|
||||
ARCHIVE_NAME = f"{PACKAGE_NAME}.sync-backup.zip"
|
||||
MARKER_NAME = ".alfred-sync-backup"
|
||||
FORMAT_VERSION = 1
|
||||
DEFAULT_WEBDAV_URL = (
|
||||
"https://u201686:T6672ICVoWAedECH@u201686.your-storagebox.de/"
|
||||
)
|
||||
DEFAULT_REMOTE_PATH = "backup/macs/alfred"
|
||||
WEBDAV_TIMEOUT = 30
|
||||
LOGGER = logging.getLogger("sync-alfred")
|
||||
|
||||
|
||||
class SyncError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class ProgressIO:
|
||||
"""Wrap a readable binary file and report transfer progress once per second."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_obj,
|
||||
*,
|
||||
total: int | None = None,
|
||||
label: str = "Progress",
|
||||
) -> None:
|
||||
self.file_obj = file_obj
|
||||
self.total = total
|
||||
self.label = label
|
||||
self.transferred = 0
|
||||
self.started_at = time.monotonic()
|
||||
self.last_report_at = self.started_at - 1
|
||||
self.finished = False
|
||||
self._report(force=True)
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self.file_obj, name)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return self.total or 0
|
||||
|
||||
def read(self, size: int = -1):
|
||||
chunk = self.file_obj.read(size)
|
||||
if chunk:
|
||||
self.transferred += len(chunk)
|
||||
self._report()
|
||||
else:
|
||||
self._report(force=True, complete=True)
|
||||
return chunk
|
||||
|
||||
def finish(self) -> None:
|
||||
self._report(force=True, complete=True)
|
||||
|
||||
@staticmethod
|
||||
def _format_bytes(value: float) -> str:
|
||||
units = ("B", "KiB", "MiB", "GiB")
|
||||
for unit in units:
|
||||
if value < 1024 or unit == units[-1]:
|
||||
return f"{value:.1f} {unit}"
|
||||
value /= 1024
|
||||
return f"{value:.1f} GiB"
|
||||
|
||||
def _report(self, *, force: bool = False, complete: bool = False) -> None:
|
||||
if self.finished:
|
||||
return
|
||||
now = time.monotonic()
|
||||
if not force and now - self.last_report_at < 1:
|
||||
return
|
||||
|
||||
self.last_report_at = now
|
||||
elapsed = max(now - self.started_at, 0.001)
|
||||
transferred = self._format_bytes(self.transferred)
|
||||
rate = self._format_bytes(self.transferred / elapsed)
|
||||
if self.total is None:
|
||||
message = f"{self.label}: {transferred} at {rate}/s"
|
||||
else:
|
||||
percent = 100 * self.transferred / self.total if self.total else 100
|
||||
total = self._format_bytes(self.total)
|
||||
message = f"{self.label}: {transferred} / {total} ({percent:.1f}%) at {rate}/s"
|
||||
|
||||
LOGGER.info(message)
|
||||
if complete:
|
||||
self.finished = True
|
||||
|
||||
|
||||
class WebDAVClient:
|
||||
def __init__(self, base_url: str, remote_path: str) -> None:
|
||||
parsed = urllib.parse.urlsplit(base_url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
raise SyncError("invalid WebDAV URL")
|
||||
if parsed.query or parsed.fragment:
|
||||
raise SyncError("WebDAV URL must not contain a query or fragment")
|
||||
|
||||
username = urllib.parse.unquote(parsed.username or "")
|
||||
password = urllib.parse.unquote(parsed.password or "")
|
||||
if not username:
|
||||
username = os.environ.get("ALFRED_WEBDAV_USER", "")
|
||||
if not password:
|
||||
password = os.environ.get("ALFRED_WEBDAV_PASSWORD", "")
|
||||
|
||||
# Strip credentials from request URLs and diagnostic output.
|
||||
netloc = parsed.netloc.rsplit("@", 1)[-1]
|
||||
base_path = parsed.path.rstrip("/")
|
||||
self.base_url = urllib.parse.urlunsplit(
|
||||
(parsed.scheme, netloc, f"{base_path}/", "", "")
|
||||
)
|
||||
self.remote_parts = self._parse_remote_path(remote_path)
|
||||
self.auth_header = None
|
||||
|
||||
password_manager = urllib.request.HTTPPasswordMgrWithDefaultRealm()
|
||||
if username and password:
|
||||
encoded_credentials = base64.b64encode(
|
||||
f"{username}:{password}".encode("utf-8")
|
||||
).decode("ascii")
|
||||
self.auth_header = f"Basic {encoded_credentials}"
|
||||
origin = urllib.parse.urlunsplit(
|
||||
(parsed.scheme, netloc, "/", "", "")
|
||||
)
|
||||
password_manager.add_password(None, origin, username, password)
|
||||
self.opener = urllib.request.build_opener(
|
||||
urllib.request.HTTPBasicAuthHandler(password_manager)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_remote_path(remote_path: str) -> tuple[str, ...]:
|
||||
parts = tuple(remote_path.strip("/").split("/"))
|
||||
if not parts or any(not part or part in (".", "..") for part in parts):
|
||||
raise SyncError(f"invalid WebDAV remote path: {remote_path!r}")
|
||||
return parts
|
||||
|
||||
def _url_for(self, parts: tuple[str, ...]) -> str:
|
||||
encoded = "/".join(urllib.parse.quote(part, safe="") for part in parts)
|
||||
return f"{self.base_url.rstrip('/')}/{encoded}"
|
||||
|
||||
@property
|
||||
def archive_url(self) -> str:
|
||||
return self._url_for(self.remote_parts + (ARCHIVE_NAME,))
|
||||
|
||||
@property
|
||||
def display_location(self) -> str:
|
||||
return self.archive_url
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
data: object | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> urllib.response.addinfourl:
|
||||
request_headers = dict(headers or {})
|
||||
if self.auth_header:
|
||||
request_headers.setdefault("Authorization", self.auth_header)
|
||||
request = urllib.request.Request(
|
||||
url, data=data, headers=request_headers, method=method
|
||||
)
|
||||
try:
|
||||
return self.opener.open(request, timeout=WEBDAV_TIMEOUT)
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = f"HTTP {exc.code} {exc.reason}"
|
||||
raise SyncError(
|
||||
f"WebDAV {method} failed for remote archive: {detail}"
|
||||
) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise SyncError(f"WebDAV {method} failed: {exc.reason}") from exc
|
||||
|
||||
def ensure_remote_directory(self) -> None:
|
||||
for index in range(1, len(self.remote_parts) + 1):
|
||||
# WebDAV collections are directory-like resources and this server
|
||||
# redirects collection URLs without their trailing slash.
|
||||
url = f"{self._url_for(self.remote_parts[:index])}/"
|
||||
headers = {"Authorization": self.auth_header} if self.auth_header else {}
|
||||
request = urllib.request.Request(url, headers=headers, method="MKCOL")
|
||||
try:
|
||||
response = self.opener.open(request, timeout=WEBDAV_TIMEOUT)
|
||||
except urllib.error.HTTPError as exc:
|
||||
# WebDAV uses 405 when MKCOL is sent for an existing collection.
|
||||
if exc.code != 405:
|
||||
detail = f"HTTP {exc.code} {exc.reason}"
|
||||
raise SyncError(
|
||||
f"WebDAV MKCOL failed for remote directory: {detail}"
|
||||
) from exc
|
||||
exc.close()
|
||||
except urllib.error.URLError as exc:
|
||||
raise SyncError(f"WebDAV MKCOL failed: {exc.reason}") from exc
|
||||
else:
|
||||
response.close()
|
||||
|
||||
def upload(self, archive_path: Path) -> None:
|
||||
self.ensure_remote_directory()
|
||||
try:
|
||||
total = archive_path.stat().st_size
|
||||
source = archive_path.open("rb")
|
||||
except OSError as exc:
|
||||
raise SyncError(f"could not open archive {archive_path}: {exc}") from exc
|
||||
|
||||
progress = ProgressIO(source, total=total, label="Uploading")
|
||||
try:
|
||||
with source:
|
||||
with self._request(
|
||||
"PUT",
|
||||
self.archive_url,
|
||||
data=progress,
|
||||
headers={
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Length": str(total),
|
||||
},
|
||||
) as response:
|
||||
response.read()
|
||||
finally:
|
||||
progress.finish()
|
||||
|
||||
def download(self, archive_path: Path) -> None:
|
||||
with self._request("GET", self.archive_url) as response:
|
||||
try:
|
||||
with archive_path.open("wb") as output:
|
||||
shutil.copyfileobj(response, output)
|
||||
output.flush()
|
||||
os.fsync(output.fileno())
|
||||
except OSError as exc:
|
||||
raise SyncError(
|
||||
f"could not write archive {archive_path}: {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
def configured_paths() -> tuple[Path, WebDAVClient]:
|
||||
local_dir = Path(
|
||||
os.environ.get("ALFRED_LOCAL_DIR", "~/Documents/alfred")
|
||||
).expanduser()
|
||||
webdav = WebDAVClient(
|
||||
os.environ.get("ALFRED_WEBDAV_URL", DEFAULT_WEBDAV_URL),
|
||||
os.environ.get("ALFRED_REMOTE_PATH", DEFAULT_REMOTE_PATH),
|
||||
)
|
||||
return local_dir, webdav
|
||||
|
||||
|
||||
def marker(kind: str) -> bytes:
|
||||
contents = {
|
||||
"format": FORMAT_VERSION,
|
||||
"package": PACKAGE_NAME,
|
||||
"kind": kind,
|
||||
"created_utc": datetime.now(timezone.utc).isoformat(),
|
||||
"source_host": socket.gethostname(),
|
||||
}
|
||||
return (json.dumps(contents, sort_keys=True) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def create_archive(source_dir: Path, archive_path: Path, kind: str) -> None:
|
||||
if not source_dir.is_dir():
|
||||
raise SyncError(f"local preferences directory not found: {source_dir}")
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(
|
||||
archive_path, "w", compression=zipfile.ZIP_DEFLATED
|
||||
) as archive:
|
||||
archive.write(source_dir, PACKAGE_NAME)
|
||||
for path in sorted(source_dir.rglob("*"), key=lambda item: item.as_posix()):
|
||||
if path.is_symlink():
|
||||
raise SyncError(f"symlinks are not supported in preferences: {path}")
|
||||
relative = path.relative_to(source_dir)
|
||||
archive.write(
|
||||
path,
|
||||
PurePosixPath(PACKAGE_NAME, *relative.parts).as_posix(),
|
||||
)
|
||||
archive.writestr(MARKER_NAME, marker(kind))
|
||||
except OSError as exc:
|
||||
raise SyncError(f"could not create archive {archive_path}: {exc}") from exc
|
||||
|
||||
|
||||
def validate_archive(archive_path: Path) -> None:
|
||||
try:
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
broken_file = archive.testzip()
|
||||
if broken_file is not None:
|
||||
raise SyncError(f"backup archive is corrupt: {broken_file}")
|
||||
|
||||
names = archive.namelist()
|
||||
if names.count(MARKER_NAME) != 1:
|
||||
raise SyncError(f"backup archive must contain one {MARKER_NAME} marker")
|
||||
|
||||
try:
|
||||
metadata = json.loads(archive.read(MARKER_NAME).decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError, KeyError) as exc:
|
||||
raise SyncError("backup marker is invalid") from exc
|
||||
|
||||
if metadata.get("format") != FORMAT_VERSION:
|
||||
raise SyncError("unsupported Alfred backup format")
|
||||
if metadata.get("package") != PACKAGE_NAME:
|
||||
raise SyncError("backup marker is for a different package")
|
||||
if metadata.get("kind") != "backup":
|
||||
raise SyncError("archive is not a primary Alfred backup")
|
||||
|
||||
seen: set[str] = set()
|
||||
has_package = False
|
||||
for name in names:
|
||||
if name in seen:
|
||||
raise SyncError(f"duplicate file in backup archive: {name}")
|
||||
seen.add(name)
|
||||
|
||||
if "\\" in name:
|
||||
raise SyncError(f"unsafe path in backup archive: {name}")
|
||||
path = PurePosixPath(name)
|
||||
if path.is_absolute() or ".." in path.parts:
|
||||
raise SyncError(f"unsafe path in backup archive: {name}")
|
||||
|
||||
if name == MARKER_NAME:
|
||||
continue
|
||||
if name == PACKAGE_NAME or name.startswith(f"{PACKAGE_NAME}/"):
|
||||
has_package = True
|
||||
continue
|
||||
raise SyncError(f"unexpected file in backup archive: {name}")
|
||||
|
||||
if not has_package:
|
||||
raise SyncError(f"backup archive does not contain {PACKAGE_NAME}")
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise SyncError(f"backup archive is not a valid ZIP: {archive_path}") from exc
|
||||
|
||||
|
||||
def extract_archive(archive_path: Path, destination: Path) -> Path:
|
||||
package_destination = destination / PACKAGE_NAME
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
for info in archive.infolist():
|
||||
if info.filename == MARKER_NAME:
|
||||
continue
|
||||
|
||||
relative = PurePosixPath(info.filename)
|
||||
target = destination.joinpath(*relative.parts)
|
||||
if info.is_dir() or info.filename.endswith("/"):
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
|
||||
mode = (info.external_attr >> 16) & 0xFFFF
|
||||
if stat.S_ISLNK(mode):
|
||||
raise SyncError(f"symlinks are not supported in backup archives: {info.filename}")
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with archive.open(info) as source, target.open("wb") as output:
|
||||
shutil.copyfileobj(source, output)
|
||||
permissions = mode & 0o777
|
||||
if permissions:
|
||||
target.chmod(permissions)
|
||||
|
||||
if not package_destination.is_dir():
|
||||
raise SyncError(f"backup archive does not contain {PACKAGE_NAME}")
|
||||
return package_destination
|
||||
|
||||
|
||||
def unique_backup_path(local_dir: Path) -> Path:
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
candidate = local_dir / f"{PACKAGE_NAME}.before-restore-{timestamp}.zip"
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
return local_dir / f"{PACKAGE_NAME}.before-restore-{timestamp}-{os.getpid()}.zip"
|
||||
|
||||
|
||||
def backup_current_preferences(local_dir: Path, temporary_archive: Path) -> Path | None:
|
||||
package = local_dir / PACKAGE_NAME
|
||||
if not package.exists():
|
||||
return None
|
||||
if not package.is_dir():
|
||||
raise SyncError(f"local preferences path is not a directory: {package}")
|
||||
|
||||
backup_path = unique_backup_path(local_dir)
|
||||
create_archive(package, temporary_archive, "pre-restore")
|
||||
os.replace(temporary_archive, backup_path)
|
||||
return backup_path
|
||||
|
||||
|
||||
def backup(local_dir: Path, webdav: WebDAVClient) -> None:
|
||||
local_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix=".alfred-backup-", dir=local_dir) as temp:
|
||||
archive = Path(temp) / ARCHIVE_NAME
|
||||
create_archive(local_dir / PACKAGE_NAME, archive, "backup")
|
||||
webdav.upload(archive)
|
||||
|
||||
LOGGER.info("Backed up %s to %s", PACKAGE_NAME, webdav.display_location)
|
||||
|
||||
|
||||
def restore(local_dir: Path, webdav: WebDAVClient) -> None:
|
||||
local_dir.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix=".alfred-restore-", dir=local_dir) as temp:
|
||||
temp_dir = Path(temp)
|
||||
archive = temp_dir / ARCHIVE_NAME
|
||||
webdav.download(archive)
|
||||
validate_archive(archive)
|
||||
restored_package = extract_archive(archive, temp_dir)
|
||||
previous_backup = backup_current_preferences(
|
||||
local_dir, temp_dir / "current-pre-restore.zip"
|
||||
)
|
||||
if previous_backup is not None:
|
||||
LOGGER.info("Backed up current preferences to %s", previous_backup)
|
||||
|
||||
old_container = Path(
|
||||
tempfile.mkdtemp(prefix=".alfred-old-", dir=local_dir)
|
||||
)
|
||||
old_package = old_container / PACKAGE_NAME
|
||||
current_package = local_dir / PACKAGE_NAME
|
||||
try:
|
||||
if current_package.exists():
|
||||
os.replace(current_package, old_package)
|
||||
os.replace(restored_package, current_package)
|
||||
except OSError as exc:
|
||||
if old_package.exists() and not current_package.exists():
|
||||
os.replace(old_package, current_package)
|
||||
raise SyncError(f"could not install restored preferences: {exc}") from exc
|
||||
else:
|
||||
shutil.rmtree(old_container)
|
||||
|
||||
LOGGER.info("Restored %s from %s", PACKAGE_NAME, webdav.display_location)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Back up or restore Alfred preferences using a marked ZIP archive."
|
||||
)
|
||||
parser.add_argument(
|
||||
"mode",
|
||||
nargs="?",
|
||||
default="backup",
|
||||
choices=("backup", "push", "restore", "pull", "apply"),
|
||||
help="backup (default) or restore; push/pull/apply are aliases",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
|
||||
try:
|
||||
local_dir, webdav = configured_paths()
|
||||
if args.mode in ("backup", "push"):
|
||||
backup(local_dir, webdav)
|
||||
else:
|
||||
restore(local_dir, webdav)
|
||||
except SyncError as exc:
|
||||
LOGGER.error("sync-alfred: %s", exc)
|
||||
return 1
|
||||
except OSError as exc:
|
||||
LOGGER.error("sync-alfred: filesystem error: %s", exc)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+69
@@ -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()
|
||||
@@ -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" },
|
||||
]
|
||||
Executable
+184
@@ -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()
|
||||
Executable
+175
@@ -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()
|
||||
Executable
+154
@@ -0,0 +1,154 @@
|
||||
#!/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)
|
||||
|
||||
|
||||
PRIORITY_LANGUAGES = ("de", "en", "tr")
|
||||
|
||||
|
||||
def pick_auto_language(transcript_list) -> str:
|
||||
available = {t.language_code: t for t in transcript_list}
|
||||
if not available:
|
||||
raise ValueError("No transcripts available for this video")
|
||||
|
||||
for lang in PRIORITY_LANGUAGES:
|
||||
if lang in available:
|
||||
return lang
|
||||
|
||||
return next(iter(available))
|
||||
|
||||
|
||||
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",
|
||||
)
|
||||
lang_group = parser.add_mutually_exclusive_group()
|
||||
lang_group.add_argument(
|
||||
"--language",
|
||||
metavar="LANG",
|
||||
help="Fetch transcript in the given language code (exits with error if unavailable)",
|
||||
)
|
||||
lang_group.add_argument(
|
||||
"--auto",
|
||||
action="store_true",
|
||||
help=f"Auto-pick a language, preferring {', '.join(PRIORITY_LANGUAGES)} in that order, "
|
||||
"then falling back to the first available generated/translated language",
|
||||
)
|
||||
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()
|
||||
|
||||
if args.language:
|
||||
transcript_list = ytt_api.list(video_id)
|
||||
try:
|
||||
chosen = transcript_list.find_transcript([args.language])
|
||||
except Exception:
|
||||
available = ", ".join(sorted(t.language_code for t in transcript_list))
|
||||
logging.error(
|
||||
f"Language '{args.language}' not available. Available languages: {available}"
|
||||
)
|
||||
sys.exit(1)
|
||||
logging.info(f"Using language: {chosen.language_code}")
|
||||
transcript = chosen.fetch()
|
||||
elif args.auto:
|
||||
transcript_list = ytt_api.list(video_id)
|
||||
lang = pick_auto_language(transcript_list)
|
||||
logging.info(f"Auto-selected language: {lang}")
|
||||
transcript = transcript_list.find_transcript([lang]).fetch()
|
||||
else:
|
||||
try:
|
||||
transcript = ytt_api.fetch(video_id)
|
||||
except Exception:
|
||||
transcript_list = ytt_api.list(video_id)
|
||||
available = ", ".join(sorted(t.language_code for t in transcript_list))
|
||||
logging.error(
|
||||
f"No transcript available in the default language. "
|
||||
f"Available languages: {available}. Use --language or --auto."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
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 = " ".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()
|
||||
@@ -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}`);
|
||||
})();
|
||||
Reference in New Issue
Block a user