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
+
Download videos from almost any website. Lightweight, self-hosted media downloader with a clean web UI.
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+## 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
+
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.
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+## 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}
+
+
+
+ {children}
);
}
diff --git a/apps/web/src/app/manifest.test.ts b/apps/web/src/app/manifest.test.ts
new file mode 100644
index 0000000..6310f6d
--- /dev/null
+++ b/apps/web/src/app/manifest.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest';
+import manifest from './manifest';
+
+describe('manifest', () => {
+ it('declares an installable app with a share target', () => {
+ const value = manifest();
+ expect(value.name).toBe('OpenMedia');
+ expect(value.display).toBe('standalone');
+ expect(value.theme_color).toBe('#12939c');
+ expect(value.icons?.map((icon) => icon.sizes)).toEqual([
+ 'any',
+ '192x192',
+ '512x512',
+ '512x512',
+ ]);
+ expect(value.share_target).toEqual({
+ action: '/',
+ method: 'GET',
+ params: { url: 'url', text: 'text', title: 'title' },
+ });
+ });
+});
diff --git a/apps/web/src/app/manifest.ts b/apps/web/src/app/manifest.ts
new file mode 100644
index 0000000..6c9477f
--- /dev/null
+++ b/apps/web/src/app/manifest.ts
@@ -0,0 +1,32 @@
+import type { MetadataRoute } from 'next';
+import { BRAND_TEAL } from '@/lib/brand';
+
+export default function manifest(): MetadataRoute.Manifest {
+ return {
+ name: 'OpenMedia',
+ short_name: 'OpenMedia',
+ description:
+ 'Download videos from almost any website. Lightweight, self-hosted media downloader with a clean web UI.',
+ start_url: '/',
+ scope: '/',
+ display: 'standalone',
+ background_color: '#f5f5f7',
+ theme_color: BRAND_TEAL,
+ icons: [
+ { src: '/icon.svg', sizes: 'any', type: 'image/svg+xml' },
+ { src: '/pwa-icon/192', sizes: '192x192', type: 'image/png' },
+ { src: '/pwa-icon/512', sizes: '512x512', type: 'image/png' },
+ {
+ src: '/pwa-icon/512',
+ sizes: '512x512',
+ type: 'image/png',
+ purpose: 'maskable',
+ },
+ ],
+ share_target: {
+ action: '/',
+ method: 'GET',
+ params: { url: 'url', text: 'text', title: 'title' },
+ },
+ };
+}
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
index c887311..121513b 100644
--- a/apps/web/src/app/page.tsx
+++ b/apps/web/src/app/page.tsx
@@ -1,69 +1,6 @@
-import Image from "next/image";
+import type { ReactNode } from "react";
+import { OpenMediaClient } from "./OpenMediaClient";
-export default function Home() {
- return (
-
-
-
-
-
- To get started, edit the{" "}
-
- page.tsx
- {" "}
- file.
-
-
- Looking for a starting point or more instructions? Head over to{" "}
-
- Templates
- {" "}
- or the{" "}
-
- Learning
- {" "}
- center.
-
-
-
-
-
- );
+export default function Home(): ReactNode {
+ return ;
}
diff --git a/apps/web/src/app/pwa-icon/[size]/route.tsx b/apps/web/src/app/pwa-icon/[size]/route.tsx
new file mode 100644
index 0000000..d56e1ea
--- /dev/null
+++ b/apps/web/src/app/pwa-icon/[size]/route.tsx
@@ -0,0 +1,29 @@
+import { ImageResponse } from "next/og";
+import {
+ BRAND_INK,
+ BRAND_MIST,
+ BRAND_TEAL,
+ PWA_ICON_SIZES,
+ brandSvg,
+} from "@/lib/brand";
+
+export const dynamic = "force-static";
+export const dynamicParams = false;
+
+export function generateStaticParams(): Array<{ size: string }> {
+ return PWA_ICON_SIZES.map((size) => ({ size: String(size) }));
+}
+
+export async function GET(
+ _request: Request,
+ context: { params: Promise<{ size: string }> },
+): Promise {
+ const { size } = await context.params;
+ const pixels = PWA_ICON_SIZES.find((candidate) => String(candidate) === size);
+ if (pixels === undefined) return new Response(null, { status: 404 });
+ const source = `data:image/svg+xml;base64,${Buffer.from(brandSvg({ frame: BRAND_MIST, wave: BRAND_TEAL, background: BRAND_INK })).toString("base64")}`;
+ return new ImageResponse(
+ ,
+ { width: pixels, height: pixels },
+ );
+}
diff --git a/apps/web/src/components/auth/LoginScreen.test.tsx b/apps/web/src/components/auth/LoginScreen.test.tsx
new file mode 100644
index 0000000..af5f03a
--- /dev/null
+++ b/apps/web/src/components/auth/LoginScreen.test.tsx
@@ -0,0 +1,41 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { api, ApiRequestError } from "@/lib/api/client";
+import { StoreProvider } from "@/state/StoreProvider";
+import { LoginScreen } from "./LoginScreen";
+
+describe("LoginScreen", () => {
+ it("focuses the password field when it opens", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByLabelText("Password")).toHaveFocus();
+ });
+
+ it("shows an error for a wrong password", async () => {
+ vi.spyOn(api, "session").mockResolvedValue({
+ auth_required: true,
+ authenticated: false,
+ limits: { max_filesize_mb: 1, max_playlist_items: 1 },
+ });
+ vi.spyOn(api, "signIn").mockRejectedValue(
+ new ApiRequestError(401, "invalid_password", "no", null),
+ );
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+ await user.type(screen.getByLabelText("Password"), "wrong");
+ await user.click(screen.getByRole("button", { name: "Sign in" }));
+ await waitFor(() =>
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "The password is not correct.",
+ ),
+ );
+ });
+});
diff --git a/apps/web/src/components/auth/LoginScreen.tsx b/apps/web/src/components/auth/LoginScreen.tsx
new file mode 100644
index 0000000..fd945c9
--- /dev/null
+++ b/apps/web/src/components/auth/LoginScreen.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import { useState, type FormEvent, type ReactNode } from "react";
+import { useI18n } from "@/lib/i18n/I18nProvider";
+import { useStore } from "@/state/StoreProvider";
+import { BrandMark } from "../controls/BrandMark";
+import { Capsule } from "../controls/Capsule";
+import styles from "./auth.module.css";
+
+export function LoginScreen(): ReactNode {
+ const { t } = useI18n();
+ const { commands } = useStore();
+ const [password, setPassword] = useState("");
+ const [failed, setFailed] = useState(false);
+ const [busy, setBusy] = useState(false);
+
+ const submit = async (event: FormEvent): Promise => {
+ event.preventDefault();
+ setBusy(true);
+ const signedIn = await commands.signIn(password);
+ setBusy(false);
+ setFailed(!signedIn);
+ if (signedIn) void commands.loadServerState();
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/components/auth/auth.module.css b/apps/web/src/components/auth/auth.module.css
new file mode 100644
index 0000000..666d615
--- /dev/null
+++ b/apps/web/src/components/auth/auth.module.css
@@ -0,0 +1,59 @@
+.screen {
+ min-height: 100dvh;
+ display: grid;
+ place-items: center;
+ background: var(--window);
+ padding: 24px;
+}
+.card {
+ display: grid;
+ gap: 16px;
+ justify-items: center;
+ width: min(100%, 340px);
+ padding: 32px 28px;
+ border-radius: var(--radius-panel);
+ background: var(--grouped);
+ box-shadow: var(--shadow-float);
+ text-align: center;
+}
+.card h1 {
+ font-family: var(--font-display);
+ font-size: 19px;
+ font-weight: 650;
+ letter-spacing: -0.02em;
+}
+.field {
+ display: grid;
+ gap: 6px;
+ width: 100%;
+ text-align: left;
+ font-size: 13px;
+ color: var(--secondary);
+}
+.field input {
+ min-height: 44px;
+ padding: 0 14px;
+ border: 0;
+ border-radius: 22px;
+ background: var(--cell);
+ box-shadow:
+ var(--shadow-soft),
+ inset 0 0 0 0.5px var(--separator);
+ color: var(--label);
+ font-size: 15px;
+ transition: box-shadow 0.25s ease;
+}
+.field input:focus-visible {
+ outline: none;
+ box-shadow:
+ var(--shadow-soft),
+ 0 0 0 3.5px color-mix(in srgb, var(--accent) 38%, transparent),
+ inset 0 0 0 0.5px var(--accent);
+}
+.shake {
+ animation: shake 0.46s var(--ease-out);
+}
+.error {
+ color: var(--red-text);
+ font-size: 13px;
+}
diff --git a/apps/web/src/components/controls/BrandMark.tsx b/apps/web/src/components/controls/BrandMark.tsx
new file mode 100644
index 0000000..a861cc2
--- /dev/null
+++ b/apps/web/src/components/controls/BrandMark.tsx
@@ -0,0 +1,25 @@
+import type { ReactNode } from "react";
+import { LOGO_FRAME_PATH, LOGO_WAVE_PATH } from "@/lib/brand";
+import styles from "./controls.module.css";
+
+export function BrandMark({ size = 26 }: { size?: number }): ReactNode {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/controls/Capsule.test.tsx b/apps/web/src/components/controls/Capsule.test.tsx
new file mode 100644
index 0000000..205ed96
--- /dev/null
+++ b/apps/web/src/components/controls/Capsule.test.tsx
@@ -0,0 +1,11 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { Capsule } from "./Capsule";
+
+describe("Capsule", () => {
+ it("does not emit an undefined class for the default gray variant", () => {
+ render(Paste );
+ const button = screen.getByRole("button", { name: "Paste" });
+ expect(button.className).not.toMatch(/undefined/);
+ });
+});
diff --git a/apps/web/src/components/controls/Capsule.tsx b/apps/web/src/components/controls/Capsule.tsx
new file mode 100644
index 0000000..23f4323
--- /dev/null
+++ b/apps/web/src/components/controls/Capsule.tsx
@@ -0,0 +1,36 @@
+import type { ButtonHTMLAttributes, ReactNode } from "react";
+import styles from "./controls.module.css";
+import { Icon, type IconName } from "./Icon";
+
+type CapsuleVariant = "gray" | "primary" | "tinted" | "plain" | "destructive";
+
+interface CapsuleProps extends ButtonHTMLAttributes {
+ variant?: CapsuleVariant;
+ size?: "regular" | "large";
+ icon?: IconName;
+}
+
+export function Capsule({
+ variant = "gray",
+ size = "regular",
+ icon,
+ className,
+ children,
+ type = "button",
+ ...rest
+}: CapsuleProps): ReactNode {
+ const classes = [
+ styles.capsule,
+ styles[variant],
+ size === "large" ? styles.large : null,
+ className,
+ ]
+ .filter(Boolean)
+ .join(" ");
+ return (
+
+ {icon ? : null}
+ {children}
+
+ );
+}
diff --git a/apps/web/src/components/controls/Icon.tsx b/apps/web/src/components/controls/Icon.tsx
new file mode 100644
index 0000000..8c711aa
--- /dev/null
+++ b/apps/web/src/components/controls/Icon.tsx
@@ -0,0 +1,100 @@
+import {
+ ArrowClockwise,
+ ArrowDown,
+ Check,
+ CheckCircle,
+ ClipboardText,
+ ClockCounterClockwise,
+ Cookie,
+ DeviceMobile,
+ DownloadSimple,
+ FacebookLogo,
+ FilmStrip,
+ GearSix,
+ Globe,
+ InstagramLogo,
+ Keyboard,
+ Link,
+ List,
+ Lock,
+ Minus,
+ Moon,
+ MusicNotes,
+ Plus,
+ Queue,
+ SignOut,
+ SoundcloudLogo,
+ Sun,
+ TiktokLogo,
+ Timer,
+ Trash,
+ VideoCamera,
+ WarningCircle,
+ X,
+ XLogo,
+ YoutubeLogo,
+ CaretRight,
+ type Icon as PhosphorIcon,
+} from "@phosphor-icons/react";
+import type { ReactNode } from "react";
+
+const ICONS = {
+ arrowClockwise: ArrowClockwise,
+ arrowDown: ArrowDown,
+ caretRight: CaretRight,
+ check: Check,
+ checkCircle: CheckCircle,
+ clipboard: ClipboardText,
+ history: ClockCounterClockwise,
+ cookie: Cookie,
+ deviceMobile: DeviceMobile,
+ download: DownloadSimple,
+ facebook: FacebookLogo,
+ filmStrip: FilmStrip,
+ gear: GearSix,
+ globe: Globe,
+ instagram: InstagramLogo,
+ keyboard: Keyboard,
+ link: Link,
+ list: List,
+ lock: Lock,
+ minus: Minus,
+ moon: Moon,
+ musicNotes: MusicNotes,
+ plus: Plus,
+ queue: Queue,
+ signOut: SignOut,
+ soundcloud: SoundcloudLogo,
+ sun: Sun,
+ tiktok: TiktokLogo,
+ timer: Timer,
+ trash: Trash,
+ videoCamera: VideoCamera,
+ vimeo: VideoCamera,
+ warningCircle: WarningCircle,
+ x: X,
+ xLogo: XLogo,
+ youtube: YoutubeLogo,
+} satisfies Record;
+
+export type IconName = keyof typeof ICONS;
+
+export function Icon({
+ name,
+ size = 18,
+ weight = "regular",
+}: {
+ name: IconName;
+ size?: number;
+ weight?: "regular" | "fill" | "bold";
+}): ReactNode {
+ const Component = ICONS[name];
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/controls/IconButton.tsx b/apps/web/src/components/controls/IconButton.tsx
new file mode 100644
index 0000000..9bbb2c3
--- /dev/null
+++ b/apps/web/src/components/controls/IconButton.tsx
@@ -0,0 +1,30 @@
+import type { ButtonHTMLAttributes, ReactNode } from "react";
+import styles from "./controls.module.css";
+import { Icon, type IconName } from "./Icon";
+
+interface IconButtonProps extends ButtonHTMLAttributes {
+ label: string;
+ icon: IconName;
+ size?: "small" | "regular";
+}
+
+export function IconButton({
+ label,
+ icon,
+ size = "regular",
+ className,
+ type = "button",
+ ...rest
+}: IconButtonProps): ReactNode {
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/components/controls/Segmented.test.tsx b/apps/web/src/components/controls/Segmented.test.tsx
new file mode 100644
index 0000000..fffddac
--- /dev/null
+++ b/apps/web/src/components/controls/Segmented.test.tsx
@@ -0,0 +1,45 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { useState } from "react";
+import { describe, expect, it } from "vitest";
+import { Segmented } from "./Segmented";
+
+function Harness(): React.ReactNode {
+ const [value, setValue] = useState<"video" | "audio">("video");
+ return (
+
+ );
+}
+
+describe("Segmented", () => {
+ it("selects with clicks and arrow keys and moves the thumb", async () => {
+ const user = userEvent.setup();
+ render( );
+ const group = screen.getByRole("radiogroup", { name: "Type" });
+ expect(screen.getByRole("radio", { name: "Video" })).toHaveAttribute(
+ "aria-checked",
+ "true",
+ );
+ await user.click(screen.getByRole("radio", { name: "Audio" }));
+ expect(screen.getByRole("radio", { name: "Audio" })).toHaveAttribute(
+ "aria-checked",
+ "true",
+ );
+ expect(group.style.getPropertyValue("--index")).toBe("1");
+ screen.getByRole("radio", { name: "Audio" }).focus();
+ await user.keyboard("{ArrowRight}");
+ expect(screen.getByRole("radio", { name: "Video" })).toHaveAttribute(
+ "aria-checked",
+ "true",
+ );
+ expect(screen.getByRole("radio", { name: "Video" })).toHaveFocus();
+ });
+});
diff --git a/apps/web/src/components/controls/Segmented.tsx b/apps/web/src/components/controls/Segmented.tsx
new file mode 100644
index 0000000..71ba10d
--- /dev/null
+++ b/apps/web/src/components/controls/Segmented.tsx
@@ -0,0 +1,84 @@
+"use client";
+
+import {
+ useRef,
+ type CSSProperties,
+ type KeyboardEvent,
+ type ReactNode,
+} from "react";
+import styles from "./controls.module.css";
+import { Icon, type IconName } from "./Icon";
+
+export interface SegmentOption {
+ readonly value: T;
+ readonly label: string;
+ readonly icon?: IconName;
+}
+
+interface SegmentedProps {
+ label: string;
+ options: ReadonlyArray>;
+ value: T;
+ onChange: (value: T) => void;
+}
+
+const ARROW_STEPS: Record = {
+ ArrowRight: 1,
+ ArrowDown: 1,
+ ArrowLeft: -1,
+ ArrowUp: -1,
+};
+
+export function Segmented({
+ label,
+ options,
+ value,
+ onChange,
+}: SegmentedProps): ReactNode {
+ const buttons = useRef>([]);
+ const index = Math.max(
+ 0,
+ options.findIndex((option) => option.value === value),
+ );
+ const style = {
+ "--count": options.length,
+ "--index": index,
+ } as CSSProperties;
+
+ const handleKeyDown = (event: KeyboardEvent): void => {
+ const step = ARROW_STEPS[event.key];
+ if (step === undefined) return;
+ event.preventDefault();
+ const next = (index + step + options.length) % options.length;
+ onChange(options[next].value);
+ buttons.current[next]?.focus();
+ };
+
+ return (
+
+
+ {options.map((option, optionIndex) => (
+ {
+ buttons.current[optionIndex] = element;
+ }}
+ type="button"
+ role="radio"
+ aria-checked={option.value === value}
+ tabIndex={option.value === value ? 0 : -1}
+ onClick={() => onChange(option.value)}
+ >
+ {option.icon ? : null}
+ {option.label}
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/components/controls/Stepper.tsx b/apps/web/src/components/controls/Stepper.tsx
new file mode 100644
index 0000000..2b66cbb
--- /dev/null
+++ b/apps/web/src/components/controls/Stepper.tsx
@@ -0,0 +1,48 @@
+import type { ReactNode } from "react";
+import styles from "./controls.module.css";
+import { Icon } from "./Icon";
+
+interface StepperProps {
+ value: number;
+ min: number;
+ max: number;
+ onChange: (value: number) => void;
+ decreaseLabel: string;
+ increaseLabel: string;
+}
+
+export function Stepper({
+ value,
+ min,
+ max,
+ onChange,
+ decreaseLabel,
+ increaseLabel,
+}: StepperProps): ReactNode {
+ return (
+
+
+ {value}
+
+
+ onChange(value - 1)}
+ >
+
+
+
+ = max}
+ onClick={() => onChange(value + 1)}
+ >
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/controls/Switch.tsx b/apps/web/src/components/controls/Switch.tsx
new file mode 100644
index 0000000..f7ce661
--- /dev/null
+++ b/apps/web/src/components/controls/Switch.tsx
@@ -0,0 +1,23 @@
+import type { ReactNode } from "react";
+import styles from "./controls.module.css";
+
+export function Switch({
+ checked,
+ onChange,
+ labelledBy,
+}: {
+ checked: boolean;
+ onChange: (checked: boolean) => void;
+ labelledBy: string;
+}): ReactNode {
+ return (
+ onChange(!checked)}
+ />
+ );
+}
diff --git a/apps/web/src/components/controls/controls.module.css b/apps/web/src/components/controls/controls.module.css
new file mode 100644
index 0000000..b61c430
--- /dev/null
+++ b/apps/web/src/components/controls/controls.module.css
@@ -0,0 +1,263 @@
+.capsule {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ min-height: 30px;
+ padding: 5px 14px;
+ border: 0;
+ border-radius: var(--radius-capsule);
+ background: var(--fill);
+ color: var(--accent-text);
+ font-weight: 600;
+ font-size: 13px;
+ white-space: nowrap;
+ cursor: pointer;
+ transition:
+ transform var(--duration-quick) var(--spring-snappy),
+ background-color 0.2s ease,
+ color 0.2s ease,
+ opacity 0.2s ease;
+}
+.capsule:hover {
+ background: var(--fill-strong);
+}
+.capsule:active {
+ transform: scale(0.96);
+}
+.capsule:disabled {
+ opacity: 0.4;
+ cursor: default;
+ transform: none;
+}
+
+.capsule.primary {
+ background: var(--button);
+ color: var(--on-accent);
+}
+.capsule.primary:hover {
+ background: color-mix(in srgb, var(--button) 88%, var(--label));
+}
+.capsule.primary:disabled {
+ background: var(--fill);
+ color: var(--tertiary);
+ opacity: 1;
+}
+.capsule.tinted {
+ background: var(--accent-soft);
+ color: var(--accent-text);
+}
+.capsule.tinted:hover {
+ background: color-mix(in srgb, var(--accent) 22%, transparent);
+}
+.capsule.plain {
+ background: transparent;
+ padding-inline: 8px;
+}
+.capsule.plain:hover {
+ background: var(--fill-hover);
+}
+.capsule.destructive {
+ background: transparent;
+ color: var(--red-text);
+}
+.capsule.destructive:hover {
+ background: color-mix(in srgb, var(--red) 12%, transparent);
+}
+.capsule.large {
+ min-height: 44px;
+ padding: 10px 22px;
+ font-size: 15px;
+}
+
+@media (max-width: 767px) {
+ .capsule {
+ min-height: 34px;
+ }
+}
+
+.iconButton {
+ display: inline-grid;
+ place-items: center;
+ width: 34px;
+ height: 34px;
+ border: 0;
+ border-radius: var(--radius-capsule);
+ background: transparent;
+ color: var(--secondary);
+ cursor: pointer;
+ transition:
+ transform var(--duration-quick) var(--spring-snappy),
+ background-color 0.2s ease,
+ color 0.2s ease;
+}
+.iconButton:hover {
+ background: var(--fill-hover);
+ color: var(--label);
+}
+.iconButton:active {
+ transform: scale(0.9);
+}
+.iconButton.small {
+ width: 26px;
+ height: 26px;
+}
+
+@media (max-width: 767px) {
+ .iconButton {
+ width: 40px;
+ height: 40px;
+ }
+}
+
+.brandMark {
+ width: 26px;
+ height: 26px;
+ flex: none;
+}
+.brandFrame {
+ stroke: var(--label);
+}
+.brandWave {
+ stroke: var(--accent);
+ transition: stroke var(--duration-base) var(--ease-out);
+}
+
+.segmented {
+ position: relative;
+ display: grid;
+ grid-template-columns: repeat(var(--count), minmax(0, 1fr));
+ padding: 2px;
+ border-radius: var(--radius-capsule);
+ background: var(--fill);
+ isolation: isolate;
+}
+.segmentedThumb {
+ position: absolute;
+ z-index: -1;
+ top: 2px;
+ bottom: 2px;
+ left: 2px;
+ width: calc((100% - 4px) / var(--count));
+ border-radius: var(--radius-capsule);
+ background: var(--segment-thumb);
+ box-shadow:
+ 0 0 0 0.5px rgb(0 0 0 / 0.04),
+ 0 3px 8px rgb(0 0 0 / 0.12),
+ 0 3px 1px rgb(0 0 0 / 0.04);
+ transform: translateX(calc(var(--index) * 100%));
+ transition: transform var(--duration-base) var(--spring-snappy);
+}
+.segmented button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ min-height: 30px;
+ padding: 4px 10px;
+ border: 0;
+ border-radius: var(--radius-capsule);
+ background: transparent;
+ color: var(--label);
+ font-size: 13px;
+ font-weight: 500;
+ white-space: nowrap;
+ cursor: pointer;
+ transition:
+ transform var(--duration-quick) var(--spring-snappy),
+ opacity 0.2s ease;
+}
+.segmented button[aria-checked="false"] {
+ opacity: 0.72;
+}
+.segmented button:active {
+ transform: scale(0.95);
+}
+
+@media (max-width: 767px) {
+ .segmented button {
+ min-height: 34px;
+ font-size: 14px;
+ }
+}
+
+.switch {
+ position: relative;
+ width: 51px;
+ height: 31px;
+ padding: 0;
+ border: 0;
+ border-radius: var(--radius-capsule);
+ background: var(--fill-strong);
+ cursor: pointer;
+ transition: background-color 0.25s ease;
+}
+.switch::after {
+ content: "";
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 27px;
+ height: 27px;
+ border-radius: 50%;
+ background: #fdfdfd;
+ box-shadow:
+ 0 3px 8px rgb(0 0 0 / 0.15),
+ 0 3px 1px rgb(0 0 0 / 0.06);
+ transition:
+ transform var(--duration-base) var(--spring-snappy),
+ width 0.2s ease;
+}
+.switch:active::after {
+ width: 32px;
+}
+.switch[aria-checked="true"] {
+ background: var(--green);
+}
+.switch[aria-checked="true"]::after {
+ transform: translateX(20px);
+}
+.switch[aria-checked="true"]:active::after {
+ transform: translateX(15px);
+}
+
+.stepperGroup {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+}
+.stepper {
+ display: inline-grid;
+ grid-template-columns: 38px 1px 38px;
+ align-items: center;
+ height: 32px;
+ border-radius: 9px;
+ background: var(--fill);
+ overflow: hidden;
+}
+.stepper button {
+ height: 100%;
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+ color: var(--label);
+ display: grid;
+ place-items: center;
+}
+.stepper button:active {
+ background: var(--fill-strong);
+}
+.stepper button:disabled {
+ opacity: 0.3;
+}
+.stepperDivider {
+ width: 1px;
+ height: 18px;
+ background: var(--separator);
+}
+.stepperValue {
+ min-width: 18px;
+ text-align: center;
+ font-variant-numeric: tabular-nums;
+ color: var(--label);
+}
diff --git a/apps/web/src/components/history/HistoryView.tsx b/apps/web/src/components/history/HistoryView.tsx
new file mode 100644
index 0000000..64a59e8
--- /dev/null
+++ b/apps/web/src/components/history/HistoryView.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { formatBytes } from "@/lib/format";
+import { useI18n } from "@/lib/i18n/I18nProvider";
+import { useStore } from "@/state/StoreProvider";
+import { Capsule } from "../controls/Capsule";
+import { Icon } from "../controls/Icon";
+import queueStyles from "../queue/queue.module.css";
+import styles from "./history.module.css";
+
+const DATE_TAGS = { vi: "vi-VN", en: "en-US" } as const;
+
+export function HistoryView({
+ onClearRequest,
+}: {
+ onClearRequest: () => void;
+}): ReactNode {
+ const { t, locale } = useI18n();
+ const { state, commands } = useStore();
+ const dateFormat = new Intl.DateTimeFormat(DATE_TAGS[locale], {
+ dateStyle: "medium",
+ timeStyle: "short",
+ });
+ return (
+
+
+
{t.history.note}
+
+ {t.history.clear}
+
+
+ {state.history.length === 0 ? (
+ {t.history.empty}
+ ) : (
+
+ {state.history.map((entry) => (
+
+
+
+
+
+ {entry.title}
+ {`${entry.label}, ${formatBytes(entry.sizeBytes, locale)}, ${dateFormat.format(new Date(entry.finishedAt))}`}
+
+ void commands.downloadAgain(entry.id)}
+ >
+ {t.history.again}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/history/history.module.css b/apps/web/src/components/history/history.module.css
new file mode 100644
index 0000000..18fbd95
--- /dev/null
+++ b/apps/web/src/components/history/history.module.css
@@ -0,0 +1,13 @@
+.glyph {
+ display: grid;
+ place-items: center;
+ width: 72px;
+ aspect-ratio: 16 / 9;
+ border-radius: var(--radius-thumb);
+ background: var(--fill);
+ color: var(--secondary);
+}
+
+.entry {
+ cursor: default;
+}
diff --git a/apps/web/src/components/importer/Importer.test.tsx b/apps/web/src/components/importer/Importer.test.tsx
new file mode 100644
index 0000000..58ee6e0
--- /dev/null
+++ b/apps/web/src/components/importer/Importer.test.tsx
@@ -0,0 +1,107 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { createRef, useState, type ReactNode } from "react";
+import { describe, expect, it, vi } from "vitest";
+import { I18nProvider } from "@/lib/i18n/I18nProvider";
+import { vi as viMessages } from "@/lib/i18n/vi";
+import type { PlaylistScope } from "@/state/types";
+import { Importer } from "./Importer";
+
+function Harness({
+ onSubmit,
+}: {
+ onSubmit: (urls: string[], scope: PlaylistScope) => void;
+}): ReactNode {
+ const [value, setValue] = useState("");
+ const [scope, setScope] = useState("single");
+ return (
+
+ );
+}
+
+describe("Importer", () => {
+ it("detects platforms, asks about playlists and submits on Enter", async () => {
+ const onSubmit = vi.fn();
+ const user = userEvent.setup();
+ render( );
+ const field = screen.getByLabelText("Links to download");
+ await user.type(
+ field,
+ "https://www.youtube.com/watch?v=a&list=PL1 https://soundcloud.com/a/b",
+ );
+ expect(screen.getByText("YouTube")).toBeInTheDocument();
+ expect(screen.getByText("SoundCloud")).toBeInTheDocument();
+ await user.click(
+ screen.getByRole("radio", { name: "Whole playlist (up to 50 videos)" }),
+ );
+ await user.keyboard("{Enter}");
+ expect(onSubmit).toHaveBeenCalledWith(
+ [
+ "https://www.youtube.com/watch?v=a&list=PL1",
+ "https://soundcloud.com/a/b",
+ ],
+ "playlist",
+ );
+ });
+
+ it("keeps Shift+Enter as a new line", async () => {
+ const onSubmit = vi.fn();
+ const user = userEvent.setup();
+ render( );
+ await user.type(
+ screen.getByLabelText("Links to download"),
+ "https://youtu.be/a{Shift>}{Enter}{/Shift}",
+ );
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it("does not submit when Enter is pressed on the paste button", async () => {
+ const onSubmit = vi.fn();
+ const user = userEvent.setup();
+ render( );
+ await user.type(
+ screen.getByLabelText("Links to download"),
+ "https://youtu.be/a",
+ );
+ screen.getByRole("button", { name: "Paste" }).focus();
+ await user.keyboard("{Enter}");
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it("shakes the field once on an invalid submit and clears on animationend", async () => {
+ const onSubmit = vi.fn();
+ const user = userEvent.setup();
+ render( );
+ const input = screen.getByLabelText("Links to download");
+ await user.type(input, "not a link");
+ await user.keyboard("{Enter}");
+ const field = input.parentElement as HTMLElement;
+ expect(field).toHaveClass("shaking");
+ fireEvent.animationEnd(field);
+ expect(field).not.toHaveClass("shaking");
+ });
+
+ it("localizes the generic platform chip label", async () => {
+ const onSubmit = vi.fn();
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+ await user.type(
+ screen.getByLabelText(viMessages.importer.label),
+ "https://example.com",
+ );
+ expect(
+ screen.getByText(viMessages.importer.platformOther),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/src/components/importer/Importer.tsx b/apps/web/src/components/importer/Importer.tsx
new file mode 100644
index 0000000..ef5b96d
--- /dev/null
+++ b/apps/web/src/components/importer/Importer.tsx
@@ -0,0 +1,215 @@
+"use client";
+
+import {
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ type FormEvent,
+ type KeyboardEvent,
+ type ReactNode,
+ type RefObject,
+} from "react";
+import { useI18n } from "@/lib/i18n/I18nProvider";
+import {
+ detectPlatforms,
+ hasPlaylist,
+ parseLinks,
+ type PlatformId,
+} from "@/lib/links";
+import type { PlaylistScope } from "@/state/types";
+import { Capsule } from "../controls/Capsule";
+import { Icon, type IconName } from "../controls/Icon";
+import { Segmented } from "../controls/Segmented";
+import styles from "./importer.module.css";
+
+const MAX_FIELD_HEIGHT = 132;
+const DEFAULT_PLAYLIST_LIMIT = 50;
+const PLATFORM_ICONS: Record = {
+ youtube: "youtube",
+ tiktok: "tiktok",
+ instagram: "instagram",
+ soundcloud: "soundcloud",
+ x: "xLogo",
+ facebook: "facebook",
+ vimeo: "vimeo",
+ other: "globe",
+};
+const PLATFORM_NAMES: Record, string> = {
+ youtube: "YouTube",
+ tiktok: "TikTok",
+ instagram: "Instagram",
+ soundcloud: "SoundCloud",
+ x: "X",
+ facebook: "Facebook",
+ vimeo: "Vimeo",
+};
+
+interface ImporterProps {
+ value: string;
+ onChange: (value: string) => void;
+ onSubmit: (urls: string[], scope: PlaylistScope) => void;
+ scope: PlaylistScope;
+ onScopeChange: (scope: PlaylistScope) => void;
+ inputRef: RefObject;
+ playlistLimit?: number;
+ onInvalid?: () => void;
+ onClipboardDenied?: () => void;
+}
+
+export function Importer({
+ value,
+ onChange,
+ onSubmit,
+ scope,
+ onScopeChange,
+ inputRef,
+ playlistLimit = DEFAULT_PLAYLIST_LIMIT,
+ onInvalid,
+ onClipboardDenied,
+}: ImporterProps): ReactNode {
+ const { t } = useI18n();
+ const links = parseLinks(value);
+ const platforms = detectPlatforms(links);
+ const showPlaylistChoice = links.some(hasPlaylist);
+ const fieldRef = useRef(null);
+
+ useLayoutEffect(() => {
+ const field = inputRef.current;
+ if (!field) return;
+ field.style.height = "auto";
+ field.style.height = `${Math.min(field.scrollHeight, MAX_FIELD_HEIGHT)}px`;
+ }, [value, inputRef]);
+
+ const platformLabel = (platform: PlatformId): string =>
+ platform === "other" ? t.importer.platformOther : PLATFORM_NAMES[platform];
+
+ const shakeField = (): void => {
+ const field = fieldRef.current;
+ if (!field) return;
+ field.classList.remove(styles.shaking);
+ void field.offsetWidth;
+ field.classList.add(styles.shaking);
+ };
+
+ useEffect(() => {
+ const field = fieldRef.current;
+ if (!field) return;
+ const clearShake = (): void => field.classList.remove(styles.shaking);
+ field.addEventListener("animationend", clearShake);
+ return () => field.removeEventListener("animationend", clearShake);
+ }, []);
+
+ const submit = (): void => {
+ if (links.length === 0) {
+ shakeField();
+ onInvalid?.();
+ return;
+ }
+ onSubmit(links, scope);
+ };
+
+ const pasteFromClipboard = async (): Promise => {
+ try {
+ const text = await navigator.clipboard.readText();
+ onChange([value.trim(), text.trim()].filter(Boolean).join("\n"));
+ } catch {
+ onClipboardDenied?.();
+ } finally {
+ inputRef.current?.focus();
+ }
+ };
+
+ const handleFormKeyDown = (event: KeyboardEvent): void => {
+ const isPlainButton =
+ event.target instanceof HTMLButtonElement &&
+ event.target.getAttribute("role") !== "radio";
+ if (
+ isPlainButton ||
+ event.key !== "Enter" ||
+ event.shiftKey ||
+ event.nativeEvent.isComposing
+ )
+ return;
+ event.preventDefault();
+ submit();
+ };
+
+ const handleSubmit = (event: FormEvent): void => {
+ event.preventDefault();
+ submit();
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/importer/importer.module.css b/apps/web/src/components/importer/importer.module.css
new file mode 100644
index 0000000..1df26b3
--- /dev/null
+++ b/apps/web/src/components/importer/importer.module.css
@@ -0,0 +1,152 @@
+.importer {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 8px 10px;
+ align-items: start;
+}
+.field {
+ position: relative;
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: start;
+ gap: 8px;
+ min-height: 44px;
+ padding: 6px 6px 6px 14px;
+ border-radius: 22px;
+ background: var(--cell);
+ box-shadow:
+ var(--shadow-soft),
+ inset 0 0 0 0.5px var(--separator);
+ transition: box-shadow 0.25s ease;
+}
+.field:focus-within {
+ box-shadow:
+ var(--shadow-soft),
+ 0 0 0 3.5px color-mix(in srgb, var(--accent) 38%, transparent),
+ inset 0 0 0 0.5px var(--accent);
+}
+.field.shaking {
+ animation: shake 0.46s var(--ease-out);
+}
+.field svg:first-child {
+ margin-top: 7px;
+ color: var(--tertiary);
+}
+.field textarea {
+ min-height: 32px;
+ max-height: 132px;
+ padding: 6px 0;
+ border: 0;
+ outline: 0;
+ resize: none;
+ background: transparent;
+ font-size: 15px;
+ line-height: 20px;
+ overflow-y: hidden;
+}
+.field textarea::placeholder {
+ color: var(--tertiary);
+}
+.paste {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ height: 32px;
+ padding: 0 12px 0 10px;
+ border: 0;
+ border-radius: var(--radius-capsule);
+ background: var(--fill);
+ color: var(--label);
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ transition:
+ transform var(--duration-quick) var(--spring-snappy),
+ background-color 0.2s ease;
+}
+.paste:hover {
+ background: var(--fill-strong);
+}
+.paste:active {
+ transform: scale(0.94);
+}
+.submit {
+ min-height: 44px;
+ padding-inline: 20px;
+ font-size: 14px;
+}
+
+.chips {
+ grid-column: 1 / -1;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ min-height: 0;
+ padding-inline: 4px;
+}
+.chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ height: 26px;
+ padding: 0 10px 0 8px;
+ border-radius: var(--radius-capsule);
+ background: var(--accent-soft);
+ color: var(--accent-text);
+ font-size: 12px;
+ font-weight: 600;
+ animation: pop 0.42s var(--spring-snappy) both;
+}
+
+.playlist {
+ grid-column: 1 / -1;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px 16px;
+ padding: 10px 10px 10px 16px;
+ border-radius: var(--radius-group);
+ background: var(--cell);
+ box-shadow: inset 0 0 0 0.5px var(--separator);
+ font-size: 13px;
+ animation: arrive 0.5s var(--spring-smooth) both;
+}
+.playlist p {
+ margin: 0;
+}
+.playlist > div[role="radiogroup"] {
+ min-width: 300px;
+}
+
+.hint {
+ grid-column: 1 / -1;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 4px;
+ padding-inline: 4px;
+ font-size: 12px;
+ color: var(--tertiary);
+}
+
+@media (max-width: 767px) {
+ .importer {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ .submit {
+ width: 100%;
+ min-height: 48px;
+ font-size: 16px;
+ }
+ .field textarea {
+ font-size: 16px;
+ }
+ .hint {
+ display: none;
+ }
+ .playlist > div[role="radiogroup"] {
+ min-width: 0;
+ width: 100%;
+ }
+}
diff --git a/apps/web/src/components/inspector/Inspector.tsx b/apps/web/src/components/inspector/Inspector.tsx
new file mode 100644
index 0000000..1d5ca56
--- /dev/null
+++ b/apps/web/src/components/inspector/Inspector.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { PHONE_QUERY, useMediaQuery } from "@/hooks/useMediaQuery";
+import { useI18n } from "@/lib/i18n/I18nProvider";
+import { selectedItem } from "@/state/reducer";
+import { useStore } from "@/state/StoreProvider";
+import { Sheet } from "../overlays/Sheet";
+import { InspectorContent } from "./InspectorContent";
+import { InspectorFooter } from "./InspectorFooter";
+import styles from "./inspector.module.css";
+
+interface InspectorProps {
+ sheetOpen: boolean;
+ onCloseSheet: () => void;
+ onOpenCookies: () => void;
+}
+
+export function Inspector({
+ sheetOpen,
+ onCloseSheet,
+ onOpenCookies,
+}: InspectorProps): ReactNode {
+ const { t } = useI18n();
+ const { state } = useStore();
+ const isPhone = useMediaQuery(PHONE_QUERY);
+ const item = selectedItem(state);
+ const footer = item ? (
+
+ ) : null;
+ if (isPhone) {
+ return (
+
+
+
+ );
+ }
+ return (
+
+
+
+
+ {footer ? : null}
+
+ );
+}
diff --git a/apps/web/src/components/inspector/InspectorContent.tsx b/apps/web/src/components/inspector/InspectorContent.tsx
new file mode 100644
index 0000000..9905d33
--- /dev/null
+++ b/apps/web/src/components/inspector/InspectorContent.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { formatClock } from "@/lib/format";
+import { useI18n } from "@/lib/i18n/I18nProvider";
+import type { QueueItem } from "@/state/types";
+import { Thumbnail } from "../queue/Thumbnail";
+import styles from "./inspector.module.css";
+import { OptionsPanel } from "./OptionsPanel";
+import { StatusCard } from "./StatusCard";
+
+export function InspectorContent({
+ item,
+}: {
+ item: QueueItem | null;
+}): ReactNode {
+ const { t } = useI18n();
+ if (!item || item.type === "fetching")
+ return {t.inspector.empty}
;
+ if (item.type === "fetch-error") return ;
+ return (
+ <>
+
+
+
{item.media.title}
+
+ {[
+ item.media.uploader,
+ item.media.url.replace(/^https?:\/\//, "").split("/")[0],
+ ]
+ .filter(Boolean)
+ .join(" · ")}
+
+
+ {item.type === "ready" ? (
+
+ ) : (
+
+ )}
+ >
+ );
+}
diff --git a/apps/web/src/components/inspector/InspectorFooter.tsx b/apps/web/src/components/inspector/InspectorFooter.tsx
new file mode 100644
index 0000000..ba5c989
--- /dev/null
+++ b/apps/web/src/components/inspector/InspectorFooter.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { api } from "@/lib/api/client";
+import { expiryText } from "@/lib/describe";
+import { formatBytes } from "@/lib/format";
+import { useI18n } from "@/lib/i18n/I18nProvider";
+import { estimateBytes, isTrimmed } from "@/state/options";
+import { useStore } from "@/state/StoreProvider";
+import type { QueueItem } from "@/state/types";
+import { Capsule } from "../controls/Capsule";
+import styles from "./inspector.module.css";
+
+export function InspectorFooter({
+ item,
+ onOpenCookies,
+}: {
+ item: QueueItem;
+ onOpenCookies: () => void;
+}): ReactNode {
+ const { t, locale } = useI18n();
+ const { commands } = useStore();
+ if (item.type === "fetching") return null;
+ if (item.type === "fetch-error") {
+ return (
+ <>
+ void commands.removeItem(item.id)}
+ >
+ {t.queue.remove}
+
+ void commands.retryFetch(item.id)}
+ >
+ {t.queue.retry}
+
+ >
+ );
+ }
+ if (item.type === "ready") {
+ const trimmed = isTrimmed(item.options, item.media.duration);
+ const size = estimateBytes(item.options, item.formats, item.media.duration);
+ return (
+ <>
+
+ {trimmed ? t.inspector.selection : t.inspector.estimate}
+ {size === null ? "" : formatBytes(size, locale)}
+
+ void commands.startDownload(item.id)}
+ >
+ {trimmed ? t.inspector.downloadSelection : t.inspector.downloadAll}
+
+ >
+ );
+ }
+ const { job } = item;
+ if (job.status === "done") {
+ return (
+ <>
+
+ {expiryText(job.expires_at, t, new Date())}
+ {formatBytes(job.files[0]?.size_bytes ?? 0, locale)}
+
+
+ >
+ );
+ }
+ if (job.status === "error") {
+ return job.error_code === "bot_check" ? (
+
+ {t.inspector.addCookies}
+
+ ) : (
+ void commands.retryJob(job.job_id)}
+ >
+ {t.queue.retry}
+
+ );
+ }
+ return (
+ <>
+ {t.inspector.keepsRunning}
+ void commands.cancelJob(job.job_id)}
+ >
+ {job.status === "queued" ? t.queue.removeQueued : t.queue.remove}
+
+ >
+ );
+}
diff --git a/apps/web/src/components/inspector/OptionsPanel.tsx b/apps/web/src/components/inspector/OptionsPanel.tsx
new file mode 100644
index 0000000..3d5715a
--- /dev/null
+++ b/apps/web/src/components/inspector/OptionsPanel.tsx
@@ -0,0 +1,106 @@
+"use client";
+
+import type { ReactNode } from "react";
+import type { AudioFormat, Container, DownloadKind } from "@/lib/api/types";
+import { useI18n } from "@/lib/i18n/I18nProvider";
+import { useStore } from "@/state/StoreProvider";
+import type { DraftOptions, ReadyItem } from "@/state/types";
+import { Segmented } from "../controls/Segmented";
+import { Switch } from "../controls/Switch";
+import styles from "./inspector.module.css";
+import { QualityList } from "./QualityList";
+import { SubtitleOptions } from "./SubtitleOptions";
+import { TrimEditor } from "./TrimEditor";
+
+const AUDIO_FORMATS: readonly AudioFormat[] = [
+ "mp3",
+ "m4a",
+ "opus",
+ "flac",
+ "wav",
+];
+const CONTAINERS: readonly Container[] = ["mp4", "mkv"];
+
+export function OptionsPanel({ item }: { item: ReadyItem }): ReactNode {
+ const { t } = useI18n();
+ const { dispatch } = useStore();
+ const { options } = item;
+ const change = (patch: Partial): void =>
+ dispatch({ type: "item/optionsChanged", id: item.id, patch });
+ return (
+
+
+
+
+ label={t.inspector.kind}
+ value={options.kind}
+ onChange={(kind) => change({ kind })}
+ options={[
+ { value: "video", label: t.inspector.video, icon: "videoCamera" },
+ { value: "audio", label: t.inspector.audio, icon: "musicNotes" },
+ ]}
+ />
+ {options.kind === "video" ? (
+
+ label={t.inspector.format}
+ value={options.container}
+ onChange={(container) => change({ container })}
+ options={CONTAINERS.map((value) => ({
+ value,
+ label: value.toUpperCase(),
+ }))}
+ />
+ ) : (
+
+ label={t.inspector.format}
+ value={options.audioFormat}
+ onChange={(audioFormat) => change({ audioFormat })}
+ options={AUDIO_FORMATS.map((value) => ({
+ value,
+ label: value === "opus" ? "Opus" : value.toUpperCase(),
+ }))}
+ />
+ )}
+
+
+
+
{t.inspector.quality}
+
+
+ {item.media.duration ? (
+
+
{t.inspector.trim}
+
+
+ change({ trim })}
+ thumbnail={item.media.thumbnail}
+ />
+
+
+
{t.inspector.trimHelp}
+
+ ) : null}
+
+ {options.kind === "video" ? (
+
{t.inspector.subtitles}
+ ) : null}
+
+ {options.kind === "video" ? (
+
+ ) : null}
+
+ {t.inspector.embedMetadata}
+ change({ embedMetadata })}
+ labelledBy={`embed-${item.id}`}
+ />
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/inspector/QualityList.tsx b/apps/web/src/components/inspector/QualityList.tsx
new file mode 100644
index 0000000..79cd024
--- /dev/null
+++ b/apps/web/src/components/inspector/QualityList.tsx
@@ -0,0 +1,112 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { formatBytes } from "@/lib/format";
+import { useI18n } from "@/lib/i18n/I18nProvider";
+import { estimateBytes } from "@/state/options";
+import type { DraftOptions, ReadyItem } from "@/state/types";
+import { Icon } from "../controls/Icon";
+import styles from "./inspector.module.css";
+
+interface Choice {
+ readonly key: string;
+ readonly label: string;
+ readonly checked: boolean;
+ readonly patch: Partial;
+}
+
+function choicesFor(
+ item: ReadyItem,
+ labels: { best: string; original: string; lossless: string },
+): Choice[] {
+ const { options, formats } = item;
+ if (options.kind === "audio") {
+ if (options.audioFormat === "flac" || options.audioFormat === "wav") {
+ return [
+ {
+ key: "best",
+ label: labels.lossless,
+ checked: true,
+ patch: { audioQuality: "best" },
+ },
+ ];
+ }
+ return [
+ {
+ key: "320k",
+ label: "320 kbps",
+ checked: options.audioQuality === "320k",
+ patch: { audioQuality: "320k" },
+ },
+ {
+ key: "best",
+ label: labels.original,
+ checked: options.audioQuality === "best",
+ patch: { audioQuality: "best" },
+ },
+ ];
+ }
+ if (formats.length === 0)
+ return [
+ {
+ key: "best",
+ label: labels.best,
+ checked: true,
+ patch: { qualityHeight: null },
+ },
+ ];
+ return formats.map((format) => ({
+ key: format.id,
+ label: format.label,
+ checked: format.height === options.qualityHeight,
+ patch: { qualityHeight: format.height },
+ }));
+}
+
+export function QualityList({
+ item,
+ onChange,
+}: {
+ item: ReadyItem;
+ onChange: (patch: Partial) => void;
+}): ReactNode {
+ const { t, locale } = useI18n();
+ const choices = choicesFor(item, {
+ best: t.inspector.qualityBest,
+ original: t.inspector.audioOriginal,
+ lossless: t.inspector.audioLossless,
+ });
+ return (
+
+ {choices.map((choice) => {
+ const size = estimateBytes(
+ { ...item.options, ...choice.patch },
+ item.formats,
+ item.media.duration,
+ );
+ return (
+ onChange(choice.patch)}
+ >
+
+
+
+ {choice.label}
+
+ {size === null ? "" : formatBytes(size, locale)}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/web/src/components/inspector/StatusCard.test.tsx b/apps/web/src/components/inspector/StatusCard.test.tsx
new file mode 100644
index 0000000..105fe19
--- /dev/null
+++ b/apps/web/src/components/inspector/StatusCard.test.tsx
@@ -0,0 +1,90 @@
+import { render, screen } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { describe, expect, it } from "vitest";
+import type { Job } from "@/lib/api/types";
+import { StoreProvider } from "@/state/StoreProvider";
+import type { JobItem } from "@/state/types";
+import { StatusCard } from "./StatusCard";
+
+function jobItem(overrides: Partial): JobItem {
+ return {
+ type: "job",
+ id: "j",
+ media: {
+ url: "https://youtu.be/a",
+ title: "Pho",
+ thumbnail: "",
+ duration: 60,
+ uploader: "",
+ platform: "youtube",
+ },
+ formats: [],
+ options: {
+ kind: "video",
+ container: "mp4",
+ qualityHeight: null,
+ audioFormat: "m4a",
+ audioQuality: "best",
+ trim: null,
+ subtitleLanguages: [],
+ subtitleMode: "embed",
+ embedMetadata: true,
+ },
+ job: {
+ job_id: "j",
+ url: "https://youtu.be/a",
+ title: "Pho",
+ status: "downloading",
+ progress: 10,
+ speed_bps: 1_000_000,
+ eta_seconds: 30,
+ downloaded_bytes: null,
+ total_bytes: null,
+ queue_position: 0,
+ options: {
+ kind: "video",
+ container: "mp4",
+ quality_height: null,
+ format_id: null,
+ audio_format: null,
+ audio_quality: null,
+ trim: null,
+ subtitles: null,
+ embed_metadata: true,
+ },
+ filename: null,
+ files: [],
+ error: null,
+ error_code: null,
+ created_at: "2026-09-14T00:00:00Z",
+ finished_at: null,
+ expires_at: null,
+ ...overrides,
+ },
+ linkedAt: 0,
+ };
+}
+
+function card(item: JobItem): ReactNode {
+ return (
+
+
+
+ );
+}
+
+describe("StatusCard", () => {
+ it("announces status changes but not speed and time left", () => {
+ const { container, rerender } = render(card(jobItem({})));
+ const live = container.querySelectorAll("[aria-live]");
+ expect(live).toHaveLength(1);
+ expect(live[0]).toHaveTextContent("Downloading MP4");
+ expect(screen.getByText(/30 s left/).closest("[aria-live]")).toBeNull();
+ rerender(card(jobItem({ progress: 50, eta_seconds: 12 })));
+ expect(container.querySelector("[aria-live]")).toBe(live[0]);
+ expect(live[0]).toHaveTextContent("Downloading MP4");
+ rerender(card(jobItem({ status: "done", progress: 100 })));
+ expect(container.querySelector("[aria-live]")).toBe(live[0]);
+ expect(live[0]).toHaveTextContent("Download complete");
+ });
+});
diff --git a/apps/web/src/components/inspector/StatusCard.tsx b/apps/web/src/components/inspector/StatusCard.tsx
new file mode 100644
index 0000000..94e6d25
--- /dev/null
+++ b/apps/web/src/components/inspector/StatusCard.tsx
@@ -0,0 +1,161 @@
+"use client";
+
+import type { CSSProperties, ReactNode } from "react";
+import { remainingText } from "@/lib/describe";
+import { formatBytes, formatSpeed } from "@/lib/format";
+import { useI18n } from "@/lib/i18n/I18nProvider";
+import { jobLabel } from "@/state/reducer";
+import { useStore } from "@/state/StoreProvider";
+import type { Messages } from "@/lib/i18n/en";
+import type { FetchErrorItem, JobItem } from "@/state/types";
+import { Icon } from "../controls/Icon";
+import styles from "./inspector.module.css";
+
+function errorMessage(
+ code: string,
+ messages: Record,
+ fallback: string,
+): string {
+ return messages[code] ?? fallback;
+}
+
+type StatusItem = JobItem | FetchErrorItem;
+
+function statusTitle(item: StatusItem, t: Messages): string {
+ if (item.type === "fetch-error") return t.inspector.errorTitle;
+ const { job } = item;
+ switch (job.status) {
+ case "downloading":
+ return t.inspector.downloadingTitle(jobLabel(job));
+ case "queued":
+ return t.inspector.queuedTitle(job.queue_position);
+ case "processing":
+ return t.inspector.processingTitle;
+ case "done":
+ return t.inspector.doneTitle;
+ default:
+ return t.inspector.errorTitle;
+ }
+}
+
+export function StatusCard({ item }: { item: StatusItem }): ReactNode {
+ const { t } = useI18n();
+ return (
+ <>
+
+ {statusTitle(item, t)}
+
+
+ >
+ );
+}
+
+function StatusDetails({ item }: { item: StatusItem }): ReactNode {
+ const { t, locale } = useI18n();
+ const { state } = useStore();
+ const errors: Record = t.errors;
+ if (item.type === "fetch-error") {
+ return (
+
+
+
+
+
+
{statusTitle(item, t)}
+
{errorMessage(item.code, errors, t.errors.unknown_error)}
+
+
+ );
+ }
+ const { job } = item;
+ if (job.status === "downloading") {
+ const style = { "--progress": job.progress } as CSSProperties;
+ const detail = [
+ job.speed_bps === null ? "" : formatSpeed(job.speed_bps, locale),
+ remainingText(job.eta_seconds, t),
+ ]
+ .filter(Boolean)
+ .join(", ");
+ return (
+
+
+
+
+
+
+
+ {Math.floor(job.progress)}%
+
+
+
+
{statusTitle(item, t)}
+
{detail}
+
+
+ );
+ }
+ const cards: Record<
+ string,
+ {
+ icon: "timer" | "arrowClockwise" | "checkCircle" | "warningCircle";
+ tone: string;
+ body: string;
+ }
+ > = {
+ queued: {
+ icon: "timer",
+ tone: "",
+ body: t.inspector.queuedHelp,
+ },
+ processing: {
+ icon: "arrowClockwise",
+ tone: "",
+ body: t.inspector.processingHelp,
+ },
+ done: {
+ icon: "checkCircle",
+ tone: styles.done,
+ body: `${jobLabel(job)}, ${formatBytes(job.files[0]?.size_bytes ?? 0, locale)}`,
+ },
+ error: {
+ icon: "warningCircle",
+ tone: styles.error,
+ body: errorMessage(
+ job.error_code ?? "unknown_error",
+ errors,
+ job.error ?? t.errors.unknown_error,
+ ),
+ },
+ };
+ const card = cards[job.status] ?? cards.error;
+ return (
+
+
+
+
+
+
{statusTitle(item, t)}
+
{card.body}
+ {job.status === "error" && state.cookies?.present ? (
+
{t.inspector.cookiesReady}
+ ) : null}
+
+
+ );
+}
diff --git a/apps/web/src/components/inspector/SubtitleOptions.tsx b/apps/web/src/components/inspector/SubtitleOptions.tsx
new file mode 100644
index 0000000..487c306
--- /dev/null
+++ b/apps/web/src/components/inspector/SubtitleOptions.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import type { ReactNode } from "react";
+import type { SubtitleMode } from "@/lib/api/types";
+import { useI18n } from "@/lib/i18n/I18nProvider";
+import type { DraftOptions } from "@/state/types";
+import { Segmented } from "../controls/Segmented";
+import styles from "./inspector.module.css";
+
+type SubtitleChoice = "off" | "vi" | "en";
+
+export function SubtitleOptions({
+ options,
+ onChange,
+}: {
+ options: DraftOptions;
+ onChange: (patch: Partial) => void;
+}): ReactNode {
+ const { t } = useI18n();
+ const first = options.subtitleLanguages[0];
+ const current: SubtitleChoice =
+ first === "vi" || first === "en" ? first : "off";
+ return (
+
+
+ label={t.inspector.subtitles}
+ value={current}
+ onChange={(choice) =>
+ onChange({ subtitleLanguages: choice === "off" ? [] : [choice] })
+ }
+ options={[
+ { value: "off", label: t.inspector.subtitlesOff },
+ { value: "vi", label: t.inspector.subtitlesVietnamese },
+ { value: "en", label: t.inspector.subtitlesEnglish },
+ ]}
+ />
+ {current !== "off" ? (
+
+ label={t.inspector.subtitles}
+ value={options.subtitleMode}
+ onChange={(mode) => onChange({ subtitleMode: mode })}
+ options={[
+ { value: "embed", label: t.inspector.subtitleEmbed },
+ { value: "srt", label: t.inspector.subtitleFile },
+ ]}
+ />
+ ) : null}
+
+ );
+}
diff --git a/apps/web/src/components/inspector/TrimEditor.test.tsx b/apps/web/src/components/inspector/TrimEditor.test.tsx
new file mode 100644
index 0000000..058e84e
--- /dev/null
+++ b/apps/web/src/components/inspector/TrimEditor.test.tsx
@@ -0,0 +1,117 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { TrimEditor } from "./TrimEditor";
+
+HTMLElement.prototype.setPointerCapture = function setPointerCapture(): void {};
+
+describe("TrimEditor", () => {
+ it("moves handles with the keyboard and accepts typed times", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+ const start = screen.getByRole("slider", { name: "Start point" });
+ expect(start).toHaveAttribute("aria-valuetext", "0:00");
+ start.focus();
+ await user.keyboard("{ArrowRight}");
+ expect(onChange).toHaveBeenLastCalledWith({ start: 1, end: 120 });
+ const end = screen.getByLabelText("End");
+ await user.clear(end);
+ await user.type(end, "1:00{Enter}");
+ expect(onChange).toHaveBeenLastCalledWith({ start: 0, end: 60 });
+ });
+
+ it("shows the selected length between the start and end fields", () => {
+ render(
+ ,
+ );
+ const length = screen.getByText("Length 0:07");
+ const row = length.parentElement;
+ expect(row?.children).toHaveLength(3);
+ expect(row?.children[1]).toBe(length);
+ expect(row?.children[2]).toContainElement(screen.getByLabelText("End"));
+ });
+
+ it("reports a full range as no trim", async () => {
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+ screen.getByRole("slider", { name: "End point" }).focus();
+ await userEvent.keyboard("{End}");
+ expect(onChange).toHaveBeenLastCalledWith(null);
+ });
+
+ it("treats the full length of a fractional duration as no trim", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+ screen.getByRole("slider", { name: "End point" }).focus();
+ await user.keyboard("{End}");
+ expect(onChange).toHaveBeenLastCalledWith(null);
+ const end = screen.getByLabelText("End");
+ await user.clear(end);
+ await user.type(end, "3:32{Enter}");
+ expect(onChange).toHaveBeenLastCalledWith(null);
+ });
+
+ it("treats dragging the end fully right as no trim", () => {
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+ const handle = screen.getByRole("slider", { name: "End point" });
+ const track = handle.parentElement!;
+ vi.spyOn(track, "getBoundingClientRect").mockReturnValue(
+ DOMRect.fromRect({ x: 0, y: 0, width: 400, height: 40 }),
+ );
+ fireEvent.pointerDown(handle, { clientX: 400, pointerId: 1 });
+ expect(onChange).toHaveBeenLastCalledWith(null);
+ });
+
+ it("keeps a quote in the thumbnail url inside the filmstrip url", () => {
+ render(
+ ,
+ );
+ const track = screen.getByRole("slider", {
+ name: "Start point",
+ }).parentElement!;
+ expect(track.style.backgroundImage).toBe(
+ 'url("https://i.example/a\\") , url(\\"https://evil.example/x.png")',
+ );
+ });
+});
diff --git a/apps/web/src/components/inspector/TrimEditor.tsx b/apps/web/src/components/inspector/TrimEditor.tsx
new file mode 100644
index 0000000..2b94559
--- /dev/null
+++ b/apps/web/src/components/inspector/TrimEditor.tsx
@@ -0,0 +1,196 @@
+"use client";
+
+import {
+ useRef,
+ useState,
+ type KeyboardEvent,
+ type PointerEvent,
+ type ReactNode,
+} from "react";
+import type { TrimRange } from "@/lib/api/types";
+import { formatClock, parseClock } from "@/lib/format";
+import { useI18n } from "@/lib/i18n/I18nProvider";
+import { Icon } from "../controls/Icon";
+import styles from "./inspector.module.css";
+import {
+ fullRange,
+ nearestEdge,
+ nudgeEdge,
+ secondsAtPointer,
+ setEdge,
+ type TrimEdge,
+} from "./trim";
+
+const FRAME_WIDTH = 92;
+
+interface TrimEditorProps {
+ duration: number;
+ value: TrimRange | null;
+ onChange: (range: TrimRange | null) => void;
+ thumbnail: string;
+}
+
+function edgeFromTarget(target: EventTarget): TrimEdge | null {
+ const edge =
+ target instanceof HTMLElement
+ ? target.closest("[data-edge]")?.dataset.edge
+ : undefined;
+ return edge === "start" || edge === "end" ? edge : null;
+}
+
+function toValue(range: TrimRange, duration: number): TrimRange | null {
+ return range.start === 0 && range.end === duration ? null : range;
+}
+
+export function TrimEditor({
+ duration,
+ value,
+ onChange,
+ thumbnail,
+}: TrimEditorProps): ReactNode {
+ const { t } = useI18n();
+ const range = value ?? fullRange(duration);
+ const track = useRef(null);
+ const handles = {
+ start: useRef(null),
+ end: useRef(null),
+ };
+ const [dragging, setDragging] = useState(null);
+ const [draft, setDraft] = useState>({
+ start: null,
+ end: null,
+ });
+
+ const commit = (next: TrimRange): void => onChange(toValue(next, duration));
+ const edgeAt = (
+ clientX: number,
+ ): { edge: TrimEdge; seconds: number } | null => {
+ const bounds = track.current?.getBoundingClientRect();
+ if (!bounds) return null;
+ const seconds = secondsAtPointer(clientX, bounds, duration);
+ return { edge: nearestEdge(range, seconds), seconds };
+ };
+
+ const beginDrag = (event: PointerEvent): void => {
+ const target = edgeAt(event.clientX);
+ if (!target) return;
+ const edge = edgeFromTarget(event.target) ?? target.edge;
+ event.currentTarget.setPointerCapture(event.pointerId);
+ handles[edge].current?.focus({ preventScroll: true });
+ setDragging(edge);
+ commit(setEdge(range, edge, target.seconds, duration));
+ };
+ const moveDrag = (event: PointerEvent): void => {
+ const target = dragging ? edgeAt(event.clientX) : null;
+ if (dragging && target)
+ commit(setEdge(range, dragging, target.seconds, duration));
+ };
+ const endDrag = (): void => setDragging(null);
+
+ const handleKey =
+ (edge: TrimEdge) =>
+ (event: KeyboardEvent): void => {
+ const next = nudgeEdge(range, edge, event.key, event.shiftKey, duration);
+ if (!next) return;
+ event.preventDefault();
+ commit(next);
+ };
+
+ const commitTyped = (edge: TrimEdge): void => {
+ const text = draft[edge];
+ setDraft((current) => ({ ...current, [edge]: null }));
+ if (text === null) return;
+ const seconds = parseClock(text);
+ if (seconds === null || seconds > duration) return;
+ commit(setEdge(range, edge, seconds, duration));
+ };
+
+ const timeField = (edge: TrimEdge): ReactNode => (
+
+ {edge === "start" ? t.inspector.trimStart : t.inspector.trimEnd}
+
+ setDraft((current) => ({
+ ...current,
+ [edge]: event.target.value,
+ }))
+ }
+ onBlur={() => commitTyped(edge)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") commitTyped(edge);
+ }}
+ />
+
+ );
+
+ const startPercent = (range.start / duration) * 100;
+ const endPercent = (range.end / duration) * 100;
+ const filmstrip = thumbnail
+ ? {
+ backgroundImage: `url(${JSON.stringify(thumbnail)})`,
+ backgroundSize: `${FRAME_WIDTH}px 100%`,
+ }
+ : undefined;
+
+ return (
+
+
+
+
+
+ {(["start", "end"] as const).map((edge) => (
+
+
+
+ ))}
+
+
+ {timeField("start")}
+
+ {t.inspector.trimLength(formatClock(range.end - range.start))}
+
+ {timeField("end")}
+
+
+ );
+}
diff --git a/apps/web/src/components/inspector/inspector.module.css b/apps/web/src/components/inspector/inspector.module.css
new file mode 100644
index 0000000..c7b4ed5
--- /dev/null
+++ b/apps/web/src/components/inspector/inspector.module.css
@@ -0,0 +1,401 @@
+.inspector {
+ min-height: 0;
+ display: grid;
+ grid-template-rows: minmax(0, 1fr) auto;
+ background: var(--content);
+ box-shadow: inset 0.5px 0 0 var(--separator);
+ position: relative;
+}
+.scroll {
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ padding: 20px 20px 24px;
+}
+.foot {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ min-height: 68px;
+ padding: 12px 20px calc(12px + var(--safe-bottom));
+ box-shadow: inset 0 0.5px 0 var(--separator);
+}
+
+.emptyState {
+ padding: 40px 16px;
+ text-align: center;
+ color: var(--secondary);
+}
+
+.titleBlock {
+ display: grid;
+ gap: 4px;
+ margin-top: 16px;
+}
+.titleBlock h2 {
+ font-family: var(--font-display);
+ font-size: 19px;
+ line-height: 1.25;
+ font-weight: 650;
+ letter-spacing: -0.02em;
+ text-wrap: balance;
+}
+.meta {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 4px 10px;
+ font-size: 13px;
+ color: var(--secondary);
+}
+
+.options {
+ margin-top: 22px;
+}
+
+.group {
+ display: grid;
+ gap: 7px;
+}
+.group + .group {
+ margin-top: 22px;
+}
+.groupLabel {
+ padding-inline: 14px;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--secondary);
+}
+.groupNote {
+ padding-inline: 14px;
+ font-size: 12px;
+ color: var(--tertiary);
+ line-height: 1.4;
+}
+.groupBody {
+ display: grid;
+ border-radius: var(--radius-group);
+ background: var(--cell);
+ box-shadow: inset 0 0 0 0.5px var(--separator);
+ overflow: hidden;
+}
+.cell {
+ position: relative;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 12px;
+ min-height: 44px;
+ padding: 8px 14px;
+ font-size: 14px;
+}
+.cellStack {
+ position: relative;
+ display: grid;
+ gap: 10px;
+ padding: 12px 14px;
+}
+.cell + .cell::before,
+.cellStack + .cellStack::before,
+.cell + .cellStack::before,
+.cellStack + .cell::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 14px;
+ right: 0;
+ height: 0.5px;
+ background: var(--separator);
+}
+
+.choice {
+ display: grid;
+ grid-template-columns: 22px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ min-height: 42px;
+ padding: 8px 14px;
+ border: 0;
+ background: transparent;
+ text-align: left;
+ font-size: 14px;
+ cursor: pointer;
+ position: relative;
+ transition: background-color 0.15s ease;
+}
+.choice + .choice::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 46px;
+ right: 0;
+ height: 0.5px;
+ background: var(--separator);
+}
+.choice:hover {
+ background: var(--fill-hover);
+}
+.choice:active {
+ background: var(--fill);
+}
+.choiceCheck {
+ color: var(--accent);
+ opacity: 0;
+ transform: scale(0.5);
+ transition:
+ opacity 0.2s ease,
+ transform var(--duration-base) var(--spring-bouncy);
+}
+.choice[aria-checked="true"] .choiceCheck {
+ opacity: 1;
+ transform: none;
+}
+.choice[aria-checked="true"] .choiceName {
+ font-weight: 600;
+}
+.choiceSize {
+ color: var(--secondary);
+ font-size: 13px;
+ font-variant-numeric: tabular-nums;
+}
+
+.trim {
+ display: grid;
+ gap: 12px;
+}
+.trimTrack {
+ position: relative;
+ height: 52px;
+ border-radius: 8px;
+ background-color: var(--fill);
+ background-size: auto 100%;
+ background-repeat: repeat-x;
+ touch-action: none;
+ user-select: none;
+}
+.trimShadeStart,
+.trimShadeEnd {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ background: rgb(0 0 0 / 0.5);
+ pointer-events: none;
+}
+.trimShadeStart {
+ left: 0;
+ border-radius: 8px 0 0 8px;
+}
+.trimShadeEnd {
+ right: 0;
+ border-radius: 0 8px 8px 0;
+}
+.trimFrame {
+ position: absolute;
+ top: -3px;
+ bottom: -3px;
+ border-block: 3px solid var(--trim);
+ border-radius: 7px;
+ pointer-events: none;
+ transition: box-shadow 0.2s ease;
+}
+.trimHandle {
+ position: absolute;
+ top: -3px;
+ bottom: -3px;
+ width: 16px;
+ display: grid;
+ place-items: center;
+ padding: 0;
+ border: 0;
+ background: var(--trim);
+ color: var(--on-trim);
+ cursor: ew-resize;
+ touch-action: none;
+}
+.handleStart {
+ border-radius: 7px 2px 2px 7px;
+ transform: translateX(-100%);
+}
+.handleEnd {
+ border-radius: 2px 7px 7px 2px;
+}
+.handleStart svg {
+ transform: scaleX(-1);
+}
+.trimHandle:focus-visible {
+ outline-offset: 3px;
+}
+.dragging .trimFrame {
+ box-shadow: 0 0 0 1px var(--trim);
+}
+.trimTimes {
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
+ align-items: end;
+ gap: 10px;
+}
+.timeField {
+ display: grid;
+ gap: 4px;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--secondary);
+}
+.timeField input {
+ width: 100%;
+ min-height: 34px;
+ padding: 4px 10px;
+ border: 0;
+ border-radius: 9px;
+ background: var(--fill);
+ font-family: var(--font-mono);
+ font-size: 14px;
+ font-variant-numeric: tabular-nums;
+ outline: none;
+}
+.timeField input:focus {
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 40%, transparent);
+}
+.timeFieldEnd {
+ text-align: right;
+}
+.timeFieldEnd input {
+ text-align: right;
+}
+.trimLength {
+ padding-bottom: 8px;
+ font-size: 12px;
+ color: var(--secondary);
+ text-align: center;
+ font-variant-numeric: tabular-nums;
+}
+
+.statusCard {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: center;
+ gap: 14px;
+ margin-top: 20px;
+ padding: 16px;
+ border-radius: var(--radius-group);
+ background: var(--cell);
+ box-shadow: inset 0 0 0 0.5px var(--separator);
+}
+.statusCard p {
+ color: var(--secondary);
+ font-size: 13px;
+}
+.statusCard strong {
+ display: block;
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--label);
+}
+.statusGlyph {
+ display: grid;
+ place-items: center;
+ width: 56px;
+ height: 56px;
+}
+.done {
+ color: var(--green);
+}
+.error {
+ color: var(--red);
+}
+.bigRing {
+ position: relative;
+ width: 56px;
+ height: 56px;
+}
+.bigRing svg {
+ width: 100%;
+ height: 100%;
+ transform: rotate(-90deg);
+}
+.bigRing circle {
+ fill: none;
+ stroke-width: 3.2;
+}
+.ringTrack {
+ stroke: var(--fill-strong);
+}
+.ringValue {
+ stroke: var(--accent);
+ stroke-linecap: round;
+ stroke-dasharray: 100;
+ stroke-dashoffset: calc(100 - var(--progress, 0));
+ transition: stroke-dashoffset 0.35s linear;
+}
+.bigRingLabel {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ font-size: 13px;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+}
+
+.estimate {
+ display: grid;
+ gap: 0;
+ font-size: 12px;
+ color: var(--secondary);
+}
+.estimate strong {
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--label);
+ font-variant-numeric: tabular-nums;
+}
+.estimateNote {
+ font-size: 12px;
+ color: var(--secondary);
+}
+.footActions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+.primaryLink {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ min-height: 44px;
+ padding: 10px 22px;
+ border-radius: var(--radius-capsule);
+ background: var(--button);
+ color: var(--on-accent);
+ font-weight: 600;
+ font-size: 15px;
+ white-space: nowrap;
+ text-decoration: none;
+ cursor: pointer;
+}
+.secondaryLink {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ min-height: 30px;
+ padding: 5px 14px;
+ border-radius: var(--radius-capsule);
+ background: var(--fill);
+ color: var(--accent-text);
+ font-weight: 600;
+ font-size: 13px;
+ white-space: nowrap;
+ text-decoration: none;
+ cursor: pointer;
+}
+
+@media (max-width: 767px) {
+ .choice,
+ .cell {
+ min-height: 48px;
+ font-size: 15px;
+ }
+}
diff --git a/apps/web/src/components/inspector/trim.test.ts b/apps/web/src/components/inspector/trim.test.ts
new file mode 100644
index 0000000..b0e7aa0
--- /dev/null
+++ b/apps/web/src/components/inspector/trim.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from 'vitest';
+import {
+ fullRange,
+ nearestEdge,
+ nudgeEdge,
+ secondsAtPointer,
+ setEdge,
+} from './trim';
+
+describe('trim math', () => {
+ it('keeps at least one second between edges and stays inside the duration', () => {
+ const range = fullRange(100);
+ expect(setEdge(range, 'start', 150, 100)).toEqual({ start: 99, end: 100 });
+ expect(setEdge({ start: 40, end: 60 }, 'end', 10, 100)).toEqual({
+ start: 40,
+ end: 41,
+ });
+ expect(setEdge(range, 'start', -5, 100)).toEqual({ start: 0, end: 100 });
+ });
+
+ it('nudges with arrows, shift and home or end', () => {
+ const range = { start: 10, end: 50 };
+ expect(nudgeEdge(range, 'start', 'ArrowRight', false, 100)).toEqual({
+ start: 11,
+ end: 50,
+ });
+ expect(nudgeEdge(range, 'end', 'ArrowLeft', true, 100)).toEqual({
+ start: 10,
+ end: 40,
+ });
+ expect(nudgeEdge(range, 'end', 'End', false, 100)).toEqual({
+ start: 10,
+ end: 100,
+ });
+ expect(nudgeEdge(range, 'start', 'Home', false, 100)).toEqual({
+ start: 0,
+ end: 50,
+ });
+ expect(nudgeEdge(range, 'start', 'Enter', false, 100)).toBeNull();
+ });
+
+ it('maps pointer positions and picks the nearest handle', () => {
+ expect(secondsAtPointer(150, { left: 100, width: 200 }, 60)).toBe(15);
+ expect(secondsAtPointer(50, { left: 100, width: 200 }, 60)).toBe(0);
+ expect(nearestEdge({ start: 10, end: 50 }, 20)).toBe('start');
+ expect(nearestEdge({ start: 10, end: 50 }, 40)).toBe('end');
+ });
+
+ it('snaps the end to a fractional duration once it reaches the last whole second', () => {
+ const duration = 212.43;
+ const range = { start: 0, end: 150 };
+ expect(setEdge(range, 'end', duration, duration).end).toBe(duration);
+ expect(setEdge(range, 'end', 212, duration).end).toBe(duration);
+ expect(setEdge(range, 'end', 211.4, duration).end).toBe(211);
+ expect(nudgeEdge(range, 'end', 'End', false, duration)?.end).toBe(duration);
+ });
+});
diff --git a/apps/web/src/components/inspector/trim.ts b/apps/web/src/components/inspector/trim.ts
new file mode 100644
index 0000000..a14c01c
--- /dev/null
+++ b/apps/web/src/components/inspector/trim.ts
@@ -0,0 +1,82 @@
+import type { TrimRange } from '@/lib/api/types';
+
+export const MIN_TRIM_SECONDS = 1;
+export const STEP_SECONDS = 1;
+export const BIG_STEP_SECONDS = 10;
+
+export type TrimEdge = 'start' | 'end';
+
+const clamp = (value: number, minimum: number, maximum: number): number =>
+ Math.min(Math.max(value, minimum), maximum);
+
+export function fullRange(duration: number): TrimRange {
+ return { start: 0, end: duration };
+}
+
+function roundedEnd(seconds: number, duration: number): number {
+ const rounded = Math.round(seconds);
+ return rounded >= Math.floor(duration) ? duration : rounded;
+}
+
+export function setEdge(
+ range: TrimRange,
+ edge: TrimEdge,
+ seconds: number,
+ duration: number,
+): TrimRange {
+ return edge === 'start'
+ ? {
+ start: clamp(Math.round(seconds), 0, range.end - MIN_TRIM_SECONDS),
+ end: range.end,
+ }
+ : {
+ start: range.start,
+ end: clamp(
+ roundedEnd(seconds, duration),
+ range.start + MIN_TRIM_SECONDS,
+ duration,
+ ),
+ };
+}
+
+const KEY_DIRECTIONS: Record = {
+ ArrowLeft: -1,
+ ArrowDown: -1,
+ ArrowRight: 1,
+ ArrowUp: 1,
+};
+
+export function nudgeEdge(
+ range: TrimRange,
+ edge: TrimEdge,
+ key: string,
+ shiftKey: boolean,
+ duration: number,
+): TrimRange | null {
+ if (key === 'Home') return setEdge(range, edge, 0, duration);
+ if (key === 'End') return setEdge(range, edge, duration, duration);
+ const direction = KEY_DIRECTIONS[key];
+ if (direction === undefined) return null;
+ const current = edge === 'start' ? range.start : range.end;
+ return setEdge(
+ range,
+ edge,
+ current + direction * (shiftKey ? BIG_STEP_SECONDS : STEP_SECONDS),
+ duration,
+ );
+}
+
+export function secondsAtPointer(
+ clientX: number,
+ bounds: { left: number; width: number },
+ duration: number,
+): number {
+ const ratio = clamp((clientX - bounds.left) / bounds.width, 0, 1);
+ return ratio * duration;
+}
+
+export function nearestEdge(range: TrimRange, seconds: number): TrimEdge {
+ return Math.abs(seconds - range.start) <= Math.abs(seconds - range.end)
+ ? 'start'
+ : 'end';
+}
diff --git a/apps/web/src/components/overlays/AlertDialog.test.tsx b/apps/web/src/components/overlays/AlertDialog.test.tsx
new file mode 100644
index 0000000..5c689ab
--- /dev/null
+++ b/apps/web/src/components/overlays/AlertDialog.test.tsx
@@ -0,0 +1,27 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { AlertDialog } from "./AlertDialog";
+
+describe("AlertDialog", () => {
+ it("closes on Escape while open", async () => {
+ const onCancel = vi.fn();
+ render(
+ ,
+ );
+ expect(
+ screen.getByRole("alertdialog", { name: "Clear history?" }),
+ ).toBeInTheDocument();
+ await userEvent.keyboard("{Escape}");
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/web/src/components/overlays/AlertDialog.tsx b/apps/web/src/components/overlays/AlertDialog.tsx
new file mode 100644
index 0000000..a1b19f3
--- /dev/null
+++ b/apps/web/src/components/overlays/AlertDialog.tsx
@@ -0,0 +1,71 @@
+"use client";
+
+import { useEffect, useRef, type ReactNode } from "react";
+import { createPortal } from "react-dom";
+import { useFocusTrap } from "@/hooks/useFocusTrap";
+import styles from "./overlays.module.css";
+
+interface AlertDialogProps {
+ open: boolean;
+ title: string;
+ message: string;
+ confirmLabel: string;
+ cancelLabel: string;
+ destructive: boolean;
+ onConfirm: () => void;
+ onCancel: () => void;
+}
+
+export function AlertDialog({
+ open,
+ title,
+ message,
+ confirmLabel,
+ cancelLabel,
+ destructive,
+ onConfirm,
+ onCancel,
+}: AlertDialogProps): ReactNode {
+ const panel = useRef(null);
+ useFocusTrap(panel, open);
+
+ useEffect(() => {
+ if (!open) return;
+ const closeOnEscape = (event: KeyboardEvent): void => {
+ if (event.key === "Escape") onCancel();
+ };
+ document.addEventListener("keydown", closeOnEscape);
+ return () => document.removeEventListener("keydown", closeOnEscape);
+ }, [open, onCancel]);
+
+ if (!open) return null;
+ return createPortal(
+
+
+
+ {title}
+ {message}
+
+
+ {cancelLabel}
+
+
+ {confirmLabel}
+
+
+
+
,
+ document.body,
+ );
+}
diff --git a/apps/web/src/components/overlays/DropOverlay.tsx b/apps/web/src/components/overlays/DropOverlay.tsx
new file mode 100644
index 0000000..1446fb0
--- /dev/null
+++ b/apps/web/src/components/overlays/DropOverlay.tsx
@@ -0,0 +1,70 @@
+"use client";
+
+import { useEffect, useRef, useState, type ReactNode } from "react";
+import { useI18n } from "@/lib/i18n/I18nProvider";
+import { Icon } from "../controls/Icon";
+import styles from "./overlays.module.css";
+
+const TEXT_TYPES = ["text/uri-list", "text/plain"];
+
+function carriesText(event: DragEvent): boolean {
+ return [...(event.dataTransfer?.types ?? [])].some((type) =>
+ TEXT_TYPES.includes(type),
+ );
+}
+
+export function DropOverlay({
+ onDrop,
+}: {
+ onDrop: (text: string) => void;
+}): ReactNode {
+ const { t } = useI18n();
+ const [visible, setVisible] = useState(false);
+ const depth = useRef(0);
+
+ useEffect(() => {
+ const enter = (event: DragEvent): void => {
+ if (!carriesText(event)) return;
+ depth.current += 1;
+ setVisible(true);
+ };
+ const leave = (): void => {
+ depth.current = Math.max(0, depth.current - 1);
+ if (depth.current === 0) setVisible(false);
+ };
+ const over = (event: DragEvent): void => {
+ if (carriesText(event)) event.preventDefault();
+ };
+ const drop = (event: DragEvent): void => {
+ depth.current = 0;
+ setVisible(false);
+ const text =
+ event.dataTransfer?.getData("text/uri-list") ||
+ event.dataTransfer?.getData("text/plain") ||
+ "";
+ if (!text) return;
+ event.preventDefault();
+ onDrop(text);
+ };
+ window.addEventListener("dragenter", enter);
+ window.addEventListener("dragleave", leave);
+ window.addEventListener("dragover", over);
+ window.addEventListener("drop", drop);
+ return () => {
+ window.removeEventListener("dragenter", enter);
+ window.removeEventListener("dragleave", leave);
+ window.removeEventListener("dragover", over);
+ window.removeEventListener("drop", drop);
+ };
+ }, [onDrop]);
+
+ if (!visible) return null;
+ return (
+
+
+
+
{t.importer.dropTitle}
+
+
+ );
+}
diff --git a/apps/web/src/components/overlays/Island.tsx b/apps/web/src/components/overlays/Island.tsx
new file mode 100644
index 0000000..1910aab
--- /dev/null
+++ b/apps/web/src/components/overlays/Island.tsx
@@ -0,0 +1,81 @@
+"use client";
+
+import { useEffect, type ReactNode } from "react";
+import { useI18n } from "@/lib/i18n/I18nProvider";
+import type { Messages } from "@/lib/i18n/en";
+import { useStore } from "@/state/StoreProvider";
+import type { Notice } from "@/state/types";
+import { Icon } from "../controls/Icon";
+import styles from "./overlays.module.css";
+
+const VISIBLE_MS = 2800;
+
+function noticeText(notice: Notice, t: Messages): string {
+ if (notice.tone === "error") {
+ const errors: Record = t.errors;
+ return errors[notice.message] ?? t.errors.unknown_error;
+ }
+ switch (notice.message) {
+ case "fetched":
+ return t.island.fetched;
+ case "playlistAdded":
+ return t.island.playlistAdded(notice.count ?? 0);
+ case "downloaded":
+ return t.island.downloaded(notice.detail ?? "");
+ case "cancelled":
+ return t.island.cancelled;
+ case "removedFromQueue":
+ return t.island.removedFromQueue;
+ case "addedAgain":
+ return t.island.addedAgain;
+ case "cookiesLoaded":
+ return t.island.cookiesLoaded;
+ case "cookiesRemoved":
+ return t.island.cookiesRemoved;
+ case "settingsSaved":
+ return t.island.settingsSaved;
+ case "pasteFallback":
+ return t.importer.pasteFallback;
+ case "noLinks":
+ return t.importer.noLinks;
+ default:
+ return notice.message;
+ }
+}
+
+export function Island(): ReactNode {
+ const { t } = useI18n();
+ const { state, dispatch } = useStore();
+ const notice = state.notice;
+
+ useEffect(() => {
+ if (!notice) return;
+ const timer = window.setTimeout(
+ () => dispatch({ type: "notice/dismissed", id: notice.id }),
+ VISIBLE_MS,
+ );
+ return () => window.clearTimeout(timer);
+ }, [notice, dispatch]);
+
+ return (
+
+ {notice ? (
+ <>
+
+
+
+ {noticeText(notice, t)}
+ >
+ ) : null}
+
+ );
+}
diff --git a/apps/web/src/components/overlays/Sheet.test.tsx b/apps/web/src/components/overlays/Sheet.test.tsx
new file mode 100644
index 0000000..f9d9f73
--- /dev/null
+++ b/apps/web/src/components/overlays/Sheet.test.tsx
@@ -0,0 +1,115 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { Sheet } from "./Sheet";
+
+HTMLElement.prototype.setPointerCapture = function setPointerCapture(): void {};
+HTMLElement.prototype.hasPointerCapture =
+ function hasPointerCapture(): boolean {
+ return true;
+ };
+
+function mockMatchesPhone(matches: boolean): void {
+ vi.spyOn(window, "matchMedia").mockImplementation(
+ (query: string) =>
+ ({
+ matches,
+ media: query,
+ onchange: null,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }) as unknown as MediaQueryList,
+ );
+}
+
+function dragHandle(
+ handle: HTMLElement,
+ distance: number,
+ elapsedMs: number,
+): void {
+ const now = vi.spyOn(performance, "now");
+ now.mockReturnValueOnce(0).mockReturnValueOnce(elapsedMs);
+ fireEvent.pointerDown(handle, { clientY: 0, pointerId: 1 });
+ fireEvent.pointerMove(handle, { clientY: distance, pointerId: 1 });
+ fireEvent.pointerUp(handle, { clientY: distance, pointerId: 1 });
+}
+
+describe("Sheet", () => {
+ it("renders a labelled dialog, moves focus inside and closes on Escape", async () => {
+ const onClose = vi.fn();
+ render(
+
+ Inside
+ ,
+ );
+ const dialog = screen.getByRole("dialog", { name: "Settings" });
+ expect(dialog).toHaveAttribute("aria-modal", "true");
+ expect(dialog.contains(document.activeElement)).toBe(true);
+ await userEvent.keyboard("{Escape}");
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("renders nothing when closed", () => {
+ render(
+
+ Hidden
+ ,
+ );
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+
+ describe("on phone", () => {
+ beforeEach(() => {
+ mockMatchesPhone(true);
+ });
+
+ it("closes when the handle is dragged past the dismiss distance", () => {
+ const onClose = vi.fn();
+ render(
+
+ Body
+ ,
+ );
+ const handle = screen.getByRole("heading", { name: "Settings" })
+ .parentElement!.parentElement!;
+ dragHandle(handle, 200, 500);
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("leaves the pointer with the Done button so its click lands", () => {
+ const onClose = vi.fn();
+ const capture = vi.spyOn(HTMLElement.prototype, "setPointerCapture");
+ render(
+
+ Body
+ ,
+ );
+ const done = screen.getByRole("button", { name: "Done" });
+ fireEvent.pointerDown(done, { clientY: 0, pointerId: 1 });
+ expect(capture).not.toHaveBeenCalled();
+ fireEvent.click(done);
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not close on a short drag", () => {
+ const onClose = vi.fn();
+ render(
+
+ Body
+ ,
+ );
+ const handle = screen.getByRole("heading", { name: "Settings" })
+ .parentElement!.parentElement!;
+ dragHandle(handle, 20, 500);
+ expect(onClose).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/apps/web/src/components/overlays/Sheet.tsx b/apps/web/src/components/overlays/Sheet.tsx
new file mode 100644
index 0000000..05f16ff
--- /dev/null
+++ b/apps/web/src/components/overlays/Sheet.tsx
@@ -0,0 +1,156 @@
+"use client";
+
+import {
+ useEffect,
+ useRef,
+ useState,
+ type PointerEvent,
+ type ReactNode,
+} from "react";
+import { createPortal } from "react-dom";
+import { useFocusTrap } from "@/hooks/useFocusTrap";
+import { PHONE_QUERY, useMediaQuery } from "@/hooks/useMediaQuery";
+import { useI18n } from "@/lib/i18n/I18nProvider";
+import { Capsule } from "../controls/Capsule";
+import styles from "./overlays.module.css";
+
+const DISMISS_DISTANCE = 120;
+const DISMISS_VELOCITY = 0.6;
+const EXIT_DURATION_MS = 420;
+
+interface SheetProps {
+ open: boolean;
+ onClose: () => void;
+ title: string;
+ labelledById: string;
+ children: ReactNode;
+ footer?: ReactNode;
+}
+
+function isOnButton(target: EventTarget): boolean {
+ return target instanceof Element && target.closest("button") !== null;
+}
+
+function useStagedPresence(open: boolean): {
+ mounted: boolean;
+ visible: boolean;
+} {
+ const [mounted, setMounted] = useState(open);
+ const [visible, setVisible] = useState(false);
+ if (open && !mounted) setMounted(true);
+ if (!open && visible) setVisible(false);
+
+ useEffect(() => {
+ if (!open) return;
+ const frame = requestAnimationFrame(() =>
+ requestAnimationFrame(() => setVisible(true)),
+ );
+ return () => cancelAnimationFrame(frame);
+ }, [open]);
+
+ useEffect(() => {
+ if (open) return;
+ const timer = window.setTimeout(() => setMounted(false), EXIT_DURATION_MS);
+ return () => window.clearTimeout(timer);
+ }, [open]);
+
+ return { mounted: mounted || open, visible };
+}
+
+export function Sheet({
+ open,
+ onClose,
+ title,
+ labelledById,
+ children,
+ footer,
+}: SheetProps): ReactNode {
+ const { t } = useI18n();
+ const isPhone = useMediaQuery(PHONE_QUERY);
+ const panel = useRef(null);
+ const drag = useRef({ startY: 0, lastY: 0, lastTime: 0, velocity: 0 });
+ const [offset, setOffset] = useState(0);
+ const { mounted, visible } = useStagedPresence(open);
+ useFocusTrap(panel, open && mounted);
+
+ useEffect(() => {
+ if (!open) return;
+ const closeOnEscape = (event: KeyboardEvent): void => {
+ if (event.key === "Escape") onClose();
+ };
+ document.addEventListener("keydown", closeOnEscape);
+ document.body.dataset.sheetOpen = isPhone ? "true" : "false";
+ return () => {
+ document.removeEventListener("keydown", closeOnEscape);
+ delete document.body.dataset.sheetOpen;
+ };
+ }, [open, onClose, isPhone]);
+
+ if (!mounted) return null;
+
+ const beginDrag = (event: PointerEvent): void => {
+ if (!isPhone || isOnButton(event.target)) return;
+ drag.current = {
+ startY: event.clientY,
+ lastY: event.clientY,
+ lastTime: performance.now(),
+ velocity: 0,
+ };
+ event.currentTarget.setPointerCapture(event.pointerId);
+ };
+ const moveDrag = (event: PointerEvent): void => {
+ if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
+ const now = performance.now();
+ drag.current.velocity =
+ (event.clientY - drag.current.lastY) /
+ Math.max(now - drag.current.lastTime, 1);
+ drag.current.lastY = event.clientY;
+ drag.current.lastTime = now;
+ setOffset(Math.max(0, event.clientY - drag.current.startY));
+ };
+ const endDrag = (event: PointerEvent): void => {
+ if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
+ const shouldClose =
+ offset > DISMISS_DISTANCE || drag.current.velocity > DISMISS_VELOCITY;
+ setOffset(0);
+ if (shouldClose) onClose();
+ };
+
+ return createPortal(
+
+
+
0
+ ? { transform: `translateY(${offset}px)`, transition: "none" }
+ : undefined
+ }
+ >
+
+
+
+ {title}
+
+ {t.settings.done}
+
+
+
+ {children}
+ {footer ? : null}
+
+
,
+ document.body,
+ );
+}
diff --git a/apps/web/src/components/overlays/ShortcutsHud.test.tsx b/apps/web/src/components/overlays/ShortcutsHud.test.tsx
new file mode 100644
index 0000000..b2022d2
--- /dev/null
+++ b/apps/web/src/components/overlays/ShortcutsHud.test.tsx
@@ -0,0 +1,24 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { ShortcutsHud } from "./ShortcutsHud";
+
+function pasteKeys(): string[] {
+ const row = screen.getByText("Paste and get info").parentElement;
+ return [...(row?.querySelectorAll("kbd") ?? [])].map(
+ (key) => key.textContent ?? "",
+ );
+}
+
+describe("ShortcutsHud", () => {
+ it("shows the Command key on Apple platforms", () => {
+ vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel");
+ render( );
+ expect(pasteKeys()).toEqual(["⌘", "V"]);
+ });
+
+ it("shows Ctrl on other platforms", () => {
+ vi.spyOn(navigator, "platform", "get").mockReturnValue("Win32");
+ render( );
+ expect(pasteKeys()).toEqual(["Ctrl", "V"]);
+ });
+});
diff --git a/apps/web/src/components/overlays/ShortcutsHud.tsx b/apps/web/src/components/overlays/ShortcutsHud.tsx
new file mode 100644
index 0000000..046080c
--- /dev/null
+++ b/apps/web/src/components/overlays/ShortcutsHud.tsx
@@ -0,0 +1,82 @@
+"use client";
+
+import { useEffect, useRef, type ReactNode } from "react";
+import { createPortal } from "react-dom";
+import { useFocusTrap } from "@/hooks/useFocusTrap";
+import { useI18n } from "@/lib/i18n/I18nProvider";
+import styles from "./overlays.module.css";
+
+const APPLE_PLATFORM = /mac|iphone|ipad|ipod/i;
+
+interface NavigatorWithUserAgentData extends Navigator {
+ readonly userAgentData?: { readonly platform: string };
+}
+
+function pasteModifier(): string {
+ const browser: NavigatorWithUserAgentData = navigator;
+ const platform = browser.userAgentData?.platform || browser.platform;
+ return APPLE_PLATFORM.test(platform) ? "⌘" : "Ctrl";
+}
+
+function shortcutList(): ReadonlyArray<{
+ readonly keys: readonly string[];
+ readonly label: "focus" | "pasteFetch" | "close" | "show";
+}> {
+ return [
+ { keys: ["/"], label: "focus" },
+ { keys: [pasteModifier(), "V"], label: "pasteFetch" },
+ { keys: ["Esc"], label: "close" },
+ { keys: ["?"], label: "show" },
+ ];
+}
+
+export function ShortcutsHud({
+ open,
+ onClose,
+}: {
+ open: boolean;
+ onClose: () => void;
+}): ReactNode {
+ const { t } = useI18n();
+ const panel = useRef(null);
+ useFocusTrap(panel, open);
+ useEffect(() => {
+ if (!open) return;
+ const close = (event: KeyboardEvent): void => {
+ if (event.key === "Escape") onClose();
+ };
+ document.addEventListener("keydown", close);
+ return () => document.removeEventListener("keydown", close);
+ }, [open, onClose]);
+ if (!open) return null;
+ return createPortal(
+
+
+
+ {t.shortcuts.title}
+
+ {shortcutList().map((shortcut) => (
+
+
{t.shortcuts[shortcut.label]}
+
+ {shortcut.keys.map((key) => (
+ {key}
+ ))}
+
+
+ ))}
+
+
+ {t.shortcuts.dismiss}
+
+
+
,
+ document.body,
+ );
+}
diff --git a/apps/web/src/components/overlays/overlays.module.css b/apps/web/src/components/overlays/overlays.module.css
new file mode 100644
index 0000000..814600f
--- /dev/null
+++ b/apps/web/src/components/overlays/overlays.module.css
@@ -0,0 +1,338 @@
+.layer {
+ position: fixed;
+ inset: 0;
+ z-index: 50;
+ display: grid;
+ place-items: center;
+}
+.scrim {
+ position: fixed;
+ inset: 0;
+ z-index: 40;
+ background: var(--scrim);
+ opacity: 0;
+ transition: opacity 0.35s ease;
+}
+.layer[data-visible="true"] .scrim {
+ opacity: 1;
+}
+
+.panel {
+ position: relative;
+ z-index: 50;
+ outline: none;
+}
+
+.modal {
+ width: min(calc(100% - 32px), 560px);
+ max-height: min(760px, calc(100dvh - 64px));
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr) auto;
+ border-radius: 20px;
+ background: var(--grouped);
+ box-shadow:
+ var(--shadow-float),
+ inset 0 0 0 0.5px var(--glass-line);
+ overflow: hidden;
+ opacity: 0;
+ transform: scale(0.94);
+ transition:
+ opacity 0.25s ease,
+ transform var(--duration-sheet) var(--spring-snappy);
+}
+.layer[data-visible="true"] .modal {
+ opacity: 1;
+ transform: scale(1);
+}
+
+.sheet {
+ position: fixed;
+ inset: auto 0 0 0;
+ width: 100%;
+ height: calc(100dvh - 44px - var(--safe-top));
+ border-radius: 16px 16px 0 0;
+ background: var(--grouped);
+ box-shadow: var(--shadow-float);
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr) auto;
+ overflow: hidden;
+ transform: translateY(105%);
+ transition: transform var(--duration-sheet) var(--spring-smooth);
+}
+.layer[data-visible="true"] .sheet {
+ transform: translateY(0);
+}
+
+.handleArea {
+ touch-action: none;
+}
+.grabber {
+ display: block;
+ width: 36px;
+ height: 5px;
+ margin: 6px auto 0;
+ border-radius: 3px;
+ background: var(--fill-strong);
+}
+@media (min-width: 768px) {
+ .grabber {
+ display: none;
+ }
+}
+
+.head {
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
+ align-items: center;
+ min-height: 52px;
+ padding: 8px 12px;
+ box-shadow: inset 0 -0.5px 0 var(--separator);
+ background: var(--glass-strong);
+}
+.head h2 {
+ grid-column: 2;
+ font-size: 15px;
+ font-weight: 600;
+}
+.head button {
+ grid-column: 3;
+ justify-self: end;
+}
+
+.body {
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ padding: 20px 20px 28px;
+}
+.footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ min-height: 68px;
+ padding: 12px 20px calc(12px + var(--safe-bottom));
+ box-shadow: inset 0 0.5px 0 var(--separator);
+}
+
+@media (max-width: 767px) {
+ .head {
+ min-height: 44px;
+ }
+ .head h2 {
+ font-size: 17px;
+ }
+ .body {
+ padding: 16px 16px calc(28px + var(--safe-bottom));
+ }
+}
+
+.hud {
+ position: relative;
+ z-index: 50;
+ width: min(calc(100% - 48px), 340px);
+ display: grid;
+ gap: 14px;
+ padding: 20px;
+ border-radius: 18px;
+ background: var(--hud);
+ -webkit-backdrop-filter: blur(30px) saturate(160%);
+ backdrop-filter: blur(30px) saturate(160%);
+ color: var(--hud-label);
+ box-shadow: var(--shadow-float);
+ opacity: 0;
+ transform: scale(0.9);
+ transition:
+ opacity 0.2s ease,
+ transform var(--duration-base) var(--spring-snappy);
+}
+.layer[data-visible="true"] .hud {
+ opacity: 1;
+ transform: scale(1);
+}
+.hud h2 {
+ font-size: 15px;
+ font-weight: 600;
+ text-align: center;
+}
+.shortcutList {
+ display: grid;
+ gap: 10px;
+ margin: 0;
+}
+.shortcutList div {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ font-size: 13px;
+}
+.shortcutList dd {
+ display: flex;
+ gap: 4px;
+ margin: 0;
+}
+.hud kbd {
+ background: rgb(255 255 255 / 0.14);
+ color: var(--hud-label);
+ box-shadow: none;
+}
+.hudClose {
+ min-height: 44px;
+ border: 0;
+ border-radius: var(--radius-capsule);
+ background: rgb(255 255 255 / 0.14);
+ color: var(--hud-label);
+ font-weight: 600;
+ cursor: pointer;
+}
+.hudClose:hover {
+ background: rgb(255 255 255 / 0.22);
+}
+
+.alert {
+ position: relative;
+ z-index: 50;
+ width: min(calc(100% - 64px), 290px);
+ display: grid;
+ gap: 4px;
+ padding-top: 20px;
+ border-radius: 16px;
+ background: var(--glass-strong);
+ -webkit-backdrop-filter: blur(30px) saturate(180%);
+ backdrop-filter: blur(30px) saturate(180%);
+ box-shadow: var(--shadow-float);
+ text-align: center;
+ overflow: hidden;
+ opacity: 0;
+ transform: scale(1.12);
+ transition:
+ opacity 0.2s ease,
+ transform var(--duration-base) var(--spring-smooth);
+}
+.layer[data-visible="true"] .alert {
+ opacity: 1;
+ transform: scale(1);
+}
+.alert h2 {
+ padding-inline: 18px;
+ font-size: 16px;
+ font-weight: 600;
+}
+.alert p {
+ padding-inline: 18px;
+ font-size: 13px;
+ color: var(--secondary);
+}
+.alertActions {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ margin-top: 16px;
+ box-shadow: inset 0 0.5px 0 var(--separator);
+}
+.alertActions button {
+ min-height: 44px;
+ border: 0;
+ background: transparent;
+ color: var(--accent-text);
+ font-size: 16px;
+ cursor: pointer;
+}
+.alertActions button + button {
+ box-shadow: inset 0.5px 0 0 var(--separator);
+}
+.alertActions button:active {
+ background: var(--fill);
+}
+.confirm {
+ font-weight: 600;
+}
+.destructive {
+ font-weight: 600;
+ color: var(--red-text);
+}
+
+.island {
+ position: fixed;
+ z-index: 80;
+ top: calc(10px + var(--safe-top));
+ left: 50%;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ max-width: calc(100% - 32px);
+ min-height: 40px;
+ padding: 8px 18px 8px 10px;
+ border-radius: var(--radius-capsule);
+ background: #141416;
+ color: #f5f5f7;
+ box-shadow: 0 10px 30px rgb(0 0 0 / 0.28);
+ font-size: 13px;
+ font-weight: 500;
+ pointer-events: none;
+ opacity: 0;
+ transform: translate(-50%, -24px) scale(0.6, 0.7);
+ transition:
+ opacity 0.22s ease,
+ transform var(--duration-sheet) var(--spring-bouncy);
+}
+.island[data-open="true"] {
+ opacity: 1;
+ transform: translate(-50%, 0) scale(1);
+}
+.islandIcon {
+ display: grid;
+ place-items: center;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ background: var(--accent);
+ color: var(--on-accent);
+ flex: none;
+}
+.island[data-tone="error"] .islandIcon {
+ background: var(--red);
+}
+.islandText {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.dropOverlay {
+ position: fixed;
+ inset: 0;
+ z-index: 90;
+ display: grid;
+ place-items: center;
+ padding: 24px;
+ background: color-mix(in srgb, var(--window) 55%, transparent);
+ -webkit-backdrop-filter: blur(18px);
+ backdrop-filter: blur(18px);
+ animation: arrive 0.35s var(--spring-smooth) both;
+}
+.dropTarget {
+ display: grid;
+ justify-items: center;
+ gap: 12px;
+ width: min(100%, 520px);
+ padding: 56px 24px;
+ border: 2px dashed var(--accent);
+ border-radius: 24px;
+ color: var(--accent-text);
+ font-size: 17px;
+ font-weight: 600;
+}
+
+@media (prefers-reduced-transparency: reduce) {
+ .dropOverlay {
+ background: var(--window);
+ }
+}
+
+@supports not (
+ (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))
+) {
+ .dropOverlay {
+ background: var(--window);
+ }
+}
diff --git a/apps/web/src/components/queue/ProgressRing.tsx b/apps/web/src/components/queue/ProgressRing.tsx
new file mode 100644
index 0000000..619cd8b
--- /dev/null
+++ b/apps/web/src/components/queue/ProgressRing.tsx
@@ -0,0 +1,37 @@
+import type { CSSProperties, ReactNode } from "react";
+import styles from "./queue.module.css";
+
+export function ProgressRing({
+ progress,
+ waiting = false,
+}: {
+ progress: number | null;
+ waiting?: boolean;
+}): ReactNode {
+ const style = { "--progress": progress ?? 0 } as CSSProperties;
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/queue/QueueRow.test.tsx b/apps/web/src/components/queue/QueueRow.test.tsx
new file mode 100644
index 0000000..97bd5c4
--- /dev/null
+++ b/apps/web/src/components/queue/QueueRow.test.tsx
@@ -0,0 +1,198 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import type { ReactNode } from "react";
+import { describe, expect, it, vi } from "vitest";
+import { api } from "@/lib/api/client";
+import { StoreProvider } from "@/state/StoreProvider";
+import type { QueueItem } from "@/state/types";
+import { QueueRow } from "./QueueRow";
+
+const media = {
+ url: "https://youtu.be/a",
+ title: "Pho",
+ thumbnail: "",
+ duration: 60,
+ uploader: "Bep",
+ platform: "youtube" as const,
+};
+const options = {
+ kind: "video" as const,
+ container: "mp4" as const,
+ qualityHeight: null,
+ audioFormat: "m4a" as const,
+ audioQuality: "best" as const,
+ trim: null,
+ subtitleLanguages: [],
+ subtitleMode: "embed" as const,
+ embedMetadata: true,
+};
+
+function wrap(children: ReactNode): ReactNode {
+ return {children} ;
+}
+
+describe("QueueRow", () => {
+ it("offers cookie settings for a bot check error", async () => {
+ vi.spyOn(api, "session").mockResolvedValue({
+ auth_required: false,
+ authenticated: true,
+ limits: { max_filesize_mb: 1, max_playlist_items: 1 },
+ });
+ vi.spyOn(api, "settings").mockResolvedValue({
+ retention_minutes: 60,
+ max_concurrent: 3,
+ });
+ vi.spyOn(api, "storage").mockResolvedValue({
+ used_bytes: 0,
+ limit_bytes: null,
+ free_bytes: 1,
+ });
+ vi.spyOn(api, "cookies").mockResolvedValue({
+ present: false,
+ domains: [],
+ expires_at: null,
+ uploaded_at: null,
+ });
+ vi.spyOn(api, "jobs").mockResolvedValue([]);
+ const onOpenCookies = vi.fn();
+ const item: QueueItem = {
+ type: "job",
+ id: "j",
+ media,
+ formats: [],
+ options,
+ job: {
+ job_id: "j",
+ url: media.url,
+ title: "Pho",
+ status: "error",
+ progress: 0,
+ speed_bps: null,
+ eta_seconds: null,
+ downloaded_bytes: null,
+ total_bytes: null,
+ queue_position: 0,
+ options: {
+ kind: "video",
+ container: "mp4",
+ quality_height: null,
+ format_id: null,
+ audio_format: null,
+ audio_quality: null,
+ trim: null,
+ subtitles: null,
+ embed_metadata: true,
+ },
+ filename: null,
+ files: [],
+ error: "bot",
+ error_code: "bot_check",
+ created_at: "2026-09-14T00:00:00Z",
+ finished_at: null,
+ expires_at: null,
+ },
+ linkedAt: 0,
+ };
+ render(
+ wrap(
+ ,
+ ),
+ );
+ await userEvent.click(screen.getByRole("button", { name: "Fix" }));
+ expect(onOpenCookies).toHaveBeenCalled();
+ });
+
+ it("links done jobs to their file", () => {
+ const item: QueueItem = {
+ type: "job",
+ id: "d",
+ media,
+ formats: [],
+ options,
+ job: {
+ job_id: "d",
+ url: media.url,
+ title: "Pho",
+ status: "done",
+ progress: 100,
+ speed_bps: null,
+ eta_seconds: null,
+ downloaded_bytes: null,
+ total_bytes: null,
+ queue_position: 0,
+ options: {
+ kind: "video",
+ container: "mp4",
+ quality_height: null,
+ format_id: null,
+ audio_format: null,
+ audio_quality: null,
+ trim: null,
+ subtitles: null,
+ embed_metadata: true,
+ },
+ filename: "Pho.mp4",
+ files: [{ index: 0, name: "Pho.mp4", kind: "media", size_bytes: 10 }],
+ error: null,
+ error_code: null,
+ created_at: "2026-09-14T00:00:00Z",
+ finished_at: "2026-09-14T00:01:00Z",
+ expires_at: "2026-09-14T01:01:00Z",
+ },
+ linkedAt: 0,
+ };
+ render(
+ wrap(
+ ,
+ ),
+ );
+ expect(screen.getByRole("link", { name: "Save" })).toHaveAttribute(
+ "href",
+ "/api/file/d",
+ );
+ });
+
+ it("selects through a row button that sits beside the trailing actions", async () => {
+ const user = userEvent.setup();
+ const onSelect = vi.fn();
+ const item: QueueItem = {
+ type: "ready",
+ id: "r",
+ media,
+ formats: [],
+ options,
+ };
+ render(
+ wrap(
+ ,
+ ),
+ );
+ expect(screen.queryByRole("option")).toBeNull();
+ const row = screen.getByRole("button", { name: /Pho/ });
+ expect(row).toHaveAttribute("aria-current", "true");
+ const download = screen.getByRole("button", { name: "Download" });
+ expect(row.contains(download)).toBe(false);
+ await user.click(row);
+ expect(onSelect).toHaveBeenCalledWith("r");
+ row.focus();
+ await user.keyboard("{Enter}");
+ expect(onSelect).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/apps/web/src/components/queue/QueueRow.tsx b/apps/web/src/components/queue/QueueRow.tsx
new file mode 100644
index 0000000..11efe61
--- /dev/null
+++ b/apps/web/src/components/queue/QueueRow.tsx
@@ -0,0 +1,170 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { api } from "@/lib/api/client";
+import { rowLine } from "@/lib/describe";
+import { useI18n } from "@/lib/i18n/I18nProvider";
+import { useStore } from "@/state/StoreProvider";
+import type { QueueItem } from "@/state/types";
+import { Capsule } from "../controls/Capsule";
+import { Icon, type IconName } from "../controls/Icon";
+import { ProgressRing } from "./ProgressRing";
+import { Thumbnail } from "./Thumbnail";
+import styles from "./queue.module.css";
+
+const PLATFORM_ICONS: Record = {
+ youtube: "youtube",
+ tiktok: "tiktok",
+ instagram: "instagram",
+ soundcloud: "soundcloud",
+ x: "xLogo",
+ facebook: "facebook",
+ vimeo: "vimeo",
+ other: "globe",
+};
+
+interface QueueRowProps {
+ item: QueueItem;
+ selected: boolean;
+ onSelect: (id: string) => void;
+ onOpenCookies: () => void;
+}
+
+function Trailing({
+ item,
+ onOpenCookies,
+}: {
+ item: QueueItem;
+ onOpenCookies: () => void;
+}): ReactNode {
+ const { t } = useI18n();
+ const { commands } = useStore();
+ if (item.type === "fetching") return null;
+ if (item.type === "fetch-error")
+ return (
+ void commands.retryFetch(item.id)}
+ >
+ {t.queue.retry}
+
+ );
+ if (item.type === "ready")
+ return (
+ void commands.startDownload(item.id)}
+ >
+ {t.queue.download}
+
+ );
+ const { job } = item;
+ if (job.status === "done") {
+ return (
+ <>
+
+
+
+
+ {t.queue.save}
+
+ >
+ );
+ }
+ if (job.status === "error") {
+ return job.error_code === "bot_check" ? (
+
+ {t.queue.fix}
+
+ ) : (
+ void commands.retryJob(job.job_id)}
+ >
+ {t.queue.retry}
+
+ );
+ }
+ const waiting = job.status === "queued";
+ return (
+ void commands.cancelJob(job.job_id)}
+ >
+
+
+ );
+}
+
+export function QueueRow({
+ item,
+ selected,
+ onSelect,
+ onOpenCookies,
+}: QueueRowProps): ReactNode {
+ const { t, locale } = useI18n();
+ if (item.type === "fetching") {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+ const line = rowLine(item, t, locale);
+ const title = item.type === "fetch-error" ? item.url : item.media.title;
+ const platform = item.type === "fetch-error" ? "other" : item.media.platform;
+ return (
+
+ onSelect(item.id)}
+ >
+ {item.type === "fetch-error" ? (
+
+
+
+ ) : (
+
+ )}
+
+ {title}
+
+
+ {line.text}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/queue/QueueView.tsx b/apps/web/src/components/queue/QueueView.tsx
new file mode 100644
index 0000000..c86fa3c
--- /dev/null
+++ b/apps/web/src/components/queue/QueueView.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { useI18n } from "@/lib/i18n/I18nProvider";
+import { countItems, visibleItems } from "@/state/reducer";
+import { useStore } from "@/state/StoreProvider";
+import { Capsule } from "../controls/Capsule";
+import { QueueRow } from "./QueueRow";
+import styles from "./queue.module.css";
+
+export function QueueView({
+ onOpenItem,
+ onOpenCookies,
+}: {
+ onOpenItem: (id: string) => void;
+ onOpenCookies: () => void;
+}): ReactNode {
+ const { t } = useI18n();
+ const { state, dispatch, commands } = useStore();
+ const items = visibleItems(state);
+ const counts = countItems(state);
+ const readyCount = state.items.filter((item) => item.type === "ready").length;
+ const select = (id: string): void => {
+ dispatch({ type: "item/selected", id });
+ onOpenItem(id);
+ };
+ return (
+
+
+
+ {t.queue.summary(counts.all, counts.active, counts.done)}
+
+
+ {state.settings ? (
+
+ {t.queue.concurrency(state.settings.max_concurrent)}
+
+ ) : null}
+ void commands.startAllReady()}
+ >
+ {t.queue.startAll(readyCount)}
+
+
+
+ {items.length > 0 ? (
+
+ {items.map((item) => (
+
+ ))}
+
+ ) : (
+ {t.queue.empty}
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/queue/Thumbnail.tsx b/apps/web/src/components/queue/Thumbnail.tsx
new file mode 100644
index 0000000..0f98d57
--- /dev/null
+++ b/apps/web/src/components/queue/Thumbnail.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { useState, type ReactNode } from "react";
+import type { DownloadKind } from "@/lib/api/types";
+import { Icon } from "../controls/Icon";
+import styles from "./queue.module.css";
+
+interface ThumbnailProps {
+ src: string;
+ kind: DownloadKind;
+ alt: string;
+ variant: "row" | "artwork";
+ badge?: string;
+}
+
+export function Thumbnail({
+ src,
+ kind,
+ alt,
+ variant,
+ badge,
+}: ThumbnailProps): ReactNode {
+ const [failed, setFailed] = useState(false);
+ const showImage = src !== "" && !failed;
+ return (
+
+ {showImage ? (
+ setFailed(true)}
+ />
+ ) : (
+
+
+
+ )}
+ {badge ? {badge} : null}
+
+ );
+}
diff --git a/apps/web/src/components/queue/queue.module.css b/apps/web/src/components/queue/queue.module.css
new file mode 100644
index 0000000..a13d208
--- /dev/null
+++ b/apps/web/src/components/queue/queue.module.css
@@ -0,0 +1,367 @@
+.view {
+ display: grid;
+ gap: 10px;
+}
+.viewBar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding-inline: 4px;
+ flex-wrap: wrap;
+}
+.summary {
+ font-size: 13px;
+ color: var(--secondary);
+}
+.viewActions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+.note {
+ font-size: 12px;
+ color: var(--tertiary);
+}
+.empty {
+ padding: 40px 16px;
+ text-align: center;
+ color: var(--secondary);
+}
+
+.list {
+ display: grid;
+ border-radius: var(--radius-group);
+ background: var(--cell);
+ box-shadow:
+ var(--shadow-soft),
+ inset 0 0 0 0.5px var(--separator);
+ overflow: hidden;
+}
+
+.row {
+ position: relative;
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 12px;
+ min-height: 64px;
+ padding: 10px 12px;
+ cursor: pointer;
+ outline: none;
+ transition: background-color 0.18s ease;
+}
+.row + .row::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 96px;
+ right: 0;
+ height: 0.5px;
+ background: var(--separator);
+}
+.row:hover {
+ background: var(--fill-hover);
+}
+.selectable {
+ cursor: default;
+ grid-template-columns: minmax(0, 1fr) auto;
+ padding-block: 0;
+ padding-left: 0;
+}
+.rowSelect {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: center;
+ align-self: stretch;
+ gap: 12px;
+ min-width: 0;
+ padding: 10px 0 10px 12px;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ text-align: start;
+ cursor: pointer;
+ outline: none;
+}
+.row:has([aria-current="true"]) {
+ background: var(--accent-soft);
+}
+.row:has([aria-current="true"])::before,
+.row:has([aria-current="true"]) + .row::before {
+ opacity: 0;
+}
+.row:has(.rowSelect:focus-visible) {
+ box-shadow: inset 0 0 0 3px color-mix(in srgb, var(--accent) 50%, transparent);
+}
+.arriving {
+ animation: arrive 0.62s var(--spring-smooth) both;
+}
+
+.thumb {
+ display: block;
+ position: relative;
+ width: 72px;
+ aspect-ratio: 16 / 9;
+ border-radius: var(--radius-thumb);
+ overflow: hidden;
+ background: var(--fill);
+ box-shadow: var(--shadow-thumb);
+}
+.thumb img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+.thumbGlyph {
+ display: grid;
+ place-items: center;
+ width: 100%;
+ height: 100%;
+ color: var(--on-accent);
+ background: var(--button);
+}
+.errorThumb {
+ display: grid;
+ place-items: center;
+ color: var(--red-text);
+ background: color-mix(in srgb, var(--red) 14%, transparent);
+}
+
+.rowText {
+ display: grid;
+ gap: 1px;
+ min-width: 0;
+}
+.rowTitle {
+ font-size: 14px;
+ font-weight: 500;
+ letter-spacing: -0.01em;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.rowLine {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ min-width: 0;
+ font-size: 12.5px;
+ color: var(--secondary);
+ white-space: nowrap;
+ overflow: hidden;
+}
+.rowLine > span:last-child {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.errorLine {
+ color: var(--red-text);
+}
+.rowTrailing {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.ringButton {
+ position: relative;
+ display: grid;
+ place-items: center;
+ width: 34px;
+ height: 34px;
+ padding: 0;
+ border: 0;
+ border-radius: 50%;
+ background: transparent;
+ color: var(--accent);
+ cursor: pointer;
+ transition: transform var(--duration-quick) var(--spring-snappy);
+}
+.ringButton:active {
+ transform: scale(0.88);
+}
+.ring {
+ position: absolute;
+ inset: 3px;
+ width: 28px;
+ height: 28px;
+ transform: rotate(-90deg);
+}
+.ring circle {
+ fill: none;
+ stroke-width: 2.6;
+}
+.ringTrack {
+ stroke: var(--fill-strong);
+}
+.ringValue {
+ stroke: var(--accent);
+ stroke-linecap: round;
+ stroke-dasharray: 100;
+ stroke-dashoffset: calc(100 - var(--progress, 0));
+ transition: stroke-dashoffset 0.35s linear;
+}
+.ringStop {
+ width: 9px;
+ height: 9px;
+ border-radius: 2px;
+ background: var(--accent);
+}
+.waiting .ring {
+ animation: spin 1.1s linear infinite;
+}
+.waiting .ringValue {
+ stroke-dashoffset: 72;
+ stroke: var(--tertiary);
+ transition: none;
+}
+.waiting .ringStop {
+ background: var(--tertiary);
+}
+
+.doneGlyph {
+ display: grid;
+ place-items: center;
+ width: 22px;
+ height: 22px;
+ color: var(--green);
+ animation: pop 0.5s var(--spring-bouncy) both;
+}
+.saveLink {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ min-height: 30px;
+ padding: 5px 14px;
+ border-radius: var(--radius-capsule);
+ background: var(--fill);
+ color: var(--accent-text);
+ font-weight: 600;
+ font-size: 13px;
+ white-space: nowrap;
+ cursor: pointer;
+ text-decoration: none;
+ transition: background-color 0.2s ease;
+}
+.saveLink:hover {
+ background: var(--fill-strong);
+}
+
+.skeleton {
+ background: inherit;
+}
+.skeletonLine {
+ height: 11px;
+ border-radius: 6px;
+ background: linear-gradient(
+ 90deg,
+ var(--fill) 25%,
+ var(--fill-hover) 50%,
+ var(--fill) 75%
+ );
+ background-size: 200% 100%;
+ animation: shimmer 1.3s linear infinite;
+}
+.short {
+ width: 60%;
+}
+
+.artwork {
+ display: block;
+ position: relative;
+ aspect-ratio: 16 / 9;
+ border-radius: var(--radius-artwork);
+ overflow: hidden;
+ background: var(--fill);
+ box-shadow:
+ 0 2px 4px rgb(0 0 0 / 0.08),
+ 0 16px 36px rgb(0 0 0 / 0.16);
+}
+.artwork img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+.artworkBadge {
+ position: absolute;
+ right: 8px;
+ bottom: 8px;
+ padding: 2px 7px;
+ border-radius: 6px;
+ background: rgb(20 20 22 / 0.55);
+ -webkit-backdrop-filter: blur(12px);
+ backdrop-filter: blur(12px);
+ color: #f5f5f7;
+ font-family: var(--font-mono);
+ font-size: 11px;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+@keyframes pop {
+ 0% {
+ transform: scale(0.4);
+ opacity: 0;
+ }
+ 60% {
+ transform: scale(1.12);
+ opacity: 1;
+ }
+ 100% {
+ transform: scale(1);
+ }
+}
+@keyframes shimmer {
+ from {
+ background-position: 150% 0;
+ }
+ to {
+ background-position: -50% 0;
+ }
+}
+@keyframes arrive {
+ from {
+ opacity: 0;
+ transform: translateY(-8px) scale(0.97);
+ filter: blur(6px);
+ }
+ to {
+ opacity: 1;
+ transform: none;
+ filter: blur(0);
+ }
+}
+
+@media (max-width: 767px) {
+ .row {
+ min-height: 68px;
+ }
+ .row:has([aria-current="true"]) {
+ background: transparent;
+ }
+ .row:has([aria-current="true"]) + .row::before {
+ opacity: 1;
+ }
+ .row:active {
+ background: var(--fill);
+ }
+ .rowTitle {
+ font-size: 15px;
+ }
+ .rowLine {
+ font-size: 13px;
+ }
+ .ringButton {
+ width: 44px;
+ height: 44px;
+ }
+ .ring {
+ inset: 8px;
+ }
+}
diff --git a/apps/web/src/components/settings/SettingsSheet.test.tsx b/apps/web/src/components/settings/SettingsSheet.test.tsx
new file mode 100644
index 0000000..960c50b
--- /dev/null
+++ b/apps/web/src/components/settings/SettingsSheet.test.tsx
@@ -0,0 +1,75 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { api } from "@/lib/api/client";
+import { StoreProvider } from "@/state/StoreProvider";
+import { SettingsSheet } from "./SettingsSheet";
+
+function mockServer(): void {
+ vi.spyOn(api, "session").mockResolvedValue({
+ auth_required: false,
+ authenticated: true,
+ limits: { max_filesize_mb: 4096, max_playlist_items: 50 },
+ });
+ vi.spyOn(api, "settings").mockResolvedValue({
+ retention_minutes: 60,
+ max_concurrent: 3,
+ });
+ vi.spyOn(api, "storage").mockResolvedValue({
+ used_bytes: 1_800_000_000,
+ limit_bytes: null,
+ free_bytes: 18_200_000_000,
+ });
+ vi.spyOn(api, "cookies").mockResolvedValue({
+ present: false,
+ domains: [],
+ expires_at: null,
+ uploaded_at: null,
+ });
+ vi.spyOn(api, "jobs").mockResolvedValue([]);
+}
+
+describe("SettingsSheet", () => {
+ it("uploads cookies and saves server settings", async () => {
+ mockServer();
+ const upload = vi.spyOn(api, "uploadCookies").mockResolvedValue({
+ present: true,
+ domains: ["youtube.com"],
+ expires_at: "2030-01-01T00:00:00Z",
+ uploaded_at: "2026-09-14T00:00:00Z",
+ });
+ const save = vi
+ .spyOn(api, "updateSettings")
+ .mockResolvedValue({ retention_minutes: 360, max_concurrent: 3 });
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+ await waitFor(() =>
+ expect(screen.getByText("No cookies yet")).toBeInTheDocument(),
+ );
+ const file = new File(["# Netscape HTTP Cookie File"], "cookies.txt", {
+ type: "text/plain",
+ });
+ await user.upload(screen.getByLabelText("Choose cookies.txt"), file);
+ expect(upload).toHaveBeenCalledWith(file);
+ await user.click(screen.getByRole("radio", { name: "6 hours" }));
+ expect(save).toHaveBeenCalledWith({ retention_minutes: 360 });
+ });
+
+ it("switches the accent color immediately", async () => {
+ mockServer();
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+ await user.click(screen.getByRole("radio", { name: "Pink" }));
+ await waitFor(() =>
+ expect(document.documentElement.dataset.accent).toBe("pink"),
+ );
+ });
+});
diff --git a/apps/web/src/components/settings/SettingsSheet.tsx b/apps/web/src/components/settings/SettingsSheet.tsx
new file mode 100644
index 0000000..02be09a
--- /dev/null
+++ b/apps/web/src/components/settings/SettingsSheet.tsx
@@ -0,0 +1,317 @@
+"use client";
+
+import { useEffect, useRef, type ChangeEvent, type ReactNode } from "react";
+import type { StorageUsage } from "@/lib/api/types";
+import { formatBytes, type Locale } from "@/lib/format";
+import type { Messages } from "@/lib/i18n/en";
+import { useI18n } from "@/lib/i18n/I18nProvider";
+import { DEFAULT_FORMATS, LANGUAGES } from "@/lib/preferences";
+import { ACCENTS, type ThemePreference } from "@/lib/theme";
+import { useStore } from "@/state/StoreProvider";
+import { Capsule } from "../controls/Capsule";
+import controlsStyles from "../controls/controls.module.css";
+import { Icon } from "../controls/Icon";
+import { Segmented } from "../controls/Segmented";
+import { Stepper } from "../controls/Stepper";
+import inspectorStyles from "../inspector/inspector.module.css";
+import { Sheet } from "../overlays/Sheet";
+import styles from "./settings.module.css";
+
+const RETENTION_CHOICES = ["15", "60", "360", "1440"] as const;
+const MILLISECONDS_PER_DAY = 86_400_000;
+
+interface SettingsSheetProps {
+ open: boolean;
+ onClose: () => void;
+ focusCookies: boolean;
+}
+
+function daysUntil(isoDate: string | null): number {
+ return isoDate === null
+ ? 0
+ : Math.max(
+ 0,
+ Math.round(
+ (new Date(isoDate).getTime() - Date.now()) / MILLISECONDS_PER_DAY,
+ ),
+ );
+}
+
+function storagePercent(storage: StorageUsage | null): number {
+ if (storage === null) return 0;
+ const total = storage.limit_bytes ?? storage.used_bytes + storage.free_bytes;
+ return total > 0 ? Math.min(100, (storage.used_bytes / total) * 100) : 0;
+}
+
+function storageText(
+ storage: StorageUsage | null,
+ t: Messages,
+ locale: Locale,
+): string {
+ if (storage === null) return "";
+ const used = formatBytes(storage.used_bytes, locale);
+ return storage.limit_bytes === null
+ ? t.settings.storageUsedUnlimited(
+ used,
+ formatBytes(storage.free_bytes, locale),
+ )
+ : t.settings.storageUsed(used, formatBytes(storage.limit_bytes, locale));
+}
+
+function retentionLabel(
+ t: Messages,
+ minutes: (typeof RETENTION_CHOICES)[number],
+): string {
+ return t.time.retention[
+ Number(minutes) as keyof Messages["time"]["retention"]
+ ];
+}
+
+export function SettingsSheet({
+ open,
+ onClose,
+ focusCookies,
+}: SettingsSheetProps): ReactNode {
+ const { t, locale } = useI18n();
+ const { state, dispatch, commands } = useStore();
+ const cookiesGroup = useRef(null);
+ const { settings, storage, cookies, preferences, session } = state;
+
+ useEffect(() => {
+ if (open && focusCookies)
+ cookiesGroup.current?.scrollIntoView({
+ block: "start",
+ behavior: "smooth",
+ });
+ }, [open, focusCookies]);
+
+ const chooseCookies = (event: ChangeEvent): void => {
+ const file = event.target.files?.[0];
+ event.target.value = "";
+ if (file) void commands.uploadCookies(file);
+ };
+
+ const retentionValue =
+ RETENTION_CHOICES.find(
+ (choice) => Number(choice) === settings?.retention_minutes,
+ ) ?? "60";
+ const chooseDefaultFormat = (value: string): void => {
+ const defaultFormat = DEFAULT_FORMATS.find((format) => format === value);
+ if (defaultFormat)
+ dispatch({ type: "preferences/changed", patch: { defaultFormat } });
+ };
+ const chooseLanguage = (value: string): void => {
+ const language = LANGUAGES.find((candidate) => candidate === value);
+ if (language)
+ dispatch({ type: "preferences/changed", patch: { language } });
+ };
+
+ return (
+
+
+
{t.settings.cookies}
+
+
+
+
+
+
+
+ {cookies?.present
+ ? t.settings.cookiesLoaded(
+ cookies.domains.join(", "),
+ daysUntil(cookies.expires_at),
+ )
+ : t.settings.cookiesNone}
+
+
+ {cookies?.present ? (
+ void commands.removeCookies()}
+ >
+ {t.settings.cookiesRemove}
+
+ ) : (
+
+ {t.settings.cookiesChoose}
+
+
+ )}
+
+
+
{t.settings.cookiesNote}
+
+
+
+
{t.settings.downloads}
+
+
+ {t.settings.retention}
+
+ label={t.settings.retention}
+ value={retentionValue}
+ onChange={(minutes) =>
+ void commands.saveSettings({
+ retention_minutes: Number(minutes),
+ })
+ }
+ options={RETENTION_CHOICES.map((minutes) => ({
+ value: minutes,
+ label: retentionLabel(t, minutes),
+ }))}
+ />
+
+
+ {t.settings.concurrency}
+
+ void commands.saveSettings({ max_concurrent: value })
+ }
+ />
+
+
+ {t.settings.defaultFormat}
+ chooseDefaultFormat(event.target.value)}
+ >
+ {DEFAULT_FORMATS.map((format) => (
+
+ {t.settings.defaultFormats[format]}
+
+ ))}
+
+
+
+
+
+
+
{t.settings.appearance}
+
+
+
+ label={t.settings.theme}
+ value={preferences.theme}
+ onChange={(theme) =>
+ dispatch({ type: "preferences/changed", patch: { theme } })
+ }
+ options={[
+ { value: "system", label: t.settings.themeSystem },
+ { value: "light", label: t.settings.themeLight },
+ { value: "dark", label: t.settings.themeDark },
+ ]}
+ />
+
+
+
{t.settings.accent}
+
+ {ACCENTS.map((accent) => (
+
+ dispatch({
+ type: "preferences/changed",
+ patch: { accent: accent.id },
+ })
+ }
+ />
+ ))}
+
+
+
+ {t.settings.language}
+ chooseLanguage(event.target.value)}
+ >
+ {LANGUAGES.map((language) => (
+
+ {t.settings.languages[language]}
+
+ ))}
+
+
+
+
+
+
+
{t.settings.access}
+
+
+
+
+
+
+
+ {session?.auth_required
+ ? t.settings.passwordOn
+ : t.settings.passwordOff}
+
+
+ {session?.auth_required ? (
+ void commands.signOut()}
+ >
+ {t.settings.signOut}
+
+ ) : null}
+
+
+ {session?.auth_required ? null : (
+
{t.settings.passwordNote}
+ )}
+
+
+
+
{t.settings.storage}
+
+
+ {storageText(storage, t, locale)}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/settings/settings.module.css b/apps/web/src/components/settings/settings.module.css
new file mode 100644
index 0000000..b3cfb21
--- /dev/null
+++ b/apps/web/src/components/settings/settings.module.css
@@ -0,0 +1,89 @@
+.cellLabel {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+}
+.cellIcon {
+ display: grid;
+ place-items: center;
+ width: 28px;
+ height: 28px;
+ border-radius: 7px;
+ color: var(--on-accent);
+ background: var(--button);
+ flex: none;
+}
+.green {
+ background: var(--green-text);
+}
+.gray {
+ background: var(--tertiary);
+}
+
+.select {
+ appearance: none;
+ -webkit-appearance: none;
+ min-height: 30px;
+ padding: 4px 30px 4px 12px;
+ border: 0;
+ border-radius: 8px;
+ background: var(--fill) no-repeat right 10px center / 10px 10px;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10'%3E%3Cpath d='M2 3.5 5 6.5 8 3.5' fill='none' stroke='%238e8e93' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
+ color: var(--label);
+ font-size: 13px;
+ cursor: pointer;
+}
+
+.swatches {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+.swatch {
+ width: 24px;
+ height: 24px;
+ padding: 0;
+ border: 0;
+ border-radius: 50%;
+ cursor: pointer;
+ box-shadow: inset 0 0 0 0.5px rgb(0 0 0 / 0.15);
+ transition: transform var(--duration-quick) var(--spring-bouncy);
+}
+.swatch:hover {
+ transform: scale(1.1);
+}
+.swatch[aria-checked="true"] {
+ box-shadow:
+ 0 0 0 2px var(--cell),
+ 0 0 0 4px currentColor;
+}
+
+.storageBar {
+ display: flex;
+ height: 5px;
+ border-radius: 3px;
+ background: var(--fill);
+ overflow: hidden;
+ gap: 1px;
+}
+.storageBar span {
+ display: block;
+ height: 100%;
+ background: var(--accent);
+}
+
+.highlight {
+ animation: highlight 1.6s ease both;
+}
+@keyframes highlight {
+ 0%,
+ 60% {
+ box-shadow:
+ inset 0 0 0 0.5px var(--separator),
+ 0 0 0 3px color-mix(in srgb, var(--accent) 45%, transparent);
+ }
+ 100% {
+ box-shadow: inset 0 0 0 0.5px var(--separator);
+ }
+}
diff --git a/apps/web/src/components/shell/AppShell.test.tsx b/apps/web/src/components/shell/AppShell.test.tsx
new file mode 100644
index 0000000..5b9e0b5
--- /dev/null
+++ b/apps/web/src/components/shell/AppShell.test.tsx
@@ -0,0 +1,69 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { TABLET_QUERY } from "@/hooks/useMediaQuery";
+import { api } from "@/lib/api/client";
+import { StoreProvider } from "@/state/StoreProvider";
+import { AppShell } from "./AppShell";
+
+function mockTabletServer(): void {
+ vi.spyOn(window, "matchMedia").mockImplementation(
+ (query: string) =>
+ ({
+ matches: query === TABLET_QUERY,
+ media: query,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ }) as unknown as MediaQueryList,
+ );
+ vi.stubGlobal(
+ "IntersectionObserver",
+ class {
+ observe(): void {}
+ disconnect(): void {}
+ },
+ );
+ vi.spyOn(api, "session").mockResolvedValue({
+ auth_required: false,
+ authenticated: true,
+ limits: { max_filesize_mb: 1, max_playlist_items: 1 },
+ });
+ vi.spyOn(api, "settings").mockResolvedValue({
+ retention_minutes: 60,
+ max_concurrent: 3,
+ });
+ vi.spyOn(api, "cookies").mockResolvedValue({
+ present: false,
+ domains: [],
+ expires_at: null,
+ uploaded_at: null,
+ });
+ vi.spyOn(api, "storage").mockResolvedValue({
+ used_bytes: 0,
+ limit_bytes: null,
+ free_bytes: 1,
+ });
+ vi.spyOn(api, "jobs").mockResolvedValue([]);
+}
+
+describe("AppShell on tablet", () => {
+ it("keeps the closed sidebar out of focus and closes it with Escape", async () => {
+ mockTabletServer();
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+ await waitFor(() => expect(api.jobs).toHaveBeenCalled());
+ const sidebar = screen.getByRole("complementary", {
+ name: "Downloads",
+ hidden: true,
+ });
+ expect(sidebar).toHaveAttribute("inert");
+ await user.click(screen.getByRole("button", { name: "Show sidebar" }));
+ expect(sidebar).not.toHaveAttribute("inert");
+ await user.keyboard("{Escape}");
+ expect(sidebar).toHaveAttribute("inert");
+ });
+});
diff --git a/apps/web/src/components/shell/AppShell.tsx b/apps/web/src/components/shell/AppShell.tsx
new file mode 100644
index 0000000..c0056ef
--- /dev/null
+++ b/apps/web/src/components/shell/AppShell.tsx
@@ -0,0 +1,261 @@
+"use client";
+
+import {
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import {
+ PHONE_QUERY,
+ TABLET_QUERY,
+ useMediaQuery,
+} from "@/hooks/useMediaQuery";
+import { useI18n } from "@/lib/i18n/I18nProvider";
+import { linkFromShare, parseLinks } from "@/lib/links";
+import { useStore } from "@/state/StoreProvider";
+import type { PlaylistScope } from "@/state/types";
+import { LoginScreen } from "../auth/LoginScreen";
+import { IconButton } from "../controls/IconButton";
+import { HistoryView } from "../history/HistoryView";
+import { Importer } from "../importer/Importer";
+import { Inspector } from "../inspector/Inspector";
+import { AlertDialog } from "../overlays/AlertDialog";
+import { DropOverlay } from "../overlays/DropOverlay";
+import { Island } from "../overlays/Island";
+import { ShortcutsHud } from "../overlays/ShortcutsHud";
+import { QueueView } from "../queue/QueueView";
+import { SettingsSheet } from "../settings/SettingsSheet";
+import styles from "./shell.module.css";
+import { Sidebar } from "./Sidebar";
+import { TabBar } from "./TabBar";
+import { Toolbar } from "./Toolbar";
+import { useShortcuts } from "./useShortcuts";
+
+function readSharedLink(): string | null {
+ const params = new URLSearchParams(window.location.search);
+ return linkFromShare({ url: params.get("url"), text: params.get("text") });
+}
+
+export function AppShell(): ReactNode {
+ const { t } = useI18n();
+ const { state, dispatch, commands } = useStore();
+ const isPhone = useMediaQuery(PHONE_QUERY);
+ const isTablet = useMediaQuery(TABLET_QUERY);
+ const inputRef = useRef(null);
+ const sentinel = useRef(null);
+ const scroller = useRef(null);
+ const [sharedLink] = useState(() =>
+ typeof window === "undefined" ? null : readSharedLink(),
+ );
+ const [linkText, setLinkText] = useState("");
+ const [scope, setScope] = useState("single");
+ const [scrolled, setScrolled] = useState(false);
+ const [sidebarOpen, setSidebarOpen] = useState(false);
+ const [settingsOpen, setSettingsOpen] = useState(false);
+ const [focusCookies, setFocusCookies] = useState(false);
+ const [shortcutsOpen, setShortcutsOpen] = useState(false);
+ const [clearAlertOpen, setClearAlertOpen] = useState(false);
+ const [inspectorSheetOpen, setInspectorSheetOpen] = useState(false);
+
+ const submitLinks = useCallback(
+ (urls: readonly string[], chosenScope: PlaylistScope): void => {
+ setLinkText("");
+ dispatch({ type: "view/changed", view: "queue", filter: "all" });
+ void commands.fetchLinks(urls, chosenScope);
+ },
+ [commands, dispatch],
+ );
+
+ useEffect(() => {
+ if (window.location.search)
+ window.history.replaceState(null, "", window.location.pathname);
+ }, []);
+
+ useEffect(() => {
+ if (!sharedLink) return;
+ const timer = window.setTimeout(
+ () => submitLinks([sharedLink], "single"),
+ 0,
+ );
+ return () => window.clearTimeout(timer);
+ }, [sharedLink, submitLinks]);
+
+ useEffect(() => {
+ const target = sentinel.current;
+ if (!target) return;
+ const observer = new IntersectionObserver(
+ ([entry]) => setScrolled(!entry.isIntersecting),
+ { root: scroller.current, rootMargin: "-52px 0px 0px 0px" },
+ );
+ observer.observe(target);
+ return () => observer.disconnect();
+ }, []);
+
+ useEffect(() => {
+ const pasteAnywhere = (event: ClipboardEvent): void => {
+ const target = event.target;
+ const typing =
+ target instanceof HTMLElement &&
+ target.closest("input, textarea, select") !== null;
+ const text = event.clipboardData?.getData("text") ?? "";
+ if (typing || parseLinks(text).length === 0) return;
+ event.preventDefault();
+ submitLinks(parseLinks(text), "single");
+ };
+ document.addEventListener("paste", pasteAnywhere);
+ return () => document.removeEventListener("paste", pasteAnywhere);
+ }, [submitLinks]);
+
+ useEffect(() => {
+ if (!sidebarOpen) return;
+ const closeOnEscape = (event: KeyboardEvent): void => {
+ if (event.key === "Escape") setSidebarOpen(false);
+ };
+ document.addEventListener("keydown", closeOnEscape);
+ return () => document.removeEventListener("keydown", closeOnEscape);
+ }, [sidebarOpen]);
+
+ const openShortcuts = useCallback(() => setShortcutsOpen(true), []);
+ const overlayOpen =
+ settingsOpen || shortcutsOpen || clearAlertOpen || inspectorSheetOpen;
+ useShortcuts({ inputRef, onShortcuts: openShortcuts, overlayOpen });
+
+ const openSettings = (cookies = false): void => {
+ setFocusCookies(cookies);
+ setInspectorSheetOpen(false);
+ setSettingsOpen(true);
+ };
+
+ if (state.session?.auth_required && !state.session.authenticated)
+ return ;
+
+ const title =
+ state.view === "history"
+ ? t.history.title
+ : {
+ all: t.nav.queue,
+ active: t.nav.downloading,
+ done: t.nav.done,
+ error: t.nav.attention,
+ }[state.filter];
+
+ return (
+
+
openSettings()}
+ onNavigate={() => setSidebarOpen(false)}
+ />
+
+ setSidebarOpen((open) => !open)}
+ onOpenShortcuts={openShortcuts}
+ />
+
+
+
+
{title}
+
+
+
+ commands.notify({ tone: "info", message: "noLinks" })
+ }
+ onClipboardDenied={() =>
+ commands.notify({ tone: "info", message: "pasteFallback" })
+ }
+ />
+ {isPhone && !state.preferences.installHintDismissed ? (
+
+
{t.importer.installHint}
+
+ dispatch({
+ type: "preferences/changed",
+ patch: { installHintDismissed: true },
+ })
+ }
+ />
+
+ ) : null}
+ {state.view === "history" ? (
+ setClearAlertOpen(true)} />
+ ) : (
+ setInspectorSheetOpen(true)}
+ onOpenCookies={() => openSettings(true)}
+ />
+ )}
+
+
+
+ setInspectorSheetOpen(false)}
+ onOpenCookies={() => openSettings(true)}
+ />
+ {isPhone ? openSettings()} /> : null}
+ {sidebarOpen ? (
+ setSidebarOpen(false)}
+ />
+ ) : null}
+ setSettingsOpen(false)}
+ focusCookies={focusCookies}
+ />
+ setShortcutsOpen(false)}
+ />
+ {
+ dispatch({ type: "history/cleared" });
+ setClearAlertOpen(false);
+ }}
+ onCancel={() => setClearAlertOpen(false)}
+ />
+
+ setLinkText((current) =>
+ [current.trim(), text.trim()].filter(Boolean).join("\n"),
+ )
+ }
+ />
+
+
+ );
+}
diff --git a/apps/web/src/components/shell/Sidebar.tsx b/apps/web/src/components/shell/Sidebar.tsx
new file mode 100644
index 0000000..d5f8c6e
--- /dev/null
+++ b/apps/web/src/components/shell/Sidebar.tsx
@@ -0,0 +1,135 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { formatBytes } from "@/lib/format";
+import { useI18n } from "@/lib/i18n/I18nProvider";
+import { countItems } from "@/state/reducer";
+import { useStore } from "@/state/StoreProvider";
+import type { Filter } from "@/state/types";
+import { BrandMark } from "../controls/BrandMark";
+import { Icon, type IconName } from "../controls/Icon";
+import { IconButton } from "../controls/IconButton";
+import styles from "./shell.module.css";
+
+const FILTER_ITEMS: ReadonlyArray<{
+ filter: Filter;
+ icon: IconName;
+ label: "queue" | "downloading" | "done" | "attention";
+}> = [
+ { filter: "all", icon: "queue", label: "queue" },
+ { filter: "active", icon: "arrowDown", label: "downloading" },
+ { filter: "done", icon: "checkCircle", label: "done" },
+ { filter: "error", icon: "warningCircle", label: "attention" },
+];
+
+export function Sidebar({
+ inert,
+ onOpenSettings,
+ onNavigate,
+}: {
+ inert: boolean;
+ onOpenSettings: () => void;
+ onNavigate: () => void;
+}): ReactNode {
+ const { t, locale } = useI18n();
+ const { state, dispatch } = useStore();
+ const counts = countItems(state);
+ const go = (
+ view: "queue" | "history",
+ filter: Filter = state.filter,
+ ): void => {
+ dispatch({ type: "view/changed", view, filter });
+ onNavigate();
+ };
+ const storage = state.storage;
+ const usedPercent = storage
+ ? Math.min(
+ 100,
+ (storage.used_bytes /
+ (storage.limit_bytes ?? storage.used_bytes + storage.free_bytes)) *
+ 100,
+ )
+ : 0;
+ return (
+
+
+
+
+ OpenMedia
+
+
+ {t.nav.downloads}
+ {FILTER_ITEMS.map((item) => (
+ go("queue", item.filter)}
+ >
+
+ {t.nav[item.label]}
+ {counts[item.filter] || ""}
+
+ ))}
+ {t.nav.other}
+ go("history")}
+ >
+
+ {t.nav.history}
+ {state.history.length || ""}
+
+
+
+ {t.nav.settings}
+
+
+
+
+ {state.preferences.installHintDismissed ? null : (
+
+
+
{t.importer.installHint}
+
+ dispatch({
+ type: "preferences/changed",
+ patch: { installHintDismissed: true },
+ })
+ }
+ />
+
+ )}
+ {storage ? (
+
+
+ {t.settings.storage}
+ {formatBytes(storage.used_bytes, locale)}
+
+
+
+
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/shell/TabBar.tsx b/apps/web/src/components/shell/TabBar.tsx
new file mode 100644
index 0000000..fed2971
--- /dev/null
+++ b/apps/web/src/components/shell/TabBar.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import type { CSSProperties, ReactNode } from "react";
+import { useI18n } from "@/lib/i18n/I18nProvider";
+import { useStore } from "@/state/StoreProvider";
+import { Icon } from "../controls/Icon";
+import styles from "./shell.module.css";
+
+export function TabBar({
+ onOpenSettings,
+}: {
+ onOpenSettings: () => void;
+}): ReactNode {
+ const { t } = useI18n();
+ const { state, dispatch } = useStore();
+ const index = state.view === "history" ? 1 : 0;
+ return (
+
+
+
+ dispatch({ type: "view/changed", view: "queue", filter: "all" })
+ }
+ >
+
+ {t.nav.queue}
+
+ dispatch({ type: "view/changed", view: "history" })}
+ >
+
+ {t.nav.history}
+
+
+
+ {t.nav.settings}
+
+
+ );
+}
diff --git a/apps/web/src/components/shell/Toolbar.test.tsx b/apps/web/src/components/shell/Toolbar.test.tsx
new file mode 100644
index 0000000..d4ab605
--- /dev/null
+++ b/apps/web/src/components/shell/Toolbar.test.tsx
@@ -0,0 +1,52 @@
+import { act, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { StoreProvider } from "@/state/StoreProvider";
+import { Toolbar } from "./Toolbar";
+
+const DARK_QUERY = "(prefers-color-scheme: dark)";
+
+function mockSystemScheme(): (dark: boolean) => void {
+ let dark = false;
+ const listeners = new Set<() => void>();
+ vi.spyOn(window, "matchMedia").mockImplementation(
+ (query: string) =>
+ ({
+ get matches() {
+ return query === DARK_QUERY && dark;
+ },
+ media: query,
+ addEventListener: (_type: string, listener: () => void) =>
+ listeners.add(listener),
+ removeEventListener: (_type: string, listener: () => void) =>
+ listeners.delete(listener),
+ }) as unknown as MediaQueryList,
+ );
+ return (next) => {
+ dark = next;
+ listeners.forEach((listener) => listener());
+ };
+}
+
+describe("Toolbar", () => {
+ it("follows the system scheme when it changes while the page is open", async () => {
+ const setSystemDark = mockSystemScheme();
+ render(
+
+
+ ,
+ );
+ const toggle = screen.getByRole("button", { name: "Switch light or dark" });
+ const lightIcon = toggle.innerHTML;
+ act(() => setSystemDark(true));
+ expect(toggle.innerHTML).not.toBe(lightIcon);
+ await userEvent.click(toggle);
+ expect(document.documentElement.dataset.theme).toBe("light");
+ });
+});
diff --git a/apps/web/src/components/shell/Toolbar.tsx b/apps/web/src/components/shell/Toolbar.tsx
new file mode 100644
index 0000000..cbd695c
--- /dev/null
+++ b/apps/web/src/components/shell/Toolbar.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { useMediaQuery } from "@/hooks/useMediaQuery";
+import { useI18n } from "@/lib/i18n/I18nProvider";
+import { useStore } from "@/state/StoreProvider";
+import { BrandMark } from "../controls/BrandMark";
+import { IconButton } from "../controls/IconButton";
+import styles from "./shell.module.css";
+
+interface ToolbarProps {
+ scrolled: boolean;
+ title: string;
+ sidebarOpen: boolean;
+ onToggleSidebar: () => void;
+ onOpenShortcuts: () => void;
+}
+
+const DARK_SCHEME_QUERY = "(prefers-color-scheme: dark)";
+
+export function Toolbar({
+ scrolled,
+ title,
+ sidebarOpen,
+ onToggleSidebar,
+ onOpenShortcuts,
+}: ToolbarProps): ReactNode {
+ const { t } = useI18n();
+ const { state, dispatch } = useStore();
+ const systemDark = useMediaQuery(DARK_SCHEME_QUERY);
+ const isDark =
+ state.preferences.theme === "dark" ||
+ (state.preferences.theme === "system" && systemDark);
+ const toggleTheme = (): void => {
+ const apply = (): void =>
+ dispatch({
+ type: "preferences/changed",
+ patch: { theme: isDark ? "light" : "dark" },
+ });
+ if (
+ typeof document.startViewTransition === "function" &&
+ !window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ )
+ document.startViewTransition(apply);
+ else apply();
+ };
+ return (
+
+
+
+
+
+
+ {title}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/shell/shell.module.css b/apps/web/src/components/shell/shell.module.css
new file mode 100644
index 0000000..fbb04b3
--- /dev/null
+++ b/apps/web/src/components/shell/shell.module.css
@@ -0,0 +1,400 @@
+.app {
+ display: grid;
+ grid-template-columns: 264px minmax(0, 1fr) 392px;
+ height: 100vh;
+ height: 100dvh;
+}
+
+.sidebar {
+ padding: 8px 0 8px 8px;
+ min-height: 0;
+}
+.sidebarPanel {
+ height: 100%;
+ border-radius: var(--radius-panel);
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr) auto;
+ padding: 14px 10px 10px;
+ gap: 10px;
+ background: color-mix(in srgb, var(--glass) 100%, transparent);
+ box-shadow:
+ var(--shadow-soft),
+ inset 0 0.5px 0 var(--glass-edge),
+ inset 0 0 0 0.5px var(--glass-line);
+}
+
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ padding: 2px 8px 6px;
+}
+.brandName {
+ font-family: var(--font-brand);
+ font-size: 17px;
+ font-weight: 650;
+ letter-spacing: -0.022em;
+}
+
+.sourceList {
+ display: grid;
+ align-content: start;
+ gap: 1px;
+ overflow-y: auto;
+}
+.sourceHeading {
+ padding: 12px 10px 4px;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--tertiary);
+}
+.sourceItem {
+ display: grid;
+ grid-template-columns: 20px 1fr auto;
+ align-items: center;
+ gap: 8px;
+ min-height: 32px;
+ padding: 5px 10px;
+ border: 0;
+ border-radius: 9px;
+ background: transparent;
+ text-align: left;
+ font-size: 13.5px;
+ color: var(--label);
+ cursor: pointer;
+ transition: background-color 0.18s ease;
+}
+.sourceItem svg {
+ color: var(--accent);
+}
+.sourceItem:hover {
+ background: var(--fill-hover);
+}
+.sourceItem[aria-current="true"] {
+ background: var(--fill-strong);
+ font-weight: 500;
+}
+.count {
+ font-size: 12px;
+ color: var(--secondary);
+ font-variant-numeric: tabular-nums;
+}
+
+.sidebarFoot {
+ display: grid;
+ gap: 10px;
+}
+.installCard {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ gap: 8px;
+ align-items: start;
+ padding: 10px 8px 10px 10px;
+ border-radius: var(--radius-group);
+ background: var(--fill-hover);
+ font-size: 12px;
+ color: var(--secondary);
+}
+.installCard svg {
+ color: var(--accent);
+ margin-top: 1px;
+}
+
+.storage {
+ display: grid;
+ gap: 6px;
+ padding: 4px 8px 2px;
+}
+.storageLine {
+ display: flex;
+ justify-content: space-between;
+ font-size: 12px;
+ color: var(--secondary);
+}
+.storageBar {
+ display: flex;
+ height: 5px;
+ border-radius: 3px;
+ background: var(--fill);
+ overflow: hidden;
+}
+.storageBar span {
+ display: block;
+ height: 100%;
+ background: var(--accent);
+}
+
+.content {
+ position: relative;
+ min-width: 0;
+ min-height: 0;
+ display: grid;
+ grid-template-rows: minmax(0, 1fr);
+ transform-origin: 50% 0;
+ transition:
+ transform var(--duration-sheet) var(--spring-smooth),
+ border-radius var(--duration-sheet) var(--spring-smooth),
+ filter var(--duration-sheet) ease;
+}
+
+.toolbar {
+ position: absolute;
+ inset: 0 0 auto 0;
+ z-index: 3;
+ height: 52px;
+ display: grid;
+ grid-template-columns: auto auto 1fr auto;
+ align-items: center;
+ gap: 8px;
+ padding-inline: 16px 12px;
+ background: transparent;
+ box-shadow: none;
+ transition:
+ background-color 0.3s ease,
+ box-shadow 0.3s ease;
+}
+.toolbar[data-scrolled="true"] {
+ background: var(--glass);
+ -webkit-backdrop-filter: blur(28px) saturate(180%);
+ backdrop-filter: blur(28px) saturate(180%);
+ box-shadow: inset 0 -0.5px 0 var(--separator);
+}
+.sidebarToggle,
+.toolbarBrand {
+ display: none;
+}
+.sidebarToggle {
+ grid-column: 1;
+}
+.toolbarBrand {
+ grid-column: 2;
+}
+.toolbarTitle {
+ grid-column: 3;
+ justify-self: center;
+ font-size: 15px;
+ font-weight: 600;
+ letter-spacing: -0.015em;
+ opacity: 0;
+ transform: translateY(6px);
+ transition:
+ opacity 0.25s ease,
+ transform var(--duration-base) var(--spring-smooth);
+}
+.toolbar[data-scrolled="true"] .toolbarTitle {
+ opacity: 1;
+ transform: none;
+}
+.toolbarActions {
+ grid-column: 4;
+ display: flex;
+ gap: 2px;
+ justify-self: end;
+}
+
+.scroller {
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ scroll-padding-top: 64px;
+}
+.page {
+ width: min(100%, 780px);
+ margin-inline: auto;
+ padding: 56px 28px 48px;
+ display: grid;
+ gap: 18px;
+ align-content: start;
+}
+
+.largeTitle {
+ display: flex;
+ align-items: baseline;
+ gap: 12px;
+ padding-inline: 4px;
+}
+.largeTitle h1 {
+ font-family: var(--font-display);
+ font-size: 30px;
+ line-height: 1.1;
+ font-weight: 700;
+ letter-spacing: -0.026em;
+}
+.sentinel {
+ height: 1px;
+ margin-top: -18px;
+}
+
+.installBanner {
+ display: none;
+}
+
+.tabbar {
+ display: none;
+}
+
+.sidebarScrim {
+ display: none;
+ position: fixed;
+ inset: 0;
+ z-index: 40;
+ border: 0;
+ padding: 0;
+ background: var(--scrim);
+}
+
+@media (max-width: 1279px) {
+ .app {
+ grid-template-columns: 232px minmax(0, 1fr) 360px;
+ }
+ .page {
+ padding-inline: 20px;
+ }
+}
+
+@media (max-width: 1023px) {
+ .app {
+ grid-template-columns: minmax(0, 1fr) 352px;
+ }
+ .sidebar {
+ position: fixed;
+ inset: 0 auto 0 0;
+ z-index: 45;
+ width: 280px;
+ padding: 8px;
+ transform: translateX(-104%);
+ transition: transform var(--duration-sheet) var(--spring-smooth);
+ }
+ .sidebarPanel {
+ background: var(--glass-strong);
+ box-shadow:
+ var(--shadow-float),
+ inset 0 0.5px 0 var(--glass-edge);
+ }
+ .app[data-sidebar-open="true"] .sidebar {
+ transform: none;
+ }
+ .sidebarToggle {
+ display: inline-grid;
+ }
+}
+
+@media (min-width: 768px) and (max-width: 1023px) {
+ .sidebarScrim {
+ display: block;
+ }
+}
+
+@media (max-width: 767px) {
+ .app {
+ grid-template-columns: minmax(0, 1fr);
+ border-radius: 0;
+ }
+ .sidebar {
+ display: none;
+ }
+ .toolbar {
+ height: calc(48px + var(--safe-top));
+ padding-top: var(--safe-top);
+ padding-inline: 10px;
+ }
+ .sidebarToggle {
+ display: none;
+ }
+ .toolbarBrand {
+ display: inline-grid;
+ padding-left: 6px;
+ }
+ .shortcutsButton {
+ display: none;
+ }
+ .page {
+ padding: calc(52px + var(--safe-top)) 16px
+ calc(var(--tabbar-height) + 36px + var(--safe-bottom));
+ gap: 14px;
+ }
+ .largeTitle h1 {
+ font-size: 32px;
+ }
+ .installBanner {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 8px 10px 14px;
+ border-radius: var(--radius-group);
+ background: var(--cell);
+ box-shadow: inset 0 0 0 0.5px var(--separator);
+ font-size: 13px;
+ color: var(--secondary);
+ }
+ .installBanner svg {
+ color: var(--accent);
+ }
+
+ .tabbar {
+ position: fixed;
+ left: 50%;
+ bottom: calc(10px + var(--safe-bottom));
+ z-index: 20;
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ width: min(calc(100% - 32px), 340px);
+ height: var(--tabbar-height);
+ padding: 5px;
+ border-radius: var(--radius-capsule);
+ transform: translateX(-50%);
+ background: var(--glass-strong);
+ box-shadow:
+ var(--shadow-float),
+ inset 0 0.5px 0 var(--glass-edge),
+ inset 0 0 0 0.5px var(--glass-line);
+ transition:
+ transform var(--duration-sheet) var(--spring-smooth),
+ opacity 0.3s ease;
+ }
+ body[data-sheet-open="true"] .tabbar {
+ transform: translate(-50%, 140%);
+ opacity: 0;
+ }
+ .tabThumb {
+ position: absolute;
+ top: 5px;
+ bottom: 5px;
+ left: 5px;
+ width: calc((100% - 10px) / 3);
+ border-radius: var(--radius-capsule);
+ background: var(--fill-strong);
+ transform: translateX(calc(var(--tab-index, 0) * 100%));
+ transition: transform var(--duration-base) var(--spring-snappy);
+ }
+ .tab {
+ position: relative;
+ display: grid;
+ justify-items: center;
+ align-content: center;
+ gap: 2px;
+ border: 0;
+ background: transparent;
+ color: var(--secondary);
+ font-size: 10.5px;
+ font-weight: 600;
+ cursor: pointer;
+ border-radius: var(--radius-capsule);
+ transition:
+ color 0.2s ease,
+ transform var(--duration-quick) var(--spring-snappy);
+ }
+ .tab:active {
+ transform: scale(0.92);
+ }
+ .tab[aria-current="page"] {
+ color: var(--accent-text);
+ }
+
+ body[data-sheet-open="true"] .content {
+ transform: scale(0.94) translateY(calc(var(--safe-top) + 6px));
+ border-radius: 14px;
+ overflow: hidden;
+ filter: brightness(0.92);
+ }
+}
diff --git a/apps/web/src/components/shell/useShortcuts.test.tsx b/apps/web/src/components/shell/useShortcuts.test.tsx
new file mode 100644
index 0000000..9cfe0a9
--- /dev/null
+++ b/apps/web/src/components/shell/useShortcuts.test.tsx
@@ -0,0 +1,42 @@
+import { act, render } from "@testing-library/react";
+import { useRef, type ReactNode } from "react";
+import { describe, expect, it, vi } from "vitest";
+import { useShortcuts } from "./useShortcuts";
+
+function Harness({
+ onShortcuts,
+ overlayOpen,
+}: {
+ onShortcuts: () => void;
+ overlayOpen: boolean;
+}): ReactNode {
+ const inputRef = useRef(null);
+ useShortcuts({ inputRef, onShortcuts, overlayOpen });
+ return ;
+}
+
+function fireKey(key: string): void {
+ document.dispatchEvent(
+ new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
+ );
+}
+
+describe("useShortcuts", () => {
+ it("focuses the input on / and opens shortcuts on ? when nothing is open", () => {
+ const onShortcuts = vi.fn();
+ render( );
+ act(() => fireKey("/"));
+ expect(document.activeElement?.tagName).toBe("TEXTAREA");
+ act(() => fireKey("?"));
+ expect(onShortcuts).toHaveBeenCalledTimes(1);
+ });
+
+ it("ignores / and ? while an overlay is open", () => {
+ const onShortcuts = vi.fn();
+ render( );
+ act(() => fireKey("/"));
+ expect(document.activeElement?.tagName).not.toBe("TEXTAREA");
+ act(() => fireKey("?"));
+ expect(onShortcuts).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/src/components/shell/useShortcuts.ts b/apps/web/src/components/shell/useShortcuts.ts
new file mode 100644
index 0000000..3023070
--- /dev/null
+++ b/apps/web/src/components/shell/useShortcuts.ts
@@ -0,0 +1,42 @@
+'use client';
+
+import { useEffect, type RefObject } from 'react';
+
+function isTyping(target: EventTarget | null): boolean {
+ return (
+ target instanceof HTMLElement &&
+ target.closest("input, textarea, select, [contenteditable='true']") !== null
+ );
+}
+
+export function useShortcuts({
+ inputRef,
+ onShortcuts,
+ overlayOpen,
+}: {
+ inputRef: RefObject;
+ onShortcuts: () => void;
+ overlayOpen: boolean;
+}): void {
+ useEffect(() => {
+ const handle = (event: KeyboardEvent): void => {
+ if (
+ overlayOpen ||
+ isTyping(event.target) ||
+ event.metaKey ||
+ event.ctrlKey ||
+ event.altKey
+ )
+ return;
+ if (event.key === '/') {
+ event.preventDefault();
+ inputRef.current?.focus();
+ } else if (event.key === '?') {
+ event.preventDefault();
+ onShortcuts();
+ }
+ };
+ document.addEventListener('keydown', handle);
+ return () => document.removeEventListener('keydown', handle);
+ }, [inputRef, onShortcuts, overlayOpen]);
+}
diff --git a/apps/web/src/hooks/useFocusTrap.ts b/apps/web/src/hooks/useFocusTrap.ts
new file mode 100644
index 0000000..85ef920
--- /dev/null
+++ b/apps/web/src/hooks/useFocusTrap.ts
@@ -0,0 +1,47 @@
+'use client';
+
+import { useEffect, type RefObject } from 'react';
+
+const FOCUSABLE =
+ "button:not([disabled]), [href], input:not([disabled]), select, textarea, [tabindex]:not([tabindex='-1'])";
+
+function focusableWithin(container: HTMLElement): HTMLElement[] {
+ return [...container.querySelectorAll(FOCUSABLE)].filter(
+ (element) =>
+ element.offsetParent !== null || element === document.activeElement,
+ );
+}
+
+export function useFocusTrap(
+ ref: RefObject,
+ active: boolean,
+): void {
+ useEffect(() => {
+ const container = ref.current;
+ if (!active || !container) return;
+ const previouslyFocused =
+ document.activeElement instanceof HTMLElement
+ ? document.activeElement
+ : null;
+ (focusableWithin(container)[0] ?? container).focus({ preventScroll: true });
+ const trap = (event: KeyboardEvent): void => {
+ if (event.key !== 'Tab') return;
+ const focusable = focusableWithin(container);
+ if (focusable.length === 0) return;
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ if (event.shiftKey && document.activeElement === first) {
+ event.preventDefault();
+ last.focus();
+ } else if (!event.shiftKey && document.activeElement === last) {
+ event.preventDefault();
+ first.focus();
+ }
+ };
+ document.addEventListener('keydown', trap);
+ return () => {
+ document.removeEventListener('keydown', trap);
+ previouslyFocused?.focus({ preventScroll: true });
+ };
+ }, [ref, active]);
+}
diff --git a/apps/web/src/hooks/useMediaQuery.ts b/apps/web/src/hooks/useMediaQuery.ts
new file mode 100644
index 0000000..cb84de3
--- /dev/null
+++ b/apps/web/src/hooks/useMediaQuery.ts
@@ -0,0 +1,18 @@
+'use client';
+
+import { useSyncExternalStore } from 'react';
+
+export const PHONE_QUERY = '(max-width: 767px)';
+export const TABLET_QUERY = '(max-width: 1023px)';
+
+export function useMediaQuery(query: string): boolean {
+ return useSyncExternalStore(
+ (onChange) => {
+ const list = window.matchMedia(query);
+ list.addEventListener('change', onChange);
+ return () => list.removeEventListener('change', onChange);
+ },
+ () => window.matchMedia(query).matches,
+ () => false,
+ );
+}
diff --git a/apps/web/src/lib/api/client.test.ts b/apps/web/src/lib/api/client.test.ts
new file mode 100644
index 0000000..73833d6
--- /dev/null
+++ b/apps/web/src/lib/api/client.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it, vi } from 'vitest';
+import { api, ApiRequestError } from './client';
+
+function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
+ return new Response(JSON.stringify(body), {
+ headers: { 'content-type': 'application/json' },
+ ...init,
+ });
+}
+
+describe('api client', () => {
+ it('posts JSON and parses the response', async () => {
+ const fetchMock = vi
+ .spyOn(globalThis, 'fetch')
+ .mockResolvedValue(jsonResponse({ title: 'Pho', formats: [] }));
+ const info = await api.info('https://youtu.be/a');
+ expect(info.title).toBe('Pho');
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(url).toBe('/api/info');
+ expect(init?.method).toBe('POST');
+ expect(new Headers(init?.headers).get('content-type')).toBe(
+ 'application/json',
+ );
+ });
+
+ it('raises typed errors with retry hints', async () => {
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
+ jsonResponse(
+ { error: 'Too many requests.', code: 'rate_limited' },
+ {
+ status: 429,
+ headers: { 'retry-after': '12', 'content-type': 'application/json' },
+ },
+ ),
+ );
+ await expect(api.jobs()).rejects.toMatchObject({
+ status: 429,
+ code: 'rate_limited',
+ retryAfterSeconds: 12,
+ });
+ });
+
+ it('maps network failures to api_unreachable', async () => {
+ vi.spyOn(globalThis, 'fetch').mockRejectedValue(
+ new TypeError('fetch failed'),
+ );
+ const error = await api.session().catch((caught: unknown) => caught);
+ expect(error).toBeInstanceOf(ApiRequestError);
+ expect((error as ApiRequestError).code).toBe('api_unreachable');
+ });
+
+ it('resolves empty responses for deletes', async () => {
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
+ new Response(null, { status: 204 }),
+ );
+ await expect(api.removeJob('abc')).resolves.toBeUndefined();
+ });
+
+ it('builds file urls', () => {
+ expect(api.fileUrl('abc')).toBe('/api/file/abc');
+ expect(api.fileUrl('abc', 1)).toBe('/api/file/abc/1');
+ });
+});
diff --git a/apps/web/src/lib/api/client.ts b/apps/web/src/lib/api/client.ts
new file mode 100644
index 0000000..07ce5cb
--- /dev/null
+++ b/apps/web/src/lib/api/client.ts
@@ -0,0 +1,110 @@
+import type {
+ CookieSummary,
+ DownloadRequest,
+ Job,
+ MediaInfo,
+ PlaylistInfo,
+ RuntimeSettings,
+ SessionInfo,
+ StorageUsage,
+} from './types';
+
+export class ApiRequestError extends Error {
+ constructor(
+ readonly status: number,
+ readonly code: string,
+ message: string,
+ readonly retryAfterSeconds: number | null,
+ ) {
+ super(message);
+ this.name = 'ApiRequestError';
+ }
+}
+
+const API_PREFIX = '/api';
+const UNREACHABLE_STATUS = 0;
+
+function jsonInit(method: string, body?: unknown): RequestInit {
+ return body === undefined
+ ? { method }
+ : {
+ method,
+ body: JSON.stringify(body),
+ headers: { 'content-type': 'application/json' },
+ };
+}
+
+async function send(path: string, init: RequestInit = {}): Promise {
+ try {
+ return await fetch(`${API_PREFIX}${path}`, {
+ credentials: 'same-origin',
+ cache: 'no-store',
+ ...init,
+ });
+ } catch {
+ throw new ApiRequestError(
+ UNREACHABLE_STATUS,
+ 'api_unreachable',
+ 'The OpenMedia API is not reachable.',
+ null,
+ );
+ }
+}
+
+async function errorFrom(response: Response): Promise {
+ const body: unknown = await response.json().catch(() => null);
+ const record =
+ typeof body === 'object' && body !== null
+ ? (body as Record)
+ : {};
+ const retryAfter = Number(response.headers.get('retry-after'));
+ return new ApiRequestError(
+ response.status,
+ typeof record.code === 'string' ? record.code : 'unknown_error',
+ typeof record.error === 'string' ? record.error : response.statusText,
+ Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : null,
+ );
+}
+
+async function requestJson(path: string, init?: RequestInit): Promise {
+ const response = await send(path, init);
+ if (!response.ok) throw await errorFrom(response);
+ return (await response.json()) as T;
+}
+
+async function requestVoid(path: string, init?: RequestInit): Promise {
+ const response = await send(path, init);
+ if (!response.ok) throw await errorFrom(response);
+}
+
+export const api = {
+ session: (): Promise => requestJson('/session'),
+ signIn: (password: string): Promise =>
+ requestVoid('/session', jsonInit('POST', { password })),
+ signOut: (): Promise => requestVoid('/session', { method: 'DELETE' }),
+ info: (url: string): Promise =>
+ requestJson('/info', jsonInit('POST', { url })),
+ playlist: (url: string): Promise =>
+ requestJson('/playlist', jsonInit('POST', { url })),
+ download: (request: DownloadRequest): Promise<{ job_id: string; job: Job }> =>
+ requestJson('/download', jsonInit('POST', request)),
+ jobs: async (signal?: AbortSignal): Promise =>
+ (await requestJson<{ jobs: Job[] }>('/jobs', { signal })).jobs,
+ removeJob: (jobId: string): Promise =>
+ requestVoid(`/jobs/${encodeURIComponent(jobId)}`, { method: 'DELETE' }),
+ settings: (): Promise => requestJson('/settings'),
+ updateSettings: (patch: Partial): Promise =>
+ requestJson('/settings', jsonInit('PUT', patch)),
+ storage: (signal?: AbortSignal): Promise =>
+ requestJson('/storage', { signal }),
+ cookies: (): Promise => requestJson('/cookies'),
+ uploadCookies: (file: File): Promise => {
+ const body = new FormData();
+ body.append('file', file);
+ return requestJson('/cookies', { method: 'PUT', body });
+ },
+ removeCookies: (): Promise =>
+ requestVoid('/cookies', { method: 'DELETE' }),
+ fileUrl: (jobId: string, index?: number): string =>
+ `${API_PREFIX}/file/${encodeURIComponent(jobId)}${index === undefined ? '' : `/${index}`}`,
+};
diff --git a/apps/web/src/lib/api/types.ts b/apps/web/src/lib/api/types.ts
new file mode 100644
index 0000000..e135008
--- /dev/null
+++ b/apps/web/src/lib/api/types.ts
@@ -0,0 +1,131 @@
+export type JobStatus =
+ 'queued' | 'downloading' | 'processing' | 'done' | 'error' | 'cancelled';
+export type DownloadKind = 'video' | 'audio';
+export type Container = 'mp4' | 'mkv';
+export type AudioFormat = 'mp3' | 'm4a' | 'opus' | 'flac' | 'wav';
+export type AudioQuality = '320k' | 'best';
+export type SubtitleMode = 'embed' | 'srt';
+
+export interface MediaFormat {
+ readonly id: string;
+ readonly label: string;
+ readonly height: number;
+ readonly ext: string | null;
+ readonly filesize: number | null;
+}
+
+export interface MediaInfo {
+ readonly id: string | null;
+ readonly title: string;
+ readonly thumbnail: string;
+ readonly duration: number | null;
+ readonly uploader: string;
+ readonly platform: string;
+ readonly webpage_url: string;
+ readonly formats: readonly MediaFormat[];
+ readonly subtitle_languages: readonly string[];
+ readonly has_chapters: boolean;
+ readonly is_playlist: boolean;
+}
+
+export interface PlaylistInfo {
+ readonly title: string;
+ readonly count: number;
+ readonly urls: readonly string[];
+}
+
+export interface TrimRange {
+ readonly start: number;
+ readonly end: number;
+}
+
+export interface SubtitleSelection {
+ readonly languages: readonly string[];
+ readonly mode: SubtitleMode;
+}
+
+export interface DownloadRequest {
+ readonly url: string;
+ readonly title: string;
+ readonly format: DownloadKind;
+ readonly format_id?: string;
+ readonly container?: Container;
+ readonly quality_height?: number;
+ readonly audio_format?: AudioFormat;
+ readonly audio_quality?: AudioQuality;
+ readonly trim?: TrimRange;
+ readonly subtitles?: SubtitleSelection;
+ readonly embed_metadata: boolean;
+}
+
+export interface JobOptions {
+ readonly kind: DownloadKind;
+ readonly container: Container;
+ readonly quality_height: number | null;
+ readonly format_id: string | null;
+ readonly audio_format: AudioFormat | null;
+ readonly audio_quality: AudioQuality | null;
+ readonly trim: TrimRange | null;
+ readonly subtitles: SubtitleSelection | null;
+ readonly embed_metadata: boolean;
+}
+
+export interface JobFile {
+ readonly index: number;
+ readonly name: string;
+ readonly kind: 'media' | 'subtitle';
+ readonly size_bytes: number;
+}
+
+export interface Job {
+ readonly job_id: string;
+ readonly url: string;
+ readonly title: string;
+ readonly status: JobStatus;
+ readonly progress: number;
+ readonly speed_bps: number | null;
+ readonly eta_seconds: number | null;
+ readonly downloaded_bytes: number | null;
+ readonly total_bytes: number | null;
+ readonly queue_position: number;
+ readonly options: JobOptions;
+ readonly filename: string | null;
+ readonly files: readonly JobFile[];
+ readonly error: string | null;
+ readonly error_code: string | null;
+ readonly created_at: string;
+ readonly finished_at: string | null;
+ readonly expires_at: string | null;
+}
+
+export interface SessionInfo {
+ readonly auth_required: boolean;
+ readonly authenticated: boolean;
+ readonly limits: {
+ readonly max_filesize_mb: number;
+ readonly max_playlist_items: number;
+ };
+}
+
+export interface RuntimeSettings {
+ readonly retention_minutes: number;
+ readonly max_concurrent: number;
+}
+
+export interface StorageUsage {
+ readonly used_bytes: number;
+ readonly limit_bytes: number | null;
+ readonly free_bytes: number;
+}
+
+export interface CookieSummary {
+ readonly present: boolean;
+ readonly domains: readonly string[];
+ readonly expires_at: string | null;
+ readonly uploaded_at: string | null;
+}
+
+export interface ApiErrorBody {
+ readonly error: string;
+ readonly code: string;
+}
diff --git a/apps/web/src/lib/brand.ts b/apps/web/src/lib/brand.ts
new file mode 100644
index 0000000..674f6d7
--- /dev/null
+++ b/apps/web/src/lib/brand.ts
@@ -0,0 +1,24 @@
+export const LOGO_FRAME_PATH =
+ 'M9 22 V9 H22 M42 9 H55 V22 M55 42 V55 H42 M22 55 H9 V42';
+export const LOGO_WAVE_PATH = 'M23 27 V37 M32 20 V44 M41 25 V39';
+
+export const BRAND_TEAL = '#12939c';
+export const BRAND_INK = '#15181d';
+export const BRAND_MIST = '#eef0f3';
+export const PWA_ICON_SIZES = [192, 512] as const;
+
+export function brandSvg({
+ frame,
+ wave,
+ background,
+}: {
+ frame: string;
+ wave: string;
+ background?: string;
+}): string {
+ const plate = background
+ ? ` `
+ : '';
+ const scale = background ? ' transform="translate(9.6 9.6) scale(0.7)"' : '';
+ return `${plate} `;
+}
diff --git a/apps/web/src/lib/describe.test.ts b/apps/web/src/lib/describe.test.ts
new file mode 100644
index 0000000..bed2cf9
--- /dev/null
+++ b/apps/web/src/lib/describe.test.ts
@@ -0,0 +1,138 @@
+import { describe, expect, it } from 'vitest';
+import type { Job } from './api/types';
+import { en } from './i18n/en';
+import { vi } from './i18n/vi';
+import { expiryText, remainingText, rowLine } from './describe';
+
+const base: Job = {
+ job_id: 'j',
+ url: 'https://youtu.be/a',
+ title: 'Pho',
+ status: 'downloading',
+ progress: 63.4,
+ speed_bps: 4_200_000,
+ eta_seconds: 90,
+ downloaded_bytes: 1,
+ total_bytes: 2,
+ queue_position: 0,
+ options: {
+ kind: 'video',
+ container: 'mp4',
+ quality_height: 1080,
+ format_id: null,
+ audio_format: null,
+ audio_quality: null,
+ trim: null,
+ subtitles: null,
+ embed_metadata: true,
+ },
+ filename: null,
+ files: [],
+ error: null,
+ error_code: null,
+ created_at: '2026-09-14T08:00:00Z',
+ finished_at: null,
+ expires_at: null,
+};
+
+const media = {
+ url: base.url,
+ title: 'Pho',
+ thumbnail: '',
+ duration: 1122,
+ uploader: 'Bep',
+ platform: 'youtube' as const,
+};
+const options = {
+ kind: 'video' as const,
+ container: 'mp4' as const,
+ qualityHeight: 1080,
+ audioFormat: 'm4a' as const,
+ audioQuality: 'best' as const,
+ trim: null,
+ subtitleLanguages: [],
+ subtitleMode: 'embed' as const,
+ embedMetadata: true,
+};
+
+describe('describe', () => {
+ it('formats remaining time', () => {
+ expect(remainingText(7, en)).toBe('7 s left');
+ expect(remainingText(90, en)).toBe('1 min 30 s left');
+ expect(remainingText(null, en)).toBe('');
+ });
+
+ it('formats expiry relative to now', () => {
+ expect(
+ expiryText('2026-09-14T09:00:00Z', en, new Date('2026-09-14T08:08:00Z')),
+ ).toBe('Deleted in 52 min');
+ expect(expiryText(null, en, new Date())).toBe('');
+ });
+
+ it('describes rows for each state', () => {
+ const job = {
+ type: 'job' as const,
+ id: 'j',
+ media,
+ formats: [],
+ options,
+ linkedAt: 0,
+ };
+ expect(rowLine({ ...job, job: base }, en, 'en').text).toBe(
+ '63% · 4.2 MB/s, 1 min 30 s left',
+ );
+ expect(
+ rowLine(
+ { ...job, job: { ...base, status: 'queued', queue_position: 2 } },
+ en,
+ 'en',
+ ).text,
+ ).toBe('Waiting, position 2');
+ expect(
+ rowLine(
+ { ...job, job: { ...base, status: 'error', error_code: 'bot_check' } },
+ en,
+ 'en',
+ ),
+ ).toEqual({ text: en.errors.bot_check, tone: 'error' });
+ expect(
+ rowLine({ type: 'ready', id: 'r', media, formats: [], options }, en, 'en')
+ .text,
+ ).toBe('Bep, 18:42');
+ expect(
+ rowLine(
+ {
+ type: 'fetch-error',
+ id: 'e',
+ url: 'https://x.y',
+ code: 'unsupported_url',
+ },
+ en,
+ 'en',
+ ).tone,
+ ).toBe('error');
+ });
+
+ it('leaves out speed and time left until the download reports them', () => {
+ const job = {
+ type: 'job' as const,
+ id: 'j',
+ media,
+ formats: [],
+ options,
+ linkedAt: 0,
+ };
+ const starting = {
+ ...base,
+ progress: 0,
+ speed_bps: null,
+ eta_seconds: null,
+ };
+ expect(rowLine({ ...job, job: starting }, en, 'en').text).toBe('0%');
+ expect(rowLine({ ...job, job: starting }, vi, 'vi').text).toBe('0%');
+ expect(
+ rowLine({ ...job, job: { ...starting, speed_bps: 4_200_000 } }, en, 'en')
+ .text,
+ ).toBe('0% · 4.2 MB/s');
+ });
+});
diff --git a/apps/web/src/lib/describe.ts b/apps/web/src/lib/describe.ts
new file mode 100644
index 0000000..1d59d9f
--- /dev/null
+++ b/apps/web/src/lib/describe.ts
@@ -0,0 +1,97 @@
+import { jobLabel } from '@/state/reducer';
+import type { QueueItem } from '@/state/types';
+import {
+ formatBytes,
+ formatClock,
+ formatSpeed,
+ splitDuration,
+ type Locale,
+} from './format';
+import type { Messages } from './i18n/en';
+
+const MILLISECONDS_PER_MINUTE = 60_000;
+const ACTIVE_DOWNLOAD_STATUSES = new Set(['downloading']);
+
+export function remainingText(seconds: number | null, t: Messages): string {
+ if (seconds === null) return '';
+ const { hours, minutes, seconds: rest } = splitDuration(Math.max(1, seconds));
+ const totalMinutes = hours * 60 + minutes;
+ return totalMinutes === 0
+ ? t.time.secondsLeft(rest)
+ : t.time.minutesLeft(totalMinutes, rest);
+}
+
+export function expiryText(
+ expiresAt: string | null,
+ t: Messages,
+ now: Date,
+): string {
+ if (expiresAt === null) return '';
+ const minutes = Math.max(
+ 0,
+ Math.round(
+ (new Date(expiresAt).getTime() - now.getTime()) / MILLISECONDS_PER_MINUTE,
+ ),
+ );
+ return t.time.expiresIn(minutes);
+}
+
+function errorText(code: string, t: Messages): string {
+ const errors: Record = t.errors;
+ return errors[code] ?? t.errors.unknown_error;
+}
+
+export function rowLine(
+ item: QueueItem,
+ t: Messages,
+ locale: Locale,
+): { text: string; tone: 'normal' | 'error' } {
+ if (item.type === 'fetching')
+ return { text: t.queue.fetching, tone: 'normal' };
+ if (item.type === 'fetch-error')
+ return { text: errorText(item.code, t), tone: 'error' };
+ if (item.type === 'ready') {
+ const duration =
+ item.media.duration === null
+ ? ''
+ : `, ${formatClock(item.media.duration)}`;
+ return {
+ text: `${item.media.uploader || item.media.url}${duration}`,
+ tone: 'normal',
+ };
+ }
+ const { job } = item;
+ if (job.status === 'error')
+ return {
+ text: errorText(job.error_code ?? 'unknown_error', t),
+ tone: 'error',
+ };
+ if (job.status === 'queued')
+ return { text: t.queue.queued(job.queue_position), tone: 'normal' };
+ if (job.status === 'processing')
+ return { text: t.queue.processing, tone: 'normal' };
+ if (job.status === 'done') {
+ const size = formatBytes(job.files[0]?.size_bytes ?? 0, locale);
+ return {
+ text: t.queue.doneLine(
+ jobLabel(job),
+ size,
+ expiryText(job.expires_at, t, new Date()),
+ ),
+ tone: 'normal',
+ };
+ }
+ if (ACTIVE_DOWNLOAD_STATUSES.has(job.status)) {
+ const speed =
+ job.speed_bps === null ? '' : formatSpeed(job.speed_bps, locale);
+ return {
+ text: t.queue.downloadingLine(
+ Math.floor(job.progress),
+ speed,
+ remainingText(job.eta_seconds, t),
+ ),
+ tone: 'normal',
+ };
+ }
+ return { text: t.queue.cancelled, tone: 'normal' };
+}
diff --git a/apps/web/src/lib/format.test.ts b/apps/web/src/lib/format.test.ts
new file mode 100644
index 0000000..8b06a9d
--- /dev/null
+++ b/apps/web/src/lib/format.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest';
+import {
+ formatBytes,
+ formatClock,
+ formatSpeed,
+ parseClock,
+ splitDuration,
+} from './format';
+
+describe('format', () => {
+ it('formats sizes with locale decimal separators', () => {
+ expect(formatBytes(1_600_000_000, 'vi')).toBe('1,6 GB');
+ expect(formatBytes(412_000_000, 'en')).toBe('412 MB');
+ expect(formatBytes(57_300_000, 'vi')).toBe('57,3 MB');
+ expect(formatBytes(800, 'en')).toBe('0.1 MB');
+ });
+
+ it('formats speeds', () => {
+ expect(formatSpeed(4_200_000, 'vi')).toBe('4,2 MB/s');
+ });
+
+ it('formats and parses clocks', () => {
+ expect(formatClock(1122)).toBe('18:42');
+ expect(formatClock(3735)).toBe('1:02:15');
+ expect(parseClock('1:02:15')).toBe(3735);
+ expect(parseClock('18:42')).toBe(1122);
+ expect(parseClock('90')).toBe(90);
+ expect(parseClock('1:xx')).toBeNull();
+ });
+
+ it('splits durations', () => {
+ expect(splitDuration(3735)).toEqual({ hours: 1, minutes: 2, seconds: 15 });
+ });
+});
diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts
new file mode 100644
index 0000000..553786b
--- /dev/null
+++ b/apps/web/src/lib/format.ts
@@ -0,0 +1,56 @@
+export type Locale = 'vi' | 'en';
+
+const BYTES_PER_MEGABYTE = 1_000_000;
+const MEGABYTES_PER_GIGABYTE = 1000;
+const MINIMUM_MEGABYTES = 0.1;
+const LOCALE_TAGS: Record = { vi: 'vi-VN', en: 'en-US' };
+
+function decimal(value: number, locale: Locale): string {
+ return new Intl.NumberFormat(LOCALE_TAGS[locale], {
+ maximumFractionDigits: 1,
+ }).format(value);
+}
+
+export function formatBytes(bytes: number, locale: Locale): string {
+ const megabytes = Math.max(bytes / BYTES_PER_MEGABYTE, MINIMUM_MEGABYTES);
+ return megabytes >= MEGABYTES_PER_GIGABYTE
+ ? `${decimal(megabytes / MEGABYTES_PER_GIGABYTE, locale)} GB`
+ : `${decimal(megabytes, locale)} MB`;
+}
+
+export function formatSpeed(bytesPerSecond: number, locale: Locale): string {
+ return `${formatBytes(bytesPerSecond, locale)}/s`;
+}
+
+export function splitDuration(totalSeconds: number): {
+ hours: number;
+ minutes: number;
+ seconds: number;
+} {
+ const whole = Math.max(0, Math.round(totalSeconds));
+ return {
+ hours: Math.floor(whole / 3600),
+ minutes: Math.floor((whole % 3600) / 60),
+ seconds: whole % 60,
+ };
+}
+
+const pad = (value: number): string => String(value).padStart(2, '0');
+
+export function formatClock(totalSeconds: number): string {
+ const { hours, minutes, seconds } = splitDuration(totalSeconds);
+ return hours > 0
+ ? `${hours}:${pad(minutes)}:${pad(seconds)}`
+ : `${minutes}:${pad(seconds)}`;
+}
+
+export function parseClock(text: string): number | null {
+ const parts = text.trim().split(':');
+ const valid =
+ parts.length >= 1 &&
+ parts.length <= 3 &&
+ parts.every((part) => /^\d+$/.test(part));
+ return valid
+ ? parts.reduce((total, part) => total * 60 + Number(part), 0)
+ : null;
+}
diff --git a/apps/web/src/lib/i18n/I18nProvider.tsx b/apps/web/src/lib/i18n/I18nProvider.tsx
new file mode 100644
index 0000000..234dae2
--- /dev/null
+++ b/apps/web/src/lib/i18n/I18nProvider.tsx
@@ -0,0 +1,40 @@
+"use client";
+
+import { createContext, useContext, useMemo, type ReactNode } from "react";
+import type { Locale } from "../format";
+import { en, type Messages } from "./en";
+import { vi } from "./vi";
+
+export type LanguagePreference = "auto" | Locale;
+
+const DICTIONARIES: Record = { en, vi };
+
+export function resolveLocale(
+ preference: LanguagePreference,
+ navigatorLanguage: string,
+): Locale {
+ if (preference !== "auto") return preference;
+ return navigatorLanguage.toLowerCase().startsWith("vi") ? "vi" : "en";
+}
+
+interface I18nValue {
+ readonly locale: Locale;
+ readonly t: Messages;
+}
+
+const I18nContext = createContext({ locale: "en", t: en });
+
+export function I18nProvider({
+ locale,
+ children,
+}: {
+ locale: Locale;
+ children: ReactNode;
+}): ReactNode {
+ const value = useMemo(() => ({ locale, t: DICTIONARIES[locale] }), [locale]);
+ return {children} ;
+}
+
+export function useI18n(): I18nValue {
+ return useContext(I18nContext);
+}
diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts
new file mode 100644
index 0000000..c9914dc
--- /dev/null
+++ b/apps/web/src/lib/i18n/en.ts
@@ -0,0 +1,252 @@
+const counted = (count: number, noun: string): string =>
+ `${count} ${count === 1 ? noun : `${noun}s`}`;
+
+export const en = {
+ app: { name: 'OpenMedia', sampleNote: 'Sample data' },
+ nav: {
+ queue: 'Queue',
+ downloading: 'Downloading',
+ done: 'Done',
+ attention: 'Needs attention',
+ history: 'History',
+ settings: 'Settings',
+ other: 'More',
+ downloads: 'Downloads',
+ showSidebar: 'Show sidebar',
+ toggleTheme: 'Switch light or dark',
+ shortcuts: 'Keyboard shortcuts',
+ },
+ importer: {
+ label: 'Links to download',
+ placeholder: 'Paste a YouTube, TikTok or SoundCloud link...',
+ paste: 'Paste',
+ fetch: 'Get info',
+ hint: 'Enter gets info, Shift+Enter adds a line, or drop links anywhere',
+ pasteFallback: 'Press Cmd+V or Ctrl+V to paste',
+ noLinks: 'No links recognized',
+ playlistPrompt: 'This link belongs to a playlist.',
+ playlistSingle: 'Only this video',
+ playlistAll: (count: number): string =>
+ `Whole playlist (up to ${count} videos)`,
+ dropTitle: 'Drop links to add them to the queue',
+ platformOther: 'Web',
+ installHint:
+ 'Install OpenMedia on your home screen to share links straight from other apps.',
+ dismissInstallHint: 'Hide install tip',
+ },
+ queue: {
+ summary: (total: number, active: number, done: number): string =>
+ `${counted(total, 'item')}, ${active} downloading, ${done} done`,
+ startAll: (count: number): string => `Download all (${count})`,
+ concurrency: (count: number): string => `Up to ${count} downloads at once`,
+ empty: 'Nothing here yet. Paste a link to start.',
+ download: 'Download',
+ save: 'Save',
+ fix: 'Fix',
+ retry: 'Try again',
+ cancel: (title: string): string => `Cancel ${title}`,
+ removeQueued: 'Remove from queue',
+ remove: 'Remove',
+ fetching: 'Getting info',
+ queued: (position: number): string => `Waiting, position ${position}`,
+ processing: 'Finishing up',
+ doneLine: (format: string, size: string, expiry: string): string =>
+ `${format}, ${size}. ${expiry}`,
+ downloadingLine: (
+ percent: number,
+ speed: string,
+ remaining: string,
+ ): string =>
+ [`${percent}%`, [speed, remaining].filter(Boolean).join(', ')]
+ .filter(Boolean)
+ .join(' · '),
+ cancelled: 'Cancelled',
+ listLabel: 'Download queue',
+ },
+ time: {
+ secondsLeft: (seconds: number): string => `${seconds} s left`,
+ minutesLeft: (minutes: number, seconds: number): string =>
+ seconds === 0
+ ? `${minutes} min left`
+ : `${minutes} min ${seconds} s left`,
+ expiresIn: (minutes: number): string =>
+ minutes >= 60
+ ? `Deleted in ${Math.round(minutes / 60)} h`
+ : `Deleted in ${minutes} min`,
+ retention: {
+ 15: '15 minutes',
+ 60: '1 hour',
+ 360: '6 hours',
+ 1440: '24 hours',
+ },
+ },
+ inspector: {
+ title: 'Details',
+ done: 'Done',
+ empty: 'Select an item to see its details.',
+ kind: 'Type',
+ video: 'Video',
+ audio: 'Audio',
+ format: 'Format',
+ quality: 'Quality',
+ qualityBest: 'Best available',
+ audioOriginal: 'Original',
+ audioLossless: 'Original, lossless',
+ trim: 'Trim',
+ trimStart: 'Start',
+ trimEnd: 'End',
+ trimLength: (length: string): string => `Length ${length}`,
+ trimHelp:
+ 'Drag the yellow handles, or select a handle and use the arrow keys.',
+ trimStartHandle: 'Start point',
+ trimEndHandle: 'End point',
+ subtitles: 'Subtitles',
+ subtitlesOff: 'Off',
+ subtitlesVietnamese: 'Vietnamese',
+ subtitlesEnglish: 'English',
+ subtitleEmbed: 'Embed in video',
+ subtitleFile: 'Separate .srt file',
+ embedMetadata: 'Embed cover and details',
+ estimate: 'Estimated',
+ selection: 'Selected part',
+ downloadAll: 'Download',
+ downloadSelection: 'Download selection',
+ downloadingTitle: (label: string): string => `Downloading ${label}`,
+ keepsRunning: 'You can close this page; the server keeps downloading.',
+ queuedTitle: (position: number): string => `Waiting, position ${position}`,
+ queuedHelp: 'Starts when a download slot is free.',
+ doneTitle: 'Download complete',
+ saveToDevice: 'Save to device',
+ subtitleFileName: (name: string): string => `Save ${name}`,
+ errorTitle: 'Could not download',
+ addCookies: 'Add cookies to continue',
+ cookiesReady: 'Cookies for this site are loaded',
+ processingTitle: 'Finishing up',
+ processingHelp: 'Merging streams and embedding details.',
+ artworkAlt: (title: string): string => `Cover of ${title}`,
+ },
+ history: {
+ title: 'History',
+ note: 'Stored in this browser',
+ clear: 'Clear history',
+ clearTitle: 'Clear history?',
+ clearMessage:
+ 'The list in this browser will be removed. Files on the server are not affected.',
+ clearConfirm: 'Clear',
+ cancel: 'Cancel',
+ again: 'Download again',
+ empty: 'History is empty.',
+ },
+ settings: {
+ title: 'Settings',
+ done: 'Done',
+ cookies: 'Cookies',
+ cookiesNone: 'No cookies yet',
+ cookiesLoaded: (domains: string, days: number): string =>
+ `Loaded for ${domains}, expires in ${counted(days, 'day')}`,
+ cookiesChoose: 'Choose cookies.txt',
+ cookiesRemove: 'Remove',
+ cookiesNote:
+ 'Used for age-restricted videos or when YouTube asks to confirm you are not a bot.',
+ downloads: 'Downloads',
+ retention: 'Keep files on the server',
+ concurrency: 'Simultaneous downloads',
+ decrease: 'Fewer downloads',
+ increase: 'More downloads',
+ defaultFormat: 'Default',
+ defaultFormats: {
+ 'video-mp4-1080': 'Video MP4 1080p',
+ 'video-mp4-720': 'Video MP4 720p',
+ 'audio-m4a': 'Audio M4A',
+ 'audio-mp3': 'Audio MP3 320 kbps',
+ },
+ appearance: 'Appearance',
+ theme: 'Theme',
+ themeSystem: 'System',
+ themeLight: 'Light',
+ themeDark: 'Dark',
+ accent: 'Accent color',
+ accents: {
+ teal: 'Teal',
+ blue: 'Blue',
+ purple: 'Purple',
+ pink: 'Pink',
+ orange: 'Orange',
+ green: 'Green',
+ graphite: 'Graphite',
+ },
+ language: 'Language',
+ languages: { auto: 'Automatic', vi: 'Tiếng Việt', en: 'English' },
+ access: 'Access',
+ passwordOn: 'Password protection is on',
+ passwordOff: 'Password protection is off',
+ passwordNote: 'Set OPENMEDIA_PASSWORD on the server to turn it on.',
+ signOut: 'Sign out',
+ storage: 'Storage',
+ storageUsed: (used: string, total: string): string =>
+ `${used} used of ${total}`,
+ storageUsedUnlimited: (used: string, free: string): string =>
+ `${used} used, ${free} free`,
+ },
+ shortcuts: {
+ title: 'Keyboard shortcuts',
+ focus: 'Enter a link',
+ pasteFetch: 'Paste and get info',
+ close: 'Close panel',
+ show: 'Show shortcuts',
+ dismiss: 'Close',
+ },
+ auth: {
+ title: 'Sign in to OpenMedia',
+ password: 'Password',
+ submit: 'Sign in',
+ wrong: 'The password is not correct.',
+ },
+ island: {
+ fetched: 'Info ready',
+ playlistAdded: (count: number): string =>
+ `Added ${counted(count, 'item')} from the playlist`,
+ downloaded: (title: string): string => `Downloaded: ${title}`,
+ cancelled: 'Download cancelled',
+ removedFromQueue: 'Removed from queue',
+ addedAgain: 'Added back to the queue',
+ cookiesLoaded: 'Cookies loaded',
+ cookiesRemoved: 'Cookies removed',
+ settingsSaved: 'Settings saved',
+ },
+ errors: {
+ invalid_url:
+ 'That does not look like a link. Paste an address that starts with http:// or https://.',
+ unsupported_url: 'This site is not supported.',
+ private_network:
+ 'Links to private or local network addresses are blocked on this server.',
+ invalid_option: 'One of the download options is not valid.',
+ not_found: 'That item no longer exists on the server.',
+ file_not_ready: 'The file is not ready yet.',
+ rate_limited: 'Too many requests. Wait a moment and try again.',
+ auth_required: 'Sign in to continue.',
+ invalid_password: 'The password is not correct.',
+ cross_site_request:
+ 'The request was blocked because it came from another website.',
+ storage_full: 'Server storage is full. Remove finished downloads first.',
+ request_entity_too_large: 'The request is too large for this server.',
+ too_large: 'The file is larger than this server allows.',
+ bot_check:
+ 'The site asked to confirm you are not a bot. Add cookies in Settings.',
+ private_video: 'This video is private.',
+ geo_blocked: "This video is not available in the server's region.",
+ unavailable: 'This video is unavailable.',
+ timeout: 'The site took too long to respond. Try again.',
+ extractor_error: 'The site could not be read. Try again later.',
+ invalid_cookies: 'That file is not a cookies.txt file in Netscape format.',
+ conversion_failed:
+ 'The file could not be converted to the chosen format. Choose MKV and try again.',
+ nested_playlist:
+ 'This entry is itself a playlist. Paste its link on its own to add its items.',
+ empty_playlist: 'This playlist has no items to download.',
+ api_unreachable: 'The OpenMedia server is not reachable.',
+ unknown_error: 'Something went wrong. Try again.',
+ },
+};
+
+export type Messages = typeof en;
diff --git a/apps/web/src/lib/i18n/i18n.test.ts b/apps/web/src/lib/i18n/i18n.test.ts
new file mode 100644
index 0000000..884ba2d
--- /dev/null
+++ b/apps/web/src/lib/i18n/i18n.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest';
+import { en } from './en';
+import { resolveLocale } from './I18nProvider';
+import { vi as vietnamese } from './vi';
+
+function keysOf(value: object, prefix = ''): string[] {
+ return Object.entries(value).flatMap(([key, child]) =>
+ typeof child === 'object' && child !== null
+ ? keysOf(child, `${prefix}${key}.`)
+ : [`${prefix}${key}`],
+ );
+}
+
+describe('i18n', () => {
+ it('resolves the browser language', () => {
+ expect(resolveLocale('auto', 'vi-VN')).toBe('vi');
+ expect(resolveLocale('auto', 'en-US')).toBe('en');
+ expect(resolveLocale('auto', 'fr-FR')).toBe('en');
+ expect(resolveLocale('vi', 'en-US')).toBe('vi');
+ });
+
+ it('keeps both dictionaries in sync', () => {
+ expect(keysOf(vietnamese).sort()).toEqual(keysOf(en).sort());
+ });
+
+ it('uses singular English nouns for a count of one', () => {
+ expect(en.queue.summary(1, 0, 1)).toBe('1 item, 0 downloading, 1 done');
+ expect(en.queue.summary(2, 1, 0)).toBe('2 items, 1 downloading, 0 done');
+ expect(en.settings.cookiesLoaded('youtube.com', 1)).toBe(
+ 'Loaded for youtube.com, expires in 1 day',
+ );
+ expect(en.island.playlistAdded(1)).toBe('Added 1 item from the playlist');
+ expect(vietnamese.island.playlistAdded(26)).toBe(
+ 'Đã thêm 26 mục từ playlist',
+ );
+ });
+
+ it('contains no dash characters reserved by the style guide', () => {
+ const strings = JSON.stringify([en, vietnamese]);
+ expect(strings).not.toMatch(/[–—]/);
+ });
+});
diff --git a/apps/web/src/lib/i18n/vi.ts b/apps/web/src/lib/i18n/vi.ts
new file mode 100644
index 0000000..da2b181
--- /dev/null
+++ b/apps/web/src/lib/i18n/vi.ts
@@ -0,0 +1,245 @@
+import type { Messages } from './en';
+
+export const vi: Messages = {
+ app: { name: 'OpenMedia', sampleNote: 'Dữ liệu mẫu' },
+ nav: {
+ queue: 'Hàng đợi',
+ downloading: 'Đang tải',
+ done: 'Đã xong',
+ attention: 'Cần xử lý',
+ history: 'Lịch sử',
+ settings: 'Cài đặt',
+ other: 'Khác',
+ downloads: 'Tải xuống',
+ showSidebar: 'Hiện thanh bên',
+ toggleTheme: 'Đổi giao diện sáng tối',
+ shortcuts: 'Phím tắt',
+ },
+ importer: {
+ label: 'Liên kết cần tải',
+ placeholder: 'Dán liên kết YouTube, TikTok, SoundCloud...',
+ paste: 'Dán',
+ fetch: 'Lấy thông tin',
+ hint: 'Enter để lấy thông tin, Shift+Enter xuống dòng, hoặc kéo thả liên kết vào bất kỳ đâu',
+ pasteFallback: 'Nhấn Cmd+V hoặc Ctrl+V để dán',
+ noLinks: 'Không nhận diện được liên kết nào',
+ playlistPrompt: 'Liên kết này nằm trong một playlist.',
+ playlistSingle: 'Chỉ video này',
+ playlistAll: (count: number): string =>
+ `Cả playlist (tối đa ${count} video)`,
+ dropTitle: 'Thả liên kết để thêm vào hàng đợi',
+ platformOther: 'Trang web',
+ installHint:
+ 'Cài OpenMedia lên màn hình chính để chia sẻ liên kết thẳng từ ứng dụng khác.',
+ dismissInstallHint: 'Ẩn gợi ý cài đặt',
+ },
+ queue: {
+ summary: (total: number, active: number, done: number): string =>
+ `${total} mục, ${active} đang tải, ${done} đã xong`,
+ startAll: (count: number): string => `Tải tất cả (${count})`,
+ concurrency: (count: number): string => `Tối đa ${count} lượt tải cùng lúc`,
+ empty: 'Chưa có gì ở đây. Dán một liên kết để bắt đầu.',
+ download: 'Tải xuống',
+ save: 'Lưu',
+ fix: 'Sửa',
+ retry: 'Thử lại',
+ cancel: (title: string): string => `Hủy ${title}`,
+ removeQueued: 'Bỏ khỏi hàng đợi',
+ remove: 'Xóa',
+ fetching: 'Đang lấy thông tin',
+ queued: (position: number): string => `Đang chờ, vị trí ${position}`,
+ processing: 'Đang hoàn tất',
+ doneLine: (format: string, size: string, expiry: string): string =>
+ `${format}, ${size}. ${expiry}`,
+ downloadingLine: (
+ percent: number,
+ speed: string,
+ remaining: string,
+ ): string =>
+ [`${percent}%`, [speed, remaining].filter(Boolean).join(', ')]
+ .filter(Boolean)
+ .join(' · '),
+ cancelled: 'Đã hủy',
+ listLabel: 'Hàng đợi tải xuống',
+ },
+ time: {
+ secondsLeft: (seconds: number): string => `Còn ${seconds} giây`,
+ minutesLeft: (minutes: number, seconds: number): string =>
+ seconds === 0
+ ? `Còn ${minutes} phút`
+ : `Còn ${minutes} phút ${seconds} giây`,
+ expiresIn: (minutes: number): string =>
+ minutes >= 60
+ ? `Sẽ xóa sau ${Math.round(minutes / 60)} giờ`
+ : `Sẽ xóa sau ${minutes} phút`,
+ retention: { 15: '15 phút', 60: '1 giờ', 360: '6 giờ', 1440: '24 giờ' },
+ },
+ inspector: {
+ title: 'Chi tiết',
+ done: 'Xong',
+ empty: 'Chọn một mục để xem chi tiết.',
+ kind: 'Loại',
+ video: 'Video',
+ audio: 'Âm thanh',
+ format: 'Định dạng',
+ quality: 'Chất lượng',
+ qualityBest: 'Tốt nhất có thể',
+ audioOriginal: 'Gốc',
+ audioLossless: 'Gốc, không nén',
+ trim: 'Cắt',
+ trimStart: 'Bắt đầu',
+ trimEnd: 'Kết thúc',
+ trimLength: (length: string): string => `Thời lượng ${length}`,
+ trimHelp:
+ 'Kéo hai tay nắm vàng, hoặc chọn một tay nắm rồi dùng phím mũi tên.',
+ trimStartHandle: 'Điểm bắt đầu',
+ trimEndHandle: 'Điểm kết thúc',
+ subtitles: 'Phụ đề',
+ subtitlesOff: 'Tắt',
+ subtitlesVietnamese: 'Tiếng Việt',
+ subtitlesEnglish: 'Tiếng Anh',
+ subtitleEmbed: 'Nhúng vào video',
+ subtitleFile: 'Tách file .srt riêng',
+ embedMetadata: 'Nhúng ảnh bìa và thông tin',
+ estimate: 'Dự kiến',
+ selection: 'Đoạn đã chọn',
+ downloadAll: 'Tải xuống',
+ downloadSelection: 'Tải đoạn đã chọn',
+ downloadingTitle: (label: string): string => `Đang tải ${label}`,
+ keepsRunning: 'Bạn có thể đóng trang này, máy chủ vẫn tiếp tục tải.',
+ queuedTitle: (position: number): string => `Đang chờ, vị trí ${position}`,
+ queuedHelp: 'Bắt đầu khi có lượt tải trống.',
+ doneTitle: 'Tải xuống hoàn tất',
+ saveToDevice: 'Lưu vào thiết bị',
+ subtitleFileName: (name: string): string => `Lưu ${name}`,
+ errorTitle: 'Không tải được',
+ addCookies: 'Thêm cookies để tiếp tục',
+ cookiesReady: 'Cookies cho trang này đã được nạp',
+ processingTitle: 'Đang hoàn tất',
+ processingHelp: 'Đang ghép luồng và nhúng thông tin.',
+ artworkAlt: (title: string): string => `Ảnh bìa của ${title}`,
+ },
+ history: {
+ title: 'Lịch sử',
+ note: 'Lưu trên trình duyệt này',
+ clear: 'Xóa lịch sử',
+ clearTitle: 'Xóa lịch sử?',
+ clearMessage:
+ 'Danh sách trên trình duyệt này sẽ bị xóa. File trên máy chủ không bị ảnh hưởng.',
+ clearConfirm: 'Xóa',
+ cancel: 'Hủy',
+ again: 'Tải lại',
+ empty: 'Lịch sử trống.',
+ },
+ settings: {
+ title: 'Cài đặt',
+ done: 'Xong',
+ cookies: 'Cookies',
+ cookiesNone: 'Chưa có cookies',
+ cookiesLoaded: (domains: string, days: number): string =>
+ `Đã nạp cho ${domains}, hết hạn sau ${days} ngày`,
+ cookiesChoose: 'Chọn file cookies.txt',
+ cookiesRemove: 'Xóa',
+ cookiesNote:
+ 'Dùng cho video giới hạn độ tuổi hoặc khi YouTube yêu cầu xác minh bạn không phải bot.',
+ downloads: 'Tải xuống',
+ retention: 'Giữ file trên máy chủ',
+ concurrency: 'Số lượt tải cùng lúc',
+ decrease: 'Giảm số lượt tải',
+ increase: 'Tăng số lượt tải',
+ defaultFormat: 'Mặc định',
+ defaultFormats: {
+ 'video-mp4-1080': 'Video MP4 1080p',
+ 'video-mp4-720': 'Video MP4 720p',
+ 'audio-m4a': 'Âm thanh M4A',
+ 'audio-mp3': 'Âm thanh MP3 320 kbps',
+ },
+ appearance: 'Giao diện',
+ theme: 'Chủ đề',
+ themeSystem: 'Hệ thống',
+ themeLight: 'Sáng',
+ themeDark: 'Tối',
+ accent: 'Màu nhấn',
+ accents: {
+ teal: 'Xanh ngọc',
+ blue: 'Xanh dương',
+ purple: 'Tím',
+ pink: 'Hồng',
+ orange: 'Cam',
+ green: 'Xanh lá',
+ graphite: 'Than chì',
+ },
+ language: 'Ngôn ngữ',
+ languages: { auto: 'Tự động', vi: 'Tiếng Việt', en: 'Tiếng Anh' },
+ access: 'Truy cập',
+ passwordOn: 'Bảo vệ bằng mật khẩu đang bật',
+ passwordOff: 'Bảo vệ bằng mật khẩu đang tắt',
+ passwordNote: 'Đặt biến OPENMEDIA_PASSWORD trên máy chủ để bật.',
+ signOut: 'Đăng xuất',
+ storage: 'Dung lượng',
+ storageUsed: (used: string, total: string): string =>
+ `Đã dùng ${used} trên ${total}`,
+ storageUsedUnlimited: (used: string, free: string): string =>
+ `Đã dùng ${used}, còn trống ${free}`,
+ },
+ shortcuts: {
+ title: 'Phím tắt',
+ focus: 'Nhập liên kết',
+ pasteFetch: 'Dán và lấy thông tin',
+ close: 'Đóng bảng',
+ show: 'Xem phím tắt',
+ dismiss: 'Đóng',
+ },
+ auth: {
+ title: 'Đăng nhập OpenMedia',
+ password: 'Mật khẩu',
+ submit: 'Đăng nhập',
+ wrong: 'Mật khẩu không đúng.',
+ },
+ island: {
+ fetched: 'Đã lấy xong thông tin',
+ playlistAdded: (count: number): string =>
+ `Đã thêm ${count} mục từ playlist`,
+ downloaded: (title: string): string => `Đã tải xong: ${title}`,
+ cancelled: 'Đã hủy tải',
+ removedFromQueue: 'Đã bỏ khỏi hàng đợi',
+ addedAgain: 'Đã thêm lại vào hàng đợi',
+ cookiesLoaded: 'Đã nạp cookies',
+ cookiesRemoved: 'Đã xóa cookies',
+ settingsSaved: 'Đã lưu cài đặt',
+ },
+ errors: {
+ invalid_url:
+ 'Đây không phải là một liên kết hợp lệ. Hãy dán địa chỉ bắt đầu bằng http:// hoặc https://.',
+ unsupported_url: 'Trang này chưa được hỗ trợ.',
+ private_network:
+ 'Liên kết tới địa chỉ mạng riêng hoặc cục bộ bị chặn trên máy chủ này.',
+ invalid_option: 'Một trong các tùy chọn tải xuống không hợp lệ.',
+ not_found: 'Mục này không còn tồn tại trên máy chủ.',
+ file_not_ready: 'File chưa sẵn sàng.',
+ rate_limited: 'Quá nhiều yêu cầu. Chờ một chút rồi thử lại.',
+ auth_required: 'Đăng nhập để tiếp tục.',
+ invalid_password: 'Mật khẩu không đúng.',
+ cross_site_request: 'Yêu cầu bị chặn vì đến từ một trang web khác.',
+ storage_full: 'Dung lượng máy chủ đã đầy. Hãy xóa bớt các mục đã tải xong.',
+ request_entity_too_large:
+ 'Yêu cầu quá lớn so với giới hạn của máy chủ này.',
+ too_large: 'File lớn hơn giới hạn cho phép của máy chủ này.',
+ bot_check:
+ 'Trang này yêu cầu xác minh bạn không phải bot. Hãy thêm cookies trong Cài đặt.',
+ private_video: 'Video này ở chế độ riêng tư.',
+ geo_blocked: 'Video này không khả dụng ở khu vực của máy chủ.',
+ unavailable: 'Video này không khả dụng.',
+ timeout: 'Trang mất quá nhiều thời gian để phản hồi. Hãy thử lại.',
+ extractor_error: 'Không đọc được trang này. Hãy thử lại sau.',
+ invalid_cookies:
+ 'File này không phải là file cookies.txt theo định dạng Netscape.',
+ conversion_failed:
+ 'Không chuyển được file sang định dạng đã chọn. Hãy chọn MKV rồi thử lại.',
+ nested_playlist:
+ 'Mục này cũng là một playlist. Hãy dán riêng liên kết của nó để thêm các mục bên trong.',
+ empty_playlist: 'Playlist này không có mục nào để tải.',
+ api_unreachable: 'Không kết nối được với máy chủ OpenMedia.',
+ unknown_error: 'Đã có lỗi xảy ra. Hãy thử lại.',
+ },
+};
diff --git a/apps/web/src/lib/links.test.ts b/apps/web/src/lib/links.test.ts
new file mode 100644
index 0000000..5417899
--- /dev/null
+++ b/apps/web/src/lib/links.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest';
+import {
+ detectPlatform,
+ detectPlatforms,
+ hasPlaylist,
+ linkFromShare,
+ parseLinks,
+} from './links';
+
+describe('links', () => {
+ it('parses links separated by spaces, commas and newlines without duplicates', () => {
+ const text =
+ 'https://youtu.be/a, https://www.tiktok.com/@x/video/1\nhttps://youtu.be/a not-a-link ftp://x.y';
+ expect(parseLinks(text)).toEqual([
+ 'https://youtu.be/a',
+ 'https://www.tiktok.com/@x/video/1',
+ ]);
+ });
+
+ it('detects platforms by host', () => {
+ expect(detectPlatform('https://m.youtube.com/watch?v=1')).toBe('youtube');
+ expect(detectPlatform('https://x.com/a/status/1')).toBe('x');
+ expect(detectPlatform('https://soundcloud.com/a/b')).toBe('soundcloud');
+ expect(detectPlatform('https://example.org/v')).toBe('other');
+ expect(
+ detectPlatforms([
+ 'https://youtu.be/a',
+ 'https://youtube.com/b',
+ 'https://vimeo.com/1',
+ ]),
+ ).toEqual(['youtube', 'vimeo']);
+ });
+
+ it('recognizes playlist parameters', () => {
+ expect(hasPlaylist('https://www.youtube.com/watch?v=a&list=PL1')).toBe(
+ true,
+ );
+ expect(hasPlaylist('https://www.youtube.com/watch?v=a')).toBe(false);
+ });
+
+ it('extracts a link from share target parameters', () => {
+ expect(
+ linkFromShare({ url: null, text: 'Look https://youtu.be/a nice' }),
+ ).toBe('https://youtu.be/a');
+ expect(linkFromShare({ url: 'https://vimeo.com/1', text: null })).toBe(
+ 'https://vimeo.com/1',
+ );
+ expect(linkFromShare({ url: null, text: 'no link' })).toBeNull();
+ });
+});
diff --git a/apps/web/src/lib/links.ts b/apps/web/src/lib/links.ts
new file mode 100644
index 0000000..1df6bff
--- /dev/null
+++ b/apps/web/src/lib/links.ts
@@ -0,0 +1,65 @@
+export type PlatformId =
+ | 'youtube'
+ | 'tiktok'
+ | 'instagram'
+ | 'soundcloud'
+ | 'x'
+ | 'facebook'
+ | 'vimeo'
+ | 'other';
+
+const PLATFORM_HOSTS: ReadonlyArray = [
+ ['youtube', /(^|\.)(youtube\.com|youtu\.be)$/],
+ ['tiktok', /(^|\.)tiktok\.com$/],
+ ['instagram', /(^|\.)instagram\.com$/],
+ ['soundcloud', /(^|\.)soundcloud\.com$/],
+ ['x', /(^|\.)(x\.com|twitter\.com)$/],
+ ['facebook', /(^|\.)(facebook\.com|fb\.watch)$/],
+ ['vimeo', /(^|\.)vimeo\.com$/],
+];
+
+const LINK_PATTERN = /^https?:\/\/[^\s/$.?#].\S*$/i;
+
+function hostOf(url: string): string {
+ try {
+ return new URL(url).hostname.replace(/^www\./, '');
+ } catch {
+ return '';
+ }
+}
+
+export function parseLinks(text: string): string[] {
+ const tokens = text
+ .split(/[\s,]+/)
+ .filter((token) => LINK_PATTERN.test(token));
+ return [...new Set(tokens)];
+}
+
+export function detectPlatform(url: string): PlatformId {
+ const host = hostOf(url);
+ return (
+ PLATFORM_HOSTS.find(([, pattern]) => pattern.test(host))?.[0] ?? 'other'
+ );
+}
+
+export function detectPlatforms(urls: readonly string[]): PlatformId[] {
+ return [...new Set(urls.map(detectPlatform))];
+}
+
+export function hasPlaylist(url: string): boolean {
+ try {
+ return new URL(url).searchParams.has('list');
+ } catch {
+ return false;
+ }
+}
+
+export function linkFromShare({
+ url,
+ text,
+}: {
+ url: string | null;
+ text: string | null;
+}): string | null {
+ return parseLinks([url ?? '', text ?? ''].join(' '))[0] ?? null;
+}
diff --git a/apps/web/src/lib/preferences.test.ts b/apps/web/src/lib/preferences.test.ts
new file mode 100644
index 0000000..4a0fa6b
--- /dev/null
+++ b/apps/web/src/lib/preferences.test.ts
@@ -0,0 +1,103 @@
+import { describe, expect, it } from 'vitest';
+import {
+ DEFAULT_PREFERENCES,
+ loadPersisted,
+ saveHistory,
+ saveItems,
+ savePreferences,
+} from './preferences';
+
+describe('preferences', () => {
+ it('returns defaults when nothing is stored or storage is corrupt', () => {
+ expect(loadPersisted().preferences).toEqual(DEFAULT_PREFERENCES);
+ window.localStorage.setItem('openmedia.preferences', '{broken');
+ expect(loadPersisted().preferences).toEqual(DEFAULT_PREFERENCES);
+ });
+
+ it('round-trips preferences, ready items and history while dropping transient items', () => {
+ savePreferences({ ...DEFAULT_PREFERENCES, accent: 'pink', theme: 'dark' });
+ saveItems([
+ { type: 'fetching', id: 'f', url: 'https://a.b' },
+ {
+ type: 'ready',
+ id: 'r',
+ media: {
+ url: 'https://a.b',
+ title: 'A',
+ thumbnail: '',
+ duration: 10,
+ uploader: '',
+ platform: 'other',
+ },
+ formats: [],
+ options: {
+ kind: 'video',
+ container: 'mp4',
+ qualityHeight: null,
+ audioFormat: 'm4a',
+ audioQuality: 'best',
+ trim: null,
+ subtitleLanguages: [],
+ subtitleMode: 'embed',
+ embedMetadata: true,
+ },
+ },
+ ]);
+ saveHistory([
+ {
+ id: 'h',
+ url: 'https://a.b',
+ title: 'A',
+ kind: 'audio',
+ label: 'MP3',
+ sizeBytes: 1,
+ finishedAt: '2026-09-14T00:00:00Z',
+ },
+ ]);
+ const restored = loadPersisted();
+ expect(restored.preferences.accent).toBe('pink');
+ expect(restored.items.map((item) => item.id)).toEqual(['r']);
+ expect(restored.history).toHaveLength(1);
+ });
+
+ it('falls back to empty lists when the queue or history holds non-array JSON', () => {
+ window.localStorage.setItem('openmedia.queue', '{}');
+ window.localStorage.setItem('openmedia.history', '{}');
+ const restored = loadPersisted();
+ expect(restored.items).toEqual([]);
+ expect(restored.history).toEqual([]);
+ });
+
+ it('replaces stored preferences outside the allowed values with defaults', () => {
+ window.localStorage.setItem(
+ 'openmedia.preferences',
+ JSON.stringify({
+ theme: 'neon',
+ accent: 'pink',
+ language: 'fr',
+ defaultFormat: 'video-webm-4k',
+ installHintDismissed: 'yes',
+ }),
+ );
+ expect(loadPersisted().preferences).toEqual({
+ ...DEFAULT_PREFERENCES,
+ accent: 'pink',
+ });
+ window.localStorage.setItem('openmedia.preferences', 'null');
+ expect(loadPersisted().preferences).toEqual(DEFAULT_PREFERENCES);
+ });
+
+ it('drops malformed queue and history entries', () => {
+ window.localStorage.setItem(
+ 'openmedia.queue',
+ JSON.stringify([null, 3, {}, { id: 'x' }]),
+ );
+ window.localStorage.setItem(
+ 'openmedia.history',
+ JSON.stringify([null, 'h', { title: 'no id' }]),
+ );
+ const restored = loadPersisted();
+ expect(restored.items).toEqual([]);
+ expect(restored.history).toEqual([]);
+ });
+});
diff --git a/apps/web/src/lib/preferences.ts b/apps/web/src/lib/preferences.ts
new file mode 100644
index 0000000..91d7aab
--- /dev/null
+++ b/apps/web/src/lib/preferences.ts
@@ -0,0 +1,114 @@
+import { DEFAULT_PREFERENCES } from '@/state/reducer';
+import type {
+ DefaultFormatId,
+ HistoryEntry,
+ Preferences,
+ QueueItem,
+} from '@/state/types';
+import type { LanguagePreference } from './i18n/I18nProvider';
+import {
+ ACCENTS,
+ PREFERENCES_STORAGE_KEY,
+ type ThemePreference,
+} from './theme';
+
+export { DEFAULT_PREFERENCES };
+
+export const DEFAULT_FORMATS: readonly DefaultFormatId[] = [
+ 'video-mp4-1080',
+ 'video-mp4-720',
+ 'audio-m4a',
+ 'audio-mp3',
+];
+export const LANGUAGES: readonly LanguagePreference[] = ['auto', 'vi', 'en'];
+
+const THEMES: readonly ThemePreference[] = ['system', 'light', 'dark'];
+const ACCENT_IDS = ACCENTS.map((accent) => accent.id);
+const FLAGS: readonly boolean[] = [true, false];
+const ITEMS_STORAGE_KEY = 'openmedia.queue';
+const HISTORY_STORAGE_KEY = 'openmedia.history';
+
+function read(key: string): unknown {
+ try {
+ const raw = window.localStorage.getItem(key);
+ return raw === null ? null : JSON.parse(raw);
+ } catch {
+ return null;
+ }
+}
+
+function write(key: string, value: unknown): boolean {
+ try {
+ window.localStorage.setItem(key, JSON.stringify(value));
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null;
+}
+
+function isStoredItem(value: unknown): value is QueueItem {
+ return isRecord(value) && (value.type === 'ready' || value.type === 'job');
+}
+
+function isStoredHistoryEntry(value: unknown): value is HistoryEntry {
+ return isRecord(value) && typeof value.id === 'string';
+}
+
+function readArray(key: string): unknown[] {
+ const value = read(key);
+ return Array.isArray(value) ? value : [];
+}
+
+function allowed(choices: readonly T[], value: unknown, fallback: T): T {
+ return choices.find((choice) => choice === value) ?? fallback;
+}
+
+function preferencesFrom(stored: unknown): Preferences {
+ const record = isRecord(stored) ? stored : {};
+ return {
+ theme: allowed(THEMES, record.theme, DEFAULT_PREFERENCES.theme),
+ accent: allowed(ACCENT_IDS, record.accent, DEFAULT_PREFERENCES.accent),
+ language: allowed(LANGUAGES, record.language, DEFAULT_PREFERENCES.language),
+ defaultFormat: allowed(
+ DEFAULT_FORMATS,
+ record.defaultFormat,
+ DEFAULT_PREFERENCES.defaultFormat,
+ ),
+ installHintDismissed: allowed(
+ FLAGS,
+ record.installHintDismissed,
+ DEFAULT_PREFERENCES.installHintDismissed,
+ ),
+ };
+}
+
+export function loadPersisted(): {
+ preferences: Preferences;
+ items: QueueItem[];
+ history: HistoryEntry[];
+} {
+ return {
+ preferences: preferencesFrom(read(PREFERENCES_STORAGE_KEY)),
+ items: readArray(ITEMS_STORAGE_KEY).filter(isStoredItem),
+ history: readArray(HISTORY_STORAGE_KEY).filter(isStoredHistoryEntry),
+ };
+}
+
+export function savePreferences(preferences: Preferences): boolean {
+ return write(PREFERENCES_STORAGE_KEY, preferences);
+}
+
+export function saveItems(items: readonly QueueItem[]): boolean {
+ return write(
+ ITEMS_STORAGE_KEY,
+ items.filter((item) => item.type === 'ready' || item.type === 'job'),
+ );
+}
+
+export function saveHistory(history: readonly HistoryEntry[]): boolean {
+ return write(HISTORY_STORAGE_KEY, history);
+}
diff --git a/apps/web/src/lib/theme.ts b/apps/web/src/lib/theme.ts
new file mode 100644
index 0000000..ebd34c0
--- /dev/null
+++ b/apps/web/src/lib/theme.ts
@@ -0,0 +1,32 @@
+export type ThemePreference = 'system' | 'light' | 'dark';
+export type AccentId =
+ 'teal' | 'blue' | 'purple' | 'pink' | 'orange' | 'green' | 'graphite';
+
+export const DEFAULT_ACCENT: AccentId = 'teal';
+
+export const ACCENTS: ReadonlyArray<{
+ readonly id: AccentId;
+ readonly swatch: string;
+}> = [
+ { id: 'teal', swatch: '#12939c' },
+ { id: 'blue', swatch: '#007aff' },
+ { id: 'purple', swatch: '#af52de' },
+ { id: 'pink', swatch: '#ff2d55' },
+ { id: 'orange', swatch: '#ff9500' },
+ { id: 'green', swatch: '#34c759' },
+ { id: 'graphite', swatch: '#8e8e93' },
+];
+
+export const PREFERENCES_STORAGE_KEY = 'openmedia.preferences';
+
+export function applyTheme(theme: ThemePreference): void {
+ const root = document.documentElement;
+ if (theme === 'system') root.removeAttribute('data-theme');
+ else root.dataset.theme = theme;
+}
+
+export function applyAccent(accent: AccentId): void {
+ document.documentElement.dataset.accent = accent;
+}
+
+export const THEME_BOOTSTRAP_SCRIPT = `try{var p=JSON.parse(localStorage.getItem("${PREFERENCES_STORAGE_KEY}")||"{}");if(p.theme==="light"||p.theme==="dark"){document.documentElement.dataset.theme=p.theme}document.documentElement.dataset.accent=p.accent||"${DEFAULT_ACCENT}"}catch(e){document.documentElement.dataset.accent="${DEFAULT_ACCENT}"}`;
diff --git a/apps/web/src/state/StoreProvider.test.tsx b/apps/web/src/state/StoreProvider.test.tsx
new file mode 100644
index 0000000..1a03c6c
--- /dev/null
+++ b/apps/web/src/state/StoreProvider.test.tsx
@@ -0,0 +1,139 @@
+import { act, render, waitFor } from "@testing-library/react";
+import { useEffect } from "react";
+import { describe, expect, it, vi } from "vitest";
+import { api } from "@/lib/api/client";
+import type { Job } from "@/lib/api/types";
+import { StoreProvider, useStore } from "./StoreProvider";
+
+const INFO = {
+ id: "a",
+ title: "Pho",
+ thumbnail: "",
+ duration: 60,
+ uploader: "",
+ platform: "Youtube",
+ webpage_url: "",
+ formats: [],
+ subtitle_languages: [],
+ has_chapters: false,
+ is_playlist: false,
+};
+
+function job(id: string, status: Job["status"] = "error"): Job {
+ return {
+ job_id: id,
+ url: "https://youtu.be/a",
+ title: "Pho",
+ status,
+ progress: 0,
+ speed_bps: null,
+ eta_seconds: null,
+ downloaded_bytes: null,
+ total_bytes: null,
+ queue_position: 0,
+ options: {
+ kind: "video",
+ container: "mp4",
+ quality_height: null,
+ format_id: null,
+ audio_format: null,
+ audio_quality: null,
+ trim: null,
+ subtitles: null,
+ embed_metadata: true,
+ },
+ filename: null,
+ files: [],
+ error: null,
+ error_code: null,
+ created_at: "2026-09-14T00:00:00Z",
+ finished_at: null,
+ expires_at: null,
+ };
+}
+
+let latest: ReturnType | null = null;
+function Probe(): null {
+ const store = useStore();
+ useEffect(() => {
+ latest = store;
+ });
+ return null;
+}
+
+function mockServer(): void {
+ vi.spyOn(api, "session").mockResolvedValue({
+ auth_required: false,
+ authenticated: true,
+ limits: { max_filesize_mb: 1, max_playlist_items: 1 },
+ });
+ vi.spyOn(api, "settings").mockResolvedValue({} as never);
+ vi.spyOn(api, "storage").mockResolvedValue({} as never);
+ vi.spyOn(api, "cookies").mockResolvedValue({
+ present: false,
+ domains: [],
+ expires_at: null,
+ uploaded_at: null,
+ });
+ vi.spyOn(api, "jobs").mockResolvedValue([]);
+}
+
+describe("StoreProvider", () => {
+ it("shows the fetched notice on the first fetch", async () => {
+ mockServer();
+ vi.spyOn(api, "info").mockResolvedValue(INFO);
+ render(
+
+
+ ,
+ );
+ await act(async () => {
+ await latest!.commands.fetchLinks(["https://youtu.be/a"], "single");
+ });
+ expect(latest!.state.items.map((item) => item.type)).toEqual(["ready"]);
+ expect(latest!.state.notice?.message).toBe("fetched");
+ });
+
+ it("retries a job by starting a fresh download", async () => {
+ mockServer();
+ vi.spyOn(api, "info").mockResolvedValue(INFO);
+ vi.spyOn(api, "download")
+ .mockResolvedValueOnce({ job_id: "j1", job: job("j1", "queued") })
+ .mockResolvedValueOnce({ job_id: "j2", job: job("j2", "queued") });
+ const remove = vi.spyOn(api, "removeJob").mockResolvedValue(undefined);
+ render(
+
+
+ ,
+ );
+ await act(async () => {
+ await latest!.commands.fetchLinks(["https://youtu.be/a"], "single");
+ });
+ await act(async () => {
+ await latest!.commands.startDownload(latest!.state.items[0].id);
+ });
+ await act(async () => {
+ await latest!.commands.retryJob("j1");
+ });
+ expect(remove).toHaveBeenCalledWith("j1");
+ expect(api.download).toHaveBeenCalledTimes(2);
+ expect(latest!.state.items[0]).toMatchObject({ type: "job", id: "j2" });
+ });
+
+ it("does not restart the polling interval on unrelated state changes", async () => {
+ mockServer();
+ const setIntervalSpy = vi.spyOn(window, "setInterval");
+ render(
+
+
+ ,
+ );
+ await waitFor(() => expect(latest!.state.session).not.toBeNull());
+ await waitFor(() => expect(api.jobs).toHaveBeenCalled());
+ const before = setIntervalSpy.mock.calls.length;
+ for (let index = 0; index < 5; index += 1) {
+ act(() => latest!.dispatch({ type: "item/selected", id: `x${index}` }));
+ }
+ expect(setIntervalSpy.mock.calls.length - before).toBe(0);
+ });
+});
diff --git a/apps/web/src/state/StoreProvider.tsx b/apps/web/src/state/StoreProvider.tsx
new file mode 100644
index 0000000..55e08d5
--- /dev/null
+++ b/apps/web/src/state/StoreProvider.tsx
@@ -0,0 +1,107 @@
+"use client";
+
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ useSyncExternalStore,
+ type Dispatch,
+ type ReactNode,
+} from "react";
+import { I18nProvider, resolveLocale } from "@/lib/i18n/I18nProvider";
+import {
+ loadPersisted,
+ saveHistory,
+ saveItems,
+ savePreferences,
+} from "@/lib/preferences";
+import { applyAccent, applyTheme } from "@/lib/theme";
+import { createCommands, type Commands } from "./commands";
+import { initialState } from "./reducer";
+import { createStore } from "./store";
+import type { Action, AppState } from "./types";
+import { useJobPolling } from "./useJobPolling";
+
+interface StoreValue {
+ readonly state: AppState;
+ readonly dispatch: Dispatch;
+ readonly commands: Commands;
+}
+
+const StoreContext = createContext(null);
+const ACTIVE_STATUSES = new Set(["queued", "downloading", "processing"]);
+
+function createInitialState(): AppState {
+ const persisted = loadPersisted();
+ return {
+ ...initialState(persisted.preferences),
+ items: persisted.items,
+ history: persisted.history,
+ };
+}
+
+export function StoreProvider({
+ children,
+}: {
+ children: ReactNode;
+}): ReactNode {
+ const [store] = useState(() => createStore(createInitialState()));
+ const [commands] = useState(() =>
+ createCommands(store.dispatch, store.getState),
+ );
+ const state = useSyncExternalStore(store.subscribe, store.getState);
+ const { dispatch } = store;
+
+ useEffect(() => {
+ void commands.loadServerState();
+ }, [commands]);
+
+ useEffect(() => {
+ savePreferences(state.preferences);
+ applyTheme(state.preferences.theme);
+ applyAccent(state.preferences.accent);
+ }, [state.preferences]);
+
+ useEffect(() => {
+ saveItems(state.items);
+ }, [state.items]);
+
+ useEffect(() => {
+ saveHistory(state.history);
+ }, [state.history]);
+
+ const hasActiveJobs = state.items.some(
+ (item) => item.type === "job" && ACTIVE_STATUSES.has(item.job.status),
+ );
+ const canPoll =
+ state.session !== null &&
+ (!state.session.auth_required || state.session.authenticated);
+ useJobPolling(commands.syncJobs, hasActiveJobs, canPoll);
+
+ const locale = resolveLocale(
+ state.preferences.language,
+ typeof navigator === "undefined" ? "en" : navigator.language,
+ );
+ useEffect(() => {
+ document.documentElement.lang = locale;
+ }, [locale]);
+
+ const value = useMemo(
+ () => ({ state, dispatch, commands }),
+ [state, dispatch, commands],
+ );
+ return (
+
+ {children}
+
+ );
+}
+
+export function useStore(): StoreValue {
+ const value = useContext(StoreContext);
+ if (value === null)
+ throw new Error("useStore must be used inside StoreProvider");
+ return value;
+}
diff --git a/apps/web/src/state/commands.test.ts b/apps/web/src/state/commands.test.ts
new file mode 100644
index 0000000..ee3aff3
--- /dev/null
+++ b/apps/web/src/state/commands.test.ts
@@ -0,0 +1,428 @@
+import { describe, expect, it, vi } from 'vitest';
+import { api, ApiRequestError } from '@/lib/api/client';
+import { createCommands, POLL_TIMEOUT_MS } from './commands';
+import { initialState, reducer } from './reducer';
+import type { Action, AppState } from './types';
+
+function harness(): {
+ commands: ReturnType;
+ state: () => AppState;
+ notices: () => number;
+} {
+ let state = initialState();
+ let noticeCount = 0;
+ const dispatch = (action: Action): void => {
+ if (action.type === 'notice/shown') noticeCount += 1;
+ state = reducer(state, action);
+ };
+ return {
+ commands: createCommands(dispatch, () => state),
+ state: () => state,
+ notices: () => noticeCount,
+ };
+}
+
+function deferred(): {
+ promise: Promise;
+ resolve: (value: T) => void;
+} {
+ let resolve: (value: T) => void = () => undefined;
+ const promise = new Promise((settle) => {
+ resolve = settle;
+ });
+ return { promise, resolve };
+}
+
+const SIGNED_OUT = {
+ auth_required: true,
+ authenticated: false,
+ limits: { max_filesize_mb: 1, max_playlist_items: 1 },
+};
+
+const unreachable = (): ApiRequestError =>
+ new ApiRequestError(0, 'api_unreachable', 'down', null);
+const expired = (): ApiRequestError =>
+ new ApiRequestError(401, 'auth_required', 'Sign in to continue.', null);
+
+const INFO = {
+ id: 'a',
+ title: 'Pho',
+ thumbnail: '',
+ duration: 60,
+ uploader: '',
+ platform: 'Youtube',
+ webpage_url: '',
+ formats: [],
+ subtitle_languages: [],
+ has_chapters: false,
+ is_playlist: false,
+};
+
+const JOB = {
+ job_id: 'j1',
+ url: 'https://youtu.be/a',
+ title: 'Pho',
+ status: 'queued',
+ progress: 0,
+ speed_bps: null,
+ eta_seconds: null,
+ downloaded_bytes: null,
+ total_bytes: null,
+ queue_position: 1,
+ options: {
+ kind: 'video',
+ container: 'mp4',
+ quality_height: null,
+ format_id: null,
+ audio_format: null,
+ audio_quality: null,
+ trim: null,
+ subtitles: null,
+ embed_metadata: true,
+ },
+ filename: null,
+ files: [],
+ error: null,
+ error_code: null,
+ created_at: '2026-09-14T00:00:00Z',
+ finished_at: null,
+ expires_at: null,
+} as const;
+
+describe('commands', () => {
+ it('fetches each link and records failures with their codes', async () => {
+ vi.spyOn(api, 'info').mockImplementation(async (url) => {
+ if (url.includes('bad'))
+ throw new ApiRequestError(400, 'unsupported_url', 'no', null);
+ return INFO;
+ });
+ const { commands, state } = harness();
+ await commands.fetchLinks(
+ ['https://youtu.be/a', 'https://bad.example/x'],
+ 'single',
+ );
+ expect(
+ state()
+ .items.map((item) => item.type)
+ .sort(),
+ ).toEqual(['fetch-error', 'ready']);
+ });
+
+ it('announces info only when the new links produced ready items', async () => {
+ vi.spyOn(api, 'info').mockImplementation(async (url) => {
+ if (url.includes('bad'))
+ throw new ApiRequestError(400, 'private_network', 'no', null);
+ return INFO;
+ });
+ const { commands, state } = harness();
+ await commands.fetchLinks(['https://youtu.be/a'], 'single');
+ const firstNotice = state().notice;
+ await commands.fetchLinks(['https://bad.example/x'], 'single');
+ expect(state().notice).toBe(firstNotice);
+ });
+
+ it('expands playlists before fetching', async () => {
+ vi.spyOn(api, 'playlist').mockResolvedValue({
+ title: 'Mix',
+ count: 2,
+ urls: ['https://youtu.be/1', 'https://youtu.be/2'],
+ });
+ const info = vi.spyOn(api, 'info').mockResolvedValue(INFO);
+ const { commands } = harness();
+ await commands.fetchLinks(
+ ['https://www.youtube.com/watch?v=a&list=PL1'],
+ 'playlist',
+ );
+ expect(info).toHaveBeenCalledTimes(2);
+ });
+
+ it('runs at most three info requests at once across links and playlist entries', async () => {
+ let running = 0;
+ let peak = 0;
+ const info = vi.spyOn(api, 'info').mockImplementation(async () => {
+ running += 1;
+ peak = Math.max(peak, running);
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ running -= 1;
+ return INFO;
+ });
+ vi.spyOn(api, 'playlist').mockResolvedValue({
+ title: 'Mix',
+ count: 5,
+ urls: [1, 2, 3, 4, 5].map((entry) => `https://youtu.be/p${entry}`),
+ });
+ const { commands, state } = harness();
+ await commands.fetchLinks(
+ [
+ 'https://www.youtube.com/watch?v=a&list=PL1',
+ 'https://youtu.be/a',
+ 'https://youtu.be/b',
+ 'https://youtu.be/c',
+ ],
+ 'playlist',
+ );
+ expect(info).toHaveBeenCalledTimes(8);
+ expect(peak).toBe(3);
+ expect(state().items.filter((item) => item.type === 'ready')).toHaveLength(
+ 8,
+ );
+ });
+
+ it('adds the entries of a link that resolves to a playlist', async () => {
+ const album = 'https://archive.org/details/fables';
+ vi.spyOn(api, 'info').mockImplementation(async (url) =>
+ url === album ? { ...INFO, is_playlist: true } : INFO,
+ );
+ const playlist = vi.spyOn(api, 'playlist').mockResolvedValue({
+ title: 'Fables',
+ count: 2,
+ urls: [
+ 'https://archive.org/download/fables/1.mp3',
+ 'https://archive.org/download/fables/2.mp3',
+ ],
+ });
+ const { commands, state } = harness();
+ await commands.fetchLinks([album], 'single');
+ expect(playlist).toHaveBeenCalledWith(album);
+ expect(state().items.map((item) => item.type)).toEqual(['ready', 'ready']);
+ expect(state().notice).toMatchObject({
+ message: 'playlistAdded',
+ count: 2,
+ });
+ });
+
+ it('expands only one level and marks nested playlists as errors', async () => {
+ const album = 'https://archive.org/details/fables';
+ const nested = 'https://archive.org/details/fables/more';
+ vi.spyOn(api, 'info').mockImplementation(async (url) =>
+ url === album || url === nested ? { ...INFO, is_playlist: true } : INFO,
+ );
+ const playlist = vi.spyOn(api, 'playlist').mockResolvedValue({
+ title: 'Fables',
+ count: 2,
+ urls: [nested, 'https://archive.org/download/fables/1.mp3'],
+ });
+ const { commands, state } = harness();
+ await commands.fetchLinks([album], 'single');
+ expect(playlist).toHaveBeenCalledTimes(1);
+ expect(
+ state()
+ .items.map((item) =>
+ item.type === 'fetch-error' ? item.code : item.type,
+ )
+ .sort(),
+ ).toEqual(['nested_playlist', 'ready']);
+ });
+
+ it('keeps an error row for a playlist that lists only itself', async () => {
+ const album = 'https://archive.org/details/empty';
+ const info = vi
+ .spyOn(api, 'info')
+ .mockResolvedValue({ ...INFO, is_playlist: true });
+ vi.spyOn(api, 'playlist').mockResolvedValue({
+ title: 'Empty',
+ count: 1,
+ urls: [album],
+ });
+ const { commands, state } = harness();
+ await commands.fetchLinks([album], 'single');
+ expect(info).toHaveBeenCalledTimes(1);
+ expect(state().items).toEqual([
+ {
+ type: 'fetch-error',
+ id: expect.any(String),
+ url: album,
+ code: 'empty_playlist',
+ },
+ ]);
+ });
+
+ it('starts a download and cancels it back to ready', async () => {
+ vi.spyOn(api, 'info').mockResolvedValue(INFO);
+ vi.spyOn(api, 'download').mockResolvedValue({ job_id: 'j1', job: JOB });
+ const remove = vi.spyOn(api, 'removeJob').mockResolvedValue(undefined);
+ const { commands, state } = harness();
+ await commands.fetchLinks(['https://youtu.be/a'], 'single');
+ await commands.startDownload(state().items[0].id);
+ expect(state().items[0]).toMatchObject({ type: 'job', id: 'j1' });
+ await commands.cancelJob('j1');
+ expect(remove).toHaveBeenCalledWith('j1');
+ expect(state().items[0].type).toBe('ready');
+ });
+
+ it('refreshes storage usage whenever jobs are synced', async () => {
+ vi.spyOn(api, 'jobs').mockResolvedValue([]);
+ const usage = { used_bytes: 5_000_000, limit_bytes: null, free_bytes: 1 };
+ vi.spyOn(api, 'storage').mockResolvedValue(usage);
+ const { commands, state } = harness();
+ await commands.syncJobs();
+ expect(state().storage).toEqual(usage);
+ });
+
+ it('keeps syncing jobs silently when storage usage fails', async () => {
+ const usage = { used_bytes: 5_000_000, limit_bytes: null, free_bytes: 1 };
+ vi.spyOn(api, 'storage')
+ .mockResolvedValueOnce(usage)
+ .mockRejectedValueOnce(
+ new ApiRequestError(500, 'unknown_error', 'x', null),
+ );
+ vi.spyOn(api, 'jobs')
+ .mockResolvedValueOnce([])
+ .mockResolvedValueOnce([JOB]);
+ const { commands, state } = harness();
+ await commands.syncJobs();
+ await commands.syncJobs();
+ expect(state().storage).toEqual(usage);
+ expect(state().notice).toBeNull();
+ expect(state().items.map((item) => item.id)).toEqual(['j1']);
+ });
+
+ it('says a history entry was added again only when its fetch succeeded', async () => {
+ vi.spyOn(api, 'info')
+ .mockRejectedValueOnce(
+ new ApiRequestError(400, 'unavailable', 'gone', null),
+ )
+ .mockResolvedValueOnce(INFO);
+ const { commands, state, notices } = harness();
+ const entryId = 'j1';
+ vi.spyOn(api, 'jobs').mockResolvedValue([{ ...JOB, status: 'done' }]);
+ vi.spyOn(api, 'storage').mockResolvedValue({
+ used_bytes: 0,
+ limit_bytes: null,
+ free_bytes: 1,
+ });
+ await commands.syncJobs();
+ expect(state().history.map((entry) => entry.id)).toEqual([entryId]);
+ const before = notices();
+ await commands.downloadAgain(entryId);
+ expect(notices()).toBe(before);
+ await commands.downloadAgain(entryId);
+ expect(state().notice).toMatchObject({ message: 'addedAgain' });
+ });
+
+ it('shows a localized notice code when the API fails', async () => {
+ vi.spyOn(api, 'updateSettings').mockRejectedValue(
+ new ApiRequestError(400, 'invalid_option', 'bad', null),
+ );
+ const { commands, state } = harness();
+ await commands.saveSettings({ max_concurrent: 9 });
+ expect(state().notice).toMatchObject({
+ tone: 'error',
+ message: 'invalid_option',
+ });
+ });
+
+ it('shows the sign in screen when a poll finds the session expired', async () => {
+ vi.spyOn(api, 'storage').mockRejectedValue(expired());
+ vi.spyOn(api, 'jobs').mockRejectedValue(expired());
+ vi.spyOn(api, 'session').mockResolvedValue(SIGNED_OUT);
+ const { commands, state, notices } = harness();
+ await commands.syncJobs();
+ expect(state().session).toEqual(SIGNED_OUT);
+ expect(notices()).toBe(0);
+ });
+
+ it('reloads the session when any command meets an expired session', async () => {
+ vi.spyOn(api, 'updateSettings').mockRejectedValue(expired());
+ vi.spyOn(api, 'session').mockResolvedValue(SIGNED_OUT);
+ const { commands, state } = harness();
+ await commands.saveSettings({ max_concurrent: 2 });
+ expect(state().session).toEqual(SIGNED_OUT);
+ });
+
+ it('reloads the session when fetching links meets an expired session', async () => {
+ vi.spyOn(api, 'info').mockRejectedValue(expired());
+ const session = vi.spyOn(api, 'session').mockResolvedValue(SIGNED_OUT);
+ const { commands, state } = harness();
+ await commands.fetchLinks(
+ ['https://youtu.be/a', 'https://youtu.be/b'],
+ 'single',
+ );
+ expect(session).toHaveBeenCalledTimes(1);
+ expect(state().session).toEqual(SIGNED_OUT);
+ });
+
+ it('reports an outage once until a poll succeeds again', async () => {
+ vi.spyOn(api, 'storage').mockRejectedValue(unreachable());
+ const jobs = vi
+ .spyOn(api, 'jobs')
+ .mockRejectedValueOnce(unreachable())
+ .mockRejectedValueOnce(unreachable())
+ .mockRejectedValueOnce(unreachable())
+ .mockResolvedValueOnce([])
+ .mockRejectedValueOnce(unreachable());
+ const { commands, notices } = harness();
+ for (let tick = 0; tick < 3; tick += 1) await commands.syncJobs();
+ expect(notices()).toBe(1);
+ await commands.syncJobs();
+ await commands.syncJobs();
+ expect(jobs).toHaveBeenCalledTimes(5);
+ expect(notices()).toBe(2);
+ });
+
+ it('skips a poll while the previous one is still running', async () => {
+ vi.spyOn(api, 'storage').mockResolvedValue({
+ used_bytes: 0,
+ limit_bytes: null,
+ free_bytes: 1,
+ });
+ const pending = deferred<[]>();
+ const jobs = vi.spyOn(api, 'jobs').mockReturnValue(pending.promise);
+ const { commands } = harness();
+ const first = commands.syncJobs();
+ await commands.syncJobs();
+ expect(jobs).toHaveBeenCalledTimes(1);
+ pending.resolve([]);
+ await first;
+ });
+
+ it('gives up on a hung poll so later polls run again', async () => {
+ vi.useFakeTimers();
+ try {
+ vi.spyOn(api, 'storage').mockResolvedValue({
+ used_bytes: 0,
+ limit_bytes: null,
+ free_bytes: 1,
+ });
+ const hangUntilAborted = (signal?: AbortSignal): Promise<[]> =>
+ new Promise((_resolve, reject) => {
+ signal?.addEventListener('abort', () =>
+ reject(new ApiRequestError(0, 'api_unreachable', 'gone', null)),
+ );
+ });
+ const jobs = vi
+ .spyOn(api, 'jobs')
+ .mockImplementationOnce(hangUntilAborted)
+ .mockResolvedValue([]);
+ const { commands } = harness();
+ const hung = commands.syncJobs();
+ await vi.advanceTimersByTimeAsync(POLL_TIMEOUT_MS);
+ await hung;
+ await commands.syncJobs();
+ expect(jobs).toHaveBeenCalledTimes(2);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('keeps a removed job away when a poll from before the removal returns', async () => {
+ vi.spyOn(api, 'info').mockResolvedValue(INFO);
+ vi.spyOn(api, 'download').mockResolvedValue({ job_id: 'j1', job: JOB });
+ vi.spyOn(api, 'removeJob').mockResolvedValue(undefined);
+ vi.spyOn(api, 'storage').mockResolvedValue({
+ used_bytes: 0,
+ limit_bytes: null,
+ free_bytes: 1,
+ });
+ const stalePoll = deferred<(typeof JOB)[]>();
+ vi.spyOn(api, 'jobs').mockReturnValue(stalePoll.promise);
+ const { commands, state } = harness();
+ await commands.fetchLinks(['https://youtu.be/a'], 'single');
+ await commands.startDownload(state().items[0].id);
+ const poll = commands.syncJobs();
+ await commands.removeItem('j1');
+ stalePoll.resolve([JOB]);
+ await poll;
+ expect(state().items).toEqual([]);
+ });
+});
diff --git a/apps/web/src/state/commands.ts b/apps/web/src/state/commands.ts
new file mode 100644
index 0000000..cd98f24
--- /dev/null
+++ b/apps/web/src/state/commands.ts
@@ -0,0 +1,367 @@
+import { api, ApiRequestError } from '@/lib/api/client';
+import type { Job, RuntimeSettings, SessionInfo } from '@/lib/api/types';
+import { hasPlaylist } from '@/lib/links';
+import { toDownloadRequest } from './options';
+import type { Action, AppState, Notice, PlaylistScope } from './types';
+
+type Dispatch = (action: Action) => void;
+type NoticeInput = Omit;
+type PlaylistHandler = (id: string, url: string) => Promise;
+
+export interface Commands {
+ notify(notice: NoticeInput): void;
+ fetchLinks(urls: readonly string[], scope: PlaylistScope): Promise;
+ startDownload(itemId: string): Promise;
+ startAllReady(): Promise;
+ cancelJob(jobId: string): Promise;
+ retryJob(jobId: string): Promise;
+ retryFetch(itemId: string): Promise;
+ removeItem(itemId: string): Promise;
+ downloadAgain(entryId: string): Promise;
+ syncJobs(): Promise;
+ loadServerState(): Promise;
+ saveSettings(patch: Partial): Promise;
+ uploadCookies(file: File): Promise;
+ removeCookies(): Promise;
+ signIn(password: string): Promise;
+ signOut(): Promise;
+}
+
+const AUTH_REQUIRED = 'auth_required';
+const MAX_CONCURRENT_INFO = 3;
+export const POLL_TIMEOUT_MS = 15_000;
+
+let noticeSequence = 0;
+let itemSequence = 0;
+
+const nextItemId = (): string =>
+ `item-${Date.now().toString(36)}-${(itemSequence += 1)}`;
+
+function errorCode(error: unknown): string {
+ return error instanceof ApiRequestError ? error.code : 'unknown_error';
+}
+
+function createLimiter(
+ limit: number,
+): (task: () => Promise) => Promise {
+ let active = 0;
+ const waiting: Array<() => void> = [];
+ const acquire = (): Promise => {
+ if (active < limit) {
+ active += 1;
+ return Promise.resolve();
+ }
+ return new Promise((resolve) => waiting.push(resolve));
+ };
+ const release = (): void => {
+ const next = waiting.shift();
+ if (next) next();
+ else active -= 1;
+ };
+ return async (task) => {
+ await acquire();
+ try {
+ return await task();
+ } finally {
+ release();
+ }
+ };
+}
+
+function needsSignIn(session: SessionInfo): boolean {
+ return session.auth_required && !session.authenticated;
+}
+
+export function createCommands(
+ dispatch: Dispatch,
+ getState: () => AppState,
+): Commands {
+ const notify = (notice: NoticeInput): void =>
+ dispatch({
+ type: 'notice/shown',
+ notice: { ...notice, id: (noticeSequence += 1) },
+ });
+ const fail = (error: unknown): void =>
+ notify({ tone: 'error', message: errorCode(error) });
+ const refreshSession = async (): Promise => {
+ const session = await api.session();
+ dispatch({ type: 'session/loaded', session });
+ return session;
+ };
+ const report = async (error: unknown): Promise => {
+ try {
+ const signedOut =
+ errorCode(error) === AUTH_REQUIRED &&
+ needsSignIn(await refreshSession());
+ if (!signedOut) fail(error);
+ } catch (sessionError) {
+ fail(sessionError);
+ }
+ };
+ const guarded = async (work: () => Promise): Promise => {
+ try {
+ await work();
+ } catch (error) {
+ await report(error);
+ }
+ };
+
+ const limitInfo = createLimiter(MAX_CONCURRENT_INFO);
+
+ const fetchLink = async (
+ url: string,
+ onPlaylist: PlaylistHandler,
+ ): Promise => {
+ const id = nextItemId();
+ dispatch({ type: 'fetch/started', id, url });
+ try {
+ const info = await limitInfo(() => api.info(url));
+ if (info.is_playlist) return await onPlaylist(id, url);
+ dispatch({ type: 'fetch/succeeded', id, url, info });
+ } catch (error) {
+ dispatch({ type: 'fetch/failed', id, code: errorCode(error) });
+ }
+ return [id];
+ };
+
+ const rejectNestedPlaylist: PlaylistHandler = async (id) => {
+ dispatch({ type: 'fetch/failed', id, code: 'nested_playlist' });
+ return [id];
+ };
+
+ const fetchEntry = (url: string): Promise =>
+ fetchLink(url, rejectNestedPlaylist);
+
+ const fetchEntries = async (urls: readonly string[]): Promise =>
+ (await Promise.all(urls.map(fetchEntry))).flat();
+
+ const replaceWithEntries: PlaylistHandler = async (id, url) => {
+ const entries = (await api.playlist(url)).urls.filter(
+ (entry) => entry !== url,
+ );
+ if (entries.length === 0) {
+ dispatch({ type: 'fetch/failed', id, code: 'empty_playlist' });
+ return [id];
+ }
+ dispatch({ type: 'item/removed', id });
+ return fetchEntries(entries);
+ };
+
+ const fetchOne = (url: string): Promise =>
+ fetchLink(url, replaceWithEntries);
+
+ const fetchScoped = async (
+ url: string,
+ scope: PlaylistScope,
+ ): Promise =>
+ scope === 'playlist' && hasPlaylist(url)
+ ? fetchEntries((await api.playlist(url)).urls)
+ : fetchOne(url);
+
+ const hasExpiredSessionRow = (ids: readonly string[]): boolean =>
+ getState().items.some(
+ (item) =>
+ item.type === 'fetch-error' &&
+ item.code === AUTH_REQUIRED &&
+ ids.includes(item.id),
+ );
+
+ const countReady = (ids: readonly string[]): number =>
+ getState().items.filter(
+ (item) => item.type === 'ready' && ids.includes(item.id),
+ ).length;
+
+ const keepLastStorage = (): void => undefined;
+
+ const refreshStorage = (signal: AbortSignal): Promise =>
+ api
+ .storage(signal)
+ .then(
+ (storage) => dispatch({ type: 'storage/loaded', storage }),
+ keepLastStorage,
+ );
+
+ const removedJobs = new Map();
+
+ const forgetRemovedBefore = (requestedAt: number): void =>
+ removedJobs.forEach((removedAt, jobId) => {
+ if (removedAt < requestedAt) removedJobs.delete(jobId);
+ });
+
+ const withoutRemoved = (jobs: readonly Job[], requestedAt: number): Job[] =>
+ jobs.filter((job) => (removedJobs.get(job.job_id) ?? -1) < requestedAt);
+
+ const syncJobs = async (): Promise => {
+ const requestedAt = Date.now();
+ const timeout = new AbortController();
+ const timer = setTimeout(() => timeout.abort(), POLL_TIMEOUT_MS);
+ try {
+ const storageRefresh = refreshStorage(timeout.signal);
+ const jobs = withoutRemoved(await api.jobs(timeout.signal), requestedAt);
+ forgetRemovedBefore(requestedAt);
+ dispatch({ type: 'jobs/synced', jobs, requestedAt });
+ await storageRefresh;
+ } finally {
+ clearTimeout(timer);
+ }
+ };
+
+ let outage = false;
+ let polling = false;
+
+ const pollJobs = async (): Promise => {
+ try {
+ await syncJobs();
+ outage = false;
+ } catch (error) {
+ if (errorCode(error) === AUTH_REQUIRED) return report(error);
+ if (!outage) fail(error);
+ outage = true;
+ }
+ };
+
+ const pollUnlessBusy = async (): Promise => {
+ if (polling) return;
+ polling = true;
+ try {
+ await pollJobs();
+ } finally {
+ polling = false;
+ }
+ };
+
+ const startDownload = async (itemId: string): Promise =>
+ guarded(async () => {
+ const item = getState().items.find(
+ (candidate) => candidate.id === itemId,
+ );
+ if (!item || item.type !== 'ready') return;
+ const { job } = await api.download(toDownloadRequest(item));
+ dispatch({ type: 'download/started', itemId, job, linkedAt: Date.now() });
+ });
+
+ return {
+ notify,
+ syncJobs: pollUnlessBusy,
+ fetchLinks: (urls, scope) =>
+ guarded(async () => {
+ const ids = (
+ await Promise.all(urls.map((url) => fetchScoped(url, scope)))
+ ).flat();
+ if (hasExpiredSessionRow(ids)) await refreshSession();
+ if (countReady(ids) > 0)
+ notify({
+ tone: 'success',
+ message: ids.length > urls.length ? 'playlistAdded' : 'fetched',
+ count: ids.length,
+ });
+ }),
+ startDownload,
+ startAllReady: async () => {
+ const readyIds = getState()
+ .items.filter((item) => item.type === 'ready')
+ .map((item) => item.id);
+ for (const id of readyIds) await startDownload(id);
+ },
+ cancelJob: (jobId) =>
+ guarded(async () => {
+ await api.removeJob(jobId);
+ dispatch({ type: 'job/cancelled', jobId });
+ notify({ tone: 'info', message: 'cancelled' });
+ }),
+ retryJob: (jobId) =>
+ guarded(async () => {
+ await api.removeJob(jobId);
+ dispatch({ type: 'job/cancelled', jobId });
+ await startDownload(jobId);
+ }),
+ retryFetch: (itemId) =>
+ guarded(async () => {
+ const item = getState().items.find(
+ (candidate) => candidate.id === itemId,
+ );
+ if (!item || item.type !== 'fetch-error') return;
+ dispatch({ type: 'item/removed', id: itemId });
+ await fetchOne(item.url);
+ }),
+ removeItem: (itemId) =>
+ guarded(async () => {
+ const item = getState().items.find(
+ (candidate) => candidate.id === itemId,
+ );
+ if (item?.type === 'job') {
+ await api.removeJob(itemId);
+ removedJobs.set(itemId, Date.now());
+ }
+ dispatch({ type: 'item/removed', id: itemId });
+ }),
+ downloadAgain: (entryId) =>
+ guarded(async () => {
+ const entry = getState().history.find(
+ (candidate) => candidate.id === entryId,
+ );
+ if (!entry) return;
+ dispatch({ type: 'view/changed', view: 'queue', filter: 'all' });
+ const ids = await fetchOne(entry.url);
+ if (countReady(ids) > 0)
+ notify({ tone: 'success', message: 'addedAgain' });
+ }),
+ loadServerState: () =>
+ guarded(async () => {
+ const session = await api.session();
+ dispatch({ type: 'session/loaded', session });
+ if (session.auth_required && !session.authenticated) return;
+ const [settings, cookies] = await Promise.all([
+ api.settings(),
+ api.cookies(),
+ ]);
+ dispatch({ type: 'settings/loaded', settings });
+ dispatch({ type: 'cookies/loaded', cookies });
+ await syncJobs();
+ }),
+ saveSettings: (patch) =>
+ guarded(async () => {
+ dispatch({
+ type: 'settings/loaded',
+ settings: await api.updateSettings(patch),
+ });
+ }),
+ uploadCookies: (file) =>
+ guarded(async () => {
+ dispatch({
+ type: 'cookies/loaded',
+ cookies: await api.uploadCookies(file),
+ });
+ notify({ tone: 'success', message: 'cookiesLoaded' });
+ }),
+ removeCookies: () =>
+ guarded(async () => {
+ await api.removeCookies();
+ dispatch({
+ type: 'cookies/loaded',
+ cookies: {
+ present: false,
+ domains: [],
+ expires_at: null,
+ uploaded_at: null,
+ },
+ });
+ notify({ tone: 'info', message: 'cookiesRemoved' });
+ }),
+ signIn: async (password) => {
+ try {
+ await api.signIn(password);
+ dispatch({ type: 'session/loaded', session: await api.session() });
+ return true;
+ } catch (error) {
+ if (errorCode(error) !== 'invalid_password') fail(error);
+ return false;
+ }
+ },
+ signOut: () =>
+ guarded(async () => {
+ await api.signOut();
+ dispatch({ type: 'session/loaded', session: await api.session() });
+ }),
+ };
+}
diff --git a/apps/web/src/state/options.test.ts b/apps/web/src/state/options.test.ts
new file mode 100644
index 0000000..0c2906a
--- /dev/null
+++ b/apps/web/src/state/options.test.ts
@@ -0,0 +1,141 @@
+import { describe, expect, it } from 'vitest';
+import type { MediaFormat } from '@/lib/api/types';
+import {
+ defaultDraft,
+ estimateBytes,
+ isTrimmed,
+ toDownloadRequest,
+} from './options';
+import type { ReadyItem } from './types';
+
+const FORMATS: MediaFormat[] = [
+ {
+ id: '313',
+ label: '2160p',
+ height: 2160,
+ ext: 'webm',
+ filesize: 1_600_000_000,
+ },
+ {
+ id: '137',
+ label: '1080p',
+ height: 1080,
+ ext: 'mp4',
+ filesize: 412_000_000,
+ },
+ { id: '136', label: '720p', height: 720, ext: 'mp4', filesize: null },
+];
+
+function readyItem(overrides: Partial = {}): ReadyItem {
+ return {
+ type: 'ready',
+ id: 'r1',
+ media: {
+ url: 'https://youtu.be/a',
+ title: 'Pho',
+ thumbnail: '',
+ duration: 1122,
+ uploader: 'Bep',
+ platform: 'youtube',
+ },
+ formats: FORMATS,
+ options: { ...defaultDraft('video-mp4-1080', FORMATS), ...overrides },
+ };
+}
+
+describe('options', () => {
+ it('picks the best height at or below the default', () => {
+ expect(defaultDraft('video-mp4-1080', FORMATS).qualityHeight).toBe(1080);
+ expect(defaultDraft('video-mp4-720', FORMATS).qualityHeight).toBe(720);
+ expect(defaultDraft('video-mp4-1080', []).qualityHeight).toBeNull();
+ expect(defaultDraft('audio-mp3', FORMATS)).toMatchObject({
+ kind: 'audio',
+ audioFormat: 'mp3',
+ audioQuality: '320k',
+ });
+ });
+
+ it('builds a minimal video request', () => {
+ expect(toDownloadRequest(readyItem())).toEqual({
+ url: 'https://youtu.be/a',
+ title: 'Pho',
+ format: 'video',
+ container: 'mp4',
+ format_id: '137',
+ quality_height: 1080,
+ embed_metadata: true,
+ });
+ });
+
+ it('includes trim and subtitles only when used', () => {
+ const request = toDownloadRequest(
+ readyItem({
+ trim: { start: 5, end: 65 },
+ subtitleLanguages: ['vi'],
+ subtitleMode: 'srt',
+ }),
+ );
+ expect(request.trim).toEqual({ start: 5, end: 65 });
+ expect(request.subtitles).toEqual({ languages: ['vi'], mode: 'srt' });
+ expect(
+ toDownloadRequest(readyItem({ trim: { start: 0, end: 1122 } })).trim,
+ ).toBeUndefined();
+ });
+
+ it('builds an audio request', () => {
+ expect(
+ toDownloadRequest(
+ readyItem({ kind: 'audio', audioFormat: 'flac', audioQuality: 'best' }),
+ ),
+ ).toEqual({
+ url: 'https://youtu.be/a',
+ title: 'Pho',
+ format: 'audio',
+ audio_format: 'flac',
+ audio_quality: 'best',
+ embed_metadata: true,
+ });
+ });
+
+ it('estimates sizes from formats, trim and bitrates', () => {
+ const item = readyItem();
+ expect(estimateBytes(item.options, FORMATS, 1122)).toBe(412_000_000);
+ expect(
+ estimateBytes(
+ { ...item.options, trim: { start: 0, end: 561 } },
+ FORMATS,
+ 1122,
+ ),
+ ).toBe(206_000_000);
+ expect(
+ estimateBytes(
+ {
+ ...item.options,
+ kind: 'audio',
+ audioFormat: 'mp3',
+ audioQuality: '320k',
+ },
+ FORMATS,
+ 60,
+ ),
+ ).toBe(2_400_000);
+ expect(
+ estimateBytes({ ...item.options, qualityHeight: 720 }, FORMATS, null),
+ ).toBeNull();
+ });
+
+ it('knows when a range is trimmed', () => {
+ expect(
+ isTrimmed(
+ { ...readyItem().options, trim: { start: 0, end: 1122 } },
+ 1122,
+ ),
+ ).toBe(false);
+ expect(
+ isTrimmed(
+ { ...readyItem().options, trim: { start: 3, end: 1122 } },
+ 1122,
+ ),
+ ).toBe(true);
+ });
+});
diff --git a/apps/web/src/state/options.ts b/apps/web/src/state/options.ts
new file mode 100644
index 0000000..813cd03
--- /dev/null
+++ b/apps/web/src/state/options.ts
@@ -0,0 +1,159 @@
+import type {
+ AudioFormat,
+ AudioQuality,
+ DownloadRequest,
+ MediaFormat,
+} from '@/lib/api/types';
+import type { DefaultFormatId, DraftOptions, ReadyItem } from './types';
+
+const BITS_PER_BYTE = 8;
+const BITS_PER_KILOBIT = 1000;
+
+export const AUDIO_BITRATES_KBPS: Record<
+ AudioFormat,
+ Record
+> = {
+ mp3: { '320k': 320, best: 245 },
+ m4a: { '320k': 320, best: 160 },
+ opus: { '320k': 320, best: 128 },
+ flac: { '320k': 900, best: 900 },
+ wav: { '320k': 1411, best: 1411 },
+};
+
+const DEFAULTS: Record<
+ DefaultFormatId,
+ Pick & {
+ maxHeight: number;
+ }
+> = {
+ 'video-mp4-1080': {
+ kind: 'video',
+ audioFormat: 'm4a',
+ audioQuality: 'best',
+ maxHeight: 1080,
+ },
+ 'video-mp4-720': {
+ kind: 'video',
+ audioFormat: 'm4a',
+ audioQuality: 'best',
+ maxHeight: 720,
+ },
+ 'audio-m4a': {
+ kind: 'audio',
+ audioFormat: 'm4a',
+ audioQuality: 'best',
+ maxHeight: 1080,
+ },
+ 'audio-mp3': {
+ kind: 'audio',
+ audioFormat: 'mp3',
+ audioQuality: '320k',
+ maxHeight: 1080,
+ },
+};
+
+function bestHeightAtMost(
+ formats: readonly MediaFormat[],
+ maxHeight: number,
+): number | null {
+ const heights = formats
+ .map((format) => format.height)
+ .filter((height) => height <= maxHeight);
+ return heights.length > 0
+ ? Math.max(...heights)
+ : (formats[formats.length - 1]?.height ?? null);
+}
+
+export function defaultDraft(
+ defaultFormat: DefaultFormatId,
+ formats: readonly MediaFormat[],
+): DraftOptions {
+ const preset = DEFAULTS[defaultFormat];
+ return {
+ kind: preset.kind,
+ container: 'mp4',
+ qualityHeight: bestHeightAtMost(formats, preset.maxHeight),
+ audioFormat: preset.audioFormat,
+ audioQuality: preset.audioQuality,
+ trim: null,
+ subtitleLanguages: [],
+ subtitleMode: 'embed',
+ embedMetadata: true,
+ };
+}
+
+export function isTrimmed(
+ options: DraftOptions,
+ duration: number | null,
+): boolean {
+ if (options.trim === null || duration === null) return false;
+ return options.trim.start > 0 || options.trim.end < duration;
+}
+
+function videoFields(item: ReadyItem): Partial {
+ const { options, formats } = item;
+ const format = formats.find(
+ (candidate) => candidate.height === options.qualityHeight,
+ );
+ return {
+ container: options.container,
+ ...(format ? { format_id: format.id } : {}),
+ ...(options.qualityHeight !== null
+ ? { quality_height: options.qualityHeight }
+ : {}),
+ ...(options.subtitleLanguages.length > 0
+ ? {
+ subtitles: {
+ languages: options.subtitleLanguages,
+ mode: options.subtitleMode,
+ },
+ }
+ : {}),
+ };
+}
+
+export function toDownloadRequest(item: ReadyItem): DownloadRequest {
+ const { options, media } = item;
+ const kindFields =
+ options.kind === 'video'
+ ? videoFields(item)
+ : {
+ audio_format: options.audioFormat,
+ audio_quality: options.audioQuality,
+ };
+ return {
+ url: media.url,
+ title: media.title,
+ format: options.kind,
+ ...kindFields,
+ ...(isTrimmed(options, media.duration) && options.trim
+ ? { trim: options.trim }
+ : {}),
+ embed_metadata: options.embedMetadata,
+ };
+}
+
+function selectedSeconds(options: DraftOptions, duration: number): number {
+ return options.trim
+ ? Math.max(options.trim.end - options.trim.start, 0)
+ : duration;
+}
+
+export function estimateBytes(
+ options: DraftOptions,
+ formats: readonly MediaFormat[],
+ duration: number | null,
+): number | null {
+ if (duration === null || duration <= 0) return null;
+ const seconds = selectedSeconds(options, duration);
+ if (options.kind === 'audio') {
+ const kbps = AUDIO_BITRATES_KBPS[options.audioFormat][options.audioQuality];
+ return Math.round((kbps * BITS_PER_KILOBIT * seconds) / BITS_PER_BYTE);
+ }
+ const format = formats.find(
+ (candidate) => candidate.height === options.qualityHeight,
+ );
+ return format?.filesize
+ ? Math.round((format.filesize * seconds) / duration)
+ : null;
+}
diff --git a/apps/web/src/state/reducer.test.ts b/apps/web/src/state/reducer.test.ts
new file mode 100644
index 0000000..c18624b
--- /dev/null
+++ b/apps/web/src/state/reducer.test.ts
@@ -0,0 +1,295 @@
+import { describe, expect, it } from 'vitest';
+import type { Job, MediaInfo } from '@/lib/api/types';
+import { countItems, initialState, reducer, visibleItems } from './reducer';
+import type { AppState } from './types';
+
+const INFO: MediaInfo = {
+ id: 'a',
+ title: 'Pho',
+ thumbnail: 'https://i.ytimg.com/a.jpg',
+ duration: 1122,
+ uploader: 'Bep',
+ platform: 'Youtube',
+ webpage_url: 'https://youtu.be/a',
+ formats: [
+ {
+ id: '137',
+ label: '1080p',
+ height: 1080,
+ ext: 'mp4',
+ filesize: 412_000_000,
+ },
+ ],
+ subtitle_languages: [],
+ has_chapters: false,
+ is_playlist: false,
+};
+
+function job(overrides: Partial = {}): Job {
+ return {
+ job_id: 'j1',
+ url: 'https://youtu.be/a',
+ title: 'Pho',
+ status: 'downloading',
+ progress: 40,
+ speed_bps: 1,
+ eta_seconds: 9,
+ downloaded_bytes: 1,
+ total_bytes: 2,
+ queue_position: 0,
+ options: {
+ kind: 'video',
+ container: 'mp4',
+ quality_height: 1080,
+ format_id: '137',
+ audio_format: null,
+ audio_quality: null,
+ trim: null,
+ subtitles: null,
+ embed_metadata: true,
+ },
+ filename: null,
+ files: [],
+ error: null,
+ error_code: null,
+ created_at: '2026-09-14T08:00:00Z',
+ finished_at: null,
+ expires_at: null,
+ ...overrides,
+ };
+}
+
+function withReadyItem(): AppState {
+ const fetching = reducer(initialState(), {
+ type: 'fetch/started',
+ id: 'r1',
+ url: 'https://youtu.be/a',
+ });
+ return reducer(fetching, {
+ type: 'fetch/succeeded',
+ id: 'r1',
+ url: 'https://youtu.be/a',
+ info: INFO,
+ });
+}
+
+describe('reducer', () => {
+ it('turns a fetched link into a selected ready item with default options', () => {
+ const state = withReadyItem();
+ expect(state.items[0]).toMatchObject({
+ type: 'ready',
+ id: 'r1',
+ media: { title: 'Pho', platform: 'youtube' },
+ options: { qualityHeight: 1080 },
+ });
+ expect(state.selectedId).toBe('r1');
+ });
+
+ it('records fetch failures', () => {
+ const state = reducer(
+ reducer(initialState(), {
+ type: 'fetch/started',
+ id: 'x',
+ url: 'https://x.y/z',
+ }),
+ { type: 'fetch/failed', id: 'x', code: 'unsupported_url' },
+ );
+ expect(state.items[0]).toEqual({
+ type: 'fetch-error',
+ id: 'x',
+ url: 'https://x.y/z',
+ code: 'unsupported_url',
+ });
+ });
+
+ it('changes options on a ready item', () => {
+ const state = reducer(withReadyItem(), {
+ type: 'item/optionsChanged',
+ id: 'r1',
+ patch: { kind: 'audio' },
+ });
+ expect(state.items[0]).toMatchObject({ options: { kind: 'audio' } });
+ });
+
+ it('links a started download and follows server updates into history', () => {
+ const started = reducer(withReadyItem(), {
+ type: 'download/started',
+ itemId: 'r1',
+ job: job(),
+ linkedAt: 0,
+ });
+ expect(started.items[0]).toMatchObject({ type: 'job', id: 'j1' });
+ expect(started.selectedId).toBe('j1');
+ const done = reducer(started, {
+ type: 'jobs/synced',
+ jobs: [
+ job({
+ status: 'done',
+ progress: 100,
+ files: [
+ { index: 0, name: 'Pho.mp4', kind: 'media', size_bytes: 412 },
+ ],
+ finished_at: '2026-09-14T08:05:00Z',
+ }),
+ ],
+ requestedAt: 0,
+ });
+ expect(done.items[0]).toMatchObject({ job: { status: 'done' } });
+ expect(done.history).toEqual([
+ {
+ id: 'j1',
+ url: 'https://youtu.be/a',
+ title: 'Pho',
+ kind: 'video',
+ label: 'MP4 1080p',
+ sizeBytes: 412,
+ finishedAt: '2026-09-14T08:05:00Z',
+ },
+ ]);
+ const again = reducer(done, {
+ type: 'jobs/synced',
+ jobs: [job({ status: 'done', finished_at: '2026-09-14T08:05:00Z' })],
+ requestedAt: 0,
+ });
+ expect(again.history).toHaveLength(1);
+ });
+
+ it('keeps history empty after clearing while done jobs stay on the server', () => {
+ const doneJob = job({
+ status: 'done',
+ finished_at: '2026-09-14T08:05:00Z',
+ });
+ const started = reducer(withReadyItem(), {
+ type: 'download/started',
+ itemId: 'r1',
+ job: job(),
+ linkedAt: 0,
+ });
+ const done = reducer(started, {
+ type: 'jobs/synced',
+ jobs: [doneJob],
+ requestedAt: 0,
+ });
+ expect(done.history).toHaveLength(1);
+ const cleared = reducer(done, { type: 'history/cleared' });
+ const polled = reducer(cleared, {
+ type: 'jobs/synced',
+ jobs: [doneJob],
+ requestedAt: 1,
+ });
+ expect(polled.history).toEqual([]);
+ });
+
+ it('records a done job it had not seen before', () => {
+ const adopted = reducer(initialState(), {
+ type: 'jobs/synced',
+ jobs: [job({ job_id: 'remote', status: 'done' })],
+ requestedAt: 0,
+ });
+ expect(adopted.history.map((entry) => entry.id)).toEqual(['remote']);
+ });
+
+ it('adopts server jobs it did not start and drops jobs the server forgot', () => {
+ const adopted = reducer(initialState(), {
+ type: 'jobs/synced',
+ jobs: [job({ job_id: 'remote', status: 'queued' })],
+ requestedAt: 0,
+ });
+ expect(adopted.items[0]).toMatchObject({
+ type: 'job',
+ id: 'remote',
+ media: { title: 'Pho', platform: 'youtube' },
+ });
+ expect(
+ reducer(adopted, { type: 'jobs/synced', jobs: [], requestedAt: 1 }).items,
+ ).toEqual([]);
+ });
+
+ it('ignores cancelled jobs from the server', () => {
+ expect(
+ reducer(initialState(), {
+ type: 'jobs/synced',
+ jobs: [job({ status: 'cancelled' })],
+ requestedAt: 0,
+ }).items,
+ ).toEqual([]);
+ });
+
+ it('returns a cancelled job to a ready item', () => {
+ const started = reducer(withReadyItem(), {
+ type: 'download/started',
+ itemId: 'r1',
+ job: job(),
+ linkedAt: 0,
+ });
+ const cancelled = reducer(started, { type: 'job/cancelled', jobId: 'j1' });
+ expect(cancelled.items[0]).toMatchObject({
+ type: 'ready',
+ id: 'j1',
+ options: { qualityHeight: 1080 },
+ });
+ });
+
+ it('filters and counts', () => {
+ const started = reducer(withReadyItem(), {
+ type: 'download/started',
+ itemId: 'r1',
+ job: job(),
+ linkedAt: 0,
+ });
+ const withError = reducer(started, {
+ type: 'fetch/started',
+ id: 'e',
+ url: 'https://x.y',
+ });
+ const failed = reducer(withError, {
+ type: 'fetch/failed',
+ id: 'e',
+ code: 'unavailable',
+ });
+ expect(countItems(failed)).toEqual({
+ all: 2,
+ active: 1,
+ done: 0,
+ error: 1,
+ });
+ const filtered = reducer(failed, {
+ type: 'view/changed',
+ view: 'queue',
+ filter: 'error',
+ });
+ expect(visibleItems(filtered).map((item) => item.id)).toEqual(['e']);
+ });
+
+ it('keeps a linked job until a poll requested after it can vouch for its absence', () => {
+ const started = reducer(withReadyItem(), {
+ type: 'download/started',
+ itemId: 'r1',
+ job: job(),
+ linkedAt: 1000,
+ });
+ const racedAway = reducer(started, {
+ type: 'jobs/synced',
+ jobs: [],
+ requestedAt: 500,
+ });
+ expect(racedAway.items[0]).toMatchObject({ type: 'job', id: 'j1' });
+ const trulyGone = reducer(racedAway, {
+ type: 'jobs/synced',
+ jobs: [],
+ requestedAt: 1500,
+ });
+ expect(trulyGone.items).toEqual([]);
+ });
+
+ it('clears history and shows notices', () => {
+ const noticed = reducer(initialState(), {
+ type: 'notice/shown',
+ notice: { id: 7, tone: 'info', message: 'hi' },
+ });
+ expect(noticed.notice?.id).toBe(7);
+ expect(
+ reducer(noticed, { type: 'notice/dismissed', id: 7 }).notice,
+ ).toBeNull();
+ });
+});
diff --git a/apps/web/src/state/reducer.ts b/apps/web/src/state/reducer.ts
new file mode 100644
index 0000000..8f79fa0
--- /dev/null
+++ b/apps/web/src/state/reducer.ts
@@ -0,0 +1,329 @@
+import type { Job, MediaInfo } from '@/lib/api/types';
+import { detectPlatform } from '@/lib/links';
+import { defaultDraft } from './options';
+import type {
+ Action,
+ AppState,
+ DraftOptions,
+ Filter,
+ HistoryEntry,
+ JobItem,
+ MediaSnapshot,
+ Preferences,
+ QueueItem,
+} from './types';
+
+export const MAX_HISTORY_ENTRIES = 200;
+
+export const DEFAULT_PREFERENCES: Preferences = {
+ theme: 'system',
+ accent: 'teal',
+ language: 'auto',
+ defaultFormat: 'video-mp4-1080',
+ installHintDismissed: false,
+};
+
+const ACTIVE_STATUSES = new Set(['queued', 'downloading', 'processing']);
+
+export function initialState(
+ preferences: Preferences = DEFAULT_PREFERENCES,
+): AppState {
+ return {
+ items: [],
+ selectedId: null,
+ view: 'queue',
+ filter: 'all',
+ history: [],
+ session: null,
+ settings: null,
+ storage: null,
+ cookies: null,
+ preferences,
+ notice: null,
+ };
+}
+
+function snapshotFromInfo(url: string, info: MediaInfo): MediaSnapshot {
+ return {
+ url,
+ title: info.title || url,
+ thumbnail: info.thumbnail,
+ duration: info.duration,
+ uploader: info.uploader,
+ platform: detectPlatform(url),
+ };
+}
+
+function snapshotFromJob(job: Job): MediaSnapshot {
+ return {
+ url: job.url,
+ title: job.title || job.url,
+ thumbnail: '',
+ duration: null,
+ uploader: '',
+ platform: detectPlatform(job.url),
+ };
+}
+
+function draftFromJob(job: Job): DraftOptions {
+ const { options } = job;
+ return {
+ kind: options.kind,
+ container: options.container,
+ qualityHeight: options.quality_height,
+ audioFormat: options.audio_format ?? 'm4a',
+ audioQuality: options.audio_quality ?? 'best',
+ trim: options.trim,
+ subtitleLanguages: options.subtitles?.languages ?? [],
+ subtitleMode: options.subtitles?.mode ?? 'embed',
+ embedMetadata: options.embed_metadata,
+ };
+}
+
+export function jobLabel(job: Job): string {
+ const { options } = job;
+ if (options.kind === 'audio')
+ return (options.audio_format ?? 'mp3').toUpperCase();
+ return `${options.container.toUpperCase()}${options.quality_height ? ` ${options.quality_height}p` : ''}`;
+}
+
+function historyEntry(job: Job): HistoryEntry {
+ return {
+ id: job.job_id,
+ url: job.url,
+ title: job.title || job.url,
+ kind: job.options.kind,
+ label: jobLabel(job),
+ sizeBytes: job.files[0]?.size_bytes ?? 0,
+ finishedAt: job.finished_at ?? job.created_at,
+ };
+}
+
+function replaceItem(
+ items: readonly QueueItem[],
+ id: string,
+ next: QueueItem,
+): QueueItem[] {
+ return items.map((item) => (item.id === id ? next : item));
+}
+
+function mergeJob(existing: JobItem | undefined, job: Job): JobItem {
+ if (existing) return { ...existing, job };
+ return {
+ type: 'job',
+ id: job.job_id,
+ media: snapshotFromJob(job),
+ formats: [],
+ options: draftFromJob(job),
+ job,
+ linkedAt: 0,
+ };
+}
+
+function syncJobs(
+ state: AppState,
+ jobs: readonly Job[],
+ requestedAt: number,
+): AppState {
+ const liveJobs = jobs.filter((job) => job.status !== 'cancelled');
+ const jobIds = new Set(liveJobs.map((job) => job.job_id));
+ const existingJobs = new Map(
+ state.items
+ .filter((item): item is JobItem => item.type === 'job')
+ .map((item) => [item.id, item]),
+ );
+ const kept = state.items.filter(
+ (item) =>
+ item.type !== 'job' ||
+ jobIds.has(item.id) ||
+ item.linkedAt >= requestedAt,
+ );
+ const known = new Set(kept.map((item) => item.id));
+ const adopted = liveJobs
+ .filter((job) => !known.has(job.job_id))
+ .map((job) => mergeJob(undefined, job));
+ const liveById = new Map(liveJobs.map((job) => [job.job_id, job]));
+ const merged = kept.map((item) => {
+ const live = item.type === 'job' ? liveById.get(item.id) : undefined;
+ return item.type === 'job' && live
+ ? mergeJob(existingJobs.get(item.id), live)
+ : item;
+ });
+ const newlyDone = liveJobs.filter(
+ (job) =>
+ job.status === 'done' &&
+ existingJobs.get(job.job_id)?.job.status !== 'done' &&
+ !state.history.some((entry) => entry.id === job.job_id),
+ );
+ const history = [...newlyDone.map(historyEntry), ...state.history].slice(
+ 0,
+ MAX_HISTORY_ENTRIES,
+ );
+ return { ...state, items: [...adopted, ...merged], history };
+}
+
+function startDownload(
+ state: AppState,
+ itemId: string,
+ job: Job,
+ linkedAt: number,
+): AppState {
+ const item = state.items.find((candidate) => candidate.id === itemId);
+ if (!item || item.type !== 'ready') return state;
+ const next: JobItem = {
+ type: 'job',
+ id: job.job_id,
+ media: item.media,
+ formats: item.formats,
+ options: item.options,
+ job,
+ linkedAt,
+ };
+ return {
+ ...state,
+ items: replaceItem(state.items, itemId, next),
+ selectedId: state.selectedId === itemId ? job.job_id : state.selectedId,
+ };
+}
+
+function cancelJob(state: AppState, jobId: string): AppState {
+ const item = state.items.find((candidate) => candidate.id === jobId);
+ if (!item || item.type !== 'job') return state;
+ return {
+ ...state,
+ items: replaceItem(state.items, jobId, {
+ type: 'ready',
+ id: item.id,
+ media: item.media,
+ formats: item.formats,
+ options: item.options,
+ }),
+ };
+}
+
+export function reducer(state: AppState, action: Action): AppState {
+ switch (action.type) {
+ case 'fetch/started':
+ return {
+ ...state,
+ items: [
+ { type: 'fetching', id: action.id, url: action.url },
+ ...state.items,
+ ],
+ };
+ case 'fetch/succeeded': {
+ const ready: QueueItem = {
+ type: 'ready',
+ id: action.id,
+ media: snapshotFromInfo(action.url, action.info),
+ formats: action.info.formats,
+ options: defaultDraft(
+ state.preferences.defaultFormat,
+ action.info.formats,
+ ),
+ };
+ return {
+ ...state,
+ items: replaceItem(state.items, action.id, ready),
+ selectedId: action.id,
+ };
+ }
+ case 'fetch/failed': {
+ const item = state.items.find((candidate) => candidate.id === action.id);
+ return item
+ ? {
+ ...state,
+ items: replaceItem(state.items, action.id, {
+ type: 'fetch-error',
+ id: action.id,
+ url: item.type === 'fetching' ? item.url : '',
+ code: action.code,
+ }),
+ }
+ : state;
+ }
+ case 'item/selected':
+ return { ...state, selectedId: action.id };
+ case 'item/optionsChanged':
+ return {
+ ...state,
+ items: state.items.map((item) =>
+ item.id === action.id && item.type === 'ready'
+ ? { ...item, options: { ...item.options, ...action.patch } }
+ : item,
+ ),
+ };
+ case 'item/removed':
+ return {
+ ...state,
+ items: state.items.filter((item) => item.id !== action.id),
+ selectedId: state.selectedId === action.id ? null : state.selectedId,
+ };
+ case 'download/started':
+ return startDownload(state, action.itemId, action.job, action.linkedAt);
+ case 'job/cancelled':
+ return cancelJob(state, action.jobId);
+ case 'jobs/synced':
+ return syncJobs(state, action.jobs, action.requestedAt);
+ case 'history/cleared':
+ return { ...state, history: [] };
+ case 'view/changed':
+ return {
+ ...state,
+ view: action.view,
+ filter: action.filter ?? state.filter,
+ };
+ case 'session/loaded':
+ return { ...state, session: action.session };
+ case 'settings/loaded':
+ return { ...state, settings: action.settings };
+ case 'storage/loaded':
+ return { ...state, storage: action.storage };
+ case 'cookies/loaded':
+ return { ...state, cookies: action.cookies };
+ case 'preferences/changed':
+ return {
+ ...state,
+ preferences: { ...state.preferences, ...action.patch },
+ };
+ case 'notice/shown':
+ return { ...state, notice: action.notice };
+ case 'notice/dismissed':
+ return state.notice?.id === action.id
+ ? { ...state, notice: null }
+ : state;
+ default: {
+ const unreachable: never = action;
+ return unreachable;
+ }
+ }
+}
+
+const FILTERS: Record boolean> = {
+ all: () => true,
+ active: (item) =>
+ item.type === 'fetching' ||
+ (item.type === 'job' && ACTIVE_STATUSES.has(item.job.status)),
+ done: (item) => item.type === 'job' && item.job.status === 'done',
+ error: (item) =>
+ item.type === 'fetch-error' ||
+ (item.type === 'job' && item.job.status === 'error'),
+};
+
+export function visibleItems(state: AppState): QueueItem[] {
+ return state.items.filter(FILTERS[state.filter]);
+}
+
+export function countItems(state: AppState): Record {
+ const settled = state.items.filter((item) => item.type !== 'fetching');
+ return {
+ all: settled.length,
+ active: settled.filter(FILTERS.active).length,
+ done: settled.filter(FILTERS.done).length,
+ error: settled.filter(FILTERS.error).length,
+ };
+}
+
+export function selectedItem(state: AppState): QueueItem | null {
+ return state.items.find((item) => item.id === state.selectedId) ?? null;
+}
diff --git a/apps/web/src/state/store.ts b/apps/web/src/state/store.ts
new file mode 100644
index 0000000..e90c2db
--- /dev/null
+++ b/apps/web/src/state/store.ts
@@ -0,0 +1,26 @@
+import { reducer } from './reducer';
+import type { Action, AppState } from './types';
+
+export interface Store {
+ getState(): AppState;
+ dispatch(action: Action): void;
+ subscribe(listener: () => void): () => void;
+}
+
+export function createStore(initial: AppState): Store {
+ let state = initial;
+ const listeners = new Set<() => void>();
+ return {
+ getState: () => state,
+ dispatch: (action) => {
+ state = reducer(state, action);
+ listeners.forEach((listener) => listener());
+ },
+ subscribe: (listener) => {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ };
+}
diff --git a/apps/web/src/state/types.ts b/apps/web/src/state/types.ts
new file mode 100644
index 0000000..8fdc3c5
--- /dev/null
+++ b/apps/web/src/state/types.ts
@@ -0,0 +1,171 @@
+import type {
+ AudioFormat,
+ AudioQuality,
+ Container,
+ CookieSummary,
+ DownloadKind,
+ Job,
+ MediaFormat,
+ MediaInfo,
+ RuntimeSettings,
+ SessionInfo,
+ StorageUsage,
+ SubtitleMode,
+ TrimRange,
+} from '@/lib/api/types';
+import type { LanguagePreference } from '@/lib/i18n/I18nProvider';
+import type { PlatformId } from '@/lib/links';
+import type { AccentId, ThemePreference } from '@/lib/theme';
+
+export type Filter = 'all' | 'active' | 'done' | 'error';
+export type View = 'queue' | 'history';
+export type DefaultFormatId =
+ 'video-mp4-1080' | 'video-mp4-720' | 'audio-m4a' | 'audio-mp3';
+export type PlaylistScope = 'single' | 'playlist';
+
+export interface DraftOptions {
+ readonly kind: DownloadKind;
+ readonly container: Container;
+ readonly qualityHeight: number | null;
+ readonly audioFormat: AudioFormat;
+ readonly audioQuality: AudioQuality;
+ readonly trim: TrimRange | null;
+ readonly subtitleLanguages: readonly string[];
+ readonly subtitleMode: SubtitleMode;
+ readonly embedMetadata: boolean;
+}
+
+export interface MediaSnapshot {
+ readonly url: string;
+ readonly title: string;
+ readonly thumbnail: string;
+ readonly duration: number | null;
+ readonly uploader: string;
+ readonly platform: PlatformId;
+}
+
+export interface FetchingItem {
+ readonly type: 'fetching';
+ readonly id: string;
+ readonly url: string;
+}
+
+export interface FetchErrorItem {
+ readonly type: 'fetch-error';
+ readonly id: string;
+ readonly url: string;
+ readonly code: string;
+}
+
+export interface ReadyItem {
+ readonly type: 'ready';
+ readonly id: string;
+ readonly media: MediaSnapshot;
+ readonly formats: readonly MediaFormat[];
+ readonly options: DraftOptions;
+}
+
+export interface JobItem {
+ readonly type: 'job';
+ readonly id: string;
+ readonly media: MediaSnapshot;
+ readonly formats: readonly MediaFormat[];
+ readonly options: DraftOptions;
+ readonly job: Job;
+ readonly linkedAt: number;
+}
+
+export type QueueItem = FetchingItem | FetchErrorItem | ReadyItem | JobItem;
+
+export interface HistoryEntry {
+ readonly id: string;
+ readonly url: string;
+ readonly title: string;
+ readonly kind: DownloadKind;
+ readonly label: string;
+ readonly sizeBytes: number;
+ readonly finishedAt: string;
+}
+
+export interface Preferences {
+ readonly theme: ThemePreference;
+ readonly accent: AccentId;
+ readonly language: LanguagePreference;
+ readonly defaultFormat: DefaultFormatId;
+ readonly installHintDismissed: boolean;
+}
+
+export interface Notice {
+ readonly id: number;
+ readonly tone: 'success' | 'info' | 'error';
+ readonly message: string;
+ readonly detail?: string;
+ readonly count?: number;
+}
+
+export interface AppState {
+ readonly items: readonly QueueItem[];
+ readonly selectedId: string | null;
+ readonly view: View;
+ readonly filter: Filter;
+ readonly history: readonly HistoryEntry[];
+ readonly session: SessionInfo | null;
+ readonly settings: RuntimeSettings | null;
+ readonly storage: StorageUsage | null;
+ readonly cookies: CookieSummary | null;
+ readonly preferences: Preferences;
+ readonly notice: Notice | null;
+}
+
+export type Action =
+ | {
+ readonly type: 'fetch/started';
+ readonly id: string;
+ readonly url: string;
+ }
+ | {
+ readonly type: 'fetch/succeeded';
+ readonly id: string;
+ readonly url: string;
+ readonly info: MediaInfo;
+ }
+ | {
+ readonly type: 'fetch/failed';
+ readonly id: string;
+ readonly code: string;
+ }
+ | { readonly type: 'item/selected'; readonly id: string | null }
+ | {
+ readonly type: 'item/optionsChanged';
+ readonly id: string;
+ readonly patch: Partial;
+ }
+ | { readonly type: 'item/removed'; readonly id: string }
+ | {
+ readonly type: 'download/started';
+ readonly itemId: string;
+ readonly job: Job;
+ readonly linkedAt: number;
+ }
+ | { readonly type: 'job/cancelled'; readonly jobId: string }
+ | {
+ readonly type: 'jobs/synced';
+ readonly jobs: readonly Job[];
+ readonly requestedAt: number;
+ }
+ | { readonly type: 'history/cleared' }
+ | {
+ readonly type: 'view/changed';
+ readonly view: View;
+ readonly filter?: Filter;
+ }
+ | { readonly type: 'session/loaded'; readonly session: SessionInfo }
+ | { readonly type: 'settings/loaded'; readonly settings: RuntimeSettings }
+ | { readonly type: 'storage/loaded'; readonly storage: StorageUsage }
+ | { readonly type: 'cookies/loaded'; readonly cookies: CookieSummary }
+ | {
+ readonly type: 'preferences/changed';
+ readonly patch: Partial;
+ }
+ | { readonly type: 'notice/shown'; readonly notice: Notice }
+ | { readonly type: 'notice/dismissed'; readonly id: number };
diff --git a/apps/web/src/state/useJobPolling.ts b/apps/web/src/state/useJobPolling.ts
new file mode 100644
index 0000000..ad5caa0
--- /dev/null
+++ b/apps/web/src/state/useJobPolling.ts
@@ -0,0 +1,26 @@
+'use client';
+
+import { useEffect } from 'react';
+
+const ACTIVE_INTERVAL_MS = 1000;
+const IDLE_INTERVAL_MS = 10000;
+
+export function useJobPolling(
+ sync: () => Promise,
+ hasActiveJobs: boolean,
+ enabled: boolean,
+): void {
+ useEffect(() => {
+ if (!enabled) return;
+ const interval = hasActiveJobs ? ACTIVE_INTERVAL_MS : IDLE_INTERVAL_MS;
+ const tick = (): void => {
+ if (document.visibilityState === 'visible') void sync();
+ };
+ const timer = window.setInterval(tick, interval);
+ document.addEventListener('visibilitychange', tick);
+ return () => {
+ window.clearInterval(timer);
+ document.removeEventListener('visibilitychange', tick);
+ };
+ }, [sync, hasActiveJobs, enabled]);
+}
diff --git a/apps/web/src/test/setup.ts b/apps/web/src/test/setup.ts
new file mode 100644
index 0000000..c82ead8
--- /dev/null
+++ b/apps/web/src/test/setup.ts
@@ -0,0 +1,22 @@
+import '@testing-library/jest-dom/vitest';
+import { cleanup } from '@testing-library/react';
+import { afterEach } from 'vitest';
+
+if (typeof window.matchMedia !== 'function') {
+ window.matchMedia = (query: string): MediaQueryList =>
+ ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ addListener: () => {},
+ removeListener: () => {},
+ dispatchEvent: () => false,
+ }) as MediaQueryList;
+}
+
+afterEach(() => {
+ cleanup();
+ window.localStorage.clear();
+});
diff --git a/apps/web/vitest.config.mts b/apps/web/vitest.config.mts
new file mode 100644
index 0000000..7210974
--- /dev/null
+++ b/apps/web/vitest.config.mts
@@ -0,0 +1,34 @@
+import react from "@vitejs/plugin-react";
+import { fileURLToPath } from "node:url";
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
+ },
+ test: {
+ restoreMocks: true,
+ css: { modules: { classNameStrategy: "non-scoped" } },
+ projects: [
+ {
+ extends: true,
+ test: {
+ name: "dom",
+ environment: "jsdom",
+ include: ["src/**/*.test.{ts,tsx}"],
+ exclude: ["src/app/api/**", "src/app/manifest.test.ts"],
+ setupFiles: ["./src/test/setup.ts"],
+ },
+ },
+ {
+ extends: true,
+ test: {
+ name: "node",
+ environment: "node",
+ include: ["src/app/api/**/*.test.ts", "src/app/manifest.test.ts"],
+ },
+ },
+ ],
+ },
+});
diff --git a/compose.dev.yaml b/compose.dev.yaml
index 491fbd5..bd2fb2a 100644
--- a/compose.dev.yaml
+++ b/compose.dev.yaml
@@ -1,6 +1,6 @@
# Local development only: throwaway services an app started outside docker can
# point at on localhost. Never shipped as a release asset — compose.yaml is the
# stack a client runs. scaffold merges the selected services in.
-name: app-dev
+name: openmedia-dev
services: {}
diff --git a/compose.test.yaml b/compose.test.yaml
index ba4ff4b..d9d5908 100644
--- a/compose.test.yaml
+++ b/compose.test.yaml
@@ -1,6 +1,6 @@
# ci and local test runs: same services as compose.dev.yaml, but tmpfs storage
# so every run starts from an empty database and nothing persists between
# runs.
-name: app-test
+name: openmedia-test
services: {}
diff --git a/compose.yaml b/compose.yaml
index 713e21f..68aba10 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -6,16 +6,18 @@
# Empty on purpose: scaffold merges in one service per application (ADR-0022)
# and whichever database and cache were selected (ADR-0019), so a project ships
# exactly what it asked for rather than a service nothing connects to.
-name: app
+name: openmedia
services:
web:
image: ghcr.io/ttncode/openmedia-web:${IMAGE_TAG:-latest}
- env_file:
- - path: .env
- required: false
+ environment:
+ API_URL: http://api:8080
restart: always
ports:
- '${WEB_PORT:-8080}:8080'
+ depends_on:
+ api:
+ condition: service_healthy
api:
image: ghcr.io/ttncode/openmedia-api:${IMAGE_TAG:-latest}
env_file:
@@ -23,4 +25,8 @@ services:
required: false
restart: always
ports:
- - '${API_PORT:-8081}:8080'
+ - '127.0.0.1:${API_PORT:-8081}:8080'
+ volumes:
+ - openmedia-data:/data
+volumes:
+ openmedia-data:
diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index 307343f..f71b3fb 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -1,19 +1,30 @@
import { defineConfig } from 'vitepress';
export default defineConfig({
- title: 'Openmedia',
- description: 'Project documentation',
- head: [['link', { rel: 'icon', href: '/logo.png' }]],
+ title: 'OpenMedia',
+ description:
+ 'Download videos from almost any website. Lightweight, self-hosted media downloader with a clean web UI.',
+ head: [['link', { rel: 'icon', href: '/logo.svg' }]],
// a dead link is a failed build, not a warning
ignoreDeadLinks: false,
+ srcExclude: ['superpowers/**'],
themeConfig: {
- logo: '/logo.png',
+ logo: '/logo.svg',
sidebar: [
{
text: 'Guide',
items: [
{ text: 'Getting started', link: '/getting-started' },
+ { text: 'Usage', link: '/usage' },
+ { text: 'Configuration', link: '/configuration' },
+ ],
+ },
+ {
+ text: 'Operations',
+ items: [
{ text: 'Deployment', link: '/deployment' },
+ { text: 'Troubleshooting', link: '/troubleshooting' },
+ { text: 'Security', link: '/security' },
],
},
],
diff --git a/docs/configuration.md b/docs/configuration.md
new file mode 100644
index 0000000..1fe77a0
--- /dev/null
+++ b/docs/configuration.md
@@ -0,0 +1,58 @@
+# Configuration
+
+All variables are set in `.env`, next to `compose.yaml`. `example.env` lists
+every one with its default.
+
+## Compose
+
+| Variable | Default | Meaning |
+| ----------- | -------- | ------------------------------------------------------------ |
+| `IMAGE_TAG` | `latest` | Image tag pulled for both services |
+| `WEB_PORT` | `8080` | Host port the web UI is published on |
+| `API_PORT` | `8081` | Host port the API is published on, bound to `127.0.0.1` only |
+
+## API URL (source builds only)
+
+| Variable | Default | Meaning |
+| --------- | ----------------------- | ---------------------------------------------------------- |
+| `API_URL` | `http://localhost:8081` | Where the web app's server-side proxy forwards `/api/*` to |
+
+In the Compose stack, `API_URL` is set for you (`http://api:8080`, the
+service name and container port) and does not need to be in `.env`. It only
+matters when running `apps/web` from source against a separately running API.
+
+## Application settings
+
+| Variable | Default | Meaning |
+| --------------------------------- | --------- | ----------------------------------------------------------------------- |
+| `OPENMEDIA_DATA_DIR` | `/data` | Root for downloads, cookies, settings, secret key, yt-dlp updates |
+| `OPENMEDIA_PASSWORD` | empty | Sign-in password; `changeme` refused at startup; empty turns it off |
+| `OPENMEDIA_SECRET_KEY` | generated | Session signing key; generated once into the data dir when empty |
+| `OPENMEDIA_RETENTION_MINUTES` | `60` | Default retention; the runtime setting below overrides it |
+| `OPENMEDIA_MAX_CONCURRENT` | `3` | Default concurrent downloads (1 to 5); the runtime setting overrides it |
+| `OPENMEDIA_MAX_FILESIZE_MB` | `4096` | Passed to yt-dlp as `--max-filesize` |
+| `OPENMEDIA_MAX_STORAGE_GB` | `0` | Total download storage limit; `0` means unlimited |
+| `OPENMEDIA_MAX_PLAYLIST_ITEMS` | `50` | Upper bound for playlist expansion |
+| `OPENMEDIA_RATE_LIMIT_PER_MINUTE` | `120` | Per client, for info, playlist and download requests |
+| `OPENMEDIA_STALL_TIMEOUT_SECONDS` | `180` | A download with no output for this long is stopped |
+| `OPENMEDIA_ALLOW_PRIVATE_URLS` | `false` | Allows URLs that resolve to private networks |
+| `OPENMEDIA_TRUSTED_PROXY_HOPS` | `1` | Trusted `X-Forwarded-For` entries, at least 1; see Deployment |
+| `OPENMEDIA_AUTO_UPDATE_YTDLP` | `true` | Container start installs the newest yt-dlp into the data dir |
+| `OPENMEDIA_YTDLP_PROXY` | empty | Optional proxy passed to yt-dlp |
+
+`example.env` ships `OPENMEDIA_PASSWORD=changeme`. `install.sh` replaces it
+with a random password; when you copy the file by hand, set your own, because
+the API refuses to start while the password is still `changeme`. With a
+password set, every API route except the session and health checks needs a
+sign-in. An empty password turns sign-in off, which is only safe for a private
+local instance. See [Security](/security) for what it protects and what it
+does not.
+
+## Runtime settings
+
+Retention and concurrency can also be changed from **Settings** in the web
+UI, without restarting the container. A change made there is saved to
+`settings.json` in the data directory and overrides
+`OPENMEDIA_RETENTION_MINUTES` and `OPENMEDIA_MAX_CONCURRENT` until changed
+again. Retention accepts 15, 60, 360 or 1440 minutes; concurrency accepts 1
+to 5.
diff --git a/docs/decisions/0001-keep-and-patch-the-reclip-backend.md b/docs/decisions/0001-keep-and-patch-the-reclip-backend.md
new file mode 100644
index 0000000..c7103e8
--- /dev/null
+++ b/docs/decisions/0001-keep-and-patch-the-reclip-backend.md
@@ -0,0 +1,46 @@
+# 0001 — Keep and patch the ReClip backend
+
+Status: Accepted
+Date: 2026-09-14
+
+## Context
+
+ReClip (https://github.com/averygan/reclip) already ships a Flask and yt-dlp
+backend with proven extractor coverage across the sites OpenMedia needs to
+support: video and audio formats, trimming, subtitles, metadata, cookies and
+a job queue. Building an equivalent backend from nothing would mean
+re-solving problems ReClip already solved, with no guarantee of matching its
+extractor compatibility.
+
+ReClip's backend was not written for exposure beyond a trusted local user: it
+has no URL validation against private networks, no cross-site request guard,
+and no rate limiting.
+
+## Decision
+
+Keep ReClip's Flask and yt-dlp backend as the base for `apps/api`, and patch
+it rather than rewrite it: add URL and option validation, a network guard
+against private and reserved addresses, a cross-site request guard, password
+authentication and rate limiting, then extend it with the features OpenMedia
+needs beyond ReClip's original scope (settings persistence, storage limits,
+cookie management). The HTTP surface stays close to ReClip's own, including
+its error response shape, so existing client code keeps working.
+
+## Consequences
+
+OpenMedia inherits ReClip's extractor coverage and its yt-dlp invocation
+patterns immediately, at the cost of carrying forward its original module
+structure until a later change has reason to restructure it. The security
+patches are the responsibility of this project going forward; ReClip's
+upstream fixes do not arrive automatically.
+
+## Alternatives considered
+
+- Rewrite the backend in Node, to share one language with the web app.
+ Rejected: yt-dlp is a Python tool, and rewriting the extractor and job
+ logic from scratch would take longer than patching a working backend, for
+ no capability gain.
+- Copy ReClip's backend verbatim and layer security on top as external
+ middleware. Rejected: several of the required checks (URL and option
+ validation, cross-site guard) need to run inside the request handlers
+ themselves to see the parsed request body, not just the raw HTTP request.
diff --git a/docs/decisions/0002-proxy-the-api-through-the-web-origin.md b/docs/decisions/0002-proxy-the-api-through-the-web-origin.md
new file mode 100644
index 0000000..70f2873
--- /dev/null
+++ b/docs/decisions/0002-proxy-the-api-through-the-web-origin.md
@@ -0,0 +1,40 @@
+# 0002 — Proxy the API through the web origin
+
+Status: Accepted
+Date: 2026-09-14
+
+## Context
+
+The web app and the API are two separate services. The browser needs some
+way to reach the API, and the way that connection is wired affects what
+ports must be exposed, what cookies work, and whether the API's address can
+change after the web app is built.
+
+## Decision
+
+The browser only ever talks to the web app's own origin. `apps/web`'s
+`src/app/api/[...path]/route.ts` proxies every method to `API_URL` at
+runtime, streaming request and response bodies and forwarding cookies and
+`X-Forwarded-*` headers. There is no build-time API URL baked into the web
+bundle; `compose.yaml` publishes only `WEB_PORT` to every interface, and
+`API_PORT` is bound to `127.0.0.1` only.
+
+## Consequences
+
+Session cookies are first-party from the browser's point of view, so the
+cross-site guard and `SameSite=Lax` work without extra configuration. One
+public port needs a certificate and a reverse proxy entry, not two. Changing
+`API_URL` (for example, moving the API to a different host) needs no web
+rebuild, only a container restart. Every request to the API takes one extra
+network hop through the web app's server, which is negligible next to yt-dlp
+download times.
+
+## Alternatives considered
+
+- A build-time `NEXT_PUBLIC_API_URL` the browser calls directly. Rejected:
+ bakes the API's address into the bundle, requires a second public port and
+ a second certificate, and makes the session cookie third-party unless CORS
+ and cookie attributes are carefully matched.
+- CORS with the browser calling the API's own origin. Rejected: same
+ two-port exposure as above, plus the cross-site guard would need to trust
+ a configured origin list instead of a same-origin check.
diff --git a/docs/decisions/0003-keep-jobs-in-memory-with-one-worker.md b/docs/decisions/0003-keep-jobs-in-memory-with-one-worker.md
new file mode 100644
index 0000000..384ca8e
--- /dev/null
+++ b/docs/decisions/0003-keep-jobs-in-memory-with-one-worker.md
@@ -0,0 +1,40 @@
+# 0003 — Keep jobs in memory with one worker
+
+Status: Accepted
+Date: 2026-09-14
+
+## Context
+
+Downloads run as background jobs: queued, dispatched when a concurrency slot
+is free, tracked for progress, and cancellable. Something has to hold that
+state and run the yt-dlp processes.
+
+## Decision
+
+`apps/api/app/jobs.py`'s `JobManager` holds the job registry in memory and
+runs downloads on worker threads, with gunicorn configured for one worker
+process and eight threads (`--workers 1 --threads 8`). A restart of the
+container loses any job that was active, queued or otherwise not yet
+finished. The retention sweeper cleans up finished job files independently
+of the process holding job state.
+
+## Consequences
+
+There is no database, message broker or extra service to run, deploy or back
+up: the whole job system is a few in-process data structures. The tradeoff is
+that a container restart (a deploy, a crash, a host reboot) silently drops
+in-flight downloads; the operator-facing documentation says so. Because
+there is exactly one worker process, in-memory state never needs to be
+shared or synchronized across processes, which is what makes threads safe
+here: running more than one worker process would require moving job state
+out of memory first.
+
+## Alternatives considered
+
+- SQLite-backed job table. Rejected: survives a restart, but adds a schema,
+ migrations and a durability guarantee nothing in OpenMedia's use case
+ needs; a dropped in-flight download is a re-click, not data loss.
+- A Redis-backed queue (Celery or similar). Rejected: a second service to
+ run and keep healthy, for a workload (one self-hosted instance,
+ `OPENMEDIA_MAX_CONCURRENT` capped at 5) that never approaches the scale
+ where a distributed queue earns its complexity.
diff --git a/docs/decisions/0004-store-history-in-the-browser.md b/docs/decisions/0004-store-history-in-the-browser.md
new file mode 100644
index 0000000..cef9aeb
--- /dev/null
+++ b/docs/decisions/0004-store-history-in-the-browser.md
@@ -0,0 +1,35 @@
+# 0004 — Store history in the browser
+
+Status: Accepted
+Date: 2026-09-14
+
+## Context
+
+Once a download finishes, something needs to remember it happened, so a user
+can find it again or download it a second time without re-fetching the info.
+OpenMedia has no user accounts, and the server already deletes files after
+the retention period.
+
+## Decision
+
+`apps/web/src/lib/preferences.ts` stores history and ready (not-yet-started)
+queue items in the browser's `localStorage`, newest first, capped at 200
+entries. "Download again" re-runs `/api/info` for the stored URL rather than
+reaching for a server-side record. There is no database and no accounts on
+the server.
+
+## Consequences
+
+Anyone with access to the web origin can start downloads, since nothing
+identifies who is asking; per-user history follows the same rule. History is
+local to one browser: it does not sync across devices, and clearing browser
+data clears it. This matches a self-hosted, single-instance tool with no
+login system, and keeps the server stateless with respect to who downloaded
+what.
+
+## Alternatives considered
+
+- A server-side history table keyed by session or account. Rejected: would
+ require adding accounts (or trusting an unauthenticated session identifier)
+ and a database, for a feature that a small `localStorage` list already
+ covers for the intended single-user or trusted-group use case.
diff --git a/docs/decisions/0005-apple-style-design-system.md b/docs/decisions/0005-apple-style-design-system.md
new file mode 100644
index 0000000..bc5265a
--- /dev/null
+++ b/docs/decisions/0005-apple-style-design-system.md
@@ -0,0 +1,45 @@
+# 0005 — Apple Human Interface style design system
+
+Status: Accepted
+Date: 2026-09-14
+
+## Context
+
+The web UI needs a design system: tokens for color, spacing, radii and
+motion, and a way to write components against them. `apps/web` also needs a
+brand identity distinct from ReClip's.
+
+## Decision
+
+The UI follows Apple Human Interface Guidelines style tokens: light and dark
+palettes, spring easings generated as CSS `linear()` with a `cubic-bezier`
+fallback, glass materials that fall back to solid under
+`prefers-reduced-transparency` or without `backdrop-filter` support, and
+consistent radii for panels, groups, artwork and capsules. Components are
+written with CSS Modules against `apps/web/src/app/globals.css`'s token
+sheet rather than Tailwind utility classes, because the layered tokens,
+springs and materials this design relies on read more clearly as CSS custom
+properties and rules than as utility class chains. The brand's default
+accent is teal (`#12939c`), taken from the logo mark, with seven accent
+choices available in Settings.
+
+## Consequences
+
+Every component shares the same tokens for color, motion and shape, so a
+token change (a new accent, a radius adjustment) applies everywhere it is
+used instead of needing to be repeated per component. Tailwind stays
+installed, since the generator ships it, but is unused for styling; this is
+a deliberate inconsistency with a default scaffold project; readers of
+`apps/web` should expect CSS Modules, not utility classes, in component
+files.
+
+## Alternatives considered
+
+- Tailwind utilities throughout. Rejected: expressing spring easings, glass
+ materials and the light and dark accent pairs used here as utility classes
+ would mean either a large custom Tailwind config that amounts to the same
+ token sheet, or repeating raw values inline across every component.
+- shadcn/ui defaults. Rejected: its component shapes and motion are close to
+ Radix and Tailwind conventions, not to the Apple Human Interface
+ Guidelines look this project targets; adapting it would mean overriding
+ most of what it provides.
diff --git a/docs/deployment.md b/docs/deployment.md
index c6dd44e..2edc290 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -1,32 +1,105 @@
# Deployment
-This project distributes container images; it does not deploy them for you.
-One image per application, named after the application's own directory: an
-application in a directory called `web` publishes `…/-web` and runs
-as the `web` service in `compose.yaml`, on its own host port (`WEB_PORT`,
-`API_PORT`, … in `.env`).
+## Topology
+
+`compose.yaml` runs two services. `web` publishes `WEB_PORT` (default 8080)
+on every interface; it is the only port meant to be reached from outside the
+host. `api` publishes `API_PORT` (default 8081) bound to `127.0.0.1` only,
+so the API is never reachable directly, even on a shared host. The web app's
+own server proxies `/api/*` requests to the API at runtime; the browser never
+talks to the API origin.
+
+Downloads, cookies, the generated secret key and runtime settings live in the
+`openmedia-data` named volume, mounted at `/data` in the `api` container.
+
+## Reverse proxy
+
+Put your own TLS-terminating reverse proxy in front of `WEB_PORT`; OpenMedia
+does not choose one for you.
+
+Caddy:
+
+```
+openmedia.example.com {
+ reverse_proxy localhost:8080
+}
+```
+
+nginx:
+
+```nginx
+location / {
+ proxy_pass http://localhost:8080;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Host $host;
+}
+```
+
+With a reverse proxy in front, bind `WEB_PORT` to the loopback interface in
+`.env`, so clients can only reach the `web` container through the proxy:
+
+```sh
+WEB_PORT=127.0.0.1:8080
+```
+
+Otherwise a client connecting to port 8080 directly can send its own
+`X-Forwarded-For` header and choose the address the per-client rate limits
+see. [Security](/security#rate-limiting) explains which limits still hold.
+
+`OPENMEDIA_TRUSTED_PROXY_HOPS` (default and minimum `1`) tells the API how
+many `X-Forwarded-For` entries, counted from the right, to trust when reading
+the client's real address for rate limiting. The `web` container does not add
+an entry of its own: it passes the header on as it arrived, or sets it to the
+connecting address when there is none. So `1` is right both with no reverse
+proxy and with one reverse proxy in front of `web`. Add 1 for each further
+proxy in a chain, for example a CDN in front of your reverse proxy, as long as
+every proxy after the first appends to the header (nginx:
+`$proxy_add_x_forwarded_for`). A value too low reads a proxy's own address as
+the client's; a value too high reads a spoofable entry as if a trusted proxy
+had set it. The forwarded host and protocol are always read from one hop, the
+`web` container, whatever this is set to.
+
+## Backups
+
+Back up the `openmedia-data` volume (or whatever host path it is bound to)
+to preserve cookies, the generated secret key and runtime settings across
+reinstalls. Downloaded media files are deleted automatically once their
+retention period elapses, so they are not worth including in a backup
+schedule.
+
+## yt-dlp updates
+
+`OPENMEDIA_AUTO_UPDATE_YTDLP` (default `true`) installs the newest `yt-dlp`
+into the data volume on every container start, ahead of the version locked
+into the image. The install gets 120 seconds and replaces the previous copy
+only when it succeeds. When it fails or times out, the previous copy is
+removed as well, so the image's locked version runs rather than an outdated
+download. Set it to `false` to run the image's bundled version, for example on
+a host with no outbound internet access; that also removes any copy an earlier
+update left in the volume.
+
+The API container reports healthy only once the update and startup have
+finished. Its health check allows 300 seconds for that, probing every 5
+seconds, and the `web` service starts when the API is healthy.
+
+## Publishing
`.github/workflows/build.yml` publishes `main` and `sha-` tags on
every push to `main`. `.github/workflows/release.yml` additionally publishes
semver tags (`1.4.0`, `1.4`) plus `latest` when a release is cut. Both build
-the same images; they differ only in which tags name them. One release covers
-every application, so their versions never drift apart.
-
-Nothing routes between them: put whatever reverse proxy you already terminate
-TLS with in front of the ports, rather than one this project chose for you.
+the same images; they differ only in which tags name them.
`compose.yaml` and `example.env` are attached to every GitHub Release, so a
deployment target always fetches a matching pair rather than whatever is on
-`main`. `install.sh` downloads both, generates a random database password,
-signs in to the registry when it needs to, starts the stack, and applies the
-schema — safe to re-run: it always overwrites `compose.yaml` with the
-release's own copy, and never touches an existing `.env`.
+`main`. `install.sh` downloads both, starts the stack, and never touches an
+existing `.env`.
## If this project is private
-A private project needs a token, and it needs it for two separate reasons —
+A private project needs a token, and it needs it for two separate reasons:
a token carrying only one of the two scopes fails in only one of the two
-places:
+places.
```sh
GITHUB_TOKEN=ghp_... bash install.sh
@@ -34,16 +107,13 @@ GITHUB_TOKEN=ghp_... bash install.sh
- **`repo`**, to download the release assets. A private release's browser
download URL returns 404 _even with a token attached_, so `install.sh`
- fetches assets through the GitHub API instead. Without that, an operator
- who hits a 404, adds a token, and hits another 404 concludes the token is
- wrong and looks in the wrong place.
+ fetches assets through the GitHub API instead.
- **`read:packages`**, to pull the image. A package's visibility on ghcr is
separate from its repository's, so a private package refuses an anonymous
pull with `unauthorized` even when the repository is public.
-`jq` is required on the host for this path only — `install.sh` uses it to
-read the release's JSON. A public project needs neither the token nor `jq`,
-and behaves exactly as it always has.
+`jq` is required on the host for this path only. A public project needs
+neither the token nor `jq`.
## Two delivery modes, one pipeline
@@ -54,16 +124,3 @@ They differ only in which `IMAGE_TAG` the deployment sets.
| `IMAGE_TAG` | `1.4.0`, pinned deliberately | `main`, moving |
| Upgrades | The client chooses when | Every merge |
| `install.sh` | Handed to the client | Used by the author |
-
-## Before the first deploy
-
-Nothing, if the GitHub repository is named after this project's directory.
-
-`compose.yaml`'s `app.image`, `install.sh`'s `RepoUrl`, and the image
-`build.yml` and `release.yml` push to were all written at generation time
-from the same owner and project name, so they already agree.
-
-If the repository was renamed, all four need the new name. `install.sh`
-re-downloads `compose.yaml` from the latest release on every run, so change
-it in this repository and cut a release — a hand-edit to a deployed copy is
-undone the next time the script runs.
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 13b6589..9202c2b 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -1,16 +1,52 @@
# Getting started
+## Requirements
+
+- Docker 24 or newer with the Compose plugin, or
+- [mise](https://mise.jdx.dev) to run OpenMedia from source
+
+## Install with Docker Compose
+
+Download `compose.yaml` and `example.env` from the
+[latest release](https://github.com/ttncode/openmedia/releases/latest):
+
+```bash
+curl -fsSLO https://github.com/ttncode/openmedia/releases/latest/download/compose.yaml
+curl -fsSLO https://github.com/ttncode/openmedia/releases/latest/download/example.env
+cp example.env .env
+```
+
+Edit `.env` and set `OPENMEDIA_PASSWORD` to a password of your own. The API
+refuses to start while it is still the placeholder `changeme`. Leaving it
+empty turns sign-in off, which is only safe for a private local instance; see
+[Security](/security) for why. Then start the stack:
+
```bash
-mise install # exact toolchain versions, from mise.lock
-lefthook install # formatting, secret scan, commit-message check
-mise run dev # the compose stack
+docker compose up -d
```
-Before opening a pull request, run `mise run checklist`. It runs exactly what
-CI runs.
+Open `http://localhost:8080` and sign in with that password. The web UI runs
+on `WEB_PORT` (default 8080); the API runs on `127.0.0.1:${API_PORT}` (default 8081) and is not reachable from outside the host.
+
+The [installer script](https://github.com/ttncode/openmedia/releases/latest/download/install.sh)
+does all of this for you, generates a random password and prints it when it
+finishes. See [Configuration](/configuration) for every variable.
+
+## First download
+
+1. Paste a link into the field at the top and press Enter, or click
+ **Get info**.
+2. Pick a format and quality in the details panel.
+3. Click **Download**. The queue shows progress; when it finishes, click
+ **Save** to save the file to your device.
+
+## Updating
+
+```bash
+docker compose pull
+docker compose up -d
+```
-The docs site ships a placeholder logo at `docs/public/logo.png`, borrowed from
-[escrcpy](https://github.com/viarotel-org/escrcpy) along with the theme colours
-in `docs/.vitepress/theme/vendor/escrcpy/NOTICE`. A logo is a trademark, not
-something the Apache licence hands over — replace it with this project's own
-mark before the site is published.
+This pulls the newest image for the tag set in `IMAGE_TAG` (`latest` by
+default) and recreates the containers. Downloaded files in the `/data` volume
+are untouched.
diff --git a/docs/index.md b/docs/index.md
index 0726ee1..ee6a51c 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,18 +1,25 @@
---
layout: home
hero:
- name: "Openmedia"
- tagline: Project documentation
+ name: "OpenMedia"
+ tagline: Download videos from almost any website. Lightweight, self-hosted media downloader with a clean web UI.
image:
- src: /logo.png
- alt: "Openmedia"
+ src: /logo.svg
+ alt: "OpenMedia"
actions:
- theme: brand
text: Getting started
link: /getting-started
- theme: alt
- text: Deployment
- link: /deployment
+ text: Configuration
+ link: /configuration
+features:
+ - title: Downloads
+ details: 1000+ sites through yt-dlp, playlists, bulk links and a queue with live progress.
+ - title: Formats
+ details: MP4 and MKV video, MP3, M4A, Opus, FLAC and WAV audio, trimming and subtitles.
+ - title: Privacy
+ details: Self-hosted, optional password, rate limiting and private-network blocking.
+ - title: Install anywhere
+ details: Installable PWA with a share target, keyboard shortcuts, light and dark themes.
---
-
-Architecture decisions live in `docs/decisions`.
diff --git a/docs/public/brand/logo-full.svg b/docs/public/brand/logo-full.svg
new file mode 100644
index 0000000..3564b1b
--- /dev/null
+++ b/docs/public/brand/logo-full.svg
@@ -0,0 +1 @@
+OpenMedia
diff --git a/docs/public/brand/logo-icon.svg b/docs/public/brand/logo-icon.svg
new file mode 100644
index 0000000..1e9715a
--- /dev/null
+++ b/docs/public/brand/logo-icon.svg
@@ -0,0 +1 @@
+OpenMedia
diff --git a/docs/public/brand/logo-white.svg b/docs/public/brand/logo-white.svg
new file mode 100644
index 0000000..0101c7a
--- /dev/null
+++ b/docs/public/brand/logo-white.svg
@@ -0,0 +1 @@
+OpenMedia
diff --git a/docs/public/logo.png b/docs/public/logo.png
deleted file mode 100644
index 4001947..0000000
Binary files a/docs/public/logo.png and /dev/null differ
diff --git a/docs/public/logo.svg b/docs/public/logo.svg
new file mode 100644
index 0000000..7034835
--- /dev/null
+++ b/docs/public/logo.svg
@@ -0,0 +1 @@
+OpenMedia
diff --git a/docs/public/screenshots/desktop-dark.png b/docs/public/screenshots/desktop-dark.png
new file mode 100644
index 0000000..734e9cb
Binary files /dev/null and b/docs/public/screenshots/desktop-dark.png differ
diff --git a/docs/public/screenshots/desktop-light.png b/docs/public/screenshots/desktop-light.png
new file mode 100644
index 0000000..769928e
Binary files /dev/null and b/docs/public/screenshots/desktop-light.png differ
diff --git a/docs/public/screenshots/phone-dark.png b/docs/public/screenshots/phone-dark.png
new file mode 100644
index 0000000..8b859b2
Binary files /dev/null and b/docs/public/screenshots/phone-dark.png differ
diff --git a/docs/public/screenshots/settings.png b/docs/public/screenshots/settings.png
new file mode 100644
index 0000000..17bb782
Binary files /dev/null and b/docs/public/screenshots/settings.png differ
diff --git a/docs/scripts/check-paths.mjs b/docs/scripts/check-paths.mjs
index bf00e52..1f7a310 100644
--- a/docs/scripts/check-paths.mjs
+++ b/docs/scripts/check-paths.mjs
@@ -24,7 +24,12 @@ const SKIP_DIRS = new Set(["node_modules", ".git", ".vitepress"]);
// markdown path scan only — the ADR-citation scan must still walk apps/, or
// a dead ADR reference in an adapter's own mise.toml goes unseen again
// (the hole task 11 closed).
-const MARKDOWN_SKIP_DIRS = new Set([...SKIP_DIRS, "apps"]);
+const MARKDOWN_SKIP_DIRS = new Set([
+ ...SKIP_DIRS,
+ "apps",
+ "superpowers",
+ ".superpowers",
+]);
async function filesMatching(dir, matches, skipDirs = SKIP_DIRS) {
const entries = await readdir(dir, { withFileTypes: true });
diff --git a/docs/security.md b/docs/security.md
new file mode 100644
index 0000000..13920d2
--- /dev/null
+++ b/docs/security.md
@@ -0,0 +1,93 @@
+# Security
+
+## Threat model
+
+OpenMedia is designed to be run by one person or team for their own use.
+Exposing it publicly means anyone who reaches the web origin can submit
+download requests, unless a password is set. Treat it like any other
+self-hosted tool: put it behind a reverse proxy with TLS, keep a password set,
+and keep the image up to date.
+
+## Password
+
+Set `OPENMEDIA_PASSWORD` to require sign-in for every route except the
+session check and health checks. The password is compared with a
+constant-time comparison, sign-in attempts are limited to 5 per minute per
+client address and 30 per minute across all clients, and the session is a signed cookie, `HttpOnly`, `SameSite=Lax`, and
+marked `Secure` whenever the request arrives over https (through the
+`X-Forwarded-Proto` header behind a reverse proxy).
+
+`example.env` ships `OPENMEDIA_PASSWORD=changeme`, `install.sh` replaces it
+with a random password, and the API refuses to start while the password is
+still `changeme`. An empty password turns sign-in off. Only do that for a
+private local instance, and read the next section first.
+
+## DNS rebinding
+
+A password is also what stops DNS rebinding. A web page on another site can
+switch its own domain name to your instance's address after the page has
+loaded. From then on the browser treats requests to your instance as coming
+from that page's own origin, so the cross-site request guard below cannot tell
+them apart from the web UI's own requests. This reaches instances that are
+only on your local network or on `localhost`, because the visitor's browser
+makes the requests. Without a password such a page can start downloads, read
+the queue and change settings. With a password it cannot: the browser holds no
+session cookie for the attacker's domain, and the page does not know the
+password.
+
+## Cross-site request guard
+
+State-changing requests (anything other than GET, HEAD or OPTIONS) are
+rejected with `cross_site_request` when the browser's `Sec-Fetch-Site`
+header says the request came from another site or from another subdomain of
+the same site, or when an `Origin` header is present and does not match the
+forwarded host. This stops another website, including a sibling subdomain,
+from making download requests through a visitor's browser session.
+
+## Rate limiting
+
+Each client address gets a token bucket of `OPENMEDIA_RATE_LIMIT_PER_MINUTE`
+requests per minute for info, playlist and download requests. Exceeding it,
+or the sign-in limits above, returns 429 with a `Retry-After` header.
+
+The API reads the client address from `X-Forwarded-For` (see
+[Deployment](/deployment)). The `web` container passes that header on
+unchanged when a request already carries one, so a client that connects to
+`WEB_PORT` directly can write any address into it and get a fresh bucket for
+every request. Per-client limits only hold when a reverse proxy you control
+sets the header and `WEB_PORT` cannot be reached around it; bind `WEB_PORT`
+to `127.0.0.1` in that setup.
+
+Two protections do not depend on the header. Sign-in attempts are capped at
+30 per minute across all clients, so rotating addresses cannot guess a
+password faster than that; the cost is that during such an attack everyone
+else's sign-in is slowed too. And once more than 10,000 client addresses are
+tracked, buckets that have fully refilled are dropped, so rotating addresses
+cannot grow memory without bound.
+
+## Network guard
+
+Every address a submitted URL's host resolves to must be public; requests to
+private, loopback, link-local or otherwise reserved addresses are rejected
+unless `OPENMEDIA_ALLOW_PRIVATE_URLS=true`. This check runs once, against the
+URL you submit: yt-dlp itself may still follow a redirect to a different
+host afterwards, which this guard does not see. If you need to guarantee no
+internal address is ever reached, even through a redirect, route yt-dlp's
+own traffic through an egress proxy that enforces it, with
+`OPENMEDIA_YTDLP_PROXY`.
+
+## Cookie file handling
+
+An uploaded `cookies.txt` file is stored with file mode 0600, never written
+to logs, and copied into each job's own directory before yt-dlp reads it, so
+concurrent jobs do not race on the same file.
+
+## Running as non-root
+
+The container runs as a non-root user (uid 10001) that owns `/data`. It does
+not need, and is not given, elevated privileges.
+
+## Reporting vulnerabilities
+
+See [SECURITY.md](https://github.com/ttncode/openmedia/blob/main/SECURITY.md)
+in the repository root for how to report a vulnerability privately.
diff --git a/docs/superpowers/plans/2026-09-14-openmedia.md b/docs/superpowers/plans/2026-09-14-openmedia.md
new file mode 100644
index 0000000..3e54d1b
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-14-openmedia.md
@@ -0,0 +1,8584 @@
+# OpenMedia Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Turn the scaffold-generated project into a shippable OpenMedia release: a secured, extended Flask + yt-dlp API and an Apple-style Next.js web app, documented and released.
+
+**Architecture:** `apps/api` keeps reclip's Flask foundation and endpoint shapes and adds a job engine (queue, progress, cancel, retention), security (network guard, cross-site guard, rate limit, optional password) and cookie handling. `apps/web` serves the UI and proxies every `/api/*` request to the API at runtime. Compose runs both images with a data volume.
+
+**Tech Stack:** Python 3.13, Flask 3, gunicorn, yt-dlp 2026.8.19 (`default`, `deno`, `curl-cffi` extras), ffmpeg, uv, pytest, ruff, mypy strict; Next.js 16.3.5, React 19.2.8, TypeScript strict, CSS Modules, `@phosphor-icons/react`, vitest, Testing Library; VitePress; Docker.
+
+**Spec:** `docs/superpowers/specs/2026-09-14-openmedia-design.md`
+
+## Global Constraints
+
+- Zero code comments in Python, TypeScript, CSS, shell and YAML you write ("clean code needs no comments"); intent lives in names. Generated files keep their existing comments.
+- Python: type hints everywhere, `mypy --strict app tests` clean, `ruff check` and `ruff format --check` clean, functions at most about 20 lines, no bare `except`.
+- TypeScript: no `any`, explicit return types on exported functions, `const` by default, `===` only, no `console.log`.
+- Every commit uses Conventional Commits (`feat:`, `fix:`, `test:`, `docs:`, `build:`, `ci:`, `chore:`), enforced by commitlint.
+- API JSON is snake_case; every error is `{"error": "", "code": ""}`.
+- reclip compatibility: `POST /api/info`, `POST /api/playlist`, `POST /api/download {url, format, format_id, title}`, `GET /api/status/{id}` (`status`, `error`, `filename`), `GET /api/file/{id}` keep working.
+- UI copy: Vietnamese and English dictionaries, no em dash or en dash characters in visible text, no emoji.
+- Accent default teal: fill `#12939c`, light text `#0b7178`, dark fill `#3fbac2`, dark text `#5cc9d0`.
+- Brand: logo frame path `M9 22 V9 H22 M42 9 H55 V22 M55 42 V55 H42 M22 55 H9 V42`, wave path `M23 27 V37 M32 20 V44 M41 25 V39`, viewBox `0 0 64 64`, stroke 6, round caps and joins.
+- Per-root verification is `mise run //apps/api:ci-unit`, `mise run //apps/web:ci-unit`, `mise run //docs:ci-unit`, all run from the project root.
+- Reference material outside the repository (read-only):
+ - Approved prototype: `/tmp/claude-1000/-home-ttndev-workspace-playground-openmedia/f044fd99-123a-4570-90dc-70fb559509d9/scratchpad/designs/apple/` (tokens.css, base.css, layout.css, components.css, overlays.css, body.html, data.js, format.js, render.js, inspector.js, overlays.js, actions.js, app.js)
+ - Logo assets: `/tmp/claude-1000/-home-ttndev-workspace-playground-openmedia/f044fd99-123a-4570-90dc-70fb559509d9/scratchpad/logo/assets/`
+ - reclip source: `/tmp/claude-1000/-home-ttndev-workspace-playground-openmedia/f044fd99-123a-4570-90dc-70fb559509d9/scratchpad/reclip/`
+
+## File Map
+
+### API (`apps/api`)
+
+| File | Task | Responsibility |
+| ------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- |
+| `apps/api/pyproject.toml` | 1 | Adds `yt-dlp[default,deno,curl-cffi]==2026.8.19` |
+| `apps/api/app/config.py` | 1 | `Settings`, `load_settings()` |
+| `apps/api/app/errors.py` | 1 | `ApiError`, `register_error_handlers()` |
+| `apps/api/app/health.py` | 1 | Liveness and readiness (yt-dlp, ffmpeg, data dir) |
+| `apps/api/app/validation.py` | 2 | URL and download option validation |
+| `apps/api/app/network_guard.py` | 2 | Public address enforcement |
+| `apps/api/app/progress.py` | 3 | Progress line parsing and tracking |
+| `apps/api/app/ytdlp.py` | 3 | Command builders, error mapping, info and playlist extraction |
+| `apps/api/app/settings_store.py` | 4 | Runtime settings persisted to JSON |
+| `apps/api/app/storage.py` | 4 | Disk usage and storage limit |
+| `apps/api/app/jobs.py` | 4 | `JobManager` and job model |
+| `apps/api/app/cleanup.py` | 4 | Retention sweeper and orphan removal |
+| `apps/api/app/cookies.py` | 5 | Cookie file validation and summary |
+| `apps/api/app/security.py` | 5 | Secret key, password session, cross-site guard, rate limiter |
+| `apps/api/app/services.py` | 6 | `Services` container and `build_services()` |
+| `apps/api/app/media.py` | 6 | All `/api/*` routes |
+| `apps/api/app/__init__.py` | 6 | `create_app()` |
+| `apps/api/tests/*` | 1-6 | pytest suites named after modules |
+| `apps/api/Dockerfile`, `apps/api/docker-entrypoint.sh`, `apps/api/.env.example` | 7 | Container image and runtime |
+| `compose.yaml`, `compose.dev.yaml`, `example.env`, `renovate.json` | 7 | Stack wiring, yt-dlp update policy |
+
+### Web (`apps/web`)
+
+| File | Task | Responsibility |
+| -------------------------------------------------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------- |
+| `apps/web/package.json` | 8 | Dependencies and test tooling |
+| `apps/web/vitest.config.ts`, `apps/web/src/test/setup.ts` | 8 | Test environment |
+| `apps/web/src/app/globals.css` | 8 | Tokens and base styles ported from the prototype |
+| `apps/web/src/app/layout.tsx` | 8 | Fonts, metadata, theme bootstrap, providers |
+| `apps/web/src/app/api/[...path]/route.ts` | 8 | Runtime API proxy |
+| `apps/web/src/lib/api/types.ts`, `apps/web/src/lib/api/client.ts` | 8 | API types and client |
+| `apps/web/src/lib/links.ts`, `apps/web/src/lib/format.ts` | 8 | Link parsing, formatting |
+| `apps/web/src/lib/i18n/` | 8 | Dictionaries and provider |
+| `apps/web/src/lib/preferences.ts` | 9 | localStorage persistence |
+| `apps/web/src/state/` | 9 | Reducer, context, polling, commands |
+| `apps/web/src/components/controls/` | 10 | Icon, Capsule, IconButton, Segmented, Switch, Stepper, BrandMark |
+| `apps/web/src/components/overlays/` | 10 | Sheet, Island, AlertDialog, ShortcutsHud, DropOverlay |
+| `apps/web/src/components/shell/` | 13 | AppShell, Sidebar, Toolbar, TabBar |
+| `apps/web/src/components/importer/` | 13 | Importer with platform chips and playlist choice |
+| `apps/web/src/components/queue/` | 11 | QueueView, QueueRow, ProgressRing, RowSkeleton |
+| `apps/web/src/components/inspector/` | 11 | Inspector, Artwork, OptionsPanel, QualityList, TrimEditor, SubtitleOptions, StatusCard, InspectorFooter |
+| `apps/web/src/components/history/`, `settings/`, `auth/` | 12 | History view, settings sheet, login screen |
+| `apps/web/src/app/manifest.ts`, `apps/web/src/app/icon.svg`, `apps/web/src/app/apple-icon.tsx`, `apps/web/src/app/pwa-icon/[size]/route.tsx` | 12 | PWA |
+
+### Repository and docs
+
+| File | Task | Responsibility |
+| --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------- |
+| `LICENSE`, `NOTICE`, `README.md`, `README.vi.md` | 14 | Licensing and landing documentation |
+| `docs/index.md`, `docs/getting-started.md`, `docs/configuration.md`, `docs/usage.md`, `docs/deployment.md`, `docs/troubleshooting.md`, `docs/security.md` | 14 | Documentation site |
+| `docs/decisions/0001-*.md` to `docs/decisions/0005-*.md` | 14 | ADRs |
+| `docs/public/logo.svg`, `docs/public/screenshots/` | 14, 15 | Brand and screenshots |
+
+---
+
+### Task 1: API foundation (dependencies, settings, errors, readiness)
+
+**Files:**
+
+- Modify: `apps/api/pyproject.toml` (via `uv add`), `apps/api/uv.lock`
+- Create: `apps/api/app/config.py`, `apps/api/app/errors.py`, `apps/api/tests/conftest.py`, `apps/api/tests/test_config.py`
+- Modify: `apps/api/app/health.py`, `apps/api/tests/test_health.py`
+
+**Interfaces:**
+
+- Produces: `Settings` (frozen dataclass) with fields `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` and properties `downloads_dir, cookies_file, settings_file, secret_key_file, ytdlp_dir`; `load_settings() -> Settings`; `ApiError(status: int, code: str, message: str, headers: Mapping[str, str] | None = None)`; `register_error_handlers(app: Flask) -> None`; pytest fixture `settings(tmp_path) -> Settings`.
+
+- [ ] **Step 1: Add the download engine dependency**
+
+Run from `apps/api`:
+
+```bash
+mise exec -- uv add "yt-dlp[default,deno,curl-cffi]==2026.8.19"
+```
+
+Expected: `pyproject.toml` lists the dependency and `uv.lock` updates.
+
+- [ ] **Step 2: Write the failing settings tests**
+
+`apps/api/tests/conftest.py`:
+
+```python
+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=30,
+ 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)
+```
+
+`apps/api/tests/test_config.py`:
+
+```python
+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"):
+ 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.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()
+```
+
+- [ ] **Step 3: Run the tests to see them fail**
+
+Run from `apps/api`: `mise exec -- uv run pytest -q tests/test_config.py`
+Expected: FAIL with `ModuleNotFoundError: No module named 'app.config'`.
+
+- [ ] **Step 4: Implement settings and errors**
+
+`apps/api/app/config.py`:
+
+```python
+import os
+from dataclasses import dataclass
+from pathlib import Path
+
+TRUE_VALUES = frozenset({"1", "true", "yes", "on"})
+
+
+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
+
+
+@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=os.environ.get("OPENMEDIA_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", 30, 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, 0, 5),
+ ytdlp_proxy=_text("OPENMEDIA_YTDLP_PROXY", ""),
+ )
+```
+
+`apps/api/app/errors.py`:
+
+```python
+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)
+```
+
+- [ ] **Step 5: Replace readiness with runtime checks**
+
+`apps/api/app/health.py`:
+
+```python
+import importlib.util
+import os
+import shutil
+from pathlib import Path
+
+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")
+
+
+@health.get("/health/ready")
+def ready() -> Reply:
+ 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")
+```
+
+`apps/api/tests/test_health.py`:
+
+```python
+from pathlib import Path
+
+import pytest
+from flask import Flask
+
+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_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
+```
+
+`apps/api/app/__init__.py` stays as generated in this task (it still registers `health`); it sets `app.config["OPENMEDIA_DATA_DIR"] = "/data"` so the readiness route works until Task 6 replaces the factory:
+
+```python
+from flask import Flask
+
+from .health import health
+
+
+def create_app() -> Flask:
+ app = Flask(__name__)
+ app.config["OPENMEDIA_DATA_DIR"] = "/data"
+ app.register_blueprint(health)
+ return app
+```
+
+- [ ] **Step 6: Run the API checks**
+
+Run from the project root: `mise run //apps/api:ci-unit`
+Expected: format, lint, `mypy --strict` and every test pass.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add apps/api
+git commit -m "feat(api): add settings, error model and runtime readiness checks"
+```
+
+### Task 2: Input validation and network guard
+
+**Files:**
+
+- Create: `apps/api/app/validation.py`, `apps/api/app/network_guard.py`, `apps/api/tests/test_validation.py`, `apps/api/tests/test_network_guard.py`
+
+**Interfaces:**
+
+- Consumes: `ApiError` (Task 1).
+- Produces:
+ - `validate_url(value: object) -> str`
+ - `Trim(start: float, end: float)`, `SubtitleOptions(languages: tuple[str, ...], mode: str)`, `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)` with `DownloadOptions.to_json() -> dict[str, object]`
+ - `parse_download_options(payload: Mapping[str, object]) -> DownloadOptions`
+ - `resolve_host(host: str) -> list[str]`, `ensure_public_url(url: str, resolver: Callable[[str], list[str]] = resolve_host) -> None`
+
+- [ ] **Step 1: Write the failing validation tests**
+
+`apps/api/tests/test_validation.py`:
+
+```python
+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": 999},
+ {"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"
+
+
+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
+```
+
+- [ ] **Step 2: Write the failing network guard tests**
+
+`apps/api/tests/test_network_guard.py`:
+
+```python
+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"
+```
+
+- [ ] **Step 3: Run both suites to see them fail**
+
+Run from `apps/api`: `mise exec -- uv run pytest -q tests/test_validation.py tests/test_network_guard.py`
+Expected: FAIL with `ModuleNotFoundError`.
+
+- [ ] **Step 4: Implement validation**
+
+`apps/api/app/validation.py`:
+
+```python
+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")
+QUALITY_HEIGHTS = (2160, 1440, 1080, 720, 480, 360)
+
+
+@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 value not in QUALITY_HEIGHTS:
+ raise invalid_option("quality_height must be one of 2160, 1440, 1080, 720, 480, 360.")
+ 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),
+ )
+```
+
+When `format` is `video`, an invalid `audio_format` in the payload is ignored because audio options do not apply; the parametrized case `{"format": "audio", "audio_format": "aac"}` covers audio validation.
+
+- [ ] **Step 5: Implement the network guard**
+
+`apps/api/app/network_guard.py`:
+
+```python
+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.")
+```
+
+- [ ] **Step 6: Run the API checks**
+
+Run from the project root: `mise run //apps/api:ci-unit`
+Expected: PASS.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add apps/api
+git commit -m "feat(api): validate download input and block private network targets"
+```
+
+### Task 3: yt-dlp command builder, progress parser and extraction
+
+**Files:**
+
+- Create: `apps/api/app/progress.py`, `apps/api/app/ytdlp.py`, `apps/api/tests/test_progress.py`, `apps/api/tests/test_ytdlp.py`
+
+**Interfaces:**
+
+- Consumes: `Settings`, `ApiError`, `DownloadOptions`, `Trim`, `SubtitleOptions`.
+- Produces:
+ - `progress.PROGRESS_MARKER = "OMPROGRESS"`, `ProgressSample(downloaded_bytes: int | None, total_bytes: int | None, speed_bps: float | None, eta_seconds: int | None)`, `parse_progress_line(line: str) -> ProgressSample | None`, `is_postprocessing_line(line: str) -> bool`, `ProgressTracker` with `percent: float`, `record(sample) -> float`, `record_processing() -> float`
+ - `ytdlp.CompletedRun(returncode: int, stdout: str, stderr: str)`, `Runner = Callable[[Sequence[str], float, Mapping[str, str]], CompletedRun]`, `run_command`, `CookieCopier = Callable[[Path], Path | None]`, `ytdlp_environment(settings) -> dict[str, str]`, `DownloadRequest(url: str, options: DownloadOptions, job_dir: Path, max_filesize_mb: int, cookies_file: Path | None, proxy: str)`, `build_download_command(request) -> list[str]`, `build_info_command(url, cookies_file, proxy) -> list[str]`, `build_playlist_command(url, limit, cookies_file, proxy) -> list[str]`, `error_from_output(output: str) -> ApiError`, `summarize_info(info: Mapping[str, Any]) -> dict[str, object]`, `YtDlpClient(settings, copy_cookies: CookieCopier, runner: Runner = run_command)` with `fetch_info(url) -> dict[str, object]` and `fetch_playlist(url, limit) -> dict[str, object]`
+
+- [ ] **Step 1: Write the failing progress tests**
+
+`apps/api/tests/test_progress.py`:
+
+```python
+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
+```
+
+- [ ] **Step 2: Write the failing yt-dlp tests**
+
+`apps/api/tests/test_ytdlp.py`:
+
+```python
+import json
+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 (
+ CompletedRun,
+ DownloadRequest,
+ YtDlpClient,
+ build_download_command,
+ build_info_command,
+ error_from_output,
+ summarize_info,
+)
+
+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
+
+
+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"
+
+
+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"
+
+
+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: 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"
+
+
+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"
+```
+
+- [ ] **Step 3: Run both suites to see them fail**
+
+Run from `apps/api`: `mise exec -- uv run pytest -q tests/test_progress.py tests/test_ytdlp.py`
+Expected: FAIL with `ModuleNotFoundError`.
+
+- [ ] **Step 4: Implement the progress parser**
+
+`apps/api/app/progress.py`:
+
+```python
+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
+```
+
+- [ ] **Step 5: Implement the yt-dlp module**
+
+`apps/api/app/ytdlp.py`:
+
+```python
+import json
+import os
+import subprocess
+import sys
+import tempfile
+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_ERROR_MESSAGE_LENGTH = 300
+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."),
+)
+
+
+@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:
+ try:
+ result = subprocess.run(list(command), capture_output=True, text=True, timeout=timeout, env=dict(env), check=False)
+ except subprocess.TimeoutExpired as error:
+ raise ApiError(504, "timeout", "The site took too long to respond. Try again.") from error
+ return CompletedRun(result.returncode, result.stdout, result.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]
+
+
+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", "--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 error_from_output(output: str) -> ApiError:
+ line = _last_line(output)
+ lowered = line.lower()
+ for fragment, code, message in ERROR_PATTERNS:
+ if fragment in lowered:
+ return ApiError(400, code, message)
+ 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")),
+ }
+
+
+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
+
+ def _run(self, build: Callable[[Path | None], list[str]], timeout: float) -> dict[str, Any]:
+ with 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]}
+```
+
+- [ ] **Step 6: Run the API checks**
+
+Run from the project root: `mise run //apps/api:ci-unit`
+Expected: PASS (ruff format may reflow long lines; run `mise run //apps/api:format-fix` first if `format` fails).
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add apps/api
+git commit -m "feat(api): build yt-dlp commands, parse progress and map extraction errors"
+```
+
+### Task 4: Job engine, runtime settings, storage and retention
+
+**Files:**
+
+- Create: `apps/api/app/settings_store.py`, `apps/api/app/storage.py`, `apps/api/app/jobs.py`, `apps/api/app/cleanup.py`
+- Test: `apps/api/tests/test_settings_store.py`, `apps/api/tests/test_storage.py`, `apps/api/tests/test_jobs.py`, `apps/api/tests/test_cleanup.py`
+
+**Interfaces:**
+
+- Consumes: `Settings`, `ApiError`, `DownloadOptions`, `parse_download_options`, `ProgressTracker`, `parse_progress_line`, `is_postprocessing_line`, `DownloadRequest`, `build_download_command`, `error_from_output`, `ytdlp_environment`, `CookieCopier`.
+- Produces:
+ - `RETENTION_CHOICES = (15, 60, 360, 1440)`, `RuntimeSettings(retention_minutes: int, max_concurrent: int)` with `to_json()`, `parse_runtime_settings(payload, fallback) -> RuntimeSettings`, `SettingsStore(path: Path, defaults: RuntimeSettings)` with `current()` and `update(payload)`
+ - `StorageUsage(used_bytes: int, limit_bytes: int | None, free_bytes: int)` with `to_json()`, `storage_usage(downloads_dir: Path, max_storage_gb: int) -> StorageUsage`, `ensure_capacity(usage: StorageUsage) -> None`
+ - `JobStatus` (`queued`, `downloading`, `processing`, `done`, `error`, `cancelled`), `Job`, `JobFile`, `ProcessHandle` protocol (`output_lines() -> Iterator[str]`, `wait() -> int`, `terminate() -> None`), `ProcessFactory`, `start_subprocess`, `JobRuntime(settings, store, copy_cookies, process_factory=start_subprocess, now=utc_now)`, `JobManager(runtime)` with `submit(url, title, options) -> Job`, `get(job_id) -> Job`, `list_jobs() -> list[Job]`, `to_json(job) -> dict[str, object]`, `dispatch() -> None`, `cancel_or_remove(job_id) -> None`, `remove_finished_before(cutoff: datetime) -> int`, `known_job_ids() -> set[str]`, `wait_until_idle(timeout: float) -> bool`, `utc_now() -> datetime`
+ - `remove_orphan_directories(downloads_dir, known_job_ids) -> int`, `RetentionSweeper(manager, store, now=utc_now)` with `sweep_once() -> int`, `start()`, `stop()`
+
+- [ ] **Step 1: Write the failing settings store and storage tests**
+
+`apps/api/tests/test_settings_store.py`:
+
+```python
+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
+```
+
+`apps/api/tests/test_storage.py`:
+
+```python
+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_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))
+```
+
+- [ ] **Step 2: Implement the settings store and storage**
+
+`apps/api/app/settings_store.py`:
+
+```python
+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
+```
+
+Because `parse_runtime_settings` compares against the fallback, a retention value equal to the current one is accepted even when it came from the environment (for example 120); any other change must be one of the four choices. The test `{"retention_minutes": 30}` uses the default fallback of 60, so it is rejected.
+
+`apps/api/app/storage.py`:
+
+```python
+import shutil
+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 directory_size(path: Path) -> int:
+ if not path.is_dir():
+ return 0
+ return sum(entry.stat().st_size for entry in path.rglob("*") if entry.is_file())
+
+
+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.")
+```
+
+- [ ] **Step 3: Run these suites**
+
+Run from `apps/api`: `mise exec -- uv run pytest -q tests/test_settings_store.py tests/test_storage.py`
+Expected: PASS.
+
+- [ ] **Step 4: Write the failing job engine tests**
+
+`apps/api/tests/test_jobs.py`:
+
+```python
+import threading
+import time
+from collections.abc import 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 JobManager, JobRuntime, JobStatus
+from app.settings_store import RuntimeSettings, SettingsStore
+from app.validation import parse_download_options
+
+URL = "https://www.youtube.com/watch?v=abc"
+
+
+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_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({}))
+ time.sleep(0.05)
+ assert first.status is JobStatus.DOWNLOADING
+ assert 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({}))
+ time.sleep(0.05)
+ 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_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")
+
+
+def test_remove_finished_before_cutoff(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)
+ 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"
+```
+
+`apps/api/tests/test_cleanup.py`:
+
+```python
+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
+```
+
+Add an empty `apps/api/tests/__init__.py` so `from .test_jobs import ...` resolves as a package import.
+
+- [ ] **Step 5: Run the job suites to see them fail**
+
+Run from `apps/api`: `mise exec -- uv run pytest -q tests/test_jobs.py tests/test_cleanup.py`
+Expected: FAIL with `ModuleNotFoundError: No module named 'app.jobs'`.
+
+- [ ] **Step 6: Implement the job engine**
+
+`apps/api/app/jobs.py`:
+
+```python
+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, ytdlp_environment
+
+OUTPUT_TAIL_LINES = 40
+MAX_TITLE_LENGTH = 100
+TITLE_UNSAFE_CHARACTERS = frozenset('\\/:*?"<>|')
+SUBTITLE_SUFFIXES = frozenset({".srt", ".vtt", ".ass", ".lrc"})
+PARTIAL_SUFFIXES = frozenset({".part", ".ytdl", ".temp"})
+WATCHDOG_INTERVAL_SECONDS = 1.0
+TERMINATED_EXIT_CODE = -15
+
+
+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, text=True, 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:
+ try:
+ os.killpg(self._process.pid, signal.SIGTERM)
+ 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) -> None:
+ self._handle = handle
+ self._timeout = timeout_seconds
+ 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 _watch(self) -> None:
+ interval = min(WATCHDOG_INTERVAL_SECONDS, self._timeout / 4)
+ while not self._stopped.wait(interval):
+ if time.monotonic() - self._last_activity > self._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._threads: list[threading.Thread] = []
+ 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)
+ thread = threading.Thread(target=self._run, args=(job,), name=f"job-{job.job_id}", daemon=True)
+ self._threads.append(thread)
+ 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
+ for thread in list(self._threads):
+ thread.join(max(deadline - time.monotonic(), 0))
+ return not any(thread.is_alive() for thread in self._threads)
+
+ 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)
+ job_dir.mkdir(parents=True, exist_ok=True)
+ try:
+ outcome = self._execute(job, job_dir)
+ except OSError as error:
+ outcome = Outcome(returncode=1, output=f"ERROR: {error}", stalled=False, cookies_file=None)
+ self._finish(job, job_dir, outcome)
+ self.dispatch()
+
+ def _execute(self, job: Job, job_dir: Path) -> Outcome:
+ settings = self._runtime.settings
+ cookies_file = self._runtime.copy_cookies(job_dir)
+ 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:
+ self._handles[job.job_id] = handle
+ watchdog = StallWatchdog(handle, settings.stall_timeout_seconds)
+ watchdog.start()
+ 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()
+ 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:
+ self._record_outcome(job, job_dir, outcome)
+ 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:
+ return self._fail(job, "timeout", "The download stalled and was stopped.")
+ if outcome.returncode != 0:
+ error = error_from_output(outcome.output)
+ return self._fail(job, error.code, error.message)
+ files = collect_files(job_dir, job.title, job.job_id)
+ if not files:
+ return self._fail(job, "extractor_error", "The download finished but no file was found.")
+ job.files, job.status, job.progress = files, JobStatus.DONE, 100.0
+ return None
+
+ def _fail(self, job: Job, code: str, message: str) -> None:
+ job.status, job.error_code, job.error = JobStatus.ERROR, code, message
+```
+
+- [ ] **Step 7: Implement retention cleanup**
+
+`apps/api/app/cleanup.py`:
+
+```python
+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()
+```
+
+- [ ] **Step 8: Run the API checks**
+
+Run from the project root: `mise run //apps/api:ci-unit`
+Expected: PASS. If `mypy --strict` flags `return self._fail(...)` in `_record_outcome`, change those lines to call `self._fail(...)` followed by `return`.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add apps/api
+git commit -m "feat(api): add the download job engine with retention and storage limits"
+```
+
+### Task 5: Cookies and security primitives
+
+**Files:**
+
+- Create: `apps/api/app/cookies.py`, `apps/api/app/security.py`
+- Test: `apps/api/tests/test_cookies.py`, `apps/api/tests/test_security.py`
+
+**Interfaces:**
+
+- Consumes: `Settings`, `ApiError`.
+- Produces:
+ - `COOKIE_COPY_NAME = ".cookies.txt"`, `MAX_COOKIE_BYTES = 1048576`, `CookieRow(domain: str, expires: int)`, `CookieSummary(present: bool, domains: tuple[str, ...], expires_at: datetime | None, uploaded_at: datetime | None)` with `to_json()`, `parse_cookie_rows(text) -> list[CookieRow]`, `validate_cookie_file(raw: bytes) -> str`, `CookieStore(path)` with `summary()`, `save(raw: bytes) -> CookieSummary`, `delete()`, `copy_into(directory: Path) -> Path | None`
+ - `load_or_create_secret_key(settings) -> str`, `RateLimiter(per_minute: int, clock=time.monotonic)` with `retry_after(key) -> float | None` and `enforce(key) -> None`, `client_address() -> str`, `ensure_same_origin_request() -> None`, `is_authenticated(settings) -> bool`, `ensure_authenticated(settings) -> None`, `password_matches(settings, candidate: object) -> bool`, `sign_in() -> None`, `sign_out() -> None`, `ForwardedProtoSessionInterface`
+
+- [ ] **Step 1: Write the failing cookie tests**
+
+`apps/api/tests/test_cookies.py`:
+
+```python
+import stat
+from pathlib import Path
+
+import pytest
+
+from app.cookies import 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_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
+ store.delete()
+ assert store.summary().present is False
+```
+
+- [ ] **Step 2: Write the failing security tests**
+
+`apps/api/tests/test_security.py`:
+
+```python
+from dataclasses import replace
+
+import pytest
+from flask import Flask
+
+from app.config import Settings
+from app.errors import ApiError
+from app.security import (
+ 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()
+ assert is_authenticated(protected) is True
+
+
+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"
+```
+
+- [ ] **Step 3: Run both suites to see them fail**
+
+Run from `apps/api`: `mise exec -- uv run pytest -q tests/test_cookies.py tests/test_security.py`
+Expected: FAIL with `ModuleNotFoundError`.
+
+- [ ] **Step 4: Implement cookies**
+
+`apps/api/app/cookies.py`:
+
+```python
+import os
+import shutil
+import threading
+from dataclasses import dataclass
+from datetime import UTC, datetime
+from pathlib import Path
+
+from .errors import ApiError
+
+COOKIE_COPY_NAME = ".cookies.txt"
+MAX_COOKIE_BYTES = 1024 * 1024
+HTTP_ONLY_PREFIX = "#HttpOnly_"
+COOKIE_FIELD_COUNT = 7
+EXPIRY_FIELD = 4
+PRIVATE_FILE_MODE = 0o600
+
+
+@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 fields[EXPIRY_FIELD].isdigit():
+ 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 row.expires > 0]
+ 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")
+ descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, PRIVATE_FILE_MODE)
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
+ handle.write(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
+ target = directory / COOKIE_COPY_NAME
+ shutil.copyfile(self._path, target)
+ target.chmod(PRIVATE_FILE_MODE)
+ return target
+```
+
+The test expects `expires_at` for year 2030 from the latest expiry `1893456000` (2030-01-01T00:00:00Z); the summary reports the latest expiry because long-lived login cookies decide whether the file still works.
+
+- [ ] **Step 5: Implement security**
+
+`apps/api/app/security.py`:
+
+```python
+import hmac
+import math
+import os
+import secrets
+import threading
+import time
+from collections.abc import Callable
+from urllib.parse import urlsplit
+
+from flask import Flask, request, session
+from flask.sessions import SecureCookieSessionInterface
+
+from .config import Settings
+from .errors import ApiError
+
+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
+PRIVATE_FILE_MODE = 0o600
+
+
+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)
+ descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, PRIVATE_FILE_MODE)
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
+ handle.write(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 retry_after(self, key: str) -> float | None:
+ with self._lock:
+ now = self._clock()
+ tokens, updated = self._buckets.get(key, (self._capacity, now))
+ tokens = min(self._capacity, tokens + (now - updated) * self._refill_per_second)
+ if tokens < 1:
+ self._buckets[key] = (tokens, now)
+ return (1 - tokens) / self._refill_per_second
+ self._buckets[key] = (tokens - 1, 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 is_authenticated(settings: Settings) -> bool:
+ return not settings.password or session.get(AUTHENTICATED_SESSION_KEY) is True
+
+
+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() -> None:
+ session.clear()
+ session[AUTHENTICATED_SESSION_KEY] = True
+ session.permanent = True
+
+
+def sign_out() -> None:
+ session.clear()
+
+
+class ForwardedProtoSessionInterface(SecureCookieSessionInterface):
+ def get_cookie_secure(self, app: Flask) -> bool:
+ return request.is_secure
+```
+
+- [ ] **Step 6: Run the API checks**
+
+Run from the project root: `mise run //apps/api:ci-unit`
+Expected: PASS.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add apps/api
+git commit -m "feat(api): add cookie storage, cross-site guard, rate limiting and optional password"
+```
+
+### Task 6: HTTP routes and application factory
+
+**Files:**
+
+- Create: `apps/api/app/services.py`, `apps/api/app/media.py`, `apps/api/tests/test_routes.py`
+- Modify: `apps/api/app/__init__.py`
+
+**Interfaces:**
+
+- Consumes: everything from Tasks 1 to 5.
+- Produces: `Services(settings, store, cookies, ytdlp, jobs, request_limiter, login_limiter)`, `EXTENSION_KEY = "openmedia"`, `build_services(settings, process_factory=start_subprocess, runner=run_command) -> Services`, `current_services() -> Services`, blueprint `media`, `create_app(settings: Settings | None = None, services: Services | None = None) -> Flask`. The gunicorn entry point stays `app:create_app()`.
+
+- [ ] **Step 1: Write the failing route tests**
+
+`apps/api/tests/test_routes.py`:
+
+```python
+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 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 = {"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_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"
+```
+
+- [ ] **Step 2: Run the suite to see it fail**
+
+Run from `apps/api`: `mise exec -- uv run pytest -q tests/test_routes.py`
+Expected: FAIL with `ModuleNotFoundError: No module named 'app.services'`.
+
+- [ ] **Step 3: Implement the services container**
+
+`apps/api/app/services.py`:
+
+```python
+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"
+LOGIN_ATTEMPTS_PER_MINUTE = 5
+
+
+@dataclass(frozen=True)
+class Services:
+ settings: Settings
+ store: SettingsStore
+ cookies: CookieStore
+ ytdlp: YtDlpClient
+ jobs: JobManager
+ request_limiter: RateLimiter
+ 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),
+ )
+
+
+def current_services() -> Services:
+ return cast(Services, current_app.extensions[EXTENSION_KEY])
+```
+
+- [ ] **Step 4: Implement the routes**
+
+`apps/api/app/media.py`:
+
+```python
+from collections.abc import Mapping
+
+from flask import Blueprint, Response, jsonify, request, send_file
+
+from .errors import ApiError
+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 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())
+ 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()
+ 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.value != "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
+```
+
+- [ ] **Step 5: Implement the application factory**
+
+`apps/api/app/__init__.py`:
+
+```python
+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
+from .media import media
+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
+ setattr(app, "wsgi_app", ProxyFix(app.wsgi_app, x_for=hops, x_proto=hops, x_host=hops))
+
+
+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(settings: Settings | None = None, services: Services | None = None) -> Flask:
+ resolved = settings or load_settings()
+ app = Flask(__name__)
+ _configure(app, resolved)
+ register_error_handlers(app)
+ app.register_blueprint(health)
+ app.register_blueprint(media)
+ app.extensions[EXTENSION_KEY] = services or _start_services(resolved)
+ return app
+```
+
+Update `apps/api/tests/test_health.py` only if `create_app` imports break it; its tests build their own Flask app and keep passing.
+
+- [ ] **Step 6: Run the API checks**
+
+Run from the project root: `mise run //apps/api:ci-unit`
+Expected: PASS. `test_unknown_route_is_json` relies on the `HTTPException` handler mapping "Not Found" to `not_found`.
+
+- [ ] **Step 7: Smoke-test against real yt-dlp**
+
+Run from `apps/api`:
+
+```bash
+OPENMEDIA_DATA_DIR=$(mktemp -d) mise exec -- uv run flask --app "app:create_app()" run --port 8095 &
+sleep 4
+curl -s -X POST localhost:8095/api/info -H 'content-type: application/json' -d '{"url":"https://www.youtube.com/watch?v=jNQXAC9IVRw"}' | head -c 300
+kill %1
+```
+
+Expected: JSON with `"title": "Me at the zoo"`. If the network is unavailable, record that in the report and continue.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add apps/api
+git commit -m "feat(api): expose the media API with reclip-compatible routes"
+```
+
+### Task 7: API container, compose stack and update policy
+
+**Files:**
+
+- Modify: `apps/api/Dockerfile`, `apps/api/.env.example`, `apps/api/mise.toml`, `apps/api/.dockerignore`, `compose.yaml`, `example.env`, `renovate.json`, `.gitignore`
+- Create: `apps/api/docker-entrypoint.sh`
+
+**Interfaces:**
+
+- Consumes: `create_app()` (Task 6), `OPENMEDIA_*` variables (spec 4.2).
+- Produces: image `ghcr.io/ttncode/openmedia-api` that serves port 8080 as user `app` (uid 10001) with `/data` as a volume; compose services `web` and `api` with `API_URL=http://api:8080` for `web`; mise task `//apps/api:dev`.
+
+- [ ] **Step 1: Rewrite the runtime stage of the Dockerfile**
+
+Keep the generated `deps` stage and its comment lines untouched. Replace everything from `FROM python:3.13-slim@sha256:... AS runtime` to the end with (keep the same pinned digest line the generator wrote):
+
+```dockerfile
+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}" \
+ 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
+HEALTHCHECK --interval=30s --timeout=3s \
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health/live')" || exit 1
+ENTRYPOINT ["/app/docker-entrypoint.sh"]
+CMD ["gunicorn", "--workers", "1", "--threads", "8", "--timeout", "120", "--bind", "0.0.0.0:8080", "--access-logfile", "-", "app:create_app()"]
+```
+
+The `# @SERVICE_SETUP@` anchor was already removed by scaffold for `--db none`; do not re-add it. If `COPY --from=deps /usr/local/bin/uv` fails because the uv image stores the binary elsewhere, find it with `docker run --rm --entrypoint sh ghcr.io/astral-sh/uv:0.12.13-python3.13-trixie-slim -c 'command -v uv'` and use that path.
+
+- [ ] **Step 2: Write the entrypoint**
+
+`apps/api/docker-entrypoint.sh`:
+
+```sh
+#!/bin/sh
+set -eu
+
+data_dir="${OPENMEDIA_DATA_DIR:-/data}"
+mkdir -p "$data_dir/downloads"
+
+if [ "${OPENMEDIA_AUTO_UPDATE_YTDLP:-true}" = "true" ]; then
+ echo "openmedia: updating yt-dlp in $data_dir/yt-dlp"
+ if ! uv pip install --quiet --python /app/.venv/bin/python --target "$data_dir/yt-dlp" --upgrade "yt-dlp[default]"; then
+ echo "openmedia: yt-dlp update failed, using the bundled version"
+ fi
+fi
+
+exec "$@"
+```
+
+Make it executable in git: `git update-index --chmod=+x apps/api/docker-entrypoint.sh` after adding.
+
+- [ ] **Step 3: Development environment and ignores**
+
+`apps/api/.env.example` (replace the generated content):
+
+```dotenv
+FLASK_DEBUG=0
+OPENMEDIA_DATA_DIR=./data
+OPENMEDIA_ALLOW_PRIVATE_URLS=false
+OPENMEDIA_AUTO_UPDATE_YTDLP=false
+```
+
+Append to `apps/api/mise.toml`:
+
+```toml
+[tasks.dev]
+env = { OPENMEDIA_DATA_DIR = "./data", OPENMEDIA_TRUSTED_PROXY_HOPS = "1" }
+run = "uv run flask --app 'app:create_app()' run --port 8081 --debug"
+```
+
+Append `data/` to `apps/api/.dockerignore` and `apps/api/data/` to the root `.gitignore`.
+
+- [ ] **Step 4: Wire the compose stack**
+
+`compose.yaml` services become (keep the header comment block):
+
+```yaml
+name: app
+services:
+ web:
+ image: ghcr.io/ttncode/openmedia-web:${IMAGE_TAG:-latest}
+ env_file:
+ - path: .env
+ required: false
+ environment:
+ API_URL: http://api:8080
+ restart: always
+ ports:
+ - "${WEB_PORT:-8080}:8080"
+ depends_on:
+ api:
+ condition: service_healthy
+ api:
+ image: ghcr.io/ttncode/openmedia-api:${IMAGE_TAG:-latest}
+ env_file:
+ - path: .env
+ required: false
+ restart: always
+ ports:
+ - "127.0.0.1:${API_PORT:-8081}:8080"
+ volumes:
+ - openmedia-data:/data
+volumes:
+ openmedia-data:
+```
+
+Append to `example.env`:
+
+```dotenv
+
+OPENMEDIA_PASSWORD=
+OPENMEDIA_RETENTION_MINUTES=60
+OPENMEDIA_MAX_CONCURRENT=3
+OPENMEDIA_MAX_FILESIZE_MB=4096
+OPENMEDIA_MAX_STORAGE_GB=0
+OPENMEDIA_MAX_PLAYLIST_ITEMS=50
+OPENMEDIA_RATE_LIMIT_PER_MINUTE=30
+OPENMEDIA_STALL_TIMEOUT_SECONDS=180
+OPENMEDIA_ALLOW_PRIVATE_URLS=false
+OPENMEDIA_AUTO_UPDATE_YTDLP=true
+OPENMEDIA_YTDLP_PROXY=
+```
+
+- [ ] **Step 5: Let yt-dlp updates through quickly**
+
+`renovate.json`:
+
+```json
+{
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
+ "extends": ["config:recommended", "helpers:pinGitHubActionDigests", "docker:pinDigests"],
+ "minimumReleaseAge": "3 days",
+ "packageRules": [
+ {
+ "matchPackageNames": ["yt-dlp", "yt-dlp-ejs"],
+ "minimumReleaseAge": "0 days",
+ "groupName": "yt-dlp"
+ }
+ ]
+}
+```
+
+- [ ] **Step 6: Build and run the image**
+
+```bash
+docker build -t openmedia-api:local apps/api
+docker volume create openmedia-verify
+docker run -d --name openmedia-api-verify -p 127.0.0.1:18081:8080 -v openmedia-verify:/data -e OPENMEDIA_AUTO_UPDATE_YTDLP=false openmedia-api:local
+sleep 8
+curl -fsS localhost:18081/health/ready
+curl -fsS -X POST localhost:18081/api/info -H 'content-type: application/json' -d '{"url":"https://www.youtube.com/watch?v=jNQXAC9IVRw"}' | head -c 200
+docker exec openmedia-api-verify sh -c 'command -v ffmpeg deno && id -u'
+docker rm -f openmedia-api-verify && docker volume rm openmedia-verify
+```
+
+Expected: `{"status":"ok"}`, JSON containing `Me at the zoo`, paths for ffmpeg and deno, uid `10001`. Then run `docker compose config --quiet` from the project root; expected: no output.
+
+- [ ] **Step 7: Run the API checks and commit**
+
+Run from the project root: `mise run //apps/api:ci-unit`
+Expected: PASS.
+
+```bash
+git add apps/api compose.yaml example.env renovate.json .gitignore
+git update-index --chmod=+x apps/api/docker-entrypoint.sh
+git commit -m "build(api): ship ffmpeg, deno and a data volume in the API image"
+```
+
+### Task 8: Web foundation (tooling, tokens, i18n, API client, proxy)
+
+**Files:**
+
+- Modify: `apps/web/package.json`, `apps/web/pnpm-lock.yaml`, `apps/web/src/app/layout.tsx`, `apps/web/src/app/page.tsx`, `apps/web/src/app/globals.css`, `apps/web/.env.example`, `apps/web/mise.toml`
+- Delete: `apps/web/public/next.svg`, `apps/web/public/vercel.svg`, `apps/web/public/file.svg`, `apps/web/public/globe.svg`, `apps/web/public/window.svg`, `apps/web/src/app/favicon.ico`
+- Create: `apps/web/vitest.config.ts`, `apps/web/src/test/setup.ts`, `apps/web/src/app/api/[...path]/route.ts`, `apps/web/src/app/api/[...path]/proxy.ts`, `apps/web/src/app/api/[...path]/proxy.test.ts`, `apps/web/src/lib/api/types.ts`, `apps/web/src/lib/api/client.ts`, `apps/web/src/lib/api/client.test.ts`, `apps/web/src/lib/links.ts`, `apps/web/src/lib/links.test.ts`, `apps/web/src/lib/format.ts`, `apps/web/src/lib/format.test.ts`, `apps/web/src/lib/i18n/en.ts`, `apps/web/src/lib/i18n/vi.ts`, `apps/web/src/lib/i18n/I18nProvider.tsx`, `apps/web/src/lib/i18n/i18n.test.ts`, `apps/web/src/lib/theme.ts`
+
+**Interfaces:**
+
+- Produces:
+ - `types.ts`: `JobStatus`, `DownloadKind = "video" | "audio"`, `Container = "mp4" | "mkv"`, `AudioFormat = "mp3" | "m4a" | "opus" | "flac" | "wav"`, `AudioQuality = "320k" | "best"`, `SubtitleMode = "embed" | "srt"`, `MediaFormat`, `MediaInfo`, `PlaylistInfo`, `DownloadRequest`, `JobOptions`, `JobFile`, `Job`, `SessionInfo`, `RuntimeSettings`, `StorageUsage`, `CookieSummary`, `ApiErrorBody`
+ - `client.ts`: `ApiRequestError(status, code, message, retryAfterSeconds)`, `api` object with `session()`, `signIn(password)`, `signOut()`, `info(url)`, `playlist(url)`, `download(request)`, `jobs()`, `removeJob(jobId)`, `settings()`, `updateSettings(patch)`, `storage()`, `cookies()`, `uploadCookies(file)`, `removeCookies()`, and `fileUrl(jobId, index?)`
+ - `links.ts`: `PlatformId`, `parseLinks(text) -> string[]`, `detectPlatform(url) -> PlatformId`, `detectPlatforms(urls) -> PlatformId[]`, `hasPlaylist(url) -> boolean`, `linkFromShare({ url, text }) -> string | null`
+ - `format.ts`: `Locale = "vi" | "en"`, `formatBytes(bytes, locale)`, `formatSpeed(bytesPerSecond, locale)`, `formatClock(seconds)`, `parseClock(text) -> number | null`, `splitDuration(seconds) -> { hours, minutes, seconds }`
+ - `i18n`: `Messages` type (from `en`), `en`, `vi`, `LanguagePreference = "auto" | Locale`, `resolveLocale(preference, navigatorLanguage) -> Locale`, `I18nProvider({ locale, children })`, `useI18n() -> { locale, t: Messages }`
+ - `theme.ts`: `ThemePreference = "system" | "light" | "dark"`, `AccentId = "teal" | "blue" | "purple" | "pink" | "orange" | "green" | "graphite"`, `ACCENTS`, `applyTheme(theme)`, `applyAccent(accent)`, `THEME_BOOTSTRAP_SCRIPT`
+ - `proxy.ts`: `buildUpstreamUrl(requestUrl, path, apiBaseUrl) -> URL`, `forwardedRequestHeaders(request) -> Headers`, `proxyToApi(request, path, apiBaseUrl, fetchImpl?) -> Promise`
+
+- [ ] **Step 1: Install dependencies**
+
+Run from `apps/web`:
+
+```bash
+mise exec -- pnpm add @phosphor-icons/react
+mise exec -- pnpm add -D jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom @vitejs/plugin-react
+```
+
+Expected: `package.json` and `pnpm-lock.yaml` update. If pnpm refuses a postinstall build, add the package to `allowBuilds` in `apps/web/pnpm-workspace.yaml` only when it is required to run.
+
+- [ ] **Step 2: Configure vitest**
+
+`apps/web/vitest.config.ts`:
+
+```ts
+import react from "@vitejs/plugin-react";
+import { fileURLToPath } from "node:url";
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
+ },
+ test: {
+ restoreMocks: true,
+ css: { modules: { classNameStrategy: "non-scoped" } },
+ projects: [
+ {
+ extends: true,
+ test: {
+ name: "dom",
+ environment: "jsdom",
+ include: ["src/**/*.test.{ts,tsx}"],
+ exclude: ["src/app/api/**"],
+ setupFiles: ["./src/test/setup.ts"],
+ },
+ },
+ {
+ extends: true,
+ test: { name: "node", environment: "node", include: ["src/app/api/**/*.test.ts"] },
+ },
+ ],
+ },
+});
+```
+
+`apps/web/src/test/setup.ts`:
+
+```ts
+import "@testing-library/jest-dom/vitest";
+import { cleanup } from "@testing-library/react";
+import { afterEach } from "vitest";
+
+afterEach(() => {
+ cleanup();
+ window.localStorage.clear();
+});
+```
+
+- [ ] **Step 3: Write the failing library tests**
+
+`apps/web/src/lib/links.test.ts`:
+
+```ts
+import { describe, expect, it } from "vitest";
+import { detectPlatform, detectPlatforms, hasPlaylist, linkFromShare, parseLinks } from "./links";
+
+describe("links", () => {
+ it("parses links separated by spaces, commas and newlines without duplicates", () => {
+ const text = "https://youtu.be/a, https://www.tiktok.com/@x/video/1\nhttps://youtu.be/a not-a-link ftp://x.y";
+ expect(parseLinks(text)).toEqual(["https://youtu.be/a", "https://www.tiktok.com/@x/video/1"]);
+ });
+
+ it("detects platforms by host", () => {
+ expect(detectPlatform("https://m.youtube.com/watch?v=1")).toBe("youtube");
+ expect(detectPlatform("https://x.com/a/status/1")).toBe("x");
+ expect(detectPlatform("https://soundcloud.com/a/b")).toBe("soundcloud");
+ expect(detectPlatform("https://example.org/v")).toBe("other");
+ expect(detectPlatforms(["https://youtu.be/a", "https://youtube.com/b", "https://vimeo.com/1"])).toEqual(["youtube", "vimeo"]);
+ });
+
+ it("recognizes playlist parameters", () => {
+ expect(hasPlaylist("https://www.youtube.com/watch?v=a&list=PL1")).toBe(true);
+ expect(hasPlaylist("https://www.youtube.com/watch?v=a")).toBe(false);
+ });
+
+ it("extracts a link from share target parameters", () => {
+ expect(linkFromShare({ url: null, text: "Look https://youtu.be/a nice" })).toBe("https://youtu.be/a");
+ expect(linkFromShare({ url: "https://vimeo.com/1", text: null })).toBe("https://vimeo.com/1");
+ expect(linkFromShare({ url: null, text: "no link" })).toBeNull();
+ });
+});
+```
+
+`apps/web/src/lib/format.test.ts`:
+
+```ts
+import { describe, expect, it } from "vitest";
+import { formatBytes, formatClock, formatSpeed, parseClock, splitDuration } from "./format";
+
+describe("format", () => {
+ it("formats sizes with locale decimal separators", () => {
+ expect(formatBytes(1_600_000_000, "vi")).toBe("1,6 GB");
+ expect(formatBytes(412_000_000, "en")).toBe("412 MB");
+ expect(formatBytes(57_300_000, "vi")).toBe("57,3 MB");
+ expect(formatBytes(800, "en")).toBe("0.1 MB");
+ });
+
+ it("formats speeds", () => {
+ expect(formatSpeed(4_200_000, "vi")).toBe("4,2 MB/s");
+ });
+
+ it("formats and parses clocks", () => {
+ expect(formatClock(1122)).toBe("18:42");
+ expect(formatClock(3735)).toBe("1:02:15");
+ expect(parseClock("1:02:15")).toBe(3735);
+ expect(parseClock("18:42")).toBe(1122);
+ expect(parseClock("90")).toBe(90);
+ expect(parseClock("1:xx")).toBeNull();
+ });
+
+ it("splits durations", () => {
+ expect(splitDuration(3735)).toEqual({ hours: 1, minutes: 2, seconds: 15 });
+ });
+});
+```
+
+`apps/web/src/lib/api/client.test.ts`:
+
+```ts
+import { describe, expect, it, vi } from "vitest";
+import { api, ApiRequestError } from "./client";
+
+function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
+ return new Response(JSON.stringify(body), { headers: { "content-type": "application/json" }, ...init });
+}
+
+describe("api client", () => {
+ it("posts JSON and parses the response", async () => {
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ title: "Pho", formats: [] }));
+ const info = await api.info("https://youtu.be/a");
+ expect(info.title).toBe("Pho");
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/info");
+ expect(init?.method).toBe("POST");
+ expect(new Headers(init?.headers).get("content-type")).toBe("application/json");
+ });
+
+ it("raises typed errors with retry hints", async () => {
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ error: "Too many requests.", code: "rate_limited" }, { status: 429, headers: { "retry-after": "12", "content-type": "application/json" } }));
+ await expect(api.jobs()).rejects.toMatchObject({ status: 429, code: "rate_limited", retryAfterSeconds: 12 });
+ });
+
+ it("maps network failures to api_unreachable", async () => {
+ vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("fetch failed"));
+ const error = await api.session().catch((caught: unknown) => caught);
+ expect(error).toBeInstanceOf(ApiRequestError);
+ expect((error as ApiRequestError).code).toBe("api_unreachable");
+ });
+
+ it("resolves empty responses for deletes", async () => {
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 204 }));
+ await expect(api.removeJob("abc")).resolves.toBeUndefined();
+ });
+
+ it("builds file urls", () => {
+ expect(api.fileUrl("abc")).toBe("/api/file/abc");
+ expect(api.fileUrl("abc", 1)).toBe("/api/file/abc/1");
+ });
+});
+```
+
+`apps/web/src/lib/i18n/i18n.test.ts`:
+
+```ts
+import { describe, expect, it } from "vitest";
+import { en } from "./en";
+import { resolveLocale } from "./I18nProvider";
+import { vi as vietnamese } from "./vi";
+
+function keysOf(value: object, prefix = ""): string[] {
+ return Object.entries(value).flatMap(([key, child]) => (typeof child === "object" && child !== null ? keysOf(child, `${prefix}${key}.`) : [`${prefix}${key}`]));
+}
+
+describe("i18n", () => {
+ it("resolves the browser language", () => {
+ expect(resolveLocale("auto", "vi-VN")).toBe("vi");
+ expect(resolveLocale("auto", "en-US")).toBe("en");
+ expect(resolveLocale("auto", "fr-FR")).toBe("en");
+ expect(resolveLocale("vi", "en-US")).toBe("vi");
+ });
+
+ it("keeps both dictionaries in sync", () => {
+ expect(keysOf(vietnamese).sort()).toEqual(keysOf(en).sort());
+ });
+
+ it("contains no dash characters reserved by the style guide", () => {
+ const strings = JSON.stringify([en, vietnamese]);
+ expect(strings).not.toMatch(/[–—]/);
+ });
+});
+```
+
+`apps/web/src/app/api/[...path]/proxy.test.ts`:
+
+```ts
+import { describe, expect, it, vi } from "vitest";
+import { buildUpstreamUrl, forwardedRequestHeaders, proxyToApi } from "./proxy";
+
+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" });
+ });
+});
+```
+
+The proxy test runs in the `node` project defined in `vitest.config.ts`, so it needs no per-file environment marker.
+
+- [ ] **Step 4: Run the tests to see them fail**
+
+Run from `apps/web`: `mise exec -- pnpm exec vitest --run`
+Expected: FAIL, modules not found.
+
+- [ ] **Step 5: Implement the libraries**
+
+`apps/web/src/lib/links.ts`:
+
+```ts
+export type PlatformId = "youtube" | "tiktok" | "instagram" | "soundcloud" | "x" | "facebook" | "vimeo" | "other";
+
+const PLATFORM_HOSTS: ReadonlyArray = [
+ ["youtube", /(^|\.)(youtube\.com|youtu\.be)$/],
+ ["tiktok", /(^|\.)tiktok\.com$/],
+ ["instagram", /(^|\.)instagram\.com$/],
+ ["soundcloud", /(^|\.)soundcloud\.com$/],
+ ["x", /(^|\.)(x\.com|twitter\.com)$/],
+ ["facebook", /(^|\.)(facebook\.com|fb\.watch)$/],
+ ["vimeo", /(^|\.)vimeo\.com$/],
+];
+
+const LINK_PATTERN = /^https?:\/\/[^\s/$.?#].\S*$/i;
+
+function hostOf(url: string): string {
+ try {
+ return new URL(url).hostname.replace(/^www\./, "");
+ } catch {
+ return "";
+ }
+}
+
+export function parseLinks(text: string): string[] {
+ const tokens = text.split(/[\s,]+/).filter((token) => LINK_PATTERN.test(token));
+ return [...new Set(tokens)];
+}
+
+export function detectPlatform(url: string): PlatformId {
+ const host = hostOf(url);
+ return PLATFORM_HOSTS.find(([, pattern]) => pattern.test(host))?.[0] ?? "other";
+}
+
+export function detectPlatforms(urls: readonly string[]): PlatformId[] {
+ return [...new Set(urls.map(detectPlatform))];
+}
+
+export function hasPlaylist(url: string): boolean {
+ try {
+ return new URL(url).searchParams.has("list");
+ } catch {
+ return false;
+ }
+}
+
+export function linkFromShare({ url, text }: { url: string | null; text: string | null }): string | null {
+ return parseLinks([url ?? "", text ?? ""].join(" "))[0] ?? null;
+}
+```
+
+`apps/web/src/lib/format.ts`:
+
+```ts
+export type Locale = "vi" | "en";
+
+const BYTES_PER_MEGABYTE = 1_000_000;
+const MEGABYTES_PER_GIGABYTE = 1000;
+const MINIMUM_MEGABYTES = 0.1;
+const LOCALE_TAGS: Record = { vi: "vi-VN", en: "en-US" };
+
+function decimal(value: number, locale: Locale): string {
+ return new Intl.NumberFormat(LOCALE_TAGS[locale], { maximumFractionDigits: 1 }).format(value);
+}
+
+export function formatBytes(bytes: number, locale: Locale): string {
+ const megabytes = Math.max(bytes / BYTES_PER_MEGABYTE, MINIMUM_MEGABYTES);
+ return megabytes >= MEGABYTES_PER_GIGABYTE ? `${decimal(megabytes / MEGABYTES_PER_GIGABYTE, locale)} GB` : `${decimal(megabytes, locale)} MB`;
+}
+
+export function formatSpeed(bytesPerSecond: number, locale: Locale): string {
+ return `${formatBytes(bytesPerSecond, locale)}/s`;
+}
+
+export function splitDuration(totalSeconds: number): { hours: number; minutes: number; seconds: number } {
+ const whole = Math.max(0, Math.round(totalSeconds));
+ return { hours: Math.floor(whole / 3600), minutes: Math.floor((whole % 3600) / 60), seconds: whole % 60 };
+}
+
+const pad = (value: number): string => String(value).padStart(2, "0");
+
+export function formatClock(totalSeconds: number): string {
+ const { hours, minutes, seconds } = splitDuration(totalSeconds);
+ return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`;
+}
+
+export function parseClock(text: string): number | null {
+ const parts = text.trim().split(":");
+ const valid = parts.length >= 1 && parts.length <= 3 && parts.every((part) => /^\d+$/.test(part));
+ return valid ? parts.reduce((total, part) => total * 60 + Number(part), 0) : null;
+}
+```
+
+`apps/web/src/lib/api/types.ts`:
+
+```ts
+export type JobStatus = "queued" | "downloading" | "processing" | "done" | "error" | "cancelled";
+export type DownloadKind = "video" | "audio";
+export type Container = "mp4" | "mkv";
+export type AudioFormat = "mp3" | "m4a" | "opus" | "flac" | "wav";
+export type AudioQuality = "320k" | "best";
+export type SubtitleMode = "embed" | "srt";
+
+export interface MediaFormat {
+ readonly id: string;
+ readonly label: string;
+ readonly height: number;
+ readonly ext: string | null;
+ readonly filesize: number | null;
+}
+
+export interface MediaInfo {
+ readonly id: string | null;
+ readonly title: string;
+ readonly thumbnail: string;
+ readonly duration: number | null;
+ readonly uploader: string;
+ readonly platform: string;
+ readonly webpage_url: string;
+ readonly formats: readonly MediaFormat[];
+ readonly subtitle_languages: readonly string[];
+ readonly has_chapters: boolean;
+}
+
+export interface PlaylistInfo {
+ readonly title: string;
+ readonly count: number;
+ readonly urls: readonly string[];
+}
+
+export interface TrimRange {
+ readonly start: number;
+ readonly end: number;
+}
+
+export interface SubtitleSelection {
+ readonly languages: readonly string[];
+ readonly mode: SubtitleMode;
+}
+
+export interface DownloadRequest {
+ readonly url: string;
+ readonly title: string;
+ readonly format: DownloadKind;
+ readonly format_id?: string;
+ readonly container?: Container;
+ readonly quality_height?: number;
+ readonly audio_format?: AudioFormat;
+ readonly audio_quality?: AudioQuality;
+ readonly trim?: TrimRange;
+ readonly subtitles?: SubtitleSelection;
+ readonly embed_metadata: boolean;
+}
+
+export interface JobOptions {
+ readonly kind: DownloadKind;
+ readonly container: Container;
+ readonly quality_height: number | null;
+ readonly format_id: string | null;
+ readonly audio_format: AudioFormat | null;
+ readonly audio_quality: AudioQuality | null;
+ readonly trim: TrimRange | null;
+ readonly subtitles: SubtitleSelection | null;
+ readonly embed_metadata: boolean;
+}
+
+export interface JobFile {
+ readonly index: number;
+ readonly name: string;
+ readonly kind: "media" | "subtitle";
+ readonly size_bytes: number;
+}
+
+export interface Job {
+ readonly job_id: string;
+ readonly url: string;
+ readonly title: string;
+ readonly status: JobStatus;
+ readonly progress: number;
+ readonly speed_bps: number | null;
+ readonly eta_seconds: number | null;
+ readonly downloaded_bytes: number | null;
+ readonly total_bytes: number | null;
+ readonly queue_position: number;
+ readonly options: JobOptions;
+ readonly filename: string | null;
+ readonly files: readonly JobFile[];
+ readonly error: string | null;
+ readonly error_code: string | null;
+ readonly created_at: string;
+ readonly finished_at: string | null;
+ readonly expires_at: string | null;
+}
+
+export interface SessionInfo {
+ readonly auth_required: boolean;
+ readonly authenticated: boolean;
+ readonly limits: { readonly max_filesize_mb: number; readonly max_playlist_items: number };
+}
+
+export interface RuntimeSettings {
+ readonly retention_minutes: number;
+ readonly max_concurrent: number;
+}
+
+export interface StorageUsage {
+ readonly used_bytes: number;
+ readonly limit_bytes: number | null;
+ readonly free_bytes: number;
+}
+
+export interface CookieSummary {
+ readonly present: boolean;
+ readonly domains: readonly string[];
+ readonly expires_at: string | null;
+ readonly uploaded_at: string | null;
+}
+
+export interface ApiErrorBody {
+ readonly error: string;
+ readonly code: string;
+}
+```
+
+`apps/web/src/lib/api/client.ts`:
+
+```ts
+import type { CookieSummary, DownloadRequest, Job, MediaInfo, PlaylistInfo, RuntimeSettings, SessionInfo, StorageUsage } from "./types";
+
+export class ApiRequestError extends Error {
+ constructor(
+ readonly status: number,
+ readonly code: string,
+ message: string,
+ readonly retryAfterSeconds: number | null,
+ ) {
+ super(message);
+ this.name = "ApiRequestError";
+ }
+}
+
+const API_PREFIX = "/api";
+const UNREACHABLE_STATUS = 0;
+
+function jsonInit(method: string, body?: unknown): RequestInit {
+ return body === undefined ? { method } : { method, body: JSON.stringify(body), headers: { "content-type": "application/json" } };
+}
+
+async function send(path: string, init: RequestInit = {}): Promise {
+ try {
+ return await fetch(`${API_PREFIX}${path}`, { credentials: "same-origin", cache: "no-store", ...init });
+ } catch {
+ throw new ApiRequestError(UNREACHABLE_STATUS, "api_unreachable", "The OpenMedia API is not reachable.", null);
+ }
+}
+
+async function errorFrom(response: Response): Promise {
+ const body: unknown = await response.json().catch(() => null);
+ const record = typeof body === "object" && body !== null ? (body as Record) : {};
+ const retryAfter = Number(response.headers.get("retry-after"));
+ return new ApiRequestError(response.status, typeof record.code === "string" ? record.code : "unknown_error", typeof record.error === "string" ? record.error : response.statusText, Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : null);
+}
+
+async function requestJson(path: string, init?: RequestInit): Promise {
+ const response = await send(path, init);
+ if (!response.ok) throw await errorFrom(response);
+ return (await response.json()) as T;
+}
+
+async function requestVoid(path: string, init?: RequestInit): Promise {
+ const response = await send(path, init);
+ if (!response.ok) throw await errorFrom(response);
+}
+
+export const api = {
+ session: (): Promise => requestJson("/session"),
+ signIn: (password: string): Promise => requestVoid("/session", jsonInit("POST", { password })),
+ signOut: (): Promise => requestVoid("/session", { method: "DELETE" }),
+ info: (url: string): Promise => requestJson("/info", jsonInit("POST", { url })),
+ playlist: (url: string): Promise => requestJson("/playlist", jsonInit("POST", { url })),
+ download: (request: DownloadRequest): Promise<{ job_id: string; job: Job }> => requestJson("/download", jsonInit("POST", request)),
+ jobs: async (): Promise => (await requestJson<{ jobs: Job[] }>("/jobs")).jobs,
+ removeJob: (jobId: string): Promise => requestVoid(`/jobs/${encodeURIComponent(jobId)}`, { method: "DELETE" }),
+ settings: (): Promise => requestJson("/settings"),
+ updateSettings: (patch: Partial): Promise => requestJson("/settings", jsonInit("PUT", patch)),
+ storage: (): Promise => requestJson("/storage"),
+ cookies: (): Promise => requestJson("/cookies"),
+ uploadCookies: (file: File): Promise => {
+ const body = new FormData();
+ body.append("file", file);
+ return requestJson("/cookies", { method: "PUT", body });
+ },
+ removeCookies: (): Promise => requestVoid("/cookies", { method: "DELETE" }),
+ fileUrl: (jobId: string, index?: number): string => `${API_PREFIX}/file/${encodeURIComponent(jobId)}${index === undefined ? "" : `/${index}`}`,
+};
+```
+
+`apps/web/src/app/api/[...path]/proxy.ts`:
+
+```ts
+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 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));
+ return headers;
+}
+
+export async function proxyToApi(request: Request, path: readonly string[], apiBaseUrl: string, fetchImpl: typeof fetch = fetch): Promise {
+ const body = METHODS_WITHOUT_BODY.has(request.method) ? undefined : await request.arrayBuffer();
+ try {
+ const upstream = await fetchImpl(buildUpstreamUrl(request.url, path, apiBaseUrl), {
+ method: request.method,
+ headers: forwardedRequestHeaders(request),
+ body,
+ 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 });
+ }
+}
+```
+
+`apps/web/src/app/api/[...path]/route.ts`:
+
+```ts
+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;
+```
+
+The existing `apps/web/src/app/api/health/live/route.ts` keeps answering the container health check because a static segment wins over the catch-all.
+
+`apps/web/src/lib/theme.ts`:
+
+```ts
+export type ThemePreference = "system" | "light" | "dark";
+export type AccentId = "teal" | "blue" | "purple" | "pink" | "orange" | "green" | "graphite";
+
+export const DEFAULT_ACCENT: AccentId = "teal";
+
+export const ACCENTS: ReadonlyArray<{ readonly id: AccentId; readonly swatch: string }> = [
+ { id: "teal", swatch: "#12939c" },
+ { id: "blue", swatch: "#007aff" },
+ { id: "purple", swatch: "#af52de" },
+ { id: "pink", swatch: "#ff2d55" },
+ { id: "orange", swatch: "#ff9500" },
+ { id: "green", swatch: "#34c759" },
+ { id: "graphite", swatch: "#8e8e93" },
+];
+
+export const PREFERENCES_STORAGE_KEY = "openmedia.preferences";
+
+export function applyTheme(theme: ThemePreference): void {
+ const root = document.documentElement;
+ if (theme === "system") root.removeAttribute("data-theme");
+ else root.dataset.theme = theme;
+}
+
+export function applyAccent(accent: AccentId): void {
+ document.documentElement.dataset.accent = accent;
+}
+
+export const THEME_BOOTSTRAP_SCRIPT = `try{var p=JSON.parse(localStorage.getItem("${PREFERENCES_STORAGE_KEY}")||"{}");if(p.theme==="light"||p.theme==="dark"){document.documentElement.dataset.theme=p.theme}document.documentElement.dataset.accent=p.accent||"${DEFAULT_ACCENT}"}catch(e){document.documentElement.dataset.accent="${DEFAULT_ACCENT}"}`;
+```
+
+`apps/web/src/lib/i18n/I18nProvider.tsx`:
+
+```tsx
+"use client";
+
+import { createContext, useContext, useMemo, type ReactNode } from "react";
+import type { Locale } from "../format";
+import { en, type Messages } from "./en";
+import { vi } from "./vi";
+
+export type LanguagePreference = "auto" | Locale;
+
+const DICTIONARIES: Record = { en, vi };
+
+export function resolveLocale(preference: LanguagePreference, navigatorLanguage: string): Locale {
+ if (preference !== "auto") return preference;
+ return navigatorLanguage.toLowerCase().startsWith("vi") ? "vi" : "en";
+}
+
+interface I18nValue {
+ readonly locale: Locale;
+ readonly t: Messages;
+}
+
+const I18nContext = createContext({ locale: "en", t: en });
+
+export function I18nProvider({ locale, children }: { locale: Locale; children: ReactNode }): ReactNode {
+ const value = useMemo(() => ({ locale, t: DICTIONARIES[locale] }), [locale]);
+ return {children} ;
+}
+
+export function useI18n(): I18nValue {
+ return useContext(I18nContext);
+}
+```
+
+The language preference itself lives in the preferences store (Task 9); the provider only receives the resolved locale.
+
+`apps/web/src/lib/i18n/en.ts`:
+
+```ts
+export const en = {
+ app: { name: "OpenMedia", sampleNote: "Sample data" },
+ nav: { queue: "Queue", downloading: "Downloading", done: "Done", attention: "Needs attention", history: "History", settings: "Settings", other: "More", downloads: "Downloads", showSidebar: "Show sidebar", toggleTheme: "Switch light or dark", shortcuts: "Keyboard shortcuts" },
+ importer: {
+ label: "Links to download",
+ placeholder: "Paste a YouTube, TikTok or SoundCloud link...",
+ paste: "Paste",
+ fetch: "Get info",
+ hint: "Enter gets info, Shift+Enter adds a line, or drop links anywhere",
+ pasteFallback: "Press Cmd+V or Ctrl+V to paste",
+ noLinks: "No links recognized",
+ playlistPrompt: "This link belongs to a playlist.",
+ playlistSingle: "Only this video",
+ playlistAll: (count: number): string => `Whole playlist (up to ${count} videos)`,
+ dropTitle: "Drop links to add them to the queue",
+ installHint: "Install OpenMedia on your home screen to share links straight from other apps.",
+ dismissInstallHint: "Hide install tip",
+ },
+ queue: {
+ summary: (total: number, active: number, done: number): string => `${total} items, ${active} downloading, ${done} done`,
+ startAll: (count: number): string => `Download all (${count})`,
+ concurrency: (count: number): string => `Up to ${count} downloads at once`,
+ empty: "Nothing here yet. Paste a link to start.",
+ download: "Download",
+ save: "Save",
+ fix: "Fix",
+ retry: "Try again",
+ cancel: (title: string): string => `Cancel ${title}`,
+ removeQueued: "Remove from queue",
+ remove: "Remove",
+ fetching: "Getting info",
+ queued: (position: number): string => `Waiting, position ${position}`,
+ processing: "Finishing up",
+ doneLine: (format: string, size: string, expiry: string): string => `${format}, ${size}. ${expiry}`,
+ downloadingLine: (percent: number, speed: string, remaining: string): string => `${percent}% · ${speed}, ${remaining}`,
+ cancelled: "Cancelled",
+ listLabel: "Download queue",
+ },
+ time: {
+ secondsLeft: (seconds: number): string => `${seconds} s left`,
+ minutesLeft: (minutes: number, seconds: number): string => (seconds === 0 ? `${minutes} min left` : `${minutes} min ${seconds} s left`),
+ expiresIn: (minutes: number): string => (minutes >= 60 ? `Deleted in ${Math.round(minutes / 60)} h` : `Deleted in ${minutes} min`),
+ retention: { 15: "15 minutes", 60: "1 hour", 360: "6 hours", 1440: "24 hours" },
+ },
+ inspector: {
+ title: "Details",
+ done: "Done",
+ empty: "Select an item to see its details.",
+ kind: "Type",
+ video: "Video",
+ audio: "Audio",
+ format: "Format",
+ quality: "Quality",
+ qualityBest: "Best available",
+ audioOriginal: "Original",
+ audioLossless: "Original, lossless",
+ trim: "Trim",
+ trimStart: "Start",
+ trimEnd: "End",
+ trimLength: (length: string): string => `Length ${length}`,
+ trimHelp: "Drag the yellow handles, or select a handle and use the arrow keys.",
+ trimStartHandle: "Start point",
+ trimEndHandle: "End point",
+ subtitles: "Subtitles",
+ subtitlesOff: "Off",
+ subtitlesVietnamese: "Vietnamese",
+ subtitlesEnglish: "English",
+ subtitleEmbed: "Embed in video",
+ subtitleFile: "Separate .srt file",
+ embedMetadata: "Embed cover and details",
+ estimate: "Estimated",
+ selection: "Selected part",
+ downloadAll: "Download",
+ downloadSelection: "Download selection",
+ downloadingTitle: (label: string): string => `Downloading ${label}`,
+ keepsRunning: "You can close this page; the server keeps downloading.",
+ queuedTitle: (position: number): string => `Waiting, position ${position}`,
+ queuedHelp: "Starts when a download slot is free.",
+ doneTitle: "Download complete",
+ saveToDevice: "Save to device",
+ subtitleFileName: (name: string): string => `Save ${name}`,
+ errorTitle: "Could not download",
+ addCookies: "Add cookies to continue",
+ cookiesReady: "Cookies for this site are loaded",
+ processingTitle: "Finishing up",
+ processingHelp: "Merging streams and embedding details.",
+ artworkAlt: (title: string): string => `Cover of ${title}`,
+ },
+ history: {
+ title: "History",
+ note: "Stored in this browser",
+ clear: "Clear history",
+ clearTitle: "Clear history?",
+ clearMessage: "The list in this browser will be removed. Files on the server are not affected.",
+ clearConfirm: "Clear",
+ cancel: "Cancel",
+ again: "Download again",
+ empty: "History is empty.",
+ },
+ settings: {
+ title: "Settings",
+ done: "Done",
+ cookies: "Cookies",
+ cookiesNone: "No cookies yet",
+ cookiesLoaded: (domains: string, days: number): string => `Loaded for ${domains}, expires in ${days} days`,
+ cookiesChoose: "Choose cookies.txt",
+ cookiesRemove: "Remove",
+ cookiesNote: "Used for age-restricted videos or when YouTube asks to confirm you are not a bot.",
+ downloads: "Downloads",
+ retention: "Keep files on the server",
+ concurrency: "Simultaneous downloads",
+ decrease: "Fewer downloads",
+ increase: "More downloads",
+ defaultFormat: "Default",
+ defaultFormats: { "video-mp4-1080": "Video MP4 1080p", "video-mp4-720": "Video MP4 720p", "audio-m4a": "Audio M4A", "audio-mp3": "Audio MP3 320 kbps" },
+ appearance: "Appearance",
+ theme: "Theme",
+ themeSystem: "System",
+ themeLight: "Light",
+ themeDark: "Dark",
+ accent: "Accent color",
+ accents: { teal: "Teal", blue: "Blue", purple: "Purple", pink: "Pink", orange: "Orange", green: "Green", graphite: "Graphite" },
+ language: "Language",
+ languages: { auto: "Automatic", vi: "Tiếng Việt", en: "English" },
+ access: "Access",
+ passwordOn: "Password protection is on",
+ passwordOff: "Password protection is off",
+ passwordNote: "Set OPENMEDIA_PASSWORD on the server to turn it on.",
+ signOut: "Sign out",
+ storage: "Storage",
+ storageUsed: (used: string, total: string): string => `${used} used of ${total}`,
+ storageUsedUnlimited: (used: string, free: string): string => `${used} used, ${free} free`,
+ },
+ shortcuts: { title: "Keyboard shortcuts", focus: "Enter a link", pasteFetch: "Paste and get info", close: "Close panel", show: "Show shortcuts", dismiss: "Close" },
+ auth: { title: "Sign in to OpenMedia", password: "Password", submit: "Sign in", wrong: "The password is not correct." },
+ island: {
+ fetched: "Info ready",
+ playlistAdded: (count: number): string => `Added ${count} videos from the playlist`,
+ downloaded: (title: string): string => `Downloaded: ${title}`,
+ cancelled: "Download cancelled",
+ removedFromQueue: "Removed from queue",
+ addedAgain: "Added back to the queue",
+ cookiesLoaded: "Cookies loaded",
+ cookiesRemoved: "Cookies removed",
+ settingsSaved: "Settings saved",
+ },
+ errors: {
+ invalid_url: "That does not look like a link. Paste an address that starts with http:// or https://.",
+ unsupported_url: "This site is not supported.",
+ private_network: "Links to private or local network addresses are blocked on this server.",
+ invalid_option: "One of the download options is not valid.",
+ not_found: "That item no longer exists on the server.",
+ file_not_ready: "The file is not ready yet.",
+ rate_limited: "Too many requests. Wait a moment and try again.",
+ auth_required: "Sign in to continue.",
+ invalid_password: "The password is not correct.",
+ cross_site_request: "The request was blocked because it came from another website.",
+ storage_full: "Server storage is full. Remove finished downloads first.",
+ too_large: "The file is larger than this server allows.",
+ bot_check: "The site asked to confirm you are not a bot. Add cookies in Settings.",
+ private_video: "This video is private.",
+ geo_blocked: "This video is not available in the server's region.",
+ unavailable: "This video is unavailable.",
+ timeout: "The site took too long to respond. Try again.",
+ extractor_error: "The site could not be read. Try again later.",
+ invalid_cookies: "That file is not a cookies.txt file in Netscape format.",
+ api_unreachable: "The OpenMedia server is not reachable.",
+ unknown_error: "Something went wrong. Try again.",
+ },
+};
+
+export type Messages = typeof en;
+```
+
+`apps/web/src/lib/i18n/vi.ts` exports `export const vi: Messages = { ... }` with the same keys in natural Vietnamese (use the prototype copy for wording, for example "Hàng đợi", "Dán liên kết YouTube, TikTok, SoundCloud...", "Lấy thông tin", "Tải tất cả (n)", "Tối đa n lượt tải cùng lúc", "Kéo hai tay nắm vàng, hoặc chọn một tay nắm rồi dùng phím mũi tên.", "Dùng cho video giới hạn tuổi hoặc khi YouTube yêu cầu xác minh.", "Đặt biến OPENMEDIA_PASSWORD để bật."). Every function keeps the English parameter list; numbers are formatted by the caller. No en or em dash characters.
+
+- [ ] **Step 6: Port the design tokens and base styles**
+
+Replace `apps/web/src/app/globals.css` with the concatenation of the prototype's `tokens.css` and `base.css`, with these changes:
+
+- Keep `@import "tailwindcss";` as the first line.
+- Change the bare `:root` accent block to the teal defaults (`--accent-l: #12939c; --accent-d: #3fbac2; --accent-text-l: #0b7178; --accent-text-d: #5cc9d0;`), add a `:root[data-accent="blue"]` rule with the prototype's blue values, and delete the teal rule that duplicates the defaults.
+- Set `--font-text` to `-apple-system, BlinkMacSystemFont, "SF Pro Text", var(--font-inter), "Segoe UI Variable Text", system-ui, sans-serif`, `--font-display` to the same with `"SF Pro Display"`, `--font-mono` to `ui-monospace, "SF Mono", var(--font-geist-mono), Menlo, Consolas, monospace`, and add `--font-brand: var(--font-geist-sans), var(--font-text)`.
+- Remove the `body { overflow: hidden; }` line from `base.css` only for widths below 768 px (phones scroll the document); keep it for 768 px and up.
+- Delete the `.brand-mark`, `.capsule`, `.icon-button`, `kbd`, `.sample-tag`, `.glass` rules from the global file; those move into component modules in Task 10. Keep resets, tokens, keyframes, `:focus-visible`, `.visually-hidden` and the reduced-motion block.
+
+- [ ] **Step 7: Replace the layout and page**
+
+`apps/web/src/app/layout.tsx`:
+
+```tsx
+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", "latin-ext"] });
+const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin", "latin-ext"] });
+const inter = Inter({ variable: "--font-inter", subsets: ["latin", "latin-ext", "vietnamese"] });
+
+export const metadata: Metadata = {
+ 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 }: { children: ReactNode }): ReactNode {
+ return (
+
+
+
+
+ {children}
+
+ );
+}
+```
+
+`apps/web/src/app/page.tsx` for this task renders a minimal branded placeholder that Task 10 replaces with the application shell:
+
+```tsx
+import type { ReactNode } from "react";
+
+export default function Home(): ReactNode {
+ return ;
+}
+```
+
+`apps/web/.env.example`:
+
+```dotenv
+API_URL=http://localhost:8081
+```
+
+Append to `apps/web/mise.toml`:
+
+```toml
+[tasks.dev]
+env = { API_URL = "http://localhost:8081" }
+run = "pnpm exec next dev --port 3000"
+```
+
+Delete the generator's sample assets listed under **Files**.
+
+- [ ] **Step 8: Run the web checks**
+
+Run from the project root: `mise run //apps/web:ci-unit`
+Expected: format, lint, typecheck and all tests pass. Run `mise run //apps/web:format-fix` first when only formatting fails.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add apps/web
+git commit -m "feat(web): add the API proxy, typed client, i18n dictionaries and design tokens"
+```
+
+### Task 9: Client state, persistence, commands and polling
+
+**Files:**
+
+- Create: `apps/web/src/state/types.ts`, `apps/web/src/state/options.ts`, `apps/web/src/state/options.test.ts`, `apps/web/src/state/reducer.ts`, `apps/web/src/state/reducer.test.ts`, `apps/web/src/lib/preferences.ts`, `apps/web/src/lib/preferences.test.ts`, `apps/web/src/state/commands.ts`, `apps/web/src/state/commands.test.ts`, `apps/web/src/state/useJobPolling.ts`, `apps/web/src/state/StoreProvider.tsx`
+
+**Interfaces:**
+
+- Consumes: `api`, `ApiRequestError`, types from `lib/api/types.ts`, `PlatformId`, `detectPlatform`, `ThemePreference`, `AccentId`, `LanguagePreference`.
+- Produces:
+ - `types.ts`: `Filter = "all" | "active" | "done" | "error"`, `View = "queue" | "history"`, `DefaultFormatId = "video-mp4-1080" | "video-mp4-720" | "audio-m4a" | "audio-mp3"`, `DraftOptions`, `MediaSnapshot`, `QueueItem` (union `FetchingItem | FetchErrorItem | ReadyItem | JobItem`), `HistoryEntry`, `Preferences`, `Notice`, `AppState`, `Action`
+ - `options.ts`: `defaultDraft(defaultFormat, formats) -> DraftOptions`, `toDownloadRequest(item: ReadyItem) -> DownloadRequest`, `estimateBytes(options, formats, duration) -> number | null`, `isTrimmed(options, duration) -> boolean`, `AUDIO_BITRATES_KBPS`
+ - `reducer.ts`: `initialState(preferences?) -> AppState`, `reducer(state, action) -> AppState`, `visibleItems(state) -> QueueItem[]`, `countItems(state) -> { all, active, done, error }`, `selectedItem(state) -> QueueItem | null`
+ - `preferences.ts`: `DEFAULT_PREFERENCES`, `loadPersisted() -> { preferences, items, history }`, `savePreferences(p)`, `saveItems(items)`, `saveHistory(entries)`
+ - `commands.ts`: `createCommands(dispatch, getState) -> Commands` with `fetchLinks(urls, scope)`, `startDownload(itemId)`, `startAllReady()`, `cancelJob(jobId)`, `retryJob(jobId)`, `retryFetch(itemId)`, `removeItem(itemId)`, `downloadAgain(entryId)`, `syncJobs()`, `loadServerState()`, `saveSettings(patch)`, `uploadCookies(file)`, `removeCookies()`, `signIn(password)`, `signOut()`, `notify(notice)`
+ - `useJobPolling(sync, hasActiveJobs, enabled) -> void`
+ - `StoreProvider({ children })`, `useStore() -> { state, dispatch, commands }`
+
+- [ ] **Step 1: Define the state types**
+
+`apps/web/src/state/types.ts`:
+
+```ts
+import type { AudioFormat, AudioQuality, Container, CookieSummary, DownloadKind, Job, MediaFormat, MediaInfo, RuntimeSettings, SessionInfo, StorageUsage, SubtitleMode, TrimRange } from "@/lib/api/types";
+import type { LanguagePreference } from "@/lib/i18n/I18nProvider";
+import type { PlatformId } from "@/lib/links";
+import type { AccentId, ThemePreference } from "@/lib/theme";
+
+export type Filter = "all" | "active" | "done" | "error";
+export type View = "queue" | "history";
+export type DefaultFormatId = "video-mp4-1080" | "video-mp4-720" | "audio-m4a" | "audio-mp3";
+export type PlaylistScope = "single" | "playlist";
+
+export interface DraftOptions {
+ readonly kind: DownloadKind;
+ readonly container: Container;
+ readonly qualityHeight: number | null;
+ readonly audioFormat: AudioFormat;
+ readonly audioQuality: AudioQuality;
+ readonly trim: TrimRange | null;
+ readonly subtitleLanguages: readonly string[];
+ readonly subtitleMode: SubtitleMode;
+ readonly embedMetadata: boolean;
+}
+
+export interface MediaSnapshot {
+ readonly url: string;
+ readonly title: string;
+ readonly thumbnail: string;
+ readonly duration: number | null;
+ readonly uploader: string;
+ readonly platform: PlatformId;
+}
+
+export interface FetchingItem {
+ readonly type: "fetching";
+ readonly id: string;
+ readonly url: string;
+}
+
+export interface FetchErrorItem {
+ readonly type: "fetch-error";
+ readonly id: string;
+ readonly url: string;
+ readonly code: string;
+}
+
+export interface ReadyItem {
+ readonly type: "ready";
+ readonly id: string;
+ readonly media: MediaSnapshot;
+ readonly formats: readonly MediaFormat[];
+ readonly options: DraftOptions;
+}
+
+export interface JobItem {
+ readonly type: "job";
+ readonly id: string;
+ readonly media: MediaSnapshot;
+ readonly formats: readonly MediaFormat[];
+ readonly options: DraftOptions;
+ readonly job: Job;
+}
+
+export type QueueItem = FetchingItem | FetchErrorItem | ReadyItem | JobItem;
+
+export interface HistoryEntry {
+ readonly id: string;
+ readonly url: string;
+ readonly title: string;
+ readonly kind: DownloadKind;
+ readonly label: string;
+ readonly sizeBytes: number;
+ readonly finishedAt: string;
+}
+
+export interface Preferences {
+ readonly theme: ThemePreference;
+ readonly accent: AccentId;
+ readonly language: LanguagePreference;
+ readonly defaultFormat: DefaultFormatId;
+ readonly installHintDismissed: boolean;
+}
+
+export interface Notice {
+ readonly id: number;
+ readonly tone: "success" | "info" | "error";
+ readonly message: string;
+ readonly detail?: string;
+ readonly count?: number;
+}
+
+export interface AppState {
+ readonly items: readonly QueueItem[];
+ readonly selectedId: string | null;
+ readonly view: View;
+ readonly filter: Filter;
+ readonly history: readonly HistoryEntry[];
+ readonly session: SessionInfo | null;
+ readonly settings: RuntimeSettings | null;
+ readonly storage: StorageUsage | null;
+ readonly cookies: CookieSummary | null;
+ readonly preferences: Preferences;
+ readonly notice: Notice | null;
+}
+
+export type Action = { readonly type: "fetch/started"; readonly id: string; readonly url: string } | { readonly type: "fetch/succeeded"; readonly id: string; readonly url: string; readonly info: MediaInfo } | { readonly type: "fetch/failed"; readonly id: string; readonly code: string } | { readonly type: "item/selected"; readonly id: string | null } | { readonly type: "item/optionsChanged"; readonly id: string; readonly patch: Partial } | { readonly type: "item/removed"; readonly id: string } | { readonly type: "download/started"; readonly itemId: string; readonly job: Job } | { readonly type: "job/cancelled"; readonly jobId: string } | { readonly type: "jobs/synced"; readonly jobs: readonly Job[] } | { readonly type: "history/cleared" } | { readonly type: "view/changed"; readonly view: View; readonly filter?: Filter } | { readonly type: "session/loaded"; readonly session: SessionInfo } | { readonly type: "settings/loaded"; readonly settings: RuntimeSettings } | { readonly type: "storage/loaded"; readonly storage: StorageUsage } | { readonly type: "cookies/loaded"; readonly cookies: CookieSummary } | { readonly type: "preferences/changed"; readonly patch: Partial } | { readonly type: "notice/shown"; readonly notice: Notice } | { readonly type: "notice/dismissed"; readonly id: number };
+```
+
+- [ ] **Step 2: Write the failing option tests**
+
+`apps/web/src/state/options.test.ts`:
+
+```ts
+import { describe, expect, it } from "vitest";
+import type { MediaFormat } from "@/lib/api/types";
+import { defaultDraft, estimateBytes, isTrimmed, toDownloadRequest } from "./options";
+import type { ReadyItem } from "./types";
+
+const FORMATS: MediaFormat[] = [
+ { id: "313", label: "2160p", height: 2160, ext: "webm", filesize: 1_600_000_000 },
+ { id: "137", label: "1080p", height: 1080, ext: "mp4", filesize: 412_000_000 },
+ { id: "136", label: "720p", height: 720, ext: "mp4", filesize: null },
+];
+
+function readyItem(overrides: Partial = {}): ReadyItem {
+ return {
+ type: "ready",
+ id: "r1",
+ media: { url: "https://youtu.be/a", title: "Pho", thumbnail: "", duration: 1122, uploader: "Bep", platform: "youtube" },
+ formats: FORMATS,
+ options: { ...defaultDraft("video-mp4-1080", FORMATS), ...overrides },
+ };
+}
+
+describe("options", () => {
+ it("picks the best height at or below the default", () => {
+ expect(defaultDraft("video-mp4-1080", FORMATS).qualityHeight).toBe(1080);
+ expect(defaultDraft("video-mp4-720", FORMATS).qualityHeight).toBe(720);
+ expect(defaultDraft("video-mp4-1080", []).qualityHeight).toBeNull();
+ expect(defaultDraft("audio-mp3", FORMATS)).toMatchObject({ kind: "audio", audioFormat: "mp3", audioQuality: "320k" });
+ });
+
+ it("builds a minimal video request", () => {
+ expect(toDownloadRequest(readyItem())).toEqual({
+ url: "https://youtu.be/a",
+ title: "Pho",
+ format: "video",
+ container: "mp4",
+ format_id: "137",
+ quality_height: 1080,
+ embed_metadata: true,
+ });
+ });
+
+ it("includes trim and subtitles only when used", () => {
+ const request = toDownloadRequest(readyItem({ trim: { start: 5, end: 65 }, subtitleLanguages: ["vi"], subtitleMode: "srt" }));
+ expect(request.trim).toEqual({ start: 5, end: 65 });
+ expect(request.subtitles).toEqual({ languages: ["vi"], mode: "srt" });
+ expect(toDownloadRequest(readyItem({ trim: { start: 0, end: 1122 } })).trim).toBeUndefined();
+ });
+
+ it("builds an audio request", () => {
+ expect(toDownloadRequest(readyItem({ kind: "audio", audioFormat: "flac", audioQuality: "best" }))).toEqual({
+ url: "https://youtu.be/a",
+ title: "Pho",
+ format: "audio",
+ audio_format: "flac",
+ audio_quality: "best",
+ embed_metadata: true,
+ });
+ });
+
+ it("estimates sizes from formats, trim and bitrates", () => {
+ const item = readyItem();
+ expect(estimateBytes(item.options, FORMATS, 1122)).toBe(412_000_000);
+ expect(estimateBytes({ ...item.options, trim: { start: 0, end: 561 } }, FORMATS, 1122)).toBe(206_000_000);
+ expect(estimateBytes({ ...item.options, kind: "audio", audioFormat: "mp3", audioQuality: "320k" }, FORMATS, 60)).toBe(2_400_000);
+ expect(estimateBytes({ ...item.options, qualityHeight: 720 }, FORMATS, null)).toBeNull();
+ });
+
+ it("knows when a range is trimmed", () => {
+ expect(isTrimmed({ ...readyItem().options, trim: { start: 0, end: 1122 } }, 1122)).toBe(false);
+ expect(isTrimmed({ ...readyItem().options, trim: { start: 3, end: 1122 } }, 1122)).toBe(true);
+ });
+});
+```
+
+- [ ] **Step 3: Implement options**
+
+`apps/web/src/state/options.ts`:
+
+```ts
+import type { AudioFormat, AudioQuality, DownloadRequest, MediaFormat } from "@/lib/api/types";
+import type { DefaultFormatId, DraftOptions, ReadyItem } from "./types";
+
+const BITS_PER_BYTE = 8;
+const BITS_PER_KILOBIT = 1000;
+
+export const AUDIO_BITRATES_KBPS: Record> = {
+ mp3: { "320k": 320, best: 245 },
+ m4a: { "320k": 320, best: 160 },
+ opus: { "320k": 320, best: 128 },
+ flac: { "320k": 900, best: 900 },
+ wav: { "320k": 1411, best: 1411 },
+};
+
+const DEFAULTS: Record & { maxHeight: number }> = {
+ "video-mp4-1080": { kind: "video", audioFormat: "m4a", audioQuality: "best", maxHeight: 1080 },
+ "video-mp4-720": { kind: "video", audioFormat: "m4a", audioQuality: "best", maxHeight: 720 },
+ "audio-m4a": { kind: "audio", audioFormat: "m4a", audioQuality: "best", maxHeight: 1080 },
+ "audio-mp3": { kind: "audio", audioFormat: "mp3", audioQuality: "320k", maxHeight: 1080 },
+};
+
+function bestHeightAtMost(formats: readonly MediaFormat[], maxHeight: number): number | null {
+ const heights = formats.map((format) => format.height).filter((height) => height <= maxHeight);
+ return heights.length > 0 ? Math.max(...heights) : (formats[formats.length - 1]?.height ?? null);
+}
+
+export function defaultDraft(defaultFormat: DefaultFormatId, formats: readonly MediaFormat[]): DraftOptions {
+ const preset = DEFAULTS[defaultFormat];
+ return {
+ kind: preset.kind,
+ container: "mp4",
+ qualityHeight: bestHeightAtMost(formats, preset.maxHeight),
+ audioFormat: preset.audioFormat,
+ audioQuality: preset.audioQuality,
+ trim: null,
+ subtitleLanguages: [],
+ subtitleMode: "embed",
+ embedMetadata: true,
+ };
+}
+
+export function isTrimmed(options: DraftOptions, duration: number | null): boolean {
+ if (options.trim === null || duration === null) return false;
+ return options.trim.start > 0 || options.trim.end < duration;
+}
+
+function videoFields(item: ReadyItem): Partial {
+ const { options, formats } = item;
+ const format = formats.find((candidate) => candidate.height === options.qualityHeight);
+ return {
+ container: options.container,
+ ...(format ? { format_id: format.id } : {}),
+ ...(options.qualityHeight !== null ? { quality_height: options.qualityHeight } : {}),
+ ...(options.subtitleLanguages.length > 0 ? { subtitles: { languages: options.subtitleLanguages, mode: options.subtitleMode } } : {}),
+ };
+}
+
+export function toDownloadRequest(item: ReadyItem): DownloadRequest {
+ const { options, media } = item;
+ const kindFields = options.kind === "video" ? videoFields(item) : { audio_format: options.audioFormat, audio_quality: options.audioQuality };
+ return {
+ url: media.url,
+ title: media.title,
+ format: options.kind,
+ ...kindFields,
+ ...(isTrimmed(options, media.duration) && options.trim ? { trim: options.trim } : {}),
+ embed_metadata: options.embedMetadata,
+ };
+}
+
+function selectedSeconds(options: DraftOptions, duration: number): number {
+ return options.trim ? Math.max(options.trim.end - options.trim.start, 0) : duration;
+}
+
+export function estimateBytes(options: DraftOptions, formats: readonly MediaFormat[], duration: number | null): number | null {
+ if (duration === null || duration <= 0) return null;
+ const seconds = selectedSeconds(options, duration);
+ if (options.kind === "audio") {
+ const kbps = AUDIO_BITRATES_KBPS[options.audioFormat][options.audioQuality];
+ return Math.round((kbps * BITS_PER_KILOBIT * seconds) / BITS_PER_BYTE);
+ }
+ const format = formats.find((candidate) => candidate.height === options.qualityHeight);
+ return format?.filesize ? Math.round((format.filesize * seconds) / duration) : null;
+}
+```
+
+- [ ] **Step 4: Write the failing reducer tests**
+
+`apps/web/src/state/reducer.test.ts`:
+
+```ts
+import { describe, expect, it } from "vitest";
+import type { Job, MediaInfo } from "@/lib/api/types";
+import { countItems, initialState, reducer, visibleItems } from "./reducer";
+import type { AppState } from "./types";
+
+const INFO: MediaInfo = {
+ id: "a",
+ title: "Pho",
+ thumbnail: "https://i.ytimg.com/a.jpg",
+ duration: 1122,
+ uploader: "Bep",
+ platform: "Youtube",
+ webpage_url: "https://youtu.be/a",
+ formats: [{ id: "137", label: "1080p", height: 1080, ext: "mp4", filesize: 412_000_000 }],
+ subtitle_languages: [],
+ has_chapters: false,
+};
+
+function job(overrides: Partial = {}): Job {
+ return {
+ job_id: "j1",
+ url: "https://youtu.be/a",
+ title: "Pho",
+ status: "downloading",
+ progress: 40,
+ speed_bps: 1,
+ eta_seconds: 9,
+ downloaded_bytes: 1,
+ total_bytes: 2,
+ queue_position: 0,
+ options: { kind: "video", container: "mp4", quality_height: 1080, format_id: "137", audio_format: null, audio_quality: null, trim: null, subtitles: null, embed_metadata: true },
+ filename: null,
+ files: [],
+ error: null,
+ error_code: null,
+ created_at: "2026-09-14T08:00:00Z",
+ finished_at: null,
+ expires_at: null,
+ ...overrides,
+ };
+}
+
+function withReadyItem(): AppState {
+ const fetching = reducer(initialState(), { type: "fetch/started", id: "r1", url: "https://youtu.be/a" });
+ return reducer(fetching, { type: "fetch/succeeded", id: "r1", url: "https://youtu.be/a", info: INFO });
+}
+
+describe("reducer", () => {
+ it("turns a fetched link into a selected ready item with default options", () => {
+ const state = withReadyItem();
+ expect(state.items[0]).toMatchObject({ type: "ready", id: "r1", media: { title: "Pho", platform: "youtube" }, options: { qualityHeight: 1080 } });
+ expect(state.selectedId).toBe("r1");
+ });
+
+ it("records fetch failures", () => {
+ const state = reducer(reducer(initialState(), { type: "fetch/started", id: "x", url: "https://x.y/z" }), { type: "fetch/failed", id: "x", code: "unsupported_url" });
+ expect(state.items[0]).toEqual({ type: "fetch-error", id: "x", url: "https://x.y/z", code: "unsupported_url" });
+ });
+
+ it("changes options on a ready item", () => {
+ const state = reducer(withReadyItem(), { type: "item/optionsChanged", id: "r1", patch: { kind: "audio" } });
+ expect(state.items[0]).toMatchObject({ options: { kind: "audio" } });
+ });
+
+ it("links a started download and follows server updates into history", () => {
+ const started = reducer(withReadyItem(), { type: "download/started", itemId: "r1", job: job() });
+ expect(started.items[0]).toMatchObject({ type: "job", id: "j1" });
+ expect(started.selectedId).toBe("j1");
+ const done = reducer(started, {
+ type: "jobs/synced",
+ jobs: [job({ status: "done", progress: 100, files: [{ index: 0, name: "Pho.mp4", kind: "media", size_bytes: 412 }], finished_at: "2026-09-14T08:05:00Z" })],
+ });
+ expect(done.items[0]).toMatchObject({ job: { status: "done" } });
+ expect(done.history).toEqual([{ id: "j1", url: "https://youtu.be/a", title: "Pho", kind: "video", label: "MP4 1080p", sizeBytes: 412, finishedAt: "2026-09-14T08:05:00Z" }]);
+ const again = reducer(done, { type: "jobs/synced", jobs: [job({ status: "done", finished_at: "2026-09-14T08:05:00Z" })] });
+ expect(again.history).toHaveLength(1);
+ });
+
+ it("adopts server jobs it did not start and drops jobs the server forgot", () => {
+ const adopted = reducer(initialState(), { type: "jobs/synced", jobs: [job({ job_id: "remote", status: "queued" })] });
+ expect(adopted.items[0]).toMatchObject({ type: "job", id: "remote", media: { title: "Pho", platform: "youtube" } });
+ expect(reducer(adopted, { type: "jobs/synced", jobs: [] }).items).toEqual([]);
+ });
+
+ it("ignores cancelled jobs from the server", () => {
+ expect(reducer(initialState(), { type: "jobs/synced", jobs: [job({ status: "cancelled" })] }).items).toEqual([]);
+ });
+
+ it("returns a cancelled job to a ready item", () => {
+ const started = reducer(withReadyItem(), { type: "download/started", itemId: "r1", job: job() });
+ const cancelled = reducer(started, { type: "job/cancelled", jobId: "j1" });
+ expect(cancelled.items[0]).toMatchObject({ type: "ready", id: "j1", options: { qualityHeight: 1080 } });
+ });
+
+ it("filters and counts", () => {
+ const started = reducer(withReadyItem(), { type: "download/started", itemId: "r1", job: job() });
+ const withError = reducer(started, { type: "fetch/started", id: "e", url: "https://x.y" });
+ const failed = reducer(withError, { type: "fetch/failed", id: "e", code: "unavailable" });
+ expect(countItems(failed)).toEqual({ all: 2, active: 1, done: 0, error: 1 });
+ const filtered = reducer(failed, { type: "view/changed", view: "queue", filter: "error" });
+ expect(visibleItems(filtered).map((item) => item.id)).toEqual(["e"]);
+ });
+
+ it("clears history and shows notices", () => {
+ const noticed = reducer(initialState(), { type: "notice/shown", notice: { id: 7, tone: "info", message: "hi" } });
+ expect(noticed.notice?.id).toBe(7);
+ expect(reducer(noticed, { type: "notice/dismissed", id: 7 }).notice).toBeNull();
+ });
+});
+```
+
+- [ ] **Step 5: Implement the reducer**
+
+`apps/web/src/state/reducer.ts`:
+
+```ts
+import type { Job, MediaInfo } from "@/lib/api/types";
+import { detectPlatform } from "@/lib/links";
+import { defaultDraft } from "./options";
+import type { Action, AppState, DraftOptions, Filter, HistoryEntry, JobItem, MediaSnapshot, Preferences, QueueItem } from "./types";
+
+export const MAX_HISTORY_ENTRIES = 200;
+
+export const DEFAULT_PREFERENCES: Preferences = {
+ theme: "system",
+ accent: "teal",
+ language: "auto",
+ defaultFormat: "video-mp4-1080",
+ installHintDismissed: false,
+};
+
+const ACTIVE_STATUSES = new Set(["queued", "downloading", "processing"]);
+
+export function initialState(preferences: Preferences = DEFAULT_PREFERENCES): AppState {
+ return { items: [], selectedId: null, view: "queue", filter: "all", history: [], session: null, settings: null, storage: null, cookies: null, preferences, notice: null };
+}
+
+function snapshotFromInfo(url: string, info: MediaInfo): MediaSnapshot {
+ return { url, title: info.title || url, thumbnail: info.thumbnail, duration: info.duration, uploader: info.uploader, platform: detectPlatform(url) };
+}
+
+function snapshotFromJob(job: Job): MediaSnapshot {
+ return { url: job.url, title: job.title || job.url, thumbnail: "", duration: null, uploader: "", platform: detectPlatform(job.url) };
+}
+
+function draftFromJob(job: Job): DraftOptions {
+ const { options } = job;
+ return {
+ kind: options.kind,
+ container: options.container,
+ qualityHeight: options.quality_height,
+ audioFormat: options.audio_format ?? "m4a",
+ audioQuality: options.audio_quality ?? "best",
+ trim: options.trim,
+ subtitleLanguages: options.subtitles?.languages ?? [],
+ subtitleMode: options.subtitles?.mode ?? "embed",
+ embedMetadata: options.embed_metadata,
+ };
+}
+
+function jobLabel(job: Job): string {
+ const { options } = job;
+ if (options.kind === "audio") return (options.audio_format ?? "mp3").toUpperCase();
+ return `${options.container.toUpperCase()}${options.quality_height ? ` ${options.quality_height}p` : ""}`;
+}
+
+function historyEntry(job: Job): HistoryEntry {
+ return {
+ id: job.job_id,
+ url: job.url,
+ title: job.title || job.url,
+ kind: job.options.kind,
+ label: jobLabel(job),
+ sizeBytes: job.files[0]?.size_bytes ?? 0,
+ finishedAt: job.finished_at ?? job.created_at,
+ };
+}
+
+function replaceItem(items: readonly QueueItem[], id: string, next: QueueItem): QueueItem[] {
+ return items.map((item) => (item.id === id ? next : item));
+}
+
+function mergeJob(existing: JobItem | undefined, job: Job): JobItem {
+ if (existing) return { ...existing, job };
+ return { type: "job", id: job.job_id, media: snapshotFromJob(job), formats: [], options: draftFromJob(job), job };
+}
+
+function syncJobs(state: AppState, jobs: readonly Job[]): AppState {
+ const liveJobs = jobs.filter((job) => job.status !== "cancelled");
+ const jobIds = new Set(liveJobs.map((job) => job.job_id));
+ const existingJobs = new Map(state.items.filter((item): item is JobItem => item.type === "job").map((item) => [item.id, item]));
+ const kept = state.items.filter((item) => item.type !== "job" || jobIds.has(item.id));
+ const known = new Set(kept.map((item) => item.id));
+ const adopted = liveJobs.filter((job) => !known.has(job.job_id)).map((job) => mergeJob(undefined, job));
+ const liveById = new Map(liveJobs.map((job) => [job.job_id, job]));
+ const merged = kept.map((item) => {
+ const live = item.type === "job" ? liveById.get(item.id) : undefined;
+ return item.type === "job" && live ? mergeJob(existingJobs.get(item.id), live) : item;
+ });
+ const newlyDone = liveJobs.filter((job) => job.status === "done" && !state.history.some((entry) => entry.id === job.job_id));
+ const history = [...newlyDone.map(historyEntry), ...state.history].slice(0, MAX_HISTORY_ENTRIES);
+ return { ...state, items: [...adopted, ...merged], history };
+}
+
+function startDownload(state: AppState, itemId: string, job: Job): AppState {
+ const item = state.items.find((candidate) => candidate.id === itemId);
+ if (!item || item.type !== "ready") return state;
+ const next: JobItem = { type: "job", id: job.job_id, media: item.media, formats: item.formats, options: item.options, job };
+ return { ...state, items: replaceItem(state.items, itemId, next), selectedId: state.selectedId === itemId ? job.job_id : state.selectedId };
+}
+
+function cancelJob(state: AppState, jobId: string): AppState {
+ const item = state.items.find((candidate) => candidate.id === jobId);
+ if (!item || item.type !== "job") return state;
+ return { ...state, items: replaceItem(state.items, jobId, { type: "ready", id: item.id, media: item.media, formats: item.formats, options: item.options }) };
+}
+
+export function reducer(state: AppState, action: Action): AppState {
+ switch (action.type) {
+ case "fetch/started":
+ return { ...state, items: [{ type: "fetching", id: action.id, url: action.url }, ...state.items] };
+ case "fetch/succeeded": {
+ const ready: QueueItem = { type: "ready", id: action.id, media: snapshotFromInfo(action.url, action.info), formats: action.info.formats, options: defaultDraft(state.preferences.defaultFormat, action.info.formats) };
+ return { ...state, items: replaceItem(state.items, action.id, ready), selectedId: action.id };
+ }
+ case "fetch/failed": {
+ const item = state.items.find((candidate) => candidate.id === action.id);
+ return item ? { ...state, items: replaceItem(state.items, action.id, { type: "fetch-error", id: action.id, url: item.type === "fetching" ? item.url : "", code: action.code }) } : state;
+ }
+ case "item/selected":
+ return { ...state, selectedId: action.id };
+ case "item/optionsChanged":
+ return { ...state, items: state.items.map((item) => (item.id === action.id && item.type === "ready" ? { ...item, options: { ...item.options, ...action.patch } } : item)) };
+ case "item/removed":
+ return { ...state, items: state.items.filter((item) => item.id !== action.id), selectedId: state.selectedId === action.id ? null : state.selectedId };
+ case "download/started":
+ return startDownload(state, action.itemId, action.job);
+ case "job/cancelled":
+ return cancelJob(state, action.jobId);
+ case "jobs/synced":
+ return syncJobs(state, action.jobs);
+ case "history/cleared":
+ return { ...state, history: [] };
+ case "view/changed":
+ return { ...state, view: action.view, filter: action.filter ?? state.filter };
+ case "session/loaded":
+ return { ...state, session: action.session };
+ case "settings/loaded":
+ return { ...state, settings: action.settings };
+ case "storage/loaded":
+ return { ...state, storage: action.storage };
+ case "cookies/loaded":
+ return { ...state, cookies: action.cookies };
+ case "preferences/changed":
+ return { ...state, preferences: { ...state.preferences, ...action.patch } };
+ case "notice/shown":
+ return { ...state, notice: action.notice };
+ case "notice/dismissed":
+ return state.notice?.id === action.id ? { ...state, notice: null } : state;
+ default: {
+ const unreachable: never = action;
+ return unreachable;
+ }
+ }
+}
+
+const FILTERS: Record boolean> = {
+ all: () => true,
+ active: (item) => item.type === "fetching" || (item.type === "job" && ACTIVE_STATUSES.has(item.job.status)),
+ done: (item) => item.type === "job" && item.job.status === "done",
+ error: (item) => item.type === "fetch-error" || (item.type === "job" && item.job.status === "error"),
+};
+
+export function visibleItems(state: AppState): QueueItem[] {
+ return state.items.filter(FILTERS[state.filter]);
+}
+
+export function countItems(state: AppState): Record {
+ const settled = state.items.filter((item) => item.type !== "fetching");
+ return {
+ all: settled.length,
+ active: settled.filter(FILTERS.active).length,
+ done: settled.filter(FILTERS.done).length,
+ error: settled.filter(FILTERS.error).length,
+ };
+}
+
+export function selectedItem(state: AppState): QueueItem | null {
+ return state.items.find((item) => item.id === state.selectedId) ?? null;
+}
+```
+
+`DEFAULT_PREFERENCES` lives here and is re-exported by `lib/preferences.ts`.
+
+- [ ] **Step 6: Write the failing persistence tests and implement persistence**
+
+`apps/web/src/lib/preferences.test.ts`:
+
+```ts
+import { describe, expect, it } from "vitest";
+import { DEFAULT_PREFERENCES, loadPersisted, saveHistory, saveItems, savePreferences } from "./preferences";
+
+describe("preferences", () => {
+ it("returns defaults when nothing is stored or storage is corrupt", () => {
+ expect(loadPersisted().preferences).toEqual(DEFAULT_PREFERENCES);
+ window.localStorage.setItem("openmedia.preferences", "{broken");
+ expect(loadPersisted().preferences).toEqual(DEFAULT_PREFERENCES);
+ });
+
+ it("round-trips preferences, ready items and history while dropping transient items", () => {
+ savePreferences({ ...DEFAULT_PREFERENCES, accent: "pink", theme: "dark" });
+ saveItems([
+ { type: "fetching", id: "f", url: "https://a.b" },
+ { type: "ready", id: "r", media: { url: "https://a.b", title: "A", thumbnail: "", duration: 10, uploader: "", platform: "other" }, formats: [], options: { kind: "video", container: "mp4", qualityHeight: null, audioFormat: "m4a", audioQuality: "best", trim: null, subtitleLanguages: [], subtitleMode: "embed", embedMetadata: true } },
+ ]);
+ saveHistory([{ id: "h", url: "https://a.b", title: "A", kind: "audio", label: "MP3", sizeBytes: 1, finishedAt: "2026-09-14T00:00:00Z" }]);
+ const restored = loadPersisted();
+ expect(restored.preferences.accent).toBe("pink");
+ expect(restored.items.map((item) => item.id)).toEqual(["r"]);
+ expect(restored.history).toHaveLength(1);
+ });
+});
+```
+
+`apps/web/src/lib/preferences.ts`:
+
+```ts
+import { DEFAULT_PREFERENCES } from "@/state/reducer";
+import type { HistoryEntry, Preferences, QueueItem } from "@/state/types";
+import { PREFERENCES_STORAGE_KEY } from "./theme";
+
+export { DEFAULT_PREFERENCES };
+
+const ITEMS_STORAGE_KEY = "openmedia.queue";
+const HISTORY_STORAGE_KEY = "openmedia.history";
+
+function read(key: string, fallback: T): T {
+ try {
+ const raw = window.localStorage.getItem(key);
+ return raw === null ? fallback : (JSON.parse(raw) as T);
+ } catch {
+ return fallback;
+ }
+}
+
+function write(key: string, value: unknown): boolean {
+ try {
+ window.localStorage.setItem(key, JSON.stringify(value));
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export function loadPersisted(): { preferences: Preferences; items: QueueItem[]; history: HistoryEntry[] } {
+ const stored = read>(PREFERENCES_STORAGE_KEY, {});
+ return {
+ preferences: { ...DEFAULT_PREFERENCES, ...(typeof stored === "object" && stored !== null ? stored : {}) },
+ items: read(ITEMS_STORAGE_KEY, []).filter((item) => item.type === "ready" || item.type === "job"),
+ history: read(HISTORY_STORAGE_KEY, []),
+ };
+}
+
+export function savePreferences(preferences: Preferences): boolean {
+ return write(PREFERENCES_STORAGE_KEY, preferences);
+}
+
+export function saveItems(items: readonly QueueItem[]): boolean {
+ return write(
+ ITEMS_STORAGE_KEY,
+ items.filter((item) => item.type === "ready" || item.type === "job"),
+ );
+}
+
+export function saveHistory(history: readonly HistoryEntry[]): boolean {
+ return write(HISTORY_STORAGE_KEY, history);
+}
+```
+
+- [ ] **Step 7: Write the failing command tests**
+
+`apps/web/src/state/commands.test.ts`:
+
+```ts
+import { describe, expect, it, vi } from "vitest";
+import { api, ApiRequestError } from "@/lib/api/client";
+import { createCommands } from "./commands";
+import { initialState, reducer } from "./reducer";
+import type { Action, AppState } from "./types";
+
+function harness(): { commands: ReturnType; state: () => AppState } {
+ let state = initialState();
+ const dispatch = (action: Action): void => {
+ state = reducer(state, action);
+ };
+ return { commands: createCommands(dispatch, () => state), state: () => state };
+}
+
+const INFO = { id: "a", title: "Pho", thumbnail: "", duration: 60, uploader: "", platform: "Youtube", webpage_url: "", formats: [], subtitle_languages: [], has_chapters: false };
+
+describe("commands", () => {
+ it("fetches each link and records failures with their codes", async () => {
+ vi.spyOn(api, "info").mockImplementation(async (url) => {
+ if (url.includes("bad")) throw new ApiRequestError(400, "unsupported_url", "no", null);
+ return INFO;
+ });
+ const { commands, state } = harness();
+ await commands.fetchLinks(["https://youtu.be/a", "https://bad.example/x"], "single");
+ expect(
+ state()
+ .items.map((item) => item.type)
+ .sort(),
+ ).toEqual(["fetch-error", "ready"]);
+ });
+
+ it("expands playlists before fetching", async () => {
+ vi.spyOn(api, "playlist").mockResolvedValue({ title: "Mix", count: 2, urls: ["https://youtu.be/1", "https://youtu.be/2"] });
+ const info = vi.spyOn(api, "info").mockResolvedValue(INFO);
+ const { commands } = harness();
+ await commands.fetchLinks(["https://www.youtube.com/watch?v=a&list=PL1"], "playlist");
+ expect(info).toHaveBeenCalledTimes(2);
+ });
+
+ it("starts a download and cancels it back to ready", async () => {
+ vi.spyOn(api, "info").mockResolvedValue(INFO);
+ const job = { job_id: "j1", url: "https://youtu.be/a", title: "Pho", status: "queued", progress: 0, speed_bps: null, eta_seconds: null, downloaded_bytes: null, total_bytes: null, queue_position: 1, options: { kind: "video", container: "mp4", quality_height: null, format_id: null, audio_format: null, audio_quality: null, trim: null, subtitles: null, embed_metadata: true }, filename: null, files: [], error: null, error_code: null, created_at: "2026-09-14T00:00:00Z", finished_at: null, expires_at: null } as const;
+ vi.spyOn(api, "download").mockResolvedValue({ job_id: "j1", job });
+ const remove = vi.spyOn(api, "removeJob").mockResolvedValue(undefined);
+ const { commands, state } = harness();
+ await commands.fetchLinks(["https://youtu.be/a"], "single");
+ await commands.startDownload(state().items[0].id);
+ expect(state().items[0]).toMatchObject({ type: "job", id: "j1" });
+ await commands.cancelJob("j1");
+ expect(remove).toHaveBeenCalledWith("j1");
+ expect(state().items[0].type).toBe("ready");
+ });
+
+ it("shows a localized notice code when the API fails", async () => {
+ vi.spyOn(api, "updateSettings").mockRejectedValue(new ApiRequestError(400, "invalid_option", "bad", null));
+ const { commands, state } = harness();
+ await commands.saveSettings({ max_concurrent: 9 });
+ expect(state().notice).toMatchObject({ tone: "error", message: "invalid_option" });
+ });
+});
+```
+
+Notices never hold translated text: an error notice's `message` is an API error code resolved through `t.errors`, and a success or info notice's `message` is a key of `t.island` (for example `"fetched"`), with optional `detail` and `count` for the message function.
+
+- [ ] **Step 8: Implement commands, polling and the provider**
+
+`apps/web/src/state/commands.ts`:
+
+```ts
+import { api, ApiRequestError } from "@/lib/api/client";
+import type { RuntimeSettings } from "@/lib/api/types";
+import { hasPlaylist } from "@/lib/links";
+import { toDownloadRequest } from "./options";
+import type { Action, AppState, Notice, PlaylistScope } from "./types";
+
+type Dispatch = (action: Action) => void;
+type NoticeInput = Omit;
+
+export interface Commands {
+ notify(notice: NoticeInput): void;
+ fetchLinks(urls: readonly string[], scope: PlaylistScope): Promise;
+ startDownload(itemId: string): Promise;
+ startAllReady(): Promise;
+ cancelJob(jobId: string): Promise;
+ retryJob(jobId: string): Promise;
+ retryFetch(itemId: string): Promise;
+ removeItem(itemId: string): Promise;
+ downloadAgain(entryId: string): Promise;
+ syncJobs(): Promise;
+ loadServerState(): Promise;
+ saveSettings(patch: Partial): Promise;
+ uploadCookies(file: File): Promise;
+ removeCookies(): Promise;
+ signIn(password: string): Promise;
+ signOut(): Promise;
+}
+
+let noticeSequence = 0;
+let itemSequence = 0;
+
+const nextItemId = (): string => `item-${Date.now().toString(36)}-${(itemSequence += 1)}`;
+
+function errorCode(error: unknown): string {
+ return error instanceof ApiRequestError ? error.code : "unknown_error";
+}
+
+export function createCommands(dispatch: Dispatch, getState: () => AppState): Commands {
+ const notify = (notice: NoticeInput): void => dispatch({ type: "notice/shown", notice: { ...notice, id: (noticeSequence += 1) } });
+ const fail = (error: unknown): void => notify({ tone: "error", message: errorCode(error) });
+ const guarded = async (work: () => Promise): Promise => {
+ try {
+ await work();
+ } catch (error) {
+ fail(error);
+ }
+ };
+
+ const fetchOne = async (url: string): Promise => {
+ const id = nextItemId();
+ dispatch({ type: "fetch/started", id, url });
+ try {
+ dispatch({ type: "fetch/succeeded", id, url, info: await api.info(url) });
+ } catch (error) {
+ dispatch({ type: "fetch/failed", id, code: errorCode(error) });
+ }
+ };
+
+ const expand = async (urls: readonly string[], scope: PlaylistScope): Promise => {
+ const expanded = await Promise.all(urls.map(async (url) => (scope === "playlist" && hasPlaylist(url) ? [...(await api.playlist(url)).urls] : [url])));
+ return expanded.flat();
+ };
+
+ const syncJobs = async (): Promise => {
+ dispatch({ type: "jobs/synced", jobs: await api.jobs() });
+ };
+
+ const startDownload = async (itemId: string): Promise =>
+ guarded(async () => {
+ const item = getState().items.find((candidate) => candidate.id === itemId);
+ if (!item || item.type !== "ready") return;
+ const { job } = await api.download(toDownloadRequest(item));
+ dispatch({ type: "download/started", itemId, job });
+ });
+
+ return {
+ notify,
+ syncJobs: () => guarded(syncJobs),
+ fetchLinks: (urls, scope) =>
+ guarded(async () => {
+ const targets = await expand(urls, scope);
+ await Promise.all(targets.map(fetchOne));
+ const ready = getState().items.filter((item) => item.type === "ready").length;
+ if (ready > 0) notify({ tone: "success", message: targets.length > urls.length ? "playlistAdded" : "fetched", count: targets.length });
+ }),
+ startDownload,
+ startAllReady: async () => {
+ const readyIds = getState()
+ .items.filter((item) => item.type === "ready")
+ .map((item) => item.id);
+ for (const id of readyIds) await startDownload(id);
+ },
+ cancelJob: (jobId) =>
+ guarded(async () => {
+ await api.removeJob(jobId);
+ dispatch({ type: "job/cancelled", jobId });
+ notify({ tone: "info", message: "cancelled" });
+ }),
+ retryJob: (jobId) =>
+ guarded(async () => {
+ await api.removeJob(jobId);
+ dispatch({ type: "job/cancelled", jobId });
+ await startDownload(jobId);
+ }),
+ retryFetch: (itemId) =>
+ guarded(async () => {
+ const item = getState().items.find((candidate) => candidate.id === itemId);
+ if (!item || item.type !== "fetch-error") return;
+ dispatch({ type: "item/removed", id: itemId });
+ await fetchOne(item.url);
+ }),
+ removeItem: (itemId) =>
+ guarded(async () => {
+ const item = getState().items.find((candidate) => candidate.id === itemId);
+ if (item?.type === "job") await api.removeJob(itemId);
+ dispatch({ type: "item/removed", id: itemId });
+ }),
+ downloadAgain: (entryId) =>
+ guarded(async () => {
+ const entry = getState().history.find((candidate) => candidate.id === entryId);
+ if (!entry) return;
+ dispatch({ type: "view/changed", view: "queue", filter: "all" });
+ await fetchOne(entry.url);
+ notify({ tone: "success", message: "addedAgain" });
+ }),
+ loadServerState: () =>
+ guarded(async () => {
+ const session = await api.session();
+ dispatch({ type: "session/loaded", session });
+ if (session.auth_required && !session.authenticated) return;
+ const [settings, storage, cookies] = await Promise.all([api.settings(), api.storage(), api.cookies()]);
+ dispatch({ type: "settings/loaded", settings });
+ dispatch({ type: "storage/loaded", storage });
+ dispatch({ type: "cookies/loaded", cookies });
+ await syncJobs();
+ }),
+ saveSettings: (patch) =>
+ guarded(async () => {
+ dispatch({ type: "settings/loaded", settings: await api.updateSettings(patch) });
+ }),
+ uploadCookies: (file) =>
+ guarded(async () => {
+ dispatch({ type: "cookies/loaded", cookies: await api.uploadCookies(file) });
+ notify({ tone: "success", message: "cookiesLoaded" });
+ }),
+ removeCookies: () =>
+ guarded(async () => {
+ await api.removeCookies();
+ dispatch({ type: "cookies/loaded", cookies: { present: false, domains: [], expires_at: null, uploaded_at: null } });
+ notify({ tone: "info", message: "cookiesRemoved" });
+ }),
+ signIn: async (password) => {
+ try {
+ await api.signIn(password);
+ dispatch({ type: "session/loaded", session: await api.session() });
+ return true;
+ } catch (error) {
+ if (errorCode(error) !== "invalid_password") fail(error);
+ return false;
+ }
+ },
+ signOut: () =>
+ guarded(async () => {
+ await api.signOut();
+ dispatch({ type: "session/loaded", session: await api.session() });
+ }),
+ };
+}
+```
+
+`apps/web/src/state/useJobPolling.ts`:
+
+```ts
+"use client";
+
+import { useEffect } from "react";
+
+const ACTIVE_INTERVAL_MS = 1000;
+const IDLE_INTERVAL_MS = 10000;
+
+export function useJobPolling(sync: () => Promise, hasActiveJobs: boolean, enabled: boolean): void {
+ useEffect(() => {
+ if (!enabled) return;
+ const interval = hasActiveJobs ? ACTIVE_INTERVAL_MS : IDLE_INTERVAL_MS;
+ const tick = (): void => {
+ if (document.visibilityState === "visible") void sync();
+ };
+ const timer = window.setInterval(tick, interval);
+ document.addEventListener("visibilitychange", tick);
+ return () => {
+ window.clearInterval(timer);
+ document.removeEventListener("visibilitychange", tick);
+ };
+ }, [sync, hasActiveJobs, enabled]);
+}
+```
+
+`apps/web/src/state/StoreProvider.tsx`:
+
+```tsx
+"use client";
+
+import { createContext, useContext, useEffect, useMemo, useReducer, useRef, type Dispatch, type ReactNode } from "react";
+import { I18nProvider, resolveLocale } from "@/lib/i18n/I18nProvider";
+import { loadPersisted, saveHistory, saveItems, savePreferences } from "@/lib/preferences";
+import { applyAccent, applyTheme } from "@/lib/theme";
+import { createCommands, type Commands } from "./commands";
+import { initialState, reducer } from "./reducer";
+import type { Action, AppState } from "./types";
+import { useJobPolling } from "./useJobPolling";
+
+interface StoreValue {
+ readonly state: AppState;
+ readonly dispatch: Dispatch;
+ readonly commands: Commands;
+}
+
+const StoreContext = createContext(null);
+const ACTIVE_STATUSES = new Set(["queued", "downloading", "processing"]);
+
+function createInitialState(): AppState {
+ const persisted = loadPersisted();
+ return { ...initialState(persisted.preferences), items: persisted.items, history: persisted.history };
+}
+
+export function StoreProvider({ children }: { children: ReactNode }): ReactNode {
+ const [state, dispatch] = useReducer(reducer, undefined, createInitialState);
+ const stateRef = useRef(state);
+ const commands = useMemo(() => createCommands(dispatch, () => stateRef.current), []);
+
+ useEffect(() => {
+ stateRef.current = state;
+ }, [state]);
+
+ useEffect(() => {
+ void commands.loadServerState();
+ }, [commands]);
+
+ useEffect(() => {
+ savePreferences(state.preferences);
+ applyTheme(state.preferences.theme);
+ applyAccent(state.preferences.accent);
+ }, [state.preferences]);
+
+ useEffect(() => {
+ saveItems(state.items);
+ }, [state.items]);
+
+ useEffect(() => {
+ saveHistory(state.history);
+ }, [state.history]);
+
+ const hasActiveJobs = state.items.some((item) => item.type === "job" && ACTIVE_STATUSES.has(item.job.status));
+ const canPoll = state.session !== null && (!state.session.auth_required || state.session.authenticated);
+ useJobPolling(commands.syncJobs, hasActiveJobs, canPoll);
+
+ const locale = resolveLocale(state.preferences.language, typeof navigator === "undefined" ? "en" : navigator.language);
+ useEffect(() => {
+ document.documentElement.lang = locale;
+ }, [locale]);
+
+ const value = useMemo(() => ({ state, dispatch, commands }), [state, commands]);
+ return (
+
+ {children}
+
+ );
+}
+
+export function useStore(): StoreValue {
+ const value = useContext(StoreContext);
+ if (value === null) throw new Error("useStore must be used inside StoreProvider");
+ return value;
+}
+```
+
+`StoreProvider` reads localStorage in its initializer, so it must only render in the browser: Task 10 mounts the application through `next/dynamic` with `ssr: false`.
+
+- [ ] **Step 9: Run the web checks**
+
+Run from the project root: `mise run //apps/web:ci-unit`
+Expected: PASS.
+
+- [ ] **Step 10: Commit**
+
+```bash
+git add apps/web
+git commit -m "feat(web): add client state, persistence, API commands and job polling"
+```
+
+### Task 10: Controls, overlays, application shell and importer
+
+**Files:**
+
+- Create in `apps/web/src/components/controls/`: `Icon.tsx`, `Capsule.tsx`, `IconButton.tsx`, `Segmented.tsx`, `Segmented.test.tsx`, `Switch.tsx`, `Stepper.tsx`, `BrandMark.tsx`, `controls.module.css`
+- Create in `apps/web/src/components/overlays/`: `Sheet.tsx`, `Sheet.test.tsx`, `AlertDialog.tsx`, `Island.tsx`, `ShortcutsHud.tsx`, `DropOverlay.tsx`, `overlays.module.css`
+- Create in `apps/web/src/hooks/`: `useMediaQuery.ts`, `useFocusTrap.ts`
+- Create in `apps/web/src/components/shell/`: `AppShell.tsx`, `Sidebar.tsx`, `Toolbar.tsx`, `TabBar.tsx`, `shell.module.css`
+- Create in `apps/web/src/components/importer/`: `Importer.tsx`, `Importer.test.tsx`, `importer.module.css`
+- Create: `apps/web/src/app/OpenMediaClient.tsx`
+- Modify: `apps/web/src/app/page.tsx`
+
+**Interfaces:**
+
+- Consumes: `useStore`, `useI18n`, `Commands`, `countItems`, `parseLinks`, `detectPlatforms`, `hasPlaylist`, `linkFromShare`, `PlaylistScope`.
+- Produces:
+ - `Icon({ name: IconName; size?: number; weight?: "regular" | "fill" | "bold" })`, `IconName` union listed in Step 1
+ - `Capsule(props: ButtonHTMLAttributes & { variant?: "gray" | "primary" | "tinted" | "plain" | "destructive"; size?: "regular" | "large"; icon?: IconName })`
+ - `IconButton(props: ButtonHTMLAttributes & { label: string; icon: IconName; size?: "small" | "regular" })`
+ - `Segmented({ label, options: ReadonlyArray<{ value: T; label: string; icon?: IconName }>, value: T, onChange: (value: T) => void })`
+ - `Switch({ checked, onChange, labelledBy })`, `Stepper({ value, min, max, onChange, decreaseLabel, increaseLabel })`, `BrandMark({ size })`
+ - `useMediaQuery(query: string) -> boolean`, `PHONE_QUERY = "(max-width: 767px)"`, `TABLET_QUERY = "(max-width: 1023px)"`, `useFocusTrap(ref, active)`
+ - `Sheet({ open, onClose, title, children, footer?, labelledById })` (modal from 768 px, draggable bottom sheet below), `AlertDialog({ open, title, message, confirmLabel, cancelLabel, destructive, onConfirm, onCancel })`, `Island()`, `ShortcutsHud({ open, onClose })`, `DropOverlay({ onDrop: (text: string) => void })`
+ - `AppShell()` (owns UI state: link text, playlist scope, sidebar, settings, shortcuts, alert), `Importer({ value, onChange, onSubmit, scope, onScopeChange, inputRef })`
+
+- [ ] **Step 1: Icons and simple controls**
+
+`apps/web/src/components/controls/Icon.tsx`:
+
+```tsx
+import { ArrowClockwise, ArrowDown, Check, CheckCircle, ClipboardText, ClockCounterClockwise, Cookie, DeviceMobile, DownloadSimple, FacebookLogo, FilmStrip, GearSix, Globe, InstagramLogo, Keyboard, Link, List, Lock, Minus, Moon, MusicNotes, Plus, Queue, SignOut, SoundcloudLogo, Sun, TiktokLogo, Timer, Trash, VideoCamera, VimeoLogo, WarningCircle, X, XLogo, YoutubeLogo, CaretRight, type Icon as PhosphorIcon } from "@phosphor-icons/react";
+import type { ReactNode } from "react";
+
+const ICONS = {
+ arrowClockwise: ArrowClockwise,
+ arrowDown: ArrowDown,
+ caretRight: CaretRight,
+ check: Check,
+ checkCircle: CheckCircle,
+ clipboard: ClipboardText,
+ history: ClockCounterClockwise,
+ cookie: Cookie,
+ deviceMobile: DeviceMobile,
+ download: DownloadSimple,
+ facebook: FacebookLogo,
+ filmStrip: FilmStrip,
+ gear: GearSix,
+ globe: Globe,
+ instagram: InstagramLogo,
+ keyboard: Keyboard,
+ link: Link,
+ list: List,
+ lock: Lock,
+ minus: Minus,
+ moon: Moon,
+ musicNotes: MusicNotes,
+ plus: Plus,
+ queue: Queue,
+ signOut: SignOut,
+ soundcloud: SoundcloudLogo,
+ sun: Sun,
+ tiktok: TiktokLogo,
+ timer: Timer,
+ trash: Trash,
+ videoCamera: VideoCamera,
+ vimeo: VimeoLogo,
+ warningCircle: WarningCircle,
+ x: X,
+ xLogo: XLogo,
+ youtube: YoutubeLogo,
+} satisfies Record;
+
+export type IconName = keyof typeof ICONS;
+
+export function Icon({ name, size = 18, weight = "regular" }: { name: IconName; size?: number; weight?: "regular" | "fill" | "bold" }): ReactNode {
+ const Component = ICONS[name];
+ return ;
+}
+```
+
+If the Phosphor package exports `SSR` variants only for server components, import from `@phosphor-icons/react/dist/ssr` in files without `"use client"`; every component in this task is used inside the client tree, so the default entry works.
+
+`apps/web/src/components/controls/Capsule.tsx`:
+
+```tsx
+import type { ButtonHTMLAttributes, ReactNode } from "react";
+import styles from "./controls.module.css";
+import { Icon, type IconName } from "./Icon";
+
+type CapsuleVariant = "gray" | "primary" | "tinted" | "plain" | "destructive";
+
+interface CapsuleProps extends ButtonHTMLAttributes {
+ variant?: CapsuleVariant;
+ size?: "regular" | "large";
+ icon?: IconName;
+}
+
+export function Capsule({ variant = "gray", size = "regular", icon, className, children, type = "button", ...rest }: CapsuleProps): ReactNode {
+ const classes = [styles.capsule, styles[variant], size === "large" ? styles.large : "", className ?? ""].join(" ").trim();
+ return (
+
+ {icon ? : null}
+ {children}
+
+ );
+}
+```
+
+`IconButton.tsx`, `Switch.tsx`, `Stepper.tsx` and `BrandMark.tsx` follow the same pattern:
+
+```tsx
+import type { ButtonHTMLAttributes, ReactNode } from "react";
+import styles from "./controls.module.css";
+import { Icon, type IconName } from "./Icon";
+
+interface IconButtonProps extends ButtonHTMLAttributes {
+ label: string;
+ icon: IconName;
+ size?: "small" | "regular";
+}
+
+export function IconButton({ label, icon, size = "regular", className, type = "button", ...rest }: IconButtonProps): ReactNode {
+ return (
+
+
+
+ );
+}
+```
+
+```tsx
+import type { ReactNode } from "react";
+import styles from "./controls.module.css";
+
+export function Switch({ checked, onChange, labelledBy }: { checked: boolean; onChange: (checked: boolean) => void; labelledBy: string }): ReactNode {
+ return onChange(!checked)} />;
+}
+```
+
+```tsx
+import type { ReactNode } from "react";
+import styles from "./controls.module.css";
+import { Icon } from "./Icon";
+
+interface StepperProps {
+ value: number;
+ min: number;
+ max: number;
+ onChange: (value: number) => void;
+ decreaseLabel: string;
+ increaseLabel: string;
+}
+
+export function Stepper({ value, min, max, onChange, decreaseLabel, increaseLabel }: StepperProps): ReactNode {
+ return (
+
+
+ {value}
+
+
+ onChange(value - 1)}>
+
+
+
+ = max} onClick={() => onChange(value + 1)}>
+
+
+
+
+ );
+}
+```
+
+```tsx
+import type { ReactNode } from "react";
+import styles from "./controls.module.css";
+
+export const LOGO_FRAME_PATH = "M9 22 V9 H22 M42 9 H55 V22 M55 42 V55 H42 M22 55 H9 V42";
+export const LOGO_WAVE_PATH = "M23 27 V37 M32 20 V44 M41 25 V39";
+
+export function BrandMark({ size = 26 }: { size?: number }): ReactNode {
+ return (
+
+
+
+
+
+
+ );
+}
+```
+
+`controls.module.css`: port from the prototype `base.css` (`.capsule` and its variants, `.icon-button`, `.brand-mark`, `.brand-frame`, `.brand-wave`) and `components.css` (`.segmented`, `.segmented-thumb`, `.segmented button`, `.switch`, `.stepper*`). Rename kebab-case classes to camelCase (`.capsule.primary` becomes `.capsule.primary`, `.icon-button.small` becomes `.iconButton.small`, `.segmented-thumb` becomes `.segmentedThumb`, `.stepper-value` becomes `.stepperValue`, `.stepper-divider` becomes `.stepperDivider`), add `.stepperGroup { display: inline-flex; align-items: center; gap: 10px; }`, and keep every value, easing and media query from the prototype.
+
+- [ ] **Step 2: Write the failing segmented control test, then implement it**
+
+`apps/web/src/components/controls/Segmented.test.tsx`:
+
+```tsx
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { useState } from "react";
+import { describe, expect, it } from "vitest";
+import { Segmented } from "./Segmented";
+
+function Harness(): React.ReactNode {
+ const [value, setValue] = useState<"video" | "audio">("video");
+ return (
+
+ );
+}
+
+describe("Segmented", () => {
+ it("selects with clicks and arrow keys and moves the thumb", async () => {
+ const user = userEvent.setup();
+ render( );
+ const group = screen.getByRole("radiogroup", { name: "Type" });
+ expect(screen.getByRole("radio", { name: "Video" })).toHaveAttribute("aria-checked", "true");
+ await user.click(screen.getByRole("radio", { name: "Audio" }));
+ expect(screen.getByRole("radio", { name: "Audio" })).toHaveAttribute("aria-checked", "true");
+ expect(group.style.getPropertyValue("--index")).toBe("1");
+ screen.getByRole("radio", { name: "Audio" }).focus();
+ await user.keyboard("{ArrowRight}");
+ expect(screen.getByRole("radio", { name: "Video" })).toHaveAttribute("aria-checked", "true");
+ expect(screen.getByRole("radio", { name: "Video" })).toHaveFocus();
+ });
+});
+```
+
+`apps/web/src/components/controls/Segmented.tsx`:
+
+```tsx
+"use client";
+
+import { useRef, type CSSProperties, type KeyboardEvent, type ReactNode } from "react";
+import styles from "./controls.module.css";
+import { Icon, type IconName } from "./Icon";
+
+export interface SegmentOption {
+ readonly value: T;
+ readonly label: string;
+ readonly icon?: IconName;
+}
+
+interface SegmentedProps {
+ label: string;
+ options: ReadonlyArray>;
+ value: T;
+ onChange: (value: T) => void;
+}
+
+const ARROW_STEPS: Record = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
+
+export function Segmented({ label, options, value, onChange }: SegmentedProps): ReactNode {
+ const buttons = useRef>([]);
+ const index = Math.max(
+ 0,
+ options.findIndex((option) => option.value === value),
+ );
+ const style = { "--count": options.length, "--index": index } as CSSProperties;
+
+ const handleKeyDown = (event: KeyboardEvent): void => {
+ const step = ARROW_STEPS[event.key];
+ if (step === undefined) return;
+ event.preventDefault();
+ const next = (index + step + options.length) % options.length;
+ onChange(options[next].value);
+ buttons.current[next]?.focus();
+ };
+
+ return (
+
+
+ {options.map((option, optionIndex) => (
+ {
+ buttons.current[optionIndex] = element;
+ }}
+ type="button"
+ role="radio"
+ aria-checked={option.value === value}
+ tabIndex={option.value === value ? 0 : -1}
+ onClick={() => onChange(option.value)}
+ >
+ {option.icon ? : null}
+ {option.label}
+
+ ))}
+
+ );
+}
+```
+
+`CSSProperties` does not declare custom properties, so the cast on `style` is the one allowed type assertion in this component; it only widens a style object.
+
+- [ ] **Step 3: Media queries and focus trapping**
+
+`apps/web/src/hooks/useMediaQuery.ts`:
+
+```ts
+"use client";
+
+import { useSyncExternalStore } from "react";
+
+export const PHONE_QUERY = "(max-width: 767px)";
+export const TABLET_QUERY = "(max-width: 1023px)";
+
+export function useMediaQuery(query: string): boolean {
+ return useSyncExternalStore(
+ (onChange) => {
+ const list = window.matchMedia(query);
+ list.addEventListener("change", onChange);
+ return () => list.removeEventListener("change", onChange);
+ },
+ () => window.matchMedia(query).matches,
+ () => false,
+ );
+}
+```
+
+`apps/web/src/hooks/useFocusTrap.ts`:
+
+```ts
+"use client";
+
+import { useEffect, type RefObject } from "react";
+
+const FOCUSABLE = "button:not([disabled]), [href], input:not([disabled]), select, textarea, [tabindex]:not([tabindex='-1'])";
+
+function focusableWithin(container: HTMLElement): HTMLElement[] {
+ return [...container.querySelectorAll(FOCUSABLE)].filter((element) => element.offsetParent !== null || element === document.activeElement);
+}
+
+export function useFocusTrap(ref: RefObject, active: boolean): void {
+ useEffect(() => {
+ const container = ref.current;
+ if (!active || !container) return;
+ const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
+ (focusableWithin(container)[0] ?? container).focus({ preventScroll: true });
+ const trap = (event: KeyboardEvent): void => {
+ if (event.key !== "Tab") return;
+ const focusable = focusableWithin(container);
+ if (focusable.length === 0) return;
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ if (event.shiftKey && document.activeElement === first) {
+ event.preventDefault();
+ last.focus();
+ } else if (!event.shiftKey && document.activeElement === last) {
+ event.preventDefault();
+ first.focus();
+ }
+ };
+ document.addEventListener("keydown", trap);
+ return () => {
+ document.removeEventListener("keydown", trap);
+ previouslyFocused?.focus({ preventScroll: true });
+ };
+ }, [ref, active]);
+}
+```
+
+- [ ] **Step 4: Write the failing sheet test, then implement overlays**
+
+`apps/web/src/components/overlays/Sheet.test.tsx`:
+
+```tsx
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { Sheet } from "./Sheet";
+
+describe("Sheet", () => {
+ it("renders a labelled dialog, moves focus inside and closes on Escape", async () => {
+ const onClose = vi.fn();
+ render(
+
+ Inside
+ ,
+ );
+ const dialog = screen.getByRole("dialog", { name: "Settings" });
+ expect(dialog).toHaveAttribute("aria-modal", "true");
+ expect(dialog.contains(document.activeElement)).toBe(true);
+ await userEvent.keyboard("{Escape}");
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("renders nothing when closed", () => {
+ render(
+
+ Hidden
+ ,
+ );
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+});
+```
+
+`apps/web/src/components/overlays/Sheet.tsx`:
+
+```tsx
+"use client";
+
+import { useEffect, useRef, useState, type PointerEvent, type ReactNode } from "react";
+import { createPortal } from "react-dom";
+import { useFocusTrap } from "@/hooks/useFocusTrap";
+import { PHONE_QUERY, useMediaQuery } from "@/hooks/useMediaQuery";
+import { useI18n } from "@/lib/i18n/I18nProvider";
+import { Capsule } from "../controls/Capsule";
+import styles from "./overlays.module.css";
+
+const DISMISS_DISTANCE = 120;
+const DISMISS_VELOCITY = 0.6;
+const EXIT_DURATION_MS = 420;
+
+interface SheetProps {
+ open: boolean;
+ onClose: () => void;
+ title: string;
+ labelledById: string;
+ children: ReactNode;
+ footer?: ReactNode;
+}
+
+function useStagedPresence(open: boolean): { mounted: boolean; visible: boolean } {
+ const [mounted, setMounted] = useState(open);
+ const [visible, setVisible] = useState(false);
+ useEffect(() => {
+ if (open) {
+ setMounted(true);
+ const frame = requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true)));
+ return () => cancelAnimationFrame(frame);
+ }
+ setVisible(false);
+ const timer = window.setTimeout(() => setMounted(false), EXIT_DURATION_MS);
+ return () => window.clearTimeout(timer);
+ }, [open]);
+ return { mounted: mounted || open, visible };
+}
+
+export function Sheet({ open, onClose, title, labelledById, children, footer }: SheetProps): ReactNode {
+ const { t } = useI18n();
+ const isPhone = useMediaQuery(PHONE_QUERY);
+ const panel = useRef(null);
+ const drag = useRef({ startY: 0, lastY: 0, lastTime: 0, velocity: 0 });
+ const [offset, setOffset] = useState(0);
+ const { mounted, visible } = useStagedPresence(open);
+ useFocusTrap(panel, open && mounted);
+
+ useEffect(() => {
+ if (!open) return;
+ const closeOnEscape = (event: KeyboardEvent): void => {
+ if (event.key === "Escape") onClose();
+ };
+ document.addEventListener("keydown", closeOnEscape);
+ document.body.dataset.sheetOpen = isPhone ? "true" : "false";
+ return () => {
+ document.removeEventListener("keydown", closeOnEscape);
+ delete document.body.dataset.sheetOpen;
+ };
+ }, [open, onClose, isPhone]);
+
+ if (!mounted) return null;
+
+ const beginDrag = (event: PointerEvent): void => {
+ if (!isPhone) return;
+ drag.current = { startY: event.clientY, lastY: event.clientY, lastTime: performance.now(), velocity: 0 };
+ event.currentTarget.setPointerCapture(event.pointerId);
+ };
+ const moveDrag = (event: PointerEvent): void => {
+ if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
+ const now = performance.now();
+ drag.current.velocity = (event.clientY - drag.current.lastY) / Math.max(now - drag.current.lastTime, 1);
+ drag.current.lastY = event.clientY;
+ drag.current.lastTime = now;
+ setOffset(Math.max(0, event.clientY - drag.current.startY));
+ };
+ const endDrag = (event: PointerEvent): void => {
+ if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
+ const shouldClose = offset > DISMISS_DISTANCE || drag.current.velocity > DISMISS_VELOCITY;
+ setOffset(0);
+ if (shouldClose) onClose();
+ };
+
+ return createPortal(
+
+
+
0 ? { transform: `translateY(${offset}px)`, transition: "none" } : undefined}>
+
+
+
+ {title}
+
+ {t.settings.done}
+
+
+
+ {children}
+ {footer ? : null}
+
+
,
+ document.body,
+ );
+}
+```
+
+`AlertDialog.tsx`, `Island.tsx`, `ShortcutsHud.tsx`, `DropOverlay.tsx`:
+
+```tsx
+"use client";
+
+import { useRef, type ReactNode } from "react";
+import { createPortal } from "react-dom";
+import { useFocusTrap } from "@/hooks/useFocusTrap";
+import styles from "./overlays.module.css";
+
+interface AlertDialogProps {
+ open: boolean;
+ title: string;
+ message: string;
+ confirmLabel: string;
+ cancelLabel: string;
+ destructive: boolean;
+ onConfirm: () => void;
+ onCancel: () => void;
+}
+
+export function AlertDialog({ open, title, message, confirmLabel, cancelLabel, destructive, onConfirm, onCancel }: AlertDialogProps): ReactNode {
+ const panel = useRef(null);
+ useFocusTrap(panel, open);
+ if (!open) return null;
+ return createPortal(
+
+
+
+ {title}
+ {message}
+
+
+ {cancelLabel}
+
+
+ {confirmLabel}
+
+
+
+
,
+ document.body,
+ );
+}
+```
+
+```tsx
+"use client";
+
+import { useEffect, type ReactNode } from "react";
+import { useI18n } from "@/lib/i18n/I18nProvider";
+import type { Messages } from "@/lib/i18n/en";
+import { useStore } from "@/state/StoreProvider";
+import type { Notice } from "@/state/types";
+import { Icon } from "../controls/Icon";
+import styles from "./overlays.module.css";
+
+const VISIBLE_MS = 2800;
+
+function noticeText(notice: Notice, t: Messages): string {
+ if (notice.tone === "error") {
+ const errors: Record = t.errors;
+ return errors[notice.message] ?? t.errors.unknown_error;
+ }
+ switch (notice.message) {
+ case "fetched":
+ return t.island.fetched;
+ case "playlistAdded":
+ return t.island.playlistAdded(notice.count ?? 0);
+ case "downloaded":
+ return t.island.downloaded(notice.detail ?? "");
+ case "cancelled":
+ return t.island.cancelled;
+ case "removedFromQueue":
+ return t.island.removedFromQueue;
+ case "addedAgain":
+ return t.island.addedAgain;
+ case "cookiesLoaded":
+ return t.island.cookiesLoaded;
+ case "cookiesRemoved":
+ return t.island.cookiesRemoved;
+ case "settingsSaved":
+ return t.island.settingsSaved;
+ case "pasteFallback":
+ return t.importer.pasteFallback;
+ case "noLinks":
+ return t.importer.noLinks;
+ default:
+ return notice.message;
+ }
+}
+
+export function Island(): ReactNode {
+ const { t } = useI18n();
+ const { state, dispatch } = useStore();
+ const notice = state.notice;
+
+ useEffect(() => {
+ if (!notice) return;
+ const timer = window.setTimeout(() => dispatch({ type: "notice/dismissed", id: notice.id }), VISIBLE_MS);
+ return () => window.clearTimeout(timer);
+ }, [notice, dispatch]);
+
+ return (
+
+ {notice ? (
+ <>
+
+
+
+ {noticeText(notice, t)}
+ >
+ ) : null}
+
+ );
+}
+```
+
+```tsx
+"use client";
+
+import { useEffect, useRef, type ReactNode } from "react";
+import { createPortal } from "react-dom";
+import { useFocusTrap } from "@/hooks/useFocusTrap";
+import { useI18n } from "@/lib/i18n/I18nProvider";
+import styles from "./overlays.module.css";
+
+const SHORTCUTS = [
+ { keys: ["/"], label: "focus" },
+ { keys: ["⌘", "V"], label: "pasteFetch" },
+ { keys: ["Esc"], label: "close" },
+ { keys: ["?"], label: "show" },
+] as const;
+
+export function ShortcutsHud({ open, onClose }: { open: boolean; onClose: () => void }): ReactNode {
+ const { t } = useI18n();
+ const panel = useRef(null);
+ useFocusTrap(panel, open);
+ useEffect(() => {
+ if (!open) return;
+ const close = (event: KeyboardEvent): void => {
+ if (event.key === "Escape") onClose();
+ };
+ document.addEventListener("keydown", close);
+ return () => document.removeEventListener("keydown", close);
+ }, [open, onClose]);
+ if (!open) return null;
+ return createPortal(
+
+
+
+