diff --git a/.gitignore b/.gitignore index 840622f..60eb50a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,10 @@ coverage/ .DS_Store *.swp + +__pycache__/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.venv/ +apps/api/data/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9d06af8..0cd947b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,9 +1,10 @@ # Contributing ```bash -mise install # exact toolchain versions, from mise.lock -lefthook install # formatting, secret scan, commit-message check -mise run dev # the compose stack +mise install # exact toolchain versions, from mise.lock +lefthook install # formatting, secret scan, commit-message check +mise run //apps/api:dev # the API +mise run //apps/web:dev # the web UI ``` Before opening a pull request, run `mise run checklist`. It runs exactly what diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8d658f4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenMedia contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..fc37988 --- /dev/null +++ b/NOTICE @@ -0,0 +1,29 @@ +OpenMedia +Copyright (c) 2026 OpenMedia contributors + +This product is a rebuild of ReClip (https://github.com/averygan/reclip), +released under the MIT License: + + MIT License + Copyright (c) 2026 ReClip authors + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + +Third-party tools used at runtime: yt-dlp (Unlicense), FFmpeg (LGPL/GPL), +Deno (MIT), Next.js (MIT), Flask (BSD-3-Clause), Phosphor Icons (MIT). diff --git a/README.md b/README.md index 9cdfe1b..6e25a94 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,128 @@ -# Openmedia +
+ OpenMedia +

OpenMedia

+

Download videos from almost any website. Lightweight, self-hosted media downloader with a clean web UI.

+

+ CI + Release + Stars + MIT license +

+

English | Tiếng Việt

+
+ +## Features + +- Downloads from 1000+ sites through yt-dlp +- MP4 and MKV video with a quality picker +- MP3, M4A, Opus, FLAC and WAV audio +- Trimming to a time range +- Subtitles, embedded or as a separate SRT file +- Cover art, metadata and chapters embedded in the file +- A queue with live progress, cancel and a concurrency limit +- Bulk links and playlists +- Cookies for age-restricted or bot-checked videos +- Optional password, rate limiting and private-network blocking +- History, drag and drop, paste anywhere, keyboard shortcuts +- Installable PWA with a share target +- Light and dark themes with seven accent colors +- English and Vietnamese + +
+ OpenMedia desktop, light theme + OpenMedia on a phone, dark theme +
+ +## Installation + +### Docker Compose + +Download `compose.yaml` and `example.env` from the +[latest release](https://github.com/ttncode/openmedia/releases/latest), then +copy the example settings: -A monorepo generated by [scaffold](https://github.com/ttncode/scaffold). Every -application in it implements the same task contract, so one command works the -same way in every config root and CI never has to learn a language. +```bash +cp example.env .env +``` -## Start here +Edit `.env` and set `OPENMEDIA_PASSWORD` to a password of your own. The API +refuses to start while it is still `changeme`; leave it empty only for a +private local instance. Then start the stack: ```bash -mise install # the exact toolchain in mise.lock -lefthook install # formatting, secret scan, commit-message check -mise run dev # the local services this project was generated against +docker compose up -d ``` -`mise run dev` starts `compose.dev.yaml` — throwaway local copies of whichever -database and cache this project selected, published on loopback only. A project -generated with neither has nothing to start. See -[docs/getting-started](docs/getting-started.md). +Open `http://localhost:8080`. -## The task contract +### Installer script -Every config root — each `apps/*`, `packages/*` and `docs` — answers the same -nine tasks: +```bash +curl -fsSL https://github.com/ttncode/openmedia/releases/latest/download/install.sh | bash +``` -| Task | What it does | -| --------------------- | ------------------------------------------ | -| `install` | install dependencies from the lockfile | -| `format`/`format-fix` | report / repair formatting | -| `lint` | report lint failures | -| `check` | type-check | -| `test` | run the tests | -| `build` | produce the build output | -| `ci-unit` | what CI runs on a pull request | -| `checklist` | `ci-unit` plus `build` — the pre-push gate | +The installer generates a random password and prints it with the address when +it finishes. -Run one for a single root with `mise run //apps/api:ci-unit`, or `mise run -checklist` from the project root to run every root at once. That is exactly -what CI runs, so a green checklist locally means a green pull request. +### From source -## Layout +```bash +git clone https://github.com/ttncode/openmedia.git +cd openmedia +mise install +mise run //apps/api:dev +mise run //apps/web:dev +``` -| Path | What lives there | -| ------------------ | --------------------------------------------------- | -| `apps/` | the applications, one config root each | -| `packages/` | code shared between them | -| `docs/` | the documentation site, and `docs/decisions` | -| `compose.yaml` | the stack a client runs, attached to every release | -| `compose.dev.yaml` | throwaway local services, started by `mise run dev` | -| `install.sh` | what an operator runs on the target host | +Open `http://localhost:3000`. -## Releasing and installing +## Documentation -Commits are [Conventional Commits](https://www.conventionalcommits.org); -Release Please turns `feat:` and `fix:` on `main` into a release, and the -release publishes a container image and attaches `compose.yaml`, `example.env` -and `install.sh` to it. +- [Getting started](docs/getting-started.md) +- [Usage](docs/usage.md) +- [Configuration](docs/configuration.md) +- [Deployment](docs/deployment.md) +- [Troubleshooting](docs/troubleshooting.md) +- [Security](docs/security.md) -An operator installs a release with: +## For Developers -```bash -curl -fsSL https://github.com/ttncode/openmedia/releases/latest/download/install.sh | bash -``` +Every config root (`apps/api`, `apps/web`, `docs`) answers the same task +contract: `install`, `format`/`format-fix`, `lint`, `check`, `test`, `build`, +`ci-unit`, `checklist`. Run one root's checks with `mise run +//apps/api:ci-unit`, or `mise run checklist` from the project root to run +every root, which is exactly what CI runs. + +| Path | What lives there | +| ---------- | --------------------------------- | +| `apps/api` | Flask backend, yt-dlp integration | +| `apps/web` | Next.js web UI | +| `docs` | This documentation site | + +See [CONTRIBUTING.md](CONTRIBUTING.md) for the branch and commit conventions. + +## Get Help + +- [Troubleshooting guide](docs/troubleshooting.md) +- [Report a problem](https://github.com/ttncode/openmedia/issues) +- [Security policy](SECURITY.md) + +## Acknowledgments + +- [ReClip](https://github.com/averygan/reclip), the project OpenMedia is built on +- [yt-dlp](https://github.com/yt-dlp/yt-dlp) +- [FFmpeg](https://ffmpeg.org) +- [Deno](https://deno.com) +- [Next.js](https://nextjs.org) +- [Flask](https://flask.palletsprojects.com) +- [Phosphor Icons](https://phosphoricons.com) +- The [scaffold](https://github.com/ttncode/scaffold) toolbox this project was generated with + +## Disclaimer -A private project needs a `GITHUB_TOKEN` carrying `repo` and `read:packages` — -see [docs/deployment](docs/deployment.md) for why one scope is not enough. +OpenMedia is for personal use. Respect copyright law and the terms of service +of the sites you download from. -## Contributing +## License -[CONTRIBUTING.md](CONTRIBUTING.md) covers the branch and commit conventions. -Architecture decisions live in [docs/decisions](docs/decisions); read the -relevant one before changing behaviour it covers, and add one when you make a -decision the next reader would otherwise have to reconstruct. +MIT, see [LICENSE](LICENSE) and [NOTICE](NOTICE). diff --git a/README.vi.md b/README.vi.md new file mode 100644 index 0000000..c3dbf12 --- /dev/null +++ b/README.vi.md @@ -0,0 +1,128 @@ +
+ OpenMedia +

OpenMedia

+

Tải video từ hầu hết mọi trang web. Công cụ tải media gọn nhẹ, tự lưu trữ, với giao diện web rõ ràng.

+

+ CI + Release + Stars + MIT license +

+

English | Tiếng Việt

+
+ +## Tính năng + +- Tải từ hơn 1000 trang web thông qua yt-dlp +- Video MP4 và MKV với bộ chọn chất lượng +- Âm thanh MP3, M4A, Opus, FLAC và WAV +- Cắt theo khoảng thời gian +- Phụ đề, nhúng vào video hoặc tách riêng dưới dạng SRT +- Nhúng ảnh bìa, metadata và chương vào tệp +- Hàng đợi hiển thị tiến độ trực tiếp, có thể hủy và giới hạn số lượng tải cùng lúc +- Dán nhiều liên kết cùng lúc và tải playlist +- Cookie cho video giới hạn độ tuổi hoặc bị chặn kiểm tra bot +- Mật khẩu tùy chọn, giới hạn tốc độ yêu cầu và chặn địa chỉ mạng nội bộ +- Lịch sử, kéo thả, dán bất kỳ đâu, phím tắt +- Cài đặt được như một PWA với share target +- Giao diện sáng và tối với bảy màu nhấn +- Tiếng Anh và tiếng Việt + +
+ OpenMedia trên desktop, giao diện sáng + OpenMedia trên điện thoại, giao diện tối +
+ +## Cài đặt + +### Docker Compose + +Tải `compose.yaml` và `example.env` từ +[bản phát hành mới nhất](https://github.com/ttncode/openmedia/releases/latest), sau đó +sao chép tệp cấu hình mẫu: + +```bash +cp example.env .env +``` + +Sửa `.env` và đặt `OPENMEDIA_PASSWORD` thành mật khẩu của riêng bạn. API sẽ +không khởi động khi giá trị vẫn là `changeme`; chỉ để trống khi chạy riêng trên +máy hoặc mạng cục bộ của bạn. Sau đó khởi động: + +```bash +docker compose up -d +``` + +Mở `http://localhost:8080`. + +### Script cài đặt + +```bash +curl -fsSL https://github.com/ttncode/openmedia/releases/latest/download/install.sh | bash +``` + +Script tạo một mật khẩu ngẫu nhiên và in mật khẩu cùng địa chỉ truy cập khi +hoàn tất. + +### Từ mã nguồn + +```bash +git clone https://github.com/ttncode/openmedia.git +cd openmedia +mise install +mise run //apps/api:dev +mise run //apps/web:dev +``` + +Mở `http://localhost:3000`. + +## Tài liệu + +- [Bắt đầu](docs/getting-started.md) +- [Sử dụng](docs/usage.md) +- [Cấu hình](docs/configuration.md) +- [Triển khai](docs/deployment.md) +- [Xử lý sự cố](docs/troubleshooting.md) +- [Bảo mật](docs/security.md) + +## Dành cho lập trình viên + +Mỗi thư mục gốc cấu hình (`apps/api`, `apps/web`, `docs`) trả lời cùng một hợp +đồng tác vụ: `install`, `format`/`format-fix`, `lint`, `check`, `test`, +`build`, `ci-unit`, `checklist`. Chạy kiểm tra của một thư mục bằng `mise run +//apps/api:ci-unit`, hoặc `mise run checklist` từ thư mục gốc dự án để chạy +tất cả, đúng như những gì CI thực hiện. + +| Đường dẫn | Nội dung | +| ---------- | ------------------------------ | +| `apps/api` | Backend Flask, tích hợp yt-dlp | +| `apps/web` | Giao diện web Next.js | +| `docs` | Trang tài liệu này | + +Xem [CONTRIBUTING.md](CONTRIBUTING.md) để biết quy ước nhánh và commit. + +## Cần trợ giúp + +- [Hướng dẫn xử lý sự cố](docs/troubleshooting.md) +- [Báo cáo vấn đề](https://github.com/ttncode/openmedia/issues) +- [Chính sách bảo mật](SECURITY.md) + +## Lời cảm ơn + +- [ReClip](https://github.com/averygan/reclip), dự án mà OpenMedia được xây dựng dựa trên +- [yt-dlp](https://github.com/yt-dlp/yt-dlp) +- [FFmpeg](https://ffmpeg.org) +- [Deno](https://deno.com) +- [Next.js](https://nextjs.org) +- [Flask](https://flask.palletsprojects.com) +- [Phosphor Icons](https://phosphoricons.com) +- Bộ công cụ [scaffold](https://github.com/ttncode/scaffold) đã tạo dự án này + +## Miễn trừ trách nhiệm + +OpenMedia dành cho mục đích cá nhân. Hãy tôn trọng luật bản quyền và điều +khoản dịch vụ của các trang bạn tải xuống. + +## Giấy phép + +MIT, xem [LICENSE](LICENSE) và [NOTICE](NOTICE). diff --git a/apps/api/.dockerignore b/apps/api/.dockerignore index 633d5a7..4e9728c 100644 --- a/apps/api/.dockerignore +++ b/apps/api/.dockerignore @@ -7,3 +7,4 @@ __pycache__ .env .env.* .git +data/ diff --git a/apps/api/.env.example b/apps/api/.env.example index 9a32ea8..2507331 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -1,3 +1,5 @@ -# off by default: Flask's debug console executes arbitrary code from the browser FLASK_DEBUG=0 -# the database variables are written by the selected service's driver +OPENMEDIA_DATA_DIR=./data +OPENMEDIA_ALLOW_PRIVATE_URLS=false +OPENMEDIA_AUTO_UPDATE_YTDLP=false +OPENMEDIA_RATE_LIMIT_PER_MINUTE=120 diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 1a1e2b9..424b174 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -9,14 +9,25 @@ COPY pyproject.toml uv.lock .python-version ./ RUN uv sync --locked --no-dev --no-install-project FROM python:3.13-slim@sha256:9d2e5553305c7c7b0097999bb17187c69b921ccd6bc9d40e4bb5ebe652c00285 AS runtime +RUN apt-get update \ + && apt-get install --yes --no-install-recommends ffmpeg ca-certificates \ + && rm -rf /var/lib/apt/lists/* WORKDIR /app +COPY --from=deps /usr/local/bin/uv /usr/local/bin/uv COPY --from=deps /app/.venv ./.venv COPY . . -ENV PATH="/app/.venv/bin:${PATH}" -RUN useradd --create-home --uid 10001 app +ENV PATH="/app/.venv/bin:${PATH}" \ + OPENMEDIA_DATA_DIR=/data \ + UV_CACHE_DIR=/data/.cache/uv \ + PYTHONUNBUFFERED=1 +RUN useradd --create-home --uid 10001 app \ + && mkdir -p /data/downloads \ + && chown -R app:app /data \ + && chmod 0755 /app/docker-entrypoint.sh USER app +VOLUME ["/data"] EXPOSE 8080 -# python, not wget or curl: the slim image ships neither. -HEALTHCHECK --interval=30s --timeout=3s \ +HEALTHCHECK --interval=30s --timeout=3s --start-period=300s --start-interval=5s \ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health/live')" || exit 1 -CMD ["gunicorn", "--bind", "0.0.0.0:8080", "app:create_app()"] +ENTRYPOINT ["/app/docker-entrypoint.sh"] +CMD ["gunicorn", "--workers", "1", "--threads", "8", "--timeout", "120", "--bind", "0.0.0.0:8080", "--access-logfile", "-", "app:create_app()"] diff --git a/apps/api/app/__init__.py b/apps/api/app/__init__.py index d2cdf13..ddc26ad 100644 --- a/apps/api/app/__init__.py +++ b/apps/api/app/__init__.py @@ -1,9 +1,53 @@ +from datetime import timedelta + from flask import Flask +from werkzeug.middleware.proxy_fix import ProxyFix + +from .cleanup import RetentionSweeper, remove_orphan_directories +from .config import Settings, load_settings +from .errors import register_error_handlers +from .health import health as health_blueprint +from .media import media as media_blueprint +from .security import ForwardedProtoSessionInterface, load_or_create_secret_key +from .services import EXTENSION_KEY, Services, build_services + +MAX_REQUEST_BYTES = 2 * 1024 * 1024 +SESSION_LIFETIME = timedelta(days=30) + + +def _configure(app: Flask, settings: Settings) -> None: + settings.data_dir.mkdir(parents=True, exist_ok=True) + app.config.update( + SECRET_KEY=load_or_create_secret_key(settings), + SESSION_COOKIE_NAME="openmedia_session", + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE="Lax", + PERMANENT_SESSION_LIFETIME=SESSION_LIFETIME, + MAX_CONTENT_LENGTH=MAX_REQUEST_BYTES, + OPENMEDIA_DATA_DIR=str(settings.data_dir), + ) + app.session_interface = ForwardedProtoSessionInterface() + hops = settings.trusted_proxy_hops + object.__setattr__( + app, "wsgi_app", ProxyFix(app.wsgi_app, x_for=hops, x_proto=1, x_host=1) + ) + -from .health import health +def _start_services(settings: Settings) -> Services: + services = build_services(settings) + remove_orphan_directories(settings.downloads_dir, services.jobs.known_job_ids()) + RetentionSweeper(services.jobs, services.store).start() + return services -def create_app() -> Flask: +def create_app( + settings: Settings | None = None, services: Services | None = None +) -> Flask: + resolved = settings or load_settings() app = Flask(__name__) - app.register_blueprint(health) + _configure(app, resolved) + register_error_handlers(app) + app.register_blueprint(health_blueprint) + app.register_blueprint(media_blueprint) + app.extensions[EXTENSION_KEY] = services or _start_services(resolved) return app diff --git a/apps/api/app/cleanup.py b/apps/api/app/cleanup.py new file mode 100644 index 0000000..7d8f7b8 --- /dev/null +++ b/apps/api/app/cleanup.py @@ -0,0 +1,55 @@ +import shutil +import threading +from collections.abc import Callable +from datetime import datetime, timedelta +from pathlib import Path + +from .jobs import JobManager, utc_now +from .settings_store import SettingsStore + +SWEEP_INTERVAL_SECONDS = 60.0 + + +def remove_orphan_directories(downloads_dir: Path, known_job_ids: set[str]) -> int: + if not downloads_dir.is_dir(): + return 0 + orphans = [ + path + for path in downloads_dir.iterdir() + if path.is_dir() and path.name not in known_job_ids + ] + for path in orphans: + shutil.rmtree(path, ignore_errors=True) + return len(orphans) + + +class RetentionSweeper: + def __init__( + self, + manager: JobManager, + store: SettingsStore, + now: Callable[[], datetime] = utc_now, + ) -> None: + self._manager = manager + self._store = store + self._now = now + self._stopped = threading.Event() + self._thread = threading.Thread( + target=self._loop, name="retention-sweeper", daemon=True + ) + + def sweep_once(self) -> int: + cutoff = self._now() - timedelta( + minutes=self._store.current().retention_minutes + ) + return self._manager.remove_finished_before(cutoff) + + def start(self) -> None: + self._thread.start() + + def stop(self) -> None: + self._stopped.set() + + def _loop(self) -> None: + while not self._stopped.wait(SWEEP_INTERVAL_SECONDS): + self.sweep_once() diff --git a/apps/api/app/config.py b/apps/api/app/config.py new file mode 100644 index 0000000..df605fd --- /dev/null +++ b/apps/api/app/config.py @@ -0,0 +1,97 @@ +import os +from dataclasses import dataclass +from pathlib import Path + +TRUE_VALUES = frozenset({"1", "true", "yes", "on"}) +PASSWORD_PLACEHOLDER = "changeme" + + +def _text(name: str, default: str) -> str: + return os.environ.get(name, default).strip() + + +def _flag(name: str, default: bool) -> bool: + raw = os.environ.get(name, "").strip().lower() + return default if not raw else raw in TRUE_VALUES + + +def _integer(name: str, default: int, minimum: int, maximum: int) -> int: + raw = os.environ.get(name, "").strip() + if not raw: + return default + try: + value = int(raw) + except ValueError as error: + raise ValueError(f"{name} must be an integer, got {raw!r}") from error + if not minimum <= value <= maximum: + raise ValueError(f"{name} must be between {minimum} and {maximum}, got {value}") + return value + + +def _password() -> str: + password = os.environ.get("OPENMEDIA_PASSWORD", "") + if password == PASSWORD_PLACEHOLDER: + raise ValueError( + f"OPENMEDIA_PASSWORD is still the placeholder {PASSWORD_PLACEHOLDER}; " + "set a password, or leave it empty only for a private local instance" + ) + return password + + +@dataclass(frozen=True) +class Settings: + data_dir: Path + password: str + secret_key: str + retention_minutes: int + max_concurrent: int + max_filesize_mb: int + max_storage_gb: int + max_playlist_items: int + rate_limit_per_minute: int + stall_timeout_seconds: float + allow_private_urls: bool + trusted_proxy_hops: int + ytdlp_proxy: str + + @property + def downloads_dir(self) -> Path: + return self.data_dir / "downloads" + + @property + def cookies_file(self) -> Path: + return self.data_dir / "cookies.txt" + + @property + def settings_file(self) -> Path: + return self.data_dir / "settings.json" + + @property + def secret_key_file(self) -> Path: + return self.data_dir / "secret_key" + + @property + def ytdlp_dir(self) -> Path: + return self.data_dir / "yt-dlp" + + +def load_settings() -> Settings: + return Settings( + data_dir=Path(_text("OPENMEDIA_DATA_DIR", "/data")), + password=_password(), + secret_key=_text("OPENMEDIA_SECRET_KEY", ""), + retention_minutes=_integer("OPENMEDIA_RETENTION_MINUTES", 60, 1, 10080), + max_concurrent=_integer("OPENMEDIA_MAX_CONCURRENT", 3, 1, 5), + max_filesize_mb=_integer("OPENMEDIA_MAX_FILESIZE_MB", 4096, 1, 1048576), + max_storage_gb=_integer("OPENMEDIA_MAX_STORAGE_GB", 0, 0, 1048576), + max_playlist_items=_integer("OPENMEDIA_MAX_PLAYLIST_ITEMS", 50, 1, 500), + rate_limit_per_minute=_integer( + "OPENMEDIA_RATE_LIMIT_PER_MINUTE", 120, 1, 10000 + ), + stall_timeout_seconds=_integer( + "OPENMEDIA_STALL_TIMEOUT_SECONDS", 180, 10, 3600 + ), + allow_private_urls=_flag("OPENMEDIA_ALLOW_PRIVATE_URLS", False), + trusted_proxy_hops=_integer("OPENMEDIA_TRUSTED_PROXY_HOPS", 1, 1, 5), + ytdlp_proxy=_text("OPENMEDIA_YTDLP_PROXY", ""), + ) diff --git a/apps/api/app/cookies.py b/apps/api/app/cookies.py new file mode 100644 index 0000000..0ef3896 --- /dev/null +++ b/apps/api/app/cookies.py @@ -0,0 +1,130 @@ +import re +import threading +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path + +from .errors import ApiError +from .private_files import write_private_text + +COOKIE_COPY_NAME = ".cookies.txt" +MAX_COOKIE_BYTES = 1024 * 1024 +HTTP_ONLY_PREFIX = "#HttpOnly_" +COOKIE_FIELD_COUNT = 7 +EXPIRY_FIELD = 4 +EXPIRY_PATTERN = re.compile(r"[0-9]+") +MAX_REPRESENTABLE_EXPIRY = int(datetime.max.replace(tzinfo=UTC).timestamp()) + + +@dataclass(frozen=True) +class CookieRow: + domain: str + expires: int + + +def _iso(moment: datetime | None) -> str | None: + return None if moment is None else moment.isoformat().replace("+00:00", "Z") + + +@dataclass(frozen=True) +class CookieSummary: + present: bool + domains: tuple[str, ...] + expires_at: datetime | None + uploaded_at: datetime | None + + def to_json(self) -> dict[str, object]: + return { + "present": self.present, + "domains": list(self.domains), + "expires_at": _iso(self.expires_at), + "uploaded_at": _iso(self.uploaded_at), + } + + +EMPTY_SUMMARY = CookieSummary( + present=False, domains=(), expires_at=None, uploaded_at=None +) + + +def parse_cookie_rows(text: str) -> list[CookieRow]: + rows = [] + for raw_line in text.splitlines(): + line = raw_line.removeprefix(HTTP_ONLY_PREFIX) + if not line.strip() or line.startswith("#"): + continue + fields = line.split("\t") + if len(fields) == COOKIE_FIELD_COUNT and EXPIRY_PATTERN.fullmatch( + fields[EXPIRY_FIELD] + ): + rows.append( + CookieRow( + domain=fields[0].lstrip("."), expires=int(fields[EXPIRY_FIELD]) + ) + ) + return rows + + +def validate_cookie_file(raw: bytes) -> str: + if len(raw) > MAX_COOKIE_BYTES: + raise ApiError( + 413, "invalid_cookies", "The cookie file must be 1 MB or smaller." + ) + try: + text = raw.decode("utf-8") + except UnicodeDecodeError as error: + raise ApiError( + 400, "invalid_cookies", "The cookie file must be UTF-8 text." + ) from error + if not parse_cookie_rows(text): + raise ApiError( + 400, "invalid_cookies", "This is not a cookies.txt file in Netscape format." + ) + return text + + +def summarize_cookies(text: str, uploaded_at: datetime) -> CookieSummary: + rows = parse_cookie_rows(text) + expiries = [ + row.expires for row in rows if 0 < row.expires <= MAX_REPRESENTABLE_EXPIRY + ] + expires_at = datetime.fromtimestamp(max(expiries), UTC) if expiries else None + return CookieSummary( + True, tuple(sorted({row.domain for row in rows})), expires_at, uploaded_at + ) + + +class CookieStore: + def __init__(self, path: Path) -> None: + self._path = path + self._lock = threading.Lock() + + def summary(self) -> CookieSummary: + with self._lock: + if not self._path.is_file(): + return EMPTY_SUMMARY + text = self._path.read_text(encoding="utf-8") + uploaded_at = datetime.fromtimestamp(self._path.stat().st_mtime, UTC) + return summarize_cookies(text, uploaded_at) + + def save(self, raw: bytes) -> CookieSummary: + text = validate_cookie_file(raw) + with self._lock: + self._path.parent.mkdir(parents=True, exist_ok=True) + temporary = self._path.with_suffix(".tmp") + write_private_text(temporary, text) + temporary.replace(self._path) + return self.summary() + + def delete(self) -> None: + with self._lock: + self._path.unlink(missing_ok=True) + + def copy_into(self, directory: Path) -> Path | None: + with self._lock: + if not self._path.is_file(): + return None + text = self._path.read_text(encoding="utf-8") + target = directory / COOKIE_COPY_NAME + write_private_text(target, text) + return target diff --git a/apps/api/app/errors.py b/apps/api/app/errors.py new file mode 100644 index 0000000..2b98714 --- /dev/null +++ b/apps/api/app/errors.py @@ -0,0 +1,39 @@ +from collections.abc import Mapping + +from flask import Flask, Response, jsonify +from werkzeug.exceptions import HTTPException + + +class ApiError(Exception): + def __init__( + self, + status: int, + code: str, + message: str, + headers: Mapping[str, str] | None = None, + ) -> None: + super().__init__(message) + self.status = status + self.code = code + self.message = message + self.headers = dict(headers or {}) + + +def error_response(status: int, code: str, message: str) -> tuple[Response, int]: + return jsonify(error=message, code=code), status + + +def _api_error(error: ApiError) -> tuple[Response, int, dict[str, str]]: + response, status = error_response(error.status, error.code, error.message) + return response, status, error.headers + + +def _http_error(error: HTTPException) -> tuple[Response, int]: + status = error.code or 500 + code = (error.name or "error").lower().replace(" ", "_") + return error_response(status, code, error.description or error.name) + + +def register_error_handlers(app: Flask) -> None: + app.register_error_handler(ApiError, _api_error) + app.register_error_handler(HTTPException, _http_error) diff --git a/apps/api/app/health.py b/apps/api/app/health.py index a0eea16..467fc2b 100644 --- a/apps/api/app/health.py +++ b/apps/api/app/health.py @@ -1,12 +1,24 @@ -from flask import Blueprint, Response, jsonify +import importlib.util +import os +import shutil +from pathlib import Path -# @DB_ENGINE@ +from flask import Blueprint, Response, current_app, jsonify health = Blueprint("health", __name__) Reply = Response | tuple[Response, int] +def missing_dependencies(data_dir: Path) -> list[str]: + checks = { + "yt-dlp": importlib.util.find_spec("yt_dlp") is not None, + "ffmpeg": shutil.which("ffmpeg") is not None, + "data directory": data_dir.is_dir() and os.access(data_dir, os.W_OK), + } + return [name for name, passed in checks.items() if not passed] + + @health.get("/health/live") def live() -> Reply: return jsonify(status="ok") @@ -14,8 +26,10 @@ def live() -> Reply: @health.get("/health/ready") def ready() -> Reply: - try: - # @DB_PROBE@ - raise RuntimeError("no database is configured for this project") - except Exception as error: # noqa: BLE001 - return jsonify(status="unavailable", reason=str(error)), 503 + data_dir = Path(current_app.config["OPENMEDIA_DATA_DIR"]) + missing = missing_dependencies(data_dir) + if missing: + return jsonify( + status="unavailable", reason=f"missing: {', '.join(missing)}" + ), 503 + return jsonify(status="ok") diff --git a/apps/api/app/jobs.py b/apps/api/app/jobs.py new file mode 100644 index 0000000..ce210a3 --- /dev/null +++ b/apps/api/app/jobs.py @@ -0,0 +1,517 @@ +import logging +import os +import secrets +import shutil +import signal +import subprocess +import threading +import time +from collections import deque +from collections.abc import Callable, Iterator, Mapping, Sequence +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from enum import StrEnum +from pathlib import Path +from typing import Protocol + +from .config import Settings +from .errors import ApiError +from .progress import ProgressTracker, is_postprocessing_line, parse_progress_line +from .settings_store import SettingsStore +from .validation import DownloadOptions +from .ytdlp import ( + CookieCopier, + DownloadRequest, + build_download_command, + error_from_output, + known_error, + ytdlp_environment, +) + +OUTPUT_TAIL_LINES = 40 +MAX_TITLE_LENGTH = 100 +FIRST_PRINTABLE_CODE = 0x20 +DELETE_CODE = 0x7F +CONTROL_CHARACTERS = frozenset(map(chr, [*range(FIRST_PRINTABLE_CODE), DELETE_CODE])) +TITLE_UNSAFE_CHARACTERS = frozenset('\\/:*?"<>|') | CONTROL_CHARACTERS +SUBTITLE_SUFFIXES = frozenset({".srt", ".vtt", ".ass", ".lrc"}) +PARTIAL_SUFFIXES = frozenset({".part", ".ytdl", ".temp"}) +WATCHDOG_INTERVAL_SECONDS = 1.0 +TERMINATED_EXIT_CODE = -15 +KILL_GRACE_SECONDS = 5.0 +IDLE_POLL_INTERVAL_SECONDS = 0.005 +PROCESSING_STALL_MULTIPLIER = 10 + +logger = logging.getLogger(__name__) + + +class JobStatus(StrEnum): + QUEUED = "queued" + DOWNLOADING = "downloading" + PROCESSING = "processing" + DONE = "done" + ERROR = "error" + CANCELLED = "cancelled" + + +ACTIVE_STATUSES = frozenset( + {JobStatus.QUEUED, JobStatus.DOWNLOADING, JobStatus.PROCESSING} +) + + +class ProcessHandle(Protocol): + def output_lines(self) -> Iterator[str]: ... + + def wait(self) -> int: ... + + def terminate(self) -> None: ... + + +ProcessFactory = Callable[[Sequence[str], Mapping[str, str]], ProcessHandle] + + +class SubprocessHandle: + def __init__(self, command: Sequence[str], env: Mapping[str, str]) -> None: + self._process = subprocess.Popen( + list(command), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + encoding="utf-8", + errors="replace", + bufsize=1, + env=dict(env), + start_new_session=True, + ) + + def output_lines(self) -> Iterator[str]: + stream = self._process.stdout + return iter(stream.readline, "") if stream is not None else iter(()) + + def wait(self) -> int: + return self._process.wait() + + def terminate(self) -> None: + self._signal_group(signal.SIGTERM) + follow_up = threading.Timer(KILL_GRACE_SECONDS, self._kill_if_running) + follow_up.daemon = True + follow_up.start() + + def _kill_if_running(self) -> None: + if self._process.poll() is None: + self._signal_group(signal.SIGKILL) + + def _signal_group(self, signal_number: signal.Signals) -> None: + try: + os.killpg(self._process.pid, signal_number) + except ProcessLookupError: + return + + +def start_subprocess(command: Sequence[str], env: Mapping[str, str]) -> ProcessHandle: + return SubprocessHandle(command, env) + + +def utc_now() -> datetime: + return datetime.now(UTC) + + +def isoformat(moment: datetime | None) -> str | None: + return None if moment is None else moment.isoformat().replace("+00:00", "Z") + + +@dataclass +class JobFile: + index: int + name: str + kind: str + size_bytes: int + path: Path + + def to_json(self) -> dict[str, object]: + return { + "index": self.index, + "name": self.name, + "kind": self.kind, + "size_bytes": self.size_bytes, + } + + +@dataclass +class Job: + job_id: str + url: str + title: str + options: DownloadOptions + created_at: datetime + status: JobStatus = JobStatus.QUEUED + progress: float = 0.0 + speed_bps: float | None = None + eta_seconds: int | None = None + downloaded_bytes: int | None = None + total_bytes: int | None = None + files: list[JobFile] = field(default_factory=list) + error: str | None = None + error_code: str | None = None + finished_at: datetime | None = None + + @property + def filename(self) -> str | None: + return self.files[0].name if self.files else None + + @property + def is_active(self) -> bool: + return self.status in ACTIVE_STATUSES + + +@dataclass(frozen=True) +class JobRuntime: + settings: Settings + store: SettingsStore + copy_cookies: CookieCopier + process_factory: ProcessFactory = start_subprocess + now: Callable[[], datetime] = utc_now + + +@dataclass(frozen=True) +class Outcome: + returncode: int + output: str + stalled: bool + cookies_file: Path | None + + +def safe_title(title: str, fallback: str) -> str: + cleaned = "".join( + character for character in title if character not in TITLE_UNSAFE_CHARACTERS + ) + return cleaned.strip()[:MAX_TITLE_LENGTH].strip() or fallback + + +def collect_files(job_dir: Path, title: str, job_id: str) -> list[JobFile]: + candidates = [ + path + for path in sorted(job_dir.iterdir()) + if path.is_file() + and not path.name.startswith(".") + and path.suffix not in PARTIAL_SUFFIXES + ] + media = [path for path in candidates if path.suffix not in SUBTITLE_SUFFIXES] + if not media: + return [] + stem = safe_title(title, f"openmedia-{job_id}") + primary = max(media, key=lambda path: path.stat().st_size) + subtitles = [path for path in candidates if path.suffix in SUBTITLE_SUFFIXES] + named = [(primary, f"{stem}{primary.suffix}", "media")] + named += [ + (path, f"{stem}.{path.name.split('.', 1)[1]}", "subtitle") for path in subtitles + ] + return [ + JobFile(index, name, kind, path.stat().st_size, path) + for index, (path, name, kind) in enumerate(named) + ] + + +class StallWatchdog: + def __init__(self, handle: ProcessHandle, timeout_seconds: float, job: Job) -> None: + self._handle = handle + self._timeout = timeout_seconds + self._job = job + self._last_activity = time.monotonic() + self._stopped = threading.Event() + self.fired = False + self._thread = threading.Thread(target=self._watch, daemon=True) + + def start(self) -> None: + self._thread.start() + + def touch(self) -> None: + self._last_activity = time.monotonic() + + def stop(self) -> None: + self._stopped.set() + + def _current_timeout(self) -> float: + if self._job.status is JobStatus.PROCESSING: + return self._timeout * PROCESSING_STALL_MULTIPLIER + return self._timeout + + def _watch(self) -> None: + interval = min(WATCHDOG_INTERVAL_SECONDS, self._timeout / 4) + while not self._stopped.wait(interval): + if time.monotonic() - self._last_activity > self._current_timeout(): + self.fired = True + self._handle.terminate() + return + + +class JobManager: + def __init__(self, runtime: JobRuntime) -> None: + self._runtime = runtime + self._jobs: dict[str, Job] = {} + self._handles: dict[str, ProcessHandle] = {} + self._running: set[str] = set() + self._cancelled: set[str] = set() + self._pending_threads = 0 + self._lock = threading.RLock() + + def submit(self, url: str, title: str, options: DownloadOptions) -> Job: + job = Job( + job_id=secrets.token_hex(5), + url=url, + title=title, + options=options, + created_at=self._runtime.now(), + ) + with self._lock: + self._jobs[job.job_id] = job + self.dispatch() + return job + + def get(self, job_id: str) -> Job: + with self._lock: + job = self._jobs.get(job_id) + if job is None: + raise ApiError(404, "not_found", "Job not found.") + return job + + def list_jobs(self) -> list[Job]: + with self._lock: + return sorted( + self._jobs.values(), key=lambda job: job.created_at, reverse=True + ) + + def known_job_ids(self) -> set[str]: + with self._lock: + return set(self._jobs) + + def _queued_in_order(self) -> list[Job]: + return [ + job + for job in sorted(self._jobs.values(), key=lambda job: job.created_at) + if job.status is JobStatus.QUEUED + ] + + def queue_position(self, job: Job) -> int: + with self._lock: + queued = self._queued_in_order() + return queued.index(job) + 1 if job in queued else 0 + + def _expires_at(self, job: Job) -> datetime | None: + if job.status is not JobStatus.DONE or job.finished_at is None: + return None + return job.finished_at + timedelta( + minutes=self._runtime.store.current().retention_minutes + ) + + def to_json(self, job: Job) -> dict[str, object]: + with self._lock: + return { + "job_id": job.job_id, + "url": job.url, + "title": job.title, + "status": job.status.value, + "progress": job.progress, + "speed_bps": job.speed_bps, + "eta_seconds": job.eta_seconds, + "downloaded_bytes": job.downloaded_bytes, + "total_bytes": job.total_bytes, + "queue_position": self.queue_position(job), + "options": job.options.to_json(), + "filename": job.filename, + "files": [entry.to_json() for entry in job.files], + "error": job.error, + "error_code": job.error_code, + "created_at": isoformat(job.created_at), + "finished_at": isoformat(job.finished_at), + "expires_at": isoformat(self._expires_at(job)), + } + + def dispatch(self) -> None: + with self._lock: + open_slots = self._runtime.store.current().max_concurrent - len( + self._running + ) + for job in self._queued_in_order()[: max(open_slots, 0)]: + job.status = JobStatus.DOWNLOADING + self._running.add(job.job_id) + self._pending_threads += 1 + thread = threading.Thread( + target=self._run, args=(job,), name=f"job-{job.job_id}", daemon=True + ) + thread.start() + + def cancel_or_remove(self, job_id: str) -> None: + job = self.get(job_id) + with self._lock: + was_active = job.is_active + is_running = job_id in self._running + if was_active: + self._mark_cancelled(job) + else: + del self._jobs[job_id] + handle = self._handles.get(job_id) + if handle is not None: + handle.terminate() + if not is_running: + self._remove_directory(job) + self.dispatch() + + def remove_finished_before(self, cutoff: datetime) -> int: + with self._lock: + expired = [ + job + for job in self._jobs.values() + if not job.is_active + and job.finished_at is not None + and job.finished_at < cutoff + ] + for job in expired: + del self._jobs[job.job_id] + for job in expired: + self._remove_directory(job) + return len(expired) + + def wait_until_idle(self, timeout: float) -> bool: + deadline = time.monotonic() + timeout + while True: + with self._lock: + idle = self._pending_threads == 0 and not any( + job.is_active for job in self._jobs.values() + ) + if idle: + return True + if time.monotonic() >= deadline: + return False + time.sleep(IDLE_POLL_INTERVAL_SECONDS) + + def _job_dir(self, job: Job) -> Path: + return self._runtime.settings.downloads_dir / job.job_id + + def _remove_directory(self, job: Job) -> None: + shutil.rmtree(self._job_dir(job), ignore_errors=True) + + def _mark_cancelled(self, job: Job) -> None: + self._cancelled.add(job.job_id) + job.status = JobStatus.CANCELLED + job.finished_at = self._runtime.now() + job.speed_bps = None + job.eta_seconds = None + + def _run(self, job: Job) -> None: + job_dir = self._job_dir(job) + try: + try: + job_dir.mkdir(parents=True, exist_ok=True) + outcome = self._execute(job, job_dir) + except Exception as error: + logger.exception("Job %s failed unexpectedly", job.job_id) + outcome = Outcome( + returncode=1, + output=f"ERROR: {error}", + stalled=False, + cookies_file=None, + ) + self._finish(job, job_dir, outcome) + self.dispatch() + finally: + with self._lock: + self._pending_threads -= 1 + + def _execute(self, job: Job, job_dir: Path) -> Outcome: + settings = self._runtime.settings + cookies_file = self._runtime.copy_cookies(job_dir) + with self._lock: + cancelled_before_start = job.job_id in self._cancelled + if cancelled_before_start: + return Outcome( + returncode=TERMINATED_EXIT_CODE, + output="", + stalled=False, + cookies_file=cookies_file, + ) + request = DownloadRequest( + job.url, + job.options, + job_dir, + settings.max_filesize_mb, + cookies_file, + settings.ytdlp_proxy, + ) + handle = self._runtime.process_factory( + build_download_command(request), ytdlp_environment(settings) + ) + with self._lock: + if job.job_id in self._cancelled: + handle.terminate() + else: + self._handles[job.job_id] = handle + watchdog = StallWatchdog(handle, settings.stall_timeout_seconds, job) + watchdog.start() + try: + tail: deque[str] = deque(maxlen=OUTPUT_TAIL_LINES) + tracker = ProgressTracker() + for line in handle.output_lines(): + watchdog.touch() + tail.append(line.rstrip()) + self._apply_line(job, tracker, line) + returncode = handle.wait() + finally: + watchdog.stop() + return Outcome(returncode, "\n".join(tail), watchdog.fired, cookies_file) + + def _apply_line(self, job: Job, tracker: ProgressTracker, line: str) -> None: + sample = parse_progress_line(line) + with self._lock: + if job.job_id in self._cancelled: + return + if sample is not None: + job.progress = round(tracker.record(sample), 1) + job.downloaded_bytes, job.total_bytes = ( + sample.downloaded_bytes, + sample.total_bytes, + ) + job.speed_bps, job.eta_seconds = sample.speed_bps, sample.eta_seconds + elif is_postprocessing_line(line): + job.status = JobStatus.PROCESSING + job.progress = tracker.record_processing() + job.speed_bps, job.eta_seconds = None, None + + def _finish(self, job: Job, job_dir: Path, outcome: Outcome) -> None: + if outcome.cookies_file is not None: + outcome.cookies_file.unlink(missing_ok=True) + with self._lock: + self._handles.pop(job.job_id, None) + self._running.discard(job.job_id) + cancelled = job.job_id in self._cancelled + if not cancelled: + try: + self._record_outcome(job, job_dir, outcome) + except Exception as error: + logger.exception( + "Job %s failed while recording its outcome", job.job_id + ) + self._fail(job, "extractor_error", f"Unexpected error: {error}") + if cancelled: + self._remove_directory(job) + + def _record_outcome(self, job: Job, job_dir: Path, outcome: Outcome) -> None: + job.finished_at = self._runtime.now() + job.speed_bps, job.eta_seconds = None, None + if outcome.stalled: + self._fail(job, "timeout", "The download stalled and was stopped.") + return + if outcome.returncode != 0: + error = error_from_output(outcome.output) + self._fail(job, error.code, error.message) + return + files = collect_files(job_dir, job.title, job.job_id) + if not files: + error = known_error(outcome.output) or ApiError( + 400, "extractor_error", "The download finished but no file was found." + ) + self._fail(job, error.code, error.message) + return + job.files, job.status, job.progress = files, JobStatus.DONE, 100.0 + + def _fail(self, job: Job, code: str, message: str) -> None: + job.status, job.error_code, job.error = JobStatus.ERROR, code, message diff --git a/apps/api/app/media.py b/apps/api/app/media.py new file mode 100644 index 0000000..97a7cec --- /dev/null +++ b/apps/api/app/media.py @@ -0,0 +1,196 @@ +from collections.abc import Mapping + +from flask import Blueprint, Response, jsonify, request, send_file + +from .errors import ApiError +from .jobs import JobStatus +from .network_guard import ensure_public_url +from .security import ( + client_address, + ensure_authenticated, + ensure_same_origin_request, + is_authenticated, + password_matches, + sign_in, + sign_out, +) +from .services import ALL_CLIENTS_KEY, current_services +from .storage import ensure_capacity, storage_usage +from .validation import parse_download_options, validate_url + +media = Blueprint("media", __name__, url_prefix="/api") + +PUBLIC_ENDPOINTS = frozenset( + {"media.session_status", "media.create_session", "media.delete_session"} +) +MAX_TITLE_LENGTH = 300 +NO_CONTENT = ("", 204) + + +@media.before_request +def guard_request() -> None: + ensure_same_origin_request() + if request.endpoint not in PUBLIC_ENDPOINTS: + ensure_authenticated(current_services().settings) + + +def json_payload() -> Mapping[str, object]: + payload = request.get_json(silent=True, force=True) + if not isinstance(payload, dict): + raise ApiError(400, "invalid_option", "Send a JSON object.") + return payload + + +def checked_url(payload: Mapping[str, object]) -> str: + url = validate_url(payload.get("url")) + if not current_services().settings.allow_private_urls: + ensure_public_url(url) + return url + + +def enforce_request_limit() -> None: + current_services().request_limiter.enforce(client_address()) + + +@media.get("/session") +def session_status() -> Response: + settings = current_services().settings + limits = { + "max_filesize_mb": settings.max_filesize_mb, + "max_playlist_items": settings.max_playlist_items, + } + return jsonify( + auth_required=bool(settings.password), + authenticated=is_authenticated(settings), + limits=limits, + ) + + +@media.post("/session") +def create_session() -> tuple[str, int]: + services = current_services() + services.login_limiter.enforce(client_address()) + services.global_login_limiter.enforce(ALL_CLIENTS_KEY) + if services.settings.password and not password_matches( + services.settings, json_payload().get("password") + ): + raise ApiError(401, "invalid_password", "The password is not correct.") + sign_in(services.settings) + return NO_CONTENT + + +@media.delete("/session") +def delete_session() -> tuple[str, int]: + sign_out() + return NO_CONTENT + + +@media.post("/info") +def get_info() -> Response: + enforce_request_limit() + url = checked_url(json_payload()) + return jsonify(current_services().ytdlp.fetch_info(url)) + + +@media.post("/playlist") +def get_playlist() -> Response: + enforce_request_limit() + payload = json_payload() + maximum = current_services().settings.max_playlist_items + requested = payload.get("limit") + limit = ( + requested + if isinstance(requested, int) + and not isinstance(requested, bool) + and 0 < requested < maximum + else maximum + ) + return jsonify(current_services().ytdlp.fetch_playlist(checked_url(payload), limit)) + + +@media.post("/download") +def start_download() -> tuple[Response, int]: + enforce_request_limit() + services = current_services() + payload = json_payload() + url = checked_url(payload) + options = parse_download_options(payload) + ensure_capacity( + storage_usage(services.settings.downloads_dir, services.settings.max_storage_gb) + ) + title = str(payload.get("title") or "")[:MAX_TITLE_LENGTH] + job = services.jobs.submit(url, title, options) + return jsonify(job_id=job.job_id, job=services.jobs.to_json(job)), 202 + + +@media.get("/jobs") +def list_jobs() -> Response: + jobs = current_services().jobs + return jsonify(jobs=[jobs.to_json(job) for job in jobs.list_jobs()]) + + +@media.get("/status/") +def job_status(job_id: str) -> Response: + jobs = current_services().jobs + return jsonify(jobs.to_json(jobs.get(job_id))) + + +@media.delete("/jobs/") +def delete_job(job_id: str) -> tuple[str, int]: + current_services().jobs.cancel_or_remove(job_id) + return NO_CONTENT + + +@media.get("/file/", defaults={"index": 0}) +@media.get("/file//") +def download_file(job_id: str, index: int) -> Response: + job = current_services().jobs.get(job_id) + if job.status is not JobStatus.DONE: + raise ApiError(404, "file_not_ready", "The file is not ready yet.") + if index >= len(job.files): + raise ApiError(404, "not_found", "File not found.") + entry = job.files[index] + return send_file( + entry.path, as_attachment=True, download_name=entry.name, conditional=True + ) + + +@media.get("/settings") +def get_settings() -> Response: + return jsonify(current_services().store.current().to_json()) + + +@media.put("/settings") +def update_settings() -> Response: + services = current_services() + updated = services.store.update(json_payload()) + services.jobs.dispatch() + return jsonify(updated.to_json()) + + +@media.get("/storage") +def get_storage() -> Response: + settings = current_services().settings + return jsonify( + storage_usage(settings.downloads_dir, settings.max_storage_gb).to_json() + ) + + +@media.get("/cookies") +def get_cookies() -> Response: + return jsonify(current_services().cookies.summary().to_json()) + + +@media.put("/cookies") +def upload_cookies() -> Response: + upload = request.files.get("file") + if upload is None: + raise ApiError(400, "invalid_cookies", "Choose a cookies.txt file to upload.") + raw = upload.stream.read(1024 * 1024 + 1) + return jsonify(current_services().cookies.save(raw).to_json()) + + +@media.delete("/cookies") +def delete_cookies() -> tuple[str, int]: + current_services().cookies.delete() + return NO_CONTENT diff --git a/apps/api/app/network_guard.py b/apps/api/app/network_guard.py new file mode 100644 index 0000000..fa5c61f --- /dev/null +++ b/apps/api/app/network_guard.py @@ -0,0 +1,34 @@ +import ipaddress +import socket +from collections.abc import Callable +from urllib.parse import urlsplit + +from .errors import ApiError + +Resolver = Callable[[str], list[str]] + + +def resolve_host(host: str) -> list[str]: + return sorted({str(info[4][0]) for info in socket.getaddrinfo(host, None)}) + + +def _is_public(address: str) -> bool: + ip = ipaddress.ip_address(address.split("%", 1)[0]) + mapped = ip.ipv4_mapped if isinstance(ip, ipaddress.IPv6Address) else None + return (mapped or ip).is_global + + +def ensure_public_url(url: str, resolver: Resolver = resolve_host) -> None: + host = urlsplit(url).hostname + if not host: + raise ApiError(400, "invalid_url", "The link has no host name.") + try: + addresses = resolver(host) + except OSError as error: + raise ApiError(400, "invalid_url", f"Could not resolve {host}.") from error + if not addresses or not all(_is_public(address) for address in addresses): + raise ApiError( + 400, + "private_network", + "Links to private or local network addresses are not allowed.", + ) diff --git a/apps/api/app/private_files.py b/apps/api/app/private_files.py new file mode 100644 index 0000000..42e20de --- /dev/null +++ b/apps/api/app/private_files.py @@ -0,0 +1,12 @@ +import os +from pathlib import Path + +PRIVATE_FILE_MODE = 0o600 + + +def write_private_text(path: Path, text: str) -> None: + descriptor = os.open( + path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, PRIVATE_FILE_MODE + ) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + handle.write(text) diff --git a/apps/api/app/progress.py b/apps/api/app/progress.py new file mode 100644 index 0000000..9bffab9 --- /dev/null +++ b/apps/api/app/progress.py @@ -0,0 +1,79 @@ +from dataclasses import dataclass + +PROGRESS_MARKER = "OMPROGRESS" +PROGRESS_FIELD_COUNT = 6 +POSTPROCESSOR_TAGS = ( + "[Merger]", + "[ExtractAudio]", + "[EmbedSubtitle]", + "[Metadata]", + "[EmbedThumbnail]", + "[FixupM3u8]", + "[FixupM4a]", + "[VideoConvertor]", + "[VideoRemuxer]", + "[SubtitlesConvertor]", + "[ThumbnailsConvertor]", + "[ModifyChapters]", +) +STREAM_RANGES = ((0.0, 90.0), (90.0, 99.0)) +PROCESSING_PERCENT = 99.0 + + +@dataclass(frozen=True) +class ProgressSample: + downloaded_bytes: int | None + total_bytes: int | None + speed_bps: float | None + eta_seconds: int | None + + +def _number(token: str) -> float | None: + try: + value = float(token) + except ValueError: + return None + return value if value >= 0 else None + + +def _whole(value: float | None) -> int | None: + return None if value is None else int(value) + + +def parse_progress_line(line: str) -> ProgressSample | None: + parts = line.split() + if len(parts) != PROGRESS_FIELD_COUNT or parts[0] != PROGRESS_MARKER: + return None + downloaded, total, estimate, speed, eta = (_number(token) for token in parts[1:]) + return ProgressSample( + downloaded_bytes=_whole(downloaded), + total_bytes=_whole(total if total is not None else estimate), + speed_bps=speed, + eta_seconds=_whole(eta), + ) + + +def is_postprocessing_line(line: str) -> bool: + return line.lstrip().startswith(POSTPROCESSOR_TAGS) + + +class ProgressTracker: + def __init__(self) -> None: + self.percent = 0.0 + self._stream = 0 + self._last_downloaded = -1 + + def record(self, sample: ProgressSample) -> float: + downloaded = sample.downloaded_bytes or 0 + if downloaded < self._last_downloaded and self._stream < len(STREAM_RANGES) - 1: + self._stream += 1 + self._last_downloaded = downloaded + if sample.total_bytes: + low, high = STREAM_RANGES[self._stream] + fraction = min(downloaded / sample.total_bytes, 1.0) + self.percent = max(self.percent, low + (high - low) * fraction) + return self.percent + + def record_processing(self) -> float: + self.percent = max(self.percent, PROCESSING_PERCENT) + return self.percent diff --git a/apps/api/app/security.py b/apps/api/app/security.py new file mode 100644 index 0000000..123a178 --- /dev/null +++ b/apps/api/app/security.py @@ -0,0 +1,145 @@ +import hashlib +import hmac +import math +import secrets +import threading +import time +from collections.abc import Callable +from urllib.parse import urlsplit + +from flask import Flask, current_app, request, session +from flask.sessions import SecureCookieSessionInterface + +from .config import Settings +from .errors import ApiError +from .private_files import write_private_text + +AUTHENTICATED_SESSION_KEY = "openmedia_authenticated" +SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) +CROSS_SITE_FETCH_VALUES = frozenset({"cross-site", "same-site"}) +SECONDS_PER_MINUTE = 60.0 +SECRET_KEY_BYTES = 32 +MAX_TRACKED_CLIENTS = 10_000 + + +def load_or_create_secret_key(settings: Settings) -> str: + if settings.secret_key: + return settings.secret_key + path = settings.secret_key_file + if path.is_file(): + return path.read_text(encoding="utf-8").strip() + path.parent.mkdir(parents=True, exist_ok=True) + key = secrets.token_hex(SECRET_KEY_BYTES) + write_private_text(path, key) + return key + + +class RateLimiter: + def __init__( + self, per_minute: int, clock: Callable[[], float] = time.monotonic + ) -> None: + self._capacity = float(per_minute) + self._refill_per_second = per_minute / SECONDS_PER_MINUTE + self._clock = clock + self._buckets: dict[str, tuple[float, float]] = {} + self._lock = threading.Lock() + + def _tokens_at(self, bucket: tuple[float, float], now: float) -> float: + tokens, updated = bucket + return min(self._capacity, tokens + (now - updated) * self._refill_per_second) + + def _evict_refilled_buckets(self, now: float) -> None: + if len(self._buckets) <= MAX_TRACKED_CLIENTS: + return + self._buckets = { + key: bucket + for key, bucket in self._buckets.items() + if self._tokens_at(bucket, now) < self._capacity + } + + def retry_after(self, key: str) -> float | None: + with self._lock: + now = self._clock() + tokens = self._tokens_at(self._buckets.get(key, (self._capacity, now)), now) + if tokens < 1: + self._buckets[key] = (tokens, now) + return (1 - tokens) / self._refill_per_second + self._buckets[key] = (tokens - 1, now) + self._evict_refilled_buckets(now) + return None + + def enforce(self, key: str) -> None: + wait = self.retry_after(key) + if wait is not None: + headers = {"Retry-After": str(math.ceil(wait))} + raise ApiError( + 429, "rate_limited", "Too many requests. Try again shortly.", headers + ) + + +def client_address() -> str: + return request.remote_addr or "unknown" + + +def _cross_site_error() -> ApiError: + return ApiError( + 403, "cross_site_request", "Requests from other websites are not allowed." + ) + + +def ensure_same_origin_request() -> None: + if request.method in SAFE_METHODS: + return + if request.headers.get("Sec-Fetch-Site", "").lower() in CROSS_SITE_FETCH_VALUES: + raise _cross_site_error() + origin = request.headers.get("Origin") + if origin and urlsplit(origin).netloc != request.host: + raise _cross_site_error() + + +def _secret_key_bytes() -> bytes: + secret = current_app.secret_key + if secret is None: + raise RuntimeError("Flask secret_key must be configured before signing in.") + return secret if isinstance(secret, bytes) else secret.encode() + + +def _password_fingerprint(settings: Settings) -> str: + return hmac.new( + _secret_key_bytes(), settings.password.encode(), hashlib.sha256 + ).hexdigest() + + +def is_authenticated(settings: Settings) -> bool: + if not settings.password: + return True + fingerprint = session.get(AUTHENTICATED_SESSION_KEY) + return isinstance(fingerprint, str) and hmac.compare_digest( + fingerprint, _password_fingerprint(settings) + ) + + +def ensure_authenticated(settings: Settings) -> None: + if not is_authenticated(settings): + raise ApiError(401, "auth_required", "Sign in to continue.") + + +def password_matches(settings: Settings, candidate: object) -> bool: + if not settings.password or not isinstance(candidate, str): + return False + return hmac.compare_digest(candidate.encode(), settings.password.encode()) + + +def sign_in(settings: Settings) -> None: + session.clear() + session[AUTHENTICATED_SESSION_KEY] = _password_fingerprint(settings) + session.permanent = True + + +def sign_out() -> None: + session.clear() + + +class ForwardedProtoSessionInterface(SecureCookieSessionInterface): + def get_cookie_secure(self, app: Flask) -> bool: + return request.is_secure diff --git a/apps/api/app/services.py b/apps/api/app/services.py new file mode 100644 index 0000000..b757c3f --- /dev/null +++ b/apps/api/app/services.py @@ -0,0 +1,61 @@ +from dataclasses import dataclass +from typing import cast + +from flask import current_app + +from .config import Settings +from .cookies import CookieStore +from .jobs import JobManager, JobRuntime, ProcessFactory, start_subprocess +from .security import RateLimiter +from .settings_store import RuntimeSettings, SettingsStore +from .ytdlp import Runner, YtDlpClient, run_command + +EXTENSION_KEY = "openmedia" +ALL_CLIENTS_KEY = "all-clients" +LOGIN_ATTEMPTS_PER_MINUTE = 5 +LOGIN_ATTEMPTS_PER_MINUTE_ALL_CLIENTS = 30 + + +@dataclass(frozen=True) +class Services: + settings: Settings + store: SettingsStore + cookies: CookieStore + ytdlp: YtDlpClient + jobs: JobManager + request_limiter: RateLimiter + login_limiter: RateLimiter + global_login_limiter: RateLimiter + + +def build_services( + settings: Settings, + process_factory: ProcessFactory = start_subprocess, + runner: Runner = run_command, +) -> Services: + settings.downloads_dir.mkdir(parents=True, exist_ok=True) + store = SettingsStore( + settings.settings_file, + RuntimeSettings(settings.retention_minutes, settings.max_concurrent), + ) + cookies = CookieStore(settings.cookies_file) + runtime = JobRuntime( + settings=settings, + store=store, + copy_cookies=cookies.copy_into, + process_factory=process_factory, + ) + return Services( + settings=settings, + store=store, + cookies=cookies, + ytdlp=YtDlpClient(settings, cookies.copy_into, runner), + jobs=JobManager(runtime), + request_limiter=RateLimiter(settings.rate_limit_per_minute), + login_limiter=RateLimiter(LOGIN_ATTEMPTS_PER_MINUTE), + global_login_limiter=RateLimiter(LOGIN_ATTEMPTS_PER_MINUTE_ALL_CLIENTS), + ) + + +def current_services() -> Services: + return cast(Services, current_app.extensions[EXTENSION_KEY]) diff --git a/apps/api/app/settings_store.py b/apps/api/app/settings_store.py new file mode 100644 index 0000000..6705c26 --- /dev/null +++ b/apps/api/app/settings_store.py @@ -0,0 +1,76 @@ +import json +import threading +from collections.abc import Mapping +from dataclasses import asdict, dataclass +from pathlib import Path + +from .errors import ApiError + +RETENTION_CHOICES = (15, 60, 360, 1440) +MIN_CONCURRENT = 1 +MAX_CONCURRENT = 5 + + +@dataclass(frozen=True) +class RuntimeSettings: + retention_minutes: int + max_concurrent: int + + def to_json(self) -> dict[str, int]: + return asdict(self) + + +def _integer_or_none(value: object) -> int | None: + return value if isinstance(value, int) and not isinstance(value, bool) else None + + +def parse_runtime_settings( + payload: Mapping[str, object], fallback: RuntimeSettings +) -> RuntimeSettings: + retention = _integer_or_none( + payload.get("retention_minutes", fallback.retention_minutes) + ) + concurrency = _integer_or_none( + payload.get("max_concurrent", fallback.max_concurrent) + ) + if retention is None or ( + retention != fallback.retention_minutes and retention not in RETENTION_CHOICES + ): + raise ApiError( + 400, "invalid_option", "retention_minutes must be 15, 60, 360 or 1440." + ) + if concurrency is None or not MIN_CONCURRENT <= concurrency <= MAX_CONCURRENT: + raise ApiError(400, "invalid_option", "max_concurrent must be between 1 and 5.") + return RuntimeSettings(retention_minutes=retention, max_concurrent=concurrency) + + +class SettingsStore: + def __init__(self, path: Path, defaults: RuntimeSettings) -> None: + self._path = path + self._lock = threading.Lock() + self._current = self._load(defaults) + + def _load(self, defaults: RuntimeSettings) -> RuntimeSettings: + try: + raw = json.loads(self._path.read_text(encoding="utf-8")) + return ( + parse_runtime_settings(raw, defaults) + if isinstance(raw, dict) + else defaults + ) + except (OSError, json.JSONDecodeError, ApiError): + return defaults + + def current(self) -> RuntimeSettings: + with self._lock: + return self._current + + def update(self, payload: Mapping[str, object]) -> RuntimeSettings: + updated = parse_runtime_settings(payload, self.current()) + self._path.parent.mkdir(parents=True, exist_ok=True) + temporary = self._path.with_suffix(".tmp") + temporary.write_text(json.dumps(updated.to_json()), encoding="utf-8") + temporary.replace(self._path) + with self._lock: + self._current = updated + return updated diff --git a/apps/api/app/storage.py b/apps/api/app/storage.py new file mode 100644 index 0000000..107baac --- /dev/null +++ b/apps/api/app/storage.py @@ -0,0 +1,55 @@ +import shutil +import stat +from dataclasses import asdict, dataclass +from pathlib import Path + +from .errors import ApiError + +BYTES_PER_GIGABYTE = 1024**3 + + +@dataclass(frozen=True) +class StorageUsage: + used_bytes: int + limit_bytes: int | None + free_bytes: int + + def to_json(self) -> dict[str, int | None]: + return asdict(self) + + +def _entry_size(entry: Path) -> int: + try: + info = entry.stat() + except FileNotFoundError: + return 0 + return info.st_size if stat.S_ISREG(info.st_mode) else 0 + + +def directory_size(path: Path) -> int: + if not path.is_dir(): + return 0 + return sum(_entry_size(entry) for entry in path.rglob("*")) + + +def _free_bytes(path: Path) -> int: + existing = path if path.exists() else path.parent + return shutil.disk_usage(existing).free + + +def storage_usage(downloads_dir: Path, max_storage_gb: int) -> StorageUsage: + limit = max_storage_gb * BYTES_PER_GIGABYTE if max_storage_gb > 0 else None + return StorageUsage( + used_bytes=directory_size(downloads_dir), + limit_bytes=limit, + free_bytes=_free_bytes(downloads_dir), + ) + + +def ensure_capacity(usage: StorageUsage) -> None: + if usage.limit_bytes is not None and usage.used_bytes >= usage.limit_bytes: + raise ApiError( + 507, + "storage_full", + "Server storage is full. Remove finished downloads or raise the limit.", + ) diff --git a/apps/api/app/validation.py b/apps/api/app/validation.py new file mode 100644 index 0000000..df05cf4 --- /dev/null +++ b/apps/api/app/validation.py @@ -0,0 +1,174 @@ +import re +from collections.abc import Mapping +from dataclasses import asdict, dataclass +from urllib.parse import urlsplit + +from .errors import ApiError + +MAX_URL_LENGTH = 2048 +FORMAT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_.+-]{1,64}$") +LANGUAGE_PATTERN = re.compile(r"^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})?$") +UNSAFE_URL_CHARACTERS = re.compile(r"[\s\x00-\x1f\x7f]") +MAX_SUBTITLE_LANGUAGES = 5 +KINDS = ("video", "audio") +CONTAINERS = ("mp4", "mkv") +AUDIO_FORMATS = ("mp3", "m4a", "opus", "flac", "wav") +AUDIO_QUALITIES = ("320k", "best") +SUBTITLE_MODES = ("embed", "srt") +MAX_QUALITY_HEIGHT = 8640 + + +@dataclass(frozen=True) +class Trim: + start: float + end: float + + +@dataclass(frozen=True) +class SubtitleOptions: + languages: tuple[str, ...] + mode: str + + +@dataclass(frozen=True) +class DownloadOptions: + kind: str + container: str + quality_height: int | None + format_id: str | None + audio_format: str | None + audio_quality: str | None + trim: Trim | None + subtitles: SubtitleOptions | None + embed_metadata: bool + + def to_json(self) -> dict[str, object]: + data = asdict(self) + if self.subtitles is not None: + data["subtitles"] = { + "languages": list(self.subtitles.languages), + "mode": self.subtitles.mode, + } + return data + + +def invalid_option(message: str) -> ApiError: + return ApiError(400, "invalid_option", message) + + +def validate_url(value: object) -> str: + if not isinstance(value, str): + raise ApiError( + 400, "invalid_url", "Provide a link that starts with http:// or https://." + ) + url = value.strip() + parts = urlsplit(url) + is_valid = ( + len(url) <= MAX_URL_LENGTH + and parts.scheme in ("http", "https") + and bool(parts.hostname) + and not UNSAFE_URL_CHARACTERS.search(url) + ) + if not is_valid: + raise ApiError( + 400, "invalid_url", "Provide a link that starts with http:// or https://." + ) + return url + + +def _choice( + payload: Mapping[str, object], key: str, choices: tuple[str, ...], default: str +) -> str: + value = payload.get(key) or default + if value not in choices: + raise invalid_option(f"{key} must be one of {', '.join(choices)}.") + return str(value) + + +def _format_id(payload: Mapping[str, object]) -> str | None: + value = payload.get("format_id") + if value in (None, ""): + return None + if not isinstance(value, str) or not FORMAT_ID_PATTERN.fullmatch(value): + raise invalid_option("format_id is not a valid format identifier.") + return value + + +def _quality_height(payload: Mapping[str, object]) -> int | None: + value = payload.get("quality_height") + if value is None: + return None + if ( + not isinstance(value, int) + or isinstance(value, bool) + or not 1 <= value <= MAX_QUALITY_HEIGHT + ): + raise invalid_option( + f"quality_height must be a whole number from 1 to {MAX_QUALITY_HEIGHT}." + ) + return value + + +def _number(value: object, name: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise invalid_option(f"trim.{name} must be a number of seconds.") + return float(value) + + +def _trim(payload: Mapping[str, object]) -> Trim | None: + value = payload.get("trim") + if value is None: + return None + if not isinstance(value, Mapping): + raise invalid_option("trim must be an object with start and end.") + start, end = _number(value.get("start"), "start"), _number(value.get("end"), "end") + if start < 0 or start >= end: + raise invalid_option("trim.start must be at least 0 and before trim.end.") + return Trim(start=start, end=end) + + +def _subtitles(payload: Mapping[str, object]) -> SubtitleOptions | None: + value = payload.get("subtitles") + if value is None: + return None + if not isinstance(value, Mapping) or not isinstance(value.get("languages"), list): + raise invalid_option("subtitles must contain a languages list.") + languages = tuple(value["languages"]) + valid = 0 < len(languages) <= MAX_SUBTITLE_LANGUAGES and all( + isinstance(language, str) and LANGUAGE_PATTERN.fullmatch(language) + for language in languages + ) + if not valid: + raise invalid_option( + "subtitles.languages must hold one to five language codes." + ) + return SubtitleOptions( + languages=languages, mode=_choice(value, "mode", SUBTITLE_MODES, "embed") + ) + + +def _embed_metadata(payload: Mapping[str, object]) -> bool: + value = payload.get("embed_metadata", True) + if not isinstance(value, bool): + raise invalid_option("embed_metadata must be true or false.") + return value + + +def parse_download_options(payload: Mapping[str, object]) -> DownloadOptions: + kind = _choice(payload, "format", KINDS, "video") + is_audio = kind == "audio" + return DownloadOptions( + kind=kind, + container=_choice(payload, "container", CONTAINERS, "mp4"), + quality_height=None if is_audio else _quality_height(payload), + format_id=None if is_audio else _format_id(payload), + audio_format=_choice(payload, "audio_format", AUDIO_FORMATS, "mp3") + if is_audio + else None, + audio_quality=_choice(payload, "audio_quality", AUDIO_QUALITIES, "best") + if is_audio + else None, + trim=_trim(payload), + subtitles=None if is_audio else _subtitles(payload), + embed_metadata=_embed_metadata(payload), + ) diff --git a/apps/api/app/ytdlp.py b/apps/api/app/ytdlp.py new file mode 100644 index 0000000..ea89eb7 --- /dev/null +++ b/apps/api/app/ytdlp.py @@ -0,0 +1,376 @@ +import contextlib +import json +import os +import signal +import subprocess +import sys +import tempfile +import threading +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from .config import Settings +from .errors import ApiError +from .progress import PROGRESS_MARKER +from .validation import DownloadOptions + +PROGRESS_TEMPLATE = ( + f"download:{PROGRESS_MARKER} %(progress.downloaded_bytes)s %(progress.total_bytes)s " + "%(progress.total_bytes_estimate)s %(progress.speed)s %(progress.eta)s" +) +MEDIA_OUTPUT_TEMPLATE = "media.%(ext)s" +INFO_TIMEOUT_SECONDS = 60.0 +PLAYLIST_TIMEOUT_SECONDS = 90.0 +MAX_CONCURRENT_LOOKUPS = 4 +MAX_ERROR_MESSAGE_LENGTH = 300 +CONVERSION_FAILED_MESSAGE = ( + "The file could not be converted to the chosen format. Try MKV instead." +) +ERROR_PATTERNS = ( + ( + "sign in to confirm", + "bot_check", + "The site asked to confirm you are not a bot. Add cookies and try again.", + ), + ("private video", "private_video", "This video is private."), + ( + "available in your country", + "geo_blocked", + "This video is not available in the server's region.", + ), + ("video unavailable", "unavailable", "This video is unavailable."), + ("unsupported url", "unsupported_url", "This link is not supported."), + ( + "larger than max-filesize", + "too_large", + "The file is larger than the configured size limit.", + ), + ( + "no space left on device", + "storage_full", + "The server ran out of disk space. Remove finished downloads and try again.", + ), + ("conversion failed", "conversion_failed", CONVERSION_FAILED_MESSAGE), + ("error opening output file", "conversion_failed", CONVERSION_FAILED_MESSAGE), + ("could not write header", "conversion_failed", CONVERSION_FAILED_MESSAGE), +) + + +@dataclass(frozen=True) +class CompletedRun: + returncode: int + stdout: str + stderr: str + + +Runner = Callable[[Sequence[str], float, Mapping[str, str]], CompletedRun] +CookieCopier = Callable[[Path], Path | None] + + +@dataclass(frozen=True) +class DownloadRequest: + url: str + options: DownloadOptions + job_dir: Path + max_filesize_mb: int + cookies_file: Path | None + proxy: str + + +def run_command( + command: Sequence[str], timeout: float, env: Mapping[str, str] +) -> CompletedRun: + with subprocess.Popen( + list(command), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=dict(env), + start_new_session=True, + ) as process: + try: + stdout, stderr = process.communicate(timeout=timeout) + except subprocess.TimeoutExpired as error: + with contextlib.suppress(ProcessLookupError): + os.killpg(process.pid, signal.SIGKILL) + process.communicate() + raise ApiError( + 504, "timeout", "The site took too long to respond. Try again." + ) from error + return CompletedRun(process.returncode, stdout, stderr) + + +def base_command() -> list[str]: + return [sys.executable, "-m", "yt_dlp"] + + +def ytdlp_environment(settings: Settings) -> dict[str, str]: + environment = dict(os.environ) + if (settings.ytdlp_dir / "yt_dlp").is_dir(): + paths = [str(settings.ytdlp_dir), environment.get("PYTHONPATH", "")] + environment["PYTHONPATH"] = os.pathsep.join(path for path in paths if path) + return environment + + +def _network_arguments(cookies_file: Path | None, proxy: str) -> list[str]: + cookies = ["--cookies", str(cookies_file)] if cookies_file is not None else [] + return [*cookies, *(["--proxy", proxy] if proxy else [])] + + +def _seconds(value: float) -> str: + return f"{value:g}" + + +def _video_arguments(options: DownloadOptions) -> list[str]: + if options.format_id: + selector = ( + f"{options.format_id}+bestaudio[ext=m4a]/{options.format_id}+bestaudio/best" + ) + elif options.quality_height: + height = options.quality_height + selector = f"bv*[height<={height}]+ba/b[height<={height}]/b" + else: + selector = "bv*+ba/b" + sorting = ["-S", "vcodec:h264,acodec:aac"] if options.container == "mp4" else [] + return [ + "-f", + selector, + *sorting, + "--merge-output-format", + options.container, + "--remux-video", + options.container, + ] + + +def _audio_arguments(options: DownloadOptions) -> list[str]: + quality = "320K" if options.audio_quality == "320k" else "0" + return [ + "-f", + "ba/b", + "-x", + "--audio-format", + options.audio_format or "mp3", + "--audio-quality", + quality, + ] + + +def _trim_arguments(options: DownloadOptions) -> list[str]: + if options.trim is None: + return [] + section = f"*{_seconds(options.trim.start)}-{_seconds(options.trim.end)}" + return ["--download-sections", section, "--force-keyframes-at-cuts"] + + +def _subtitle_arguments(options: DownloadOptions) -> list[str]: + if options.subtitles is None: + return [] + delivery = ( + ["--embed-subs"] + if options.subtitles.mode == "embed" + else ["--convert-subs", "srt"] + ) + return [ + "--write-subs", + "--write-auto-subs", + "--sub-langs", + ",".join(options.subtitles.languages), + *delivery, + ] + + +def _metadata_arguments(options: DownloadOptions) -> list[str]: + if not options.embed_metadata: + return [] + thumbnail = [] if options.audio_format == "wav" else ["--embed-thumbnail"] + return ["--embed-metadata", "--embed-chapters", *thumbnail] + + +def build_download_command(request: DownloadRequest) -> list[str]: + options = request.options + selection = ( + _audio_arguments(options) + if options.kind == "audio" + else _video_arguments(options) + ) + return [ + *base_command(), + "--no-playlist", + "--playlist-items", + "1", + "--newline", + "--no-colors", + "--no-warnings", + "--progress", + "--progress-template", + PROGRESS_TEMPLATE, + "--max-filesize", + f"{request.max_filesize_mb}M", + "-P", + str(request.job_dir), + "-o", + MEDIA_OUTPUT_TEMPLATE, + *selection, + *_trim_arguments(options), + *_subtitle_arguments(options), + *_metadata_arguments(options), + *_network_arguments(request.cookies_file, request.proxy), + "--", + request.url, + ] + + +def build_info_command(url: str, cookies_file: Path | None, proxy: str) -> list[str]: + return [ + *base_command(), + "-J", + "--no-playlist", + "--no-warnings", + *_network_arguments(cookies_file, proxy), + "--", + url, + ] + + +def build_playlist_command( + url: str, limit: int, cookies_file: Path | None, proxy: str +) -> list[str]: + return [ + *base_command(), + "-J", + "--flat-playlist", + "--playlist-end", + str(limit), + "--no-warnings", + *_network_arguments(cookies_file, proxy), + "--", + url, + ] + + +def _last_line(output: str) -> str: + lines = [line.strip() for line in output.splitlines() if line.strip()] + return lines[-1] if lines else "yt-dlp failed without output" + + +def known_error(output: str) -> ApiError | None: + lowered = output.lower() + for fragment, code, message in ERROR_PATTERNS: + if fragment in lowered: + return ApiError(400, code, message) + return None + + +def error_from_output(output: str) -> ApiError: + line = _last_line(output) + known = known_error(line) + if known is not None: + return known + detail = line.removeprefix("ERROR:").strip()[:MAX_ERROR_MESSAGE_LENGTH] + return ApiError(400, "extractor_error", detail) + + +def first_json_document(stdout: str) -> dict[str, Any]: + for candidate in (stdout, *stdout.splitlines()): + try: + document = json.loads(candidate) + except json.JSONDecodeError: + continue + if isinstance(document, dict): + return document + raise ApiError(502, "extractor_error", "yt-dlp returned no data.") + + +def _best_formats_by_height( + formats: Sequence[Mapping[str, Any]], +) -> list[dict[str, object]]: + best: dict[int, Mapping[str, Any]] = {} + for entry in formats: + height = entry.get("height") + if not isinstance(height, int) or entry.get("vcodec", "none") == "none": + continue + if height not in best or (entry.get("tbr") or 0) > ( + best[height].get("tbr") or 0 + ): + best[height] = entry + return [ + { + "id": str(entry["format_id"]), + "label": f"{height}p", + "height": height, + "ext": entry.get("ext"), + "filesize": entry.get("filesize") or entry.get("filesize_approx"), + } + for height, entry in sorted(best.items(), reverse=True) + ] + + +def summarize_info(info: Mapping[str, Any]) -> dict[str, object]: + return { + "id": info.get("id"), + "title": info.get("title") or "", + "thumbnail": info.get("thumbnail") or "", + "duration": info.get("duration"), + "uploader": info.get("uploader") or info.get("channel") or "", + "platform": info.get("extractor_key") or "", + "webpage_url": info.get("webpage_url") or "", + "formats": _best_formats_by_height(info.get("formats") or []), + "subtitle_languages": sorted((info.get("subtitles") or {}).keys()), + "has_chapters": bool(info.get("chapters")), + "is_playlist": info.get("_type") == "playlist", + } + + +class YtDlpClient: + def __init__( + self, + settings: Settings, + copy_cookies: CookieCopier, + runner: Runner = run_command, + ) -> None: + self._settings = settings + self._copy_cookies = copy_cookies + self._runner = runner + self._lookup_slots = threading.BoundedSemaphore(MAX_CONCURRENT_LOOKUPS) + + def _run( + self, build: Callable[[Path | None], list[str]], timeout: float + ) -> dict[str, Any]: + with ( + self._lookup_slots, + tempfile.TemporaryDirectory(prefix="openmedia-") as workdir, + ): + command = build(self._copy_cookies(Path(workdir))) + result = self._runner(command, timeout, ytdlp_environment(self._settings)) + if result.returncode != 0: + raise error_from_output(result.stderr) + return first_json_document(result.stdout) + + def fetch_info(self, url: str) -> dict[str, object]: + proxy = self._settings.ytdlp_proxy + document = self._run( + lambda cookies: build_info_command(url, cookies, proxy), + INFO_TIMEOUT_SECONDS, + ) + return summarize_info(document) + + def fetch_playlist(self, url: str, limit: int) -> dict[str, object]: + proxy = self._settings.ytdlp_proxy + document = self._run( + lambda cookies: build_playlist_command(url, limit, cookies, proxy), + PLAYLIST_TIMEOUT_SECONDS, + ) + entries = document.get("entries") or [] + urls = [ + str(entry.get("url") or entry.get("webpage_url")) + for entry in entries + if entry.get("url") or entry.get("webpage_url") + ] + return { + "title": document.get("title") or "", + "count": len(urls[:limit]), + "urls": urls[:limit], + } diff --git a/apps/api/docker-entrypoint.sh b/apps/api/docker-entrypoint.sh new file mode 100755 index 0000000..5356277 --- /dev/null +++ b/apps/api/docker-entrypoint.sh @@ -0,0 +1,34 @@ +#!/bin/sh +set -eu + +data_dir="${OPENMEDIA_DATA_DIR:-/data}" +ytdlp_dir="$data_dir/yt-dlp" +update_timeout_seconds=120 +mkdir -p "$data_dir/downloads" +rm -rf "$data_dir"/.yt-dlp.* + +update_ytdlp() { + staging_dir="$(mktemp -d "$data_dir/.yt-dlp.XXXXXX")" + if timeout "$update_timeout_seconds" uv pip install --quiet --python /app/.venv/bin/python --target "$staging_dir" --upgrade "yt-dlp[default]"; then + rm -rf "$ytdlp_dir" + mv "$staging_dir" "$ytdlp_dir" && return 0 + fi + rm -rf "$staging_dir" + return 1 +} + +auto_update="$(printf '%s' "${OPENMEDIA_AUTO_UPDATE_YTDLP:-true}" | tr '[:upper:]' '[:lower:]')" +case "$auto_update" in + 1 | true | yes | on) + echo "openmedia: updating yt-dlp in $ytdlp_dir" + if ! update_ytdlp; then + echo "openmedia: yt-dlp update failed, using the bundled version" + rm -rf "$ytdlp_dir" + fi + ;; + *) + rm -rf "$ytdlp_dir" + ;; +esac + +exec "$@" diff --git a/apps/api/mise.toml b/apps/api/mise.toml index 286fdf4..b2f0a15 100644 --- a/apps/api/mise.toml +++ b/apps/api/mise.toml @@ -43,3 +43,7 @@ run = [ [tasks.checklist] run = [{ task = ":ci-unit" }, { task = ":build" }] + +[tasks.dev] +env = { OPENMEDIA_DATA_DIR = "./data", OPENMEDIA_TRUSTED_PROXY_HOPS = "1" } +run = "uv run flask --app 'app:create_app()' run --port 8081 --debug" diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index 0a14c4c..c6b7662 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -5,6 +5,7 @@ requires-python = ">=3.13" dependencies = [ "flask>=3.1.3", "gunicorn>=26.2.0", + "yt-dlp[curl-cffi,default,deno]==2026.8.19", ] [dependency-groups] diff --git a/apps/api/tests/__init__.py b/apps/api/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/tests/conftest.py b/apps/api/tests/conftest.py new file mode 100644 index 0000000..bfff27f --- /dev/null +++ b/apps/api/tests/conftest.py @@ -0,0 +1,31 @@ +from dataclasses import replace +from pathlib import Path +from typing import Any + +import pytest + +from app.config import Settings + + +def make_settings(data_dir: Path, **overrides: Any) -> Settings: + base = Settings( + data_dir=data_dir, + password="", + secret_key="test-secret-key", + retention_minutes=60, + max_concurrent=3, + max_filesize_mb=4096, + max_storage_gb=0, + max_playlist_items=50, + rate_limit_per_minute=120, + stall_timeout_seconds=180, + allow_private_urls=False, + trusted_proxy_hops=1, + ytdlp_proxy="", + ) + return replace(base, **overrides) + + +@pytest.fixture +def settings(tmp_path: Path) -> Settings: + return make_settings(tmp_path) diff --git a/apps/api/tests/test_cleanup.py b/apps/api/tests/test_cleanup.py new file mode 100644 index 0000000..c88ec7c --- /dev/null +++ b/apps/api/tests/test_cleanup.py @@ -0,0 +1,34 @@ +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from app.cleanup import RetentionSweeper, remove_orphan_directories +from app.config import Settings +from app.jobs import JobManager, JobRuntime +from app.settings_store import RuntimeSettings, SettingsStore +from app.validation import parse_download_options + +from .test_jobs import ProcessScript, no_cookies + + +def test_orphan_directories_are_removed(tmp_path: Path) -> None: + (tmp_path / "keep").mkdir() + (tmp_path / "orphan").mkdir() + (tmp_path / "orphan" / "media.mp4").write_bytes(b"x") + assert remove_orphan_directories(tmp_path, {"keep"}) == 1 + assert sorted(path.name for path in tmp_path.iterdir()) == ["keep"] + + +def test_sweeper_uses_the_current_retention(settings: Settings) -> None: + store = SettingsStore(settings.settings_file, RuntimeSettings(15, 3)) + manager = JobManager( + JobRuntime( + settings=settings, + store=store, + copy_cookies=no_cookies, + process_factory=ProcessScript([], {"media.mp4": 1}), + ) + ) + manager.submit("https://www.youtube.com/watch?v=a", "x", parse_download_options({})) + assert manager.wait_until_idle(5) + later = datetime.now(UTC) + timedelta(minutes=16) + assert RetentionSweeper(manager, store, now=lambda: later).sweep_once() == 1 diff --git a/apps/api/tests/test_config.py b/apps/api/tests/test_config.py new file mode 100644 index 0000000..b095342 --- /dev/null +++ b/apps/api/tests/test_config.py @@ -0,0 +1,60 @@ +from pathlib import Path + +import pytest + +from app.config import load_settings + + +def test_defaults_point_at_data_volume(monkeypatch: pytest.MonkeyPatch) -> None: + for name in ( + "OPENMEDIA_DATA_DIR", + "OPENMEDIA_MAX_CONCURRENT", + "OPENMEDIA_ALLOW_PRIVATE_URLS", + "OPENMEDIA_RATE_LIMIT_PER_MINUTE", + ): + monkeypatch.delenv(name, raising=False) + settings = load_settings() + assert settings.data_dir == Path("/data") + assert settings.max_concurrent == 3 + assert settings.allow_private_urls is False + assert settings.rate_limit_per_minute == 120 + assert settings.downloads_dir == Path("/data/downloads") + + +def test_environment_overrides(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("OPENMEDIA_DATA_DIR", str(tmp_path)) + monkeypatch.setenv("OPENMEDIA_MAX_CONCURRENT", "5") + monkeypatch.setenv("OPENMEDIA_ALLOW_PRIVATE_URLS", "true") + settings = load_settings() + assert settings.data_dir == tmp_path + assert settings.max_concurrent == 5 + assert settings.allow_private_urls is True + + +def test_out_of_range_value_is_rejected(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPENMEDIA_MAX_CONCURRENT", "9") + with pytest.raises(ValueError, match="OPENMEDIA_MAX_CONCURRENT"): + load_settings() + + +def test_trusted_proxy_hops_must_count_the_web_proxy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("OPENMEDIA_TRUSTED_PROXY_HOPS", "0") + with pytest.raises(ValueError, match="OPENMEDIA_TRUSTED_PROXY_HOPS"): + load_settings() + + +def test_placeholder_password_is_refused(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPENMEDIA_PASSWORD", "changeme") + with pytest.raises(ValueError) as caught: + load_settings() + assert str(caught.value) == ( + "OPENMEDIA_PASSWORD is still the placeholder changeme; set a password, " + "or leave it empty only for a private local instance" + ) + + +def test_empty_password_turns_sign_in_off(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPENMEDIA_PASSWORD", "") + assert load_settings().password == "" diff --git a/apps/api/tests/test_cookies.py b/apps/api/tests/test_cookies.py new file mode 100644 index 0000000..6fd020d --- /dev/null +++ b/apps/api/tests/test_cookies.py @@ -0,0 +1,87 @@ +import stat +from pathlib import Path + +import pytest + +from app.cookies import ( + COOKIE_COPY_NAME, + CookieStore, + parse_cookie_rows, + validate_cookie_file, +) +from app.errors import ApiError + +COOKIES = ( + "# Netscape HTTP Cookie File\n" + ".youtube.com\tTRUE\t/\tTRUE\t1893456000\tSID\tabc\n" + "#HttpOnly_.youtube.com\tTRUE\t/\tTRUE\t1861920000\tHSID\tdef\n" + "accounts.google.com\tFALSE\t/\tTRUE\t0\tLSID\tghi\n" +) + + +def test_rows_include_http_only_entries() -> None: + rows = parse_cookie_rows(COOKIES) + assert [(row.domain, row.expires) for row in rows] == [ + ("youtube.com", 1893456000), + ("youtube.com", 1861920000), + ("accounts.google.com", 0), + ] + + +@pytest.mark.parametrize("raw", [b"hello world", b"\xff\xfe", b"x" * (1024 * 1024 + 1)]) +def test_invalid_files_are_rejected(raw: bytes) -> None: + with pytest.raises(ApiError) as caught: + validate_cookie_file(raw) + assert caught.value.code == "invalid_cookies" + + +def test_non_ascii_expiry_field_is_not_a_valid_row() -> None: + text = ".example.com\tTRUE\t/\tTRUE\t²\tSID\tabc\n" + assert parse_cookie_rows(text) == [] + + +def test_far_future_expiry_does_not_crash_save_or_summarize(tmp_path: Path) -> None: + text = ".example.com\tTRUE\t/\tTRUE\t1000000000000\tSID\tabc\n" + store = CookieStore(tmp_path / "cookies.txt") + summary = store.save(text.encode()) + assert summary.present is True + assert summary.domains == ("example.com",) + assert summary.expires_at is None + + +def test_store_saves_privately_and_summarizes(tmp_path: Path) -> None: + store = CookieStore(tmp_path / "cookies.txt") + assert store.summary().present is False + summary = store.save(COOKIES.encode()) + assert summary.present is True + assert summary.domains == ("accounts.google.com", "youtube.com") + assert summary.expires_at is not None and summary.expires_at.year == 2030 + assert stat.S_IMODE((tmp_path / "cookies.txt").stat().st_mode) == 0o600 + assert summary.to_json()["expires_at"] == "2030-01-01T00:00:00Z" + + +def test_copy_into_and_delete(tmp_path: Path) -> None: + store = CookieStore(tmp_path / "cookies.txt") + assert store.copy_into(tmp_path) is None + store.save(COOKIES.encode()) + job_dir = tmp_path / "job" + job_dir.mkdir() + copied = store.copy_into(job_dir) + assert copied == job_dir / ".cookies.txt" + assert copied.read_text() == COOKIES + assert stat.S_IMODE(copied.stat().st_mode) == 0o600 + store.delete() + assert store.summary().present is False + + +def test_copy_into_refuses_to_follow_a_symlink(tmp_path: Path) -> None: + store = CookieStore(tmp_path / "cookies.txt") + store.save(COOKIES.encode()) + job_dir = tmp_path / "job" + job_dir.mkdir() + outside = tmp_path / "outside.txt" + outside.write_text("do not touch") + (job_dir / COOKIE_COPY_NAME).symlink_to(outside) + with pytest.raises(OSError): + store.copy_into(job_dir) + assert outside.read_text() == "do not touch" diff --git a/apps/api/tests/test_health.py b/apps/api/tests/test_health.py index 54f8e70..51373f3 100644 --- a/apps/api/tests/test_health.py +++ b/apps/api/tests/test_health.py @@ -1,12 +1,36 @@ -from app import create_app +from pathlib import Path +import pytest +from flask import Flask -def test_live_reports_ok() -> None: - response = create_app().test_client().get("/health/live") +from app.health import health, missing_dependencies + + +def make_app(data_dir: Path) -> Flask: + app = Flask(__name__) + app.config["OPENMEDIA_DATA_DIR"] = str(data_dir) + app.register_blueprint(health) + return app + + +def test_live_reports_ok(tmp_path: Path) -> None: + response = make_app(tmp_path).test_client().get("/health/live") assert response.status_code == 200 assert response.get_json() == {"status": "ok"} -def test_ready_route_is_registered() -> None: - rules = create_app().url_map.iter_rules() - assert any(rule.rule == "/health/ready" for rule in rules) +def test_ready_names_missing_dependencies( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("app.health.shutil.which", lambda name: None) + assert missing_dependencies(tmp_path) == ["ffmpeg"] + response = make_app(tmp_path).test_client().get("/health/ready") + assert response.status_code == 503 + assert "ffmpeg" in response.get_json()["reason"] + + +def test_ready_passes_when_everything_is_present( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("app.health.shutil.which", lambda name: "/usr/bin/ffmpeg") + assert make_app(tmp_path).test_client().get("/health/ready").status_code == 200 diff --git a/apps/api/tests/test_jobs.py b/apps/api/tests/test_jobs.py new file mode 100644 index 0000000..8ba847b --- /dev/null +++ b/apps/api/tests/test_jobs.py @@ -0,0 +1,334 @@ +import os +import signal +import threading +import time +from collections.abc import Callable, Iterator, Mapping, Sequence +from dataclasses import replace +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest + +from app.config import Settings +from app.errors import ApiError +from app.jobs import ( + Job, + JobManager, + JobRuntime, + JobStatus, + StallWatchdog, + SubprocessHandle, + utc_now, +) +from app.settings_store import RuntimeSettings, SettingsStore +from app.validation import parse_download_options + +URL = "https://www.youtube.com/watch?v=abc" + + +def wait_until(predicate: Callable[[], bool], timeout: float = 5.0) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(0.005) + return predicate() + + +class ScriptedProcess: + def __init__(self, command: Sequence[str], script: "ProcessScript") -> None: + self.job_dir = Path(command[list(command).index("-P") + 1]) + self.script = script + self.terminated = threading.Event() + + def output_lines(self) -> Iterator[str]: + yield from self.script.lines + while self.script.hold and not ( + self.script.release.is_set() or self.terminated.is_set() + ): + time.sleep(0.01) + if not self.terminated.is_set(): + for name, size in self.script.files.items(): + (self.job_dir / name).write_bytes(b"x" * size) + + def wait(self) -> int: + return -15 if self.terminated.is_set() else self.script.returncode + + def terminate(self) -> None: + self.terminated.set() + + +class ProcessScript: + def __init__( + self, + lines: list[str], + files: dict[str, int], + returncode: int = 0, + hold: bool = False, + ) -> None: + self.lines = lines + self.files = files + self.returncode = returncode + self.hold = hold + self.release = threading.Event() + self.processes: list[ScriptedProcess] = [] + + def __call__( + self, command: Sequence[str], env: Mapping[str, str] + ) -> ScriptedProcess: + process = ScriptedProcess(command, self) + self.processes.append(process) + return process + + +def no_cookies(directory: Path) -> Path | None: + return None + + +def make_manager( + settings: Settings, script: ProcessScript, concurrency: int = 3 +) -> JobManager: + store = SettingsStore(settings.settings_file, RuntimeSettings(60, concurrency)) + return JobManager( + JobRuntime( + settings=settings, + store=store, + copy_cookies=no_cookies, + process_factory=script, + ) + ) + + +def test_successful_download_collects_named_files(settings: Settings) -> None: + script = ProcessScript( + ["OMPROGRESS 50 100 NA 1000 5", '[Merger] Merging formats into "media.mp4"'], + {"media.mp4": 30, "media.vi.srt": 5}, + ) + manager = make_manager(settings, script) + job = manager.submit( + URL, + "Phở: bò/Hà Nội", + parse_download_options({"subtitles": {"languages": ["vi"], "mode": "srt"}}), + ) + assert manager.wait_until_idle(5) + assert job.status is JobStatus.DONE + assert job.progress == 100.0 + assert [(f.name, f.kind, f.size_bytes) for f in job.files] == [ + ("Phở bòHà Nội.mp4", "media", 30), + ("Phở bòHà Nội.vi.srt", "subtitle", 5), + ] + payload = manager.to_json(job) + assert payload["status"] == "done" + assert payload["filename"] == "Phở bòHà Nội.mp4" + assert payload["expires_at"] is not None + + +def test_failure_maps_the_last_error_line(settings: Settings) -> None: + script = ProcessScript( + ["ERROR: [youtube] abc: Sign in to confirm you're not a bot"], {}, returncode=1 + ) + manager = make_manager(settings, script) + job = manager.submit(URL, "x", parse_download_options({})) + assert manager.wait_until_idle(5) + assert (job.status, job.error_code) == (JobStatus.ERROR, "bot_check") + + +def test_missing_output_file_is_an_error(settings: Settings) -> None: + manager = make_manager(settings, ProcessScript([], {})) + job = manager.submit(URL, "x", parse_download_options({})) + assert manager.wait_until_idle(5) + assert job.error_code == "extractor_error" + + +def test_max_filesize_abort_with_a_clean_exit_is_too_large(settings: Settings) -> None: + script = ProcessScript( + [ + "[download] File is larger than max-filesize (5000 bytes > 10 bytes). Aborting.", + "[info] finished", + ], + {}, + ) + manager = make_manager(settings, script) + job = manager.submit(URL, "x", parse_download_options({})) + assert manager.wait_until_idle(5) + assert (job.status, job.error_code) == (JobStatus.ERROR, "too_large") + + +def test_unexpected_error_releases_the_slot_and_marks_the_job_failed( + settings: Settings, +) -> None: + settings.downloads_dir.write_text("not a directory") + manager = make_manager(settings, ProcessScript([], {}), concurrency=1) + job = manager.submit(URL, "x", parse_download_options({})) + assert manager.wait_until_idle(5) + assert job.status is JobStatus.ERROR + assert job.error_code == "extractor_error" + + +def test_concurrency_limit_queues_and_releases(settings: Settings) -> None: + script = ProcessScript([], {"media.mp4": 1}, hold=True) + manager = make_manager(settings, script, concurrency=1) + first = manager.submit(URL, "first", parse_download_options({})) + second = manager.submit(URL, "second", parse_download_options({})) + assert wait_until( + lambda: ( + first.status is JobStatus.DOWNLOADING and second.status is JobStatus.QUEUED + ) + ) + assert manager.to_json(second)["queue_position"] == 1 + script.release.set() + assert manager.wait_until_idle(5) + assert (first.status, second.status) == (JobStatus.DONE, JobStatus.DONE) + + +def test_raising_concurrency_starts_queued_jobs(settings: Settings) -> None: + script = ProcessScript([], {"media.mp4": 1}, hold=True) + store = SettingsStore(settings.settings_file, RuntimeSettings(60, 1)) + manager = JobManager( + JobRuntime( + settings=settings, + store=store, + copy_cookies=no_cookies, + process_factory=script, + ) + ) + manager.submit(URL, "a", parse_download_options({})) + queued = manager.submit(URL, "b", parse_download_options({})) + store.update({"max_concurrent": 2}) + manager.dispatch() + assert queued.status is JobStatus.DOWNLOADING + script.release.set() + assert manager.wait_until_idle(5) + + +def test_cancelling_a_running_job_stops_the_process_and_removes_files( + settings: Settings, +) -> None: + script = ProcessScript(["OMPROGRESS 10 100 NA NA NA"], {"media.mp4": 1}, hold=True) + manager = make_manager(settings, script) + job = manager.submit(URL, "x", parse_download_options({})) + assert wait_until(lambda: len(script.processes) == 1) + manager.cancel_or_remove(job.job_id) + assert manager.wait_until_idle(5) + assert job.status is JobStatus.CANCELLED + assert script.processes[0].terminated.is_set() + assert not (settings.downloads_dir / job.job_id).exists() + + +def test_cancelling_a_queued_job(settings: Settings) -> None: + script = ProcessScript([], {"media.mp4": 1}, hold=True) + manager = make_manager(settings, script, concurrency=1) + manager.submit(URL, "running", parse_download_options({})) + queued = manager.submit(URL, "queued", parse_download_options({})) + manager.cancel_or_remove(queued.job_id) + assert queued.status is JobStatus.CANCELLED + script.release.set() + assert manager.wait_until_idle(5) + assert len(script.processes) == 1 + + +def test_cancelling_before_the_process_starts_skips_it(settings: Settings) -> None: + script = ProcessScript([], {"media.mp4": 1}) + cookie_copy_started = threading.Event() + + def slow_cookies(directory: Path) -> Path | None: + cookie_copy_started.set() + time.sleep(0.1) + return None + + store = SettingsStore(settings.settings_file, RuntimeSettings(60, 3)) + manager = JobManager( + JobRuntime( + settings=settings, + store=store, + copy_cookies=slow_cookies, + process_factory=script, + ) + ) + job = manager.submit(URL, "x", parse_download_options({})) + assert cookie_copy_started.wait(1) + manager.cancel_or_remove(job.job_id) + assert manager.wait_until_idle(5) + assert job.status is JobStatus.CANCELLED + assert script.processes == [] + assert not (settings.downloads_dir / job.job_id).exists() + + +def test_removing_a_finished_job_deletes_it(settings: Settings) -> None: + manager = make_manager(settings, ProcessScript([], {"media.mp4": 1})) + job = manager.submit(URL, "x", parse_download_options({})) + assert manager.wait_until_idle(5) + manager.cancel_or_remove(job.job_id) + with pytest.raises(ApiError): + manager.get(job.job_id) + assert not (settings.downloads_dir / job.job_id).exists() + + +def test_stalled_download_times_out(settings: Settings) -> None: + script = ProcessScript([], {}, hold=True) + manager = make_manager(replace(settings, stall_timeout_seconds=0.3), script) + job = manager.submit(URL, "x", parse_download_options({})) + assert manager.wait_until_idle(5) + assert (job.status, job.error_code) == (JobStatus.ERROR, "timeout") + + +class SilentHandle: + def __init__(self) -> None: + self.terminated = threading.Event() + + def output_lines(self) -> Iterator[str]: + return iter(()) + + def wait(self) -> int: + return 0 + + def terminate(self) -> None: + self.terminated.set() + + +def test_watchdog_gives_processing_jobs_more_time_before_stalling() -> None: + job = Job( + job_id="watchdog-test", + url=URL, + title="x", + options=parse_download_options({}), + created_at=utc_now(), + ) + handle = SilentHandle() + watchdog = StallWatchdog(handle, timeout_seconds=0.1, job=job) + watchdog.start() + job.status = JobStatus.PROCESSING + time.sleep(0.25) + assert not handle.terminated.is_set() + assert wait_until(lambda: handle.terminated.is_set(), timeout=2.0) + watchdog.stop() + + +def test_terminate_kills_a_process_that_ignores_sigterm( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("app.jobs.KILL_GRACE_SECONDS", 0.2) + script = 'trap "" TERM; echo ready; exec sleep 3' + handle = SubprocessHandle(["sh", "-c", script], dict(os.environ)) + assert next(handle.output_lines()).strip() == "ready" + handle.terminate() + exit_codes: list[int] = [] + waiter = threading.Thread(target=lambda: exit_codes.append(handle.wait())) + waiter.start() + waiter.join(2) + assert exit_codes == [-signal.SIGKILL] + + +def test_remove_finished_before_cutoff(settings: Settings) -> None: + manager = make_manager(settings, ProcessScript([], {"media.mp4": 1})) + manager.submit(URL, "x", parse_download_options({})) + assert manager.wait_until_idle(5) + assert manager.remove_finished_before(datetime.now(UTC) - timedelta(minutes=5)) == 0 + assert manager.remove_finished_before(datetime.now(UTC) + timedelta(seconds=1)) == 1 + assert manager.known_job_ids() == set() + + +def test_unknown_job_is_not_found(settings: Settings) -> None: + with pytest.raises(ApiError) as caught: + make_manager(settings, ProcessScript([], {})).get("missing") + assert caught.value.code == "not_found" diff --git a/apps/api/tests/test_network_guard.py b/apps/api/tests/test_network_guard.py new file mode 100644 index 0000000..01712c4 --- /dev/null +++ b/apps/api/tests/test_network_guard.py @@ -0,0 +1,52 @@ +from collections.abc import Callable + +import pytest + +from app.errors import ApiError +from app.network_guard import ensure_public_url + + +def resolver_for(*addresses: str) -> Callable[[str], list[str]]: + return lambda host: list(addresses) + + +@pytest.mark.parametrize( + "address", + [ + "127.0.0.1", + "10.1.2.3", + "192.168.1.20", + "169.254.169.254", + "::1", + "fd00::1", + "::ffff:10.0.0.1", + "0.0.0.0", + ], +) +def test_private_addresses_are_blocked(address: str) -> None: + with pytest.raises(ApiError) as caught: + ensure_public_url("https://internal.example/x", resolver_for(address)) + assert caught.value.code == "private_network" + + +def test_any_private_address_blocks_the_host() -> None: + with pytest.raises(ApiError): + ensure_public_url( + "https://mixed.example", resolver_for("142.250.1.1", "10.0.0.5") + ) + + +def test_public_addresses_pass() -> None: + ensure_public_url( + "https://www.youtube.com/watch?v=a", + resolver_for("142.250.190.14", "2607:f8b0:4005:80b::200e"), + ) + + +def test_unresolvable_host_is_invalid() -> None: + def failing(host: str) -> list[str]: + raise OSError("no such host") + + with pytest.raises(ApiError) as caught: + ensure_public_url("https://nope.invalid", failing) + assert caught.value.code == "invalid_url" diff --git a/apps/api/tests/test_progress.py b/apps/api/tests/test_progress.py new file mode 100644 index 0000000..ad9c3fa --- /dev/null +++ b/apps/api/tests/test_progress.py @@ -0,0 +1,48 @@ +from app.progress import ( + ProgressSample, + ProgressTracker, + is_postprocessing_line, + parse_progress_line, +) + + +def test_parses_a_full_progress_line() -> None: + sample = parse_progress_line("OMPROGRESS 1048576 4194304 NA 524288.5 6") + assert sample == ProgressSample( + downloaded_bytes=1048576, total_bytes=4194304, speed_bps=524288.5, eta_seconds=6 + ) + + +def test_falls_back_to_the_size_estimate() -> None: + sample = parse_progress_line("OMPROGRESS 100 NA 400.0 NA NA") + assert sample is not None + assert (sample.total_bytes, sample.speed_bps, sample.eta_seconds) == ( + 400, + None, + None, + ) + + +def test_ignores_other_output() -> None: + assert parse_progress_line("[youtube] abc: Downloading webpage") is None + assert parse_progress_line("OMPROGRESS 1 2") is None + + +def test_detects_postprocessing_lines() -> None: + assert is_postprocessing_line('[Merger] Merging formats into "media.mp4"') + assert is_postprocessing_line("[ExtractAudio] Destination: media.mp3") + assert not is_postprocessing_line("[download] Destination: media.f137.mp4") + + +def test_two_streams_map_into_one_rising_percentage() -> None: + tracker = ProgressTracker() + assert tracker.record(ProgressSample(50, 100, None, None)) == 45.0 + assert tracker.record(ProgressSample(100, 100, None, None)) == 90.0 + assert tracker.record(ProgressSample(10, 20, None, None)) == 94.5 + assert tracker.record_processing() == 99.0 + assert tracker.record(ProgressSample(20, 20, None, None)) == 99.0 + + +def test_unknown_total_keeps_the_percentage() -> None: + tracker = ProgressTracker() + assert tracker.record(ProgressSample(500, None, 10.0, None)) == 0.0 diff --git a/apps/api/tests/test_routes.py b/apps/api/tests/test_routes.py new file mode 100644 index 0000000..1f06a88 --- /dev/null +++ b/apps/api/tests/test_routes.py @@ -0,0 +1,287 @@ +import io +import json +from collections.abc import Iterator +from dataclasses import replace + +import pytest +from flask import Flask +from flask.testing import FlaskClient + +from app import create_app +from app.config import Settings +from app.services import ( + LOGIN_ATTEMPTS_PER_MINUTE_ALL_CLIENTS, + Services, + build_services, +) +from app.storage import StorageUsage +from app.ytdlp import CompletedRun + +from .test_cookies import COOKIES +from .test_jobs import ProcessScript +from .test_ytdlp import RecordingRunner + +URL = "https://www.youtube.com/watch?v=abc" +INFO = { + "id": "abc", + "title": "Pho", + "duration": 1122, + "formats": [{"format_id": "137", "height": 1080, "vcodec": "avc1", "tbr": 1}], +} + + +def build( + settings: Settings, + script: ProcessScript | None = None, + output: dict[str, object] | None = None, +) -> tuple[Flask, Services]: + runner = RecordingRunner(CompletedRun(0, json.dumps(output or INFO), "")) + services = build_services( + settings, + process_factory=script or ProcessScript([], {"media.mp4": 12}), + runner=runner, + ) + return create_app(settings, services), services + + +@pytest.fixture +def open_settings(settings: Settings) -> Settings: + return replace(settings, allow_private_urls=True) + + +@pytest.fixture +def client(open_settings: Settings) -> Iterator[FlaskClient]: + app, _ = build(open_settings) + yield app.test_client() + + +def test_session_without_password(client: FlaskClient) -> None: + body = client.get("/api/session").get_json() + assert body == { + "auth_required": False, + "authenticated": True, + "limits": {"max_filesize_mb": 4096, "max_playlist_items": 50}, + } + + +def test_password_protects_the_api(open_settings: Settings) -> None: + app, _ = build(replace(open_settings, password="hunter2")) + client = app.test_client() + assert client.get("/api/jobs").get_json()["code"] == "auth_required" + assert client.post("/api/session", json={"password": "nope"}).status_code == 401 + assert client.post("/api/session", json={"password": "hunter2"}).status_code == 204 + assert client.get("/api/jobs").status_code == 200 + assert client.delete("/api/session").status_code == 204 + assert client.get("/api/jobs").status_code == 401 + + +def test_cross_site_post_is_rejected(client: FlaskClient) -> None: + response = client.post( + "/api/info", json={"url": URL}, headers={"Sec-Fetch-Site": "cross-site"} + ) + assert (response.status_code, response.get_json()["code"]) == ( + 403, + "cross_site_request", + ) + + +def test_info_returns_the_summary(client: FlaskClient) -> None: + body = client.post("/api/info", json={"url": URL}).get_json() + assert body["title"] == "Pho" + assert body["formats"][0] == { + "id": "137", + "label": "1080p", + "height": 1080, + "ext": None, + "filesize": None, + } + + +def test_reclip_injection_payload_is_rejected(client: FlaskClient) -> None: + response = client.post("/api/info", json={"url": "--exec=touch /tmp/pwned"}) + assert (response.status_code, response.get_json()["code"]) == (400, "invalid_url") + assert "error" in response.get_json() + + +def test_private_network_is_blocked_by_default(settings: Settings) -> None: + app, _ = build(settings) + response = app.test_client().post( + "/api/info", json={"url": "http://127.0.0.1:8080/admin"} + ) + assert response.get_json()["code"] == "private_network" + + +def test_playlist_is_limited(open_settings: Settings) -> None: + document: dict[str, object] = { + "title": "Mix", + "entries": [{"url": f"{URL}{n}"} for n in range(80)], + } + app, _ = build(open_settings, output=document) + body = app.test_client().post("/api/playlist", json={"url": URL}).get_json() + assert body["count"] == 50 + + +def test_reclip_download_flow(open_settings: Settings) -> None: + app, services = build(open_settings) + client = app.test_client() + response = client.post( + "/api/download", + json={"url": URL, "format": "video", "format_id": "137", "title": "Pho bo"}, + ) + assert response.status_code == 202 + job_id = response.get_json()["job_id"] + assert services.jobs.wait_until_idle(5) + status = client.get(f"/api/status/{job_id}").get_json() + assert (status["status"], status["error"], status["filename"]) == ( + "done", + None, + "Pho bo.mp4", + ) + file_response = client.get(f"/api/file/{job_id}") + assert file_response.status_code == 200 + assert file_response.data == b"x" * 12 + assert ( + "Pho%20bo.mp4" in file_response.headers["Content-Disposition"] + or "Pho bo.mp4" in file_response.headers["Content-Disposition"] + ) + assert client.get(f"/api/file/{job_id}/5").get_json()["code"] == "not_found" + + +def test_control_characters_never_reach_the_download_header( + open_settings: Settings, +) -> None: + app, services = build(open_settings) + client = app.test_client() + job_id = client.post( + "/api/download", json={"url": URL, "title": "Pho\r\nbo\x00\x1f\x7f"} + ).get_json()["job_id"] + assert services.jobs.wait_until_idle(5) + response = client.get(f"/api/file/{job_id}") + assert response.status_code == 200 + assert "Phobo.mp4" in response.headers["Content-Disposition"] + + +def test_file_not_ready_while_downloading(open_settings: Settings) -> None: + script = ProcessScript([], {"media.mp4": 1}, hold=True) + app, services = build(open_settings, script=script) + client = app.test_client() + job_id = client.post("/api/download", json={"url": URL}).get_json()["job_id"] + assert client.get(f"/api/file/{job_id}").get_json()["code"] == "file_not_ready" + assert client.delete(f"/api/jobs/{job_id}").status_code == 204 + script.release.set() + assert services.jobs.wait_until_idle(5) + assert client.get(f"/api/status/{job_id}").get_json()["status"] == "cancelled" + + +def test_jobs_list_and_remove(client: FlaskClient) -> None: + job_id = client.post( + "/api/download", json={"url": URL, "format": "audio"} + ).get_json()["job_id"] + jobs = client.get("/api/jobs").get_json()["jobs"] + assert [job["job_id"] for job in jobs] == [job_id] + + +def test_storage_full_refuses_downloads( + open_settings: Settings, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr( + "app.media.storage_usage", lambda directory, limit: StorageUsage(10, 10, 0) + ) + app, _ = build(open_settings) + response = app.test_client().post("/api/download", json={"url": URL}) + assert (response.status_code, response.get_json()["code"]) == (507, "storage_full") + + +def test_settings_round_trip(client: FlaskClient) -> None: + assert client.get("/api/settings").get_json() == { + "retention_minutes": 60, + "max_concurrent": 3, + } + assert ( + client.put("/api/settings", json={"max_concurrent": 5}).get_json()[ + "max_concurrent" + ] + == 5 + ) + assert client.put("/api/settings", json={"retention_minutes": 7}).status_code == 400 + + +def test_storage_reports_usage(client: FlaskClient) -> None: + body = client.get("/api/storage").get_json() + assert set(body) == {"used_bytes", "limit_bytes", "free_bytes"} + + +def test_cookie_upload_and_removal(client: FlaskClient) -> None: + upload = client.put( + "/api/cookies", + data={"file": (io.BytesIO(COOKIES.encode()), "cookies.txt")}, + content_type="multipart/form-data", + ) + assert upload.status_code == 200 + assert upload.get_json()["domains"] == ["accounts.google.com", "youtube.com"] + bad = client.put( + "/api/cookies", + data={"file": (io.BytesIO(b"nope"), "cookies.txt")}, + content_type="multipart/form-data", + ) + assert bad.get_json()["code"] == "invalid_cookies" + assert ( + client.put( + "/api/cookies", data={}, content_type="multipart/form-data" + ).get_json()["code"] + == "invalid_cookies" + ) + assert client.delete("/api/cookies").status_code == 204 + assert client.get("/api/cookies").get_json()["present"] is False + + +def test_rate_limit(open_settings: Settings) -> None: + app, _ = build(replace(open_settings, rate_limit_per_minute=2)) + client = app.test_client() + client.post("/api/info", json={"url": URL}) + client.post("/api/info", json={"url": URL}) + limited = client.post("/api/info", json={"url": URL}) + assert limited.status_code == 429 + assert "Retry-After" in limited.headers + + +def test_unknown_route_is_json(client: FlaskClient) -> None: + response = client.get("/api/nope") + assert response.status_code == 404 + assert response.get_json()["code"] == "not_found" + + +def test_sign_in_attempts_are_limited_across_rotating_client_addresses( + open_settings: Settings, +) -> None: + app, _ = build(replace(open_settings, password="hunter2")) + client = app.test_client() + statuses = [ + client.post( + "/api/session", + json={"password": "nope"}, + headers={"X-Forwarded-For": f"203.0.113.{attempt}"}, + ).status_code + for attempt in range(LOGIN_ATTEMPTS_PER_MINUTE_ALL_CLIENTS + 1) + ] + assert set(statuses[:-1]) == {401} + assert statuses[-1] == 429 + + +def test_writes_through_the_web_proxy_succeed_with_extra_trusted_hops( + open_settings: Settings, +) -> None: + app, _ = build(replace(open_settings, trusted_proxy_hops=2)) + response = app.test_client().put( + "/api/settings", + json={"max_concurrent": 2}, + base_url="http://api:8080", + headers={ + "Origin": "https://openmedia.example.com", + "Sec-Fetch-Site": "same-origin", + "X-Forwarded-Host": "openmedia.example.com", + "X-Forwarded-Proto": "https", + "X-Forwarded-For": "198.51.100.7, 203.0.113.9", + }, + ) + assert response.status_code == 200 diff --git a/apps/api/tests/test_security.py b/apps/api/tests/test_security.py new file mode 100644 index 0000000..d5a272e --- /dev/null +++ b/apps/api/tests/test_security.py @@ -0,0 +1,130 @@ +import stat +from dataclasses import replace + +import pytest +from flask import Flask + +from app.config import Settings +from app.errors import ApiError +from app.security import ( + MAX_TRACKED_CLIENTS, + RateLimiter, + ensure_authenticated, + ensure_same_origin_request, + is_authenticated, + load_or_create_secret_key, + password_matches, + sign_in, +) + + +class FakeClock: + def __init__(self) -> None: + self.now = 0.0 + + def __call__(self) -> float: + return self.now + + +def test_rate_limiter_refills_over_time() -> None: + clock = FakeClock() + limiter = RateLimiter(2, clock) + assert limiter.retry_after("a") is None + assert limiter.retry_after("a") is None + wait = limiter.retry_after("a") + assert wait is not None and 29 <= wait <= 30 + assert limiter.retry_after("b") is None + clock.now = 30.0 + assert limiter.retry_after("a") is None + + +def test_enforce_sets_retry_after_header() -> None: + limiter = RateLimiter(1, FakeClock()) + limiter.enforce("a") + with pytest.raises(ApiError) as caught: + limiter.enforce("a") + assert caught.value.status == 429 + assert caught.value.headers["Retry-After"] == "60" + + +@pytest.mark.parametrize( + ("headers", "allowed"), + [ + ({}, True), + ({"Sec-Fetch-Site": "same-origin"}, True), + ({"Sec-Fetch-Site": "cross-site"}, False), + ({"Sec-Fetch-Site": "same-site"}, False), + ({"Origin": "http://localhost"}, True), + ({"Origin": "https://evil.example"}, False), + ], +) +def test_cross_site_guard(headers: dict[str, str], allowed: bool) -> None: + app = Flask(__name__) + with app.test_request_context( + "/api/download", method="POST", headers=headers, base_url="http://localhost" + ): + if allowed: + ensure_same_origin_request() + else: + with pytest.raises(ApiError) as caught: + ensure_same_origin_request() + assert caught.value.code == "cross_site_request" + + +def test_safe_methods_skip_the_guard() -> None: + app = Flask(__name__) + with app.test_request_context( + "/api/jobs", method="GET", headers={"Sec-Fetch-Site": "cross-site"} + ): + ensure_same_origin_request() + + +def test_password_session(settings: Settings) -> None: + protected = replace(settings, password="correct horse") + app = Flask(__name__) + app.secret_key = "test" + with app.test_request_context("/api/jobs"): + assert is_authenticated(settings) is True + assert is_authenticated(protected) is False + with pytest.raises(ApiError) as caught: + ensure_authenticated(protected) + assert caught.value.code == "auth_required" + assert password_matches(protected, "wrong") is False + assert password_matches(protected, 42) is False + assert password_matches(protected, "correct horse") is True + sign_in(protected) + assert is_authenticated(protected) is True + + +def test_sign_in_is_revoked_when_password_changes(settings: Settings) -> None: + protected = replace(settings, password="correct horse") + app = Flask(__name__) + app.secret_key = "test" + with app.test_request_context("/api/jobs"): + sign_in(protected) + assert is_authenticated(protected) is True + changed = replace(protected, password="different password") + assert is_authenticated(changed) is False + + +def test_secret_key_is_generated_once(settings: Settings) -> None: + generated = replace(settings, secret_key="") + first = load_or_create_secret_key(generated) + assert len(first) == 64 + assert load_or_create_secret_key(generated) == first + assert load_or_create_secret_key(settings) == "test-secret-key" + assert stat.S_IMODE(generated.secret_key_file.stat().st_mode) == 0o600 + + +def test_rate_limiter_evicts_refilled_buckets_beyond_the_tracking_limit() -> None: + clock = FakeClock() + limiter = RateLimiter(1, clock) + limiter.retry_after("exhausted") + clock.now = 30.0 + for client in range(MAX_TRACKED_CLIENTS): + limiter.retry_after(f"client-{client}") + clock.now = 90.0 + limiter.retry_after("newcomer") + assert len(limiter._buckets) == 1 + clock.now = 90.5 + assert limiter.retry_after("newcomer") is not None diff --git a/apps/api/tests/test_settings_store.py b/apps/api/tests/test_settings_store.py new file mode 100644 index 0000000..f4b316c --- /dev/null +++ b/apps/api/tests/test_settings_store.py @@ -0,0 +1,54 @@ +import json +from pathlib import Path + +import pytest + +from app.errors import ApiError +from app.settings_store import RuntimeSettings, SettingsStore + +DEFAULTS = RuntimeSettings(retention_minutes=60, max_concurrent=3) + + +def test_missing_file_uses_defaults(tmp_path: Path) -> None: + assert SettingsStore(tmp_path / "settings.json", DEFAULTS).current() == DEFAULTS + + +def test_update_persists_and_reloads(tmp_path: Path) -> None: + path = tmp_path / "settings.json" + SettingsStore(path, DEFAULTS).update( + {"retention_minutes": 360, "max_concurrent": 5} + ) + assert json.loads(path.read_text()) == { + "retention_minutes": 360, + "max_concurrent": 5, + } + assert SettingsStore(path, DEFAULTS).current() == RuntimeSettings(360, 5) + + +def test_partial_update_keeps_other_values(tmp_path: Path) -> None: + store = SettingsStore(tmp_path / "settings.json", DEFAULTS) + assert store.update({"max_concurrent": 1}) == RuntimeSettings(60, 1) + + +@pytest.mark.parametrize( + "payload", + [ + {"retention_minutes": 30}, + {"max_concurrent": 0}, + {"max_concurrent": 6}, + {"max_concurrent": True}, + {"retention_minutes": "60"}, + ], +) +def test_invalid_updates_are_rejected( + tmp_path: Path, payload: dict[str, object] +) -> None: + with pytest.raises(ApiError) as caught: + SettingsStore(tmp_path / "settings.json", DEFAULTS).update(payload) + assert caught.value.code == "invalid_option" + + +def test_corrupt_file_falls_back_to_defaults(tmp_path: Path) -> None: + path = tmp_path / "settings.json" + path.write_text("{not json") + assert SettingsStore(path, DEFAULTS).current() == DEFAULTS diff --git a/apps/api/tests/test_storage.py b/apps/api/tests/test_storage.py new file mode 100644 index 0000000..fe5584e --- /dev/null +++ b/apps/api/tests/test_storage.py @@ -0,0 +1,50 @@ +import os +from pathlib import Path + +import pytest + +from app.errors import ApiError +from app.storage import StorageUsage, ensure_capacity, storage_usage + + +def test_usage_counts_files_recursively(tmp_path: Path) -> None: + (tmp_path / "job1").mkdir() + (tmp_path / "job1" / "media.mp4").write_bytes(b"x" * 1500) + (tmp_path / "loose.bin").write_bytes(b"x" * 500) + usage = storage_usage(tmp_path, 0) + assert usage.used_bytes == 2000 + assert usage.limit_bytes is None + assert usage.free_bytes > 0 + + +def test_limit_is_reported_in_bytes(tmp_path: Path) -> None: + assert storage_usage(tmp_path, 2).limit_bytes == 2 * 1024**3 + + +def test_missing_directory_counts_as_empty(tmp_path: Path) -> None: + assert storage_usage(tmp_path / "absent", 0).used_bytes == 0 + + +def test_vanished_file_is_skipped( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + (tmp_path / "steady.bin").write_bytes(b"x" * 100) + vanished = tmp_path / "downloading.part" + vanished.write_bytes(b"x" * 50) + original_stat = Path.stat + + def flaky_stat(self: Path, *, follow_symlinks: bool = True) -> os.stat_result: + if self == vanished: + raise FileNotFoundError(self) + return original_stat(self, follow_symlinks=follow_symlinks) + + monkeypatch.setattr(Path, "stat", flaky_stat) + assert storage_usage(tmp_path, 0).used_bytes == 100 + + +def test_full_storage_is_refused() -> None: + with pytest.raises(ApiError) as caught: + ensure_capacity(StorageUsage(used_bytes=10, limit_bytes=10, free_bytes=100)) + assert (caught.value.status, caught.value.code) == (507, "storage_full") + ensure_capacity(StorageUsage(used_bytes=9, limit_bytes=10, free_bytes=100)) + ensure_capacity(StorageUsage(used_bytes=10**12, limit_bytes=None, free_bytes=100)) diff --git a/apps/api/tests/test_validation.py b/apps/api/tests/test_validation.py new file mode 100644 index 0000000..c17c972 --- /dev/null +++ b/apps/api/tests/test_validation.py @@ -0,0 +1,117 @@ +import pytest + +from app.errors import ApiError +from app.validation import DownloadOptions, Trim, parse_download_options, validate_url + + +@pytest.mark.parametrize( + "value", + [ + "--exec=touch /tmp/pwned", + "file:///etc/passwd", + "ftp://example.com/a", + "https://", + "https://exa mple.com", + 42, + None, + "https://example.com/" + "a" * 2100, + ], +) +def test_rejects_unsafe_or_malformed_urls(value: object) -> None: + with pytest.raises(ApiError) as caught: + validate_url(value) + assert caught.value.code == "invalid_url" + + +def test_accepts_and_trims_http_urls() -> None: + assert ( + validate_url(" https://www.youtube.com/watch?v=abc ") + == "https://www.youtube.com/watch?v=abc" + ) + + +def test_reclip_request_maps_to_video_defaults() -> None: + options = parse_download_options( + {"url": "https://x.com/a", "format": "video", "format_id": "137"} + ) + assert options == DownloadOptions( + kind="video", + container="mp4", + quality_height=None, + format_id="137", + audio_format=None, + audio_quality=None, + trim=None, + subtitles=None, + embed_metadata=True, + ) + + +def test_reclip_audio_request_defaults_to_mp3() -> None: + options = parse_download_options({"format": "audio"}) + assert (options.kind, options.audio_format, options.audio_quality) == ( + "audio", + "mp3", + "best", + ) + + +def test_full_request_is_parsed() -> None: + options = parse_download_options( + { + "format": "video", + "container": "mkv", + "quality_height": 720, + "trim": {"start": 5, "end": 65.5}, + "subtitles": {"languages": ["vi", "en-US"], "mode": "srt"}, + "embed_metadata": False, + } + ) + assert options.container == "mkv" + assert options.quality_height == 720 + assert options.trim == Trim(start=5.0, end=65.5) + assert options.subtitles is not None and options.subtitles.languages == ( + "vi", + "en-US", + ) + assert options.embed_metadata is False + + +@pytest.mark.parametrize( + "payload", + [ + {"format": "gif"}, + {"format_id": "137; rm -rf /"}, + {"container": "avi"}, + {"quality_height": 0}, + {"quality_height": 8641}, + {"quality_height": True}, + {"format": "audio", "audio_format": "aac"}, + {"trim": {"start": 10, "end": 5}}, + {"trim": {"start": -1, "end": 5}}, + { + "subtitles": { + "languages": ["vi", "en", "fr", "de", "ja", "ko"], + "mode": "embed", + } + }, + {"subtitles": {"languages": ["../x"], "mode": "embed"}}, + {"subtitles": {"languages": ["vi"], "mode": "burn"}}, + {"embed_metadata": "yes"}, + ], +) +def test_invalid_options_are_rejected(payload: dict[str, object]) -> None: + with pytest.raises(ApiError) as caught: + parse_download_options(payload) + assert caught.value.code == "invalid_option" + + +@pytest.mark.parametrize("height", [2250, 1920, 1350, 544]) +def test_accepts_source_heights_outside_the_common_ladder(height: int) -> None: + assert parse_download_options({"quality_height": height}).quality_height == height + + +def test_options_serialize_for_the_job_payload() -> None: + options = parse_download_options({"format": "audio", "audio_format": "flac"}) + assert options.to_json()["audio_format"] == "flac" + assert options.to_json()["trim"] is None diff --git a/apps/api/tests/test_ytdlp.py b/apps/api/tests/test_ytdlp.py new file mode 100644 index 0000000..c253444 --- /dev/null +++ b/apps/api/tests/test_ytdlp.py @@ -0,0 +1,326 @@ +import json +import os +import threading +import time +from collections.abc import Mapping, Sequence +from pathlib import Path + +import pytest + +from app.config import Settings +from app.errors import ApiError +from app.validation import ( + DownloadOptions, + SubtitleOptions, + Trim, + parse_download_options, +) +from app.ytdlp import ( + MAX_CONCURRENT_LOOKUPS, + CompletedRun, + DownloadRequest, + YtDlpClient, + build_download_command, + build_info_command, + error_from_output, + run_command, + summarize_info, +) + +from .test_jobs import wait_until + +URL = "https://www.youtube.com/watch?v=abc" + + +def request_for( + options: DownloadOptions, cookies: Path | None = None +) -> DownloadRequest: + return DownloadRequest( + URL, options, Path("/data/downloads/job1"), 4096, cookies, "" + ) + + +def value_after(command: list[str], flag: str) -> str: + return command[command.index(flag) + 1] + + +def test_url_is_always_the_final_argument_after_the_separator() -> None: + command = build_download_command(request_for(parse_download_options({}))) + assert command[-2:] == ["--", URL] + assert value_after(command, "-P") == "/data/downloads/job1" + assert value_after(command, "-o") == "media.%(ext)s" + assert value_after(command, "--max-filesize") == "4096M" + assert "--newline" in command and "--no-playlist" in command + assert value_after(command, "--playlist-items") == "1" + + +def test_mp4_prefers_compatible_codecs_for_a_chosen_format() -> None: + command = build_download_command( + request_for(parse_download_options({"format_id": "137"})) + ) + assert value_after(command, "-f") == "137+bestaudio[ext=m4a]/137+bestaudio/best" + assert value_after(command, "-S") == "vcodec:h264,acodec:aac" + assert value_after(command, "--merge-output-format") == "mp4" + + +def test_mkv_with_height_cap_skips_codec_sorting() -> None: + command = build_download_command( + request_for(parse_download_options({"container": "mkv", "quality_height": 720})) + ) + assert value_after(command, "-f") == "bv*[height<=720]+ba/b[height<=720]/b" + assert "-S" not in command + assert value_after(command, "--merge-output-format") == "mkv" + + +@pytest.mark.parametrize("container", ["mp4", "mkv"]) +def test_single_file_video_is_remuxed_into_the_chosen_container(container: str) -> None: + command = build_download_command( + request_for(parse_download_options({"container": container})) + ) + assert value_after(command, "--remux-video") == container + + +def test_audio_extraction_arguments() -> None: + command = build_download_command( + request_for( + parse_download_options( + {"format": "audio", "audio_format": "m4a", "audio_quality": "320k"} + ) + ) + ) + assert value_after(command, "-f") == "ba/b" + assert "-x" in command + assert value_after(command, "--audio-format") == "m4a" + assert value_after(command, "--audio-quality") == "320K" + assert "--remux-video" not in command + + +def test_trim_subtitles_metadata_and_cookies() -> None: + options = DownloadOptions( + "video", + "mp4", + None, + None, + None, + None, + Trim(5, 65.5), + SubtitleOptions(("vi", "en"), "srt"), + True, + ) + command = build_download_command( + request_for(options, Path("/tmp/job/.cookies.txt")) + ) + assert value_after(command, "--download-sections") == "*5-65.5" + assert "--force-keyframes-at-cuts" in command + assert value_after(command, "--sub-langs") == "vi,en" + assert value_after(command, "--convert-subs") == "srt" + assert "--embed-subs" not in command + assert {"--embed-metadata", "--embed-chapters", "--embed-thumbnail"} <= set(command) + assert value_after(command, "--cookies") == "/tmp/job/.cookies.txt" + + +def test_wav_skips_thumbnail_embedding() -> None: + command = build_download_command( + request_for(parse_download_options({"format": "audio", "audio_format": "wav"})) + ) + assert "--embed-thumbnail" not in command + assert "--embed-metadata" in command + + +def test_info_command_ends_with_separator_and_url() -> None: + assert build_info_command(URL, None, "socks5://proxy:1080")[-4:] == [ + "--proxy", + "socks5://proxy:1080", + "--", + URL, + ] + + +@pytest.mark.parametrize( + ("line", "code"), + [ + ("ERROR: [youtube] abc: Sign in to confirm you're not a bot", "bot_check"), + ("ERROR: [youtube] abc: Private video. Sign in", "private_video"), + ( + "ERROR: The uploader has not made this video available in your country", + "geo_blocked", + ), + ("ERROR: [youtube] abc: Video unavailable", "unavailable"), + ("ERROR: Unsupported URL: https://example.com", "unsupported_url"), + ( + "ERROR: File is larger than max-filesize (5000 bytes > 10 bytes). Aborting.", + "too_large", + ), + ( + "ERROR: Postprocessing: Error opening output files: Invalid argument", + "conversion_failed", + ), + ("ERROR: Postprocessing: Conversion failed!", "conversion_failed"), + ( + "ERROR: unable to write data: [Errno 28] No space left on device", + "storage_full", + ), + ("ERROR: something else broke", "extractor_error"), + ], +) +def test_error_mapping(line: str, code: str) -> None: + error = error_from_output(f"[info] noise\n{line}\n") + assert error.code == code + + +def test_summarize_keeps_best_format_per_height() -> None: + info = { + "id": "abc", + "title": "Pho", + "thumbnail": "https://i.ytimg.com/a.jpg", + "duration": 1122, + "uploader": "Bep", + "extractor_key": "Youtube", + "webpage_url": URL, + "chapters": [{"title": "Intro"}], + "subtitles": {"vi": [], "en": []}, + "formats": [ + { + "format_id": "136", + "height": 720, + "vcodec": "avc1", + "tbr": 900, + "ext": "mp4", + "filesize": 236, + }, + { + "format_id": "247", + "height": 720, + "vcodec": "vp9", + "tbr": 1200, + "ext": "webm", + "filesize_approx": 250, + }, + { + "format_id": "137", + "height": 1080, + "vcodec": "avc1", + "tbr": 2000, + "ext": "mp4", + "filesize": 412, + }, + {"format_id": "140", "height": None, "vcodec": "none", "ext": "m4a"}, + ], + } + summary = summarize_info(info) + formats = summary["formats"] + assert isinstance(formats, list) + assert [entry["id"] for entry in formats] == ["137", "247"] + assert summary["subtitle_languages"] == ["en", "vi"] + assert summary["has_chapters"] is True + assert summary["platform"] == "Youtube" + assert summary["is_playlist"] is False + + +def test_summarize_marks_links_that_resolve_to_a_playlist() -> None: + summary = summarize_info({"_type": "playlist", "title": "Fables", "entries": []}) + assert summary["is_playlist"] is True + + +class RecordingRunner: + def __init__(self, result: CompletedRun) -> None: + self.result = result + self.commands: list[list[str]] = [] + + def __call__( + self, command: Sequence[str], timeout: float, env: Mapping[str, str] + ) -> CompletedRun: + self.commands.append(list(command)) + return self.result + + +def no_cookies(directory: Path) -> Path | None: + return None + + +def test_fetch_info_summarizes_output(settings: Settings) -> None: + runner = RecordingRunner( + CompletedRun(0, json.dumps({"title": "Pho", "formats": []}), "") + ) + info = YtDlpClient(settings, no_cookies, runner).fetch_info(URL) + assert info["title"] == "Pho" + assert runner.commands[0][-2:] == ["--", URL] + + +def test_fetch_info_raises_mapped_error(settings: Settings) -> None: + runner = RecordingRunner( + CompletedRun(1, "", "ERROR: [youtube] abc: Sign in to confirm you're not a bot") + ) + with pytest.raises(ApiError) as caught: + YtDlpClient(settings, no_cookies, runner).fetch_info(URL) + assert caught.value.code == "bot_check" + + +def test_fetch_playlist_limits_entries(settings: Settings) -> None: + document = { + "title": "Mix", + "entries": [{"url": f"https://www.youtube.com/watch?v={n}"} for n in range(5)], + } + runner = RecordingRunner(CompletedRun(0, json.dumps(document), "")) + playlist = YtDlpClient(settings, no_cookies, runner).fetch_playlist(URL, 3) + assert playlist == { + "title": "Mix", + "count": 3, + "urls": [f"https://www.youtube.com/watch?v={n}" for n in range(3)], + } + assert runner.commands[0][runner.commands[0].index("--playlist-end") + 1] == "3" + + +class BlockingRunner: + def __init__(self) -> None: + self.release = threading.Event() + self.active = 0 + self.peak = 0 + self._lock = threading.Lock() + + def __call__( + self, command: Sequence[str], timeout: float, env: Mapping[str, str] + ) -> CompletedRun: + with self._lock: + self.active += 1 + self.peak = max(self.peak, self.active) + self.release.wait(5) + with self._lock: + self.active -= 1 + return CompletedRun(0, json.dumps({"title": "Pho"}), "") + + +def test_concurrent_lookups_are_capped(settings: Settings) -> None: + runner = BlockingRunner() + client = YtDlpClient(settings, no_cookies, runner) + callers = [ + threading.Thread(target=client.fetch_info, args=(URL,)) + for _ in range(MAX_CONCURRENT_LOOKUPS + 2) + ] + for caller in callers: + caller.start() + assert wait_until(lambda: runner.active == MAX_CONCURRENT_LOOKUPS) + time.sleep(0.1) + assert runner.active == MAX_CONCURRENT_LOOKUPS + runner.release.set() + for caller in callers: + caller.join(5) + assert runner.peak == MAX_CONCURRENT_LOOKUPS + + +def process_is_gone(pid: int) -> bool: + try: + stat = Path(f"/proc/{pid}/stat").read_text() + except FileNotFoundError: + return True + return stat.rpartition(") ")[2].startswith("Z") + + +def test_timed_out_lookup_kills_the_whole_process_group(tmp_path: Path) -> None: + pid_file = tmp_path / "grandchild.pid" + script = f'sleep 30 >/dev/null 2>&1 & echo $! > "{pid_file}"; wait' + with pytest.raises(ApiError) as caught: + run_command(["sh", "-c", script], 0.5, dict(os.environ)) + assert caught.value.code == "timeout" + grandchild = int(pid_file.read_text()) + assert wait_until(lambda: process_is_gone(grandchild), timeout=2.0) diff --git a/apps/api/uv.lock b/apps/api/uv.lock index e7182fd..42424fa 100644 --- a/apps/api/uv.lock +++ b/apps/api/uv.lock @@ -13,6 +13,7 @@ source = { virtual = "." } dependencies = [ { name = "flask" }, { name = "gunicorn" }, + { name = "yt-dlp", extra = ["curl-cffi", "default", "deno"] }, ] [package.dev-dependencies] @@ -26,6 +27,7 @@ dev = [ requires-dist = [ { name = "flask", specifier = ">=3.1.3" }, { name = "gunicorn", specifier = ">=26.2.0" }, + { name = "yt-dlp", extras = ["curl-cffi", "default", "deno"], specifier = "==2026.8.19" }, ] [package.metadata.requires-dev] @@ -108,6 +110,252 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, ] +[[package]] +name = "brotli" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" }, + { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, +] + +[[package]] +name = "brotlicffi" +version = "1.2.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/97/7845739a36828ffe751a1c6b240692f552fd7ecf65026c51326c0a4aa369/brotlicffi-1.2.0.2.tar.gz", hash = "sha256:5e0fbd13644cf1f6015e75fa5e0ad8fdce1048d9c9ff90b0ce826174b249ee35", size = 478755, upload-time = "2026-08-21T17:29:18.415Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/a2/edda4f3fc7143434402eacad1e91433fe68ae648c22738eeddb6138638ba/brotlicffi-1.2.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad05ca993234cf947f0ad71b1c8bc0af3d74e0410b1e2c32bb99de0cef6a994b", size = 438789, upload-time = "2026-08-21T17:28:55.708Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9c/506dc8edabb3cf9339c89f1ecc80a218aa166bb83b9f2e9cc1da67314072/brotlicffi-1.2.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0636cb5a85f31c36e08953d09a226cb788be900b976f81302895e3cf35d5e707", size = 1541246, upload-time = "2026-08-21T17:28:57.669Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d6/74cee9f9fbea8c42030a81056c64e092030a95bd2756ea83da1d1e8f5f29/brotlicffi-1.2.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97bae40d45ebc2a6ac7b1c9b30825496a257192194b672ef5869e2df93467f69", size = 1542129, upload-time = "2026-08-21T17:28:59.502Z" }, + { url = "https://files.pythonhosted.org/packages/24/cc/c32630b042ec2a13e8342e6ecb6b9d3531b1be4647b733d6fd365976041c/brotlicffi-1.2.0.2-cp314-cp314t-win32.whl", hash = "sha256:8f3f9bd61293dc48359763e693951393f39656086315067cf97e23e23e8911ab", size = 346840, upload-time = "2026-08-21T17:29:01.085Z" }, + { url = "https://files.pythonhosted.org/packages/ee/0b/83cac3075721fe4c253ea1cc5310cb687c2f7d987e0fd60eb3ed769c24c0/brotlicffi-1.2.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:908add8a9c0eea00f5de799dc6de9f6d205d9ee11afabc7c03d6812c481200e2", size = 386079, upload-time = "2026-08-21T17:29:02.667Z" }, + { url = "https://files.pythonhosted.org/packages/2e/71/c27f24b8334f65f2492601c7764338f156cb904d2ffe0061e6004a76d9cc/brotlicffi-1.2.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:d5a8ffa154f16660ab818d78045b55fa6f9970f1ca4c38998766e99c672071cb", size = 438885, upload-time = "2026-08-21T17:29:04.113Z" }, + { url = "https://files.pythonhosted.org/packages/ef/22/d8fd1a4d09b7ab563b89380395e09151d2ef1344be31594df6a6987d4028/brotlicffi-1.2.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec6b1af7b7a8ce788354f2c603651ada0fba166ec31ab879e2eec462a3e6dbf4", size = 1534365, upload-time = "2026-08-21T17:29:05.878Z" }, + { url = "https://files.pythonhosted.org/packages/06/78/076419ed6c2c6aa3eaac6fd6b076502b4be89d50625fcdc513cd4aeca718/brotlicffi-1.2.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22916101de0e7ff535f2edf54b52a85591853b8ae9a98737643defdd3c063a3a", size = 1536851, upload-time = "2026-08-21T17:29:07.599Z" }, + { url = "https://files.pythonhosted.org/packages/35/dd/31ae9945cbd605339fb51c9a609f7dbb182cd361adeabc1d470142357206/brotlicffi-1.2.0.2-cp39-abi3-win32.whl", hash = "sha256:df1d34c4ad9adbf7f63a6b42f7d0e4dfd259c88141b85145b57abecc1abc3b24", size = 342379, upload-time = "2026-08-21T17:29:09.05Z" }, + { url = "https://files.pythonhosted.org/packages/95/ae/afd54e744df93b51cc29f6a19beccf9998b25743d7177697390de10479d1/brotlicffi-1.2.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:489ca4da3ee65926d72bf01584b61088a9da6bdd1bb01b2040901e1beaffa8f0", size = 379761, upload-time = "2026-08-21T17:29:10.687Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + [[package]] name = "click" version = "8.5.0" @@ -126,6 +374,52 @@ 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 = "curl-cffi" +version = "0.16.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/e1/730125c43e3e331d98e17af3cb310ba526b3f1101b7635ca23d976ebfcf5/curl_cffi-0.16.3.tar.gz", hash = "sha256:d15d0c2a35f2d75bec430c28946c2a833f421c85773bdb0795182cc5c515665b", size = 239020, upload-time = "2026-09-02T11:58:23.266Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7a/ec08ef0665c4ef4ea76b47042eb1c043e4afb374d8b9218e00272c9e73a2/curl_cffi-0.16.3-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:0f1f6878863fba393801e4d59b2f2766d1983b5c9d9dfa11d4becfd6a74cc937", size = 3025646, upload-time = "2026-09-02T11:57:39.326Z" }, + { url = "https://files.pythonhosted.org/packages/4c/86/e21b8ed384db26401a4438f20f01c7bcd9c3a6f8ceede458344e2d62775c/curl_cffi-0.16.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:f3b63da797912bc82911e34dfe449725514e4281527fb516931fc457087cfb44", size = 2784023, upload-time = "2026-09-02T11:57:40.986Z" }, + { url = "https://files.pythonhosted.org/packages/97/2d/25b106e64178829be1ce171b6cd45ba354ab7a2a5169001866b38d4c440f/curl_cffi-0.16.3-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5a4103f2baa1fcf619ec3101b419827d367044ba106b206228137cc71a5a9c5", size = 12834219, upload-time = "2026-09-02T11:57:42.711Z" }, + { url = "https://files.pythonhosted.org/packages/e7/dd/db27a521777d0cf00f9a1554453ae730539dd134bca108d8df256a85c91e/curl_cffi-0.16.3-cp310-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f2795f0ef2e8cc0e6d702e52367af6600f5bcf10e44d683e256254adc7e3589f", size = 12655304, upload-time = "2026-09-02T11:57:45.334Z" }, + { url = "https://files.pythonhosted.org/packages/72/01/2bbf141baa0fc3921d31a90de5465b7a94188845a8fe84dee86bf7bd90f1/curl_cffi-0.16.3-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a875a661e2f9a949be29454880bbb9553307a487c4c08819738298cf5c1622e2", size = 13484311, upload-time = "2026-09-02T11:57:47.58Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d4/745ca299a2a223ee18574ec7cff75de620a92ce69b3cb09490db8fba614b/curl_cffi-0.16.3-cp310-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0851e710608122a2716bdee35788bbd7e9d4a0fd42899b2bca9181277095af8e", size = 12840616, upload-time = "2026-09-02T11:57:50.016Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bf/98d72d7a081cc155a71ab66bde6a18640d4ac5d4f6766f729a92cb4257c0/curl_cffi-0.16.3-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d7e553442cefec100dfd1fca4ae7035ab6c094457244bf60a38670b8ac8185d", size = 12612602, upload-time = "2026-09-02T11:57:52.584Z" }, + { url = "https://files.pythonhosted.org/packages/36/cf/2fdaff71fd6f39c5994495af8378e6e26bca8e447d94d2f75a76337908c8/curl_cffi-0.16.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:60621b3f561346046dd62be33abfb50c8b88a8007699d6b11d39ad4755312c4a", size = 12588697, upload-time = "2026-09-02T11:57:55.138Z" }, + { url = "https://files.pythonhosted.org/packages/74/55/68c399019bc24ea6ac783c98139a2555f88589631f3d127fe6b9073f019b/curl_cffi-0.16.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:20a7b1b473371cfaf2118958034977e457c6fa279fbd11543c9e0ab58be9eedd", size = 13253555, upload-time = "2026-09-02T11:57:57.524Z" }, + { url = "https://files.pythonhosted.org/packages/9b/72/1732a24ef4a2aeba994b80ec163debe8deda403c07e4abbc0443bca078b8/curl_cffi-0.16.3-cp310-abi3-win_amd64.whl", hash = "sha256:fe87b66e324ed7318166698e02169f3208dbda32b872a27d2bc61a9c19b335eb", size = 1978602, upload-time = "2026-09-02T11:58:00.033Z" }, + { url = "https://files.pythonhosted.org/packages/45/bb/67bec3132aeabac99dfe2f299a9b43dcb5de23ad96219ee98516d177fc9c/curl_cffi-0.16.3-cp310-abi3-win_arm64.whl", hash = "sha256:5a2ba880019f9e5a9e8f38ae22de6e4ea4c8d34a51ae4f1a2fce962c7b632006", size = 1713140, upload-time = "2026-09-02T11:58:01.558Z" }, + { url = "https://files.pythonhosted.org/packages/49/e3/b88f9b1a60a1e29b42e9371c1b3f4fdd83bf8177fcf863df67438da12693/curl_cffi-0.16.3-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:01c31369b1c8063c7e459152c508c90de7a4218aa66ee3a1f575ae37ce44bc5a", size = 8607348, upload-time = "2026-09-02T11:58:03.095Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f4/3dedff1a31c93a9b18acaa346e23832c29bc18075138e90e9af795188e5e/curl_cffi-0.16.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:0c8b70191dc88ea770a5c39d7e213bff1606e248c13566777e6527f0d8cf96ec", size = 8607323, upload-time = "2026-09-02T11:58:05.099Z" }, + { url = "https://files.pythonhosted.org/packages/73/b7/99708ed83c11132ec0311a28ed46fe1cd10e8cb6ecd3c82f01f1f80c3c2c/curl_cffi-0.16.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8055ec9d7c15237747be254739c40057e3684f56854e95c12aaf3c95838ba2d6", size = 3026149, upload-time = "2026-09-02T11:58:06.973Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d5/6c0400fb64097c4662da4e5d2d1e7daa8027d1431b1c8880c0f8f2051ae1/curl_cffi-0.16.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:391096e903ec98b909bb355e008ec7c211d710b6a23663a7f1f10aa54a027538", size = 2784153, upload-time = "2026-09-02T11:58:08.726Z" }, + { url = "https://files.pythonhosted.org/packages/87/a4/3c8702d25e21f420e88707701af15006e72a2a2b9f3fa419c7c80ce7451c/curl_cffi-0.16.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6cef43f248b3635de9b82337e0ed2c7403aa1506e51587144d552702eb9d0775", size = 12839612, upload-time = "2026-09-02T11:58:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/be/bf/44a7e7a1e309136a1b086332feb03c7718169af550bdbf7eab52ae0497e0/curl_cffi-0.16.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e1fffac4b5a02c5ec74d184d668c5b882f80fa1d961e7adba6e1755877af41e1", size = 13488945, upload-time = "2026-09-02T11:58:13.15Z" }, + { url = "https://files.pythonhosted.org/packages/52/83/5321d5fb67ff16195fb0c3bd5434be4532c85967c80546092a1cf3654cc9/curl_cffi-0.16.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:849026be5b36cf7b95d5fce63a84aa7b17248e83b4374e67715e7387ca2be50c", size = 12592235, upload-time = "2026-09-02T11:58:15.58Z" }, + { url = "https://files.pythonhosted.org/packages/12/aa/0b4e110729a86b434196d15e2e2839d992a9b8f3003f0569c77e27a9faca/curl_cffi-0.16.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82cc688349c8e8955d346cc5cc7759b68742edc587ae47ba5783a096502a7a92", size = 13259593, upload-time = "2026-09-02T11:58:17.971Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3a/e4f199cfc9f131411543aacdf6811d8b72b81ce6ac6e9f6ddffecfc31e54/curl_cffi-0.16.3-cp314-cp314t-win_amd64.whl", hash = "sha256:72376595490c4822ad1a5360adb568660ca66dff4ba2c2de2912778c15f43edb", size = 2031033, upload-time = "2026-09-02T11:58:19.949Z" }, + { url = "https://files.pythonhosted.org/packages/18/8f/9354e5552982d38abd3ce2db859f049fee6bff0eee4e25aacaaa2b29f0b4/curl_cffi-0.16.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b450fad876aa9f9ed3edfb6e3a48a8c28eafa66aae634eff17800a8b5006568d", size = 1782234, upload-time = "2026-09-02T11:58:21.629Z" }, +] + +[[package]] +name = "deno" +version = "2.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/fa/04bdb9b54ee425a9708c82c61e3acf5796405718756600c7aab79910b712/deno-2.9.6.tar.gz", hash = "sha256:0dba7666e7d3db62ff1e73f7f69fc74b63a551a198c311d2ffb409d1dc96167f", size = 8167, upload-time = "2026-08-27T17:32:04.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/89/a50877162323e98129329a3a54555ac164d8cc911b2a6b8e5ccefc498c54/deno-2.9.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:845831e26d98553fb17b7baf9af4949857c5d78965c35890e7a0af9425e63734", size = 42274922, upload-time = "2026-08-27T17:31:43.39Z" }, + { url = "https://files.pythonhosted.org/packages/8e/09/c46e566dd9f8dbc09c376c9c273e27da61d89e4e7e7d03b5be35720ecfb3/deno-2.9.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ecf7b5d938562e4227a5ee5b5b1025094ad44cf92d829917a3d3060bface5093", size = 38462382, upload-time = "2026-08-27T17:31:48.132Z" }, + { url = "https://files.pythonhosted.org/packages/5b/22/7cccf9010e036a8d9be61cf8c2747a3e429d25e20dceace43f2c399522fc/deno-2.9.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:f8e1f6c053d3376388b61e658a2438ba45904a62b261988f2b858efefdc0aaad", size = 39823223, upload-time = "2026-08-27T17:31:52.629Z" }, + { url = "https://files.pythonhosted.org/packages/49/22/f3cb68c5b46140eafec610c763062d147bf73050c1d24ab8180902d8ba93/deno-2.9.6-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:037458003a1c76382e41baa1cd152b3325d9a13b2678f75cc653c893c0b2dfec", size = 41593942, upload-time = "2026-08-27T17:31:57.197Z" }, + { url = "https://files.pythonhosted.org/packages/9b/d1/085d2f83ec0a55346c9b6cd96f74fc5b69d4edbdd9575f84ee1b08b4218f/deno-2.9.6-py3-none-win_amd64.whl", hash = "sha256:ad65ac596e53f7275d783070be2fd96a47f4a478e67ecd0584e6b744804ed863", size = 41516258, upload-time = "2026-08-27T17:32:01.818Z" }, +] + [[package]] name = "flask" version = "3.1.3" @@ -152,6 +446,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/85/7522a52e5e2f42faf1a129113ab63e548c42e103e9af395b7bfe65e403e2/gunicorn-26.2.0-py3-none-any.whl", hash = "sha256:bd249d0b3f7972f7432f0a6b6ff3b3ee2d129f70cd1ff6c09a9dd9e29a2b88e3", size = 228389, upload-time = "2026-08-24T15:05:57.67Z" }, ] +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -320,6 +623,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mutagen" +version = "1.48.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/70/1675da133ea92227da41bf5b24e1c66be597ff736a1533ade41da986852f/mutagen-1.48.1.tar.gz", hash = "sha256:8f95637ab9f6f305cec6bd1294e197debe207998e3e068596563c74f86b0a173", size = 1276978, upload-time = "2026-06-25T09:47:32.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/d8/a29e4e3991765e7ce4ed1f7e4074fe1ba9da03e0048639734de60f9cadb9/mutagen-1.48.1-py3-none-any.whl", hash = "sha256:4f077fe87d3fc7fba259aa63d8c026b18382ca6a42ef37c61e16f1b1b5b82fe7", size = 195706, upload-time = "2026-06-25T09:47:30.296Z" }, +] + [[package]] name = "mypy" version = "2.3.1" @@ -404,6 +716,45 @@ 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 = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[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 = "pygments" version = "2.21.0" @@ -429,6 +780,21 @@ 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 = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + [[package]] name = "ruff" version = "0.16.7" @@ -463,6 +829,124 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "websockets" +version = "17.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/72/fba934cb3dff7a85d811820efffcd141ddd52b5a2a01637f64551373ff4d/websockets-17.1.tar.gz", hash = "sha256:acfea4c20bf54384883ea33b1240fc1db4f52e190823a4e2b334bc3e8bfca96a", size = 187520, upload-time = "2026-08-26T17:25:33.063Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/31/5f6450a7879f4f063ef08897cc385ea3ce3f1fe17f08b11e3fd959abdf27/websockets-17.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2a0162a6372110a5601cb5c9fd826635cedf69f3e110c545dd19774e040b970e", size = 217006, upload-time = "2026-08-26T14:56:10.509Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2a/c1b006fc861695d2aa4e35327b842015ce1d98cf8f99241829b3d6460bfc/websockets-17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:829dba1bc049779de9b332088c1a6a9858e96bd67e50b6b644a95e02b67836bc", size = 214690, upload-time = "2026-08-26T14:56:11.681Z" }, + { url = "https://files.pythonhosted.org/packages/46/69/66e5b7d01445e0eeb1d4ab419c30315f2c90cf7a8a8cd4ecc47f894dba54/websockets-17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd8f47dbf2e8adb15c847215f83436de3fdb120b51fdae0fbbdf69fd97a3ad80", size = 214947, upload-time = "2026-08-26T14:56:12.923Z" }, + { url = "https://files.pythonhosted.org/packages/07/ce/033cafe2d2538562efa876b9149a2c7a0f7787870a4b1bb6e28adc9ceb6b/websockets-17.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9f4c0377a83e163a303514fdfab501dbe379bdc13e5b9312a91d112658b29dce", size = 224329, upload-time = "2026-08-26T14:56:14.212Z" }, + { url = "https://files.pythonhosted.org/packages/34/c7/e1c2e8a67f6cc0aa43abe0046fb3b7a020980649e6a843751dc7ce9eb170/websockets-17.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c3241d684a76eaaef8b2dc789afde4343cd3aad55ea81e4e8ab3605b529bae51", size = 224611, upload-time = "2026-08-26T14:56:15.702Z" }, + { url = "https://files.pythonhosted.org/packages/be/de/07c6d48eb3d2069709410c851e7de10ab83d752c4bd09862899627c2729b/websockets-17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5f5c7a893507d0e83a80b88aefd6522f7e882cd53f9722c6f23f5a020c9557c", size = 225848, upload-time = "2026-08-26T14:56:16.962Z" }, + { url = "https://files.pythonhosted.org/packages/f3/dd/3c68572d20509648cc2fb6f50ccf3deeb4b87270f2c8966e99476e278ea3/websockets-17.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00bf34b64501e3477e81fc281532ff3cbf4da26633c10b63979d5085d46602d3", size = 227290, upload-time = "2026-08-26T14:56:18.204Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4a/8f6651c8a22093539c9215af0c5bbf217b87b382c99d2112039b92d593c2/websockets-17.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ce0305b702b20d1e1d60a9aaace6bc89970e1753565543f310d549eab22c2435", size = 226476, upload-time = "2026-08-26T14:56:19.459Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/f6fc33cea86b1127fd1297b18c107e81580ab55a73a39f9a934441ef321f/websockets-17.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29176d8b429cfa0fa443c473878d37a5c06cfd0cb36b71ba4314accc71e05906", size = 225233, upload-time = "2026-08-26T14:56:20.939Z" }, + { url = "https://files.pythonhosted.org/packages/cb/83/65edaf05f7c9b1dea82f4d252fdc37706a84571646f06119a27b0a16fe19/websockets-17.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3709a1ab30b4b922027d22f68d2b61a0656a91680ac894a537624e6be7dd7f7c", size = 222488, upload-time = "2026-08-26T14:56:22.208Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/d1169c2f7f1f0032b0d4b0c00f0711a070cd7c735de37bfeb876bc0f9606/websockets-17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:43bd0c1ceb924d67f5c1a5254d8361dd9d94246e6331a726064dfa2917880780", size = 225295, upload-time = "2026-08-26T14:56:23.445Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f4/64e2a386c3899b917c2933225c9b47887874229d159797f3bf1a11c20d51/websockets-17.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:1fce0f43e0d41422e0b2cad6561e1970df22f212f4c7e884967df7cf591b031c", size = 223891, upload-time = "2026-08-26T14:56:24.647Z" }, + { url = "https://files.pythonhosted.org/packages/26/b3/dfb5c482f7e310a3432fdbb045ddfe6d34114680e89a233d4ff900a32961/websockets-17.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4031152769179ab8dcdeafc7b0e58052a49117560a28671700b47b2c7b717aad", size = 224661, upload-time = "2026-08-26T14:56:26.027Z" }, + { url = "https://files.pythonhosted.org/packages/a4/cf/94865130a336029f46412adc127c4fbe380f46172b90ce251369e35c4302/websockets-17.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a06f3b5085176763182449559e20391d7ce616a8972a9f7a33deda87ea6d4f3c", size = 225766, upload-time = "2026-08-26T14:56:27.455Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/eb8c658f86dfe562ed49a887a27424bfe9e618c26ea6f865b093d075d3a6/websockets-17.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:77b37cceca17291897c3c73bd30a7c7c7909593554b5da574ec852af83c1742a", size = 223323, upload-time = "2026-08-26T14:56:28.807Z" }, + { url = "https://files.pythonhosted.org/packages/1b/7e/2629609652ece5ca0c7ac235927dd4511b08131e3a5d53439b798fddf002/websockets-17.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d8e83333385cac6030a5167fd18bf96cc6c58b914c308e683f05b0cf94bc8dd0", size = 224276, upload-time = "2026-08-26T14:56:29.991Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6b/8525737fe840b38e5f40956c198fb586a4fac1e07144d41a5b949b989cf8/websockets-17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:073c5c3f7e127041fa9d34a9e29ceefee8c3cafbd267ed2927318f425144380d", size = 224558, upload-time = "2026-08-26T14:56:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/74/ab/3a958c6cbcf74b118f601c20a80ac8bd5e8dfec0bcf7345116feaeefb121/websockets-17.1-cp313-cp313-win32.whl", hash = "sha256:2afb58c7ba48b329d56769f8dfd89f394efe587b65ef806bae810a484d6d3608", size = 217475, upload-time = "2026-08-26T14:56:32.431Z" }, + { url = "https://files.pythonhosted.org/packages/22/36/fb521f0f2994c25509651f169efe5582dddd8713d57a0757ba87859372ef/websockets-17.1-cp313-cp313-win_amd64.whl", hash = "sha256:0340bbef6bfbe16da888b3983d666a4db4954ac3253c38f13bc7aba0c7db5a2f", size = 217784, upload-time = "2026-08-26T14:56:33.608Z" }, + { url = "https://files.pythonhosted.org/packages/68/92/9b8419584681a12a7534b746dfb2737c466efe2455483e2fbf8b941a04ec/websockets-17.1-cp313-cp313-win_arm64.whl", hash = "sha256:7a72efa3bf4fa3a6669a54420a472ad056da3973d827f10e3a536da463f926c2", size = 217715, upload-time = "2026-08-26T14:56:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/90/0d/500cf5daea09d4669dff3a7d67159094a0bd6c4ef130381404f6edd3eb5f/websockets-17.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0c9982938980e086da59f70d05f9418cd143401a601a0faac10fa48f7bb1cd3e", size = 217048, upload-time = "2026-08-26T14:56:36.03Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/5b12c6168aa269cffbfd24d177cd492b130120403a418c7e89462e27b4ac/websockets-17.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:57b39dc8541cf7ed3f639da82bf7451060483967f9e733da1f8173e4095f0642", size = 214737, upload-time = "2026-08-26T14:56:37.43Z" }, + { url = "https://files.pythonhosted.org/packages/0c/36/e453e5106e4e2416f008ac222837c2f1637a063b08008afcd1088889b631/websockets-17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:96abdecbaae746851b87c3a36cb4a661df93ca3d92f114270f79228bf1d00de6", size = 214955, upload-time = "2026-08-26T14:56:38.71Z" }, + { url = "https://files.pythonhosted.org/packages/dd/30/0204bb86176db02cdfc678ce65ed808a66fab87d250ce61a8790800a60b0/websockets-17.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9fc873e239c5abeb150bc24dbd1a7af23a9254526383ce0a077f5e20adbeb19", size = 224331, upload-time = "2026-08-26T14:56:39.924Z" }, + { url = "https://files.pythonhosted.org/packages/46/c8/d8372256e00c4e3cab1115c45075d1eeedb642a3f2b42bd70c4deae03f06/websockets-17.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6f42912fa9eb4cb7c7ec9fde9b3332ba339eb8a8811981043d4029599f3d950b", size = 224685, upload-time = "2026-08-26T14:56:41.169Z" }, + { url = "https://files.pythonhosted.org/packages/12/7d/650355b8f67f908ff99603351d4458d1a0b787d627950a47c38db7e25308/websockets-17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f98bf378d7a5be047a044a1a27c987a8f355e10e3b5754617dbe756248cbc5ce", size = 225927, upload-time = "2026-08-26T14:56:42.359Z" }, + { url = "https://files.pythonhosted.org/packages/34/6c/a9ffa5b903579eed76017870f055d75ecc73988d9d0c9b65a92ba0bf2a27/websockets-17.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d334d11398086bb5559606cb42d51c013ea7c061c7db701521392373d3c087f5", size = 227300, upload-time = "2026-08-26T14:56:43.538Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5d/4551c2269066af7481ee44605a0813770961615b5b5da3e87a8f5cb859ea/websockets-17.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c27336b1a0ac56569493e858497870347854372395f50483725f8cdacc5a45c", size = 226533, upload-time = "2026-08-26T14:56:44.669Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/237a99233e5c445759a613831b3a92e91905afc064dc3bd0ad33c35fd1e2/websockets-17.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67258b00302a5aaf0b267771c7014b13429abd7ea17eebc4c55bd935ff101555", size = 225280, upload-time = "2026-08-26T14:56:45.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b5/e9407a91613d1d1cd932414143a1012096b26674a782fc55a0bd23217ee4/websockets-17.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:455ffeea0879d313205df1e745e5883e1feb7f31ecd26be882f5f0babd3db04f", size = 222540, upload-time = "2026-08-26T14:56:47.053Z" }, + { url = "https://files.pythonhosted.org/packages/db/d2/db76628db0577b783205d9779f64d8e373416b04c62d1546be4b75dc8540/websockets-17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f7233eaf441a345a5943a929fd4b5ea3278f11aed35a9ed0f3106b8cb3ca846a", size = 225354, upload-time = "2026-08-26T14:56:48.32Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4c/2174181c067b89a74ae18e2650c2ac29959f4b796afe876ab3f4d30d642c/websockets-17.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c65da239a5ad553619804c1f9d65c1a0b3005381c6158ee14da2c7444cbd0c78", size = 223867, upload-time = "2026-08-26T14:56:49.579Z" }, + { url = "https://files.pythonhosted.org/packages/df/75/274decb9a8253561b5be3261e02a6676fc8ecdf31e95b722e53d5bfb8fd2/websockets-17.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9fa1ffa08c81a4f809cdab6129f8e55bee4650b9d6d3461019dda73aacd146b6", size = 224652, upload-time = "2026-08-26T14:56:50.885Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e6/49824f1fb4db7656d2f7492b1d8be16147b759d909490e32f4776843ee64/websockets-17.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:406b8107943a43ef4649b1e0cb0cdc052bbf08fe1c8905a623c4af9586e5cebb", size = 225822, upload-time = "2026-08-26T14:56:52.356Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6a/5dc43838c0b02a95f42c47a0de33c5ddd7767a9feeb4d0d8777ac1cfefe4/websockets-17.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4e8ffcb486c8490a34a4cef5e4409d8da5a1cb1681e5bf7d786ce5e84aa8540d", size = 223379, upload-time = "2026-08-26T14:56:53.699Z" }, + { url = "https://files.pythonhosted.org/packages/c2/62/585637cf06d6b321232f79c55dc14d65518d12cf87c94c44f5864068810e/websockets-17.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fb88076df585b69c5761c387c0081aa87d7b9eb1b205a6535ca4777e25650d81", size = 224330, upload-time = "2026-08-26T14:56:55.184Z" }, + { url = "https://files.pythonhosted.org/packages/de/68/c3b234a6a1366b6ab5bbfaa4434a1b946e1dc4e8ddd6824bfd93a8835b7f/websockets-17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5d4724255fb8398acd9e583b97eb2279cec20e0bd0f9a94bf75f6056ef9f13da", size = 224622, upload-time = "2026-08-26T14:56:56.393Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d4/84cf3d1376f5d8207f55f43c1c818babd6b89447f5dcd01f18a6d5526796/websockets-17.1-cp314-cp314-win32.whl", hash = "sha256:be3f0129c5654517b2abf07dcb75bb1d9479759a4ccfb569e8293579e9fc029a", size = 217036, upload-time = "2026-08-26T14:56:57.652Z" }, + { url = "https://files.pythonhosted.org/packages/d0/0f/9e7ac63c5d7cb642952200814f584318e65146df008b7d375d5d9c6b2c97/websockets-17.1-cp314-cp314-win_amd64.whl", hash = "sha256:2a4dc6ef83f4559e0d05f313a375cb38f63c986096a9da99fe94fdd779d313e5", size = 217382, upload-time = "2026-08-26T14:56:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/1ae6b91f7f3ac05f5c9f14a72dc2181c115ff370bcd8a7f10f02c174adfd/websockets-17.1-cp314-cp314-win_arm64.whl", hash = "sha256:46c0331c9eaaf73a559f3a9e388466be0df96eb83d40f06f1ca6ab6613b35c82", size = 217268, upload-time = "2026-08-26T14:57:00.654Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f0/f65644d0e0b2b90918a8c41503841cc4072a58f2bf76c09bc36e751fc0dd/websockets-17.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d411ea5ca18ac1b12c0c94be88b60c18ca641ac43bcdfdf1c9f79d46cdbe1603", size = 217379, upload-time = "2026-08-26T14:57:02.181Z" }, + { url = "https://files.pythonhosted.org/packages/ff/35/4c46d1f620ac1a30f92b6eae78ee40a772a93f568647ca7ccdc5ea283cf8/websockets-17.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:07fa3e7c30e2c577928d359b56bf872a3e0cbcc15553eaa0907c1ee86344b56f", size = 214911, upload-time = "2026-08-26T14:57:03.478Z" }, + { url = "https://files.pythonhosted.org/packages/04/6e/4587e8406d7c1188e97b9cf466c081e93399380d447f885bfce81626cd37/websockets-17.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6de9acef07e3a78e9567fcd26c29011a4da8f050b13004bbf880a0fd82a6eea5", size = 215115, upload-time = "2026-08-26T14:57:04.692Z" }, + { url = "https://files.pythonhosted.org/packages/ec/06/1381c8fff525041025909eb80ace32489194a00ba22a0a8d428030afcc84/websockets-17.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ea0ed9373b880115911d9d39634bccc95b8ce590c9c42e8589f5cacc3ef3cee2", size = 224696, upload-time = "2026-08-26T14:57:05.899Z" }, + { url = "https://files.pythonhosted.org/packages/36/9d/9034e867dc85340be058619751742b895f722326e83100d110063461ca07/websockets-17.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50903d335bfda026c2fa11dd9aed09d8cbee0c451e3a85122a9acb041b7dc69b", size = 224975, upload-time = "2026-08-26T14:57:07.262Z" }, + { url = "https://files.pythonhosted.org/packages/40/eb/ed03aa3cae748ebf6397e5d44028f433f746bad09dc568ff754fda3a3c9b/websockets-17.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a74531ce81af587f906ab42f194032388fcff8fc7938402e5917c9147a39441", size = 226151, upload-time = "2026-08-26T14:57:08.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c9/cc1964a096d16f3b73cb1ee5f14f277f5a3bcac07c6e8f9a1dcded99f4c8/websockets-17.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8fbf28e639544503b7d1c96452a5e5e043e4108d89b1f3fa02910603622d19db", size = 228292, upload-time = "2026-08-26T14:57:09.846Z" }, + { url = "https://files.pythonhosted.org/packages/1a/26/46da6dd0363c2db2e4876fd59a40fd40c1943a82d7018d0a33afbce47d52/websockets-17.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f612dc57f00c07cf4aa2673f7cbceabd654ad2457b7e639f061b794d6e11f9fd", size = 226722, upload-time = "2026-08-26T14:57:11.118Z" }, + { url = "https://files.pythonhosted.org/packages/78/98/ecd8f5e1c5d0e54c08ebc5c66852271112166db68107cb0e17ca1bf25009/websockets-17.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c7ac77401227212dc6e849182feee50d57cf456ec6329ffd6979c94bb136c5c", size = 225451, upload-time = "2026-08-26T14:57:12.601Z" }, + { url = "https://files.pythonhosted.org/packages/65/4d/da8d2760db53e17aae763738b6ba834b1fcf16813d3632f3edb6951e1ec8/websockets-17.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32a2a68d989d6e5b74a9d5095415c51189ebae29fceb7cf2b64a1c0318a81256", size = 223003, upload-time = "2026-08-26T14:57:13.875Z" }, + { url = "https://files.pythonhosted.org/packages/a4/40/ea401c141a79c5b1d0021a0dab9d0df2051c108f1620fbb39a6e7c714c3b/websockets-17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:aec00f018d34c67500ff0438dc314b40277be4a1b983cbacbf53ccf7db63e257", size = 225704, upload-time = "2026-08-26T14:57:15.091Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8e/07ab3f44215d89840d5385fdcaaab1fed8caeffa67c6899e15062957c12c/websockets-17.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0014eaff8ad5b3b43feda2279f9d34bf2eaae040720b9fbbb55944b10f40b14d", size = 224192, upload-time = "2026-08-26T14:57:16.3Z" }, + { url = "https://files.pythonhosted.org/packages/58/93/ccf1af0a23e5748d4e22292a377d78d15cf294d7e707bbb11a8990ae6bd5/websockets-17.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:db9d7ee47f3ba531e278be539af39e2c7c7d28fb94897b6cd1120d63b0ef5922", size = 225082, upload-time = "2026-08-26T14:57:17.531Z" }, + { url = "https://files.pythonhosted.org/packages/e2/db/e32200f99ce282e728d2929f2c429db353cf3282db7d0eba99eb32c9fec1/websockets-17.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:ff3e2ba7a9f0a110b0555452e9b5a03a34e11662544e01beea15f144b48ba7b7", size = 226101, upload-time = "2026-08-26T14:57:18.802Z" }, + { url = "https://files.pythonhosted.org/packages/28/3d/e7a6e9777b29433620167c98f3caaff0d6b08b1239a273ef7f7fd1393349/websockets-17.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6da17fc94bd270f5987b10bee113461ac36a36a98b0481ddcc98056e5a90001a", size = 223794, upload-time = "2026-08-26T14:57:20.313Z" }, + { url = "https://files.pythonhosted.org/packages/48/05/ac569090726dedd6656f3ee28b0c02dfb1ba76e898dceaccc2987a237cef/websockets-17.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:e8dc3fa6d6b7ead3f9de57895f41b116a28787548e066365d9d90f7356bcaad2", size = 224567, upload-time = "2026-08-26T14:57:21.634Z" }, + { url = "https://files.pythonhosted.org/packages/14/50/4ef62941111db6b31193f4fabbb65f845a5177579040cb8fe0d774d25034/websockets-17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b65d5fe48219dc2d5e158de9e6514e75600f379cc7e37108d35f31764c155566", size = 224993, upload-time = "2026-08-26T14:57:22.86Z" }, + { url = "https://files.pythonhosted.org/packages/28/42/2b95ada4ea19bf3a2072b68669ce4f4afb212690b727d31640576287fd68/websockets-17.1-cp314-cp314t-win32.whl", hash = "sha256:2cce251f3e2469b99b6802b55435bcdd07123b41870f54c87b336183af9d7e68", size = 217168, upload-time = "2026-08-26T14:57:24.466Z" }, + { url = "https://files.pythonhosted.org/packages/32/0a/67d5ee08dd8060a37d612fd40a625b5376ad19ae48fe1c8ad428c278b817/websockets-17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f6c38cdcaf98a911d7acc25577f2f9e710f3a2fc2bde1563556784320196b51", size = 217508, upload-time = "2026-08-26T14:57:25.983Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/822005d0c674451d2411027b878cdc128a2b7ea5a30d337d9e279da22eba/websockets-17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:d1e2f5fa2b6d01f0d85b4f223fea7ed1d504be282a02a81bd2be4817ef7a2f03", size = 217425, upload-time = "2026-08-26T14:57:27.324Z" }, + { url = "https://files.pythonhosted.org/packages/de/d5/99a6c6a1eb5d5ae9f45f59a3c97f4e3b21f310eb404a547fb3e7d2fc054c/websockets-17.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:88381602e379165b66244b2ebc29f9b23ea0851fbe63ae157f91ca324f072d6f", size = 216970, upload-time = "2026-08-26T14:57:28.575Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0e/1e7f6e833728193958d3ed3d67b5d57c3c7cfa948abf94d4bc553257c954/websockets-17.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:88bc5138e53903a85c354e59df7ba73ce306f7b09724cef74dba121e60a88ce2", size = 214699, upload-time = "2026-08-26T14:57:29.862Z" }, + { url = "https://files.pythonhosted.org/packages/07/00/95d39549f86e34425a0412bcbe61708dd1fc46af654e2134a6c4389102ad/websockets-17.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3546ef55b3a074494106508bc6505c73825970d2d9505f7bf53882b3e88b0d1e", size = 214927, upload-time = "2026-08-26T14:57:31.148Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ff/b442415fc4f7f9943b0fc8e8eebaa13923ca73361e167c439ba634eecbd9/websockets-17.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9ae55d24241fc055f22aea3ac924559069848bd0ad4ea065fdd72d2194685fe8", size = 224373, upload-time = "2026-08-26T14:57:32.833Z" }, + { url = "https://files.pythonhosted.org/packages/a8/dd/b83537aae4cf61615b9d8b2dbb235c0030ba85457a6d934798273814600f/websockets-17.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d7b349265fad6244013eecd99df8d83c12bf3013943e431f4fadd5bffc37db42", size = 224801, upload-time = "2026-08-26T14:57:34.041Z" }, + { url = "https://files.pythonhosted.org/packages/76/83/5ab0abed58454909e8dbab45086ac68ee4556d7a8ada26735addc909b903/websockets-17.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc5789e5ea182b77a38881383ada5347202a6c66f4857d054e075290e80b604b", size = 225967, upload-time = "2026-08-26T14:57:35.292Z" }, + { url = "https://files.pythonhosted.org/packages/4b/26/e2412f2b998a8c1dfc00c0709ff6ee0c634dd0b0b4f92bdfe9667876b71c/websockets-17.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ce13c7d233239e739600a57d4a73c1192ad8259e655a4d55aa1a454242bc809d", size = 227664, upload-time = "2026-08-26T14:57:36.493Z" }, + { url = "https://files.pythonhosted.org/packages/ec/25/0dd4495df3c0e02f6db705312ba85ab9b2dd42257dc23eb0da10066e4844/websockets-17.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1036189bd34b0bc1b10a4679321e2c7968af317efe6e8e4c1c5141c4254fb5bb", size = 226447, upload-time = "2026-08-26T14:57:37.781Z" }, + { url = "https://files.pythonhosted.org/packages/be/67/6df3f63ffc48f08126ed0cd2fd2a41092967c3e364f8ec100deae90b6d77/websockets-17.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e78fd4b7b2c5086a38671c9c882c1e643385eccea360b5b1fda4a105e590087e", size = 225343, upload-time = "2026-08-26T14:57:39.133Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8d/a8479bbb09ff054907d141123d8f52fb6ae5ac39c6dbe39e6a02a8408309/websockets-17.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:46e7a10bf04318c7b0c0273791925ae5e1cbe4a11e34aa934d2ef27862058a80", size = 222748, upload-time = "2026-08-26T14:57:40.478Z" }, + { url = "https://files.pythonhosted.org/packages/40/fb/4c3d2a3269cde3f3087916de9c3d9fc5d7196b46846d8c3a9ae59ad0a884/websockets-17.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:33e45c7ea38428e740a7f233555d71df0b875cef7fc080acebc9654475e35335", size = 225453, upload-time = "2026-08-26T14:57:41.859Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1c/6467b401d19408f34e1c7389c222c2c7e1dfdf08c551190269b5eabc726c/websockets-17.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6e63c01803be425ff062b7f7fc201a74def1d49fc94a2410dd17375df75936e9", size = 224112, upload-time = "2026-08-26T14:57:43.136Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5f/744e032ac80e11039a7447657ebabb46e9b5c2dbcec83be571335212932f/websockets-17.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:722ec21717eec6477bce582147a28acdfe034e604239466a6a95daedb863e774", size = 224646, upload-time = "2026-08-26T14:57:44.871Z" }, + { url = "https://files.pythonhosted.org/packages/9f/47/bcb9128d9afc4d0934d9192e2a24897ca2f7a63df2654904915349c6c46d/websockets-17.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:e74e41f0ad12ff1e8983e349daef79d37cc8280c743ce9d134d6c74c18dab5d6", size = 225797, upload-time = "2026-08-26T14:57:46.338Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e0/b058047b7cf565e1105b10ef6b6b24a6ebe3575678c7dc75a645334705a7/websockets-17.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:12fe8984a32dbfd084e0603f1a8d740c0180cb85b3174585c54a80d2455a8394", size = 223605, upload-time = "2026-08-26T14:57:48.175Z" }, + { url = "https://files.pythonhosted.org/packages/b9/69/fc1555bff884de363f1bf9eebf2836dbeb29fa7e4f957debb7bbcf43abba/websockets-17.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:01dcb47deebc40b38fd4a493b9b9f4d0a704b7bec6f35e4d34085b329abce71a", size = 224508, upload-time = "2026-08-26T14:57:49.407Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/648d4e68621688b19093b06f7b497d520952e68cdea1c1b54371fe9491de/websockets-17.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f4c45ee2512d3757b5e6c67c5a34e435143f2ecb7df3324f9fd888688c45c0f4", size = 224767, upload-time = "2026-08-26T14:57:50.799Z" }, + { url = "https://files.pythonhosted.org/packages/58/93/f8342b55864f71df13eb8e9ef7dce691b87a87f04f75bb8a1385b3336e7c/websockets-17.1-cp315-cp315-win32.whl", hash = "sha256:0f4f50dfe2cc810fc4e2de979b35e83bf8bb4bccdc6fe472d93762ea7b1d5927", size = 217003, upload-time = "2026-08-26T14:57:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f0/7b5fdb774c245e0b6217009e2a24d2105c1a64923949f33be41aa7959302/websockets-17.1-cp315-cp315-win_amd64.whl", hash = "sha256:4af784f3e436f65b355c117c6497320f2b5cf6a559295cb1c4c7338e335d45cc", size = 217300, upload-time = "2026-08-26T14:57:53.492Z" }, + { url = "https://files.pythonhosted.org/packages/76/33/1fe6ed1b5087516115ca451b2c240314b010647071f8fc3bd78a21e4dddb/websockets-17.1-cp315-cp315-win_arm64.whl", hash = "sha256:d58159af7835fde09c462394293c0d7aaf8fb4557d8f8e5699f5e722ccae013d", size = 217214, upload-time = "2026-08-26T14:57:54.88Z" }, + { url = "https://files.pythonhosted.org/packages/94/ca/ed02e75996a266d76c5fcb5dd9b930db4cf2b388ca5fa3d2a72086f81568/websockets-17.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1a5cf4e7bbe3ca499e6a289206cb4fcb7444b09919e129bd517f57d5fa192c13", size = 217282, upload-time = "2026-08-26T14:57:56.108Z" }, + { url = "https://files.pythonhosted.org/packages/bd/7d/d536f5bc89ea5b52fd1c1727c59fabafee6bc41f5ce92c3bd2f83047908c/websockets-17.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:416b4bc8789a1865a3ff643ec4ee073a5f52402d0dbeafd27b1798d5dd6b6a51", size = 214863, upload-time = "2026-08-26T14:57:57.355Z" }, + { url = "https://files.pythonhosted.org/packages/37/37/944cf17bad668e9be1247e6314f88a48b9faf7c250e383410db8b38af0b9/websockets-17.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:259f45358c76d3b18489e3e80636cdbe807e05ecf1b10fdf1a779106d23d0c8e", size = 215073, upload-time = "2026-08-26T14:57:58.719Z" }, + { url = "https://files.pythonhosted.org/packages/74/bf/3267966cc1bbc2b8fa62fd329651b0af502df1f5d1c0eed027ff339d6aa8/websockets-17.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9d01e8ede41fea4f5a847dad9d628355f74905f437a5b6856d67aa66d193800", size = 225229, upload-time = "2026-08-26T14:58:00.235Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d8/85ea722f483510abb39fc71aafb4465d17cf9051a275ab036874ff3c300c/websockets-17.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7b35181a14cbfcae163b4de545d22abfd07d06c2c41ca69cfcd99251d6888ab", size = 225500, upload-time = "2026-08-26T14:58:01.994Z" }, + { url = "https://files.pythonhosted.org/packages/50/ce/64c7d00005bd0d15ecb5c5fcb7fb2597b6b92ddd16c4fa6bbc3d2835ad63/websockets-17.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a8e768a048c2220697477ce2e67e4345dc9f693d0ee6af53945b5e30227c6a7", size = 226829, upload-time = "2026-08-26T14:58:03.327Z" }, + { url = "https://files.pythonhosted.org/packages/b4/dc/096c67940fb957e667ca3c542818150434eb0388c6fdc90b3a502f3c3e96/websockets-17.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:880069d21cc33a558dcf180924a546d1ecf8ada5be3e4e70acee87019d706a24", size = 228457, upload-time = "2026-08-26T14:58:04.78Z" }, + { url = "https://files.pythonhosted.org/packages/51/fe/f2331b6b7ccc67589891da354fa46a5cb79e95f83b9fd0e734d77f1f2140/websockets-17.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cec1bb8f22abccc8d20f8ca63df9be41600c26c190f4b97ee86c675fd4a863a6", size = 227265, upload-time = "2026-08-26T14:58:06.102Z" }, + { url = "https://files.pythonhosted.org/packages/47/a5/fb1642302f8ec77ca922203074f155a9831a5128ad75e725059a476d1227/websockets-17.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f3a1d577e081667dda7f8e5b4796e6e32f9713c93e2a3d930669519840a3c623", size = 226143, upload-time = "2026-08-26T14:58:07.464Z" }, + { url = "https://files.pythonhosted.org/packages/d7/41/7133fcfb63f5562750b269d6a845c689dde6a2c6407286da395beea19ddd/websockets-17.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc053f9e95a76213c5eb7ed95779f7daf0d2bf0e4e03073629ebfa43a033f151", size = 223501, upload-time = "2026-08-26T14:58:08.766Z" }, + { url = "https://files.pythonhosted.org/packages/64/b1/82b36bfabc79ff2d383a1fc043cee6a13f794ef4f6bf1b4810ad6988cf6f/websockets-17.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:bb0efe019480a1c93e168ce96479273aaebd672fc8c350d5eed1e507ababb1b8", size = 226330, upload-time = "2026-08-26T14:58:09.987Z" }, + { url = "https://files.pythonhosted.org/packages/41/7d/5b511b9bf6e9ad331e6ff902fcbcc71c3794d10ef3b5efe80ccb8f0a7861/websockets-17.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:615746b12b26a3fd4077bc6fbeb277a1c192a45dd57b531d07ad9ed5c52a9a7a", size = 224980, upload-time = "2026-08-26T14:58:11.303Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/aed08f25301f8eef23be903ff9319fcf35630ca2bdec9d226f7d804dd5b3/websockets-17.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:1a20136d61f9ca3a31493732762661fafc2c20e8861930214e21afc6a8a692a2", size = 225478, upload-time = "2026-08-26T14:58:12.543Z" }, + { url = "https://files.pythonhosted.org/packages/3e/47/0d63d4168536b4682c9d19b7399443b1176f25dbb68878374fa716670230/websockets-17.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:2786cbd273ab69c22612db8a41229ddf2c158060b17b5928884bf388d07887f3", size = 226588, upload-time = "2026-08-26T14:58:14.457Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/844bd0b6386fc81ed6a55f4b6dd26f01c6987eda205afa10175ea12b2164/websockets-17.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b1c323fc3be1dc3f87f6c59458cb7d9e13dcbbf971d6c3f3e2bbaf58d3bfcdfe", size = 224336, upload-time = "2026-08-26T14:58:15.778Z" }, + { url = "https://files.pythonhosted.org/packages/96/18/03709c84bc88ec4dcea68d4be4ccd07d611073dec111203a5bf45af8809d/websockets-17.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:12c8e2b25df59755954a04dfa09c990b96691025aaf7eafd19ed6da24b09c18d", size = 225197, upload-time = "2026-08-26T14:58:17.141Z" }, + { url = "https://files.pythonhosted.org/packages/27/cf/0d1c694b6466c89e875b85b32b51312c472cf6708eee91914866f5087dde/websockets-17.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f58f58b4b29bbea2a3635e2c56eff4a3adab011fe383802a9e542e31b97085fc", size = 225493, upload-time = "2026-08-26T14:58:18.521Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f5/99857c3dd9676749f33e3668665a34ad6099505fb8d75eb084f49f7807a9/websockets-17.1-cp315-cp315t-win32.whl", hash = "sha256:f78a3ffb1994304db2c0c4588e4d1a518079b557054fa3bb985a6f5e50ff49a3", size = 217130, upload-time = "2026-08-26T14:58:20.037Z" }, + { url = "https://files.pythonhosted.org/packages/2c/84/77599922ab441bfe61508f97dab2c71f8e114d31793993ea54011db16199/websockets-17.1-cp315-cp315t-win_amd64.whl", hash = "sha256:ad68c28a27246fed109a4409393d677b7e1388345cbbd2f5aee5c182d8506110", size = 217448, upload-time = "2026-08-26T14:58:21.382Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3c/8b9a225b523f06a9389be81f1b0ab07c49bec6014742e6aa359c1f920f1f/websockets-17.1-cp315-cp315t-win_arm64.whl", hash = "sha256:e552e0037230ac16e5f568de7012041344d1b18c9feed30ec2891b8eba55af81", size = 217372, upload-time = "2026-08-26T14:58:22.807Z" }, + { url = "https://files.pythonhosted.org/packages/41/63/23572870e01836a98346075b9e17a8bc24a6ddd9800a3204ceee58677f3c/websockets-17.1-py3-none-any.whl", hash = "sha256:f221081107b8c48184d99f7019604486376e7ef826037e70aad6b02540732c23", size = 211134, upload-time = "2026-08-26T17:25:31.397Z" }, +] + [[package]] name = "werkzeug" version = "3.1.8" @@ -474,3 +958,40 @@ sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd1 wheels = [ { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, ] + +[[package]] +name = "yt-dlp" +version = "2026.8.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/e0/832fa4ca334b766a06933a196066edc3dba37cdb6f14cd98d59bcc69a4b4/yt_dlp-2026.8.19.tar.gz", hash = "sha256:9e213e48cea35c66b378e4447903f118f6392a5fa380a2b6d7070ec86f4e0af1", size = 3052025, upload-time = "2026-08-19T23:48:59.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/8cd1613f56eed7ceb64fbd4df3f1c01246bfb098e6f398228bafda22b80b/yt_dlp-2026.8.19-py3-none-any.whl", hash = "sha256:1d57897e94c6665a0a6f9bc54b34e584284e32c034ffab3a7df25d8f7b24eedf", size = 3185533, upload-time = "2026-08-19T23:48:56.925Z" }, +] + +[package.optional-dependencies] +curl-cffi = [ + { name = "curl-cffi", marker = "implementation_name == 'cpython'" }, +] +default = [ + { name = "brotli", marker = "implementation_name == 'cpython' and sys_platform != 'ios'" }, + { name = "brotlicffi", marker = "implementation_name != 'cpython'" }, + { name = "certifi" }, + { name = "mutagen" }, + { name = "pycryptodomex" }, + { name = "requests" }, + { name = "urllib3" }, + { name = "websockets" }, + { name = "yt-dlp-ejs" }, +] +deno = [ + { name = "deno" }, +] + +[[package]] +name = "yt-dlp-ejs" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/e6/cceb9530e8f4e5940f6f7822d90e9d94f1b85343329a16baaf47bbbb3de1/yt_dlp_ejs-0.8.0.tar.gz", hash = "sha256:d5fa1639f63b5c4af8d932495f60689d5370f1a095782c944f7f62a303eb104e", size = 96571, upload-time = "2026-03-17T22:49:19.299Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/bd/520769863744b669440a924271a6159ddd82ad5ae26b4ac4d4b69e9f8d44/yt_dlp_ejs-0.8.0-py3-none-any.whl", hash = "sha256:79300e5fca7f937a1eeede11f0456862c1b41107ce1d726871e0207424f4bdb4", size = 53443, upload-time = "2026-03-17T22:49:17.736Z" }, +] diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..18b00bc --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1 @@ +API_URL=http://localhost:8081 diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 5ef6a52..7b8da95 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs index 05e726d..7e3f376 100644 --- a/apps/web/eslint.config.mjs +++ b/apps/web/eslint.config.mjs @@ -13,6 +13,14 @@ const eslintConfig = defineConfig([ "build/**", "next-env.d.ts", ]), + { + files: [ + "src/components/queue/Thumbnail.tsx", + "src/app/apple-icon.tsx", + "src/app/pwa-icon/**", + ], + rules: { "@next/next/no-img-element": "off" }, + }, ]); export default eslintConfig; diff --git a/apps/web/mise.toml b/apps/web/mise.toml index b08ffef..b088651 100644 --- a/apps/web/mise.toml +++ b/apps/web/mise.toml @@ -43,3 +43,7 @@ run = [ [tasks.checklist] run = [{ task = ":ci-unit" }, { task = ":build" }] + +[tasks.dev] +env = { API_URL = "http://localhost:8081" } +run = "pnpm exec next dev --port 3000" diff --git a/apps/web/package.json b/apps/web/package.json index 1934334..8e80006 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,17 +9,23 @@ "lint": "eslint" }, "dependencies": { + "@phosphor-icons/react": "^2.1.10", "next": "16.3.5", "react": "19.2.8", "react-dom": "19.2.8" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitejs/plugin-react": "^6.1.1", "eslint": "^9", "eslint-config-next": "16.3.5", + "jsdom": "^30.0.1", "prettier": "^3.9.6", "tailwindcss": "^4", "typescript": "^5", diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index 03535dd..7ec7cd6 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@phosphor-icons/react': + specifier: ^2.1.10 + version: 2.1.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8) next: specifier: 16.3.5 version: 16.3.5(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@20.19.43)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -21,6 +24,15 @@ importers: '@tailwindcss/postcss': specifier: ^4 version: 4.3.3 + '@testing-library/jest-dom': + specifier: ^7.0.1 + version: 7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.0(@types/node@20.19.43)(jsdom@30.0.1)(vite@8.3.0(@types/node@20.19.43)(jiti@2.7.0))) + '@testing-library/react': + specifier: ^16.3.3 + version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/user-event': + specifier: ^14.6.7 + version: 14.6.7(@testing-library/dom@10.4.1) '@types/node': specifier: ^20 version: 20.19.43 @@ -30,12 +42,18 @@ importers: '@types/react-dom': specifier: ^19 version: 19.3.0(@types/react@19.3.0) + '@vitejs/plugin-react': + specifier: ^6.1.1 + version: 6.1.1(vite@8.3.0(@types/node@20.19.43)(jiti@2.7.0)) eslint: specifier: ^9 version: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) eslint-config-next: specifier: 16.3.5 version: 16.3.5(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + jsdom: + specifier: ^30.0.1 + version: 30.0.1 prettier: specifier: ^3.9.6 version: 3.9.6 @@ -47,14 +65,25 @@ importers: version: 5.9.3 vitest: specifier: ^5.0.0 - version: 5.0.0(@types/node@20.19.43)(vite@8.3.0(@types/node@20.19.43)(jiti@2.7.0)) + version: 5.0.0(@types/node@20.19.43)(jsdom@30.0.1)(vite@8.3.0(@types/node@20.19.43)(jiti@2.7.0)) packages: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@alloc/quick-lru@5.3.0': resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==} engines: {node: '>=10'} + '@asamuzakjp/css-color@6.0.7': + resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@8.3.2': + resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==} + engines: {node: ^22.13.0 || >=24.0.0} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -110,6 +139,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -122,6 +155,46 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.1.1': + resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.2.2': + resolution: {integrity: sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.13': + resolution: {integrity: sha512-i9ZylF5QNhmNfPA9l0vHAWK4kPrbIp6g9lKgaiIFsIBz2F/WNB7OLrzlNNcCOm+h42bkaSD2v1PG+IBPHhc3ZA==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -178,6 +251,15 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -460,6 +542,13 @@ packages: '@oxc-project/types@0.149.0': resolution: {integrity: sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==} + '@phosphor-icons/react@2.1.10': + resolution: {integrity: sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==} + engines: {node: '>=10'} + peerDependencies: + react: '>= 16.8' + react-dom: '>= 16.8' + '@rolldown/binding-android-arm-eabi@1.2.8': resolution: {integrity: sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -657,9 +746,47 @@ packages: '@tailwindcss/postcss@4.3.3': resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@7.0.1': + resolution: {integrity: sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + peerDependencies: + '@testing-library/dom': '>=10 <11' + vitest: '>= 0.32' + peerDependenciesMeta: + vitest: + optional: true + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.7': + resolution: {integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -865,6 +992,22 @@ packages: cpu: [x64] os: [win32] + '@vitejs/plugin-react@6.1.1': + resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.145.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true + '@vitest/mocker@5.0.0': resolution: {integrity: sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==} peerDependencies: @@ -892,13 +1035,24 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -970,6 +1124,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} + brace-expansion@1.1.18: resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} @@ -1033,12 +1190,23 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -1068,6 +1236,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1079,6 +1250,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1087,6 +1262,12 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1101,6 +1282,10 @@ packages: resolution: {integrity: sha512-ghq3mhs649mvbarTCAlZn2wRhbfHmzAFiKxoWA14B3VtqnxtZt+wz8BroKXU0tF3GiJsnUldjVncQGZ8qk4rdA==} engines: {node: '>=10.13.0'} + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + es-abstract-get@1.0.0: resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} engines: {node: '>= 0.4'} @@ -1422,6 +1607,10 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1438,6 +1627,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -1513,6 +1706,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -1570,6 +1766,15 @@ packages: resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true + jsdom@30.0.1: + resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1770,9 +1975,17 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1783,6 +1996,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1791,6 +2007,10 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@10.2.6: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} @@ -1902,6 +2122,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1945,6 +2168,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -1963,10 +2190,17 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react@19.2.8: resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -1975,6 +2209,10 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2011,6 +2249,10 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -2115,6 +2357,10 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -2140,6 +2386,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwindcss@4.3.3: resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} @@ -2159,10 +2408,25 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tldts-core@7.4.12: + resolution: {integrity: sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==} + + tldts@7.4.12: + resolution: {integrity: sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -2214,6 +2478,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@8.10.2: + resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==} + engines: {node: '>=22.19.0'} + unrs-resolver@1.12.2: resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} @@ -2310,6 +2578,26 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + whatwg-url@17.1.1: + resolution: {integrity: sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==} + engines: {node: ^22.14.0 || >=24.0.0} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -2340,6 +2628,13 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -2358,8 +2653,25 @@ packages: snapshots: + '@adobe/css-tools@4.5.0': {} + '@alloc/quick-lru@5.3.0': {} + '@asamuzakjp/css-color@6.0.7': + dependencies: + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 + + '@asamuzakjp/dom-selector@8.3.2': + dependencies: + bidi-js: 1.1.0 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -2437,6 +2749,8 @@ snapshots: dependencies: '@babel/types': 7.29.8 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -2460,6 +2774,34 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.1': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.13(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -2532,6 +2874,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@exodus/bytes@1.15.1': {} + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -2730,6 +3074,11 @@ snapshots: '@oxc-project/types@0.149.0': {} + '@phosphor-icons/react@2.1.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + '@rolldown/binding-android-arm-eabi@1.2.8': optional: true @@ -2852,11 +3201,50 @@ snapshots: postcss: 8.5.28 tailwindcss: 4.3.3 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.0(@types/node@20.19.43)(jsdom@30.0.1)(vite@8.3.0(@types/node@20.19.43)(jiti@2.7.0)))': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.1 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + optionalDependencies: + vitest: 5.0.0(@types/node@20.19.43)(jsdom@30.0.1)(vite@8.3.0(@types/node@20.19.43)(jiti@2.7.0)) + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@testing-library/user-event@14.6.7(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -3043,6 +3431,11 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true + '@vitejs/plugin-react@6.1.1(vite@8.3.0(@types/node@20.19.43)(jiti@2.7.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.3.0(@types/node@20.19.43)(jiti@2.7.0) + '@vitest/mocker@5.0.0(vite@8.3.0(@types/node@20.19.43)(jiti@2.7.0))': dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -3067,12 +3460,20 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-regex@5.0.1: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -3162,6 +3563,10 @@ snapshots: baseline-browser-mapping@2.11.23: {} + bidi-js@1.1.0: + dependencies: + require-from-string: 2.0.2 + brace-expansion@1.1.18: dependencies: balanced-match: 1.0.2 @@ -3229,10 +3634,24 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + csstype@3.2.3: {} damerau-levenshtein@1.0.8: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -3263,6 +3682,8 @@ snapshots: optionalDependencies: supports-color: 7.2.0 + decimal.js@10.6.0: {} + deep-is@0.1.4: {} define-data-property@1.1.4: @@ -3277,12 +3698,18 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + dequal@2.0.3: {} + detect-libc@2.1.2: {} doctrine@2.1.0: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -3298,6 +3725,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + entities@8.1.0: {} + es-abstract-get@1.0.0: dependencies: es-errors: 1.3.0 @@ -3772,6 +4201,12 @@ snapshots: dependencies: hermes-estree: 0.25.1 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + ignore@5.3.2: {} ignore@7.0.9: {} @@ -3783,6 +4218,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -3866,6 +4303,8 @@ snapshots: is-number@7.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -3926,6 +4365,32 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@30.0.1: + dependencies: + '@asamuzakjp/css-color': 6.0.7 + '@asamuzakjp/dom-selector': 8.3.2 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.13(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 8.10.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.1.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -4070,10 +4535,14 @@ snapshots: dependencies: js-tokens: 4.0.0 + lru-cache@11.5.2: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -4084,6 +4553,8 @@ snapshots: math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -4091,6 +4562,8 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + min-indent@1.0.1: {} + minimatch@10.2.6: dependencies: brace-expansion: 5.0.9 @@ -4215,6 +4688,10 @@ snapshots: dependencies: callsites: 3.1.0 + parse5@8.0.1: + dependencies: + entities: 8.1.0 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -4245,6 +4722,12 @@ snapshots: prettier@3.9.6: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -4262,8 +4745,15 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react@19.2.8: {} + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.9 @@ -4284,6 +4774,8 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -4343,6 +4835,10 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} @@ -4507,6 +5003,10 @@ snapshots: strip-bom@3.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@3.1.1: {} styled-jsx@5.1.6(@babel/core@7.29.7(supports-color@7.2.0))(react@19.2.8): @@ -4522,6 +5022,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + tailwindcss@4.3.3: {} tapable@2.3.3: {} @@ -4535,10 +5037,24 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 + tldts-core@7.4.12: {} + + tldts@7.4.12: + dependencies: + tldts-core: 7.4.12 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.12 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -4611,6 +5127,8 @@ snapshots: undici-types@6.21.0: {} + undici@8.10.2: {} + unrs-resolver@1.12.2: dependencies: napi-postinstall: 0.3.4 @@ -4660,7 +5178,7 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 - vitest@5.0.0(@types/node@20.19.43)(vite@8.3.0(@types/node@20.19.43)(jiti@2.7.0)): + vitest@5.0.0(@types/node@20.19.43)(jsdom@30.0.1)(vite@8.3.0(@types/node@20.19.43)(jiti@2.7.0)): dependencies: '@types/chai': 5.2.3 '@vitest/mocker': 5.0.0(vite@8.3.0(@types/node@20.19.43)(jiti@2.7.0)) @@ -4678,9 +5196,34 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.19.43 + jsdom: 30.0.1 transitivePeerDependencies: - msw + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + whatwg-url@17.1.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -4733,6 +5276,10 @@ snapshots: word-wrap@1.2.5: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/apps/web/public/file.svg b/apps/web/public/file.svg deleted file mode 100644 index 004145c..0000000 --- a/apps/web/public/file.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/web/public/globe.svg b/apps/web/public/globe.svg deleted file mode 100644 index 567f17b..0000000 --- a/apps/web/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/web/public/next.svg b/apps/web/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/apps/web/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/web/public/vercel.svg b/apps/web/public/vercel.svg deleted file mode 100644 index 7705396..0000000 --- a/apps/web/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/web/public/window.svg b/apps/web/public/window.svg deleted file mode 100644 index b2b2a44..0000000 --- a/apps/web/public/window.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/web/src/app/OpenMediaClient.tsx b/apps/web/src/app/OpenMediaClient.tsx new file mode 100644 index 0000000..02c2c1b --- /dev/null +++ b/apps/web/src/app/OpenMediaClient.tsx @@ -0,0 +1,25 @@ +"use client"; + +import dynamic from "next/dynamic"; +import type { ReactNode } from "react"; + +const ClientApp = dynamic( + async () => { + const [{ StoreProvider }, { AppShell }] = await Promise.all([ + import("@/state/StoreProvider"), + import("@/components/shell/AppShell"), + ]); + return function OpenMediaApp(): ReactNode { + return ( + + + + ); + }; + }, + { ssr: false }, +); + +export function OpenMediaClient(): ReactNode { + return ; +} diff --git a/apps/web/src/app/api/[...path]/proxy.test.ts b/apps/web/src/app/api/[...path]/proxy.test.ts new file mode 100644 index 0000000..58f91a2 --- /dev/null +++ b/apps/web/src/app/api/[...path]/proxy.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + buildUpstreamUrl, + forwardedRequestHeaders, + MAX_REQUEST_BYTES, + proxyToApi, +} from './proxy'; + +function streamOf(size: number): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(size)); + controller.close(); + }, + }); +} + +describe('api proxy', () => { + it('maps the path and query onto the API base url', () => { + const url = buildUpstreamUrl( + 'http://localhost:8080/api/status/abc?x=1', + ['status', 'abc'], + 'http://api:8080', + ); + expect(url.toString()).toBe('http://api:8080/api/status/abc?x=1'); + }); + + it('forwards host and protocol and drops hop-by-hop headers', () => { + const request = new Request('http://media.local:8080/api/download', { + method: 'POST', + headers: { + host: 'media.local:8080', + connection: 'keep-alive', + cookie: 'openmedia_session=1', + origin: 'http://media.local:8080', + }, + }); + const headers = forwardedRequestHeaders(request); + expect(headers.get('x-forwarded-host')).toBe('media.local:8080'); + expect(headers.get('x-forwarded-proto')).toBe('http'); + expect(headers.get('cookie')).toBe('openmedia_session=1'); + expect(headers.get('connection')).toBeNull(); + expect(headers.get('host')).toBeNull(); + }); + + it('streams the upstream response and keeps every set-cookie', async () => { + const upstreamHeaders = new Headers({ 'content-type': 'application/json' }); + upstreamHeaders.append('set-cookie', 'a=1; Path=/'); + upstreamHeaders.append('set-cookie', 'b=2; Path=/'); + const fetchImpl = vi + .fn() + .mockResolvedValue( + new Response('{"ok":true}', { status: 201, headers: upstreamHeaders }), + ); + const response = await proxyToApi( + new Request('http://localhost/api/session', { + method: 'POST', + body: '{}', + }), + ['session'], + 'http://api:8080', + fetchImpl, + ); + expect(response.status).toBe(201); + expect(response.headers.getSetCookie()).toEqual([ + 'a=1; Path=/', + 'b=2; Path=/', + ]); + expect(await response.text()).toBe('{"ok":true}'); + expect(fetchImpl.mock.calls[0][1].method).toBe('POST'); + }); + + it('answers 502 when the API is down', async () => { + const fetchImpl = vi + .fn() + .mockRejectedValue(new TypeError('connect ECONNREFUSED')); + const response = await proxyToApi( + new Request('http://localhost/api/jobs'), + ['jobs'], + 'http://api:8080', + fetchImpl, + ); + expect(response.status).toBe(502); + expect(await response.json()).toEqual({ + error: 'The OpenMedia API is not reachable.', + code: 'api_unreachable', + }); + }); + + it('rejects a declared body larger than the API accepts without calling it', async () => { + const fetchImpl = vi.fn(); + const response = await proxyToApi( + new Request('http://localhost/api/cookies', { + method: 'PUT', + headers: { 'content-length': String(MAX_REQUEST_BYTES + 1) }, + body: 'x', + }), + ['cookies'], + 'http://api:8080', + fetchImpl, + ); + expect(response.status).toBe(413); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('rejects an undeclared body once it grows past the limit', async () => { + const fetchImpl = vi.fn(); + const response = await proxyToApi( + new Request('http://localhost/api/cookies', { + method: 'PUT', + body: streamOf(MAX_REQUEST_BYTES + 1), + duplex: 'half', + } as RequestInit), + ['cookies'], + 'http://api:8080', + fetchImpl, + ); + expect(response.status).toBe(413); + expect(await response.json()).toMatchObject({ + code: 'request_entity_too_large', + }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('forwards a body within the limit and the abort signal', async () => { + const fetchImpl = vi.fn().mockResolvedValue(new Response(null)); + const request = new Request('http://localhost/api/info', { + method: 'POST', + body: streamOf(MAX_REQUEST_BYTES), + duplex: 'half', + } as RequestInit); + await proxyToApi(request, ['info'], 'http://api:8080', fetchImpl); + const init = fetchImpl.mock.calls[0][1]; + expect(new Blob([init.body]).size).toBe(MAX_REQUEST_BYTES); + expect(init.signal).toBe(request.signal); + }); + + it('keeps the upstream content length only for unencoded bodies', async () => { + const plain = await proxyToApi( + new Request('http://localhost/api/file/j'), + ['file', 'j'], + 'http://api:8080', + vi + .fn() + .mockResolvedValue( + new Response('abc', { headers: { 'content-length': '3' } }), + ), + ); + expect(plain.headers.get('content-length')).toBe('3'); + const encoded = await proxyToApi( + new Request('http://localhost/api/file/j'), + ['file', 'j'], + 'http://api:8080', + vi.fn().mockResolvedValue( + new Response('abc', { + headers: { 'content-length': '3', 'content-encoding': 'gzip' }, + }), + ), + ); + expect(encoded.headers.get('content-length')).toBeNull(); + }); +}); diff --git a/apps/web/src/app/api/[...path]/proxy.ts b/apps/web/src/app/api/[...path]/proxy.ts new file mode 100644 index 0000000..0bb707a --- /dev/null +++ b/apps/web/src/app/api/[...path]/proxy.ts @@ -0,0 +1,128 @@ +const HOP_BY_HOP_HEADERS = new Set([ + 'connection', + 'keep-alive', + 'proxy-connection', + 'transfer-encoding', + 'upgrade', + 'te', + 'trailer', + 'host', + 'content-length', +]); +const METHODS_WITHOUT_BODY = new Set(['GET', 'HEAD']); +export const MAX_REQUEST_BYTES = 2 * 1024 * 1024; + +export function buildUpstreamUrl( + requestUrl: string, + path: readonly string[], + apiBaseUrl: string, +): URL { + const upstream = new URL( + `/api/${path.map(encodeURIComponent).join('/')}`, + apiBaseUrl, + ); + upstream.search = new URL(requestUrl).search; + return upstream; +} + +export function forwardedRequestHeaders(request: Request): Headers { + const incoming = new URL(request.url); + const headers = new Headers(); + request.headers.forEach((value, key) => { + if (!HOP_BY_HOP_HEADERS.has(key)) headers.set(key, value); + }); + headers.set('x-forwarded-host', request.headers.get('host') ?? incoming.host); + headers.set( + 'x-forwarded-proto', + request.headers.get('x-forwarded-proto') ?? + incoming.protocol.replace(':', ''), + ); + return headers; +} + +function responseHeaders(upstream: Response): Headers { + const headers = new Headers(); + upstream.headers.forEach((value, key) => { + if (!HOP_BY_HOP_HEADERS.has(key) && key !== 'set-cookie') + headers.set(key, value); + }); + upstream.headers + .getSetCookie() + .forEach((cookie) => headers.append('set-cookie', cookie)); + const contentLength = upstream.headers.get('content-length'); + if (contentLength !== null && !upstream.headers.has('content-encoding')) + headers.set('content-length', contentLength); + return headers; +} + +function declaresOversizedBody(request: Request): boolean { + return Number(request.headers.get('content-length')) > MAX_REQUEST_BYTES; +} + +async function readLimitedBody( + body: ReadableStream, +): Promise { + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let received = 0; + for ( + let chunk = await reader.read(); + !chunk.done; + chunk = await reader.read() + ) { + received += chunk.value.byteLength; + if (received > MAX_REQUEST_BYTES) { + await reader.cancel(); + return null; + } + chunks.push(new Uint8Array(chunk.value)); + } + return new Blob(chunks); +} + +function payloadTooLarge(): Response { + return Response.json( + { + error: 'The request is larger than the API accepts.', + code: 'request_entity_too_large', + }, + { status: 413 }, + ); +} + +export async function proxyToApi( + request: Request, + path: readonly string[], + apiBaseUrl: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const incoming = METHODS_WITHOUT_BODY.has(request.method) + ? null + : request.body; + if (incoming && declaresOversizedBody(request)) return payloadTooLarge(); + const body = incoming ? await readLimitedBody(incoming) : undefined; + if (body === null) return payloadTooLarge(); + try { + const upstream = await fetchImpl( + buildUpstreamUrl(request.url, path, apiBaseUrl), + { + method: request.method, + headers: forwardedRequestHeaders(request), + body, + signal: request.signal, + redirect: 'manual', + cache: 'no-store', + }, + ); + return new Response(upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers: responseHeaders(upstream), + }); + } catch { + return Response.json( + { error: 'The OpenMedia API is not reachable.', code: 'api_unreachable' }, + { status: 502 }, + ); + } +} diff --git a/apps/web/src/app/api/[...path]/route.ts b/apps/web/src/app/api/[...path]/route.ts new file mode 100644 index 0000000..70e695d --- /dev/null +++ b/apps/web/src/app/api/[...path]/route.ts @@ -0,0 +1,22 @@ +import { proxyToApi } from './proxy'; + +export const dynamic = 'force-dynamic'; + +const DEFAULT_API_URL = 'http://localhost:8081'; + +type ProxyContext = { params: Promise<{ path: string[] }> }; + +async function handle( + request: Request, + context: ProxyContext, +): Promise { + const { path } = await context.params; + return proxyToApi(request, path, process.env.API_URL ?? DEFAULT_API_URL); +} + +export const GET = handle; +export const HEAD = handle; +export const POST = handle; +export const PUT = handle; +export const PATCH = handle; +export const DELETE = handle; diff --git a/apps/web/src/app/apple-icon.tsx b/apps/web/src/app/apple-icon.tsx new file mode 100644 index 0000000..194818b --- /dev/null +++ b/apps/web/src/app/apple-icon.tsx @@ -0,0 +1,13 @@ +import { ImageResponse } from "next/og"; +import { BRAND_INK, BRAND_MIST, BRAND_TEAL, brandSvg } from "@/lib/brand"; + +export const size = { width: 180, height: 180 }; +export const contentType = "image/png"; + +export default function AppleIcon(): ImageResponse { + const source = `data:image/svg+xml;base64,${Buffer.from(brandSvg({ frame: BRAND_MIST, wave: BRAND_TEAL, background: BRAND_INK })).toString("base64")}`; + return new ImageResponse( + , + size, + ); +} diff --git a/apps/web/src/app/favicon.ico b/apps/web/src/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/apps/web/src/app/favicon.ico and /dev/null differ diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index a2dc41e..58c0671 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1,26 +1,508 @@ @import "tailwindcss"; :root { - --background: #ffffff; - --foreground: #171717; + color-scheme: light; + --font-text: + -apple-system, BlinkMacSystemFont, "SF Pro Text", var(--font-inter), + "Segoe UI Variable Text", system-ui, sans-serif; + --font-display: + -apple-system, BlinkMacSystemFont, "SF Pro Display", var(--font-inter), + "Segoe UI Variable Display", system-ui, sans-serif; + --font-mono: + ui-monospace, "SF Mono", var(--font-geist-mono), Menlo, Consolas, monospace; + --font-brand: var(--font-geist-sans), var(--font-text); + + --accent-l: #12939c; + --accent-d: #3fbac2; + --accent-text-l: #0b7178; + --accent-text-d: #5cc9d0; + + --window: #f5f5f7; + --content: #fbfbfd; + --elevated: #fefefe; + --segment-thumb: #fefefe; + --grouped: #f2f2f7; + --cell: #fdfdfe; + --label: #1d1d1f; + --secondary: #6e6e73; + --tertiary: #8e8e93; + --separator: rgb(60 60 67 / 0.13); + --fill: rgb(120 120 128 / 0.12); + --fill-strong: rgb(120 120 128 / 0.2); + --fill-hover: rgb(120 120 128 / 0.08); + --glass: rgb(250 250 252 / 0.7); + --glass-strong: rgb(252 252 253 / 0.86); + --glass-edge: rgb(255 255 255 / 0.65); + --glass-line: rgb(0 0 0 / 0.06); + --shadow-soft: 0 1px 1px rgb(0 0 0 / 0.03), 0 4px 14px rgb(0 0 0 / 0.05); + --shadow-float: 0 1px 2px rgb(0 0 0 / 0.05), 0 12px 40px rgb(0 0 0 / 0.12); + --shadow-thumb: 0 1px 2px rgb(0 0 0 / 0.08), 0 3px 8px rgb(0 0 0 / 0.06); + --scrim: rgb(0 0 0 / 0.22); + --hud: rgb(38 38 40 / 0.82); + --hud-label: #f5f5f7; + + --green: #34c759; + --green-text: #248a3d; + --red: #ff3b30; + --red-text: #d70015; + --orange: #ff9500; + --orange-text: #c93400; + --trim: #ffcc00; + --on-trim: #3a2f00; + --on-accent: #fdfdfe; + + --accent: var(--accent-l); + --accent-text: var(--accent-text-l); + --button: var(--accent-text-l); + --accent-soft: color-mix(in srgb, var(--accent) 14%, transparent); + + --radius-panel: 18px; + --radius-group: 12px; + --radius-artwork: 14px; + --radius-thumb: 7px; + --radius-capsule: 999px; + + --spring-smooth: linear( + 0, + 0.0315, + 0.1057, + 0.2002, + 0.301, + 0.3995, + 0.4911, + 0.5733, + 0.6454, + 0.7075, + 0.7603, + 0.8046, + 0.8414, + 0.8718, + 0.8968, + 0.9172, + 0.9337, + 0.9471, + 0.9579, + 0.9665, + 0.9734, + 0.979, + 0.9834, + 0.9869, + 0.9897, + 0.9919, + 0.9936, + 0.995, + 0.9961, + 0.9969, + 0.9976, + 0.9981, + 0.9985, + 0.9988, + 0.9991, + 0.9993, + 0.9995, + 0.9996, + 0.9997, + 0.9997, + 1 + ); + --spring-snappy: linear( + 0, + 0.0445, + 0.1498, + 0.2831, + 0.4222, + 0.5535, + 0.6692, + 0.7661, + 0.8436, + 0.9032, + 0.947, + 0.9778, + 0.9981, + 1.0105, + 1.0171, + 1.0197, + 1.0196, + 1.018, + 1.0155, + 1.0127, + 1.01, + 1.0076, + 1.0055, + 1.0038, + 1.0024, + 1.0014, + 1.0007, + 1.0002, + 0.9999, + 0.9997, + 0.9996, + 0.9996, + 0.9996, + 0.9997, + 0.9997, + 0.9998, + 0.9998, + 0.9999, + 0.9999, + 0.9999, + 1 + ); + --spring-bouncy: linear( + 0, + 0.0527, + 0.1801, + 0.3433, + 0.5136, + 0.6718, + 0.8066, + 0.913, + 0.9904, + 1.0416, + 1.0706, + 1.0825, + 1.0821, + 1.0737, + 1.0611, + 1.0468, + 1.033, + 1.0208, + 1.0109, + 1.0034, + 0.9982, + 0.995, + 0.9934, + 0.993, + 0.9935, + 0.9944, + 0.9956, + 0.9967, + 0.9978, + 0.9988, + 0.9995, + 1, + 1.0003, + 1.0005, + 1.0006, + 1.0006, + 1.0005, + 1.0004, + 1.0003, + 1.0002, + 1 + ); + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --duration-quick: 0.28s; + --duration-base: 0.5s; + --duration-sheet: 0.62s; + + --tabbar-height: 64px; + --safe-bottom: env(safe-area-inset-bottom, 0px); + --safe-top: env(safe-area-inset-top, 0px); } -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); +@supports not (transition-timing-function: linear(0, 1)) { + :root { + --spring-smooth: cubic-bezier(0.22, 1, 0.36, 1); + --spring-snappy: cubic-bezier(0.34, 1.3, 0.64, 1); + --spring-bouncy: cubic-bezier(0.34, 1.56, 0.64, 1); + } } @media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; + :root:not([data-theme="light"]) { + color-scheme: dark; + --window: #1e1e20; + --content: #161618; + --elevated: #2c2c2e; + --segment-thumb: #5b5b60; + --grouped: #111113; + --cell: #1c1c1e; + --label: #f5f5f7; + --secondary: #a1a1a6; + --tertiary: #8e8e93; + --separator: rgb(84 84 88 / 0.5); + --fill: rgb(120 120 128 / 0.24); + --fill-strong: rgb(120 120 128 / 0.34); + --fill-hover: rgb(120 120 128 / 0.16); + --glass: rgb(34 34 36 / 0.66); + --glass-strong: rgb(40 40 42 / 0.86); + --glass-edge: rgb(255 255 255 / 0.1); + --glass-line: rgb(255 255 255 / 0.06); + --shadow-soft: 0 1px 1px rgb(0 0 0 / 0.3), 0 4px 14px rgb(0 0 0 / 0.25); + --shadow-float: 0 1px 2px rgb(0 0 0 / 0.4), 0 16px 48px rgb(0 0 0 / 0.45); + --shadow-thumb: 0 1px 2px rgb(0 0 0 / 0.4), 0 3px 8px rgb(0 0 0 / 0.3); + --scrim: rgb(0 0 0 / 0.5); + --green: #30d158; + --green-text: #30d158; + --red: #ff453a; + --red-text: #ff6961; + --orange: #ff9f0a; + --orange-text: #ffb340; + --trim: #ffd60a; + --accent: var(--accent-d); + --accent-text: var(--accent-text-d); + } +} + +:root[data-theme="dark"] { + color-scheme: dark; + --window: #1e1e20; + --content: #161618; + --elevated: #2c2c2e; + --segment-thumb: #5b5b60; + --grouped: #111113; + --cell: #1c1c1e; + --label: #f5f5f7; + --secondary: #a1a1a6; + --tertiary: #8e8e93; + --separator: rgb(84 84 88 / 0.5); + --fill: rgb(120 120 128 / 0.24); + --fill-strong: rgb(120 120 128 / 0.34); + --fill-hover: rgb(120 120 128 / 0.16); + --glass: rgb(34 34 36 / 0.66); + --glass-strong: rgb(40 40 42 / 0.86); + --glass-edge: rgb(255 255 255 / 0.1); + --glass-line: rgb(255 255 255 / 0.06); + --shadow-soft: 0 1px 1px rgb(0 0 0 / 0.3), 0 4px 14px rgb(0 0 0 / 0.25); + --shadow-float: 0 1px 2px rgb(0 0 0 / 0.4), 0 16px 48px rgb(0 0 0 / 0.45); + --shadow-thumb: 0 1px 2px rgb(0 0 0 / 0.4), 0 3px 8px rgb(0 0 0 / 0.3); + --scrim: rgb(0 0 0 / 0.5); + --green: #30d158; + --green-text: #30d158; + --red: #ff453a; + --red-text: #ff6961; + --orange: #ff9f0a; + --orange-text: #ffb340; + --trim: #ffd60a; + --accent: var(--accent-d); + --accent-text: var(--accent-text-d); +} + +:root[data-accent="blue"] { + --accent-l: #007aff; + --accent-d: #0a84ff; + --accent-text-l: #0066cc; + --accent-text-d: #409cff; +} +:root[data-accent="purple"] { + --accent-l: #af52de; + --accent-d: #bf5af2; + --accent-text-l: #8944ab; + --accent-text-d: #d08cf5; +} +:root[data-accent="pink"] { + --accent-l: #ff2d55; + --accent-d: #ff375f; + --accent-text-l: #d30f45; + --accent-text-d: #ff6482; +} +:root[data-accent="orange"] { + --accent-l: #ff9500; + --accent-d: #ff9f0a; + --accent-text-l: #c93400; + --accent-text-d: #ffb340; +} +:root[data-accent="green"] { + --accent-l: #34c759; + --accent-d: #30d158; + --accent-text-l: #248a3d; + --accent-text-d: #4cd964; +} +:root[data-accent="graphite"] { + --accent-l: #8e8e93; + --accent-d: #98989d; + --accent-text-l: #636366; + --accent-text-d: #aeaeb2; +} + +@media (prefers-reduced-transparency: reduce) { + :root:not([data-theme="light"]), + :root[data-theme="light"] { + --glass: var(--window); + --glass-strong: var(--elevated); + --hud: rgb(38 38 40); + } +} + +@supports not ( + (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) +) { + :root:not([data-theme="light"]), + :root[data-theme="light"] { + --glass: var(--window); + --glass-strong: var(--elevated); + --hud: rgb(38 38 40); } } +*, +*::before, +*::after { + box-sizing: border-box; +} +[hidden] { + display: none !important; +} + +html, body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + height: 100%; +} + +body { + margin: 0; + background: var(--window); + color: var(--label); + font-family: var(--font-text); + font-size: 14px; + line-height: 1.43; + letter-spacing: -0.006em; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + font-feature-settings: "cv11", "ss03"; +} + +@media (min-width: 768px) { + body { + overflow: hidden; + } +} + +h1, +h2, +h3, +p { + margin: 0; +} +button, +input, +select, +textarea { + font: inherit; + color: inherit; + letter-spacing: inherit; +} +button { + -webkit-tap-highlight-color: transparent; +} +img { + display: block; + max-width: 100%; +} +ul { + margin: 0; + padding: 0; + list-style: none; +} + +.mono { + font-family: var(--font-mono); + font-size: 0.92em; + letter-spacing: 0; + font-variant-numeric: tabular-nums; +} +.tabular { + font-variant-numeric: tabular-nums; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +.icon { + width: 18px; + height: 18px; + flex: none; + display: block; +} + +:focus-visible { + outline: 3px solid color-mix(in srgb, var(--accent) 55%, transparent); + outline-offset: 2px; + border-radius: 8px; +} + +@keyframes shake { + 0%, + 100% { + transform: translateX(0); + } + 15% { + transform: translateX(-9px); + } + 30% { + transform: translateX(8px); + } + 45% { + transform: translateX(-6px); + } + 60% { + transform: translateX(4px); + } + 75% { + transform: translateX(-2px); + } +} + +@keyframes shimmer { + from { + background-position: 150% 0; + } + to { + background-position: -50% 0; + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes arrive { + from { + opacity: 0; + transform: translateY(-8px) scale(0.97); + filter: blur(6px); + } + to { + opacity: 1; + transform: none; + filter: blur(0); + } +} + +@keyframes pop { + 0% { + transform: scale(0.4); + opacity: 0; + } + 60% { + transform: scale(1.12); + opacity: 1; + } + 100% { + transform: scale(1); + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } } diff --git a/apps/web/src/app/icon.svg b/apps/web/src/app/icon.svg new file mode 100644 index 0000000..638ca14 --- /dev/null +++ b/apps/web/src/app/icon.svg @@ -0,0 +1 @@ +OpenMedia diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 9852c15..226eb02 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,29 +1,60 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import type { Metadata, Viewport } from "next"; +import { Geist, Geist_Mono, Inter } from "next/font/google"; +import type { ReactNode } from "react"; +import { THEME_BOOTSTRAP_SCRIPT } from "@/lib/theme"; import "./globals.css"; const geistSans = Geist({ variable: "--font-geist-sans", - subsets: ["latin"], + subsets: ["latin", "latin-ext"], }); - const geistMono = Geist_Mono({ variable: "--font-geist-mono", - subsets: ["latin"], + subsets: ["latin", "latin-ext"], +}); +const inter = Inter({ + variable: "--font-inter", + subsets: ["latin", "latin-ext", "vietnamese"], }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "OpenMedia", + description: + "Download videos from almost any website. Lightweight, self-hosted media downloader with a clean web UI.", + applicationName: "OpenMedia", + appleWebApp: { + capable: true, + title: "OpenMedia", + statusBarStyle: "black-translucent", + }, +}; + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + viewportFit: "cover", + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "#f5f5f7" }, + { media: "(prefers-color-scheme: dark)", color: "#1e1e20" }, + ], }; -export default function RootLayout({ children }: LayoutProps<"/">) { +export default function RootLayout({ + children, +}: { + children: ReactNode; +}): ReactNode { return ( - {children} + +