diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5bbc689..a98732d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -4,40 +4,84 @@ on: push: branches: - 'master' + paths-ignore: + - '**.md' jobs: + quality-checks: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: lts/* + - name: Enable pnpm + run: corepack enable + - name: Install frontend dependencies + working-directory: ui + run: pnpm install --frozen-lockfile + - name: Run frontend lint + working-directory: ui + run: pnpm run lint + - name: Build frontend + working-directory: ui + run: pnpm run build + - name: Run frontend tests + working-directory: ui + run: pnpm exec ng test --watch=false + env: + CI: true + - name: Install uv + uses: astral-sh/setup-uv@v7 + - name: Install Python dependencies + run: uv sync --frozen --group dev + - name: Run backend smoke checks + run: python -m compileall app + - name: Run backend tests + run: uv run pytest app/tests/ + - name: Run Trivy filesystem scan + uses: aquasecurity/trivy-action@v0.36.0 + with: + scan-type: fs + scan-ref: . + format: table + severity: CRITICAL,HIGH + dockerhub-build-push: + needs: quality-checks runs-on: ubuntu-latest steps: - name: Get current date id: date - run: echo "::set-output name=date::$(date +'%Y.%m.%d')" + run: echo "date=$(date +'%Y.%m.%d')" >> "$GITHUB_OUTPUT" - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to DockerHub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . platforms: linux/amd64,linux/arm64 @@ -74,7 +118,7 @@ jobs: id: date run: echo "date=$(date +'%Y.%m.%d')" >> $GITHUB_OUTPUT - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Get commits since last release @@ -167,7 +211,7 @@ jobs: git push origin ":refs/tags/$TAG_NAME" || true fi - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ steps.date.outputs.date }} name: Release ${{ steps.date.outputs.date }} diff --git a/.github/workflows/update-yt-dlp.yml b/.github/workflows/update-yt-dlp.yml index b9104e6..ea7d7e9 100644 --- a/.github/workflows/update-yt-dlp.yml +++ b/.github/workflows/update-yt-dlp.yml @@ -10,7 +10,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: token: ${{ secrets.AUTOUPDATE_PAT }} - diff --git a/.vscode/launch.json b/.vscode/launch.json index dc4e30d..5c31505 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -13,12 +13,16 @@ "env": { "DOWNLOAD_DIR": "${env:USERPROFILE}/Downloads", "STATE_DIR": "${env:TEMP}", + "ALLOW_YTDL_OPTIONS_OVERRIDES": "true", + "YTDL_OPTIONS_PRESETS": "{\"sponsorblock\": {\"postprocessors\": [{\"key\": \"SponsorBlock\", \"categories\": [\"sponsor\", \"selfpromo\", \"interaction\"]}, {\"key\": \"ModifyChapters\", \"remove_sponsor_segments\": [\"sponsor\", \"selfpromo\", \"interaction\"]}]}, \"embed-subs\": {\"writesubtitles\": true, \"writeautomaticsub\": true, \"subtitleslangs\": [\"en\", \"de\"], \"postprocessors\": [{\"key\": \"FFmpegEmbedSubtitle\"}]}, \"limit-rate\": {\"ratelimit\": 5000000}}", } }, "osx": { "env": { "DOWNLOAD_DIR": "${env:HOME}/Downloads", "STATE_DIR": "${env:TMPDIR}", + "ALLOW_YTDL_OPTIONS_OVERRIDES": "true", + "YTDL_OPTIONS_PRESETS": "{\"sponsorblock\": {\"postprocessors\": [{\"key\": \"SponsorBlock\", \"categories\": [\"sponsor\", \"selfpromo\", \"interaction\"]}, {\"key\": \"ModifyChapters\", \"remove_sponsor_segments\": [\"sponsor\", \"selfpromo\", \"interaction\"]}]}, \"embed-subs\": {\"writesubtitles\": true, \"writeautomaticsub\": true, \"subtitleslangs\": [\"en\", \"de\"], \"postprocessors\": [{\"key\": \"FFmpegEmbedSubtitle\"}]}, \"limit-rate\": {\"ratelimit\": 5000000}}", } }, "console": "integratedTerminal" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6ec2596 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,157 @@ +# Agent Guidelines + +## Project scope — read this before planning a feature + +MeTube's contract is: give it a URL, it runs yt-dlp well, and correct files appear. +The maintainer holds a deliberate line on what belongs inside that contract, and PRs +on the wrong side of it are declined **regardless of code quality**. Check your plan +against this line before writing any code. + +**In scope — improving the write itself:** + +- Features that make the file yt-dlp writes at download time come out more correct, + using only data the extractor already provides (e.g. filling a missing album-artist + tag from the extractor's own metadata). +- Surfacing functionality yt-dlp itself owns and maintains as first-class UI options + (e.g. a SponsorBlock toggle that just passes yt-dlp postprocessor params). +- Download queue, subscriptions, output templates, and UI improvements to the + download workflow. + +**Out of scope — managing files after they exist:** + +- Tag editors, metadata dialogs, or any workflow that rewrites files after the + download has finished. This holds even for slimmed-down versions. +- Lookups against external metadata services (iTunes, Deezer, MusicBrainz, etc.). + More broadly: any new dependency on a third-party API, or new network egress from + self-hosted instances, beyond what yt-dlp itself performs. +- Library organization: moving/renaming existing files into Artist/Album layouts, + watch-folder processing, and similar media-manager features. Dedicated tools + (beets, MusicBrainz Picard, Lidarr) do this properly; the README points users + to them. + +**Corollaries that shape borderline PRs:** + +- Site-specific intelligence (parsing playlist-ID prefixes, URL path conventions, + and other platform internals) is extractor work and belongs upstream in yt-dlp, + not re-implemented here — it silently breaks when the platform changes and + MeTube would own the breakage. +- Prefer enriching yt-dlp's info dict and letting its existing pipeline + (FFmpegMetadata etc.) do the writing, over adding custom per-format tag-writing + code to MeTube. +- Supplemental processing must never fail a download that otherwise succeeded: + warn and continue, don't raise. +- Keep feature scope minimal on first submission. A hardcoded sensible default + beats a configuration surface; follow-ups can add options when users actually + ask. PRs that bundle several "reasonable next steps" invite rejection of the + whole. + +If a feature idea fails this test, the accepted alternative is usually a README +section documenting how to pair MeTube with the right dedicated tool. + +## README.md size constraint + +The README.md is synced to Docker Hub, which has a **25,000 character limit**. +Any change to README.md **must** keep the file under 25,000 characters (`wc -c README.md`). +If an addition would exceed the limit, trim existing prose elsewhere — prefer tightening verbose descriptions over removing sections. + +## Tech stack + +- **Backend:** Python 3.13+, aiohttp, python-socketio 5.x, yt-dlp +- **Frontend:** Angular 22, TypeScript, Bootstrap 5, SASS, ngx-socket-io +- **Package managers:** uv (Python), pnpm (frontend) +- **Container:** Multi-stage Docker (Node builder + Python runtime), multi-arch (amd64/arm64) + +## Build & test commands + +```bash +# Frontend (run from ui/) +pnpm install --frozen-lockfile +pnpm run lint +pnpm run build +pnpm exec ng test --watch=false + +# Backend (run from repo root) +uv sync --frozen --group dev +python -m compileall app +uv run pytest app/tests/ +``` + +All of these run in CI (`.github/workflows/main.yml`) on every push to master and must pass. + +Gotchas: + +- Backend tests must run **from the repo root**: `main.py` resolves the static-assets + path relative to the cwd, and several test modules import `main`. Running from + `app/` makes five test modules fail to import. +- The frontend must be **built before** running backend tests (same reason — the + assets at `ui/dist/metube/browser` must exist). The command order above is + load-bearing. +- `app/tests/test_ytdl_utils.py` stubs `yt_dlp` at import time. Run standalone, + two tests fail with `AttributeError: does not have the + attribute 'YoutubeDL'`; under the full suite the real module is imported first + and they pass. This is a known quirk, not a bug to fix in the code under test. + +Every non-markdown push to master builds multi-arch Docker images and cuts a dated +release the same day. **Master is continuously released** — a PR must be +release-ready exactly as merged; there is no stabilization window for follow-up +fixes. + +## Code style + +Follow `.editorconfig`: +- Python: 4-space indent +- Everything else (TypeScript, YAML, JSON, HTML): 2-space indent +- UTF-8, LF line endings, trim trailing whitespace, final newline + +Frontend additionally uses ESLint (`ui/eslint.config.js`) and Prettier (config in `ui/package.json`: `printWidth=100`, `singleQuote=true`). + +## Project structure + +``` +app/main.py — HTTP server, Socket.IO events, REST API routes, Config class +app/ytdl.py — Download queue logic, yt-dlp integration +app/subscriptions.py — Channel/playlist subscription manager +app/state_store.py — JSON-based persistent storage with atomic writes +app/dl_formats.py — Video/audio codec/quality mapping +app/tests/ — pytest tests (asyncio_mode=auto) +ui/src/app/ — Angular standalone components (no NgModules) +``` + +## Key conventions + +- Backend configuration lives in the `Config` class in `app/main.py` with env-var defaults in `_DEFAULTS`. New env vars go there. +- Real-time communication uses Socket.IO events, not REST polling. +- Frontend uses standalone Angular components with `inject()` for DI, RxJS Subjects for state, and `takeUntilDestroyed()` for cleanup. +- Frontend components use OnPush change detection: subscribe callbacks must call `cdr.markForCheck()`. +- State is persisted as JSON files via `AtomicJsonStore` in `app/state_store.py`. +- Persisted state stays compact: the completed queue deliberately drops bulky entry data (see `_compact_persisted_entry` in `app/ytdl.py`). Don't expand what gets persisted without discussion. +- Custom yt-dlp postprocessors added to `ytdl_params['postprocessors']` run in **list order** within a stage. When combining postprocessors, mirror the ordering the yt-dlp CLI would produce (e.g. sponsor-segment removal before chapter splitting). +- No pre-commit hooks — linting and tests are enforced in CI only. + +## Checklist: adding a per-download option + +New options on the download form (the `split_by_chapters` pattern) need **all** of +these pieces — the last three are the ones commonly missed: + +1. `parse_download_options` in `app/main.py`. +2. A field on `DownloadInfo` in `app/ytdl.py`. +3. A `hasattr` backfill in `DownloadInfo.__setstate__` for old persisted records. +4. The safe-deserialization field list in `app/ytdl.py`. +5. UI form control + cookie persistence in `ui/src/app/app.ts` / `app.html`, and + the payload in `downloads.service.ts` (plus its spec). +6. The redownload path in `app.ts`, so retries carry the option. +7. If the option makes sense for unattended downloads: threading through + `app/subscriptions.py` (`SubscriptionInfo` field, serializer, add/update + routes, the enqueue call) — or a note in the PR that it's deliberately + direct-downloads-only. + +## Security invariants + +User input and extractor-provided metadata (titles, playlist names, URLs) are +untrusted. Use the existing guards instead of hand-rolling: + +- User-submitted URLs go through the SSRF guard (see `test_url_guard.py` for the + expected behavior). +- Anything that becomes a filesystem path goes through `_is_within_directory` and + `_sanitize_path_component` in `app/ytdl.py` — including values that arrive via + yt-dlp metadata, which sites can influence. diff --git a/Dockerfile b/Dockerfile index 3772784..4a72f0e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,8 @@ -FROM node:lts-alpine AS builder +# Pinned to a major version rather than the lts-alpine floating tag: that tag +# has lagged behind and resolved to a Node patch older than the Angular CLI's +# minimum supported version, breaking the build. node:22-alpine currently +# satisfies @angular/cli's >=22.22.3 requirement. +FROM node:22-alpine AS builder WORKDIR /metube COPY ui ./ @@ -26,15 +30,12 @@ RUN sed -i 's/\r$//g' docker-entrypoint.sh && \ gosu \ curl \ tini \ - file \ - gdbmtool \ - sqlite3 \ build-essential && \ curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh && \ UV_PROJECT_ENVIRONMENT=/usr/local uv sync --frozen --no-dev --compile-bytecode && \ uv cache clean && \ rm -f /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/uvw && \ - curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s -- -y v2.7.2 && \ + curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s -- -y && \ apt-get purge -y --auto-remove build-essential && \ rm -rf /var/lib/apt/lists/* && \ mkdir /.cache && chmod 777 /.cache @@ -63,11 +64,14 @@ ENV PUID=1000 ENV PGID=1000 ENV UMASK=022 -ENV DOWNLOAD_DIR /downloads -ENV STATE_DIR /downloads/.metube -ENV TEMP_DIR /downloads +ENV DOWNLOAD_DIR=/downloads +ENV STATE_DIR=/downloads/.metube +ENV TEMP_DIR=/downloads +ENV PORT=8081 VOLUME /downloads EXPOSE 8081 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD case "$HTTPS" in true|True|on|1) curl -fsSk "https://localhost:${PORT}/";; *) curl -fsS "http://localhost:${PORT}/";; esac || exit 1 # Add build-time argument for version ARG VERSION=dev diff --git a/README.md b/README.md index a913a0f..a73b8b4 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,12 @@ ![Build Status](https://github.com/alexta69/metube/actions/workflows/main.yml/badge.svg) ![Docker Pulls](https://img.shields.io/docker/pulls/alexta69/metube.svg) -Web GUI for youtube-dl (using the [yt-dlp](https://github.com/yt-dlp/yt-dlp) fork) with playlist support. Allows you to download videos from YouTube and [dozens of other sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md). +MeTube is a self-hosted web UI for `yt-dlp`, for downloading media from YouTube and [dozens of other sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md). Docker images are multi-arch (amd64/arm64). + +Key capabilities: +* Download videos, audio, captions, and thumbnails from a browser UI. +* Download playlists and channels, with configurable output and download options. +* [Subscribe](https://github.com/alexta69/metube/wiki/Subscriptions) to channels and playlists, periodically check for new items, and queue new uploads automatically. ![screenshot1](https://github.com/alexta69/metube/raw/master/screenshot.gif) @@ -13,7 +18,7 @@ Web GUI for youtube-dl (using the [yt-dlp](https://github.com/yt-dlp/yt-dlp) for docker run -d -p 8081:8081 -v /path/to/downloads:/downloads ghcr.io/alexta69/metube ``` -## 🐳 Run using docker-compose +## 🐳 Run using Docker Compose ```yaml services: @@ -29,13 +34,26 @@ services: ## ⚙️ Configuration via environment variables -Certain values can be set via environment variables, using the `-e` parameter on the docker command line, or the `environment:` section in docker-compose. +Certain values can be set via environment variables, using the `-e` parameter on the docker command line, or the `environment:` section in Docker Compose. + +### 🏠 Runtime & Permissions + +* __PUID__: User under which MeTube will run. Defaults to `1000` (legacy `UID` also supported). +* __PGID__: Group under which MeTube will run. Defaults to `1000` (legacy `GID` also supported). +* __UMASK__: Umask value used by MeTube. Defaults to `022`. +* __DEFAULT_THEME__: Default theme to use for the UI, can be set to `light`, `dark`, or `auto`. Defaults to `auto`. +* __LOGLEVEL__: Log level, can be set to `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`, or `NONE`. Defaults to `INFO`. +* __ENABLE_ACCESSLOG__: Whether to enable access log. Defaults to `false`. ### ⬇️ Download Behavior -* __MAX_CONCURRENT_DOWNLOADS__: Maximum number of simultaneous downloads allowed. For example, if set to `5`, then at most five downloads will run concurrently, and any additional downloads will wait until one of the active downloads completes. Defaults to `3`. +* __MAX_CONCURRENT_DOWNLOADS__: Maximum number of simultaneous downloads allowed. For example, if set to `5`, then at most five downloads will run concurrently, and any additional downloads will wait until one of the active downloads completes. Defaults to `3`. * __DELETE_FILE_ON_TRASHCAN__: if `true`, downloaded files are deleted on the server, when they are trashed from the "Completed" section of the UI. Defaults to `false`. * __DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT__: Maximum number of playlist items that can be downloaded. Defaults to `0` (no limit). +* __SUBSCRIPTION_DEFAULT_CHECK_INTERVAL__: Default minutes between automatic checks for each subscription. Defaults to `60`. +* __SUBSCRIPTION_SCAN_PLAYLIST_END__: Maximum playlist/channel entries to fetch per subscription check (newest-first). Defaults to `50`. +* __SUBSCRIPTION_MAX_SEEN_IDS__: Cap on stored video IDs per subscription to limit state file growth. Defaults to `50000`. +* __CLEAR_COMPLETED_AFTER__: Number of seconds after which completed (and failed) downloads are automatically removed from the "Completed" list. Defaults to `0` (disabled). ### 📁 Storage & Directories @@ -45,7 +63,7 @@ Certain values can be set via environment variables, using the `-e` parameter on * __CREATE_CUSTOM_DIRS__: Whether to support automatically creating directories within the __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__) if they do not exist. When enabled, the download directory selector supports free-text input, and the specified directory will be created recursively. Defaults to `true`. * __CUSTOM_DIRS_EXCLUDE_REGEX__: Regular expression to exclude some custom directories from the dropdown. Empty regex disables exclusion. Defaults to `(^|/)[.@].*$`, which means directories starting with `.` or `@`. * __DOWNLOAD_DIRS_INDEXABLE__: If `true`, the download directories (__DOWNLOAD_DIR__ and __AUDIO_DOWNLOAD_DIR__) are indexable on the web server. Defaults to `false`. -* __STATE_DIR__: Path to where the queue persistence files will be saved. Defaults to `/downloads/.metube` in the Docker image, and `.` otherwise. +* __STATE_DIR__: Path to where MeTube will store its persistent state files (`queue.json`, `pending.json`, `completed.json`, `subscriptions.json`). Defaults to `/downloads/.metube` in the Docker image, and `.` otherwise. * __TEMP_DIR__: Path where intermediary download files will be saved. Defaults to `/downloads` in the Docker image, and `.` otherwise. * Set this to an SSD or RAM filesystem (e.g., `tmpfs`) for better performance. * __Note__: Using a RAM filesystem may prevent downloads from being resumed. @@ -55,10 +73,14 @@ Certain values can be set via environment variables, using the `-e` parameter on * __OUTPUT_TEMPLATE__: The template for the filenames of the downloaded videos, formatted according to [this spec](https://github.com/yt-dlp/yt-dlp/blob/master/README.md#output-template). Defaults to `%(title)s.%(ext)s`. * __OUTPUT_TEMPLATE_CHAPTER__: The template for the filenames of the downloaded videos when split into chapters via postprocessors. Defaults to `%(title)s - %(section_number)s %(section_title)s.%(ext)s`. -* __OUTPUT_TEMPLATE_PLAYLIST__: The template for the filenames of the downloaded videos when downloaded as a playlist. Defaults to `%(playlist_title)s/%(title)s.%(ext)s`. When empty, then `OUTPUT_TEMPLATE` is used. -* __OUTPUT_TEMPLATE_CHANNEL__: The template for the filenames of the downloaded videos when downloaded as a channel. Defaults to `%(channel)s/%(title)s.%(ext)s`. When empty, then `OUTPUT_TEMPLATE` is used. -* __YTDL_OPTIONS__: Additional options to pass to yt-dlp in JSON format. [See available options here](https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/YoutubeDL.py#L222). They roughly correspond to command-line options, though some do not have exact equivalents here. For example, `--recode-video` has to be specified via `postprocessors`. Also note that dashes are replaced with underscores. You may find [this script](https://github.com/yt-dlp/yt-dlp/blob/master/devscripts/cli_to_api.py) helpful for converting from command-line options to `YTDL_OPTIONS`. -* __YTDL_OPTIONS_FILE__: A path to a JSON file that will be loaded and used for populating `YTDL_OPTIONS` above. Please note that if both `YTDL_OPTIONS_FILE` and `YTDL_OPTIONS` are specified, the options in `YTDL_OPTIONS` take precedence. The file will be monitored for changes and reloaded automatically when changes are detected. +* __OUTPUT_TEMPLATE_PLAYLIST__: The template for the filenames of the downloaded videos when downloaded as a playlist. Defaults to `%(playlist_title)s/%(title)s.%(ext)s`. Set to empty to use `OUTPUT_TEMPLATE` instead. +* __OUTPUT_TEMPLATE_CHANNEL__: The template for the filenames of the downloaded videos when downloaded as a channel. Defaults to `%(channel)s/%(title)s.%(ext)s`. Set to empty to use `OUTPUT_TEMPLATE` instead. +* __YTDL_OPTIONS__: Additional options to pass to yt-dlp, as a JSON object. See [Configuring yt-dlp options](#%EF%B8%8F-configuring-yt-dlp-options) for details, examples, and available options reference. +* __YTDL_OPTIONS_FILE__: Path to a JSON file containing yt-dlp options. Monitored and reloaded automatically on changes. See [Configuring yt-dlp options](#%EF%B8%8F-configuring-yt-dlp-options). +* __YTDL_OPTIONS_PRESETS__: Named bundles of yt-dlp options, selectable per download in the UI. See [Configuring yt-dlp options](#%EF%B8%8F-configuring-yt-dlp-options) for format and examples. +* __YTDL_OPTIONS_PRESETS_FILE__: Path to a JSON file containing presets. Monitored and reloaded automatically on changes. See [Configuring yt-dlp options](#%EF%B8%8F-configuring-yt-dlp-options). +* __ALLOW_YTDL_OPTIONS_OVERRIDES__: Whether to show a free-text field in the UI for per-download yt-dlp option overrides. Defaults to `false`. See [Configuring yt-dlp options](#%EF%B8%8F-configuring-yt-dlp-options) for details and security considerations. +* __YTDL_NIGHTLY_UPDATE_TIME__: If set, will cause MeTube to use [nightly yt-dlp builds](https://github.com/yt-dlp/yt-dlp-nightly-builds) instead of the stable releases. Set to the time (`HH:MM`, 24-hour) when you want the daily upgrades and MeTube restart to happen. Defaults to empty (disabled). ### 🌐 Web Server & URLs @@ -70,16 +92,126 @@ Certain values can be set via environment variables, using the `-e` parameter on * __HTTPS__: Use `https` instead of `http` (__CERTFILE__ and __KEYFILE__ required). Defaults to `false`. * __CERTFILE__: HTTPS certificate file path. * __KEYFILE__: HTTPS key file path. +* __CORS_ALLOWED_ORIGINS__: Comma-separated list of origins permitted to make cross-origin requests to the MeTube API; `*` allows all. When unset or empty, all cross-origin requests are denied. Required for browser extensions and bookmarklets — see [Sending links to MeTube](#-sending-links-to-metube). * __ROBOTS_TXT__: A path to a `robots.txt` file mounted in the container. -### 🏠 Basic Setup +## 🎛️ Configuring yt-dlp options -* __PUID__: User under which MeTube will run. Defaults to `1000` (legacy `UID` also supported). -* __PGID__: Group under which MeTube will run. Defaults to `1000` (legacy `GID` also supported). -* __UMASK__: Umask value used by MeTube. Defaults to `022`. -* __DEFAULT_THEME__: Default theme to use for the UI, can be set to `light`, `dark`, or `auto`. Defaults to `auto`. -* __LOGLEVEL__: Log level, can be set to `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`, or `NONE`. Defaults to `INFO`. -* __ENABLE_ACCESSLOG__: Whether to enable access log. Defaults to `false`. +MeTube lets you customize how [yt-dlp](https://github.com/yt-dlp/yt-dlp) behaves at three levels, from broadest to most specific: + +1. **Global options** — apply to every download by default. +2. **Presets** — named bundles of options that users can pick per download from the UI. +3. **Per-download overrides** — free-form options entered in the UI for a single download. + +When a download starts, these layers are combined in order. If the same option appears in more than one layer, the more specific one wins: per-download overrides beat presets, and presets beat global options. + +In JSON presets and overrides, setting an option to **`null`** clears that option for that download (for example, `"download_archive": null` overrides a global archive path so the archive is not used). This follows yt-dlp’s usual meaning of `None` for that option. + +### Option format + +yt-dlp options in MeTube are expressed as JSON objects. The keys are yt-dlp API option names, which roughly correspond to command-line flags with dashes replaced by underscores. For example, the command-line flag `--write-subs` becomes `"writesubtitles": true` in JSON. + +> **Tip:** Some command-line flags don't have a direct single-key equivalent — for instance, `--embed-thumbnail` and `--recode-video` must be expressed via `"postprocessors"`. A full list of available API options can be found [in the yt-dlp source](https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/YoutubeDL.py#L224), and [this conversion script](https://github.com/yt-dlp/yt-dlp/blob/master/devscripts/cli_to_api.py) can help translate command-line flags to their API equivalents. + +### Global options + +Global options form the baseline for every download. There are two ways to define them, and you can use either or both: + +**Inline via environment variable** (`YTDL_OPTIONS`) — pass a JSON object directly: + +```yaml +environment: + - 'YTDL_OPTIONS={"writesubtitles": true, "subtitleslangs": ["en", "de"], "updatetime": false, "writethumbnail": true}' +``` + +**Via a JSON file** (`YTDL_OPTIONS_FILE`) — mount a file into the container and point to it: + +```yaml +volumes: + - /path/to/ytdl-options.json:/config/ytdl-options.json +environment: + - YTDL_OPTIONS_FILE=/config/ytdl-options.json +``` + +where `ytdl-options.json` contains: + +```json +{ + "writesubtitles": true, + "subtitleslangs": ["en", "de"], + "updatetime": false, + "writethumbnail": true +} +``` + +The file is monitored for changes and reloaded automatically — no container restart needed. If you use both methods and they define the same key, the **file takes precedence**. + +### Presets + +Presets let you define named bundles of options that appear in the web UI under **Advanced Options** as "Option Presets". Users can select one or more presets per download, making it easy to apply common option combinations without editing global settings. + +Like global options, presets can be set inline or via a file: + +* `YTDL_OPTIONS_PRESETS` — a JSON object where each key is a preset name and its value is a set of yt-dlp options. +* `YTDL_OPTIONS_PRESETS_FILE` — path to a JSON file containing presets, monitored and reloaded on changes. + +If both are used and they define a preset with the same name, the **file's version takes precedence**. + +**Example** — a presets file defining three presets: + +```json +{ + "sponsorblock": { + "postprocessors": [ + { "key": "SponsorBlock", "categories": ["sponsor", "selfpromo", "interaction"] }, + { "key": "ModifyChapters", "remove_sponsor_segments": ["sponsor", "selfpromo", "interaction"] } + ] + }, + "embed-subs": { + "writesubtitles": true, + "writeautomaticsub": true, + "subtitleslangs": ["en", "de"], + "postprocessors": [{ "key": "FFmpegEmbedSubtitle" }] + }, + "limit-rate": { + "ratelimit": 5000000 + } +} +``` + +This makes three presets available in the UI: +* **sponsorblock** — strips sponsor, self-promo, and interaction segments from videos. +* **embed-subs** — downloads English and German subtitles and embeds them into the video file. +* **limit-rate** — caps download speed to ~5 MB/s. + +When multiple presets are selected for a download, they are applied in order. If two presets set the same option, the later one wins. + +### Per-download overrides + +For one-off tweaks, MeTube can expose a free-text JSON field in the UI ("Custom yt-dlp Options") where users type yt-dlp options that apply only to that single download. This is disabled by default: + +```yaml +environment: + - ALLOW_YTDL_OPTIONS_OVERRIDES=true +``` + +Once enabled, the field appears under **Advanced Options**. Any options entered there take the highest priority, overriding both global options and selected presets. + +> **⚠️ Security note:** Enabling this allows arbitrary yt-dlp API options to be supplied by anyone with access to the UI. Depending on the options used, this may enable arbitrary command execution inside the container. Enable only in trusted environments. + +### How the layers combine + +When a download starts, the final set of yt-dlp options is built in this order: + +1. Start with **global options** (`YTDL_OPTIONS` / `YTDL_OPTIONS_FILE`). +2. Apply each selected **preset** in order (later presets overwrite earlier ones for conflicting keys). +3. Apply any **per-download overrides** on top (overwrite everything else for conflicting keys). + +MeTube always forces its own flat-extract behaviour during the initial metadata fetch (`extract_flat`, `noplaylist`, etc.); presets cannot override those keys for that phase. + +**Example:** Suppose your global options set `"writesubtitles": false`, but you select a preset that sets `"writesubtitles": true`. Subtitles will be written for that download because the preset overrides the global setting. If you additionally enter `{"writesubtitles": false}` in the per-download overrides field, that value wins and subtitles will not be written. + +### Configuration cookbooks The project's Wiki contains examples of useful configurations contributed by users of MeTube: * [YTDL_OPTIONS Cookbook](https://github.com/alexta69/metube/wiki/YTDL_OPTIONS-Cookbook) @@ -97,62 +229,27 @@ In case you need to use your browser's cookies with MeTube, for example to downl * After upload, the cookie indicator should show as active. * Use **Delete Cookies** in the same section to remove uploaded cookies. -## 🔌 Browser extensions +## 🔗 Sending links to MeTube -Browser extensions allow right-clicking videos and sending them directly to MeTube. Please note that if you're on an HTTPS page, your MeTube instance must be behind an HTTPS reverse proxy (see below) for the extensions to work. +Several integrations let you send URLs to MeTube from wherever you are, instead of pasting them into the UI. The browser-based ones make cross-origin requests, so they require `CORS_ALLOWED_ORIGINS` to be set; and if you're on an HTTPS page, your MeTube instance must be served over HTTPS too (with `HTTPS=true` or behind an HTTPS reverse proxy — see below). -__Chrome:__ contributed by [Rpsl](https://github.com/rpsl). You can install it from [Google Chrome Webstore](https://chrome.google.com/webstore/detail/metube-downloader/fbmkmdnlhacefjljljlbhkodfmfkijdh) or use developer mode and install [from sources](https://github.com/Rpsl/metube-browser-extension). +__Browser extensions__ allow right-clicking videos and sending them directly to MeTube. Since extensions request from their own origin, set `CORS_ALLOWED_ORIGINS=*`. +* __Chrome:__ contributed by [Rpsl](https://github.com/rpsl) — install from the [Chrome Webstore](https://chrome.google.com/webstore/detail/metube-downloader/fbmkmdnlhacefjljljlbhkodfmfkijdh) or [from sources](https://github.com/Rpsl/metube-browser-extension). +* __Firefox:__ contributed by [nanocortex](https://github.com/nanocortex) — install from [Firefox Addons](https://addons.mozilla.org/en-US/firefox/addon/metube-downloader) or get sources [here](https://github.com/nanocortex/metube-firefox-addon). -__Firefox:__ contributed by [nanocortex](https://github.com/nanocortex). You can install it from [Firefox Addons](https://addons.mozilla.org/en-US/firefox/addon/metube-downloader) or get sources from [here](https://github.com/nanocortex/metube-firefox-addon). +__Bookmarklets__ send the currently open page to MeTube with one click. Add the origins of the sites where you use them to `CORS_ALLOWED_ORIGINS`, e.g. `https://www.youtube.com,https://www.vimeo.com`. The code (Chrome and Firefox variants, contributed by [kushfest](https://github.com/kushfest) and [shoonya75](https://github.com/shoonya75)) is in the [Bookmarklets wiki page](https://github.com/alexta69/metube/wiki/Bookmarklets). -## 📱 iOS Shortcut +__iOS Shortcut:__ [rithask](https://github.com/rithask) created an [iOS shortcut](https://www.icloud.com/shortcuts/66627a9f334c467baabdb2769763a1a6) for sending URLs to MeTube from Safari's share menu; it prompts for your instance address on first use. -[rithask](https://github.com/rithask) created an iOS shortcut to send URLs to MeTube from Safari. Enter the MeTube instance address when prompted which will be saved for later use. You can run the shortcut from Safari’s share menu. The shortcut can be downloaded from [this iCloud link](https://www.icloud.com/shortcuts/66627a9f334c467baabdb2769763a1a6). +__Raycast:__ [dotvhs](https://github.com/dotvhs) has created an [extension for Raycast](https://www.raycast.com/dot/metube) for adding videos to MeTube directly from Raycast. -## 📱 iOS Compatibility +## 🎵 Pairing with a music tagger -iOS has strict requirements for video files, requiring h264 or h265 video codec and aac audio codec in MP4 container. This can sometimes be a lower quality than the best quality available. To accommodate iOS requirements, when downloading a MP4 format you can choose "Best (iOS)" to get the best quality formats as compatible as possible with iOS requirements. +MeTube deliberately stops once the file is written — tagging and library organization belong to dedicated tools. Point one at your audio download folder (`AUDIO_DOWNLOAD_DIR`): -To force all downloads to be converted to an iOS-compatible codec, insert this as an environment variable: - -```yaml - environment: - - 'YTDL_OPTIONS={"format": "best", "exec": "ffmpeg -i %(filepath)q -c:v libx264 -c:a aac %(filepath)q.h264.mp4"}' -``` - -## 🔖 Bookmarklet - -[kushfest](https://github.com/kushfest) has created a Chrome bookmarklet for sending the currently open webpage to MeTube. Please note that if you're on an HTTPS page, your MeTube instance must be configured with `HTTPS` as `true` in the environment, or be behind an HTTPS reverse proxy (see below) for the bookmarklet to work. - -GitHub doesn't allow embedding JavaScript as a link, so the bookmarklet has to be created manually by copying the following code to a new bookmark you create on your bookmarks bar. Change the hostname in the URL below to point to your MeTube instance. - -```javascript -javascript:!function(){xhr=new XMLHttpRequest();xhr.open("POST","https://metube.domain.com/add");xhr.withCredentials=true;xhr.send(JSON.stringify({"url":document.location.href,"quality":"best"}));xhr.onload=function(){if(xhr.status==200){alert("Sent to metube!")}else{alert("Send to metube failed. Check the javascript console for clues.")}}}(); -``` - -[shoonya75](https://github.com/shoonya75) has contributed a Firefox version: - -```javascript -javascript:(function(){xhr=new XMLHttpRequest();xhr.open("POST","https://metube.domain.com/add");xhr.send(JSON.stringify({"url":document.location.href,"quality":"best"}));xhr.onload=function(){if(xhr.status==200){alert("Sent to metube!")}else{alert("Send to metube failed. Check the javascript console for clues.")}}})(); -``` - -The above bookmarklets use `alert()` as a success/failure notification. The following will show a toast message instead: - -Chrome: - -```javascript -javascript:!function(){function notify(msg) {var sc = document.scrollingElement.scrollTop; var text = document.createElement('span');text.innerHTML=msg;var ts = text.style;ts.all = 'revert';ts.color = '#000';ts.fontFamily = 'Verdana, sans-serif';ts.fontSize = '15px';ts.backgroundColor = 'white';ts.padding = '15px';ts.border = '1px solid gainsboro';ts.boxShadow = '3px 3px 10px';ts.zIndex = '100';document.body.appendChild(text);ts.position = 'absolute'; ts.top = 50 + sc + 'px'; ts.left = (window.innerWidth / 2)-(text.offsetWidth / 2) + 'px'; setTimeout(function () { text.style.visibility = "hidden"; }, 1500);}xhr=new XMLHttpRequest();xhr.open("POST","https://metube.domain.com/add");xhr.send(JSON.stringify({"url":document.location.href,"quality":"best"}));xhr.onload=function() { if(xhr.status==200){notify("Sent to metube!")}else {notify("Send to metube failed. Check the javascript console for clues.")}}}(); -``` - -Firefox: - -```javascript -javascript:(function(){function notify(msg) {var sc = document.scrollingElement.scrollTop; var text = document.createElement('span');text.innerHTML=msg;var ts = text.style;ts.all = 'revert';ts.color = '#000';ts.fontFamily = 'Verdana, sans-serif';ts.fontSize = '15px';ts.backgroundColor = 'white';ts.padding = '15px';ts.border = '1px solid gainsboro';ts.boxShadow = '3px 3px 10px';ts.zIndex = '100';document.body.appendChild(text);ts.position = 'absolute'; ts.top = 50 + sc + 'px'; ts.left = (window.innerWidth / 2)-(text.offsetWidth / 2) + 'px'; setTimeout(function () { text.style.visibility = "hidden"; }, 1500);}xhr=new XMLHttpRequest();xhr.open("POST","https://metube.domain.com/add");xhr.send(JSON.stringify({"url":document.location.href,"quality":"best"}));xhr.onload=function() { if(xhr.status==200){notify("Sent to metube!")}else {notify("Send to metube failed. Check the javascript console for clues.")}}})(); -``` - -## ⚡ Raycast extension - -[dotvhs](https://github.com/dotvhs) has created an [extension for Raycast](https://www.raycast.com/dot/metube) that allows adding videos to MeTube directly from Raycast. +* [beets](https://beets.io) — `beet import` matches tracks against MusicBrainz, fixes tags, and files them into an Artist/Album library; headless and scriptable. +* [MusicBrainz Picard](https://picard.musicbrainz.org) — GUI tagger with acoustic fingerprinting. +* [Lidarr](https://lidarr.audio) — full music library manager; add the folder as an import path. ## 🔒 HTTPS support, and running behind a reverse proxy @@ -176,13 +273,7 @@ services: - KEYFILE=/ssl/key.pem ``` -It's also possible to run MeTube behind a reverse proxy, in order to support authentication. HTTPS support can also be added in this way. - -When running behind a reverse proxy which remaps the URL (i.e. serves MeTube under a subdirectory and not under root), don't forget to set the URL_PREFIX environment variable to the correct value. - -If you're using the [linuxserver/swag](https://docs.linuxserver.io/general/swag) image for your reverse proxying needs (which I can heartily recommend), it already includes ready snippets for proxying MeTube both in [subfolder](https://github.com/linuxserver/reverse-proxy-confs/blob/master/metube.subfolder.conf.sample) and [subdomain](https://github.com/linuxserver/reverse-proxy-confs/blob/master/metube.subdomain.conf.sample) modes under the `nginx/proxy-confs` directory in the configuration volume. It also includes Authelia which can be used for authentication. - -### 🌐 NGINX +MeTube can also run behind a reverse proxy for HTTPS termination or authentication. When serving under a subdirectory, set `URL_PREFIX` accordingly. MeTube uses WebSocket for real-time updates, so the proxy must pass the `Upgrade`/`Connection` headers, as in this NGINX example: ```nginx location /metube/ { @@ -194,66 +285,26 @@ location /metube/ { } ``` -Note: the extra `proxy_set_header` directives are there to make WebSocket work. - -### 🌐 Apache - -Contributed by [PIE-yt](https://github.com/PIE-yt). Source [here](https://gist.github.com/PIE-yt/29e7116588379032427f5bd446b2cac4). - -```apache -# For putting in your Apache sites site.conf -# Serves MeTube under a /metube/ subdir (http://yourdomain.com/metube/) - - ProxyPass http://localhost:8081/ retry=0 timeout=30 - ProxyPassReverse http://localhost:8081/ - - - - RewriteEngine On - RewriteCond %{QUERY_STRING} transport=websocket [NC] - RewriteRule /(.*) ws://localhost:8081/socket.io/$1 [P,L] - ProxyPass http://localhost:8081/socket.io retry=0 timeout=30 - ProxyPassReverse http://localhost:8081/socket.io - -``` - -### 🌐 Caddy - -The following example Caddyfile gets a reverse proxy going behind [caddy](https://caddyserver.com). - -```caddyfile -example.com { - route /metube/* { - uri strip_prefix metube - reverse_proxy metube:8081 - } -} -``` +Apache, Caddy, and [linuxserver/swag](https://docs.linuxserver.io/general/swag) (with Authelia) examples are in the [Reverse proxy configurations wiki page](https://github.com/alexta69/metube/wiki/Reverse-proxy-configurations). ## 🔄 Updating yt-dlp -The engine which powers the actual video downloads in MeTube is [yt-dlp](https://github.com/yt-dlp/yt-dlp). Since video sites regularly change their layouts, frequent updates of yt-dlp are required to keep up. - -There's an automatic nightly build of MeTube which looks for a new version of yt-dlp, and if one exists, the build pulls it and publishes an updated docker image. Therefore, in order to keep up with the changes, it's recommended that you update your MeTube container regularly with the latest image. - -I recommend installing and setting up [watchtower](https://github.com/nicholas-fedor/watchtower) for this purpose. +MeTube is powered by [yt-dlp](https://github.com/yt-dlp/yt-dlp), which requires frequent updates as video sites change their layouts. A new MeTube Docker image is published automatically when a new yt-dlp stable release is available, so keep your container up to date — [watchtower](https://github.com/nicholas-fedor/watchtower) works well for this. To follow yt-dlp's nightly channel instead, set `YTDL_NIGHTLY_UPDATE_TIME`. ## 🔧 Troubleshooting and submitting issues -Before asking a question or submitting an issue for MeTube, please remember that MeTube is only a UI for [yt-dlp](https://github.com/yt-dlp/yt-dlp). Any issues you might be experiencing with authentication to video websites, postprocessing, permissions, other `YTDL_OPTIONS` configurations which seem not to work, or anything else that concerns the workings of the underlying yt-dlp library, need not be opened on the MeTube project. In order to debug and troubleshoot them, it's advised to try using the yt-dlp binary directly first, bypassing the UI, and once that is working, importing the options that worked for you into `YTDL_OPTIONS`. - -In order to test with the yt-dlp command directly, you can either download it and run it locally, or for a better simulation of its actual conditions, you can run it within the MeTube container itself. Assuming your MeTube container is called `metube`, run the following on your Docker host to get a shell inside the container: +MeTube is only a UI for [yt-dlp](https://github.com/yt-dlp/yt-dlp). Issues with authentication, postprocessing, permissions, or `YTDL_OPTIONS` should be debugged with yt-dlp directly first — once working, import those options into MeTube. To test inside the container: ```bash docker exec -ti metube sh cd /downloads ``` -Once there, you can use the yt-dlp command freely. +Common issues and their fixes are collected in the [Troubleshooting FAQ](https://github.com/alexta69/metube/wiki/Troubleshooting-FAQ) on the wiki. ## 💡 Submitting feature requests -MeTube development relies on code contributions by the community. The program as it currently stands fits my own use cases, and is therefore feature-complete as far as I'm concerned. If your use cases are different and require additional features, please feel free to submit PRs that implement those features. It's advisable to create an issue first to discuss the planned implementation, because in an effort to reduce bloat, some PRs may not be accepted. However, note that opening a feature request when you don't intend to implement the feature will rarely result in the request being fulfilled. +MeTube development relies on community contributions. If you need additional features, please submit a PR. Create an issue first to discuss the implementation before writing code — MeTube's scope is deliberately narrow: it downloads well and stops once the file is written. Features that improve the download itself are welcome; post-download file management (tag editing, metadata lookups, library organization) is out of scope regardless of implementation quality — see [AGENTS.md](AGENTS.md) for the full policy. Feature requests without an accompanying PR are unlikely to be fulfilled. ## 🛠️ Building and running locally diff --git a/app/bg_tasks.py b/app/bg_tasks.py new file mode 100644 index 0000000..2a1da6c --- /dev/null +++ b/app/bg_tasks.py @@ -0,0 +1,26 @@ +import asyncio +import logging + +log = logging.getLogger("bg_tasks") +_TASKS: set[asyncio.Task] = set() + + +def create_task(coro, *, name: str | None = None) -> asyncio.Task: + """create_task that keeps a strong reference and logs unexpected failures. + + A bare ``asyncio.create_task(...)`` is only weakly referenced by the event + loop; if nothing else holds the returned Task, it can be garbage collected + mid-flight. Keeping a module-level strong reference (removed once the task + finishes) avoids that, and the done-callback surfaces otherwise-silent + failures. + """ + task = asyncio.get_running_loop().create_task(coro, name=name) + _TASKS.add(task) + + def _done(t: asyncio.Task) -> None: + _TASKS.discard(t) + if not t.cancelled() and t.exception() is not None: + log.error("Background task %s failed", t.get_name(), exc_info=t.exception()) + + task.add_done_callback(_done) + return task diff --git a/app/dl_formats.py b/app/dl_formats.py index e94ec9a..9fcd779 100644 --- a/app/dl_formats.py +++ b/app/dl_formats.py @@ -4,6 +4,28 @@ AUDIO_FORMATS = ("m4a", "mp3", "opus", "wav", "flac") CAPTION_MODES = ("auto_only", "manual_only", "prefer_manual", "prefer_auto") +def merge_ytdl_option_layers(presets, overrides, presets_config) -> dict: + """Overlay named presets (in order) then per-item overrides onto a fresh dict. + + Does NOT include any base ``YTDL_OPTIONS`` — callers layer this on top of + their own base (a per-download build adds the global base; a subscription + scan relies on ``**config.YTDL_OPTIONS`` already being present in its + params). ``presets_config`` maps a preset name to its options dict. + """ + merged: dict = {} + for name in presets or []: + merged.update(presets_config.get(name, {})) + merged.update(overrides or {}) + return merged + +CODEC_FILTER_MAP = { + 'h264': "[vcodec~='^(h264|avc)']", + 'h265': "[vcodec~='^(h265|hevc)']", + 'av1': "[vcodec~='^av0?1']", + 'vp9': "[vcodec~='^vp0?9']", +} + + def _normalize_caption_mode(mode: str) -> str: mode = (mode or "").strip() return mode if mode in CAPTION_MODES else "prefer_manual" @@ -14,84 +36,92 @@ def _normalize_subtitle_language(language: str) -> str: return language or "en" -def get_format(format: str, quality: str) -> str: +def get_format(download_type: str, codec: str, format: str, quality: str) -> str: """ - Returns format for download + Returns yt-dlp format selector. Args: - format (str): format selected - quality (str): quality selected + download_type (str): selected content type (video, audio, captions, thumbnail) + codec (str): selected video codec (auto, h264, h265, av1, vp9) + format (str): selected output format/profile for type + quality (str): selected quality Raises: - Exception: unknown quality, unknown format + Exception: unknown type/format Returns: - dl_format: Formatted download string + str: yt-dlp format selector """ - format = format or "any" + download_type = (download_type or "video").strip().lower() + format = (format or "any").strip().lower() + codec = (codec or "auto").strip().lower() + quality = (quality or "best").strip().lower() if format.startswith("custom:"): + # Unreachable via the HTTP API (format is validated against a fixed + # set in main.py), but legacy persisted downloads may carry a + # custom: format from before that validation existed; removing this + # would crash PersistentQueue.load() for those records. return format[7:] - if format == "thumbnail": - # Quality is irrelevant in this case since we skip the download + if download_type == "thumbnail": return "bestaudio/best" - if format == "captions": - # Quality is irrelevant in this case since we skip the download + if download_type == "captions": return "bestaudio/best" - if format in AUDIO_FORMATS: - # Audio quality needs to be set post-download, set in opts + if download_type == "audio": + if format not in AUDIO_FORMATS: + raise ValueError(f"Unknown audio format {format}") return f"bestaudio[ext={format}]/bestaudio/best" - if format in ("mp4", "any"): - if quality == "audio": - return "bestaudio/best" - # video {res} {vfmt} + audio {afmt} {res} {vfmt} - vfmt, afmt = ("[ext=mp4]", "[ext=m4a]") if format == "mp4" else ("", "") - vres = f"[height<={quality}]" if quality not in ("best", "best_ios", "worst") else "" + if download_type == "video": + if format not in ("any", "mp4", "ios"): + raise ValueError(f"Unknown video format {format}") + vfmt, afmt = ("[ext=mp4]", "[ext=m4a]") if format in ("mp4", "ios") else ("", "") + vres = f"[height<={quality}]" if quality not in ("best", "worst") else "" vcombo = vres + vfmt + codec_filter = CODEC_FILTER_MAP.get(codec, "") - if quality == "best_ios": - # iOS has strict requirements for video files, requiring h264 or h265 - # video codec and aac audio codec in MP4 container. This format string - # attempts to get the fully compatible formats first, then the h264/h265 - # video codec with any M4A audio codec (because audio is faster to - # convert if needed), and falls back to getting the best available MP4 - # file. + if format == "ios": return f"bestvideo[vcodec~='^((he|a)vc|h26[45])']{vres}+bestaudio[acodec=aac]/bestvideo[vcodec~='^((he|a)vc|h26[45])']{vres}+bestaudio{afmt}/bestvideo{vcombo}+bestaudio{afmt}/best{vcombo}" + + if codec_filter: + return f"bestvideo{codec_filter}{vcombo}+bestaudio{afmt}/bestvideo{vcombo}+bestaudio{afmt}/best{vcombo}" return f"bestvideo{vcombo}+bestaudio{afmt}/best{vcombo}" - raise Exception(f"Unkown format {format}") + raise ValueError(f"Unknown download_type {download_type}") def get_opts( + download_type: str, + _codec: str, format: str, quality: str, ytdl_opts: dict, - subtitle_format: str = "srt", subtitle_language: str = "en", subtitle_mode: str = "prefer_manual", ) -> dict: """ - Returns extra download options - Mostly postprocessing options + Returns extra yt-dlp options/postprocessors. Args: - format (str): format selected - quality (str): quality of format selected (needed for some formats) + download_type (str): selected content type + codec (str): selected codec (unused currently, kept for API consistency) + format (str): selected format/profile + quality (str): selected quality ytdl_opts (dict): current options selected Returns: - ytdl_opts: Extra options + dict: extended options """ - + download_type = (download_type or "video").strip().lower() + format = (format or "any").strip().lower() opts = copy.deepcopy(ytdl_opts) postprocessors = [] - if format in AUDIO_FORMATS: + if download_type == "audio": postprocessors.append( { "key": "FFmpegExtractAudio", @@ -100,8 +130,7 @@ def get_opts( } ) - # Audio formats without thumbnail - if format not in ("wav") and "writethumbnail" not in opts: + if format != "wav" and "writethumbnail" not in opts: opts["writethumbnail"] = True postprocessors.append( { @@ -113,22 +142,34 @@ def get_opts( postprocessors.append({"key": "FFmpegMetadata"}) postprocessors.append({"key": "EmbedThumbnail"}) - if format == "thumbnail": + if download_type == "thumbnail": opts["skip_download"] = True opts["writethumbnail"] = True postprocessors.append( {"key": "FFmpegThumbnailsConvertor", "format": "jpg", "when": "before_dl"} ) - if format == "captions": + if download_type == "captions": mode = _normalize_caption_mode(subtitle_mode) language = _normalize_subtitle_language(subtitle_language) opts["skip_download"] = True - requested_subtitle_format = (subtitle_format or "srt").lower() - # txt is a derived, non-timed format produced from SRT after download. + requested_subtitle_format = (format or "srt").lower() if requested_subtitle_format == "txt": requested_subtitle_format = "srt" - opts["subtitlesformat"] = requested_subtitle_format + opts["subtitlesformat"] = f"{requested_subtitle_format}/best" + if requested_subtitle_format in ("srt", "vtt"): + # subtitlesformat above is only a preference: if the extractor + # doesn't natively offer this ext (e.g. YouTube has no native srt), + # yt-dlp silently falls back to whatever it has. ffmpeg can only + # convert to srt/vtt/ass/lrc, so only guarantee the requested + # container for those; other formats stay best-effort. + postprocessors.append( + { + "key": "FFmpegSubtitlesConvertor", + "format": requested_subtitle_format, + "when": "before_dl", + } + ) if mode == "manual_only": opts["writesubtitles"] = True opts["writeautomaticsub"] = False diff --git a/app/main.py b/app/main.py index 1c4bd66..fa575a9 100644 --- a/app/main.py +++ b/app/main.py @@ -4,8 +4,10 @@ import os import sys import asyncio +from datetime import datetime, timedelta from pathlib import Path from aiohttp import web +from aiohttp.web import GracefulExit from aiohttp.log import access_logger import ssl import socket @@ -14,27 +16,37 @@ import logging import json import pathlib import re +import time +from urllib.parse import parse_qs, urlencode, urlparse, urlunparse from watchfiles import DefaultFilter, Change, awatch -from ytdl import DownloadQueueNotifier, DownloadQueue +import bg_tasks +from ytdl import DownloadQueueNotifier, DownloadQueue, Download +from subscriptions import SubscriptionManager, SubscriptionNotifier, SubscriptionInfo, coerce_optional_bool from yt_dlp.version import __version__ as yt_dlp_version log = logging.getLogger('main') +_NIGHTLY_TIME_RE = re.compile(r'^([01]\d|2[0-3]):[0-5]\d$') +_RESTART_FOR_UPDATE = False + +def _request_graceful_exit() -> None: + raise GracefulExit() + + +def seconds_until_next_daily_time(time_hhmm: str, now: datetime | None = None) -> float: + """Seconds until the next occurrence of HH:MM in local time.""" + now = now or datetime.now() + hour, minute = map(int, time_hhmm.split(':')) + target = now.replace(hour=hour, minute=minute, second=0, microsecond=0) + if target <= now: + target += timedelta(days=1) + return (target - now).total_seconds() + def parseLogLevel(logLevel): - match logLevel: - case 'DEBUG': - return logging.DEBUG - case 'INFO': - return logging.INFO - case 'WARNING': - return logging.WARNING - case 'ERROR': - return logging.ERROR - case 'CRITICAL': - return logging.CRITICAL - case _: - return None + if not isinstance(logLevel, str): + return None + return getattr(logging, logLevel.upper(), None) # Configure logging before Config() uses it so early messages are not dropped. # Only configure if no handlers are set (avoid clobbering hosting app settings). @@ -60,8 +72,16 @@ class Config: 'OUTPUT_TEMPLATE_PLAYLIST': '%(playlist_title)s/%(title)s.%(ext)s', 'OUTPUT_TEMPLATE_CHANNEL': '%(channel)s/%(title)s.%(ext)s', 'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT' : '0', + 'SUBSCRIPTION_DEFAULT_CHECK_INTERVAL': '60', + 'SUBSCRIPTION_SCAN_PLAYLIST_END': '50', + 'SUBSCRIPTION_MAX_SEEN_IDS': '50000', + 'CLEAR_COMPLETED_AFTER': '0', 'YTDL_OPTIONS': '{}', 'YTDL_OPTIONS_FILE': '', + 'YTDL_OPTIONS_PRESETS': '{}', + 'YTDL_OPTIONS_PRESETS_FILE': '', + 'ALLOW_YTDL_OPTIONS_OVERRIDES': 'false', + 'CORS_ALLOWED_ORIGINS': '', 'ROBOTS_TXT': '', 'HOST': '0.0.0.0', 'PORT': '8081', @@ -70,12 +90,13 @@ class Config: 'KEYFILE': '', 'BASE_DIR': '', 'DEFAULT_THEME': 'auto', - 'MAX_CONCURRENT_DOWNLOADS': 3, + 'MAX_CONCURRENT_DOWNLOADS': '3', 'LOGLEVEL': 'INFO', 'ENABLE_ACCESSLOG': 'false', + 'YTDL_NIGHTLY_UPDATE_TIME': '', } - _BOOLEAN = ('DOWNLOAD_DIRS_INDEXABLE', 'CUSTOM_DIRS', 'CREATE_CUSTOM_DIRS', 'DELETE_FILE_ON_TRASHCAN', 'HTTPS', 'ENABLE_ACCESSLOG') + _BOOLEAN = ('DOWNLOAD_DIRS_INDEXABLE', 'CUSTOM_DIRS', 'CREATE_CUSTOM_DIRS', 'DELETE_FILE_ON_TRASHCAN', 'HTTPS', 'ENABLE_ACCESSLOG', 'ALLOW_YTDL_OPTIONS_OVERRIDES') def __init__(self): for k, v in self._DEFAULTS.items(): @@ -93,15 +114,61 @@ class Config: if not self.URL_PREFIX.endswith('/'): self.URL_PREFIX += '/' + # A blank PUBLIC_HOST_AUDIO_URL (e.g. set empty in a compose file) bypasses the + # default via os.environ.get, which would leave audio links root-relative and 404. + # Fall back to the 'audio_download/' route that serves AUDIO_DOWNLOAD_DIR. When + # PUBLIC_HOST_URL is also blank we leave it blank to preserve serving from web root. + if not self.PUBLIC_HOST_AUDIO_URL and self.PUBLIC_HOST_URL: + self.PUBLIC_HOST_AUDIO_URL = self._DEFAULTS['PUBLIC_HOST_AUDIO_URL'] + + for attr in ('PUBLIC_HOST_URL', 'PUBLIC_HOST_AUDIO_URL'): + val = getattr(self, attr) + if val and not val.endswith('/'): + setattr(self, attr, val + '/') + # Convert relative addresses to absolute addresses to prevent the failure of file address comparison if self.YTDL_OPTIONS_FILE and self.YTDL_OPTIONS_FILE.startswith('.'): self.YTDL_OPTIONS_FILE = str(Path(self.YTDL_OPTIONS_FILE).resolve()) + if self.YTDL_OPTIONS_PRESETS_FILE and self.YTDL_OPTIONS_PRESETS_FILE.startswith('.'): + self.YTDL_OPTIONS_PRESETS_FILE = str(Path(self.YTDL_OPTIONS_PRESETS_FILE).resolve()) + + if self.YTDL_NIGHTLY_UPDATE_TIME and not _NIGHTLY_TIME_RE.match(self.YTDL_NIGHTLY_UPDATE_TIME): + log.error( + 'Environment variable "YTDL_NIGHTLY_UPDATE_TIME" must be HH:MM (24-hour), got "%s"', + self.YTDL_NIGHTLY_UPDATE_TIME, + ) + sys.exit(1) + + self._validate_int('MAX_CONCURRENT_DOWNLOADS', minimum=1) + self._validate_int('PORT', minimum=1, maximum=65535) + self._validate_int('CLEAR_COMPLETED_AFTER', minimum=0) + self._validate_int('DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT', minimum=0) + self._validate_int('SUBSCRIPTION_DEFAULT_CHECK_INTERVAL', minimum=1) + self._validate_int('SUBSCRIPTION_SCAN_PLAYLIST_END', minimum=1) + self._validate_int('SUBSCRIPTION_MAX_SEEN_IDS', minimum=1) self._runtime_overrides = {} success,_ = self.load_ytdl_options() if not success: sys.exit(1) + success,_ = self.load_ytdl_option_presets() + if not success: + sys.exit(1) + + def _validate_int(self, key, *, minimum=None, maximum=None): + raw = getattr(self, key) + try: + value = int(raw) + except (TypeError, ValueError): + log.error('Environment variable "%s" must be an integer, got "%s"', key, raw) + sys.exit(1) + if minimum is not None and value < minimum: + log.error('Environment variable "%s" must be >= %d, got "%s"', key, minimum, raw) + sys.exit(1) + if maximum is not None and value > maximum: + log.error('Environment variable "%s" must be <= %d, got "%s"', key, maximum, raw) + sys.exit(1) def set_runtime_override(self, key, value): self._runtime_overrides[key] = value @@ -114,6 +181,27 @@ class Config: def _apply_runtime_overrides(self): self.YTDL_OPTIONS.update(self._runtime_overrides) + # Keys sent to the browser. Sensitive or server-only keys (YTDL_OPTIONS, + # paths, TLS config, etc.) are intentionally excluded. + _FRONTEND_KEYS = ( + 'CUSTOM_DIRS', + 'CREATE_CUSTOM_DIRS', + 'OUTPUT_TEMPLATE_CHAPTER', + 'PUBLIC_HOST_URL', + 'PUBLIC_HOST_AUDIO_URL', + 'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT', + 'SUBSCRIPTION_DEFAULT_CHECK_INTERVAL', + 'ALLOW_YTDL_OPTIONS_OVERRIDES', + ) + + def frontend_safe(self) -> dict: + """Return only the config keys that are safe to expose to browser clients. + + Sensitive or server-only keys (YTDL_OPTIONS, file-system paths, TLS + settings, etc.) are intentionally excluded. + """ + return {k: getattr(self, k) for k in self._FRONTEND_KEYS} + def load_ytdl_options(self) -> tuple[bool, str]: try: self.YTDL_OPTIONS = json.loads(os.environ.get('YTDL_OPTIONS', '{}')) @@ -145,6 +233,37 @@ class Config: self._apply_runtime_overrides() return (True, '') + def load_ytdl_option_presets(self) -> tuple[bool, str]: + try: + self.YTDL_OPTIONS_PRESETS = json.loads(os.environ.get('YTDL_OPTIONS_PRESETS', '{}')) + assert isinstance(self.YTDL_OPTIONS_PRESETS, dict) + assert all(isinstance(name, str) and isinstance(options, dict) for name, options in self.YTDL_OPTIONS_PRESETS.items()) + except (json.decoder.JSONDecodeError, AssertionError): + msg = 'Environment variable YTDL_OPTIONS_PRESETS is invalid' + log.error(msg) + return (False, msg) + + if not self.YTDL_OPTIONS_PRESETS_FILE: + return (True, '') + + log.info(f'Loading yt-dlp option presets from "{self.YTDL_OPTIONS_PRESETS_FILE}"') + if not os.path.exists(self.YTDL_OPTIONS_PRESETS_FILE): + msg = f'File "{self.YTDL_OPTIONS_PRESETS_FILE}" not found' + log.error(msg) + return (False, msg) + try: + with open(self.YTDL_OPTIONS_PRESETS_FILE) as json_data: + opts = json.load(json_data) + assert isinstance(opts, dict) + assert all(isinstance(name, str) and isinstance(options, dict) for name, options in opts.items()) + except (json.decoder.JSONDecodeError, AssertionError): + msg = 'YTDL_OPTIONS_PRESETS_FILE contents is invalid' + log.error(msg) + return (False, msg) + + self.YTDL_OPTIONS_PRESETS.update(opts) + return (True, '') + config = Config() # Align root logger level with Config (keeps a single source of truth). # This re-applies the log level after Config loads, in case LOGLEVEL was @@ -153,7 +272,13 @@ logging.getLogger().setLevel(parseLogLevel(str(config.LOGLEVEL)) or logging.INFO class ObjectSerializer(json.JSONEncoder): def default(self, obj): - # First try to use __dict__ for custom objects + # Prefer an explicit client-facing view when the object provides one + # (e.g. DownloadInfo / SubscriptionInfo) so server-only or bulky fields + # are never broadcast to browser clients. + to_public = getattr(obj, 'to_public_dict', None) + if callable(to_public): + return to_public() + # Fall back to __dict__ for other custom objects if hasattr(obj, '__dict__'): return obj.__dict__ # Convert iterables (generators, dict_items, etc.) to lists @@ -161,19 +286,259 @@ class ObjectSerializer(json.JSONEncoder): elif hasattr(obj, '__iter__') and not isinstance(obj, (str, bytes)): try: return list(obj) - except: + except Exception: pass # Fall back to default behavior return json.JSONEncoder.default(self, obj) serializer = ObjectSerializer() -app = web.Application() -sio = socketio.AsyncServer(cors_allowed_origins='*') + +_STATE_DIR_REAL = os.path.realpath(config.STATE_DIR) + + +def _is_within_state_dir(real_target: str) -> bool: + return real_target == _STATE_DIR_REAL or real_target.startswith(_STATE_DIR_REAL + os.sep) + + +@web.middleware +async def state_dir_guard(request, handler): + for prefix, base in ( + (config.URL_PREFIX + 'download/', config.DOWNLOAD_DIR), + (config.URL_PREFIX + 'audio_download/', config.AUDIO_DOWNLOAD_DIR), + ): + if request.path.startswith(prefix): + # request.path is already percent-decoded by aiohttp; decoding it + # again would mangle a download whose filename contains a literal + # '%' (e.g. "%" turning into a truncated escape) into a false 404. + rel = request.path[len(prefix):] + target = os.path.realpath(os.path.join(base, rel)) + if _is_within_state_dir(target): + raise web.HTTPNotFound() + break + return await handler(request) + + +app = web.Application(middlewares=[state_dir_guard]) +_cors_origins = [o.strip() for o in config.CORS_ALLOWED_ORIGINS.split(',') if o.strip()] if config.CORS_ALLOWED_ORIGINS else [] +sio = socketio.AsyncServer(cors_allowed_origins=_cors_origins if _cors_origins else []) sio.attach(app, socketio_path=config.URL_PREFIX + 'socket.io') routes = web.RouteTableDef() VALID_SUBTITLE_FORMATS = {'srt', 'txt', 'vtt', 'ttml', 'sbv', 'scc', 'dfxp'} VALID_SUBTITLE_MODES = {'auto_only', 'manual_only', 'prefer_manual', 'prefer_auto'} SUBTITLE_LANGUAGE_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9-]{0,34}$') +VALID_DOWNLOAD_TYPES = {'video', 'audio', 'captions', 'thumbnail'} +VALID_VIDEO_CODECS = {'auto', 'h264', 'h265', 'av1', 'vp9'} +VALID_VIDEO_FORMATS = {'any', 'mp4', 'ios'} +VALID_AUDIO_FORMATS = {'m4a', 'mp3', 'opus', 'wav', 'flac'} +VALID_THUMBNAIL_FORMATS = {'jpg'} +def _parse_ytdl_options_overrides(value, *, enabled: bool) -> dict: + if value is None or value == '': + return {} + + if isinstance(value, str): + try: + value = json.loads(value) + except json.JSONDecodeError as exc: + raise web.HTTPBadRequest(reason='ytdl_options_overrides must be valid JSON') from exc + + if not isinstance(value, dict): + raise web.HTTPBadRequest(reason='ytdl_options_overrides must be a JSON object') + + if value and not enabled: + raise web.HTTPBadRequest(reason='ytdl_options_overrides are disabled') + + return value + + +_YOUTUBE_T_COMPACT_RE = re.compile( + r'^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)(?:s)?)?$', + re.IGNORECASE, +) + + +def _parse_youtube_t_compact(value: str) -> float | None: + """Parse YouTube-style ``t`` values: ``885``, ``885s``, ``14m45s``, ``1h2m3s``.""" + v = value.strip() + if not v: + return None + if re.fullmatch(r'-?\d+(\.\d+)?', v): + sec = float(v) + return sec if sec >= 0 else None + m = _YOUTUBE_T_COMPACT_RE.match(v) + if m and any(m.groups()): + hours = int(m.group(1) or 0) + minutes = int(m.group(2) or 0) + seconds = int(m.group(3) or 0) + total = hours * 3600 + minutes * 60 + seconds + return float(total) if total >= 0 else None + return None + + +def _parse_clock_timestamp(s: str) -> float: + """Parse ``MM:SS``, ``H:MM:SS``, or single segment as seconds (with optional decimals).""" + part = s.strip() + if not part: + raise ValueError('empty timestamp') + segments = part.split(':') + if len(segments) > 3: + raise ValueError('too many segments') + try: + nums = [float(x) for x in segments] + except ValueError as exc: + raise ValueError('invalid number') from exc + if any(x < 0 for x in nums): + raise ValueError('negative segment') + if len(segments) == 1: + return nums[0] + if len(segments) == 2: + return nums[0] * 60 + nums[1] + return nums[0] * 3600 + nums[1] * 60 + nums[2] + + +def _parse_clip_timestamp_value(value) -> float: + """Coerce a clip boundary from JSON to seconds (non-negative).""" + if isinstance(value, bool): + raise web.HTTPBadRequest(reason='clip timestamp must be a number or string') + if isinstance(value, (int, float)): + if value < 0: + raise web.HTTPBadRequest(reason='clip timestamp must be non-negative') + return float(value) + s = str(value).strip() + if not s: + raise web.HTTPBadRequest(reason='clip timestamp cannot be empty') + if ':' in s: + try: + return _parse_clock_timestamp(s) + except ValueError as exc: + raise web.HTTPBadRequest(reason='invalid clip timestamp format') from exc + compact = _parse_youtube_t_compact(s) + if compact is not None: + return compact + raise web.HTTPBadRequest(reason='invalid clip timestamp format') + + +def _optional_clip_field(raw) -> float | None: + if raw is None: + return None + if isinstance(raw, str) and not raw.strip(): + return None + return _parse_clip_timestamp_value(raw) + + +def _clip_field_provided_in_post(raw) -> bool: + if raw is None: + return False + if isinstance(raw, str) and not raw.strip(): + return False + return True + + +def _extract_t_query_from_url(url: str) -> tuple[str, float | None]: + """If ``t=`` is present and parseable, return URL without ``t`` and start seconds. + + Restricted to YouTube hosts: ``t`` is a generic query parameter name that + other sites may use for unrelated purposes, so rewriting it there would + silently mutate the URL and inject a bogus clip start. + """ + try: + parsed = urlparse(url) + params = parse_qs(parsed.query) + except Exception: + return url, None + host = (parsed.hostname or '').lower() + if not (host in ('youtu.be', 'youtube.com') or host.endswith('.youtube.com')): + return url, None + t_values = params.get('t') + if not t_values: + return url, None + start = _parse_youtube_t_compact(t_values[0]) + if start is None: + return url, None + filtered = {k: v for k, v in params.items() if k != 't'} + new_query = urlencode(filtered, doseq=True) + cleaned = urlunparse(( + parsed.scheme, + parsed.netloc, + parsed.path, + parsed.params, + new_query, + parsed.fragment, + )) + return cleaned, float(start) + + +def _parse_ytdl_options_presets(post: dict) -> list[str]: + """Normalize preset names from add/subscribe body; supports list or legacy singular string.""" + raw = post.get('ytdl_options_presets') + if raw is None: + raw = post.get('ytdl_options_preset') + if raw is None: + return [] + if isinstance(raw, list): + return [str(x).strip() for x in raw if str(x).strip()] + if isinstance(raw, str): + s = raw.strip() + return [s] if s else [] + raise web.HTTPBadRequest( + reason='ytdl_options_presets must be a JSON array of strings (or legacy ytdl_options_preset string)', + ) + + +def _migrate_legacy_request(post: dict) -> dict: + """ + BACKWARD COMPATIBILITY: Translate old API request schema into the new one. + + Old API: + format (any/mp4/m4a/mp3/opus/wav/flac/thumbnail/captions) + quality + video_codec + subtitle_format (only when format=captions) + + New API: + download_type (video/audio/captions/thumbnail) + codec + format + quality + """ + if "download_type" in post: + return post + + old_format = str(post.get("format") or "any").strip().lower() + old_quality = str(post.get("quality") or "best").strip().lower() + old_video_codec = str(post.get("video_codec") or "auto").strip().lower() + + if old_format in VALID_AUDIO_FORMATS: + post["download_type"] = "audio" + post["codec"] = "auto" + post["format"] = old_format + elif old_format == "thumbnail": + post["download_type"] = "thumbnail" + post["codec"] = "auto" + post["format"] = "jpg" + post["quality"] = "best" + elif old_format == "captions": + post["download_type"] = "captions" + post["codec"] = "auto" + post["format"] = str(post.get("subtitle_format") or "srt").strip().lower() + post["quality"] = "best" + else: + # old_format is usually any/mp4 (legacy video path) + post["download_type"] = "video" + post["codec"] = old_video_codec + if old_quality == "best_ios": + post["format"] = "ios" + post["quality"] = "best" + elif old_quality == "audio": + # Legacy "audio only" under video format maps to m4a audio. + post["download_type"] = "audio" + post["codec"] = "auto" + post["format"] = "m4a" + post["quality"] = "best" + else: + post["format"] = old_format + post["quality"] = old_quality + + return post class Notifier(DownloadQueueNotifier): async def added(self, dl): @@ -197,7 +562,73 @@ class Notifier(DownloadQueueNotifier): await sio.emit('cleared', serializer.encode(id)) dqueue = DownloadQueue(config, Notifier()) -app.on_startup.append(lambda app: dqueue.initialize()) + + +async def _download_queue_startup(app): + await dqueue.initialize() + + +async def _shutdown_download_manager(app): + dqueue.close() + Download.shutdown_manager() + + +app.on_startup.append(_download_queue_startup) +app.on_cleanup.append(_shutdown_download_manager) + + +class MetubeSubscriptionNotifier(SubscriptionNotifier): + async def subscription_added(self, sub: SubscriptionInfo): + log.info("Subscription added: %s", sub.name) + await sio.emit('subscription_added', serializer.encode(sub.to_public_dict())) + + async def subscription_updated(self, sub: SubscriptionInfo): + await sio.emit('subscription_updated', serializer.encode(sub.to_public_dict())) + + async def subscription_removed(self, sub_id: str): + log.info("Subscription removed: %s", sub_id) + await sio.emit('subscription_removed', serializer.encode(sub_id)) + + async def subscriptions_all(self, subs: list[SubscriptionInfo]): + await sio.emit('subscriptions_all', serializer.encode([s.to_public_dict() for s in subs])) + + +submgr = SubscriptionManager(config, dqueue, MetubeSubscriptionNotifier()) + + +async def _shutdown_subscriptions(app): + submgr.close() + + +app.on_cleanup.append(_shutdown_subscriptions) + + +async def _subscription_loop_startup(app): + """aiohttp on_startup requires awaitable receivers; start_background_loop is sync.""" + submgr.start_background_loop() + + +app.on_startup.append(_subscription_loop_startup) + + +async def _schedule_nightly_update() -> None: + global _RESTART_FOR_UPDATE + time_hhmm = config.YTDL_NIGHTLY_UPDATE_TIME + if not time_hhmm: + return + delay = seconds_until_next_daily_time(time_hhmm) + log.info('Next yt-dlp nightly update in %.0f seconds (at %s local time)', delay, time_hhmm) + await asyncio.sleep(delay) + log.info('Scheduled yt-dlp nightly update: requesting restart') + _RESTART_FOR_UPDATE = True + asyncio.get_running_loop().call_soon(_request_graceful_exit) + + +async def _start_nightly_update_schedule(app): + bg_tasks.create_task(_schedule_nightly_update(), name="nightly_update_schedule") + + +app.on_startup.append(_start_nightly_update_schedule) class FileOpsFilter(DefaultFilter): def __call__(self, change_type: int, path: str) -> bool: @@ -243,31 +674,46 @@ async def watch_files(): await sio.emit('ytdl_options_changed', serializer.encode(result)) log.info(f'Starting Watch File: {config.YTDL_OPTIONS_FILE}') - asyncio.create_task(_watch_files()) + bg_tasks.create_task(_watch_files(), name="watch_ytdl_options_file") + +async def _watch_files_startup(app): + await watch_files() + if config.YTDL_OPTIONS_FILE: - app.on_startup.append(lambda app: watch_files()) + app.on_startup.append(_watch_files_startup) -@routes.post(config.URL_PREFIX + 'add') -async def add(request): - log.info("Received request to add download") - post = await request.json() - log.info(f"Request data: {post}") + +async def _read_json_request(request: web.Request) -> dict: + try: + post = await request.json() + except json.JSONDecodeError as exc: + raise web.HTTPBadRequest(reason='Invalid JSON request body') from exc + if not isinstance(post, dict): + raise web.HTTPBadRequest(reason='JSON request body must be an object') + return post + + +def parse_download_options(post: dict) -> dict: + """Validate add/subscribe body; raise HTTPBadRequest on invalid input.""" + post = _migrate_legacy_request(dict(post)) url = post.get('url') - quality = post.get('quality') - if not url or not quality: - log.error("Bad request: missing 'url' or 'quality'") - raise web.HTTPBadRequest() + download_type = post.get('download_type') + codec = post.get('codec') format = post.get('format') + quality = post.get('quality') + if not url or not quality or not download_type: + raise web.HTTPBadRequest(reason="missing 'url', 'download_type', or 'quality'") + url = str(url).strip() folder = post.get('folder') custom_name_prefix = post.get('custom_name_prefix') playlist_item_limit = post.get('playlist_item_limit') auto_start = post.get('auto_start') split_by_chapters = post.get('split_by_chapters') chapter_template = post.get('chapter_template') - subtitle_format = post.get('subtitle_format') subtitle_language = post.get('subtitle_language') subtitle_mode = post.get('subtitle_mode') + ytdl_options_overrides = post.get('ytdl_options_overrides') if custom_name_prefix is None: custom_name_prefix = '' @@ -281,54 +727,281 @@ async def add(request): split_by_chapters = False if chapter_template is None: chapter_template = config.OUTPUT_TEMPLATE_CHAPTER - if subtitle_format is None: - subtitle_format = 'srt' if subtitle_language is None: subtitle_language = 'en' if subtitle_mode is None: subtitle_mode = 'prefer_manual' - subtitle_format = str(subtitle_format).strip().lower() + download_type = str(download_type).strip().lower() + codec = str(codec or 'auto').strip().lower() + format = str(format or '').strip().lower() + quality = str(quality).strip().lower() subtitle_language = str(subtitle_language).strip() subtitle_mode = str(subtitle_mode).strip() + ytdl_options_presets = _parse_ytdl_options_presets(post) + ytdl_options_overrides = _parse_ytdl_options_overrides( + ytdl_options_overrides, + enabled=config.ALLOW_YTDL_OPTIONS_OVERRIDES, + ) + if chapter_template and ('..' in chapter_template or chapter_template.startswith('/') or chapter_template.startswith('\\')): raise web.HTTPBadRequest(reason='chapter_template must not contain ".." or start with a path separator') - if subtitle_format not in VALID_SUBTITLE_FORMATS: - raise web.HTTPBadRequest(reason=f'subtitle_format must be one of {sorted(VALID_SUBTITLE_FORMATS)}') if not SUBTITLE_LANGUAGE_RE.fullmatch(subtitle_language): raise web.HTTPBadRequest(reason='subtitle_language must match pattern [A-Za-z0-9-] and be at most 35 characters') if subtitle_mode not in VALID_SUBTITLE_MODES: raise web.HTTPBadRequest(reason=f'subtitle_mode must be one of {sorted(VALID_SUBTITLE_MODES)}') + for preset_name in ytdl_options_presets: + if preset_name not in config.YTDL_OPTIONS_PRESETS: + raise web.HTTPBadRequest(reason='ytdl_options_presets must only contain configured preset names') - playlist_item_limit = int(playlist_item_limit) + if download_type not in VALID_DOWNLOAD_TYPES: + raise web.HTTPBadRequest(reason=f'download_type must be one of {sorted(VALID_DOWNLOAD_TYPES)}') + if codec not in VALID_VIDEO_CODECS: + raise web.HTTPBadRequest(reason=f'codec must be one of {sorted(VALID_VIDEO_CODECS)}') + if download_type == 'video': + if format not in VALID_VIDEO_FORMATS: + raise web.HTTPBadRequest(reason=f'format must be one of {sorted(VALID_VIDEO_FORMATS)} for video') + if quality not in {'best', 'worst', '2160', '1440', '1080', '720', '480', '360', '240'}: + raise web.HTTPBadRequest(reason="quality must be one of ['best', '2160', '1440', '1080', '720', '480', '360', '240', 'worst'] for video") + elif download_type == 'audio': + if format not in VALID_AUDIO_FORMATS: + raise web.HTTPBadRequest(reason=f'format must be one of {sorted(VALID_AUDIO_FORMATS)} for audio') + allowed_audio_qualities = {'best'} + if format == 'mp3': + allowed_audio_qualities |= {'320', '192', '128'} + elif format == 'm4a': + allowed_audio_qualities |= {'192', '128'} + if quality not in allowed_audio_qualities: + raise web.HTTPBadRequest(reason=f'quality must be one of {sorted(allowed_audio_qualities)} for format {format}') + codec = 'auto' + elif download_type == 'captions': + if format not in VALID_SUBTITLE_FORMATS: + raise web.HTTPBadRequest(reason=f'format must be one of {sorted(VALID_SUBTITLE_FORMATS)} for captions') + quality = 'best' + codec = 'auto' + elif download_type == 'thumbnail': + if format not in VALID_THUMBNAIL_FORMATS: + raise web.HTTPBadRequest(reason=f'format must be one of {sorted(VALID_THUMBNAIL_FORMATS)} for thumbnail') + quality = 'best' + codec = 'auto' + + try: + playlist_item_limit = int(playlist_item_limit) + except (TypeError, ValueError) as exc: + raise web.HTTPBadRequest(reason='playlist_item_limit must be an integer') from exc + + clip_start_raw = post.get('clip_start') + clip_end_raw = post.get('clip_end') + clip_start: float | None + clip_end: float | None + if download_type in ('captions', 'thumbnail'): + if _clip_field_provided_in_post(clip_start_raw) or _clip_field_provided_in_post(clip_end_raw): + raise web.HTTPBadRequest( + reason='clip_start and clip_end are only supported for video and audio downloads', + ) + clip_start = None + clip_end = None + else: + cleaned_url, url_t = _extract_t_query_from_url(url) + if url_t is not None: + url = cleaned_url + explicit_start = _optional_clip_field(clip_start_raw) + explicit_end = _optional_clip_field(clip_end_raw) + explicit_start_provided = _clip_field_provided_in_post(clip_start_raw) + explicit_end_provided = _clip_field_provided_in_post(clip_end_raw) + if explicit_start_provided: + clip_start = explicit_start + elif explicit_end_provided: + clip_start = 0.0 + elif url_t is not None: + clip_start = url_t + else: + clip_start = None + clip_end = explicit_end + if clip_end is not None and clip_start is None: + clip_start = 0.0 + if clip_start is not None and clip_end is not None and clip_end <= clip_start: + raise web.HTTPBadRequest(reason='clip_end must be greater than clip_start') + + return { + 'url': url, + 'download_type': download_type, + 'codec': codec, + 'format': format, + 'quality': quality, + 'folder': folder, + 'custom_name_prefix': custom_name_prefix, + 'playlist_item_limit': playlist_item_limit, + 'auto_start': auto_start, + 'split_by_chapters': split_by_chapters, + 'chapter_template': chapter_template, + 'subtitle_language': subtitle_language, + 'subtitle_mode': subtitle_mode, + 'ytdl_options_presets': ytdl_options_presets, + 'ytdl_options_overrides': ytdl_options_overrides, + 'clip_start': clip_start, + 'clip_end': clip_end, + } + + +@routes.post(config.URL_PREFIX + 'add') +async def add(request): + log.info("Received request to add download") + post = await _read_json_request(request) + try: + o = parse_download_options(post) + except web.HTTPBadRequest as e: + log.error("Bad request: %s", e.reason) + raise + log.info( + "Add download request: type=%s quality=%s format=%s has_folder=%s auto_start=%s", + o['download_type'], + o['quality'], + o['format'], + bool(o.get('folder')), + o['auto_start'], + ) status = await dqueue.add( - url, - quality, - format, - folder, - custom_name_prefix, - playlist_item_limit, - auto_start, - split_by_chapters, - chapter_template, - subtitle_format, - subtitle_language, - subtitle_mode, + o['url'], + o['download_type'], + o['codec'], + o['format'], + o['quality'], + o['folder'], + o['custom_name_prefix'], + o['playlist_item_limit'], + o['auto_start'], + o['split_by_chapters'], + o['chapter_template'], + o['subtitle_language'], + o['subtitle_mode'], + o['ytdl_options_presets'], + o['ytdl_options_overrides'], + o['clip_start'], + o['clip_end'], ) return web.Response(text=serializer.encode(status)) + +@routes.get(config.URL_PREFIX + 'presets') +async def presets(request): + return web.Response( + text=serializer.encode({'presets': sorted(config.YTDL_OPTIONS_PRESETS.keys())}), + content_type='application/json', + ) + @routes.post(config.URL_PREFIX + 'cancel-add') async def cancel_add(request): dqueue.cancel_add() return web.Response(text=serializer.encode({'status': 'ok'}), content_type='application/json') + +@routes.post(config.URL_PREFIX + 'subscribe') +async def subscribe(request): + post = await _read_json_request(request) + o = parse_download_options(post) + cic = post.get('check_interval_minutes') + if cic is None: + cic = config.SUBSCRIPTION_DEFAULT_CHECK_INTERVAL + try: + cic = int(cic) + except (TypeError, ValueError) as exc: + raise web.HTTPBadRequest(reason='check_interval_minutes must be an integer') from exc + if cic < 1: + raise web.HTTPBadRequest(reason='check_interval_minutes must be at least 1') + if o.get('clip_start') is not None or o.get('clip_end') is not None: + raise web.HTTPBadRequest(reason='clip options are not supported for subscriptions') + + try: + skip_subscriber_only = coerce_optional_bool( + post.get('skip_subscriber_only'), + default=False, + field_name='skip_subscriber_only', + ) + except ValueError as exc: + raise web.HTTPBadRequest(reason=str(exc)) from exc + + result = await submgr.add_subscription( + o['url'], + check_interval_minutes=cic, + download_type=o['download_type'], + codec=o['codec'], + format=o['format'], + quality=o['quality'], + folder=o['folder'] or '', + custom_name_prefix=o['custom_name_prefix'], + auto_start=o['auto_start'], + playlist_item_limit=o['playlist_item_limit'], + split_by_chapters=o['split_by_chapters'], + chapter_template=o['chapter_template'], + subtitle_language=o['subtitle_language'], + subtitle_mode=o['subtitle_mode'], + ytdl_options_presets=o['ytdl_options_presets'], + ytdl_options_overrides=o['ytdl_options_overrides'], + title_regex=post.get('title_regex'), + skip_subscriber_only=skip_subscriber_only, + ) + return web.Response(text=serializer.encode(result)) + + +@routes.get(config.URL_PREFIX + 'subscriptions') +async def subscriptions_list(request): + return web.Response(text=serializer.encode([s.to_public_dict() for s in submgr.list_all()])) + + +@routes.post(config.URL_PREFIX + 'subscriptions/update') +async def subscriptions_update(request): + post = await _read_json_request(request) + sub_id = post.get('id') + if not sub_id: + raise web.HTTPBadRequest(reason='missing subscription id') + changes = { + k: v + for k, v in post.items() + if k != 'id' + and k in ('enabled', 'check_interval_minutes', 'name', 'title_regex', 'skip_subscriber_only') + } + if not changes: + raise web.HTTPBadRequest(reason='no valid fields to update') + log.info("Subscription update requested for %s: %s", sub_id, sorted(changes.keys())) + result = await submgr.update_subscription(str(sub_id), changes) + return web.Response(text=serializer.encode(result)) + + +@routes.post(config.URL_PREFIX + 'subscriptions/delete') +async def subscriptions_delete(request): + post = await _read_json_request(request) + ids = post.get('ids') + if not ids or not isinstance(ids, list): + raise web.HTTPBadRequest(reason='missing ids list') + result = await submgr.delete_subscriptions([str(i) for i in ids]) + return web.Response(text=serializer.encode(result)) + + +@routes.post(config.URL_PREFIX + 'subscriptions/check') +async def subscriptions_check(request): + post = await _read_json_request(request) + ids = post.get('ids') + if ids is not None and not isinstance(ids, list): + raise web.HTTPBadRequest(reason='ids must be a list') + log.info("Subscription check-now requested for ids=%s", ids if ids else "all-enabled") + result = await submgr.check_now([str(i) for i in ids] if ids else None) + return web.Response(text=serializer.encode(result)) + +def _require_id_list(post: dict) -> list: + ids = post.get('ids') + if not isinstance(ids, list) or not ids or not all(isinstance(i, str) for i in ids): + raise web.HTTPBadRequest(reason="'ids' must be a non-empty list of strings") + return ids + + @routes.post(config.URL_PREFIX + 'delete') async def delete(request): - post = await request.json() - ids = post.get('ids') + post = await _read_json_request(request) + ids = _require_id_list(post) where = post.get('where') - if not ids or where not in ['queue', 'done']: - log.error("Bad request: missing 'ids' or incorrect 'where' value") + if where not in ['queue', 'done']: + log.error("Bad request: incorrect 'where' value") raise web.HTTPBadRequest() status = await (dqueue.cancel(ids) if where == 'queue' else dqueue.clear(ids)) log.info(f"Download delete request processed for ids: {ids}, where: {where}") @@ -336,8 +1009,8 @@ async def delete(request): @routes.post(config.URL_PREFIX + 'start') async def start(request): - post = await request.json() - ids = post.get('ids') + post = await _read_json_request(request) + ids = _require_id_list(post) log.info(f"Received request to start pending downloads for ids: {ids}") status = await dqueue.start_pending(ids) return web.Response(text=serializer.encode(status)) @@ -351,17 +1024,29 @@ async def upload_cookies(request): field = await reader.next() if field is None or field.name != 'cookies': return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'No cookies file provided'})) + + max_size = 1_000_000 # 1MB limit size = 0 - with open(COOKIES_PATH, 'wb') as f: - while True: - chunk = await field.read_chunk() - if not chunk: - break - size += len(chunk) - if size > 1_000_000: # 1MB limit - os.remove(COOKIES_PATH) - return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'Cookie file too large (max 1MB)'})) - f.write(chunk) + content = bytearray() + while True: + chunk = await field.read_chunk() + if not chunk: + break + size += len(chunk) + if size > max_size: + return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'Cookie file too large (max 1MB)'})) + content.extend(chunk) + + tmp_cookie_path = f"{COOKIES_PATH}.tmp" + with open(tmp_cookie_path, 'wb') as f: + f.write(content) + # Cookies are sensitive auth material; restrict to owner read/write only + # (the container's default umask would otherwise leave them group/world readable). + try: + os.chmod(tmp_cookie_path, 0o600) + except OSError as exc: + log.warning(f'Could not restrict permissions on cookies file: {exc}') + os.replace(tmp_cookie_path, COOKIES_PATH) config.set_runtime_override('cookiefile', COOKIES_PATH) log.info(f'Cookies file uploaded ({size} bytes)') return web.Response(text=serializer.encode({'status': 'ok', 'msg': f'Cookies uploaded ({size} bytes)'})) @@ -405,12 +1090,15 @@ async def cookie_status(request): async def history(request): history = { 'done': [], 'queue': [], 'pending': []} - for _, v in dqueue.queue.saved_items(): - history['queue'].append(v) - for _, v in dqueue.done.saved_items(): - history['done'].append(v) - for _, v in dqueue.pending.saved_items(): - history['pending'].append(v) + # Served from the in-memory queues (like the socket 'all' event) rather + # than saved_items(), which reloads and re-compacts the on-disk state on + # every call. + for _, v in dqueue.queue.items(): + history['queue'].append(v.info) + for _, v in dqueue.done.items(): + history['done'].append(v.info) + for _, v in dqueue.pending.items(): + history['pending'].append(v.info) log.info("Sending download history") return web.Response(text=serializer.encode(history)) @@ -419,13 +1107,34 @@ async def history(request): async def connect(sid, environ): log.info(f"Client connected: {sid}") await sio.emit('all', serializer.encode(dqueue.get()), to=sid) - await sio.emit('configuration', serializer.encode(config), to=sid) + await sio.emit('subscriptions_all', serializer.encode([s.to_public_dict() for s in submgr.list_all()]), to=sid) + await sio.emit('configuration', serializer.encode(config.frontend_safe()), to=sid) if config.CUSTOM_DIRS: - await sio.emit('custom_dirs', serializer.encode(get_custom_dirs()), to=sid) + # get_custom_dirs() can walk the whole download tree on a cache miss; + # keep that off the event loop so a large library doesn't stall every + # client's connect handshake. + dirs = await asyncio.get_running_loop().run_in_executor(None, get_custom_dirs) + await sio.emit('custom_dirs', serializer.encode(dirs), to=sid) if config.YTDL_OPTIONS_FILE: await sio.emit('ytdl_options_changed', serializer.encode(get_options_update_time()), to=sid) def get_custom_dirs(): + cache_ttl_seconds = 5 + now = time.monotonic() + cache_key = ( + config.DOWNLOAD_DIR, + config.AUDIO_DOWNLOAD_DIR, + config.CUSTOM_DIRS_EXCLUDE_REGEX, + ) + if ( + hasattr(get_custom_dirs, "_cache_key") + and hasattr(get_custom_dirs, "_cache_value") + and hasattr(get_custom_dirs, "_cache_time") + and get_custom_dirs._cache_key == cache_key + and (now - get_custom_dirs._cache_time) < cache_ttl_seconds + ): + return get_custom_dirs._cache_value + def recursive_dirs(base): path = pathlib.Path(base) @@ -447,8 +1156,12 @@ def get_custom_dirs(): else: return re.search(config.CUSTOM_DIRS_EXCLUDE_REGEX, d) is None - # Recursively lists all subdirectories of DOWNLOAD_DIR + # Recursively lists all subdirectories of DOWNLOAD_DIR. + # Always include '' (the base directory itself) even when the + # directory is empty or does not yet exist. dirs = list(filter(include_dir, map(convert, path.glob('**/')))) + if '' not in dirs: + dirs.insert(0, '') return dirs @@ -458,20 +1171,24 @@ def get_custom_dirs(): if config.DOWNLOAD_DIR != config.AUDIO_DOWNLOAD_DIR: audio_download_dir = recursive_dirs(config.AUDIO_DOWNLOAD_DIR) - return { + result = { "download_dir": download_dir, "audio_download_dir": audio_download_dir } + get_custom_dirs._cache_key = cache_key + get_custom_dirs._cache_time = now + get_custom_dirs._cache_value = result + return result @routes.get(config.URL_PREFIX) -def index(request): +async def index(request): response = web.FileResponse(os.path.join(config.BASE_DIR, 'ui/dist/metube/browser/index.html')) if 'metube_theme' not in request.cookies: response.set_cookie('metube_theme', config.DEFAULT_THEME) return response @routes.get(config.URL_PREFIX + 'robots.txt') -def robots(request): +async def robots(request): if config.ROBOTS_TXT: response = web.FileResponse(os.path.join(config.BASE_DIR, config.ROBOTS_TXT)) else: @@ -481,7 +1198,7 @@ def robots(request): return response @routes.get(config.URL_PREFIX + 'version') -def version(request): +async def version(request): return web.json_response({ "yt-dlp": yt_dlp_version, "version": os.getenv("METUBE_VERSION", "dev") @@ -489,11 +1206,11 @@ def version(request): if config.URL_PREFIX != '/': @routes.get('/') - def index_redirect_root(request): + async def index_redirect_root(request): return web.HTTPFound(config.URL_PREFIX) @routes.get(config.URL_PREFIX[:-1]) - def index_redirect_dir(request): + async def index_redirect_dir(request): return web.HTTPFound(config.URL_PREFIX) routes.static(config.URL_PREFIX + 'download/', config.DOWNLOAD_DIR, show_index=config.DOWNLOAD_DIRS_INDEXABLE) @@ -513,12 +1230,18 @@ async def add_cors(request): app.router.add_route('OPTIONS', config.URL_PREFIX + 'add', add_cors) app.router.add_route('OPTIONS', config.URL_PREFIX + 'cancel-add', add_cors) +app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscribe', add_cors) +app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions', add_cors) +app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions/update', add_cors) +app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions/delete', add_cors) +app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions/check', add_cors) app.router.add_route('OPTIONS', config.URL_PREFIX + 'upload-cookies', add_cors) app.router.add_route('OPTIONS', config.URL_PREFIX + 'delete-cookies', add_cors) async def on_prepare(request, response): - if 'Origin' in request.headers: - response.headers['Access-Control-Allow-Origin'] = request.headers['Origin'] + origin = request.headers.get('Origin') + if origin and _cors_origins and ('*' in _cors_origins or origin in _cors_origins): + response.headers['Access-Control-Allow-Origin'] = origin response.headers['Access-Control-Allow-Headers'] = 'Content-Type' app.on_response_prepare.append(on_prepare) @@ -554,3 +1277,5 @@ if __name__ == '__main__': web.run_app(app, host=config.HOST, port=int(config.PORT), reuse_port=supports_reuse_port(), ssl_context=ssl_context, access_log=isAccessLogEnabled()) else: web.run_app(app, host=config.HOST, port=int(config.PORT), reuse_port=supports_reuse_port(), access_log=isAccessLogEnabled()) + if _RESTART_FOR_UPDATE: + sys.exit(42) diff --git a/app/state_store.py b/app/state_store.py new file mode 100644 index 0000000..940a882 --- /dev/null +++ b/app/state_store.py @@ -0,0 +1,237 @@ +from __future__ import annotations + +import base64 +import collections.abc +import errno +import json +import logging +import os +import shelve +import tempfile +import time +from datetime import datetime +from typing import Any, Optional + +log = logging.getLogger("state_store") + +STATE_SCHEMA_VERSION = 2 +_BYTES_MARKER = "__metube_bytes__" +_DATETIME_MARKER = "__metube_datetime__" + +# Errnos that signal the filesystem cannot support the temp-file + rename +# atomic-write strategy (for example an NFS-backed state dir returning EPERM on +# mkstemp). These are safe to fall back on because they mean the atomic +# mechanism is unavailable, not that the data write itself failed. Errors like +# ENOSPC/EIO are deliberately excluded so a genuine storage failure surfaces +# instead of silently truncating an existing good state file. +_ATOMIC_UNSUPPORTED_ERRNOS = frozenset( + e + for e in ( + errno.EPERM, + errno.EACCES, + errno.ENOSYS, + errno.EINVAL, + getattr(errno, "EOPNOTSUPP", None), + getattr(errno, "ENOTSUP", None), + ) + if e is not None +) + + +def to_json_compatible(value: Any) -> Any: + if value is None or isinstance(value, (bool, int, float, str)): + return value + if isinstance(value, bytes): + return {_BYTES_MARKER: base64.b64encode(value).decode("ascii")} + if isinstance(value, datetime): + return {_DATETIME_MARKER: value.isoformat()} + if isinstance(value, collections.abc.Mapping): + return {str(k): to_json_compatible(v) for k, v in value.items()} + if isinstance(value, (list, tuple, set, frozenset)): + return [to_json_compatible(v) for v in value] + if isinstance(value, collections.abc.Iterable): + return [to_json_compatible(v) for v in value] + raise TypeError(f"Value of type {type(value).__name__} is not JSON serializable") + + +def from_json_compatible(value: Any) -> Any: + if isinstance(value, list): + return [from_json_compatible(v) for v in value] + if isinstance(value, dict): + if set(value.keys()) == {_BYTES_MARKER}: + return base64.b64decode(value[_BYTES_MARKER].encode("ascii")) + if set(value.keys()) == {_DATETIME_MARKER}: + return datetime.fromisoformat(value[_DATETIME_MARKER]) + return {k: from_json_compatible(v) for k, v in value.items()} + return value + + +def read_legacy_shelf(path: str) -> Optional[list[tuple[Any, Any]]]: + if not os.path.exists(path): + return None + try: + with shelve.open(path, "r") as shelf: + return list(shelf.items()) + except Exception as exc: + log.warning("Could not read legacy shelf at %s: %s", path, exc) + return None + + +class AtomicJsonStore: + def __init__(self, path: str, *, kind: str, schema_version: int = STATE_SCHEMA_VERSION): + self.path = path + self.kind = kind + self.schema_version = schema_version + self._direct_write_fallback_warned = False + + def _ensure_parent(self) -> None: + parent = os.path.dirname(self.path) + if parent and not os.path.isdir(parent): + os.makedirs(parent, exist_ok=True) + + def _build_payload(self, data: dict[str, Any]) -> dict[str, Any]: + payload = { + "schema_version": self.schema_version, + "kind": self.kind, + } + payload.update(data) + return payload + + def load(self) -> Optional[dict[str, Any]]: + if not os.path.exists(self.path): + return None + try: + with open(self.path, encoding="utf-8") as f: + payload = json.load(f) + if not isinstance(payload, dict): + raise ValueError("State file must contain a JSON object") + if payload.get("kind") != self.kind: + raise ValueError( + f"State file kind mismatch: expected {self.kind}, got {payload.get('kind')}" + ) + return payload + except Exception as exc: + self.quarantine_invalid_file(exc) + return None + + def save(self, data: dict[str, Any]) -> None: + self._ensure_parent() + payload = self._build_payload(data) + try: + self._atomic_write(payload) + except OSError as exc: + if exc.errno not in _ATOMIC_UNSUPPORTED_ERRNOS: + raise + self._warn_direct_write_fallback(exc) + self._direct_write(payload) + + def _atomic_write(self, payload: dict[str, Any]) -> None: + text = self._serialize(payload) + parent = os.path.dirname(self.path) or "." + fd, tmp_path = tempfile.mkstemp( + prefix=f".{os.path.basename(self.path)}.", + suffix=".tmp", + dir=parent, + text=True, + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(text) + f.flush() + self._best_effort_fsync(f.fileno()) + os.replace(tmp_path, self.path) + self._fsync_directory(parent) + except Exception: + try: + os.remove(tmp_path) + except OSError: + pass + raise + + def _direct_write(self, payload: dict[str, Any]) -> None: + # Serialize before truncating so a serialization failure never destroys + # the existing state file (the atomic path gets this for free via its + # temp file). + text = self._serialize(payload) + # Create with 0o600 so the fallback keeps the owner-only permissions the + # atomic path gets from mkstemp; state files can contain URLs and + # per-download option overrides that must not leak on shared mounts. + fd = os.open(self.path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as f: + # The 0o600 mode above only applies when the file is created; force + # it on rewrites too so an existing, broadly-permissioned state file + # is tightened to match the atomic path. Best-effort because some + # network filesystems reject chmod, and that must not re-crash save. + try: + os.fchmod(f.fileno(), 0o600) + except OSError: + pass + f.write(text) + f.flush() + self._best_effort_fsync(f.fileno()) + # Make the new directory entry durable too, matching the atomic path. + self._fsync_directory(os.path.dirname(self.path) or ".") + + @staticmethod + def _best_effort_fsync(fileno: int) -> None: + # Tolerate fsync being unsupported on the underlying filesystem (for + # example a network mount that returns EINVAL/ENOSYS), but let genuine + # storage failures such as ENOSPC/EIO surface so a non-durable write is + # never reported as success. An unsupported fsync must not by itself + # abandon the atomic rename path. + try: + os.fsync(fileno) + except OSError as exc: + if exc.errno not in _ATOMIC_UNSUPPORTED_ERRNOS: + raise + + @staticmethod + def _serialize(payload: dict[str, Any]) -> str: + return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n" + + def _warn_direct_write_fallback(self, exc: OSError) -> None: + if self._direct_write_fallback_warned: + return + self._direct_write_fallback_warned = True + log.warning( + "Atomic state write failed for %s (%s); falling back to direct write", + self.path, + exc, + ) + + def quarantine_invalid_file(self, exc: Exception) -> None: + if not os.path.exists(self.path): + return + ts = time.strftime("%Y%m%d%H%M%S") + backup_path = f"{self.path}.invalid.{ts}" + try: + os.replace(self.path, backup_path) + log.warning( + "State file at %s was invalid (%s); moved it to %s", + self.path, + exc, + backup_path, + ) + except OSError as move_exc: + log.warning( + "State file at %s was invalid (%s) and could not be moved aside: %s", + self.path, + exc, + move_exc, + ) + + @staticmethod + def _fsync_directory(path: str) -> None: + try: + flags = os.O_RDONLY + if hasattr(os, "O_DIRECTORY"): + flags |= os.O_DIRECTORY + fd = os.open(path, flags) + except OSError: + return + try: + os.fsync(fd) + except OSError: + pass + finally: + os.close(fd) diff --git a/app/subscriptions.py b/app/subscriptions.py new file mode 100644 index 0000000..8bfeae1 --- /dev/null +++ b/app/subscriptions.py @@ -0,0 +1,964 @@ +"""Channel/playlist subscriptions: periodic yt-dlp flat extract + queue new videos.""" + +from __future__ import annotations + +import asyncio +import copy +import logging +import os +import re +import time +import types +import uuid +from dataclasses import dataclass, field, fields +from functools import partial +from typing import Any, Optional + +import yt_dlp +import yt_dlp.networking.impersonate +import bg_tasks +from dl_formats import merge_ytdl_option_layers +from state_store import AtomicJsonStore, read_legacy_shelf +from url_guard import validate_url + +log = logging.getLogger("subscriptions") + +# How many subscription feeds to scan at once. Bounded so one slow/hung feed +# doesn't serialize the rest, without bursting a large subscription list at the +# extractor (which risks rate-limiting / bot detection). +_MAX_CONCURRENT_CHECKS = 4 + +VIDEO_ONLY_MSG = ( + "This URL points to a single video, not a channel or playlist. Use Download instead." +) +_MEDIA_HINT_FIELDS = ( + "duration", + "timestamp", + "release_timestamp", + "upload_date", + "view_count", + "live_status", + "availability", +) + + +def _impersonate_opt(ytdl_options: dict) -> dict: + opts = dict(ytdl_options) + if "impersonate" in opts: + opts["impersonate"] = yt_dlp.networking.impersonate.ImpersonateTarget.from_str( + opts["impersonate"] + ) + return opts + + +def _build_ydl_params( + config, *, playlistend: Optional[int] = None, extra_opts: Optional[dict[str, Any]] = None +) -> dict: + params: dict[str, Any] = { + "quiet": not logging.getLogger().isEnabledFor(logging.DEBUG), + "verbose": logging.getLogger().isEnabledFor(logging.DEBUG), + "no_color": True, + "extract_flat": True, + "ignore_no_formats_error": True, + "lazy_playlist": True, + "paths": {"home": config.DOWNLOAD_DIR, "temp": config.TEMP_DIR}, + **config.YTDL_OPTIONS, + **(extra_opts or {}), + } + params = _impersonate_opt(params) + if playlistend is not None and playlistend > 0: + params["playlistend"] = playlistend + return params + + +def _is_media_entry(entry: Any) -> bool: + if not isinstance(entry, dict): + return False + etype = str(entry.get("_type") or "") + if etype in ("playlist", "multi_video", "channel"): + return False + if entry.get("entries"): + return False + url = _entry_video_url(entry) + if not url: + return False + ie_key = str(entry.get("ie_key") or entry.get("extractor_key") or "").lower() + if any(token in ie_key for token in ("playlist", "channel", "tab")): + return any(entry.get(field) is not None for field in _MEDIA_HINT_FIELDS) + return True + + +def extract_flat_playlist( + config, url: str, playlistend: int, *, extra_opts: Optional[dict[str, Any]] = None, _depth: int = 0 +): + """Return (info_dict, entries_list) for playlist/channel URLs.""" + params = _build_ydl_params(config, playlistend=playlistend, extra_opts=extra_opts) + with yt_dlp.YoutubeDL(params=params) as ydl: + info = ydl.extract_info(url, download=False) + if not info: + return None, [] + etype = info.get("_type") or "video" + if etype == "video": + return info, [] + if etype in ("playlist", "channel"): + entries = info.get("entries") or [] + if isinstance(entries, types.GeneratorType): + entries = list(entries) + # Drop None placeholders from incomplete flat playlists + entries = [e for e in entries if e] + media_entries = [e for e in entries if _is_media_entry(e)] + if media_entries: + return info, media_entries + if _depth < 1: + for ent in entries[:5]: + nested_url = _entry_video_url(ent) + if not nested_url: + continue + # nested_url comes from remote playlist content; guard it too. + if validate_url(nested_url) is not None: + continue + nested_info, nested_entries = extract_flat_playlist( + config, + nested_url, + playlistend, + extra_opts=extra_opts, + _depth=_depth + 1, + ) + if nested_entries: + return nested_info, nested_entries + return info, entries + if etype.startswith("url") and info.get("url"): + # Single nested URL without playlist wrapper — treat as non-subscribable + return info, [] + return info, [] + + +def _entry_video_url(entry: dict) -> Optional[str]: + return entry.get("webpage_url") or entry.get("url") + + +def _entry_id(entry: dict) -> Optional[str]: + eid = entry.get("id") + if eid is not None: + return str(eid) + url = _entry_video_url(entry) + return url + + +def _is_subscriber_only_entry(entry: dict) -> bool: + """True when yt-dlp marks the entry as channel member-only (subscriber_only availability).""" + return str(entry.get("availability") or "") == "subscriber_only" + + +def coerce_optional_bool(value: Any, *, default: bool = False, field_name: str = "value") -> bool: + """Parse optional JSON booleans for subscription settings.""" + if value is None: + return default + try: + return _coerce_bool(value) + except ValueError as exc: + raise ValueError(f"{field_name} must be a boolean") from exc + + +@dataclass +class SubscriptionInfo: + id: str + name: str + url: str + enabled: bool = True + check_interval_minutes: int = 60 + download_type: str = "video" + codec: str = "auto" + format: str = "any" + quality: str = "best" + folder: str = "" + custom_name_prefix: str = "" + auto_start: bool = True + playlist_item_limit: int = 0 + split_by_chapters: bool = False + chapter_template: str = "" + subtitle_language: str = "en" + subtitle_mode: str = "prefer_manual" + ytdl_options_presets: list[str] = field(default_factory=list) + ytdl_options_overrides: dict[str, Any] = field(default_factory=dict) + title_regex: str = "" + skip_subscriber_only: bool = False + last_checked: Optional[float] = None + seen_ids: list[str] = field(default_factory=list) + error: Optional[str] = None + timestamp: float = field(default_factory=time.time) + + def seen_set(self) -> set[str]: + return set(self.seen_ids) + + def to_public_dict(self) -> dict: + return { + "id": self.id, + "name": self.name, + "url": self.url, + "enabled": self.enabled, + "check_interval_minutes": self.check_interval_minutes, + "download_type": self.download_type, + "codec": self.codec, + "format": self.format, + "quality": self.quality, + "folder": self.folder, + "title_regex": self.title_regex, + "skip_subscriber_only": self.skip_subscriber_only, + "last_checked": self.last_checked, + "seen_count": len(self.seen_ids), + "error": self.error, + } + + +def _subscription_to_record(sub: SubscriptionInfo) -> dict[str, Any]: + return { + "id": sub.id, + "name": sub.name, + "url": sub.url, + "enabled": sub.enabled, + "check_interval_minutes": sub.check_interval_minutes, + "download_type": sub.download_type, + "codec": sub.codec, + "format": sub.format, + "quality": sub.quality, + "folder": sub.folder, + "custom_name_prefix": sub.custom_name_prefix, + "auto_start": sub.auto_start, + "playlist_item_limit": sub.playlist_item_limit, + "split_by_chapters": sub.split_by_chapters, + "chapter_template": sub.chapter_template, + "subtitle_language": sub.subtitle_language, + "subtitle_mode": sub.subtitle_mode, + "ytdl_options_presets": list(sub.ytdl_options_presets), + "ytdl_options_overrides": sub.ytdl_options_overrides, + "title_regex": sub.title_regex, + "skip_subscriber_only": sub.skip_subscriber_only, + "last_checked": sub.last_checked, + "seen_ids": list(sub.seen_ids), + "error": sub.error, + } + + +def _normalize_subscription_record(rec: dict[str, Any]) -> dict[str, Any]: + """Migrate legacy ytdl_options_preset (str) to ytdl_options_presets (list).""" + out = dict(rec) + if "ytdl_options_presets" not in out: + old = out.pop("ytdl_options_preset", None) + if old is None: + out["ytdl_options_presets"] = [] + elif isinstance(old, list): + out["ytdl_options_presets"] = [str(x).strip() for x in old if str(x).strip()] + elif isinstance(old, str): + out["ytdl_options_presets"] = [old.strip()] if old.strip() else [] + else: + out["ytdl_options_presets"] = [] + else: + out.pop("ytdl_options_preset", None) + return out + + +def _subscription_from_record(record: Any) -> Optional[SubscriptionInfo]: + field_names = {f.name for f in fields(SubscriptionInfo)} + if isinstance(record, SubscriptionInfo): + return record + if isinstance(record, dict): + try: + normalized = _normalize_subscription_record(dict(record)) + return SubscriptionInfo(**{k: v for k, v in normalized.items() if k in field_names}) + except TypeError: + return None + return None + + +def _normalize_title_regex_value(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return value.strip() + return str(value).strip() + + +def validate_title_regex(value: Any) -> str: + """Return stored title regex string; non-empty values must compile (re.error on failure).""" + s = _normalize_title_regex_value(value) + if s: + re.compile(s) + return s + + +def _coerce_bool(value: Any) -> bool: + """Accept JSON booleans and common string forms used by API clients.""" + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"true", "1", "on"}: + return True + if lowered in {"false", "0", "off"}: + return False + raise ValueError("enabled must be a boolean") + + +class SubscriptionNotifier: + """Hook for Socket.IO / UI updates.""" + + async def subscription_added(self, sub: SubscriptionInfo) -> None: + raise NotImplementedError + + async def subscription_updated(self, sub: SubscriptionInfo) -> None: + raise NotImplementedError + + async def subscription_removed(self, sub_id: str) -> None: + raise NotImplementedError + + async def subscriptions_all(self, subs: list[SubscriptionInfo]) -> None: + raise NotImplementedError + + +class SubscriptionManager: + def __init__(self, config, download_queue, notifier: SubscriptionNotifier): + self.config = config + self.dqueue = download_queue + self.notifier = notifier + pdir = config.STATE_DIR + if not os.path.isdir(pdir): + os.makedirs(pdir, exist_ok=True) + self._legacy_path = os.path.join(pdir, "subscriptions") + self._path = os.path.join(pdir, "subscriptions.json") + self._store = AtomicJsonStore(self._path, kind="subscriptions") + self._subs: dict[str, SubscriptionInfo] = {} + self._url_index: dict[str, str] = {} # normalized url -> id + self._pending_urls: set[str] = set() + self._checks_in_flight: set[str] = set() # subscription ids being checked right now + self._lock = asyncio.Lock() + self._loop_task: Optional[asyncio.Task] = None + self._load_all() + + def close(self) -> None: + # No persistent shelf handle to close. + return + + def _normalize_url(self, url: str) -> str: + return (url or "").strip() + + def _normalize_seen_ids(self, seen_ids: list[str]) -> list[str]: + max_seen = int(getattr(self.config, "SUBSCRIPTION_MAX_SEEN_IDS", 50000)) + normalized = [str(sid) for sid in dict.fromkeys(seen_ids)] + if len(normalized) > max_seen: + normalized = normalized[:max_seen] + return normalized + + def _load_all(self) -> None: + payload = self._store.load() + loaded_from_legacy = False + if payload is not None: + records = payload.get("items") or [] + else: + legacy_items = read_legacy_shelf(self._legacy_path) + records = [raw for _key, raw in legacy_items] if legacy_items else [] + if records: + loaded_from_legacy = True + + loaded_subs = self._iter_valid_subs(records) + compact_records = [] + for sub in loaded_subs: + sub.seen_ids = self._normalize_seen_ids(sub.seen_ids) + self._subs[sub.id] = sub + self._url_index[self._normalize_url(sub.url)] = sub.id + compact_records.append(_subscription_to_record(sub)) + + if loaded_from_legacy or ( + payload is not None + and ( + payload.get("schema_version") != self._store.schema_version + or compact_records != records + ) + ): + self._store.save({"items": compact_records}) + + def _iter_valid_subs(self, records: list[Any]) -> list[SubscriptionInfo]: + subs: list[SubscriptionInfo] = [] + for record in records: + sub = _subscription_from_record(record) + if sub is not None: + subs.append(sub) + return subs + + def _save_locked(self) -> None: + self._store.save({"items": [_subscription_to_record(sub) for sub in self._subs.values()]}) + + def _scan_extra_opts( + self, + ytdl_options_presets: Optional[list[str]], + ytdl_options_overrides: Optional[dict[str, Any]], + ) -> dict[str, Any]: + """Merge configured presets (in order) with per-subscription overrides. + + Applied on top of the global YTDL_OPTIONS when scanning a + subscription's feed, so cookies/impersonation/etc. configured via a + preset or override also take effect during the flat-playlist scan, + not just the eventual per-video download. (The global YTDL_OPTIONS base + is already spread into the scan params by ``_build_ydl_params``.) + """ + return merge_ytdl_option_layers( + ytdl_options_presets, ytdl_options_overrides, self.config.YTDL_OPTIONS_PRESETS + ) + + async def _queue_subscription_entries( + self, + entries: list[dict], + *, + download_type: str, + codec: str, + format: str, + quality: str, + folder: str, + custom_name_prefix: str, + playlist_item_limit: int, + auto_start: bool, + split_by_chapters: bool, + chapter_template: str, + subtitle_language: str, + subtitle_mode: str, + ytdl_options_presets: Optional[list[str]] = None, + ytdl_options_overrides: Optional[dict[str, Any]] = None, + ) -> tuple[list[str], list[str]]: + queued_ids: list[str] = [] + queue_errors: list[str] = [] + presets = list(ytdl_options_presets or []) + for ent in entries: + eid = _entry_id(ent) + vurl = _entry_video_url(ent) + if not eid or not vurl: + continue + queue_entry = dict(ent) + if "id" not in queue_entry: + queue_entry["id"] = eid + queue_entry["_type"] = "video" + queue_entry["webpage_url"] = vurl + result = await self.dqueue.add_entry( + queue_entry, + download_type, + codec, + format, + quality, + folder or None, + custom_name_prefix, + playlist_item_limit, + auto_start, + split_by_chapters, + chapter_template or None, + subtitle_language, + subtitle_mode, + presets, + ytdl_options_overrides, + ) + if isinstance(result, dict) and result.get("status") == "error": + msg = str(result.get("msg") or f"Queueing failed for {vurl}") + queue_errors.append(msg) + log.warning("Subscription queueing failed for %s: %s", vurl, msg) + continue + queued_ids.append(eid) + return queued_ids, queue_errors + + def list_all(self) -> list[SubscriptionInfo]: + return list(self._subs.values()) + + def get(self, sub_id: str) -> Optional[SubscriptionInfo]: + return self._subs.get(sub_id) + + def start_background_loop(self) -> None: + if self._loop_task is not None and not self._loop_task.done(): + return + # bg_tasks.create_task already logs unexpected task failures with the name. + self._loop_task = bg_tasks.create_task(self._periodic_loop(), name="subscription_loop") + + async def _periodic_loop(self) -> None: + while True: + await asyncio.sleep(60) + try: + await self.run_due_checks() + except Exception as e: + log.exception("Subscription periodic check error: %s", e) + + async def run_due_checks(self) -> None: + now = time.time() + due: list[SubscriptionInfo] = [] + async with self._lock: + for sub in list(self._subs.values()): + if not sub.enabled: + continue + interval_sec = max(60, int(sub.check_interval_minutes) * 60) + if sub.last_checked is None: + due.append(sub) + continue + if now - sub.last_checked < interval_sec: + continue + due.append(sub) + await self._check_many(due) + + async def _check_many(self, subs: list[SubscriptionInfo]) -> None: + """Check subscriptions with bounded concurrency so one slow feed does + not serialize the rest. Failures are isolated per subscription.""" + if not subs: + return + sem = asyncio.Semaphore(_MAX_CONCURRENT_CHECKS) + + async def _guarded(sub: SubscriptionInfo) -> None: + async with sem: + await self._check_one_unlocked(sub) + + results = await asyncio.gather( + *(_guarded(sub) for sub in subs), return_exceptions=True + ) + for sub, result in zip(subs, results): + if isinstance(result, Exception): + log.error( + "Subscription check crashed for %s: %s", + sub.name, + result, + exc_info=result, + ) + + async def add_subscription( + self, + url: str, + *, + check_interval_minutes: int, + download_type: str, + codec: str, + format: str, + quality: str, + folder: str, + custom_name_prefix: str, + auto_start: bool, + playlist_item_limit: int, + split_by_chapters: bool, + chapter_template: str, + subtitle_language: str, + subtitle_mode: str, + ytdl_options_presets: Optional[list[str]] = None, + ytdl_options_overrides: Optional[dict[str, Any]] = None, + title_regex: Any = None, + skip_subscriber_only: Any = None, + ) -> dict: + url = self._normalize_url(url) + if not url: + return {"status": "error", "msg": "Missing URL"} + # SSRF guard: block non-http(s) schemes and internal/metadata hosts + # before yt-dlp fetches the feed. May do a DNS lookup, so run off-loop. + url_error = await asyncio.get_running_loop().run_in_executor(None, validate_url, url) + if url_error is not None: + log.warning('Rejected subscription URL "%s": %s', url, url_error) + return {"status": "error", "msg": url_error} + try: + title_regex_stored = validate_title_regex(title_regex) + except re.error as exc: + return {"status": "error", "msg": f"Invalid title_regex: {exc}"} + try: + skip_so = coerce_optional_bool( + skip_subscriber_only, + default=False, + field_name="skip_subscriber_only", + ) + except ValueError as exc: + return {"status": "error", "msg": str(exc)} + + async with self._lock: + if url in self._url_index or url in self._pending_urls: + return {"status": "error", "msg": "This URL is already subscribed"} + self._pending_urls.add(url) + + try: + scan_first = max(int(getattr(self.config, "SUBSCRIPTION_SCAN_PLAYLIST_END", 50)), 1) + scan_extra_opts = self._scan_extra_opts(ytdl_options_presets, ytdl_options_overrides) + try: + info, entries = await asyncio.get_running_loop().run_in_executor( + None, + partial(extract_flat_playlist, self.config, url, scan_first, extra_opts=scan_extra_opts), + ) + except yt_dlp.utils.YoutubeDLError as exc: + return {"status": "error", "msg": str(exc)} + + if not info: + return {"status": "error", "msg": "Could not resolve URL"} + + etype = info.get("_type") or "video" + if etype not in ("playlist", "channel"): + return {"status": "error", "msg": VIDEO_ONLY_MSG} + + name = ( + info.get("title") + or info.get("channel") + or info.get("playlist_title") + or info.get("uploader") + or url + ) + + seen_entries = [ent for ent in entries if _is_media_entry(ent)] + all_ids: list[str] = [] + for ent in seen_entries: + if ent.get("live_status") == "is_upcoming": + continue # Don't mark scheduled streams as seen; queue them when they go live + eid = _entry_id(ent) + if eid: + all_ids.append(eid) + + sub = SubscriptionInfo( + id=str(uuid.uuid4()), + name=str(name), + url=url, + enabled=True, + check_interval_minutes=max(1, int(check_interval_minutes)), + download_type=download_type, + codec=codec, + format=format, + quality=quality, + folder=folder or "", + custom_name_prefix=custom_name_prefix or "", + auto_start=bool(auto_start), + playlist_item_limit=int(playlist_item_limit), + split_by_chapters=bool(split_by_chapters), + chapter_template=chapter_template or "", + subtitle_language=subtitle_language, + subtitle_mode=subtitle_mode, + ytdl_options_presets=list(ytdl_options_presets or []), + ytdl_options_overrides=dict(ytdl_options_overrides or {}), + title_regex=title_regex_stored, + skip_subscriber_only=skip_so, + last_checked=time.time(), + seen_ids=list(dict.fromkeys(all_ids)), + error=None, + ) + + async with self._lock: + if url in self._url_index: + return {"status": "error", "msg": "This URL is already subscribed"} + self._subs[sub.id] = sub + self._url_index[url] = sub.id + try: + self._save_locked() + except Exception: + self._subs.pop(sub.id, None) + self._url_index.pop(url, None) + raise + + await self.notifier.subscription_added(sub) + return {"status": "ok", "subscription": sub.to_public_dict()} + finally: + async with self._lock: + self._pending_urls.discard(url) + + async def delete_subscriptions(self, ids: list[str]) -> dict: + removed: list[str] = [] + async with self._lock: + previous_subs = self._subs.copy() + previous_index = self._url_index.copy() + for sid in ids: + sub = self._subs.pop(sid, None) + if sub: + normalized_url = self._normalize_url(sub.url) + self._url_index.pop(normalized_url, None) + removed.append(sid) + if removed: + try: + self._save_locked() + except Exception: + self._subs = previous_subs + self._url_index = previous_index + raise + for sid in removed: + await self.notifier.subscription_removed(sid) + return {"status": "ok"} + + async def update_subscription(self, sub_id: str, changes: dict) -> dict: + validated_tr: Optional[str] = None + if "title_regex" in changes: + try: + validated_tr = validate_title_regex(changes["title_regex"]) + except re.error as exc: + return {"status": "error", "msg": f"Invalid title_regex: {exc}"} + + skip_so_set = False + validated_skip_so = False + if "skip_subscriber_only" in changes: + try: + validated_skip_so = coerce_optional_bool( + changes["skip_subscriber_only"], + field_name="skip_subscriber_only", + ) + skip_so_set = True + except ValueError as exc: + return {"status": "error", "msg": str(exc)} + + enabled_set = False + validated_enabled = False + if "enabled" in changes: + try: + validated_enabled = _coerce_bool(changes["enabled"]) + enabled_set = True + except ValueError as exc: + return {"status": "error", "msg": str(exc)} + + interval_set = False + validated_interval = 0 + if "check_interval_minutes" in changes: + try: + validated_interval = max(1, int(changes["check_interval_minutes"])) + except (TypeError, ValueError): + return {"status": "error", "msg": "check_interval_minutes must be an integer"} + interval_set = True + + async with self._lock: + sub = self._subs.get(sub_id) + if not sub: + return {"status": "error", "msg": "Subscription not found"} + previous = copy.deepcopy(sub) + old_enabled = sub.enabled + + if enabled_set: + sub.enabled = validated_enabled + if interval_set: + sub.check_interval_minutes = validated_interval + if "name" in changes and changes["name"]: + sub.name = str(changes["name"]) + if validated_tr is not None: + sub.title_regex = validated_tr + if skip_so_set: + sub.skip_subscriber_only = validated_skip_so + + try: + self._save_locked() + except Exception: + self._subs[sub_id] = previous + raise + updated = sub + if "enabled" in changes and updated.enabled != old_enabled: + log.info( + "Subscription %s %s", + updated.name, + "resumed" if updated.enabled else "paused", + ) + await self.notifier.subscription_updated(updated) + return {"status": "ok", "subscription": updated.to_public_dict()} + + async def check_now(self, ids: Optional[list[str]] = None) -> dict: + async with self._lock: + targets = ( + [self._subs[i] for i in ids if i in self._subs] + if ids + else [s for s in self._subs.values() if s.enabled] + ) + log.info( + "Manual subscription check requested for %d subscription(s)", + len(targets), + ) + await self._check_many(targets) + return {"status": "ok"} + + async def _check_one_unlocked(self, sub: SubscriptionInfo) -> None: + sid = sub.id + # Prevent overlapping checks for the same subscription (e.g. the periodic + # loop and a manual check-now firing together), which could double-queue + # entries and drop seen_ids via a read-modify-write race. + async with self._lock: + if sid in self._checks_in_flight: + log.info("Subscription check already in progress for %s, skipping", sub.name) + return + self._checks_in_flight.add(sid) + try: + await self._check_one_inner(sub) + finally: + async with self._lock: + self._checks_in_flight.discard(sid) + + async def _check_one_inner(self, sub: SubscriptionInfo) -> None: + sid = sub.id + scan = int(getattr(self.config, "SUBSCRIPTION_SCAN_PLAYLIST_END", 50)) + # ytdl_options_presets/overrides are set at subscription creation and + # never mutated afterwards (update_subscription doesn't allow it), so + # reading them off `sub` here without holding the lock is safe. + scan_extra_opts = self._scan_extra_opts(sub.ytdl_options_presets, sub.ytdl_options_overrides) + log.info("Checking subscription: %s", sub.name) + try: + info, entries = await asyncio.get_running_loop().run_in_executor( + None, + partial(extract_flat_playlist, self.config, sub.url, scan, extra_opts=scan_extra_opts), + ) + except yt_dlp.utils.YoutubeDLError as exc: + async with self._lock: + cur = self._subs.get(sid) + if cur: + previous = copy.deepcopy(cur) + cur.error = str(exc) + cur.last_checked = time.time() + try: + self._save_locked() + except Exception: + self._subs[sid] = previous + raise + sub = cur + log.warning("Subscription check failed for %s: %s", sub.name, exc) + await self.notifier.subscription_updated(sub) + return + entries = [ent for ent in entries if _is_media_entry(ent)] + + etype = (info or {}).get("_type") or "video" + if etype == "video": + async with self._lock: + cur = self._subs.get(sid) + if cur: + previous = copy.deepcopy(cur) + cur.error = VIDEO_ONLY_MSG + cur.last_checked = time.time() + try: + self._save_locked() + except Exception: + self._subs[sid] = previous + raise + sub = cur + log.warning("Subscription %s no longer resolves to a subscribable feed", sub.name) + await self.notifier.subscription_updated(sub) + return + if not entries: + async with self._lock: + cur = self._subs.get(sid) + if cur: + previous = copy.deepcopy(cur) + cur.last_checked = time.time() + cur.error = None + try: + self._save_locked() + except Exception: + self._subs[sid] = previous + raise + sub = cur + log.warning("Subscription check finished for %s: No entries found", sub.name) + await self.notifier.subscription_updated(sub) + return + + async with self._lock: + cur = self._subs.get(sid) + if not cur: + return + seen = cur.seen_set() + seen_ids_snapshot = list(cur.seen_ids) + dl_type = cur.download_type + dl_codec = cur.codec + dl_format = cur.format + dl_quality = cur.quality + dl_folder = cur.folder + dl_prefix = cur.custom_name_prefix + dl_plimit = cur.playlist_item_limit + dl_autostart = cur.auto_start + dl_split = cur.split_by_chapters + dl_chapter = cur.chapter_template + dl_sublang = cur.subtitle_language + dl_submode = cur.subtitle_mode + dl_ytdl_presets = list(cur.ytdl_options_presets) + dl_ytdl_overrides = dict(cur.ytdl_options_overrides) + dl_title_regex = cur.title_regex or "" + dl_skip_subscriber_only = bool(cur.skip_subscriber_only) + + new_entries: list[dict] = [] + for ent in entries: + eid = _entry_id(ent) + if not eid: + continue + # Seen entries that are currently live are deliberately re-queued: + # a stream first seen as 'upcoming' must still be captured once it + # goes live. The download queue dedups by URL while a capture is + # in flight, so this can't double-queue an active capture. + if eid in seen and ent.get("live_status") != "is_live": + continue + new_entries.append(ent) + + pattern_re: Optional[re.Pattern[str]] = None + if dl_title_regex: + try: + pattern_re = re.compile(dl_title_regex) + except re.error: + log.warning( + "Invalid stored title_regex on subscription %s, ignoring filter", + sub.name, + ) + + queue_entries: list[dict] = [] + filtered_ids: list[str] = [] + for ent in new_entries: + eid = _entry_id(ent) + if pattern_re is not None: + title = str(ent.get("title") or "") + if not pattern_re.search(title): + if eid: + filtered_ids.append(eid) + continue + queue_entries.append(ent) + + subscriber_filtered_ids: list[str] = [] + if dl_skip_subscriber_only: + kept_entries: list[dict] = [] + for ent in queue_entries: + eid = _entry_id(ent) + if _is_subscriber_only_entry(ent): + if eid: + subscriber_filtered_ids.append(eid) + continue + kept_entries.append(ent) + queue_entries = kept_entries + + queued_ids, queue_errors = await self._queue_subscription_entries( + queue_entries, + download_type=dl_type, + codec=dl_codec, + format=dl_format, + quality=dl_quality, + folder=dl_folder, + custom_name_prefix=dl_prefix, + playlist_item_limit=dl_plimit, + auto_start=dl_autostart, + split_by_chapters=dl_split, + chapter_template=dl_chapter or "", + subtitle_language=dl_sublang, + subtitle_mode=dl_submode, + ytdl_options_presets=dl_ytdl_presets, + ytdl_options_overrides=dl_ytdl_overrides, + ) + log.info( + "Subscription check finished for %s: %d new, %d filtered, %d subscriber_skipped, %d queued, %d failed", + sub.name, + len(new_entries), + len(filtered_ids), + len(subscriber_filtered_ids), + len(queued_ids), + len(queue_errors), + ) + + merged = list( + dict.fromkeys( + queued_ids + filtered_ids + subscriber_filtered_ids + seen_ids_snapshot + ) + ) + max_seen = int(getattr(self.config, "SUBSCRIPTION_MAX_SEEN_IDS", 50000)) + if len(merged) > max_seen: + merged = merged[:max_seen] + + async with self._lock: + cur = self._subs.get(sid) + if not cur: + return + previous = copy.deepcopy(cur) + cur.seen_ids = merged + cur.last_checked = time.time() + cur.error = "; ".join(queue_errors[:3]) if queue_errors else None + try: + self._save_locked() + except Exception: + self._subs[sid] = previous + raise + sub = cur + await self.notifier.subscription_updated(sub) + + async def emit_all(self) -> None: + await self.notifier.subscriptions_all(self.list_all()) diff --git a/app/tests/conftest.py b/app/tests/conftest.py new file mode 100644 index 0000000..114806b --- /dev/null +++ b/app/tests/conftest.py @@ -0,0 +1,32 @@ +"""Pytest configuration: set env and filesystem layout before importing ``main``.""" + +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + + +def _ensure_test_env() -> None: + if os.environ.get("METUBE_TEST_ENV_READY"): + return + tmp = tempfile.mkdtemp(prefix="metube-pytest-") + base = Path(tmp) + browser = base / "ui" / "dist" / "metube" / "browser" + browser.mkdir(parents=True) + (browser / "index.html").write_text("", encoding="utf-8") + dl = base / "downloads" + st = base / "state" + dl.mkdir(parents=True) + st.mkdir(parents=True) + os.environ["DOWNLOAD_DIR"] = str(dl) + os.environ["STATE_DIR"] = str(st) + os.environ["TEMP_DIR"] = str(dl) + os.environ["YTDL_OPTIONS"] = "{}" + os.environ["YTDL_OPTIONS_FILE"] = "" + os.environ["BASE_DIR"] = str(base) + os.environ["LOGLEVEL"] = "INFO" + os.environ["METUBE_TEST_ENV_READY"] = "1" + + +_ensure_test_env() diff --git a/app/tests/test_api.py b/app/tests/test_api.py new file mode 100644 index 0000000..8ac86b3 --- /dev/null +++ b/app/tests/test_api.py @@ -0,0 +1,430 @@ +"""HTTP handler tests for ``main`` using mocked ``web.Request`` (no TestServer).""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock +from urllib.parse import quote + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + +import main + + +@pytest.fixture +def mock_dqueue(monkeypatch): + d = MagicMock() + d.initialize = AsyncMock(return_value=None) + d.add = AsyncMock(return_value={"status": "ok"}) + d.cancel = AsyncMock(return_value={"status": "ok"}) + d.clear = AsyncMock(return_value={"status": "ok"}) + d.start_pending = AsyncMock(return_value={"status": "ok"}) + d.cancel_add = MagicMock() + d.queue = MagicMock() + d.done = MagicMock() + d.pending = MagicMock() + d.queue.saved_items = MagicMock(return_value=[]) + d.done.saved_items = MagicMock(return_value=[]) + d.pending.saved_items = MagicMock(return_value=[]) + d.queue.items = MagicMock(return_value=[]) + d.done.items = MagicMock(return_value=[]) + d.pending.items = MagicMock(return_value=[]) + d.get = MagicMock(return_value=([], [])) + monkeypatch.setattr(main, "dqueue", d) + return d + + +def _valid_video_add_body(**kwargs): + base = { + "url": "https://example.com/watch?v=1", + "download_type": "video", + "codec": "auto", + "format": "any", + "quality": "best", + "ytdl_options_presets": [], + "ytdl_options_overrides": "", + } + base.update(kwargs) + return base + + +def _json_request(body: dict | None): + req = MagicMock(spec=web.Request) + req.json = AsyncMock(return_value=body) + return req + + +@pytest.mark.asyncio +async def test_add_ok(mock_dqueue): + req = _json_request(_valid_video_add_body()) + resp = await main.add(req) + assert resp.status == 200 + text = resp.text + data = json.loads(text) + assert data["status"] == "ok" + mock_dqueue.add.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_add_passes_preset_and_overrides(mock_dqueue, monkeypatch): + monkeypatch.setattr(main.config, "YTDL_OPTIONS_PRESETS", {"Preset A": {"writesubtitles": True}}) + monkeypatch.setattr(main.config, "ALLOW_YTDL_OPTIONS_OVERRIDES", True) + req = _json_request( + _valid_video_add_body( + ytdl_options_presets=["Preset A"], + ytdl_options_overrides='{"writesubtitles": true}', + ) + ) + resp = await main.add(req) + assert resp.status == 200 + call = mock_dqueue.add.await_args + assert call is not None + assert call.args[13] == ["Preset A"] + assert call.args[14] == {"writesubtitles": True} + + +@pytest.mark.asyncio +async def test_add_legacy_string_preset_normalized(mock_dqueue, monkeypatch): + monkeypatch.setattr(main.config, "YTDL_OPTIONS_PRESETS", {"Legacy": {}}) + body = _valid_video_add_body() + del body["ytdl_options_presets"] + body["ytdl_options_preset"] = "Legacy" + req = _json_request(body) + resp = await main.add(req) + assert resp.status == 200 + call = mock_dqueue.add.await_args + assert call.args[13] == ["Legacy"] + + +@pytest.mark.asyncio +async def test_add_missing_url_returns_400(mock_dqueue): + req = _json_request({"download_type": "video", "quality": "best", "format": "any"}) + with pytest.raises(web.HTTPBadRequest): + await main.add(req) + mock_dqueue.add.assert_not_called() + + +@pytest.mark.asyncio +async def test_add_invalid_download_type(mock_dqueue): + req = _json_request(_valid_video_add_body(download_type="invalid")) + with pytest.raises(web.HTTPBadRequest): + await main.add(req) + + +@pytest.mark.asyncio +async def test_add_invalid_video_quality(mock_dqueue): + req = _json_request(_valid_video_add_body(quality="9999")) + with pytest.raises(web.HTTPBadRequest): + await main.add(req) + + +@pytest.mark.asyncio +async def test_add_invalid_subtitle_language(mock_dqueue): + req = _json_request( + { + "url": "https://example.com/v", + "download_type": "captions", + "codec": "auto", + "format": "srt", + "quality": "best", + "subtitle_language": "bad language!", + } + ) + with pytest.raises(web.HTTPBadRequest): + await main.add(req) + + +@pytest.mark.asyncio +async def test_add_custom_name_prefix_path_traversal(mock_dqueue): + req = _json_request(_valid_video_add_body(custom_name_prefix="../evil")) + with pytest.raises(web.HTTPBadRequest): + await main.add(req) + + +@pytest.mark.asyncio +async def test_add_chapter_template_path_traversal(mock_dqueue): + req = _json_request( + _valid_video_add_body( + split_by_chapters=True, + chapter_template="/etc/passwd%(title)s", + ) + ) + with pytest.raises(web.HTTPBadRequest): + await main.add(req) + + +@pytest.mark.asyncio +async def test_add_invalid_json_body(mock_dqueue): + req = MagicMock(spec=web.Request) + req.json = AsyncMock(side_effect=json.JSONDecodeError("msg", "", 0)) + with pytest.raises(web.HTTPBadRequest): + await main.add(req) + + +@pytest.mark.asyncio +async def test_add_invalid_ytdl_options_override_json(mock_dqueue): + req = _json_request(_valid_video_add_body(ytdl_options_overrides="{bad json}")) + with pytest.raises(web.HTTPBadRequest): + await main.add(req) + + +@pytest.mark.asyncio +async def test_add_rejects_ytdl_options_overrides_when_disabled(mock_dqueue): + req = _json_request(_valid_video_add_body(ytdl_options_overrides='{"exec": "rm -rf /"}')) + with pytest.raises(web.HTTPBadRequest): + await main.add(req) + + +@pytest.mark.asyncio +async def test_add_allows_any_ytdl_options_override_key_when_enabled(mock_dqueue, monkeypatch): + monkeypatch.setattr(main.config, "ALLOW_YTDL_OPTIONS_OVERRIDES", True) + req = _json_request(_valid_video_add_body(ytdl_options_overrides='{"exec": "echo hi"}')) + resp = await main.add(req) + assert resp.status == 200 + call = mock_dqueue.add.await_args + assert call is not None + assert call.args[14] == {"exec": "echo hi"} + + +@pytest.mark.asyncio +async def test_add_unknown_ytdl_preset(mock_dqueue): + req = _json_request(_valid_video_add_body(ytdl_options_presets=["Missing"])) + with pytest.raises(web.HTTPBadRequest): + await main.add(req) + + +@pytest.mark.asyncio +async def test_delete_missing_ids(mock_dqueue): + req = _json_request({"where": "queue"}) + with pytest.raises(web.HTTPBadRequest): + await main.delete(req) + + +@pytest.mark.asyncio +async def test_delete_queue_calls_cancel(mock_dqueue): + req = _json_request({"where": "queue", "ids": ["http://x"]}) + resp = await main.delete(req) + assert resp.status == 200 + mock_dqueue.cancel.assert_awaited_once_with(["http://x"]) + + +@pytest.mark.asyncio +async def test_start_pending(mock_dqueue): + req = _json_request({"ids": ["a"]}) + resp = await main.start(req) + assert resp.status == 200 + mock_dqueue.start_pending.assert_awaited_once_with(["a"]) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("body", [{}, {"ids": "abc"}, {"ids": []}, {"ids": [1, 2]}]) +async def test_start_rejects_malformed_ids(mock_dqueue, body): + req = _json_request(body) + with pytest.raises(web.HTTPBadRequest): + await main.start(req) + mock_dqueue.start_pending.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "body", + [ + {"where": "queue"}, + {"where": "queue", "ids": "abc"}, + {"where": "queue", "ids": []}, + {"where": "queue", "ids": [1, 2]}, + ], +) +async def test_delete_rejects_malformed_ids(mock_dqueue, body): + req = _json_request(body) + with pytest.raises(web.HTTPBadRequest): + await main.delete(req) + mock_dqueue.cancel.assert_not_awaited() + mock_dqueue.clear.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_history_shape(mock_dqueue): + req = MagicMock(spec=web.Request) + resp = await main.history(req) + assert resp.status == 200 + data = json.loads(resp.text) + assert set(data.keys()) == {"done", "queue", "pending"} + + +@pytest.mark.asyncio +async def test_history_reads_in_memory_queues_not_disk_state(mock_dqueue): + fake_queue_dl = MagicMock() + fake_queue_dl.info = {"id": "q1", "title": "Queued"} + fake_done_dl = MagicMock() + fake_done_dl.info = {"id": "d1", "title": "Done"} + fake_pending_dl = MagicMock() + fake_pending_dl.info = {"id": "p1", "title": "Pending"} + mock_dqueue.queue.items.return_value = [("q1", fake_queue_dl)] + mock_dqueue.done.items.return_value = [("d1", fake_done_dl)] + mock_dqueue.pending.items.return_value = [("p1", fake_pending_dl)] + + req = MagicMock(spec=web.Request) + resp = await main.history(req) + assert resp.status == 200 + data = json.loads(resp.text) + assert [item["id"] for item in data["queue"]] == ["q1"] + assert [item["id"] for item in data["done"]] == ["d1"] + assert [item["id"] for item in data["pending"]] == ["p1"] + mock_dqueue.queue.saved_items.assert_not_called() + mock_dqueue.done.saved_items.assert_not_called() + mock_dqueue.pending.saved_items.assert_not_called() + + +@pytest.mark.asyncio +async def test_version_json(mock_dqueue): + req = MagicMock(spec=web.Request) + resp = await main.version(req) + assert resp.status == 200 + body = json.loads(resp.text) + assert "yt-dlp" in body and "version" in body + + +@pytest.mark.asyncio +async def test_presets_endpoint_returns_names(mock_dqueue, monkeypatch): + monkeypatch.setattr(main.config, "YTDL_OPTIONS_PRESETS", {"Preset B": {}, "Preset A": {}}) + req = MagicMock(spec=web.Request) + resp = await main.presets(req) + assert resp.status == 200 + assert json.loads(resp.text) == {"presets": ["Preset A", "Preset B"]} + + +@pytest.mark.asyncio +async def test_cookie_status(mock_dqueue): + req = MagicMock(spec=web.Request) + resp = await main.cookie_status(req) + assert resp.status == 200 + data = json.loads(resp.text) + assert data.get("status") == "ok" + assert "has_cookies" in data + + +@pytest.mark.asyncio +async def test_options_add_cors(mock_dqueue): + req = MagicMock(spec=web.Request) + resp = await main.add_cors(req) + assert resp.status == 200 + + +@pytest.mark.asyncio +async def test_upload_cookies_missing_field(mock_dqueue): + req = MagicMock(spec=web.Request) + reader = MagicMock() + field = MagicMock() + field.name = "wrongname" + reader.next = AsyncMock(side_effect=[field, None]) + req.multipart = AsyncMock(return_value=reader) + resp = await main.upload_cookies(req) + assert resp.status == 400 + + +@pytest.mark.asyncio +async def test_add_legacy_format_migrated(mock_dqueue): + req = _json_request({"url": "https://example.com/v", "format": "m4a", "quality": "best"}) + resp = await main.add(req) + assert resp.status == 200 + call = mock_dqueue.add.await_args + assert call is not None + assert call.args[1] == "audio" + + +@pytest.mark.asyncio +async def test_add_passes_clip_bounds_to_queue(mock_dqueue): + req = _json_request( + _valid_video_add_body(clip_start="2:26", clip_end="3:24"), + ) + resp = await main.add(req) + assert resp.status == 200 + call = mock_dqueue.add.await_args + assert call is not None + assert call.args[15] == pytest.approx(146.0) + assert call.args[16] == pytest.approx(204.0) + + +@pytest.mark.asyncio +async def test_subscribe_rejects_clip_options(mock_dqueue, monkeypatch): + monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock()) + req = _json_request( + { + **_valid_video_add_body(clip_start="10"), + "check_interval_minutes": 60, + } + ) + with pytest.raises(web.HTTPBadRequest): + await main.subscribe(req) + main.submgr.add_subscription.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_subscriptions_update_invalid_enabled_returns_error_not_500(mock_dqueue): + req = _json_request({"id": "nonexistent", "enabled": "maybe"}) + resp = await main.subscriptions_update(req) + assert resp.status == 200 + body = json.loads(resp.text) + assert body["status"] == "error" + + +@pytest.mark.asyncio +async def test_subscriptions_update_invalid_interval_returns_error_not_500(mock_dqueue): + req = _json_request({"id": "nonexistent", "check_interval_minutes": "abc"}) + resp = await main.subscriptions_update(req) + assert resp.status == 200 + body = json.loads(resp.text) + assert body["status"] == "error" + + +def test_is_within_state_dir_blocks_state_subtree(): + state_dir = main._STATE_DIR_REAL + assert main._is_within_state_dir(state_dir) + assert main._is_within_state_dir(os.path.join(state_dir, "cookies.txt")) + assert main._is_within_state_dir(os.path.join(state_dir, "queue", "item.json")) + + +def test_is_within_state_dir_allows_sibling_downloads(): + download_dir = os.path.realpath(main.config.DOWNLOAD_DIR) + assert not main._is_within_state_dir(os.path.join(download_dir, "video.mp4")) + assert not main._is_within_state_dir("/tmp/unrelated/video.mp4") + + +@pytest.mark.asyncio +async def test_download_blocks_state_dir_files(monkeypatch): + download_dir = Path(main.config.DOWNLOAD_DIR) + state_dir = download_dir / ".metube" + state_dir.mkdir(parents=True, exist_ok=True) + (state_dir / "cookies.txt").write_text("# Netscape HTTP Cookie File\n", encoding="utf-8") + (download_dir / "video.mp4").write_bytes(b"video") + # request.path is already percent-decoded by aiohttp; state_dir_guard must + # not decode it a second time, or a filename containing a literal '%' + # gets mangled into a false 404. + percent_filename = "100% done.mp4" + (download_dir / percent_filename).write_bytes(b"percent video") + + monkeypatch.setattr(main.config, "STATE_DIR", str(state_dir)) + monkeypatch.setattr(main, "_STATE_DIR_REAL", os.path.realpath(str(state_dir))) + + try: + async with TestClient(TestServer(main.app)) as client: + blocked = await client.get("/download/.metube/cookies.txt") + assert blocked.status == 404 + + allowed = await client.get("/download/video.mp4") + assert allowed.status == 200 + assert await allowed.read() == b"video" + + percent_resp = await client.get("/download/" + quote(percent_filename)) + assert percent_resp.status == 200 + assert await percent_resp.read() == b"percent video" + finally: + (state_dir / "cookies.txt").unlink(missing_ok=True) + (download_dir / "video.mp4").unlink(missing_ok=True) + (download_dir / percent_filename).unlink(missing_ok=True) + state_dir.rmdir() diff --git a/app/tests/test_bg_tasks.py b/app/tests/test_bg_tasks.py new file mode 100644 index 0000000..a9c1237 --- /dev/null +++ b/app/tests/test_bg_tasks.py @@ -0,0 +1,55 @@ +"""Tests for the ``bg_tasks.create_task`` strong-reference/logging helper.""" + +from __future__ import annotations + +import asyncio +import logging + +import pytest + +import bg_tasks + + +@pytest.mark.asyncio +async def test_create_task_removes_itself_from_registry_on_success(): + async def _ok(): + return 42 + + task = bg_tasks.create_task(_ok(), name="ok_task") + assert task in bg_tasks._TASKS + result = await task + assert result == 42 + assert task not in bg_tasks._TASKS + + +@pytest.mark.asyncio +async def test_create_task_logs_unhandled_exception(caplog): + async def _boom(): + raise ValueError("kaboom") + + with caplog.at_level(logging.ERROR, logger="bg_tasks"): + task = bg_tasks.create_task(_boom(), name="boom_task") + with pytest.raises(ValueError): + await task + # Let the done-callback (scheduled via call_soon) run. + await asyncio.sleep(0) + + assert task not in bg_tasks._TASKS + assert any("boom_task" in record.message for record in caplog.records) + + +@pytest.mark.asyncio +async def test_create_task_does_not_log_on_cancellation(caplog): + async def _sleep_forever(): + await asyncio.sleep(10) + + with caplog.at_level(logging.ERROR, logger="bg_tasks"): + task = bg_tasks.create_task(_sleep_forever(), name="cancel_task") + await asyncio.sleep(0) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + await asyncio.sleep(0) + + assert task not in bg_tasks._TASKS + assert not any("cancel_task" in record.message for record in caplog.records) diff --git a/app/tests/test_config.py b/app/tests/test_config.py new file mode 100644 index 0000000..5fed6fd --- /dev/null +++ b/app/tests/test_config.py @@ -0,0 +1,231 @@ +"""Tests for ``Config`` (env parsing, yt-dlp options, frontend_safe).""" + +from __future__ import annotations + +import json +import os +import tempfile +import unittest +from unittest.mock import patch + +from main import Config + + +def _base_env(**overrides: str) -> dict[str, str]: + env = {k: str(v) for k, v in Config._DEFAULTS.items()} + env.update(overrides) + return env + + +class ConfigTests(unittest.TestCase): + def test_url_prefix_gets_trailing_slash(self): + with patch.dict(os.environ, _base_env(URL_PREFIX="foo"), clear=False): + c = Config() + self.assertEqual(c.URL_PREFIX, "foo/") + + def test_public_host_url_gets_trailing_slash(self): + with patch.dict( + os.environ, + _base_env(PUBLIC_HOST_URL="https://ytdl.example.com"), + clear=False, + ): + c = Config() + self.assertEqual(c.PUBLIC_HOST_URL, "https://ytdl.example.com/") + + def test_public_host_audio_url_gets_trailing_slash(self): + with patch.dict( + os.environ, + _base_env(PUBLIC_HOST_AUDIO_URL="https://audio.example.com"), + clear=False, + ): + c = Config() + self.assertEqual(c.PUBLIC_HOST_AUDIO_URL, "https://audio.example.com/") + + def test_public_host_url_empty_stays_empty(self): + with patch.dict( + os.environ, + _base_env(PUBLIC_HOST_URL="", PUBLIC_HOST_AUDIO_URL=""), + clear=False, + ): + c = Config() + self.assertEqual(c.PUBLIC_HOST_URL, "") + self.assertEqual(c.PUBLIC_HOST_AUDIO_URL, "") + + def test_blank_audio_host_falls_back_to_audio_download_route(self): + # Regression: a present-but-blank PUBLIC_HOST_AUDIO_URL must not stay empty + # (which produced root-relative, 404ing audio links). It falls back to the + # 'audio_download/' route that serves AUDIO_DOWNLOAD_DIR. + with patch.dict( + os.environ, + _base_env(PUBLIC_HOST_URL="https://ytdl.example.com", PUBLIC_HOST_AUDIO_URL=""), + clear=False, + ): + c = Config() + self.assertEqual(c.PUBLIC_HOST_URL, "https://ytdl.example.com/") + self.assertEqual(c.PUBLIC_HOST_AUDIO_URL, "audio_download/") + + def test_public_host_url_already_slashed_unchanged(self): + with patch.dict( + os.environ, + _base_env( + PUBLIC_HOST_URL="https://ytdl.example.com/", + PUBLIC_HOST_AUDIO_URL="https://audio.example.com/", + ), + clear=False, + ): + c = Config() + self.assertEqual(c.PUBLIC_HOST_URL, "https://ytdl.example.com/") + self.assertEqual(c.PUBLIC_HOST_AUDIO_URL, "https://audio.example.com/") + + def test_ytdl_options_json_loaded(self): + opts = {"quiet": True, "no_warnings": True} + with patch.dict( + os.environ, + _base_env(YTDL_OPTIONS=json.dumps(opts)), + clear=False, + ): + c = Config() + self.assertEqual(c.YTDL_OPTIONS["quiet"], True) + + def test_ytdl_option_presets_json_loaded(self): + presets = {"Audio extras": {"embed_thumbnail": True}} + with patch.dict( + os.environ, + _base_env(YTDL_OPTIONS_PRESETS=json.dumps(presets)), + clear=False, + ): + c = Config() + self.assertEqual(c.YTDL_OPTIONS_PRESETS["Audio extras"]["embed_thumbnail"], True) + + def test_invalid_ytdl_options_exits(self): + with patch.dict(os.environ, _base_env(YTDL_OPTIONS="not-json"), clear=False): + with self.assertRaises(SystemExit): + Config() + + def test_invalid_boolean_env_exits(self): + with patch.dict(os.environ, _base_env(CUSTOM_DIRS="maybe"), clear=False): + with self.assertRaises(SystemExit): + Config() + + def test_frontend_safe_excludes_secrets(self): + with patch.dict(os.environ, _base_env(), clear=False): + c = Config() + safe = c.frontend_safe() + self.assertNotIn("YTDL_OPTIONS", safe) + self.assertNotIn("HOST", safe) + self.assertEqual(safe["ALLOW_YTDL_OPTIONS_OVERRIDES"], False) + + def test_allow_ytdl_options_overrides_boolean_loaded(self): + with patch.dict(os.environ, _base_env(ALLOW_YTDL_OPTIONS_OVERRIDES="true"), clear=False): + c = Config() + self.assertTrue(c.ALLOW_YTDL_OPTIONS_OVERRIDES) + + def test_ytdl_nightly_update_time_empty_default(self): + with patch.dict(os.environ, _base_env(YTDL_NIGHTLY_UPDATE_TIME=""), clear=False): + c = Config() + self.assertEqual(c.YTDL_NIGHTLY_UPDATE_TIME, "") + + def test_ytdl_nightly_update_time_valid(self): + with patch.dict(os.environ, _base_env(YTDL_NIGHTLY_UPDATE_TIME="04:00"), clear=False): + c = Config() + self.assertEqual(c.YTDL_NIGHTLY_UPDATE_TIME, "04:00") + + def test_ytdl_nightly_update_time_invalid_exits(self): + for bad in ("25:00", "4am", "12:60"): + with patch.dict(os.environ, _base_env(YTDL_NIGHTLY_UPDATE_TIME=bad), clear=False): + with self.assertRaises(SystemExit): + Config() + + def test_invalid_max_concurrent_downloads_exits(self): + for bad in ("0", "-1", "abc"): + with patch.dict(os.environ, _base_env(MAX_CONCURRENT_DOWNLOADS=bad), clear=False): + with self.assertRaises(SystemExit): + Config() + + def test_invalid_port_exits(self): + for bad in ("0", "70000", "notaport"): + with patch.dict(os.environ, _base_env(PORT=bad), clear=False): + with self.assertRaises(SystemExit): + Config() + + def test_invalid_clear_completed_after_exits(self): + for bad in ("-5", "soon"): + with patch.dict(os.environ, _base_env(CLEAR_COMPLETED_AFTER=bad), clear=False): + with self.assertRaises(SystemExit): + Config() + + def test_clear_completed_after_zero_allowed(self): + with patch.dict(os.environ, _base_env(CLEAR_COMPLETED_AFTER="0"), clear=False): + c = Config() + self.assertEqual(c.CLEAR_COMPLETED_AFTER, "0") + + def test_invalid_default_option_playlist_item_limit_exits(self): + for bad in ("-1", "many"): + with patch.dict(os.environ, _base_env(DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT=bad), clear=False): + with self.assertRaises(SystemExit): + Config() + + def test_default_option_playlist_item_limit_zero_allowed(self): + with patch.dict(os.environ, _base_env(DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT="0"), clear=False): + c = Config() + self.assertEqual(c.DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT, "0") + + def test_invalid_subscription_default_check_interval_exits(self): + for bad in ("0", "-1", "often"): + with patch.dict(os.environ, _base_env(SUBSCRIPTION_DEFAULT_CHECK_INTERVAL=bad), clear=False): + with self.assertRaises(SystemExit): + Config() + + def test_invalid_subscription_scan_playlist_end_exits(self): + for bad in ("0", "-1", "all"): + with patch.dict(os.environ, _base_env(SUBSCRIPTION_SCAN_PLAYLIST_END=bad), clear=False): + with self.assertRaises(SystemExit): + Config() + + def test_invalid_subscription_max_seen_ids_exits(self): + for bad in ("0", "-1", "unlimited"): + with patch.dict(os.environ, _base_env(SUBSCRIPTION_MAX_SEEN_IDS=bad), clear=False): + with self.assertRaises(SystemExit): + Config() + + def test_runtime_override_roundtrip(self): + with patch.dict(os.environ, _base_env(), clear=False): + c = Config() + c.set_runtime_override("cookiefile", "/tmp/c.txt") + self.assertEqual(c.YTDL_OPTIONS.get("cookiefile"), "/tmp/c.txt") + c.remove_runtime_override("cookiefile") + self.assertIsNone(c.YTDL_OPTIONS.get("cookiefile")) + + def test_ytdl_options_file_merges(self): + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f: + json.dump({"extractor_args": {"youtube": {"player_client": ["web"]}}}, f) + path = f.name + try: + with patch.dict( + os.environ, + _base_env(YTDL_OPTIONS="{}", YTDL_OPTIONS_FILE=path), + clear=False, + ): + c = Config() + self.assertIn("extractor_args", c.YTDL_OPTIONS) + finally: + os.unlink(path) + + def test_ytdl_option_presets_file_merges(self): + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f: + json.dump({"With subtitles": {"writesubtitles": True}}, f) + path = f.name + try: + with patch.dict( + os.environ, + _base_env(YTDL_OPTIONS_PRESETS="{}", YTDL_OPTIONS_PRESETS_FILE=path), + clear=False, + ): + c = Config() + self.assertIn("With subtitles", c.YTDL_OPTIONS_PRESETS) + finally: + os.unlink(path) + + +if __name__ == "__main__": + unittest.main() diff --git a/app/tests/test_dl_formats.py b/app/tests/test_dl_formats.py new file mode 100644 index 0000000..07e26dd --- /dev/null +++ b/app/tests/test_dl_formats.py @@ -0,0 +1,182 @@ +"""Tests for ``app.dl_formats`` format selectors and yt-dlp option mapping.""" + +from __future__ import annotations + +import copy +import unittest + +from app.dl_formats import ( + _normalize_caption_mode, + _normalize_subtitle_language, + get_format, + get_opts, + merge_ytdl_option_layers, +) + + +class DlFormatsTests(unittest.TestCase): + def test_audio_unknown_format_raises_value_error(self): + with self.assertRaises(ValueError): + get_format("audio", "auto", "invalid", "best") + + def test_wav_does_not_enable_thumbnail_postprocessing(self): + opts = get_opts("audio", "auto", "wav", "best", {}) + self.assertNotIn("writethumbnail", opts) + + def test_mp3_enables_thumbnail_postprocessing(self): + opts = get_opts("audio", "auto", "mp3", "best", {}) + self.assertTrue(opts.get("writethumbnail")) + + def test_custom_format_passthrough(self): + self.assertEqual(get_format("video", "auto", "custom:bestvideo+bestaudio", "best"), "bestvideo+bestaudio") + + def test_thumbnail_and_captions_format_strings(self): + self.assertEqual(get_format("thumbnail", "auto", "jpg", "best"), "bestaudio/best") + self.assertEqual(get_format("captions", "auto", "srt", "best"), "bestaudio/best") + + def test_audio_formats(self): + for fmt in ("m4a", "mp3", "opus", "wav", "flac"): + with self.subTest(fmt=fmt): + self.assertIn(f"ext={fmt}", get_format("audio", "auto", fmt, "best")) + + def test_video_unknown_format_raises(self): + with self.assertRaises(ValueError): + get_format("video", "auto", "mkv", "best") + + def test_unknown_download_type_raises(self): + with self.assertRaises(ValueError): + get_format("unknown", "auto", "any", "best") + + def test_video_any_mp4_ios_with_height_quality(self): + self.assertIn("height<=1080", get_format("video", "auto", "any", "1080")) + self.assertNotIn("height<=", get_format("video", "auto", "any", "best")) + self.assertNotIn("height<=", get_format("video", "auto", "any", "worst")) + + def test_video_codec_filters(self): + self.assertIn("h264", get_format("video", "h264", "any", "best")) + self.assertIn("hevc", get_format("video", "h265", "any", "best")) + self.assertIn("av0?1", get_format("video", "av1", "any", "best")) + self.assertIn("vp0?9", get_format("video", "vp9", "any", "best")) + + def test_video_mp4_includes_m4a_audio(self): + s = get_format("video", "auto", "mp4", "720") + self.assertIn("[ext=m4a]", s) + + def test_video_ios_selector_contains_avc_pattern(self): + s = get_format("video", "auto", "ios", "best") + self.assertIn("h26[45]", s) + + def test_get_opts_deepcopy_does_not_mutate_input(self): + base = {"postprocessors": [{"key": "Existing"}]} + orig = copy.deepcopy(base) + get_opts("audio", "auto", "mp3", "best", base) + self.assertEqual(base, orig) + + def test_get_opts_audio_m4a_postprocessors(self): + opts = get_opts("audio", "auto", "m4a", "best", {}) + keys = [p["key"] for p in opts["postprocessors"]] + self.assertIn("FFmpegExtractAudio", keys) + + def test_get_opts_audio_mp3_quality_not_best(self): + opts = get_opts("audio", "auto", "mp3", "192", {}) + ext = next(p for p in opts["postprocessors"] if p["key"] == "FFmpegExtractAudio") + self.assertEqual(ext["preferredquality"], "192") + + def test_get_opts_thumbnail_skip_download(self): + opts = get_opts("thumbnail", "auto", "jpg", "best", {}) + self.assertTrue(opts.get("skip_download")) + self.assertTrue(opts.get("writethumbnail")) + + def test_get_opts_captions_manual_only(self): + opts = get_opts( + "captions", "auto", "vtt", "best", {}, subtitle_language="fr", subtitle_mode="manual_only" + ) + self.assertTrue(opts.get("writesubtitles")) + self.assertFalse(opts.get("writeautomaticsub")) + self.assertEqual(opts["subtitleslangs"], ["fr"]) + + def test_get_opts_captions_auto_only(self): + opts = get_opts( + "captions", "auto", "srt", "best", {}, subtitle_language="de", subtitle_mode="auto_only" + ) + self.assertFalse(opts.get("writesubtitles")) + self.assertTrue(opts.get("writeautomaticsub")) + self.assertEqual(opts["subtitleslangs"], ["de-orig", "de"]) + + def test_get_opts_captions_prefer_auto(self): + opts = get_opts( + "captions", "auto", "srt", "best", {}, subtitle_language="es", subtitle_mode="prefer_auto" + ) + self.assertTrue(opts.get("writesubtitles")) + self.assertTrue(opts.get("writeautomaticsub")) + self.assertEqual(opts["subtitleslangs"], ["es-orig", "es"]) + + def test_get_opts_captions_prefer_manual_default_branch(self): + opts = get_opts( + "captions", "auto", "srt", "best", {}, subtitle_language="it", subtitle_mode="prefer_manual" + ) + self.assertEqual(opts["subtitleslangs"], ["it", "it-orig"]) + + def test_get_opts_captions_txt_maps_to_srt_format(self): + opts = get_opts("captions", "auto", "txt", "best", {}) + self.assertEqual(opts["subtitlesformat"], "srt/best") + keys = [p["key"] for p in opts["postprocessors"]] + self.assertIn("FFmpegSubtitlesConvertor", keys) + convertor = next(p for p in opts["postprocessors"] if p["key"] == "FFmpegSubtitlesConvertor") + self.assertEqual(convertor["format"], "srt") + + def test_get_opts_captions_srt_guarantees_convertor(self): + opts = get_opts("captions", "auto", "srt", "best", {}) + self.assertEqual(opts["subtitlesformat"], "srt/best") + keys = [p["key"] for p in opts["postprocessors"]] + self.assertIn("FFmpegSubtitlesConvertor", keys) + + def test_get_opts_captions_vtt_guarantees_convertor(self): + opts = get_opts("captions", "auto", "vtt", "best", {}) + self.assertEqual(opts["subtitlesformat"], "vtt/best") + keys = [p["key"] for p in opts["postprocessors"]] + self.assertIn("FFmpegSubtitlesConvertor", keys) + convertor = next(p for p in opts["postprocessors"] if p["key"] == "FFmpegSubtitlesConvertor") + self.assertEqual(convertor["format"], "vtt") + + def test_get_opts_captions_ttml_has_no_convertor(self): + opts = get_opts("captions", "auto", "ttml", "best", {}) + self.assertEqual(opts["subtitlesformat"], "ttml/best") + keys = [p["key"] for p in opts["postprocessors"]] + self.assertNotIn("FFmpegSubtitlesConvertor", keys) + + def test_get_opts_merges_existing_postprocessors(self): + opts = get_opts("audio", "auto", "opus", "best", {"postprocessors": [{"key": "SponsorBlock"}]}) + keys = [p["key"] for p in opts["postprocessors"]] + self.assertIn("SponsorBlock", keys) + self.assertIn("FFmpegExtractAudio", keys) + + def test_normalize_caption_mode_invalid_defaults(self): + self.assertEqual(_normalize_caption_mode(""), "prefer_manual") + self.assertEqual(_normalize_caption_mode("not_a_mode"), "prefer_manual") + + def test_normalize_subtitle_language_empty_defaults_en(self): + self.assertEqual(_normalize_subtitle_language(""), "en") + self.assertEqual(_normalize_subtitle_language(" "), "en") + + +class MergeYtdlOptionLayersTests(unittest.TestCase): + def test_presets_applied_in_order_then_overrides(self): + presets_config = { + "a": {"x": 1, "y": 1}, + "b": {"y": 2, "z": 2}, + } + merged = merge_ytdl_option_layers(["a", "b"], {"z": 3, "w": 4}, presets_config) + # b overrides a's y; explicit overrides win over presets. + self.assertEqual(merged, {"x": 1, "y": 2, "z": 3, "w": 4}) + + def test_no_base_options_included(self): + # The helper only produces the preset/override layer, never base opts. + self.assertEqual(merge_ytdl_option_layers(None, None, {}), {}) + + def test_unknown_preset_names_ignored(self): + self.assertEqual(merge_ytdl_option_layers(["missing"], {"a": 1}, {}), {"a": 1}) + + +if __name__ == "__main__": + unittest.main() diff --git a/app/tests/test_download_queue.py b/app/tests/test_download_queue.py new file mode 100644 index 0000000..4b375f6 --- /dev/null +++ b/app/tests/test_download_queue.py @@ -0,0 +1,948 @@ +"""Tests for ``DownloadQueue`` with mocked yt-dlp extraction.""" + +from __future__ import annotations + +import os +import re +import tempfile +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +import time + +from ytdl import Download, DownloadInfo, DownloadQueue + + +@pytest.fixture +def dq_env(): + with tempfile.TemporaryDirectory() as tmp: + dl = os.path.join(tmp, "downloads") + st = os.path.join(tmp, "state") + os.makedirs(dl, exist_ok=True) + os.makedirs(st, exist_ok=True) + cfg = MagicMock() + cfg.STATE_DIR = st + cfg.DOWNLOAD_DIR = dl + cfg.AUDIO_DOWNLOAD_DIR = dl + cfg.TEMP_DIR = dl + cfg.MAX_CONCURRENT_DOWNLOADS = "3" + cfg.YTDL_OPTIONS = {} + cfg.YTDL_OPTIONS_PRESETS = {} + cfg.CUSTOM_DIRS = True + cfg.CREATE_CUSTOM_DIRS = True + cfg.CLEAR_COMPLETED_AFTER = "0" + cfg.DELETE_FILE_ON_TRASHCAN = False + cfg.OUTPUT_TEMPLATE = "%(title)s.%(ext)s" + cfg.OUTPUT_TEMPLATE_CHAPTER = "%(title)s.%(ext)s" + cfg.OUTPUT_TEMPLATE_PLAYLIST = "" + cfg.OUTPUT_TEMPLATE_CHANNEL = "" + yield cfg + + +def test_cancel_add_increments_generation(dq_env): + notifier = MagicMock() + dq = DownloadQueue(dq_env, notifier) + before = dq._add_generation + dq.cancel_add() + assert dq._add_generation == before + 1 + + +def test_download_queue_has_dedicated_executor_sized_from_config(dq_env): + notifier = MagicMock() + dq = DownloadQueue(dq_env, notifier) + assert dq._download_executor is not None + assert dq._download_executor._max_workers == 2 * int(dq_env.MAX_CONCURRENT_DOWNLOADS) + 2 + dq.close() + + +def test_close_cancels_running_downloads_before_shutdown(dq_env): + notifier = MagicMock() + dq = DownloadQueue(dq_env, notifier) + + running = MagicMock() + running.started.return_value = True + running.running.return_value = True + idle = MagicMock() + idle.started.return_value = False + idle.running.return_value = False + + dq.queue.dict["u-running"] = running + dq.queue.dict["u-idle"] = idle + + dq.close() + + # The active download's subprocess group is killed; the not-started one is + # left alone. Executor is shut down afterwards. + running.cancel.assert_called_once() + idle.cancel.assert_not_called() + assert dq._download_executor._shutdown + + +def test_get_returns_tuple_of_lists(dq_env): + notifier = MagicMock() + dq = DownloadQueue(dq_env, notifier) + q, done = dq.get() + assert q == [] and done == [] + + +@pytest.mark.asyncio +async def test_add_single_video_goes_to_pending_when_auto_start_false(dq_env): + notifier = AsyncMock() + + def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None): + return { + "_type": "video", + "id": "vid1", + "title": "Test Video", + "url": url, + "webpage_url": url, + } + + dq = DownloadQueue(dq_env, notifier) + with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract): + result = await dq.add( + "https://example.com/watch?v=1", + "video", + "auto", + "any", + "best", + "", + "", + 0, + auto_start=False, + ) + assert result["status"] == "ok" + assert dq.pending.exists("https://example.com/watch?v=1") + + +@pytest.mark.asyncio +async def test_cancel_removes_from_pending(dq_env): + notifier = AsyncMock() + + def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None): + return { + "_type": "video", + "id": "vid1", + "title": "Test Video", + "url": url, + "webpage_url": url, + } + + dq = DownloadQueue(dq_env, notifier) + with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract): + await dq.add( + "https://example.com/pending", + "video", + "auto", + "any", + "best", + "", + "", + 0, + auto_start=False, + ) + url = "https://example.com/pending" + await dq.cancel([url]) + assert not dq.pending.exists(url) + notifier.canceled.assert_awaited() + + +@pytest.mark.asyncio +async def test_cancel_before_start_marks_download_canceled(dq_env): + """Regression test for the race condition where cancel() arrives after the + download has been placed in the queue and ``__start_download`` has been + scheduled via ``asyncio.create_task`` but has not yet executed. Without the + fix, the pending task would run ``download.start()`` despite the user + cancelling, because its ``download.canceled`` guard was never flipped.""" + notifier = AsyncMock() + + def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None): + return { + "_type": "video", + "id": "vid1", + "title": "Test Video", + "url": url, + "webpage_url": url, + } + + dq = DownloadQueue(dq_env, notifier) + url = "https://example.com/race" + start_mock = AsyncMock() + with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \ + patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock): + await dq.add( + url, + "video", + "auto", + "any", + "best", + "", + "", + 0, + auto_start=True, + ) + assert dq.queue.exists(url) + download = dq.queue.get(url) + assert download.canceled is False + await dq.cancel([url]) + assert not dq.queue.exists(url) + assert download.canceled is True + notifier.canceled.assert_awaited_with(url) + + +@pytest.mark.asyncio +async def test_start_pending_moves_to_queue(dq_env): + notifier = AsyncMock() + + def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None): + return { + "_type": "video", + "id": "vid1", + "title": "Test Video", + "url": url, + "webpage_url": url, + } + + dq = DownloadQueue(dq_env, notifier) + with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract): + await dq.add( + "https://example.com/startme", + "video", + "auto", + "any", + "best", + "", + "", + 0, + auto_start=False, + ) + url = "https://example.com/startme" + # Starting will spawn real download — cancel immediately before worker runs much + with patch.object(DownloadQueue, "_DownloadQueue__start_download", AsyncMock()): + await dq.start_pending([url]) + assert not dq.pending.exists(url) + + +@pytest.mark.asyncio +async def test_add_entry_queues_single_video_without_reextracting(dq_env): + notifier = AsyncMock() + dq = DownloadQueue(dq_env, notifier) + entry = { + "_type": "video", + "id": "vid1", + "title": "Test Video", + "url": "https://example.com/watch?v=1", + "webpage_url": "https://example.com/watch?v=1", + "playlist_index": "01", + "playlist_title": "Playlist", + } + + with patch.object(DownloadQueue, "_DownloadQueue__extract_info", side_effect=AssertionError("should not re-extract")): + result = await dq.add_entry( + entry, + "video", + "auto", + "any", + "best", + "", + "", + 0, + auto_start=False, + ) + + assert result["status"] == "ok" + assert dq.pending.exists("https://example.com/watch?v=1") + + +@pytest.mark.asyncio +async def test_add_entry_duplicate_while_pending_is_skipped_not_clobbered(dq_env): + notifier = AsyncMock() + dq = DownloadQueue(dq_env, notifier) + entry = { + "_type": "video", + "id": "vid1", + "title": "Original Title", + "url": "https://example.com/watch?v=1", + "webpage_url": "https://example.com/watch?v=1", + } + + with patch.object(DownloadQueue, "_DownloadQueue__extract_info", side_effect=AssertionError("should not re-extract")): + first = await dq.add_entry(entry, "video", "auto", "any", "best", "", "", 0, auto_start=False) + assert first["status"] == "ok" + assert "msg" not in first + + dupe_entry = {**entry, "title": "Different Title"} + second = await dq.add_entry(dupe_entry, "audio", "auto", "mp3", "best", "", "", 0, auto_start=False) + + assert second["status"] == "ok" + assert "Already in queue" in second["msg"] + # The original pending download's options must survive untouched. + pending_dl = dq.pending.get("https://example.com/watch?v=1") + assert pending_dl.info.download_type == "video" + assert pending_dl.info.title == "Original Title" + + +@pytest.mark.asyncio +async def test_add_entry_duplicate_while_queued_is_skipped(dq_env): + notifier = AsyncMock() + dq = DownloadQueue(dq_env, notifier) + entry = { + "_type": "video", + "id": "vid1", + "title": "Test Video", + "url": "https://example.com/watch?v=1", + "webpage_url": "https://example.com/watch?v=1", + } + + with patch.object(DownloadQueue, "_DownloadQueue__extract_info", side_effect=AssertionError("should not re-extract")), \ + patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()): + first = await dq.add_entry(entry, "video", "auto", "any", "best", "", "", 0, auto_start=True) + assert first["status"] == "ok" + assert dq.queue.exists("https://example.com/watch?v=1") + + second = await dq.add_entry(entry, "video", "auto", "any", "best", "", "", 0, auto_start=True) + + assert second["status"] == "ok" + assert "Already in queue" in second["msg"] + + +@pytest.mark.asyncio +async def test_channel_download_uses_output_template_when_channel_template_empty(dq_env): + """Channel tabs reported as playlists must honor OUTPUT_TEMPLATE when OUTPUT_TEMPLATE_CHANNEL is empty.""" + notifier = AsyncMock() + dq_env.OUTPUT_TEMPLATE = "%(channel)s [YT]/%(title)s.%(ext)s" + dq_env.OUTPUT_TEMPLATE_CHANNEL = "" + dq_env.OUTPUT_TEMPLATE_PLAYLIST = "" + + channel_id = "UCabcd123" + + def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None): + return { + "_type": "playlist", + "id": channel_id, + "channel_id": channel_id, + "channel": "Odin", + "title": "Odin - Videos", + "entries": [ + { + "id": "vid1", + "title": "Salvia Plath - Pondering", + "url": "https://example.com/watch?v=1", + "webpage_url": "https://example.com/watch?v=1", + "channel": "Odin", + "upload_date": "20130804", + }, + ], + } + + dq = DownloadQueue(dq_env, notifier) + with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract): + result = await dq.add( + "https://www.youtube.com/@odin/videos", + "video", + "auto", + "any", + "best", + "", + "", + 0, + auto_start=False, + ) + + assert result["status"] == "ok" + url = "https://example.com/watch?v=1" + assert dq.pending.exists(url) + download = dq.pending.get(url) + assert download.output_template.startswith("Odin [YT]/") + assert "Odin - Videos" not in download.output_template + + +@pytest.mark.asyncio +async def test_playlist_download_not_treated_as_channel(dq_env): + """Real playlists (id != channel_id) must not be promoted to channel downloads.""" + notifier = AsyncMock() + dq_env.OUTPUT_TEMPLATE = "%(channel)s [YT]/%(title)s.%(ext)s" + dq_env.OUTPUT_TEMPLATE_CHANNEL = "" + dq_env.OUTPUT_TEMPLATE_PLAYLIST = "%(playlist_title)s/%(title)s.%(ext)s" + + def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None): + return { + "_type": "playlist", + "id": "PLxyz789", + "channel_id": "UCabcd123", + "channel": "Odin", + "title": "My Playlist", + "entries": [ + { + "id": "vid1", + "title": "Test Video", + "url": "https://example.com/watch?v=1", + "webpage_url": "https://example.com/watch?v=1", + }, + ], + } + + dq = DownloadQueue(dq_env, notifier) + with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract): + result = await dq.add( + "https://www.youtube.com/playlist?list=PLxyz789", + "video", + "auto", + "any", + "best", + "", + "", + 0, + auto_start=False, + ) + + assert result["status"] == "ok" + url = "https://example.com/watch?v=1" + assert dq.pending.exists(url) + download = dq.pending.get(url) + assert download.output_template.startswith("My Playlist/") + + +@pytest.mark.asyncio +async def test_add_merges_global_preset_and_override_options(dq_env): + notifier = AsyncMock() + dq_env.YTDL_OPTIONS = {"writesubtitles": False, "cookiefile": "/tmp/global.txt"} + dq_env.YTDL_OPTIONS_PRESETS = { + "Preset A": {"writesubtitles": True, "proxy": "http://preset-a"}, + "Preset B": {"writesubtitles": False, "ratelimit": 1000}, + } + + def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None): + return { + "_type": "video", + "id": "vid2", + "title": "Preset Video", + "url": url, + "webpage_url": url, + } + + dq = DownloadQueue(dq_env, notifier) + with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract): + result = await dq.add( + "https://example.com/preset", + "video", + "auto", + "any", + "best", + "", + "", + 0, + auto_start=False, + ytdl_options_presets=["Preset A", "Preset B"], + ytdl_options_overrides={"proxy": "http://override", "embed_thumbnail": True}, + ) + + assert result["status"] == "ok" + queued = dq.pending.get("https://example.com/preset") + assert queued.ytdl_opts["cookiefile"] == "/tmp/global.txt" + assert queued.ytdl_opts["writesubtitles"] is False + assert queued.ytdl_opts["ratelimit"] == 1000 + assert queued.ytdl_opts["proxy"] == "http://override" + assert queued.ytdl_opts["embed_thumbnail"] is True + + +@pytest.mark.asyncio +async def test_extract_info_preset_null_download_archive_overrides_global(dq_env): + """Preset download_archive:null must apply during extract_info (global archive otherwise wins first).""" + dq_env.YTDL_OPTIONS = {"download_archive": "/tmp/archive.txt"} + dq_env.YTDL_OPTIONS_PRESETS = {"NoArchive": {"download_archive": None}} + + captured_params: list = [] + + class FakeYoutubeDL: + def __init__(self, params=None): + captured_params.append(params) + + def extract_info(self, url, download=False): + return { + "_type": "video", + "id": "vid-archive", + "title": "Archive Test", + "url": url, + "webpage_url": url, + } + + notifier = AsyncMock() + dq = DownloadQueue(dq_env, notifier) + with patch("ytdl.yt_dlp.YoutubeDL", FakeYoutubeDL): + result = await dq.add( + "https://example.com/archive-test", + "video", + "auto", + "any", + "best", + "", + "", + 0, + auto_start=False, + ytdl_options_presets=["NoArchive"], + ) + + assert result["status"] == "ok" + assert len(captured_params) == 1 + extract_params = captured_params[0] + assert extract_params.get("download_archive") is None + assert extract_params["extract_flat"] is True + assert extract_params["noplaylist"] is True + + +@pytest.mark.asyncio +async def test_extract_info_metube_extract_keys_win_over_preset(dq_env): + """MeTube's flat-extract settings must not be overridden by presets.""" + dq_env.YTDL_OPTIONS = {} + dq_env.YTDL_OPTIONS_PRESETS = { + "TryOverride": {"extract_flat": False, "noplaylist": False}, + } + + captured_params: list = [] + + class FakeYoutubeDL: + def __init__(self, params=None): + captured_params.append(params) + + def extract_info(self, url, download=False): + return { + "_type": "video", + "id": "vid-flat", + "title": "Flat Test", + "url": url, + "webpage_url": url, + } + + notifier = AsyncMock() + dq = DownloadQueue(dq_env, notifier) + with patch("ytdl.yt_dlp.YoutubeDL", FakeYoutubeDL): + result = await dq.add( + "https://example.com/flat-test", + "video", + "auto", + "any", + "best", + "", + "", + 0, + auto_start=False, + ytdl_options_presets=["TryOverride"], + ) + + assert result["status"] == "ok" + assert captured_params[0]["extract_flat"] is True + assert captured_params[0]["noplaylist"] is True + + +@pytest.mark.asyncio +async def test_add_sets_clip_bounds_on_download_info(dq_env): + notifier = AsyncMock() + + def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None): + return { + "_type": "video", + "id": "vid1", + "title": "Test Video", + "url": url, + "webpage_url": url, + } + + dq = DownloadQueue(dq_env, notifier) + with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract): + result = await dq.add( + "https://example.com/clip", + "video", + "auto", + "any", + "best", + "", + "", + 0, + auto_start=False, + clip_start=10.0, + clip_end=99.5, + ) + + assert result["status"] == "ok" + download = dq.pending.get("https://example.com/clip") + assert download.info.clip_start == 10.0 + assert download.info.clip_end == 99.5 + + +def _upcoming_entry(url: str, *, release_timestamp: float | None = None) -> dict: + return { + "_type": "video", + "id": "live1", + "title": "Upcoming Stream", + "url": url, + "webpage_url": url, + "live_status": "is_upcoming", + "release_timestamp": release_timestamp if release_timestamp is not None else time.time() + 3600, + } + + +@pytest.mark.asyncio +async def test_add_upcoming_stream_scheduled_without_starting(dq_env): + notifier = AsyncMock() + url = "https://example.com/live-upcoming" + start_mock = AsyncMock() + + dq = DownloadQueue(dq_env, notifier) + with patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock): + result = await dq.add_entry( + _upcoming_entry(url), + "video", + "auto", + "any", + "best", + "", + "", + 0, + auto_start=True, + ) + + assert result["status"] == "ok" + assert dq.queue.exists(url) + download = dq.queue.get(url) + assert download.info.status == "scheduled" + assert download.info.live_status == "is_upcoming" + assert download.info.live_release_timestamp is not None + start_mock.assert_not_called() + assert url in dq._scheduled_probe_at + # The "scheduled to start at ..." message must include a UTC offset + # (a naive datetime's %z would render as an empty string here). + assert re.search(r"[+-]\d{4}$", download.info.error) + + +@pytest.mark.asyncio +async def test_probe_scheduled_starts_when_live(dq_env): + notifier = AsyncMock() + url = "https://example.com/live-upcoming" + start_mock = AsyncMock() + + dq = DownloadQueue(dq_env, notifier) + with patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock): + await dq.add_entry( + _upcoming_entry(url), + "video", + "auto", + "any", + "best", + "", + "", + 0, + auto_start=True, + ) + + download = dq.queue.get(url) + + def fake_probe_extract(self, probe_url, ytdl_options_presets=None, ytdl_options_overrides=None): + assert probe_url == url + return { + "_type": "video", + "id": "live1", + "title": "Live Now", + "url": url, + "webpage_url": url, + "live_status": "is_live", + "formats": [{"format_id": "22"}], + } + + with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_probe_extract), \ + patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock): + await dq._probe_scheduled_download(download) + + assert url not in dq._scheduled_probe_at + assert download.info.live_status == "is_live" + assert download.info.status == "pending" + start_mock.assert_called_once_with(download) + + +@pytest.mark.asyncio +async def test_import_scheduled_re_registers_monitor(dq_env): + notifier = AsyncMock() + url = "https://example.com/live-restart" + release = time.time() + 7200 + + info = DownloadInfo( + id="live1", + title="Upcoming Stream", + url=url, + quality="best", + download_type="video", + codec="auto", + format="any", + folder="", + custom_name_prefix="", + error=None, + entry=None, + playlist_item_limit=0, + split_by_chapters=False, + chapter_template="", + live_status="is_upcoming", + live_release_timestamp=release, + ) + info.status = "scheduled" + + dq = DownloadQueue(dq_env, notifier) + start_mock = AsyncMock() + with patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock): + await dq._DownloadQueue__add_download(info, True) + + assert dq.queue.exists(url) + assert dq.queue.get(url).info.status == "scheduled" + assert url in dq._scheduled_probe_at + start_mock.assert_not_called() + + +@pytest.mark.asyncio +async def test_probe_transient_error_retries_without_failing(dq_env): + """A single probe failure must not abandon the scheduled stream.""" + import ytdl + + notifier = AsyncMock() + url = "https://example.com/live-transient" + start_mock = AsyncMock() + + dq = DownloadQueue(dq_env, notifier) + with patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock): + await dq.add_entry( + _upcoming_entry(url), + "video", "auto", "any", "best", "", "", 0, + auto_start=True, + ) + download = dq.queue.get(url) + + def boom(self, *args, **kwargs): + raise ytdl.yt_dlp.utils.YoutubeDLError("temporary network glitch") + + before = time.time() + with patch.object(DownloadQueue, "_DownloadQueue__extract_info", boom): + await dq._probe_scheduled_download(download) + + # Still scheduled, still monitored, probe rescheduled into the future. + assert download.info.status == "scheduled" + assert url in dq._scheduled_probe_at + assert dq._scheduled_probe_at[url] >= before + assert dq._scheduled_probe_failures[url] == 1 + notifier.completed.assert_not_called() + + +@pytest.mark.asyncio +async def test_probe_gives_up_after_max_failures(dq_env): + import ytdl + + notifier = AsyncMock() + url = "https://example.com/live-dead" + start_mock = AsyncMock() + + dq = DownloadQueue(dq_env, notifier) + with patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock): + await dq.add_entry( + _upcoming_entry(url), + "video", "auto", "any", "best", "", "", 0, + auto_start=True, + ) + download = dq.queue.get(url) + + def boom(self, *args, **kwargs): + raise ytdl.yt_dlp.utils.YoutubeDLError("stream was deleted") + + with patch.object(DownloadQueue, "_DownloadQueue__extract_info", boom): + for _ in range(ytdl._LIVE_PROBE_MAX_FAILURES): + await dq._probe_scheduled_download(download) + + assert url not in dq._scheduled_probe_at + assert not dq.queue.exists(url) + assert dq.done.exists(url) + assert download.info.status == "error" + notifier.completed.assert_awaited() + + +@pytest.mark.asyncio +async def test_probe_recovers_after_transient_then_starts(dq_env): + """A transient failure followed by a successful live probe should start the download.""" + import ytdl + + notifier = AsyncMock() + url = "https://example.com/live-recover" + start_mock = AsyncMock() + + dq = DownloadQueue(dq_env, notifier) + with patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock): + await dq.add_entry( + _upcoming_entry(url), + "video", "auto", "any", "best", "", "", 0, + auto_start=True, + ) + download = dq.queue.get(url) + # The scheduling placeholder error is set on add. + assert download.info.error + + def boom(self, *args, **kwargs): + raise ytdl.yt_dlp.utils.YoutubeDLError("temporary glitch") + + with patch.object(DownloadQueue, "_DownloadQueue__extract_info", boom): + await dq._probe_scheduled_download(download) + assert dq._scheduled_probe_failures[url] == 1 + + def live_now(self, *args, **kwargs): + return { + "_type": "video", "id": "live1", "title": "Live Now", + "url": url, "webpage_url": url, "live_status": "is_live", + "formats": [{"format_id": "22"}], + } + + with patch.object(DownloadQueue, "_DownloadQueue__extract_info", live_now), \ + patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock): + await dq._probe_scheduled_download(download) + + assert url not in dq._scheduled_probe_at + assert url not in dq._scheduled_probe_failures + assert download.info.status == "pending" + # Placeholder error/msg cleared now that a real download is starting. + assert download.info.error is None + assert download.info.msg is None + start_mock.assert_called_once_with(download) + + +def test_seconds_until_next_probe_none_when_empty(dq_env): + notifier = AsyncMock() + dq = DownloadQueue(dq_env, notifier) + assert dq._seconds_until_next_probe() is None + + +def test_calc_download_path_allows_subfolder(dq_env): + notifier = AsyncMock() + dq = DownloadQueue(dq_env, notifier) + path, err = dq._DownloadQueue__calc_download_path("video", "sub/dir") + assert err is None + assert os.path.realpath(path) == os.path.join(os.path.realpath(dq_env.DOWNLOAD_DIR), "sub", "dir") + + +def test_calc_download_path_rejects_sibling_prefix_escape(dq_env): + """A folder resolving to a sibling sharing a name prefix must be rejected. + + Regression test: ``startswith`` would have accepted ``../downloads-secret`` + when the base directory is ``.../downloads``. + """ + notifier = AsyncMock() + base = os.path.realpath(dq_env.DOWNLOAD_DIR) + sibling = base + "-secret" + os.makedirs(sibling, exist_ok=True) + dq = DownloadQueue(dq_env, notifier) + escape_folder = os.path.join("..", os.path.basename(sibling), "x") + path, err = dq._DownloadQueue__calc_download_path("video", escape_folder) + assert path is None + assert err is not None and err["status"] == "error" + + +def test_calc_download_path_rejects_parent_escape(dq_env): + notifier = AsyncMock() + dq = DownloadQueue(dq_env, notifier) + path, err = dq._DownloadQueue__calc_download_path("video", "../../etc") + assert path is None + assert err is not None and err["status"] == "error" + + +def test_download_info_to_public_dict_excludes_server_only_fields(): + info = DownloadInfo( + id="vid1", + title="Test Video", + url="https://example.com/watch?v=1", + quality="best", + download_type="video", + codec="auto", + format="any", + folder="", + custom_name_prefix="", + error=None, + entry={"id": "vid1", "huge": "x" * 100000}, + playlist_item_limit=0, + split_by_chapters=False, + chapter_template="", + ) + info.subtitle_files = [{"filename": "a.srt", "size": 10}] + public = info.to_public_dict() + assert "entry" not in public + assert "subtitle_files" not in public + # Client-facing fields are still present. + assert public["url"] == "https://example.com/watch?v=1" + assert public["title"] == "Test Video" + assert public["status"] == "pending" + + +def _make_download(dq_env, *, download_type="video", status="downloading", filename=None): + info = DownloadInfo( + id="id1", + title="t", + url="http://example.com/v", + quality="best", + download_type=download_type, + codec="auto", + format="any", + folder="", + custom_name_prefix="", + error=None, + entry=None, + playlist_item_limit=0, + split_by_chapters=False, + chapter_template="", + ) + info.status = status + info.filename = filename + info.size = 123 if filename else None + return Download( + dq_env.DOWNLOAD_DIR, dq_env.TEMP_DIR, "%(title)s.%(ext)s", "%(title)s.%(ext)s", "best", "any", {}, info + ) + + +@pytest.mark.asyncio +async def test_post_download_cleanup_clears_filename_on_error(dq_env): + notifier = AsyncMock() + dq = DownloadQueue(dq_env, notifier) + download = _make_download(dq_env, status="downloading", filename="../tmp/partial.mp4") + dq.queue.put(download) + + dq._post_download_cleanup(download) + + assert download.info.status == "error" + assert download.info.filename is None + assert download.info.size is None + + +@pytest.mark.asyncio +async def test_post_download_cleanup_keeps_captured_subtitles_on_error(dq_env): + notifier = AsyncMock() + dq = DownloadQueue(dq_env, notifier) + download = _make_download(dq_env, download_type="captions", status="downloading", filename="en.srt") + download.info.subtitle_files = [{"filename": "en.srt", "size": 42}] + dq.queue.put(download) + + dq._post_download_cleanup(download) + + assert download.info.status == "error" + assert download.info.filename == "en.srt" + + +@pytest.mark.asyncio +async def test_clear_skips_deletion_outside_download_directory(dq_env): + notifier = AsyncMock() + dq_env.DELETE_FILE_ON_TRASHCAN = True + dq = DownloadQueue(dq_env, notifier) + + outside_dir = tempfile.mkdtemp() + outside_file = os.path.join(outside_dir, "outside.txt") + with open(outside_file, "w") as f: + f.write("do not delete me") + + # A crafted/legacy relative filename that escapes DOWNLOAD_DIR via '..'. + escaping_filename = os.path.relpath(outside_file, dq_env.DOWNLOAD_DIR) + download = _make_download(dq_env, status="finished", filename=escaping_filename) + dq.done.put(download) + + await dq.clear([download.info.url]) + + assert os.path.exists(outside_file) + assert not dq.done.exists(download.info.url) diff --git a/app/tests/test_main_helpers.py b/app/tests/test_main_helpers.py new file mode 100644 index 0000000..282e940 --- /dev/null +++ b/app/tests/test_main_helpers.py @@ -0,0 +1,315 @@ +"""Tests for pure helpers in ``main`` (legacy API migration, logging, JSON serializer).""" + +from __future__ import annotations + +import json +import logging +import unittest + +import main + + +class MigrateLegacyRequestTests(unittest.TestCase): + def test_already_new_schema_unchanged(self): + post = {"download_type": "video", "codec": "h264", "format": "mp4", "quality": "1080"} + before = post.copy() + self.assertIs(main._migrate_legacy_request(post), post) + self.assertEqual(post, before) + + def test_legacy_audio_m4a(self): + post = {"format": "m4a", "quality": "best"} + main._migrate_legacy_request(post) + self.assertEqual(post["download_type"], "audio") + self.assertEqual(post["codec"], "auto") + self.assertEqual(post["format"], "m4a") + + def test_legacy_thumbnail(self): + post = {"format": "thumbnail", "quality": "best"} + main._migrate_legacy_request(post) + self.assertEqual(post["download_type"], "thumbnail") + self.assertEqual(post["format"], "jpg") + self.assertEqual(post["quality"], "best") + + def test_legacy_captions_with_subtitle_format(self): + post = {"format": "captions", "subtitle_format": "vtt", "quality": "best"} + main._migrate_legacy_request(post) + self.assertEqual(post["download_type"], "captions") + self.assertEqual(post["format"], "vtt") + + def test_legacy_video_best_ios(self): + post = {"format": "any", "quality": "best_ios", "video_codec": "auto"} + main._migrate_legacy_request(post) + self.assertEqual(post["download_type"], "video") + self.assertEqual(post["format"], "ios") + self.assertEqual(post["quality"], "best") + + def test_legacy_video_quality_audio_maps_to_m4a(self): + post = {"format": "mp4", "quality": "audio", "video_codec": "h264"} + main._migrate_legacy_request(post) + self.assertEqual(post["download_type"], "audio") + self.assertEqual(post["format"], "m4a") + self.assertEqual(post["quality"], "best") + + def test_legacy_video_default(self): + post = {"format": "mp4", "quality": "1080", "video_codec": "h265"} + main._migrate_legacy_request(post) + self.assertEqual(post["download_type"], "video") + self.assertEqual(post["codec"], "h265") + self.assertEqual(post["format"], "mp4") + self.assertEqual(post["quality"], "1080") + + +class ParseLogLevelTests(unittest.TestCase): + def test_valid_levels(self): + self.assertEqual(main.parseLogLevel("INFO"), logging.INFO) + self.assertEqual(main.parseLogLevel("debug"), logging.DEBUG) + + def test_invalid_returns_none(self): + self.assertIsNone(main.parseLogLevel("not_a_level")) + self.assertIsNone(main.parseLogLevel(123)) + + +class ObjectSerializerTests(unittest.TestCase): + def test_dict_like_object(self): + class Obj: + def __init__(self): + self.a = 1 + + ser = main.ObjectSerializer() + self.assertEqual(json.loads(ser.encode(Obj())), {"a": 1}) + + def test_generator_becomes_list(self): + ser = main.ObjectSerializer() + + def gen(): + yield 1 + yield 2 + + self.assertEqual(json.loads(ser.encode(gen())), [1, 2]) + + def test_string_not_split_to_chars(self): + ser = main.ObjectSerializer() + self.assertEqual(json.loads(ser.encode("hello")), "hello") + + +class FrontendSafeTests(unittest.TestCase): + def test_only_expected_keys(self): + safe = main.config.frontend_safe() + for key in main.Config._FRONTEND_KEYS: + self.assertIn(key, safe) + self.assertNotIn("YTDL_OPTIONS", safe) + self.assertNotIn("DOWNLOAD_DIR", safe) + self.assertIn("ALLOW_YTDL_OPTIONS_OVERRIDES", safe) + + +class ParseYtdlOverridesTests(unittest.TestCase): + def test_empty_override_string_returns_empty_dict(self): + self.assertEqual(main._parse_ytdl_options_overrides("", enabled=False), {}) + + def test_rejects_non_object_json(self): + with self.assertRaises(main.web.HTTPBadRequest): + main._parse_ytdl_options_overrides('["bad"]', enabled=True) + + def test_rejects_non_empty_overrides_when_disabled(self): + with self.assertRaises(main.web.HTTPBadRequest): + main._parse_ytdl_options_overrides('{"exec": "rm -rf /"}', enabled=False) + + def test_allows_any_keys_when_enabled(self): + self.assertEqual( + main._parse_ytdl_options_overrides('{"exec": "rm -rf /"}', enabled=True), + {"exec": "rm -rf /"}, + ) + + +class ParseDownloadOptionsTests(unittest.TestCase): + def test_accepts_known_preset_and_overrides(self): + previous = dict(main.config.YTDL_OPTIONS_PRESETS) + previous_allow = main.config.ALLOW_YTDL_OPTIONS_OVERRIDES + main.config.YTDL_OPTIONS_PRESETS = {"With subtitles": {"writesubtitles": True}} + main.config.ALLOW_YTDL_OPTIONS_OVERRIDES = True + try: + parsed = main.parse_download_options({ + "url": "https://example.com/v", + "download_type": "video", + "codec": "auto", + "format": "any", + "quality": "best", + "ytdl_options_preset": "With subtitles", + "ytdl_options_overrides": '{"writesubtitles": true}', + }) + finally: + main.config.YTDL_OPTIONS_PRESETS = previous + main.config.ALLOW_YTDL_OPTIONS_OVERRIDES = previous_allow + self.assertEqual(parsed["ytdl_options_presets"], ["With subtitles"]) + self.assertEqual(parsed["ytdl_options_overrides"], {"writesubtitles": True}) + + def test_accepts_multiple_presets_in_order(self): + previous = dict(main.config.YTDL_OPTIONS_PRESETS) + main.config.YTDL_OPTIONS_PRESETS = { + "A": {"writesubtitles": True}, + "B": {"writesubtitles": False}, + } + try: + parsed = main.parse_download_options({ + "url": "https://example.com/v", + "download_type": "video", + "codec": "auto", + "format": "any", + "quality": "best", + "ytdl_options_presets": ["A", "B"], + }) + finally: + main.config.YTDL_OPTIONS_PRESETS = previous + self.assertEqual(parsed["ytdl_options_presets"], ["A", "B"]) + + def test_legacy_singular_preset_string_normalized_to_list(self): + previous = dict(main.config.YTDL_OPTIONS_PRESETS) + main.config.YTDL_OPTIONS_PRESETS = {"Solo": {}} + try: + parsed = main.parse_download_options({ + "url": "https://example.com/v", + "download_type": "video", + "codec": "auto", + "format": "any", + "quality": "best", + "ytdl_options_preset": "Solo", + }) + finally: + main.config.YTDL_OPTIONS_PRESETS = previous + self.assertEqual(parsed["ytdl_options_presets"], ["Solo"]) + + def test_rejects_unknown_preset(self): + with self.assertRaises(main.web.HTTPBadRequest): + main.parse_download_options({ + "url": "https://example.com/v", + "download_type": "video", + "codec": "auto", + "format": "any", + "quality": "best", + "ytdl_options_presets": ["Missing preset"], + }) + + def test_rejects_unknown_preset_in_list(self): + previous = dict(main.config.YTDL_OPTIONS_PRESETS) + main.config.YTDL_OPTIONS_PRESETS = {"Known": {}} + try: + with self.assertRaises(main.web.HTTPBadRequest): + main.parse_download_options({ + "url": "https://example.com/v", + "download_type": "video", + "codec": "auto", + "format": "any", + "quality": "best", + "ytdl_options_presets": ["Known", "Nope"], + }) + finally: + main.config.YTDL_OPTIONS_PRESETS = previous + + def test_clip_start_end_seconds_and_clock(self): + parsed = main.parse_download_options({ + "url": "https://example.com/watch?v=1", + "download_type": "video", + "codec": "auto", + "format": "any", + "quality": "best", + "clip_start": "2:26", + "clip_end": "3:24", + }) + self.assertEqual(parsed["clip_start"], 146.0) + self.assertEqual(parsed["clip_end"], 204.0) + + def test_clip_url_t_param_strips_query_and_sets_start(self): + parsed = main.parse_download_options({ + "url": "https://www.youtube.com/watch?v=1&t=855s", + "download_type": "video", + "codec": "auto", + "format": "any", + "quality": "best", + }) + self.assertEqual(parsed["url"], "https://www.youtube.com/watch?v=1") + self.assertEqual(parsed["clip_start"], 855.0) + self.assertIsNone(parsed["clip_end"]) + + def test_clip_explicit_start_wins_over_url_t(self): + parsed = main.parse_download_options({ + "url": "https://www.youtube.com/watch?v=1&t=100", + "download_type": "video", + "codec": "auto", + "format": "any", + "quality": "best", + "clip_start": "50", + }) + self.assertEqual(parsed["url"], "https://www.youtube.com/watch?v=1") + self.assertEqual(parsed["clip_start"], 50.0) + self.assertIsNone(parsed["clip_end"]) + + def test_clip_end_only_sets_start_zero_and_strips_url_t(self): + parsed = main.parse_download_options({ + "url": "https://www.youtube.com/watch?v=1&t=999", + "download_type": "video", + "codec": "auto", + "format": "any", + "quality": "best", + "clip_end": "60", + }) + self.assertEqual(parsed["url"], "https://www.youtube.com/watch?v=1") + self.assertEqual(parsed["clip_start"], 0.0) + self.assertEqual(parsed["clip_end"], 60.0) + + def test_clip_url_t_param_ignored_on_non_youtube_host(self): + # 't' is a generic query param name; only rewrite it on YouTube hosts + # so an unrelated site's URL isn't silently mutated with a bogus clip. + parsed = main.parse_download_options({ + "url": "https://example.com/watch?v=1&t=855s", + "download_type": "video", + "codec": "auto", + "format": "any", + "quality": "best", + }) + self.assertEqual(parsed["url"], "https://example.com/watch?v=1&t=855s") + self.assertIsNone(parsed["clip_start"]) + self.assertIsNone(parsed["clip_end"]) + + def test_extract_t_query_youtu_be_short_host(self): + cleaned, start = main._extract_t_query_from_url("https://youtu.be/abc123?t=90") + self.assertEqual(cleaned, "https://youtu.be/abc123") + self.assertEqual(start, 90.0) + + def test_clip_rejects_end_before_start(self): + with self.assertRaises(main.web.HTTPBadRequest): + main.parse_download_options({ + "url": "https://example.com/watch?v=1", + "download_type": "video", + "codec": "auto", + "format": "any", + "quality": "best", + "clip_start": "100", + "clip_end": "50", + }) + + def test_clip_rejected_for_captions(self): + with self.assertRaises(main.web.HTTPBadRequest): + main.parse_download_options({ + "url": "https://example.com/watch?v=1", + "download_type": "captions", + "codec": "auto", + "format": "srt", + "quality": "best", + "clip_start": "1", + }) + + +class GetCustomDirsTests(unittest.TestCase): + def test_works_without_a_running_event_loop(self): + # get_custom_dirs() used to time its cache via + # asyncio.get_running_loop().time(), which raises RuntimeError outside + # a running loop (e.g. when called from a plain executor thread). It + # must work from a synchronous context too. + result = main.get_custom_dirs() + self.assertIn("download_dir", result) + self.assertIn("audio_download_dir", result) + self.assertIn("", result["download_dir"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/app/tests/test_nightly_update.py b/app/tests/test_nightly_update.py new file mode 100644 index 0000000..749ca8a --- /dev/null +++ b/app/tests/test_nightly_update.py @@ -0,0 +1,29 @@ +"""Tests for nightly yt-dlp update scheduling helpers.""" + +from __future__ import annotations + +import unittest +from datetime import datetime + +from main import seconds_until_next_daily_time + + +class NightlyUpdateTests(unittest.TestCase): + def test_seconds_until_later_today(self): + now = datetime(2026, 6, 4, 10, 0, 0) + delay = seconds_until_next_daily_time("15:30", now) + self.assertEqual(delay, 5 * 3600 + 30 * 60) + + def test_seconds_until_wraps_to_next_day(self): + now = datetime(2026, 6, 4, 18, 0, 0) + delay = seconds_until_next_daily_time("04:00", now) + self.assertEqual(delay, 10 * 3600) + + def test_seconds_until_same_minute_is_next_day(self): + now = datetime(2026, 6, 4, 4, 0, 30) + delay = seconds_until_next_daily_time("04:00", now) + self.assertAlmostEqual(delay, 24 * 3600 - 30, delta=1) + + +if __name__ == "__main__": + unittest.main() diff --git a/app/tests/test_persistent_queue.py b/app/tests/test_persistent_queue.py new file mode 100644 index 0000000..4bb0226 --- /dev/null +++ b/app/tests/test_persistent_queue.py @@ -0,0 +1,291 @@ +"""Integration tests for ``PersistentQueue`` using the JSON state store.""" + +from __future__ import annotations + +import json +import os +import shelve +import sys +import tempfile +import types +import unittest +from unittest.mock import patch + +fake_yt_dlp = types.ModuleType("yt_dlp") +fake_networking = types.ModuleType("yt_dlp.networking") +fake_impersonate = types.ModuleType("yt_dlp.networking.impersonate") +fake_utils = types.ModuleType("yt_dlp.utils") + + +class _ImpersonateTarget: + @staticmethod + def from_str(value): + return value + + +fake_impersonate.ImpersonateTarget = _ImpersonateTarget +fake_networking.impersonate = fake_impersonate +fake_utils.STR_FORMAT_RE_TMPL = r"(?P)%\((?P{})\)(?P[-0-9.]*{})" +fake_utils.STR_FORMAT_TYPES = "diouxXeEfFgGcrsa" +fake_yt_dlp.networking = fake_networking +fake_yt_dlp.utils = fake_utils +sys.modules.setdefault("yt_dlp", fake_yt_dlp) +sys.modules.setdefault("yt_dlp.networking", fake_networking) +sys.modules.setdefault("yt_dlp.networking.impersonate", fake_impersonate) +sys.modules.setdefault("yt_dlp.utils", fake_utils) + +from ytdl import DownloadInfo, PersistentQueue + + +class _FakeDownload: + __slots__ = ("info",) + + def __init__(self, info: DownloadInfo): + self.info = info + + +def _make_info(url: str = "https://example.com/v") -> DownloadInfo: + return DownloadInfo( + id="id1", + title="Title", + url=url, + quality="best", + download_type="video", + codec="auto", + format="any", + folder="", + custom_name_prefix="", + error=None, + entry=None, + playlist_item_limit=0, + split_by_chapters=False, + chapter_template="", + ) + + +def _create_legacy_shelf(path: str, *infos: DownloadInfo) -> None: + with shelve.open(path, "c") as shelf: + for info in infos: + shelf[info.url] = info + + +class PersistentQueueTests(unittest.TestCase): + def test_put_get_delete_roundtrip(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "queue") + pq = PersistentQueue("queue", path) + dl = _FakeDownload(_make_info("http://a.example")) + pq.put(dl) + self.assertTrue(os.path.exists(path + ".json")) + self.assertTrue(pq.exists("http://a.example")) + self.assertFalse(pq.empty()) + got = pq.get("http://a.example") + self.assertEqual(got.info.url, "http://a.example") + pq.delete("http://a.example") + self.assertFalse(pq.exists("http://a.example")) + + def test_saved_items_sorted_by_timestamp(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "queue") + pq = PersistentQueue("queue", path) + a = _FakeDownload(_make_info("http://first.example")) + b = _FakeDownload(_make_info("http://second.example")) + a.info.timestamp = 100 + b.info.timestamp = 200 + pq.put(a) + pq.put(b) + keys = [k for k, _ in pq.saved_items()] + self.assertEqual(keys, ["http://first.example", "http://second.example"]) + + def test_load_restores_from_json(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "queue") + pq1 = PersistentQueue("queue", path) + pq1.put(_FakeDownload(_make_info("http://load.example"))) + pq2 = PersistentQueue("queue", path) + pq2.load() + self.assertTrue(pq2.exists("http://load.example")) + + def test_load_imports_legacy_shelve(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "queue") + _create_legacy_shelf(path, _make_info("http://legacy.example")) + pq = PersistentQueue("queue", path) + pq.load() + self.assertTrue(pq.exists("http://legacy.example")) + self.assertTrue(os.path.exists(path + ".json")) + + def test_queue_persists_only_compact_entry_subset(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "queue") + pq = PersistentQueue("queue", path) + info = _make_info("http://entry.example") + info.entry = { + "playlist_index": "01", + "playlist_title": "Playlist", + "channel_index": "02", + "channel_title": "Channel", + "formats": [{"id": "huge"}], + "description": "very large payload", + } + pq.put(_FakeDownload(info)) + + with open(path + ".json", encoding="utf-8") as f: + payload = json.load(f) + + record = payload["items"][0]["info"] + self.assertEqual( + record["entry"], + { + "playlist_index": "01", + "playlist_title": "Playlist", + "channel_index": "02", + "channel_title": "Channel", + }, + ) + self.assertNotIn("formats", record["entry"]) + self.assertNotIn("description", record["entry"]) + + def test_completed_queue_does_not_persist_entry_or_transient_progress(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "completed") + pq = PersistentQueue("completed", path) + info = _make_info("http://done.example") + info.status = "finished" + info.percent = 88 + info.speed = 123 + info.eta = 9 + info.entry = { + "playlist_index": "01", + "playlist_title": "Playlist", + "formats": [{"id": "huge"}], + } + info.filename = "done.mp4" + pq.put(_FakeDownload(info)) + + with open(path + ".json", encoding="utf-8") as f: + payload = json.load(f) + + record = payload["items"][0]["info"] + self.assertNotIn("entry", record) + self.assertNotIn("percent", record) + self.assertNotIn("speed", record) + self.assertNotIn("eta", record) + self.assertEqual(record["filename"], "done.mp4") + + def test_invalid_json_is_quarantined_and_legacy_is_imported(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "queue") + _create_legacy_shelf(path, _make_info("http://legacy.example")) + with open(path + ".json", "w", encoding="utf-8") as f: + f.write("{not valid json") + + pq = PersistentQueue("queue", path) + pq.load() + + self.assertTrue(pq.exists("http://legacy.example")) + self.assertTrue( + any(name.startswith("queue.json.invalid.") for name in os.listdir(tmp)) + ) + + def test_loading_old_json_rewrites_to_compact_format(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "queue") + with open(path + ".json", "w", encoding="utf-8") as f: + json.dump( + { + "schema_version": 1, + "kind": "persistent_queue:queue", + "items": [ + { + "key": "http://legacy-json.example", + "info": { + "id": "id1", + "title": "Title", + "url": "http://legacy-json.example", + "quality": "best", + "download_type": "video", + "codec": "auto", + "format": "any", + "folder": "", + "custom_name_prefix": "", + "playlist_item_limit": 0, + "split_by_chapters": False, + "chapter_template": "", + "subtitle_language": "en", + "subtitle_mode": "prefer_manual", + "status": "pending", + "timestamp": 1, + "entry": { + "playlist_index": "01", + "playlist_title": "Playlist", + "formats": [{"id": "huge"}], + }, + "percent": 15, + "speed": 20, + "eta": 30, + }, + } + ], + }, + f, + ) + + pq = PersistentQueue("queue", path) + pq.load() + + with open(path + ".json", encoding="utf-8") as f: + payload = json.load(f) + + record = payload["items"][0]["info"] + self.assertEqual(payload["schema_version"], 2) + self.assertEqual(record["entry"], {"playlist_index": "01", "playlist_title": "Playlist"}) + self.assertNotIn("percent", record) + self.assertNotIn("speed", record) + self.assertNotIn("eta", record) + + def test_put_rollbacks_in_memory_queue_when_state_write_fails(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "queue") + pq = PersistentQueue("queue", path) + dl = _FakeDownload(_make_info("http://rollback.example")) + self.assertFalse(pq.exists("http://rollback.example")) + + orig_save = __import__("state_store").AtomicJsonStore.save + + def bad_save(store, data): + if store.path == path + ".json": + raise OSError("simulated shelf failure") + return orig_save(store, data) + + with patch("ytdl.AtomicJsonStore.save", bad_save): + with self.assertRaises(OSError): + pq.put(dl) + + self.assertFalse(pq.exists("http://rollback.example")) + + def test_put_rollbacks_to_previous_download_when_replace_fails(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "queue") + pq = PersistentQueue("queue", path) + first = _FakeDownload(_make_info("http://same.example")) + second = _FakeDownload(_make_info("http://same.example")) + second.info.title = "Replaced title" + pq.put(first) + + orig_save = __import__("state_store").AtomicJsonStore.save + + def bad_save(store, data): + if store.path == path + ".json": + raise OSError("simulated shelf failure") + return orig_save(store, data) + + with patch("ytdl.AtomicJsonStore.save", bad_save): + with self.assertRaises(OSError): + pq.put(second) + + self.assertEqual(pq.get("http://same.example").info.title, "Title") + + +if __name__ == "__main__": + unittest.main() diff --git a/app/tests/test_state_store.py b/app/tests/test_state_store.py new file mode 100644 index 0000000..15e0880 --- /dev/null +++ b/app/tests/test_state_store.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import os +import tempfile +import unittest +from datetime import datetime +from unittest.mock import patch + +from state_store import AtomicJsonStore, from_json_compatible, to_json_compatible + + +class StateStoreTests(unittest.TestCase): + def test_save_and_load_roundtrip(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "queue.json") + store = AtomicJsonStore(path, kind="persistent_queue:queue") + store.save({"items": [{"key": "a", "info": {"title": "hello"}}]}) + + payload = store.load() + + self.assertEqual(payload["kind"], "persistent_queue:queue") + self.assertEqual(payload["schema_version"], 2) + self.assertEqual(payload["items"][0]["info"]["title"], "hello") + + def test_save_falls_back_to_direct_write_when_mkstemp_fails(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "queue.json") + store = AtomicJsonStore(path, kind="persistent_queue:queue") + + with self.assertLogs("state_store", level="WARNING") as logs: + with patch( + "state_store.tempfile.mkstemp", + side_effect=PermissionError(1, "Operation not permitted"), + ): + store.save({"items": [{"key": "a"}]}) + + self.assertTrue(os.path.exists(path)) + self.assertTrue(any(path in message for message in logs.output)) + # Fallback keeps owner-only permissions, matching the atomic path. + self.assertEqual(os.stat(path).st_mode & 0o777, 0o600) + payload = store.load() + self.assertEqual(payload["items"], [{"key": "a"}]) + + def test_fallback_tightens_permissions_on_existing_file(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "queue.json") + with open(path, "w", encoding="utf-8") as f: + f.write("{}") + os.chmod(path, 0o644) + + store = AtomicJsonStore(path, kind="persistent_queue:queue") + with patch( + "state_store.tempfile.mkstemp", + side_effect=PermissionError(1, "Operation not permitted"), + ): + store.save({"items": [{"key": "a"}]}) + + self.assertEqual(os.stat(path).st_mode & 0o777, 0o600) + self.assertEqual(store.load()["items"], [{"key": "a"}]) + + def test_save_falls_back_to_direct_write_when_replace_fails(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "queue.json") + store = AtomicJsonStore(path, kind="persistent_queue:queue") + + with patch( + "state_store.os.replace", + side_effect=PermissionError(1, "Operation not permitted"), + ): + store.save({"items": [{"key": "a"}]}) + + self.assertTrue(os.path.exists(path)) + payload = store.load() + self.assertEqual(payload["items"], [{"key": "a"}]) + self.assertEqual([], [name for name in os.listdir(tmp) if name.endswith(".tmp")]) + + def test_save_reraises_when_atomic_and_direct_write_fail(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "queue.json") + store = AtomicJsonStore(path, kind="persistent_queue:queue") + + with patch( + "state_store.tempfile.mkstemp", + side_effect=PermissionError(1, "Operation not permitted"), + ): + with patch( + "state_store.os.open", + side_effect=PermissionError(13, "Permission denied"), + ): + with self.assertRaises(PermissionError) as ctx: + store.save({"items": [{"key": "a"}]}) + + self.assertEqual(ctx.exception.errno, 13) + self.assertFalse(os.path.exists(path)) + + def test_unsupported_fsync_keeps_atomic_path(self): + # fsync being unsupported (EINVAL/ENOSYS) must not by itself trigger the + # direct-write fallback; the atomic temp-file + rename path still runs. + import errno as _errno + + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "queue.json") + store = AtomicJsonStore(path, kind="persistent_queue:queue") + + with patch( + "state_store.os.fsync", + side_effect=OSError(_errno.EINVAL, "Invalid argument"), + ): + with self.assertNoLogs("state_store", level="WARNING"): + store.save({"items": [{"key": "a"}]}) + + self.assertEqual(store.load()["items"], [{"key": "a"}]) + self.assertEqual([], [name for name in os.listdir(tmp) if name.endswith(".tmp")]) + + def test_save_reraises_and_preserves_state_on_non_atomic_errno(self): + # A storage failure such as ENOSPC is not an "atomic unavailable" + # signal, so it must surface instead of falling back to a direct write + # that would truncate the existing good state file. + import errno as _errno + + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "queue.json") + store = AtomicJsonStore(path, kind="persistent_queue:queue") + store.save({"items": [{"key": "good"}]}) + + with patch( + "state_store.tempfile.mkstemp", + side_effect=OSError(_errno.ENOSPC, "No space left on device"), + ): + with self.assertRaises(OSError) as ctx: + store.save({"items": [{"key": "new"}]}) + + self.assertEqual(ctx.exception.errno, _errno.ENOSPC) + # Existing state is untouched. + self.assertEqual(store.load()["items"], [{"key": "good"}]) + + def test_serialization_failure_preserves_existing_state(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "queue.json") + store = AtomicJsonStore(path, kind="persistent_queue:queue") + store.save({"items": [{"key": "good"}]}) + + # Even on the fallback path, a non-serializable payload must raise + # before the existing good state file is touched. + with patch( + "state_store.tempfile.mkstemp", + side_effect=PermissionError(1, "Operation not permitted"), + ): + with self.assertRaises(TypeError): + store.save({"items": object()}) + + self.assertEqual(store.load()["items"], [{"key": "good"}]) + + def test_invalid_file_is_quarantined(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "queue.json") + with open(path, "w", encoding="utf-8") as f: + f.write("{broken") + + store = AtomicJsonStore(path, kind="persistent_queue:queue") + payload = store.load() + + self.assertIsNone(payload) + self.assertTrue( + any(name.startswith("queue.json.invalid.") for name in os.listdir(tmp)) + ) + + def test_json_compat_helpers_roundtrip_bytes_and_datetime(self): + raw = { + "payload": b"abc", + "timestamp": datetime(2024, 1, 2, 3, 4, 5), + "items": (1, 2, 3), + } + + restored = from_json_compatible(to_json_compatible(raw)) + + self.assertEqual(restored["payload"], b"abc") + self.assertEqual(restored["timestamp"], datetime(2024, 1, 2, 3, 4, 5)) + self.assertEqual(restored["items"], [1, 2, 3]) + + +if __name__ == "__main__": + unittest.main() diff --git a/app/tests/test_subscriptions.py b/app/tests/test_subscriptions.py new file mode 100644 index 0000000..0f97af1 --- /dev/null +++ b/app/tests/test_subscriptions.py @@ -0,0 +1,1246 @@ +from __future__ import annotations + +import asyncio +import json +import os +import shelve +import sys +import tempfile +import time +import types +import unittest +from unittest.mock import patch + +fake_yt_dlp = types.ModuleType("yt_dlp") +fake_networking = types.ModuleType("yt_dlp.networking") +fake_impersonate = types.ModuleType("yt_dlp.networking.impersonate") + + +class _ImpersonateTarget: + @staticmethod + def from_str(value): + return value + + +fake_impersonate.ImpersonateTarget = _ImpersonateTarget +fake_networking.impersonate = fake_impersonate +fake_yt_dlp.networking = fake_networking +fake_yt_dlp.utils = types.SimpleNamespace(YoutubeDLError=Exception) +sys.modules.setdefault("yt_dlp", fake_yt_dlp) +sys.modules.setdefault("yt_dlp.networking", fake_networking) +sys.modules.setdefault("yt_dlp.networking.impersonate", fake_impersonate) + +from subscriptions import ( + SubscriptionInfo, + SubscriptionManager, + _is_subscriber_only_entry, + coerce_optional_bool, + extract_flat_playlist, +) + + +class _Config: + def __init__(self, state_dir: str): + self.STATE_DIR = state_dir + self.SUBSCRIPTION_SCAN_PLAYLIST_END = 50 + self.SUBSCRIPTION_MAX_SEEN_IDS = 50000 + self.DOWNLOAD_DIR = state_dir + self.TEMP_DIR = state_dir + self.YTDL_OPTIONS = {} + self.YTDL_OPTIONS_PRESETS = {} + + +class _Queue: + def __init__(self): + self.entries = [] + self.fail = False + + async def add(self, *args, **kwargs): + return None + + async def add_entry(self, entry, *args, **kwargs): + if self.fail: + return {"status": "error", "msg": "queue failed"} + self.entries.append((entry, args, kwargs)) + return {"status": "ok"} + + +class _Notifier: + async def subscription_added(self, sub): + return None + + async def subscription_updated(self, sub): + return None + + async def subscription_removed(self, sub_id): + return None + + async def subscriptions_all(self, subs): + return None + + +def _create_legacy_shelf(path: str, record) -> None: + with shelve.open(path, "c") as shelf: + shelf["sub-1"] = record + + +class SubscriberOnlyHelperTests(unittest.TestCase): + def test_is_subscriber_only_detects_availability(self): + self.assertTrue(_is_subscriber_only_entry({"availability": "subscriber_only"})) + self.assertFalse(_is_subscriber_only_entry({"availability": None})) + self.assertFalse(_is_subscriber_only_entry({})) + + def test_coerce_optional_bool_defaults_and_fields(self): + self.assertFalse(coerce_optional_bool(None, default=False)) + self.assertTrue(coerce_optional_bool(True)) + self.assertFalse(coerce_optional_bool(False)) + with self.assertRaises(ValueError): + coerce_optional_bool("maybe", field_name="skip_subscriber_only") + + +class SubscriptionPersistenceTests(unittest.IsolatedAsyncioTestCase): + def test_load_imports_legacy_subscription_shelf(self): + with tempfile.TemporaryDirectory() as tmp: + legacy_path = os.path.join(tmp, "subscriptions") + json_path = os.path.join(tmp, "subscriptions.json") + _create_legacy_shelf( + legacy_path, + { + "id": "sub-1", + "name": "Channel", + "url": "https://example.com/channel", + "timestamp": 1.0, + }, + ) + + mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier()) + + self.assertEqual(len(mgr.list_all()), 1) + self.assertTrue(os.path.exists(json_path)) + with open(json_path, encoding="utf-8") as f: + payload = json.load(f) + self.assertEqual(payload["schema_version"], 2) + self.assertNotIn("timestamp", payload["items"][0]) + + def test_invalid_json_is_quarantined_and_legacy_is_imported(self): + with tempfile.TemporaryDirectory() as tmp: + legacy_path = os.path.join(tmp, "subscriptions") + json_path = os.path.join(tmp, "subscriptions.json") + _create_legacy_shelf( + legacy_path, + { + "id": "sub-1", + "name": "Channel", + "url": "https://example.com/channel", + "timestamp": 1.0, + }, + ) + with open(json_path, "w", encoding="utf-8") as f: + f.write("{not valid json") + + mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier()) + + self.assertEqual(len(mgr.list_all()), 1) + self.assertTrue( + any(name.startswith("subscriptions.json.invalid.") for name in os.listdir(tmp)) + ) + + def test_load_rewrites_old_json_and_trims_seen_ids(self): + with tempfile.TemporaryDirectory() as tmp: + json_path = os.path.join(tmp, "subscriptions.json") + cfg = _Config(tmp) + cfg.SUBSCRIPTION_MAX_SEEN_IDS = 2 + with open(json_path, "w", encoding="utf-8") as f: + json.dump( + { + "schema_version": 1, + "kind": "subscriptions", + "items": [ + { + "id": "sub-1", + "name": "Channel", + "url": "https://example.com/channel", + "enabled": True, + "check_interval_minutes": 60, + "download_type": "video", + "codec": "auto", + "format": "any", + "quality": "best", + "folder": "", + "custom_name_prefix": "", + "auto_start": True, + "playlist_item_limit": 0, + "split_by_chapters": False, + "chapter_template": "", + "subtitle_language": "en", + "subtitle_mode": "prefer_manual", + "last_checked": None, + "seen_ids": ["a", "b", "a", "c"], + "error": None, + "timestamp": 123, + } + ], + }, + f, + ) + + mgr = SubscriptionManager(cfg, _Queue(), _Notifier()) + self.assertEqual(mgr.list_all()[0].seen_ids, ["a", "b"]) + + with open(json_path, encoding="utf-8") as f: + payload = json.load(f) + + self.assertEqual(payload["schema_version"], 2) + self.assertEqual(payload["items"][0]["seen_ids"], ["a", "b"]) + self.assertNotIn("timestamp", payload["items"][0]) + + async def test_add_subscription_rolls_back_when_state_write_fails(self): + with tempfile.TemporaryDirectory() as tmp: + mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier()) + + orig_save = __import__("state_store").AtomicJsonStore.save + + def bad_save(store, data): + if store.path == mgr._path: + raise OSError("simulated shelf failure") + return orig_save(store, data) + + with patch( + "subscriptions.extract_flat_playlist", + return_value=( + {"_type": "channel", "title": "Channel"}, + [{"id": "v1", "webpage_url": "https://example.com/v1"}], + ), + ): + with patch("subscriptions.AtomicJsonStore.save", bad_save): + with self.assertRaises(OSError): + await mgr.add_subscription( + "https://example.com/channel", + check_interval_minutes=60, + download_type="video", + codec="auto", + format="any", + quality="best", + folder="", + custom_name_prefix="", + auto_start=True, + playlist_item_limit=0, + split_by_chapters=False, + chapter_template="", + subtitle_language="en", + subtitle_mode="prefer_manual", + ) + + self.assertEqual(mgr.list_all(), []) + self.assertNotIn("https://example.com/channel", mgr._url_index) + + async def test_add_subscription_marks_existing_videos_seen_without_queueing(self): + with tempfile.TemporaryDirectory() as tmp: + queue = _Queue() + mgr = SubscriptionManager(_Config(tmp), queue, _Notifier()) + + with patch( + "subscriptions.extract_flat_playlist", + return_value=( + {"_type": "channel", "title": "Channel"}, + [ + {"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}, + {"id": "v2", "title": "Two", "webpage_url": "https://example.com/v2"}, + {"id": "v3", "title": "Three", "webpage_url": "https://example.com/v3"}, + ], + ), + ): + result = await mgr.add_subscription( + "https://example.com/channel", + check_interval_minutes=60, + download_type="video", + codec="auto", + format="any", + quality="best", + folder="", + custom_name_prefix="", + auto_start=True, + playlist_item_limit=0, + split_by_chapters=False, + chapter_template="", + subtitle_language="en", + subtitle_mode="prefer_manual", + ) + + assert result["status"] == "ok" + sub = mgr.list_all()[0] + self.assertEqual(sub.seen_ids, ["v1", "v2", "v3"]) + self.assertIsNone(sub.error) + self.assertEqual(queue.entries, []) + + async def test_add_subscription_skips_collection_tab_entries(self): + with tempfile.TemporaryDirectory() as tmp: + queue = _Queue() + mgr = SubscriptionManager(_Config(tmp), queue, _Notifier()) + + with patch( + "subscriptions.extract_flat_playlist", + return_value=( + {"_type": "channel", "title": "Channel"}, + [ + { + "_type": "url", + "ie_key": "YoutubeTab", + "title": "Channel - Live", + "url": "https://example.com/live", + "webpage_url": "https://example.com/live", + }, + { + "_type": "url", + "ie_key": "Youtube", + "id": "v1", + "title": "One", + "duration": 10, + "webpage_url": "https://example.com/v1", + }, + ], + ), + ): + result = await mgr.add_subscription( + "https://example.com/channel", + check_interval_minutes=60, + download_type="video", + codec="auto", + format="any", + quality="best", + folder="", + custom_name_prefix="", + auto_start=True, + playlist_item_limit=0, + split_by_chapters=False, + chapter_template="", + subtitle_language="en", + subtitle_mode="prefer_manual", + ) + + self.assertEqual(result["status"], "ok") + sub = mgr.list_all()[0] + self.assertEqual(sub.seen_ids, ["v1"]) + self.assertEqual(queue.entries, []) + + async def test_check_now_keeps_failed_queue_items_unseen_and_sets_error(self): + with tempfile.TemporaryDirectory() as tmp: + queue = _Queue() + mgr = SubscriptionManager(_Config(tmp), queue, _Notifier()) + + with patch( + "subscriptions.extract_flat_playlist", + side_effect=[ + ( + {"_type": "channel", "title": "Channel"}, + [{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}], + ), + ( + {"_type": "channel", "title": "Channel"}, + [{"id": "v2", "title": "Two", "webpage_url": "https://example.com/v2"}], + ), + ], + ): + result = await mgr.add_subscription( + "https://example.com/channel", + check_interval_minutes=60, + download_type="video", + codec="auto", + format="any", + quality="best", + folder="", + custom_name_prefix="", + auto_start=True, + playlist_item_limit=0, + split_by_chapters=False, + chapter_template="", + subtitle_language="en", + subtitle_mode="prefer_manual", + ) + queue.fail = True + await mgr.check_now([result["subscription"]["id"]]) + + sub = mgr.list_all()[0] + self.assertEqual(sub.error, "queue failed") + self.assertEqual(sub.seen_ids, ["v1"]) + + async def test_check_now_queues_new_video_and_updates_seen_ids(self): + with tempfile.TemporaryDirectory() as tmp: + queue = _Queue() + mgr = SubscriptionManager(_Config(tmp), queue, _Notifier()) + + with patch( + "subscriptions.extract_flat_playlist", + side_effect=[ + ( + {"_type": "channel", "title": "Channel"}, + [{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}], + ), + ( + {"_type": "channel", "title": "Channel"}, + [ + {"id": "v2", "title": "Two", "webpage_url": "https://example.com/v2"}, + {"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}, + ], + ), + ], + ): + result = await mgr.add_subscription( + "https://example.com/channel", + check_interval_minutes=60, + download_type="video", + codec="auto", + format="any", + quality="best", + folder="", + custom_name_prefix="", + auto_start=True, + playlist_item_limit=0, + split_by_chapters=False, + chapter_template="", + subtitle_language="en", + subtitle_mode="prefer_manual", + ) + await mgr.check_now([result["subscription"]["id"]]) + + sub = mgr.list_all()[0] + self.assertIsNotNone(sub.last_checked) + self.assertIsNone(sub.error) + self.assertEqual(sub.seen_ids[:2], ["v2", "v1"]) + self.assertEqual([entry["webpage_url"] for entry, _, _ in queue.entries], ["https://example.com/v2"]) + + async def test_check_now_queues_subscriber_only_when_skip_disabled(self): + with tempfile.TemporaryDirectory() as tmp: + queue = _Queue() + mgr = SubscriptionManager(_Config(tmp), queue, _Notifier()) + + with patch( + "subscriptions.extract_flat_playlist", + side_effect=[ + ( + {"_type": "channel", "title": "Channel"}, + [{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}], + ), + ( + {"_type": "channel", "title": "Channel"}, + [ + { + "id": "v2", + "title": "Members", + "webpage_url": "https://example.com/v2", + "availability": "subscriber_only", + }, + {"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}, + ], + ), + ], + ): + result = await mgr.add_subscription( + "https://example.com/channel", + check_interval_minutes=60, + download_type="video", + codec="auto", + format="any", + quality="best", + folder="", + custom_name_prefix="", + auto_start=True, + playlist_item_limit=0, + split_by_chapters=False, + chapter_template="", + subtitle_language="en", + subtitle_mode="prefer_manual", + skip_subscriber_only=False, + ) + self.assertFalse(mgr.list_all()[0].skip_subscriber_only) + await mgr.check_now([result["subscription"]["id"]]) + + sub = mgr.list_all()[0] + self.assertIsNone(sub.error) + self.assertEqual(sub.seen_ids[:2], ["v2", "v1"]) + self.assertEqual([entry["webpage_url"] for entry, _, _ in queue.entries], ["https://example.com/v2"]) + + async def test_check_now_skips_subscriber_only_when_skip_enabled(self): + with tempfile.TemporaryDirectory() as tmp: + queue = _Queue() + mgr = SubscriptionManager(_Config(tmp), queue, _Notifier()) + + with patch( + "subscriptions.extract_flat_playlist", + side_effect=[ + ( + {"_type": "channel", "title": "Channel"}, + [{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}], + ), + ( + {"_type": "channel", "title": "Channel"}, + [ + { + "id": "v2", + "title": "Members", + "webpage_url": "https://example.com/v2", + "availability": "subscriber_only", + }, + {"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}, + ], + ), + ], + ): + result = await mgr.add_subscription( + "https://example.com/channel", + check_interval_minutes=60, + download_type="video", + codec="auto", + format="any", + quality="best", + folder="", + custom_name_prefix="", + auto_start=True, + playlist_item_limit=0, + split_by_chapters=False, + chapter_template="", + subtitle_language="en", + subtitle_mode="prefer_manual", + skip_subscriber_only=True, + ) + self.assertTrue(mgr.list_all()[0].skip_subscriber_only) + await mgr.check_now([result["subscription"]["id"]]) + + sub = mgr.list_all()[0] + self.assertIsNone(sub.error) + self.assertEqual(sub.seen_ids[:2], ["v2", "v1"]) + self.assertEqual(queue.entries, []) + + async def test_update_subscription_parses_string_false_enabled(self): + with tempfile.TemporaryDirectory() as tmp: + queue = _Queue() + mgr = SubscriptionManager(_Config(tmp), queue, _Notifier()) + + with patch( + "subscriptions.extract_flat_playlist", + return_value=( + {"_type": "channel", "title": "Channel"}, + [{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}], + ), + ): + result = await mgr.add_subscription( + "https://example.com/channel", + check_interval_minutes=60, + download_type="video", + codec="auto", + format="any", + quality="best", + folder="", + custom_name_prefix="", + auto_start=True, + playlist_item_limit=0, + split_by_chapters=False, + chapter_template="", + subtitle_language="en", + subtitle_mode="prefer_manual", + ) + + sub_id = result["subscription"]["id"] + update = await mgr.update_subscription(sub_id, {"enabled": "false"}) + self.assertEqual(update["status"], "ok") + self.assertFalse(mgr.list_all()[0].enabled) + + async def test_update_subscription_rejects_invalid_enabled_value(self): + with tempfile.TemporaryDirectory() as tmp: + queue = _Queue() + mgr = SubscriptionManager(_Config(tmp), queue, _Notifier()) + + with patch( + "subscriptions.extract_flat_playlist", + return_value=( + {"_type": "channel", "title": "Channel"}, + [{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}], + ), + ): + result = await mgr.add_subscription( + "https://example.com/channel", + check_interval_minutes=60, + download_type="video", + codec="auto", + format="any", + quality="best", + folder="", + custom_name_prefix="", + auto_start=True, + playlist_item_limit=0, + split_by_chapters=False, + chapter_template="", + subtitle_language="en", + subtitle_mode="prefer_manual", + ) + + sub_id = result["subscription"]["id"] + update_result = await mgr.update_subscription(sub_id, {"enabled": "maybe"}) + self.assertEqual(update_result["status"], "error") + stored = mgr.get(sub_id) + self.assertTrue(stored.enabled) + + update_result = await mgr.update_subscription( + sub_id, {"check_interval_minutes": "abc"} + ) + self.assertEqual(update_result["status"], "error") + self.assertEqual(mgr.get(sub_id).check_interval_minutes, 60) + + async def test_add_subscription_rejects_invalid_title_regex(self): + with tempfile.TemporaryDirectory() as tmp: + mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier()) + with patch( + "subscriptions.extract_flat_playlist", + return_value=( + {"_type": "channel", "title": "Channel"}, + [{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}], + ), + ): + result = await mgr.add_subscription( + "https://example.com/channel", + check_interval_minutes=60, + download_type="video", + codec="auto", + format="any", + quality="best", + folder="", + custom_name_prefix="", + auto_start=True, + playlist_item_limit=0, + split_by_chapters=False, + chapter_template="", + subtitle_language="en", + subtitle_mode="prefer_manual", + title_regex="[", + ) + self.assertEqual(result["status"], "error") + self.assertIn("title_regex", result["msg"].lower()) + self.assertEqual(mgr.list_all(), []) + + async def test_add_subscription_stores_and_exposes_title_regex(self): + with tempfile.TemporaryDirectory() as tmp: + queue = _Queue() + mgr = SubscriptionManager(_Config(tmp), queue, _Notifier()) + with patch( + "subscriptions.extract_flat_playlist", + return_value=( + {"_type": "channel", "title": "Channel"}, + [{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}], + ), + ): + result = await mgr.add_subscription( + "https://example.com/channel", + check_interval_minutes=60, + download_type="video", + codec="auto", + format="any", + quality="best", + folder="", + custom_name_prefix="", + auto_start=True, + playlist_item_limit=0, + split_by_chapters=False, + chapter_template="", + subtitle_language="en", + subtitle_mode="prefer_manual", + title_regex="EPISODE", + ) + self.assertEqual(result["status"], "ok") + self.assertEqual(result["subscription"]["title_regex"], "EPISODE") + self.assertEqual(mgr.list_all()[0].title_regex, "EPISODE") + + async def test_check_now_title_regex_queues_only_matches_and_marks_unmatched_seen(self): + with tempfile.TemporaryDirectory() as tmp: + queue = _Queue() + mgr = SubscriptionManager(_Config(tmp), queue, _Notifier()) + with patch( + "subscriptions.extract_flat_playlist", + side_effect=[ + ( + {"_type": "channel", "title": "Channel"}, + [{"id": "v1", "title": "Old", "webpage_url": "https://example.com/v1"}], + ), + ( + {"_type": "channel", "title": "Channel"}, + [ + { + "id": "v2", + "title": "Minecraft | EPISODE 1", + "webpage_url": "https://example.com/v2", + }, + { + "id": "v3", + "title": "Unrelated IRL", + "webpage_url": "https://example.com/v3", + }, + { + "id": "v1", + "title": "Old", + "webpage_url": "https://example.com/v1", + }, + ], + ), + ], + ): + result = await mgr.add_subscription( + "https://example.com/channel", + check_interval_minutes=60, + download_type="video", + codec="auto", + format="any", + quality="best", + folder="", + custom_name_prefix="", + auto_start=True, + playlist_item_limit=0, + split_by_chapters=False, + chapter_template="", + subtitle_language="en", + subtitle_mode="prefer_manual", + title_regex="EPISODE", + ) + await mgr.check_now([result["subscription"]["id"]]) + self.assertEqual([e["webpage_url"] for e, _, _ in queue.entries], ["https://example.com/v2"]) + sub = mgr.list_all()[0] + self.assertEqual(sub.seen_ids[:3], ["v2", "v3", "v1"]) + + async def test_check_now_title_regex_queue_failure_keeps_matched_id_unseen(self): + with tempfile.TemporaryDirectory() as tmp: + queue = _Queue() + mgr = SubscriptionManager(_Config(tmp), queue, _Notifier()) + with patch( + "subscriptions.extract_flat_playlist", + side_effect=[ + ( + {"_type": "channel", "title": "Channel"}, + [{"id": "v1", "title": "Old", "webpage_url": "https://example.com/v1"}], + ), + ( + {"_type": "channel", "title": "Channel"}, + [ + { + "id": "v2", + "title": "Show | EPISODE 1", + "webpage_url": "https://example.com/v2", + }, + { + "id": "v3", + "title": "Other", + "webpage_url": "https://example.com/v3", + }, + ], + ), + ], + ): + result = await mgr.add_subscription( + "https://example.com/channel", + check_interval_minutes=60, + download_type="video", + codec="auto", + format="any", + quality="best", + folder="", + custom_name_prefix="", + auto_start=True, + playlist_item_limit=0, + split_by_chapters=False, + chapter_template="", + subtitle_language="en", + subtitle_mode="prefer_manual", + title_regex="EPISODE", + ) + queue.fail = True + await mgr.check_now([result["subscription"]["id"]]) + sub = mgr.list_all()[0] + self.assertEqual(sub.error, "queue failed") + self.assertEqual(set(sub.seen_ids), {"v1", "v3"}) + self.assertNotIn("v2", sub.seen_ids) + + async def test_update_subscription_rejects_invalid_title_regex(self): + with tempfile.TemporaryDirectory() as tmp: + queue = _Queue() + mgr = SubscriptionManager(_Config(tmp), queue, _Notifier()) + with patch( + "subscriptions.extract_flat_playlist", + return_value=( + {"_type": "channel", "title": "Channel"}, + [{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}], + ), + ): + result = await mgr.add_subscription( + "https://example.com/channel", + check_interval_minutes=60, + download_type="video", + codec="auto", + format="any", + quality="best", + folder="", + custom_name_prefix="", + auto_start=True, + playlist_item_limit=0, + split_by_chapters=False, + chapter_template="", + subtitle_language="en", + subtitle_mode="prefer_manual", + ) + sub_id = result["subscription"]["id"] + upd = await mgr.update_subscription(sub_id, {"title_regex": "("}) + self.assertEqual(upd["status"], "error") + self.assertEqual(mgr.list_all()[0].title_regex, "") + + async def test_update_subscription_persists_valid_title_regex(self): + with tempfile.TemporaryDirectory() as tmp: + queue = _Queue() + mgr = SubscriptionManager(_Config(tmp), queue, _Notifier()) + with patch( + "subscriptions.extract_flat_playlist", + return_value=( + {"_type": "channel", "title": "Channel"}, + [{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}], + ), + ): + result = await mgr.add_subscription( + "https://example.com/channel", + check_interval_minutes=60, + download_type="video", + codec="auto", + format="any", + quality="best", + folder="", + custom_name_prefix="", + auto_start=True, + playlist_item_limit=0, + split_by_chapters=False, + chapter_template="", + subtitle_language="en", + subtitle_mode="prefer_manual", + ) + sub_id = result["subscription"]["id"] + upd = await mgr.update_subscription(sub_id, {"title_regex": "foo|bar"}) + self.assertEqual(upd["status"], "ok") + self.assertEqual(upd["subscription"]["title_regex"], "foo|bar") + self.assertEqual(mgr.list_all()[0].title_regex, "foo|bar") + + async def test_update_subscription_skip_subscriber_only(self): + with tempfile.TemporaryDirectory() as tmp: + queue = _Queue() + mgr = SubscriptionManager(_Config(tmp), queue, _Notifier()) + with patch( + "subscriptions.extract_flat_playlist", + return_value=( + {"_type": "channel", "title": "Channel"}, + [{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}], + ), + ): + result = await mgr.add_subscription( + "https://example.com/channel", + check_interval_minutes=60, + download_type="video", + codec="auto", + format="any", + quality="best", + folder="", + custom_name_prefix="", + auto_start=True, + playlist_item_limit=0, + split_by_chapters=False, + chapter_template="", + subtitle_language="en", + subtitle_mode="prefer_manual", + ) + sub_id = result["subscription"]["id"] + self.assertFalse(mgr.list_all()[0].skip_subscriber_only) + upd = await mgr.update_subscription(sub_id, {"skip_subscriber_only": True}) + self.assertEqual(upd["status"], "ok") + self.assertTrue(upd["subscription"]["skip_subscriber_only"]) + self.assertTrue(mgr.list_all()[0].skip_subscriber_only) + + async def test_update_subscription_rejects_invalid_skip_subscriber_only(self): + with tempfile.TemporaryDirectory() as tmp: + queue = _Queue() + mgr = SubscriptionManager(_Config(tmp), queue, _Notifier()) + with patch( + "subscriptions.extract_flat_playlist", + return_value=( + {"_type": "channel", "title": "Channel"}, + [{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}], + ), + ): + result = await mgr.add_subscription( + "https://example.com/channel", + check_interval_minutes=60, + download_type="video", + codec="auto", + format="any", + quality="best", + folder="", + custom_name_prefix="", + auto_start=True, + playlist_item_limit=0, + split_by_chapters=False, + chapter_template="", + subtitle_language="en", + subtitle_mode="prefer_manual", + ) + sub_id = result["subscription"]["id"] + upd = await mgr.update_subscription(sub_id, {"skip_subscriber_only": "maybe"}) + self.assertEqual(upd["status"], "error") + self.assertFalse(mgr.list_all()[0].skip_subscriber_only) + + def test_persistence_includes_title_regex(self): + with tempfile.TemporaryDirectory() as tmp: + json_path = os.path.join(tmp, "subscriptions.json") + with open(json_path, "w", encoding="utf-8") as f: + json.dump( + { + "schema_version": 2, + "kind": "subscriptions", + "items": [ + { + "id": "sub-1", + "name": "Channel", + "url": "https://example.com/channel", + "enabled": True, + "check_interval_minutes": 60, + "download_type": "video", + "codec": "auto", + "format": "any", + "quality": "best", + "folder": "", + "custom_name_prefix": "", + "auto_start": True, + "playlist_item_limit": 0, + "split_by_chapters": False, + "chapter_template": "", + "subtitle_language": "en", + "subtitle_mode": "prefer_manual", + "ytdl_options_presets": [], + "ytdl_options_overrides": {}, + "title_regex": "EPISODE", + "last_checked": None, + "seen_ids": [], + "error": None, + } + ], + }, + f, + ) + mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier()) + self.assertEqual(mgr.list_all()[0].title_regex, "EPISODE") + self.assertFalse(mgr.list_all()[0].skip_subscriber_only) + + def test_persistence_includes_skip_subscriber_only(self): + with tempfile.TemporaryDirectory() as tmp: + json_path = os.path.join(tmp, "subscriptions.json") + with open(json_path, "w", encoding="utf-8") as f: + json.dump( + { + "schema_version": 2, + "kind": "subscriptions", + "items": [ + { + "id": "sub-1", + "name": "Channel", + "url": "https://example.com/channel", + "enabled": True, + "check_interval_minutes": 60, + "download_type": "video", + "codec": "auto", + "format": "any", + "quality": "best", + "folder": "", + "custom_name_prefix": "", + "auto_start": True, + "playlist_item_limit": 0, + "split_by_chapters": False, + "chapter_template": "", + "subtitle_language": "en", + "subtitle_mode": "prefer_manual", + "ytdl_options_presets": [], + "ytdl_options_overrides": {}, + "title_regex": "", + "skip_subscriber_only": True, + "last_checked": None, + "seen_ids": [], + "error": None, + } + ], + }, + f, + ) + mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier()) + self.assertTrue(mgr.list_all()[0].skip_subscriber_only) + + +class ExtractFlatPlaylistTests(unittest.TestCase): + def test_descends_one_level_when_root_entries_are_nested_collections(self): + responses = iter( + [ + { + "_type": "channel", + "entries": [ + { + "_type": "url", + "ie_key": "YoutubeTab", + "title": "Channel - Videos", + "url": "https://example.com/videos", + "webpage_url": "https://example.com/videos", + } + ], + }, + { + "_type": "playlist", + "entries": [ + { + "_type": "url", + "ie_key": "Youtube", + "id": "v1", + "title": "One", + "duration": 10, + "webpage_url": "https://example.com/v1", + } + ], + }, + ] + ) + + class _FakeYDL: + def __init__(self, params): + self.params = params + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def extract_info(self, url, download=False): + return next(responses) + + cfg = _Config(tempfile.mkdtemp()) + with patch("subscriptions.yt_dlp.YoutubeDL", _FakeYDL, create=True): + info, entries = extract_flat_playlist(cfg, "https://example.com/channel", 50) + + self.assertEqual(info.get("_type"), "playlist") + self.assertEqual([entry["webpage_url"] for entry in entries], ["https://example.com/v1"]) + + def test_extra_opts_applied_on_top_of_config_options(self): + captured: dict = {} + + class _FakeYDL: + def __init__(self, params): + captured.update(params) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def extract_info(self, url, download=False): + return {"_type": "video"} + + cfg = _Config(tempfile.mkdtemp()) + with patch("subscriptions.yt_dlp.YoutubeDL", _FakeYDL, create=True): + extract_flat_playlist(cfg, "https://example.com/v1", 50, extra_opts={"cookiefile": "x"}) + + self.assertEqual(captured.get("cookiefile"), "x") + + +def _make_scan_capturing_fake_ydl(captured_params: list, entries: list[dict]): + class _FakeYDL: + def __init__(self, params): + captured_params.append(params) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def extract_info(self, url, download=False): + return {"_type": "channel", "title": "Channel", "entries": entries} + + return _FakeYDL + + +class SubscriptionScanExtraOptsTests(unittest.IsolatedAsyncioTestCase): + async def test_add_subscription_scan_applies_presets_and_overrides(self): + captured_params: list = [] + fake_ydl = _make_scan_capturing_fake_ydl( + captured_params, + [{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}], + ) + + with tempfile.TemporaryDirectory() as tmp: + cfg = _Config(tmp) + cfg.YTDL_OPTIONS_PRESETS = {"mypreset": {"cookiefile": "preset.txt"}} + mgr = SubscriptionManager(cfg, _Queue(), _Notifier()) + + with patch("subscriptions.yt_dlp.YoutubeDL", fake_ydl, create=True): + await mgr.add_subscription( + "https://example.com/channel", + check_interval_minutes=60, + download_type="video", + codec="auto", + format="any", + quality="best", + folder="", + custom_name_prefix="", + auto_start=True, + playlist_item_limit=0, + split_by_chapters=False, + chapter_template="", + subtitle_language="en", + subtitle_mode="prefer_manual", + ytdl_options_presets=["mypreset"], + ytdl_options_overrides={"extra": "override"}, + ) + + self.assertTrue(captured_params) + self.assertEqual(captured_params[0].get("cookiefile"), "preset.txt") + self.assertEqual(captured_params[0].get("extra"), "override") + + async def test_check_now_scan_applies_stored_subscription_presets(self): + entries = [{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}] + + with tempfile.TemporaryDirectory() as tmp: + cfg = _Config(tmp) + cfg.YTDL_OPTIONS_PRESETS = {"mypreset": {"cookiefile": "preset.txt"}} + mgr = SubscriptionManager(cfg, _Queue(), _Notifier()) + + add_captured: list = [] + with patch( + "subscriptions.yt_dlp.YoutubeDL", + _make_scan_capturing_fake_ydl(add_captured, entries), + create=True, + ): + result = await mgr.add_subscription( + "https://example.com/channel", + check_interval_minutes=60, + download_type="video", + codec="auto", + format="any", + quality="best", + folder="", + custom_name_prefix="", + auto_start=True, + playlist_item_limit=0, + split_by_chapters=False, + chapter_template="", + subtitle_language="en", + subtitle_mode="prefer_manual", + ytdl_options_presets=["mypreset"], + ) + sub_id = result["subscription"]["id"] + + check_captured: list = [] + with patch( + "subscriptions.yt_dlp.YoutubeDL", + _make_scan_capturing_fake_ydl(check_captured, entries), + create=True, + ): + await mgr.check_now([sub_id]) + + self.assertTrue(check_captured) + self.assertEqual(check_captured[0].get("cookiefile"), "preset.txt") + + +class SubscriptionEventLoopTests(unittest.IsolatedAsyncioTestCase): + async def test_check_now_does_not_block_event_loop(self): + with tempfile.TemporaryDirectory() as tmp: + queue = _Queue() + mgr = SubscriptionManager(_Config(tmp), queue, _Notifier()) + + with patch( + "subscriptions.extract_flat_playlist", + return_value=( + {"_type": "channel", "title": "Channel"}, + [{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}], + ), + ): + result = await mgr.add_subscription( + "https://example.com/channel", + check_interval_minutes=60, + download_type="video", + codec="auto", + format="any", + quality="best", + folder="", + custom_name_prefix="", + auto_start=True, + playlist_item_limit=0, + split_by_chapters=False, + chapter_template="", + subtitle_language="en", + subtitle_mode="prefer_manual", + ) + sub_id = result["subscription"]["id"] + + def _slow_extract(config, url, playlistend, **kwargs): + time.sleep(0.3) + return ( + {"_type": "channel", "title": "Channel"}, + [{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}], + ) + + with patch("subscriptions.extract_flat_playlist", side_effect=_slow_extract): + check_task = asyncio.ensure_future(mgr.check_now([sub_id])) + # If check_now() blocked the event loop, this would not complete + # until after the slow extraction finishes. + await asyncio.wait_for(asyncio.sleep(0.05), timeout=0.2) + self.assertFalse(check_task.done()) + await check_task + + async def test_check_many_isolates_a_crashing_subscription(self): + with tempfile.TemporaryDirectory() as tmp: + mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier()) + + good = SubscriptionInfo(id="good", name="Good", url="https://example.com/good") + bad = SubscriptionInfo(id="bad", name="Bad", url="https://example.com/bad") + other = SubscriptionInfo(id="other", name="Other", url="https://example.com/other") + + checked: list[str] = [] + + async def fake_check(sub): + if sub.id == "bad": + raise RuntimeError("boom") + checked.append(sub.id) + + with patch.object(mgr, "_check_one_unlocked", side_effect=fake_check): + # The crashing subscription must not prevent the others running. + await mgr._check_many([good, bad, other]) + + self.assertIn("good", checked) + self.assertIn("other", checked) + + async def test_check_many_bounded_concurrency(self): + with tempfile.TemporaryDirectory() as tmp: + mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier()) + subs = [ + SubscriptionInfo(id=str(i), name=str(i), url=f"https://example.com/{i}") + for i in range(10) + ] + + import subscriptions as subs_mod + + concurrent = 0 + peak = 0 + + async def fake_check(sub): + nonlocal concurrent, peak + concurrent += 1 + peak = max(peak, concurrent) + await asyncio.sleep(0.02) + concurrent -= 1 + + with patch.object(mgr, "_check_one_unlocked", side_effect=fake_check): + await mgr._check_many(subs) + + # Never exceed the configured bound, but do run more than one at once. + self.assertLessEqual(peak, subs_mod._MAX_CONCURRENT_CHECKS) + self.assertGreater(peak, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/app/tests/test_url_guard.py b/app/tests/test_url_guard.py new file mode 100644 index 0000000..1808f3e --- /dev/null +++ b/app/tests/test_url_guard.py @@ -0,0 +1,102 @@ +"""Tests for the SSRF URL guard (``url_guard.validate_url``).""" + +from __future__ import annotations + +import socket +import unittest +from unittest import mock + +from url_guard import validate_url + + +def _addrinfo(*addrs, family=socket.AF_INET): + return [(family, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", (addr, 0)) for addr in addrs] + + +class NonUrlInputTests(unittest.TestCase): + """Bare IDs and yt-dlp search/extractor prefixes must pass untouched.""" + + def test_bare_video_id_allowed(self): + self.assertIsNone(validate_url("dQw4w9WgXcQ")) + + def test_ytsearch_prefix_allowed(self): + self.assertIsNone(validate_url("ytsearch:some song")) + + def test_empty_string_allowed(self): + self.assertIsNone(validate_url("")) + + def test_non_string_rejected(self): + self.assertIsNotNone(validate_url(None)) + + +class SchemeTests(unittest.TestCase): + def test_file_scheme_blocked(self): + self.assertIsNotNone(validate_url("file:///etc/passwd")) + + def test_ftp_scheme_blocked(self): + self.assertIsNotNone(validate_url("ftp://example.com/x")) + + def test_data_scheme_blocked(self): + self.assertIsNotNone(validate_url("data://text/plain;base64,AAAA")) + + +class HostnameBlocklistTests(unittest.TestCase): + def test_localhost_blocked_without_lookup(self): + with mock.patch("url_guard.socket.getaddrinfo") as gai: + self.assertIsNotNone(validate_url("http://localhost:8080/x")) + gai.assert_not_called() + + def test_localhost_subdomain_blocked(self): + self.assertIsNotNone(validate_url("http://foo.localhost/x")) + + def test_gcp_metadata_name_blocked(self): + self.assertIsNotNone(validate_url("http://metadata.google.internal/x")) + + +class AddressResolutionTests(unittest.TestCase): + def _validate_with_addrs(self, url, *addrs, family=socket.AF_INET): + with mock.patch("url_guard.socket.getaddrinfo", return_value=_addrinfo(*addrs, family=family)): + return validate_url(url) + + def test_public_https_allowed(self): + self.assertIsNone(self._validate_with_addrs("https://youtube.com/watch?v=x", "142.250.1.1")) + + def test_public_http_allowed(self): + self.assertIsNone(self._validate_with_addrs("http://example.com/x", "93.184.216.34")) + + def test_link_local_metadata_blocked(self): + self.assertIsNotNone(self._validate_with_addrs("http://metadata/x", "169.254.169.254")) + + def test_loopback_ipv4_blocked(self): + self.assertIsNotNone(self._validate_with_addrs("http://127.0.0.1/x", "127.0.0.1")) + + def test_private_rfc1918_blocked(self): + self.assertIsNotNone(self._validate_with_addrs("http://intranet/x", "10.0.0.5")) + + def test_decimal_ip_form_blocked(self): + # 2852039166 == 169.254.169.254; the OS resolver normalizes it. + self.assertIsNotNone(self._validate_with_addrs("http://2852039166/x", "169.254.169.254")) + + def test_ipv6_loopback_blocked(self): + self.assertIsNotNone( + self._validate_with_addrs("http://[::1]/x", "::1", family=socket.AF_INET6) + ) + + def test_ipv4_mapped_ipv6_metadata_blocked(self): + self.assertIsNotNone( + self._validate_with_addrs( + "http://evil/x", "::ffff:169.254.169.254", family=socket.AF_INET6 + ) + ) + + def test_mixed_public_and_private_blocked(self): + # If any resolved address is internal, reject the whole URL. + self.assertIsNotNone(self._validate_with_addrs("http://mixed/x", "142.250.1.1", "127.0.0.1")) + + def test_resolution_failure_defers_to_ytdlp(self): + with mock.patch("url_guard.socket.getaddrinfo", side_effect=socket.gaierror): + self.assertIsNone(validate_url("http://does-not-resolve.example/x")) + + +if __name__ == "__main__": + unittest.main() diff --git a/app/tests/test_ytdl_utils.py b/app/tests/test_ytdl_utils.py new file mode 100644 index 0000000..72c716e --- /dev/null +++ b/app/tests/test_ytdl_utils.py @@ -0,0 +1,674 @@ +"""Tests for pure helpers and migration logic in ``ytdl``.""" + +from __future__ import annotations + +import pickle +import signal +import sys +import tempfile +import threading +import types +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +fake_yt_dlp = types.ModuleType("yt_dlp") +fake_networking = types.ModuleType("yt_dlp.networking") +fake_impersonate = types.ModuleType("yt_dlp.networking.impersonate") +fake_postprocessor = types.ModuleType("yt_dlp.postprocessor") +fake_postprocessor_common = types.ModuleType("yt_dlp.postprocessor.common") +fake_utils = types.ModuleType("yt_dlp.utils") + + +class _ImpersonateTarget: + @staticmethod + def from_str(value): + return value + + +class _PostProcessor: + def __init__(self, downloader=None): + self._downloader = downloader + + +fake_impersonate.ImpersonateTarget = _ImpersonateTarget +fake_networking.impersonate = fake_impersonate +fake_postprocessor_common.PostProcessor = _PostProcessor +# The inner ``key`` group mirrors the real ``STR_FORMAT_RE_TMPL`` so that +# ``_OUTTMPL_FIELD_RE`` (compiled at import time) has the named group that +# ``_resolve_outtmpl_fields`` reads via ``match.group('key')``. +fake_utils.STR_FORMAT_RE_TMPL = r"(?P)%\((?P(?P{}))\)(?P[-0-9.]*{})" +fake_utils.STR_FORMAT_TYPES = "diouxXeEfFgGcrsa" +fake_yt_dlp.networking = fake_networking +fake_yt_dlp.postprocessor = fake_postprocessor +fake_yt_dlp.utils = fake_utils +sys.modules.setdefault("yt_dlp", fake_yt_dlp) +sys.modules.setdefault("yt_dlp.networking", fake_networking) +sys.modules.setdefault("yt_dlp.networking.impersonate", fake_impersonate) +sys.modules.setdefault("yt_dlp.postprocessor", fake_postprocessor) +sys.modules.setdefault("yt_dlp.postprocessor.common", fake_postprocessor_common) +sys.modules.setdefault("yt_dlp.utils", fake_utils) + +from ytdl import ( + Download, + DownloadInfo, + _compact_persisted_entry, + _convert_srt_to_txt_file, + _AlbumArtistPostProcessor, + _output_dir_escapes, + _resolve_outtmpl_fields, + _sanitize_entry_for_pickle, + _sanitize_path_component, +) + +# Detect whether the real yt-dlp is loaded (as opposed to the minimal fake +# shim above). _resolve_outtmpl_fields needs YoutubeDL at runtime. +_has_real_ytdlp = hasattr(sys.modules.get("yt_dlp"), "YoutubeDL") + + +class AlbumArtistPostProcessorTests(unittest.TestCase): + def setUp(self): + self.postprocessor = _AlbumArtistPostProcessor() + + def test_fills_album_artist_from_artist(self): + info = {'album': 'CrasH Talk', 'artist': 'ScHoolboy Q'} + + _, result = self.postprocessor.run(info) + + self.assertEqual(result['album_artist'], 'ScHoolboy Q') + + def test_uses_main_artist_for_featured_track(self): + info = { + 'album': 'CrasH Talk', + 'artists': ['ScHoolboy Q · Travis Scott'], + } + + _, result = self.postprocessor.run(info) + + self.assertEqual(result['album_artist'], 'ScHoolboy Q') + + def test_uses_topic_channel_artist_for_joint_album(self): + info = { + 'album': 'Watch the Throne', + 'artists': ['JAY-Z', 'Kanye West'], + 'channel': 'JAY-Z & Kanye West - Topic', + } + + _, result = self.postprocessor.run(info) + + self.assertEqual(result['album_artist'], 'JAY-Z & Kanye West') + + def test_uses_topic_uploader_and_strips_suffix_for_compilation(self): + info = { + 'album': 'Compilation', + 'artist': 'Track Artist', + 'channel': 'Regular Channel', + 'uploader': 'Various Artists - Topic', + } + + _, result = self.postprocessor.run(info) + + self.assertEqual(result['album_artist'], 'Various Artists') + + def test_regular_channel_falls_back_to_main_artist(self): + info = { + 'album': 'Album', + 'artist': 'Track Artist', + 'channel': 'Label Channel', + } + + _, result = self.postprocessor.run(info) + + self.assertEqual(result['album_artist'], 'Track Artist') + + def test_preserves_explicit_various_artists(self): + info = { + 'album': 'Revenge of the Dreamers III', + 'artist': 'J. Cole', + 'album_artist': 'Various Artists', + } + + _, result = self.postprocessor.run(info) + + self.assertEqual(result['album_artist'], 'Various Artists') + + def test_preserves_existing_album_artists_list(self): + info = { + 'album': 'Album', + 'artist': 'Track Artist', + 'album_artists': ['Album Artist'], + } + + _, result = self.postprocessor.run(info) + + self.assertEqual(result['album_artists'], ['Album Artist']) + self.assertNotIn('album_artist', result) + + def test_uses_first_artist_when_artist_list_has_multiple_entries(self): + info = {'album': 'Album', 'artists': ['Main Artist', 'Featured Artist']} + + _, result = self.postprocessor.run(info) + + self.assertEqual(result['album_artist'], 'Main Artist') + + def test_does_not_fill_without_album(self): + info = {'artist': 'Standalone Artist'} + + _, result = self.postprocessor.run(info) + + self.assertNotIn('album_artist', result) + self.assertNotIn('album_artists', result) + + def test_does_not_fill_without_artist(self): + info = {'album': 'Instrumental Album'} + + _, result = self.postprocessor.run(info) + + self.assertNotIn('album_artist', result) + self.assertNotIn('album_artists', result) + + +class AlbumArtistRegistrationTests(unittest.TestCase): + def test_audio_download_registers_pre_process_postprocessor(self): + download = _make_test_download() + download.info.download_type = 'audio' + fake_ydl = MagicMock() + + with patch('ytdl.yt_dlp.YoutubeDL', return_value=fake_ydl): + result = download._make_youtube_dl({'quiet': True}) + + self.assertIs(result, fake_ydl) + postprocessor, = fake_ydl.add_post_processor.call_args.args + self.assertIsInstance(postprocessor, _AlbumArtistPostProcessor) + self.assertEqual(fake_ydl.add_post_processor.call_args.kwargs, {'when': 'pre_process'}) + + def test_video_download_does_not_register_postprocessor(self): + download = _make_test_download() + fake_ydl = MagicMock() + + with patch('ytdl.yt_dlp.YoutubeDL', return_value=fake_ydl): + download._make_youtube_dl({'quiet': True}) + + fake_ydl.add_post_processor.assert_not_called() + + +class SanitizePathComponentTests(unittest.TestCase): + def test_replaces_windows_invalid_chars(self): + self.assertEqual(_sanitize_path_component('a:b*c?d"eg|h'), "a_b_c_d_e_f_g_h") + + def test_non_string_passthrough(self): + self.assertIs(_sanitize_path_component(None), None) + self.assertEqual(_sanitize_path_component(42), 42) + + def test_strips_path_separators_and_traversal(self): + result = _sanitize_path_component('../../../../etc/x') + self.assertNotIn('..', result) + self.assertNotIn('/', result) + self.assertNotIn('\\', result) + + def test_strips_leading_absolute_path_separator(self): + result = _sanitize_path_component('/tmp/x') + self.assertFalse(result.startswith('/')) + self.assertFalse(result.startswith('\\')) + self.assertEqual(result, '_tmp_x') + + def test_collapses_slashes_in_legitimate_titles(self): + self.assertEqual(_sanitize_path_component('AC/DC'), 'AC_DC') + + def test_empty_after_strip_becomes_underscore(self): + self.assertEqual(_sanitize_path_component(' '), '_') + + +@unittest.skipUnless(_has_real_ytdlp, "requires real yt-dlp") +class ResolveOuttmplFieldsTests(unittest.TestCase): + """Tests for _resolve_outtmpl_fields (delegates to yt-dlp's template engine).""" + + def test_simple_playlist_substitution(self): + info = {"playlist_title": "My PL", "playlist_index": "03"} + result = _resolve_outtmpl_fields("%(playlist_title)s/%(title)s.%(ext)s", info, ("playlist",)) + self.assertEqual(result, "My PL/%(title)s.%(ext)s") + + def test_format_spec_int(self): + info = {"playlist_index": "3"} + result = _resolve_outtmpl_fields("%(playlist_index)02d-%(title)s", info, ("playlist",)) + self.assertEqual(result, "03-%(title)s") + + def test_non_targeted_fields_unchanged(self): + info = {"playlist_title": "PL"} + result = _resolve_outtmpl_fields("%(title)s/%(ext)s", info, ("playlist",)) + self.assertEqual(result, "%(title)s/%(ext)s") + + def test_default_value(self): + info = {"playlist_index": "1"} + result = _resolve_outtmpl_fields("%(playlist_title|Unknown)s/%(playlist_index)s", info, ("playlist",)) + self.assertEqual(result, "Unknown/1") + + def test_channel_prefix(self): + info = {"channel": "MyChan", "channel_index": "05"} + result = _resolve_outtmpl_fields("%(channel)s/%(channel_index)02d-%(title)s", info, ("channel",)) + self.assertEqual(result, "MyChan/05-%(title)s") + + def test_math_operation(self): + info = {"playlist_index": "3"} + result = _resolve_outtmpl_fields("%(playlist_index+100)d", info, ("playlist",)) + self.assertEqual(result, "103") + + def test_playlist_count_and_autonumber(self): + info = { + "playlist_title": "My PL", + "playlist_index": "03", + "playlist_count": 10, + "playlist_autonumber": 3, + "n_entries": 10, + "__last_playlist_index": 10, + } + result = _resolve_outtmpl_fields( + "%(playlist_title)s/%(playlist_autonumber)s of %(playlist_count)s - %(title)s.%(ext)s", + info, + ("playlist",), + ) + # playlist_autonumber is auto-padded by yt-dlp using __last_playlist_index + self.assertEqual(result, "My PL/03 of 10 - %(title)s.%(ext)s") + + def test_conditional_playlist_index(self): + info = { + "playlist_index": "5", + "playlist_count": 10, + } + result = _resolve_outtmpl_fields( + "%(playlist_index&{} - |)s%(title)s.%(ext)s", + info, + ("playlist",), + ) + self.assertEqual(result, "5 - %(title)s.%(ext)s") + + def test_malicious_playlist_title_cannot_escape_via_template(self): + malicious_title = '/tmp/METUBE_ARBITRARY_WRITE_POC' + entry = { + 'playlist_title': malicious_title, + 'playlist_index': '1', + 'title': 'video', + 'ext': 'mp4', + } + sanitized = {k: _sanitize_path_component(v) for k, v in entry.items()} + template = '%(playlist_title)s/%(title)s.%(ext)s' + result = _resolve_outtmpl_fields(template, sanitized, ('playlist',)) + marker = result.find('%(') + literal_prefix = result[:marker] if marker != -1 else result + self.assertNotIn('..', literal_prefix) + self.assertFalse(literal_prefix.startswith('/')) + self.assertFalse(literal_prefix.startswith('\\')) + + +class OutputDirEscapesTests(unittest.TestCase): + def setUp(self): + self.base_dir = tempfile.mkdtemp() + + def test_relative_traversal_escapes(self): + self.assertTrue(_output_dir_escapes(self.base_dir, '../../tmp/x/%(title)s.%(ext)s')) + + def test_absolute_path_escapes(self): + self.assertTrue(_output_dir_escapes(self.base_dir, '/tmp/x/%(title)s.%(ext)s')) + + def test_normal_playlist_dir_stays_inside(self): + self.assertFalse(_output_dir_escapes(self.base_dir, 'Playlist/%(title)s.%(ext)s')) + + +class SanitizeEntryForPickleTests(unittest.TestCase): + def test_nested(self): + def g(): + yield 1 + + obj = {"a": g(), "b": [g()]} + out = _sanitize_entry_for_pickle(obj) + self.assertEqual(out, {"a": [1], "b": [[1]]}) + pickle.dumps(out) + + def test_plain(self): + self.assertEqual(_sanitize_entry_for_pickle(5), 5) + + def test_set_converted_to_list(self): + obj = {"s": {1, 2}} + out = _sanitize_entry_for_pickle(obj) + self.assertEqual(sorted(out["s"]), [1, 2]) + pickle.dumps(out) + + def test_map_iterator(self): + out = _sanitize_entry_for_pickle({"m": map(int, ["1", "2"])}) + self.assertEqual(out, {"m": [1, 2]}) + + def test_lock_replaced_with_none(self): + lock = threading.Lock() + out = _sanitize_entry_for_pickle({"k": lock}) + self.assertIsNone(out["k"]) + pickle.dumps(out) + + def test_ordered_dict(self): + from collections import OrderedDict + + od = OrderedDict([("z", 1), ("a", 2)]) + out = _sanitize_entry_for_pickle(od) + self.assertEqual(out, {"z": 1, "a": 2}) + + +def _make_test_download() -> Download: + info = DownloadInfo( + id="id1", + title="t", + url="http://example.com/v", + quality="best", + download_type="video", + codec="auto", + format="any", + folder="", + custom_name_prefix="", + error=None, + entry=None, + playlist_item_limit=0, + split_by_chapters=False, + chapter_template="", + ) + return Download("/tmp", "/tmp", "%(title)s.%(ext)s", "%(title)s.%(ext)s", "best", "any", {}, info) + + +class ProgressThrottleTests(unittest.TestCase): + def test_downloading_ticks_are_throttled(self): + dl = _make_test_download() + forwarded = [] + dl.status_queue = types.SimpleNamespace(put=forwarded.append) + hook = dl._make_progress_hook() + + with patch("ytdl.time.monotonic", side_effect=[100.0, 100.1, 100.6, 100.7]): + hook({"status": "downloading", "downloaded_bytes": 1}) + hook({"status": "downloading", "downloaded_bytes": 2}) + hook({"status": "downloading", "downloaded_bytes": 3}) + hook({"status": "downloading", "downloaded_bytes": 4}) + + # Only the 1st and 3rd ticks are >= 0.5s apart from the last forwarded one. + self.assertEqual(len(forwarded), 2) + + def test_finished_and_error_statuses_always_forwarded(self): + dl = _make_test_download() + forwarded = [] + dl.status_queue = types.SimpleNamespace(put=forwarded.append) + hook = dl._make_progress_hook() + + with patch("ytdl.time.monotonic", side_effect=[200.0, 200.1]): + hook({"status": "downloading"}) + hook({"status": "finished"}) + hook({"status": "downloading"}) + hook({"status": "error", "msg": "boom"}) + + statuses = [item.get("status") for item in forwarded] + self.assertIn("finished", statuses) + self.assertIn("error", statuses) + + +class CancelProcessGroupTests(unittest.TestCase): + def test_cancel_kills_group_when_child_is_group_leader(self): + # Child successfully ran os.setpgrp(): its pgid equals its own pid. + dl = _make_test_download() + dl.proc = types.SimpleNamespace(pid=4321) + dl.status_queue = types.SimpleNamespace(put=lambda _item: None) + + with patch.object(Download, "running", return_value=True), \ + patch("ytdl.os.getpgid", return_value=4321) as mock_getpgid, \ + patch("ytdl.os.killpg") as mock_killpg: + dl.cancel() + + mock_getpgid.assert_called_once_with(4321) + mock_killpg.assert_called_once_with(4321, signal.SIGKILL) + self.assertTrue(dl.canceled) + + def test_cancel_does_not_killpg_parent_group_kills_child_only(self): + # Child has NOT become its own group leader yet (pgid != pid, e.g. it is + # still in the server's process group). killpg must NOT be called — that + # would SIGKILL the whole server — and we fall back to proc.kill(). + dl = _make_test_download() + dl.proc = types.SimpleNamespace(pid=4321, kill=MagicMock()) + dl.status_queue = types.SimpleNamespace(put=lambda _item: None) + + with patch.object(Download, "running", return_value=True), \ + patch("ytdl.os.getpgid", return_value=999), \ + patch("ytdl.os.killpg") as mock_killpg: + dl.cancel() + + mock_killpg.assert_not_called() + dl.proc.kill.assert_called_once() + self.assertTrue(dl.canceled) + + def test_cancel_falls_back_to_proc_kill_when_getpgid_unavailable(self): + dl = _make_test_download() + dl.proc = types.SimpleNamespace(pid=4321, kill=MagicMock()) + dl.status_queue = types.SimpleNamespace(put=lambda _item: None) + + with patch.object(Download, "running", return_value=True), \ + patch("ytdl.os.getpgid", side_effect=OSError("no such process")): + dl.cancel() + + dl.proc.kill.assert_called_once() + self.assertTrue(dl.canceled) + + +class ConvertSrtToTxtTests(unittest.TestCase): + def test_basic_conversion(self): + srt = """1 +00:00:01,000 --> 00:00:02,000 +Hello world + +2 +00:00:03,000 --> 00:00:04,000 +Second line +""" + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "sub.srt" + path.write_text(srt, encoding="utf-8") + txt_path = _convert_srt_to_txt_file(str(path)) + self.assertIsNotNone(txt_path) + self.assertTrue(txt_path.endswith(".txt")) + content = Path(txt_path).read_text(encoding="utf-8") + self.assertIn("Hello world", content) + self.assertIn("Second line", content) + + def test_vtt_input_strips_header_and_metadata(self): + # yt-dlp can fall back to VTT even when srt/txt was requested (the + # extractor may not offer a native srt track); the converter must not + # leak VTT-only header/metadata lines into the plain-text output. + vtt = """WEBVTT +Kind: captions +Language: en + +NOTE +This is a note block + +1 +00:00:01.000 --> 00:00:02.000 +Hello world + +2 +00:00:03.000 --> 00:00:04.000 +Second line +""" + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "sub.vtt" + path.write_text(vtt, encoding="utf-8") + txt_path = _convert_srt_to_txt_file(str(path)) + self.assertIsNotNone(txt_path) + content = Path(txt_path).read_text(encoding="utf-8") + self.assertIn("Hello world", content) + self.assertIn("Second line", content) + self.assertNotIn("WEBVTT", content) + self.assertNotIn("Kind:", content) + self.assertNotIn("Language:", content) + self.assertNotIn("This is a note block", content) + + def test_vtt_standalone_header_block_is_stripped(self): + # Some VTT files put a blank line after WEBVTT, so Kind:/Language: form + # their own block. That header block (before the first timed cue) must + # still be stripped. + vtt = """WEBVTT + +Kind: captions +Language: en + +1 +00:00:01.000 --> 00:00:02.000 +Hello world +""" + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "sub.vtt" + path.write_text(vtt, encoding="utf-8") + content = Path(_convert_srt_to_txt_file(str(path))).read_text(encoding="utf-8") + self.assertIn("Hello world", content) + self.assertNotIn("Kind:", content) + self.assertNotIn("Language:", content) + + def test_cue_text_starting_with_metadata_keyword_is_kept(self): + # A real caption line beginning with "Kind:"/"Language:" must NOT be + # dropped as if it were VTT header metadata. + srt = """1 +00:00:01,000 --> 00:00:02,000 +Kind: regards, everyone + +2 +00:00:03,000 --> 00:00:04,000 +Language: they spoke French +""" + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "sub.srt" + path.write_text(srt, encoding="utf-8") + content = Path(_convert_srt_to_txt_file(str(path))).read_text(encoding="utf-8") + self.assertIn("Kind: regards, everyone", content) + self.assertIn("Language: they spoke French", content) + + +class DownloadInfoSetstateTests(unittest.TestCase): + def _base_state(self, **kwargs): + base = { + "id": "id1", + "title": "t", + "url": "http://example.com/v", + "folder": "", + "custom_name_prefix": "", + "error": None, + "entry": None, + "playlist_item_limit": 0, + "split_by_chapters": False, + "chapter_template": "", + "msg": None, + "percent": None, + "speed": None, + "eta": None, + "status": "pending", + "size": None, + "timestamp": 0, + } + base.update(kwargs) + return base + + def test_migrates_old_audio_format(self): + state = self._base_state(format="m4a", quality="best") + di = DownloadInfo.__new__(DownloadInfo) + di.__setstate__(state) + self.assertEqual(di.download_type, "audio") + self.assertEqual(di.codec, "auto") + + def test_migrates_thumbnail(self): + state = self._base_state(format="thumbnail", quality="best") + di = DownloadInfo.__new__(DownloadInfo) + di.__setstate__(state) + self.assertEqual(di.download_type, "thumbnail") + self.assertEqual(di.format, "jpg") + + def test_migrates_captions(self): + state = self._base_state(format="captions", subtitle_format="vtt", quality="best") + di = DownloadInfo.__new__(DownloadInfo) + di.__setstate__(state) + self.assertEqual(di.download_type, "captions") + self.assertEqual(di.format, "vtt") + + def test_migrates_best_ios(self): + state = self._base_state( + format="any", quality="best_ios", video_codec="auto" + ) + di = DownloadInfo.__new__(DownloadInfo) + di.__setstate__(state) + self.assertEqual(di.format, "ios") + self.assertEqual(di.quality, "best") + + def test_migrates_quality_audio(self): + state = self._base_state(format="mp4", quality="audio", video_codec="h264") + di = DownloadInfo.__new__(DownloadInfo) + di.__setstate__(state) + self.assertEqual(di.download_type, "audio") + self.assertEqual(di.format, "m4a") + + def test_new_state_has_subtitle_files(self): + state = self._base_state( + download_type="video", + codec="auto", + format="any", + quality="best", + ) + di = DownloadInfo.__new__(DownloadInfo) + di.__setstate__(state) + self.assertEqual(di.subtitle_files, []) + + def test_missing_optional_fields_are_defaulted(self): + state = self._base_state( + download_type="video", + codec="auto", + format="any", + quality="best", + ) + state.pop("folder") + state.pop("custom_name_prefix") + state.pop("playlist_item_limit") + state.pop("split_by_chapters") + state.pop("chapter_template") + di = DownloadInfo.__new__(DownloadInfo) + di.__setstate__(state) + self.assertEqual(di.folder, "") + self.assertEqual(di.custom_name_prefix, "") + self.assertEqual(di.playlist_item_limit, 0) + self.assertFalse(di.split_by_chapters) + self.assertEqual(di.chapter_template, "") + + +class CompactPersistedEntryTests(unittest.TestCase): + def test_keeps_only_playlist_and_channel_keys(self): + entry = { + "playlist_index": "01", + "playlist_title": "Playlist", + "playlist_count": 10, + "playlist_autonumber": 1, + "channel_index": "02", + "channel_title": "Channel", + "n_entries": 10, + "__last_playlist_index": 10, + "formats": [{"id": "huge"}], + "description": "big blob", + } + + compact = _compact_persisted_entry(entry) + + self.assertEqual( + compact, + { + "playlist_index": "01", + "playlist_title": "Playlist", + "playlist_count": 10, + "playlist_autonumber": 1, + "channel_index": "02", + "channel_title": "Channel", + "n_entries": 10, + "__last_playlist_index": 10, + }, + ) + + def test_returns_none_when_no_restart_relevant_keys_exist(self): + self.assertIsNone(_compact_persisted_entry({"id": "x", "title": "y"})) + + +if __name__ == "__main__": + unittest.main() diff --git a/app/url_guard.py b/app/url_guard.py new file mode 100644 index 0000000..9d5adc7 --- /dev/null +++ b/app/url_guard.py @@ -0,0 +1,89 @@ +"""Lightweight SSRF guard for user-submitted URLs. + +MeTube hands user-submitted URLs to yt-dlp, whose generic extractor will fetch +any ``http(s)`` URL. Without a guard, an attacker can make the server fetch +internal endpoints (cloud metadata services, loopback, RFC1918 hosts, etc.) and +have the response saved to the download directory and served back. + +This module provides a single cheap validator applied at every URL ingress. It +intentionally does NOT attempt DNS-rebinding pinning, redirect-chain +re-validation, or validation of every media URL yt-dlp derives from remote +metadata — network isolation (e.g. Docker) remains the backstop for those. +""" + +import ipaddress +import logging +import socket +from urllib.parse import urlsplit + +log = logging.getLogger('url_guard') + +_ALLOWED_SCHEMES = ('http', 'https') + +# Hostnames that must be blocked without needing a lookup. ``localhost`` and any +# subdomain of it are conventionally loopback, and the GCP metadata name is a +# well-known SSRF target that may resolve via a resolver we don't control. +_BLOCKED_HOSTNAMES = ('localhost', 'metadata.google.internal') + + +def _hostname_is_blocked(hostname: str) -> bool: + host = hostname.rstrip('.').lower() + for blocked in _BLOCKED_HOSTNAMES: + if host == blocked or host.endswith('.' + blocked): + return True + return False + + +def _address_is_global(addr: str) -> bool: + try: + ip = ipaddress.ip_address(addr) + except ValueError: + return False + # Unwrap IPv4-mapped/compatible IPv6 (e.g. ::ffff:169.254.169.254) so the + # embedded IPv4 address is judged on its own merits. + if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None: + ip = ip.ipv4_mapped + return ip.is_global + + +def validate_url(url: str) -> str | None: + """Return an error message if the URL is disallowed, else ``None``. + + Inputs without a ``://`` scheme separator (bare video IDs, ``ytsearch:`` + and other yt-dlp search/extractor prefixes) are allowed unchanged so that + non-URL entries keep working. + """ + if not isinstance(url, str): + return 'Invalid URL' + + candidate = url.strip() + if '://' not in candidate: + # Not an absolute URL: bare video IDs, ytsearch: prefixes, etc. + return None + + parts = urlsplit(candidate) + scheme = parts.scheme.lower() + if scheme not in _ALLOWED_SCHEMES: + return f'URL scheme "{parts.scheme}" is not allowed (only http and https)' + + hostname = parts.hostname + if not hostname: + return 'URL is missing a host' + + if _hostname_is_blocked(hostname): + return f'Refusing to fetch internal host "{hostname}"' + + try: + addrinfo = socket.getaddrinfo(hostname, parts.port, proto=socket.IPPROTO_TCP) + except socket.gaierror: + # Let yt-dlp surface a normal resolution error rather than masking it. + return None + except (UnicodeError, ValueError): + return f'Invalid host "{hostname}"' + + for family, _type, _proto, _canonname, sockaddr in addrinfo: + addr = sockaddr[0] + if not _address_is_global(addr): + return f'Refusing to fetch internal address "{addr}" for host "{hostname}"' + + return None diff --git a/app/ytdl.py b/app/ytdl.py index bc84b74..20a0f4b 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -1,73 +1,228 @@ import os import shutil import yt_dlp +import collections +import collections.abc +import copy +import pickle from collections import OrderedDict -import shelve import time import asyncio import multiprocessing +from concurrent.futures import ThreadPoolExecutor +from functools import partial import logging import re +import signal +import sys import types -import dbm -import subprocess -from typing import Any -from functools import lru_cache +from typing import Any, Optional import yt_dlp.networking.impersonate +from yt_dlp.postprocessor.common import PostProcessor from yt_dlp.utils import STR_FORMAT_RE_TMPL, STR_FORMAT_TYPES -from dl_formats import get_format, get_opts, AUDIO_FORMATS +import bg_tasks +from dl_formats import get_format, get_opts, AUDIO_FORMATS, merge_ytdl_option_layers from datetime import datetime +from state_store import AtomicJsonStore, from_json_compatible, read_legacy_shelf, to_json_compatible +from subscriptions import _entry_id +from url_guard import validate_url log = logging.getLogger('ytdl') +# Python 3.14 switches the default multiprocessing start method on Linux +# (this app's only supported deployment target, per the Dockerfile) from fork +# to forkserver. Download._download relies on inheriting process state the +# way fork provides, so pin it back on Linux specifically. +# +# This must NOT be widened to "prefer fork wherever available": on macOS the +# platform default has long been spawn (never fork) precisely because forking +# a multi-threaded parent is hazardous — inherited locks held by threads that +# vanish in the child can deadlock it silently before it does any work. This +# app creates background threads (executors, notifier callbacks) well before +# any download starts, so forcing fork there reproduces exactly that hazard. +_MP_CTX = ( + multiprocessing.get_context("fork") + if sys.platform.startswith("linux") and "fork" in multiprocessing.get_all_start_methods() + else multiprocessing.get_context() +) -@lru_cache(maxsize=None) -def _compile_outtmpl_pattern(field: str) -> re.Pattern: - """Compile a regex pattern to match a specific field in an output template, including optional format specifiers.""" - conversion_types = f"[{re.escape(STR_FORMAT_TYPES)}]" - return re.compile(STR_FORMAT_RE_TMPL.format(re.escape(field), conversion_types)) +_LIVE_CHECK_INTERVAL = 60 +_LIVE_MAX_CHECK_INTERVAL = 3600 +# Consecutive probe failures (network blips, rate limits, transient extractor +# errors) tolerated before a scheduled live download is abandoned as errored. +_LIVE_PROBE_MAX_FAILURES = 5 -def _outtmpl_substitute_field(template: str, field: str, value: Any) -> str: - """Substitute a single field in an output template, applying any format specifiers to the value.""" - pattern = _compile_outtmpl_pattern(field) +class _AlbumArtistPostProcessor(PostProcessor): + """Fill missing album-artist metadata from yt-dlp's album-level signals.""" - def replacement(match: re.Match) -> str: - if match.group("has_key") is None: - return match.group(0) + _TOPIC_SUFFIX = ' - Topic' - prefix = match.group("prefix") or "" - format_spec = match.group("format") + @staticmethod + def _has_value(value: Any) -> bool: + if isinstance(value, str): + return bool(value.strip()) + if isinstance(value, (list, tuple)): + return any(_AlbumArtistPostProcessor._has_value(item) for item in value) + return value is not None - if not format_spec: - return f"{prefix}{value}" + @staticmethod + def _main_artist(info) -> Optional[str]: + artists = info.get('artists') + candidates = artists if isinstance(artists, list) else [info.get('artist')] + for candidate in candidates: + if not isinstance(candidate, str) or not candidate.strip(): + continue + # YouTube Music uses a spaced middle dot between credited artists. + # The first credit is the primary artist for normal albums. + return candidate.split(' · ', 1)[0].strip() + return None - conversion_type = format_spec[-1] - try: - if conversion_type in "diouxX": - coerced_value = int(value) - elif conversion_type in "eEfFgG": - coerced_value = float(value) - else: - coerced_value = value + @classmethod + def _topic_artist(cls, info) -> Optional[str]: + for field in ('channel', 'uploader'): + value = info.get(field) + if not isinstance(value, str) or not value.endswith(cls._TOPIC_SUFFIX): + continue + if artist := value[:-len(cls._TOPIC_SUFFIX)].strip(): + return artist + return None - return f"{prefix}{('%' + format_spec) % coerced_value}" - except (ValueError, TypeError): - return f"{prefix}{value}" + def run(self, info): + if not self._has_value(info.get('album')): + return [], info + if self._has_value(info.get('album_artist')) or self._has_value(info.get('album_artists')): + return [], info - return pattern.sub(replacement, template) + if artist := self._topic_artist(info) or self._main_artist(info): + info['album_artist'] = artist + return [], info -def _convert_generators_to_lists(obj): - """Recursively convert generators to lists in a dictionary to make it pickleable.""" - if isinstance(obj, types.GeneratorType): - return list(obj) - elif isinstance(obj, dict): - return {k: _convert_generators_to_lists(v) for k, v in obj.items()} - elif isinstance(obj, (list, tuple)): - return type(obj)(_convert_generators_to_lists(item) for item in obj) - else: + +def _is_within_directory(real_base: str, real_target: str) -> bool: + """True if ``real_target`` is inside (or equal to) ``real_base``. + + Both arguments must already be resolved with ``os.path.realpath``. Uses + ``commonpath`` rather than ``startswith`` so a sibling directory sharing a + name prefix (e.g. base ``/downloads`` vs ``/downloads-secret``) cannot be + reached via ``../downloads-secret``. + """ + try: + return os.path.commonpath([real_base, real_target]) == real_base + except ValueError: + # Raised when paths are on different drives (Windows) or mix + # absolute/relative; treat as outside the base directory. + return False + + +# Characters that are invalid in Windows/NTFS path components. These are pre- +# sanitised when substituting playlist/channel titles into output templates so +# that downloads do not fail on NTFS-mounted volumes or Windows Docker hosts. +_WINDOWS_INVALID_PATH_CHARS = re.compile(r'[\\:*?"<>|]') +_PATH_SEP_OR_TRAVERSAL = re.compile(r'[\\/]|\.\.') + + +def _sanitize_path_component(value: Any) -> Any: + """Replace characters that are invalid in Windows path components with '_'. + + Non-string values (int, float, None, …) are passed through unchanged so + that numeric format specs (e.g. ``%(playlist_index)02d``) still work. + Only string values are sanitised because Windows-invalid characters are + only a concern for human-readable strings (titles, channel names, etc.) + that may end up as directory names. Path separators and ``..`` segments + are also collapsed so attacker-controlled playlist/channel titles cannot + escape the download directory via the output template. + """ + if not isinstance(value, str): + return value + value = _WINDOWS_INVALID_PATH_CHARS.sub('_', value) + value = _PATH_SEP_OR_TRAVERSAL.sub('_', value) + return value.lstrip('.').strip() or '_' + + +def _output_dir_escapes(base_dir: str, output_template: str) -> bool: + """True when the literal directory prefix of *output_template* resolves outside *base_dir*.""" + marker = output_template.find('%(') + literal = output_template if marker == -1 else output_template[:marker] + dir_prefix = os.path.dirname(literal) + if not dir_prefix: + return False + real_base = os.path.realpath(base_dir) + real_target = os.path.realpath(os.path.join(base_dir, dir_prefix)) + return not _is_within_directory(real_base, real_target) + + +# Regex matching yt-dlp output-template field references, e.g. ``%(title)s`` +# or ``%(playlist_index)03d``. Built from yt-dlp's own ``STR_FORMAT_RE_TMPL`` +# so that it stays in sync with upstream changes to the template syntax. +_OUTTMPL_FIELD_RE = re.compile( + STR_FORMAT_RE_TMPL.format('[^)]+', f'[{STR_FORMAT_TYPES}ljhqBUDS]') +) + + +def _resolve_outtmpl_fields(template: str, info_dict: dict, prefixes: tuple[str, ...]) -> str: + """Resolve specific fields in an output template using yt-dlp's template engine. + + Only field references whose root name starts with one of *prefixes* are + evaluated. All other references are left untouched so that yt-dlp can + resolve them later during the actual download. + + This delegates to ``YoutubeDL.evaluate_outtmpl`` for each targeted field + reference, giving access to the full yt-dlp template syntax (defaults, + conditional formatting, math operations, datetime formatting, etc.). + """ + matches = list(_OUTTMPL_FIELD_RE.finditer(template)) + if not matches: + return template + + with yt_dlp.YoutubeDL({'quiet': True}) as ydl: + for match in reversed(matches): + key = match.group('key') + if key is None: + continue + root = re.match(r'\w+', key) + if root is None or not root.group(0).startswith(prefixes): + continue + resolved = ydl.evaluate_outtmpl(match.group(0), info_dict) + template = template[:match.start()] + resolved + template[match.end():] + + return template + +_MAX_ENTRY_SANITIZE_DEPTH = 64 + + +def _sanitize_entry_for_pickle(obj, _depth=0): + """Recursively normalize yt-dlp ``info_dict`` data so it can be stored in shelve/pickle. + + Live streams and newer yt-dlp versions may nest generators, iterators, sets, or + non-serializable objects (e.g. locks) inside the extracted metadata. The previous + helper only walked plain dict/list/tuple and only expanded ``types.GeneratorType``. + """ + if _depth > _MAX_ENTRY_SANITIZE_DEPTH: + return None + if obj is None or isinstance(obj, (bool, int, float, str, bytes)): return obj + if isinstance(obj, types.GeneratorType): + return _sanitize_entry_for_pickle(list(obj), _depth + 1) + if isinstance(obj, collections.abc.Mapping): + return {k: _sanitize_entry_for_pickle(v, _depth + 1) for k, v in obj.items()} + if isinstance(obj, (list, tuple)): + return type(obj)(_sanitize_entry_for_pickle(x, _depth + 1) for x in obj) + if isinstance(obj, (set, frozenset)): + return [_sanitize_entry_for_pickle(x, _depth + 1) for x in obj] + if isinstance(obj, collections.deque): + return [_sanitize_entry_for_pickle(x, _depth + 1) for x in obj] + if isinstance(obj, collections.abc.Iterator): + try: + return _sanitize_entry_for_pickle(list(obj), _depth + 1) + except Exception: + return None + try: + pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL) + return obj + except Exception: + return None def _convert_srt_to_txt_file(subtitle_path: str): @@ -80,10 +235,28 @@ def _convert_srt_to_txt_file(subtitle_path: str): # Normalize newlines so cue splitting is consistent across platforms. content = content.replace("\r\n", "\n").replace("\r", "\n") cues = [] + # The caption format may resolve to a VTT file even when srt/txt was + # requested (yt-dlp falls back when the extractor doesn't offer the + # requested ext). VTT header metadata (WEBVTT/NOTE/STYLE and + # "Kind:"/"Language:" fields) only ever appears BEFORE the first timed + # cue, so only strip it while still in that header region — otherwise a + # real caption line like "Kind: regards" would be dropped as metadata. + seen_cue = False for block in re.split(r"\n{2,}", content): lines = [line.strip() for line in block.split("\n") if line.strip()] if not lines: continue + has_timing = any("-->" in line for line in lines) + if not seen_cue and not has_timing: + # Still in the header region: drop metadata-only blocks + # (WEBVTT/NOTE/STYLE, or blocks made entirely of "Key: value" + # header fields such as a standalone "Kind:/Language:" block). + if re.match(r"^(WEBVTT|NOTE|STYLE)\b", lines[0]) or all( + re.match(r"^[A-Za-z][\w-]*:\s", line) for line in lines + ): + continue + if has_timing: + seen_cue = True if re.fullmatch(r"\d+", lines[0]): lines = lines[1:] if lines and "-->" in lines[0]: @@ -131,6 +304,8 @@ class DownloadInfo: title, url, quality, + download_type, + codec, format, folder, custom_name_prefix, @@ -139,14 +314,21 @@ class DownloadInfo: playlist_item_limit, split_by_chapters, chapter_template, - subtitle_format="srt", subtitle_language="en", subtitle_mode="prefer_manual", + ytdl_options_presets=None, + ytdl_options_overrides=None, + clip_start=None, + clip_end=None, + live_status=None, + live_release_timestamp=None, ): self.id = id if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{id}' self.title = title if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{title}' self.url = url self.quality = quality + self.download_type = download_type + self.codec = codec self.format = format self.folder = folder self.custom_name_prefix = custom_name_prefix @@ -155,31 +337,220 @@ class DownloadInfo: self.size = None self.timestamp = time.time_ns() self.error = error - # Convert generators to lists to make entry pickleable - self.entry = _convert_generators_to_lists(entry) if entry is not None else None + # Strip non-pickleable values (generators, iterators, locks, etc.) for shelve + self.entry = _sanitize_entry_for_pickle(entry) if entry is not None else None self.playlist_item_limit = playlist_item_limit self.split_by_chapters = split_by_chapters self.chapter_template = chapter_template - self.subtitle_format = subtitle_format self.subtitle_language = subtitle_language self.subtitle_mode = subtitle_mode + self.ytdl_options_presets = list(ytdl_options_presets or []) + self.ytdl_options_overrides = dict(ytdl_options_overrides or {}) + self.clip_start = clip_start + self.clip_end = clip_end + self.live_status = live_status + self.live_release_timestamp = live_release_timestamp self.subtitle_files = [] + # Fields that are useful server-side but must not be broadcast to browser + # clients: ``entry`` is the full yt-dlp info-dict (potentially large and + # re-sent on every progress tick) and ``subtitle_files`` is only used + # internally to derive the primary caption ``filename``. + _PUBLIC_EXCLUDED_FIELDS = ("entry", "subtitle_files") + + def to_public_dict(self) -> dict: + """Return the client-facing view, omitting server-only/bulky fields.""" + return { + k: v + for k, v in self.__dict__.items() + if k not in self._PUBLIC_EXCLUDED_FIELDS + } + + def __setstate__(self, state): + """BACKWARD COMPATIBILITY: migrate old DownloadInfo from persistent queue files.""" + self.__dict__.update(state) + if 'download_type' not in state: + old_format = state.get('format', 'any') + old_video_codec = state.get('video_codec', 'auto') + old_quality = state.get('quality', 'best') + old_subtitle_format = state.get('subtitle_format', 'srt') + + if old_format in AUDIO_FORMATS: + self.download_type = 'audio' + self.codec = 'auto' + elif old_format == 'thumbnail': + self.download_type = 'thumbnail' + self.codec = 'auto' + self.format = 'jpg' + elif old_format == 'captions': + self.download_type = 'captions' + self.codec = 'auto' + self.format = old_subtitle_format + else: + self.download_type = 'video' + self.codec = old_video_codec + if old_quality == 'best_ios': + self.format = 'ios' + self.quality = 'best' + elif old_quality == 'audio': + self.download_type = 'audio' + self.codec = 'auto' + self.format = 'm4a' + self.quality = 'best' + self.__dict__.pop('video_codec', None) + self.__dict__.pop('subtitle_format', None) + + if not getattr(self, "codec", None): + self.codec = "auto" + if not hasattr(self, "folder"): + self.folder = "" + if not hasattr(self, "custom_name_prefix"): + self.custom_name_prefix = "" + if not hasattr(self, "playlist_item_limit"): + self.playlist_item_limit = 0 + if not hasattr(self, "split_by_chapters"): + self.split_by_chapters = False + if not hasattr(self, "chapter_template"): + self.chapter_template = "" + if not hasattr(self, "subtitle_language"): + self.subtitle_language = "en" + if not hasattr(self, "subtitle_mode"): + self.subtitle_mode = "prefer_manual" + legacy_preset = self.__dict__.pop("ytdl_options_preset", None) + if "ytdl_options_presets" not in self.__dict__: + if isinstance(legacy_preset, str) and legacy_preset.strip(): + self.ytdl_options_presets = [legacy_preset.strip()] + elif isinstance(legacy_preset, list): + self.ytdl_options_presets = [str(x).strip() for x in legacy_preset if str(x).strip()] + else: + self.ytdl_options_presets = [] + if not hasattr(self, "ytdl_options_overrides"): + self.ytdl_options_overrides = {} + if not hasattr(self, "entry"): + self.entry = None + if not hasattr(self, "subtitle_files"): + self.subtitle_files = [] + if not hasattr(self, "chapter_files"): + self.chapter_files = [] + if not hasattr(self, "clip_start"): + self.clip_start = None + if not hasattr(self, "clip_end"): + self.clip_end = None + if not hasattr(self, "live_status"): + self.live_status = None + if not hasattr(self, "live_release_timestamp"): + self.live_release_timestamp = None + + +_PERSISTED_DOWNLOAD_FIELDS = ( + "id", + "title", + "url", + "quality", + "download_type", + "codec", + "format", + "folder", + "custom_name_prefix", + "playlist_item_limit", + "split_by_chapters", + "chapter_template", + "subtitle_language", + "subtitle_mode", + "ytdl_options_presets", + "ytdl_options_overrides", + "clip_start", + "clip_end", + "live_status", + "live_release_timestamp", + "status", + "timestamp", + "error", + "msg", + "filename", + "size", + "chapter_files", +) + + +_COMPACT_ENTRY_EXTRA_KEYS = frozenset(("n_entries", "__last_playlist_index")) + + +def _compact_persisted_entry(entry: Any) -> Optional[dict[str, Any]]: + if not isinstance(entry, dict): + return None + compact = { + key: value + for key, value in entry.items() + if key.startswith("playlist") or key.startswith("channel") or key in _COMPACT_ENTRY_EXTRA_KEYS + } + return compact or None + + +def _download_info_to_record( + info: DownloadInfo, + *, + include_entry: bool, +) -> dict[str, Any]: + record: dict[str, Any] = {} + for key in _PERSISTED_DOWNLOAD_FIELDS: + if hasattr(info, key): + value = getattr(info, key) + if value is not None: + record[key] = to_json_compatible(value) + if include_entry: + compact_entry = _compact_persisted_entry(getattr(info, "entry", None)) + if compact_entry is not None: + record["entry"] = to_json_compatible(compact_entry) + return record + + +def _download_info_from_record(record: dict[str, Any]) -> DownloadInfo: + info = DownloadInfo.__new__(DownloadInfo) + info.__setstate__({key: from_json_compatible(value) for key, value in record.items()}) + if not hasattr(info, "msg"): + info.msg = None + if not hasattr(info, "percent"): + info.percent = None + if not hasattr(info, "speed"): + info.speed = None + if not hasattr(info, "eta"): + info.eta = None + if not hasattr(info, "status"): + info.status = "pending" + if not hasattr(info, "size"): + info.size = None + if not hasattr(info, "error"): + info.error = None + return info + class Download: manager = None + @classmethod + def shutdown_manager(cls): + if cls.manager is not None: + cls.manager.shutdown() + cls.manager = None + def __init__(self, download_dir, temp_dir, output_template, output_template_chapter, quality, format, ytdl_opts, info): self.download_dir = download_dir self.temp_dir = temp_dir self.output_template = output_template self.output_template_chapter = output_template_chapter self.info = info - self.format = get_format(format, quality) + self.format = get_format( + getattr(info, 'download_type', 'video'), + getattr(info, 'codec', 'auto'), + format, + quality, + ) self.ytdl_opts = get_opts( + getattr(info, 'download_type', 'video'), + getattr(info, 'codec', 'auto'), format, quality, ytdl_opts, - subtitle_format=getattr(info, 'subtitle_format', 'srt'), subtitle_language=getattr(info, 'subtitle_language', 'en'), subtitle_mode=getattr(info, 'subtitle_mode', 'prefer_manual'), ) @@ -191,23 +562,56 @@ class Download: self.proc = None self.loop = None self.notifier = None + self._executor = None + + # Minimum interval between forwarded 'downloading' progress ticks. yt-dlp + # emits these many times per second; without throttling, each active + # download broadcasts hundreds of socket.io events/sec to every client. + _PROGRESS_THROTTLE_SECONDS = 0.5 + + def _make_progress_hook(self): + last_forward = 0.0 + + def put_status(st): + nonlocal last_forward + if st.get('status') == 'downloading': + now = time.monotonic() + if now - last_forward < self._PROGRESS_THROTTLE_SECONDS: + return + last_forward = now + self.status_queue.put({k: v for k, v in st.items() if k in ( + 'tmpfilename', + 'filename', + 'status', + 'msg', + 'total_bytes', + 'total_bytes_estimate', + 'downloaded_bytes', + 'speed', + 'eta', + )}) + + return put_status + + def _make_youtube_dl(self, params): + ydl = yt_dlp.YoutubeDL(params=params) + if getattr(self.info, 'download_type', '') == 'audio': + ydl.add_post_processor(_AlbumArtistPostProcessor(ydl), when='pre_process') + return ydl def _download(self): + # Run in our own process group so cancel() can SIGKILL the whole + # group (yt-dlp + any ffmpeg children it spawned for merge/postproc), + # instead of orphaning ffmpeg when only the yt-dlp process is killed. + if hasattr(os, 'setpgrp'): + try: + os.setpgrp() + except OSError: + pass log.info(f"Starting download for: {self.info.title} ({self.info.url})") try: debug_logging = logging.getLogger().isEnabledFor(logging.DEBUG) - def put_status(st): - self.status_queue.put({k: v for k, v in st.items() if k in ( - 'tmpfilename', - 'filename', - 'status', - 'msg', - 'total_bytes', - 'total_bytes_estimate', - 'downloaded_bytes', - 'speed', - 'eta', - )}) + put_status = self._make_progress_hook() def put_status_postprocessor(d): if d['postprocessor'] == 'MoveFiles' and d['status'] == 'finished': @@ -221,7 +625,7 @@ class Download: # For captions-only downloads, yt-dlp may still report a media-like # filepath in MoveFiles. Capture subtitle outputs explicitly so the # UI can link to real caption files. - if self.info.format == 'captions': + if getattr(self.info, 'download_type', '') == 'captions': requested_subtitles = d.get('info_dict', {}).get('requested_subtitles', {}) or {} for subtitle in requested_subtitles.values(): if isinstance(subtitle, dict) and subtitle.get('filepath'): @@ -262,26 +666,37 @@ class Download: 'force_keyframes': False }) - ret = yt_dlp.YoutubeDL(params=ytdl_params).download([self.info.url]) + clip_start = getattr(self.info, 'clip_start', None) + clip_end = getattr(self.info, 'clip_end', None) + if clip_start is not None or clip_end is not None: + start = float(clip_start) if clip_start is not None else 0.0 + end = float(clip_end) if clip_end is not None else float('inf') + ytdl_params['download_ranges'] = yt_dlp.utils.download_range_func( + None, + [(start, end)], + ) + + ret = self._make_youtube_dl(ytdl_params).download([self.info.url]) self.status_queue.put({'status': 'finished' if ret == 0 else 'error'}) log.info(f"Finished download for: {self.info.title}") except yt_dlp.utils.YoutubeDLError as exc: log.error(f"Download error for {self.info.title}: {str(exc)}") self.status_queue.put({'status': 'error', 'msg': str(exc)}) - async def start(self, notifier): + async def start(self, notifier, executor=None): log.info(f"Preparing download for: {self.info.title}") if Download.manager is None: - Download.manager = multiprocessing.Manager() + Download.manager = _MP_CTX.Manager() self.status_queue = Download.manager.Queue() - self.proc = multiprocessing.Process(target=self._download) + self.proc = _MP_CTX.Process(target=self._download) self.proc.start() self.loop = asyncio.get_running_loop() self.notifier = notifier + self._executor = executor self.info.status = 'preparing' await self.notifier.updated(self.info) self.status_task = asyncio.create_task(self.update_status()) - await self.loop.run_in_executor(None, self.proc.join) + await self.loop.run_in_executor(self._executor, self.proc.join) # Signal update_status to stop and wait for it to finish # so that all status updates (including MoveFiles with correct # file size) are processed before _post_download_cleanup runs. @@ -292,10 +707,26 @@ class Download: def cancel(self): log.info(f"Cancelling download: {self.info.title}") if self.running(): + killed_group = False try: - self.proc.kill() - except Exception as e: - log.error(f"Error killing process for {self.info.title}: {e}") + pgid = os.getpgid(self.proc.pid) + # Only kill the whole group (yt-dlp + any ffmpeg children) when + # the child actually became its own group leader via + # os.setpgrp() in _download() — that sets its pgid equal to its + # own pid. If it hasn't run setpgrp() yet, or setpgrp() failed, + # its pgid is still the SERVER's group and killpg would SIGKILL + # the entire MeTube process (PID 1 in Docker). Fall back to + # killing just the child process by pid in that case. + if pgid == self.proc.pid: + os.killpg(pgid, signal.SIGKILL) + killed_group = True + except (OSError, AttributeError): + pass + if not killed_group: + try: + self.proc.kill() + except Exception as e: + log.error(f"Error killing process for {self.info.title}: {e}") self.canceled = True if self.status_queue is not None: self.status_queue.put(None) @@ -316,7 +747,13 @@ class Download: async def update_status(self): while True: - status = await self.loop.run_in_executor(None, self.status_queue.get) + try: + status = await self.loop.run_in_executor(self._executor, self.status_queue.get) + except RuntimeError: + # The download executor was shut down (server shutting down); + # stop polling instead of raising a noisy background-task error. + log.info(f"Status polling stopped (executor shut down) for: {self.info.title}") + return if status is None: log.info(f"Status update finished for: {self.info.title}") return @@ -329,15 +766,18 @@ class Download: rel_name = os.path.relpath(fileName, self.download_dir) # For captions mode, ignore media-like placeholders and let subtitle_file # statuses define the final file shown in the UI. - if self.info.format == 'captions': - requested_subtitle_format = str(getattr(self.info, 'subtitle_format', '')).lower() + if getattr(self.info, 'download_type', '') == 'captions': + requested_subtitle_format = str(getattr(self.info, 'format', '')).lower() allowed_caption_exts = ('.txt',) if requested_subtitle_format == 'txt' else ('.vtt', '.srt', '.sbv', '.scc', '.ttml', '.dfxp') if not rel_name.lower().endswith(allowed_caption_exts): continue self.info.filename = rel_name self.info.size = os.path.getsize(fileName) if os.path.exists(fileName) else None - if self.info.format == 'thumbnail': - self.info.filename = re.sub(r'\.webm$', '.jpg', self.info.filename) + if getattr(self.info, 'download_type', '') == 'thumbnail': + # The thumbnail convertor always emits a .jpg, but yt-dlp may + # report the pre-conversion media/thumbnail extension + # (.webm/.mp4/.png/.webp/...). Normalise to .jpg regardless. + self.info.filename = os.path.splitext(self.info.filename)[0] + '.jpg' # Handle chapter files log.debug(f"Update status for {self.info.title}: {status}") @@ -361,7 +801,7 @@ class Download: subtitle_output_file = subtitle_file # txt mode is derived from SRT by stripping cue metadata. - if self.info.format == 'captions' and str(getattr(self.info, 'subtitle_format', '')).lower() == 'txt': + if getattr(self.info, 'download_type', '') == 'captions' and str(getattr(self.info, 'format', '')).lower() == 'txt': converted_txt = _convert_srt_to_txt_file(subtitle_file) if converted_txt: subtitle_output_file = converted_txt @@ -377,9 +817,9 @@ class Download: if not existing: self.info.subtitle_files.append({'filename': rel_path, 'size': file_size}) # Prefer first subtitle file as the primary result link in captions mode. - if self.info.format == 'captions' and ( + if getattr(self.info, 'download_type', '') == 'captions' and ( not getattr(self.info, 'filename', None) or - str(getattr(self.info, 'subtitle_format', '')).lower() == 'txt' + str(getattr(self.info, 'format', '')).lower() == 'txt' ): self.info.filename = rel_path self.info.size = file_size @@ -400,18 +840,16 @@ class PersistentQueue: def __init__(self, name, path): self.identifier = name pdir = os.path.dirname(path) - if not os.path.isdir(pdir): - os.mkdir(pdir) - with shelve.open(path, 'c'): - pass - - self.path = path - self.repair() + if pdir and not os.path.isdir(pdir): + os.makedirs(pdir, exist_ok=True) + self.legacy_path = path + self.path = f"{path}.json" + self.store = AtomicJsonStore(self.path, kind=f"persistent_queue:{name}") self.dict = OrderedDict() def load(self): for k, v in self.saved_items(): - self.dict[k] = Download(None, None, None, None, None, None, {}, v) + self.dict[k] = Download(None, None, None, None, getattr(v, 'quality', 'best'), getattr(v, 'format', 'any'), {}, v) def exists(self, key): return key in self.dict @@ -423,99 +861,95 @@ class PersistentQueue: return self.dict.items() def saved_items(self): - with shelve.open(self.path, 'r') as shelf: - return sorted(shelf.items(), key=lambda item: item[1].timestamp) + items = [ + (item["key"], _download_info_from_record(item["info"])) + for item in self._load_state_items() + ] + return sorted(items, key=lambda item: item[1].timestamp) + + def _should_persist_entry(self) -> bool: + return self.identifier != "completed" + + def _serialize_items(self): + return [ + { + "key": key, + "info": _download_info_to_record( + download.info, + include_entry=self._should_persist_entry(), + ), + } + for key, download in self.dict.items() + ] + + def _save_dict(self): + self.store.save({"items": self._serialize_items()}) + + def _load_state_items(self): + payload = self.store.load() + if payload is not None: + items = payload.get("items") + if isinstance(items, list): + compact_items = [ + { + "key": item["key"], + "info": _download_info_to_record( + _download_info_from_record(item["info"]), + include_entry=self._should_persist_entry(), + ), + } + for item in items + if isinstance(item, dict) and "key" in item and "info" in item + ] + if payload.get("schema_version") != self.store.schema_version or compact_items != items: + self.store.save({"items": compact_items}) + return compact_items + log.warning("PersistentQueue:%s state file did not contain an items list", self.identifier) + return [] + + legacy_items = read_legacy_shelf(self.legacy_path) + if legacy_items is None: + return [] + + items = [ + { + "key": key, + "info": _download_info_to_record( + value, + include_entry=self._should_persist_entry(), + ), + } + for key, value in sorted(legacy_items, key=lambda item: item[1].timestamp) + ] + self.store.save({"items": items}) + return items def put(self, value): key = value.info.url + old = self.dict.get(key) self.dict[key] = value - with shelve.open(self.path, 'w') as shelf: - shelf[key] = value.info + try: + self._save_dict() + except Exception: + if old is None: + del self.dict[key] + else: + self.dict[key] = old + raise def delete(self, key): if key in self.dict: + old = self.dict[key] del self.dict[key] - with shelve.open(self.path, 'w') as shelf: - shelf.pop(key, None) - - def next(self): - k, v = next(iter(self.dict.items())) - return k, v + try: + self._save_dict() + except Exception: + self.dict[key] = old + raise def empty(self): return not bool(self.dict) - def repair(self): - # check DB format - type_check = subprocess.run( - ["file", self.path], - capture_output=True, - text=True - ) - db_type = type_check.stdout.lower() - - # create backup (.old) - try: - shutil.copy2(self.path, f"{self.path}.old") - except Exception as e: - # if we cannot backup then its not safe to attempt a repair - # since it could be due to a filesystem error - log.debug(f"PersistentQueue:{self.identifier} backup failed, skipping repair") - return - - if "gnu dbm" in db_type: - # perform gdbm repair - log_prefix = f"PersistentQueue:{self.identifier} repair (dbm/file)" - log.debug(f"{log_prefix} started") - try: - result = subprocess.run( - ["gdbmtool", self.path], - input="recover verbose summary\n", - text=True, - capture_output=True, - timeout=60 - ) - log.debug(f"{log_prefix} {result.stdout}") - if result.stderr: - log.debug(f"{log_prefix} failed: {result.stderr}") - except FileNotFoundError: - log.debug(f"{log_prefix} failed: 'gdbmtool' was not found") - - # perform null key cleanup - log_prefix = f"PersistentQueue:{self.identifier} repair (null keys)" - log.debug(f"{log_prefix} started") - deleted = 0 - try: - with dbm.open(self.path, "w") as db: - for key in list(db.keys()): - if len(key) > 0 and all(b == 0x00 for b in key): - log.debug(f"{log_prefix} deleting key of length {len(key)} (all NUL bytes)") - del db[key] - deleted += 1 - log.debug(f"{log_prefix} done - deleted {deleted} key(s)") - except dbm.error: - log.debug(f"{log_prefix} failed: db type is dbm.gnu, but the module is not available (dbm.error; module support may be missing or the file may be corrupted)") - - elif "sqlite" in db_type: - # perform sqlite3 recovery - log_prefix = f"PersistentQueue:{self.identifier} repair (sqlite3/file)" - log.debug(f"{log_prefix} started") - try: - result = subprocess.run( - f"sqlite3 {self.path} '.recover' | sqlite3 {self.path}.tmp", - capture_output=True, - text=True, - shell=True, - timeout=60 - ) - if result.stderr: - log.debug(f"{log_prefix} failed: {result.stderr}") - else: - shutil.move(f"{self.path}.tmp", self.path) - log.debug(f"{log_prefix}{result.stdout or " was successful, no output"}") - except FileNotFoundError: - log.debug(f"{log_prefix} failed: 'sqlite3' was not found") - class DownloadQueue: def __init__(self, config, notifier): self.config = config @@ -525,14 +959,35 @@ class DownloadQueue: self.pending = PersistentQueue("pending", self.config.STATE_DIR + '/pending') self.active_downloads = set() self.semaphore = asyncio.Semaphore(int(self.config.MAX_CONCURRENT_DOWNLOADS)) + # Each active download parks two threads for its whole duration + # (proc.join + status_queue.get). A dedicated pool keeps those from + # starving the default executor, which extract_info/live-probes also use. + self._download_executor = ThreadPoolExecutor( + max_workers=2 * int(self.config.MAX_CONCURRENT_DOWNLOADS) + 2, + thread_name_prefix="dl", + ) self.done.load() self._add_generation = 0 self._canceled_urls = set() # URLs canceled during current playlist add + self._scheduled_probe_at: dict[str, float] = {} + self._scheduled_probe_failures: dict[str, int] = {} + self._live_monitor_task: Optional[asyncio.Task] = None + self._live_monitor_wakeup = asyncio.Event() def cancel_add(self): self._add_generation += 1 log.info('Playlist add operation canceled by user') + @staticmethod + def __is_channel_extraction(entry): + """Return True when yt-dlp reported a channel tab as a playlist. + + YouTube channel tabs are extracted with ``_type: 'playlist'`` but set + ``id`` equal to ``channel_id``; real playlists keep a distinct id. + """ + channel_id = entry.get('channel_id') + return bool(channel_id) and entry.get('id') == channel_id + async def __import_queue(self): for k, v in self.queue.saved_items(): await self.__add_download(v, True) @@ -543,8 +998,160 @@ class DownloadQueue: async def initialize(self): log.info("Initializing DownloadQueue") - asyncio.create_task(self.__import_queue()) - asyncio.create_task(self.__import_pending()) + self._start_live_monitor() + bg_tasks.create_task(self.__import_queue(), name="import_queue") + bg_tasks.create_task(self.__import_pending(), name="import_pending") + + def _start_live_monitor(self) -> None: + if self._live_monitor_task is not None and not self._live_monitor_task.done(): + return + # bg_tasks.create_task already logs unexpected task failures with the name. + self._live_monitor_task = bg_tasks.create_task(self._live_monitor_loop(), name="live_monitor") + + def _register_scheduled(self, download: Download) -> None: + self._scheduled_probe_at[download.info.url] = 0 + self._scheduled_probe_failures.pop(download.info.url, None) + self._start_live_monitor() + self._wake_live_monitor() + + def _unregister_scheduled(self, url: str) -> None: + self._scheduled_probe_at.pop(url, None) + self._scheduled_probe_failures.pop(url, None) + + def _wake_live_monitor(self) -> None: + try: + self._live_monitor_wakeup.set() + except RuntimeError: + pass + + def _probe_interval_seconds(self, release_timestamp: Any) -> float: + if release_timestamp is not None: + try: + diff = float(release_timestamp) - time.time() + if diff > 0: + return max(_LIVE_CHECK_INTERVAL, min(diff, _LIVE_MAX_CHECK_INTERVAL)) + except (TypeError, ValueError): + pass + return float(_LIVE_CHECK_INTERVAL) + + def _seconds_until_next_probe(self) -> Optional[float]: + """Time until the earliest scheduled probe, or None when nothing is scheduled.""" + if not self._scheduled_probe_at: + return None + return max(0.0, min(self._scheduled_probe_at.values()) - time.time()) + + async def _live_monitor_loop(self) -> None: + while True: + timeout = self._seconds_until_next_probe() + try: + await asyncio.wait_for(self._live_monitor_wakeup.wait(), timeout=timeout) + except asyncio.TimeoutError: + pass + self._live_monitor_wakeup.clear() + now = time.time() + due: list[Download] = [] + for url, probe_at in list(self._scheduled_probe_at.items()): + if now < probe_at: + continue + if not self.queue.exists(url): + self._unregister_scheduled(url) + continue + download = self.queue.get(url) + if download.info.status != 'scheduled' or download.canceled: + self._unregister_scheduled(url) + continue + due.append(download) + for download in due: + try: + await self._probe_scheduled_download(download) + except Exception as exc: + # Defensive: _probe_scheduled_download handles its own errors, + # but never let an unexpected failure leave probe_at in the past + # (which would spin this loop) or kill the monitor task. + log.exception("Scheduled live probe crashed for %s: %s", download.info.url, exc) + if download.info.url in self._scheduled_probe_at: + self._scheduled_probe_at[download.info.url] = time.time() + _LIVE_CHECK_INTERVAL + + async def _probe_scheduled_download(self, download: Download) -> None: + url = download.info.url + info = download.info + if info.status != 'scheduled' or download.canceled: + self._unregister_scheduled(url) + return + + try: + entry = await asyncio.get_running_loop().run_in_executor( + None, + partial( + self.__extract_info, + url, + getattr(info, 'ytdl_options_presets', None), + getattr(info, 'ytdl_options_overrides', {}) or {}, + ), + ) + except Exception as exc: + # Treat all probe failures (transient network blips, rate limits, + # extractor errors) as recoverable up to a point: retry on the next + # interval and only give up after repeated consecutive failures so a + # momentary glitch doesn't abandon a stream the user is waiting for. + fails = self._scheduled_probe_failures.get(url, 0) + 1 + self._scheduled_probe_failures[url] = fails + if fails >= _LIVE_PROBE_MAX_FAILURES: + log.warning( + "Giving up on scheduled live probe for %s after %d consecutive failures: %s", + info.title, fails, exc, + ) + info.status = 'error' + info.msg = str(exc) + if not info.error: + info.error = str(exc) + self._unregister_scheduled(url) + self.queue.delete(url) + self.done.put(download) + await self.notifier.completed(info) + else: + log.warning( + "Scheduled live probe failed for %s (attempt %d/%d), will retry: %s", + info.title, fails, _LIVE_PROBE_MAX_FAILURES, exc, + ) + self._scheduled_probe_at[url] = time.time() + _LIVE_CHECK_INTERVAL + return + + # Successful probe resets the transient-failure streak. + self._scheduled_probe_failures.pop(url, None) + + release_ts = entry.get('release_timestamp') + live_status = entry.get('live_status') + if release_ts is not None: + info.live_release_timestamp = release_ts + if live_status is not None: + info.live_status = live_status + + if live_status == 'is_upcoming': + self._scheduled_probe_at[url] = time.time() + self._probe_interval_seconds(release_ts) + await self.notifier.updated(info) + return + + self._unregister_scheduled(url) + info.status = 'pending' + # Clear the "scheduled to start at ..." placeholder now that the stream + # is live and a real download is about to begin. + info.error = None + info.msg = None + await self.notifier.updated(info) + bg_tasks.create_task(self.__start_download(download), name="start_download") + + def _schedule_upcoming_download(self, download: Download) -> None: + download.info.status = 'scheduled' + self.queue.put(download) + self._register_scheduled(download) + + def _force_start_scheduled(self, download: Download) -> None: + self._unregister_scheduled(download.info.url) + download.info.status = 'pending' + download.info.error = None + download.info.msg = None + bg_tasks.create_task(self.__start_download(download), name="start_download") async def __start_download(self, download): if download.canceled: @@ -554,7 +1161,7 @@ class DownloadQueue: if download.canceled: log.info(f"Download {download.info.title} was canceled, skipping start.") return - await download.start(self.notifier) + await download.start(self.notifier, self._download_executor) self._post_download_cleanup(download) def _post_download_cleanup(self, download): @@ -562,21 +1169,58 @@ class DownloadQueue: if download.tmpfilename and os.path.isfile(download.tmpfilename): try: os.remove(download.tmpfilename) - except: + except OSError: pass download.info.status = 'error' + # A progress tick may have set filename to a temp-directory + # relative path before the error occurred; clear it so the UI + # doesn't render a broken link (or, worse, so a later trashcan + # delete doesn't act on a path outside the download directory). + # Captions downloads may still have captured valid subtitle + # files even when the overall status is 'error' — keep those. + has_captured_subtitles = bool(getattr(download.info, 'subtitle_files', None)) + if not (download.info.download_type == 'captions' and has_captured_subtitles): + download.info.filename = None + download.info.size = None download.close() if self.queue.exists(download.info.url): self.queue.delete(download.info.url) if download.canceled: - asyncio.create_task(self.notifier.canceled(download.info.url)) + bg_tasks.create_task(self.notifier.canceled(download.info.url), name="notify_canceled") else: self.done.put(download) - asyncio.create_task(self.notifier.completed(download.info)) + bg_tasks.create_task(self.notifier.completed(download.info), name="notify_completed") + try: + clear_after = int(self.config.CLEAR_COMPLETED_AFTER) + except ValueError: + log.error(f'CLEAR_COMPLETED_AFTER is set to an invalid value "{self.config.CLEAR_COMPLETED_AFTER}", expected an integer number of seconds') + clear_after = 0 + if clear_after > 0: + # bg_tasks.create_task already logs unexpected task failures. + bg_tasks.create_task( + self.__auto_clear_after_delay(download.info.url, clear_after), + name="auto_clear", + ) - def __extract_info(self, url): + async def __auto_clear_after_delay(self, url, delay_seconds): + await asyncio.sleep(delay_seconds) + if self.done.exists(url): + log.debug(f'Auto-clearing completed download: {url}') + await self.clear([url]) + + def _build_ytdl_options(self, ytdl_options_presets=None, ytdl_options_overrides=None): + """Merge global options, presets (in order), and per-download overrides.""" + opts = dict(self.config.YTDL_OPTIONS) + opts.update(merge_ytdl_option_layers( + ytdl_options_presets, ytdl_options_overrides, self.config.YTDL_OPTIONS_PRESETS + )) + return opts + + def __extract_info(self, url, ytdl_options_presets=None, ytdl_options_overrides=None): debug_logging = logging.getLogger().isEnabledFor(logging.DEBUG) - return yt_dlp.YoutubeDL(params={ + user_opts = self._build_ytdl_options(ytdl_options_presets, ytdl_options_overrides) + params = { + **user_opts, 'quiet': not debug_logging, 'verbose': debug_logging, 'no_color': True, @@ -584,18 +1228,20 @@ class DownloadQueue: 'ignore_no_formats_error': True, 'noplaylist': True, 'paths': {"home": self.config.DOWNLOAD_DIR, "temp": self.config.TEMP_DIR}, - **self.config.YTDL_OPTIONS, - **({'impersonate': yt_dlp.networking.impersonate.ImpersonateTarget.from_str(self.config.YTDL_OPTIONS['impersonate'])} if 'impersonate' in self.config.YTDL_OPTIONS else {}), - }).extract_info(url, download=False) + } + imp = user_opts.get('impersonate') + if imp is not None: + params['impersonate'] = yt_dlp.networking.impersonate.ImpersonateTarget.from_str(imp) + return yt_dlp.YoutubeDL(params=params).extract_info(url, download=False) - def __calc_download_path(self, quality, format, folder): - base_directory = self.config.DOWNLOAD_DIR if (quality != 'audio' and format not in AUDIO_FORMATS) else self.config.AUDIO_DOWNLOAD_DIR + def __calc_download_path(self, download_type, folder): + base_directory = self.config.AUDIO_DOWNLOAD_DIR if download_type == 'audio' else self.config.DOWNLOAD_DIR if folder: if not self.config.CUSTOM_DIRS: return None, {'status': 'error', 'msg': 'A folder for the download was specified but CUSTOM_DIRS is not true in the configuration.'} dldirectory = os.path.realpath(os.path.join(base_directory, folder)) real_base_directory = os.path.realpath(base_directory) - if not dldirectory.startswith(real_base_directory): + if not _is_within_directory(real_base_directory, dldirectory): return None, {'status': 'error', 'msg': f'Folder "{folder}" must resolve inside the base download directory "{real_base_directory}"'} if not os.path.isdir(dldirectory): if not self.config.CREATE_CUSTOM_DIRS: @@ -606,7 +1252,7 @@ class DownloadQueue: return dldirectory, None async def __add_download(self, dl, auto_start): - dldirectory, error_message = self.__calc_download_path(dl.quality, dl.format, dl.folder) + dldirectory, error_message = self.__calc_download_path(dl.download_type, dl.folder) if error_message is not None: return error_message output = self.config.OUTPUT_TEMPLATE if len(dl.custom_name_prefix) == 0 else f'{dl.custom_name_prefix}.{self.config.OUTPUT_TEMPLATE}' @@ -615,24 +1261,34 @@ class DownloadQueue: if entry is not None and entry.get('playlist_index') is not None: if len(self.config.OUTPUT_TEMPLATE_PLAYLIST): output = self.config.OUTPUT_TEMPLATE_PLAYLIST - for property, value in entry.items(): - if property.startswith("playlist"): - output = _outtmpl_substitute_field(output, property, value) + sanitized = {k: _sanitize_path_component(v) for k, v in entry.items()} + output = _resolve_outtmpl_fields(output, sanitized, ('playlist',)) if entry is not None and entry.get('channel_index') is not None: if len(self.config.OUTPUT_TEMPLATE_CHANNEL): output = self.config.OUTPUT_TEMPLATE_CHANNEL - for property, value in entry.items(): - if property.startswith("channel"): - output = _outtmpl_substitute_field(output, property, value) - ytdl_options = dict(self.config.YTDL_OPTIONS) + sanitized = {k: _sanitize_path_component(v) for k, v in entry.items()} + output = _resolve_outtmpl_fields(output, sanitized, ('channel',)) + ytdl_options = self._build_ytdl_options( + getattr(dl, 'ytdl_options_presets', None), + getattr(dl, 'ytdl_options_overrides', {}) or {}, + ) playlist_item_limit = getattr(dl, 'playlist_item_limit', 0) if playlist_item_limit > 0: log.info(f'playlist limit is set. Processing only first {playlist_item_limit} entries') ytdl_options['playlistend'] = playlist_item_limit + if _output_dir_escapes(dldirectory, output): + return {'status': 'error', 'msg': 'Refusing download: resolved output path escapes the download directory'} download = Download(dldirectory, self.config.TEMP_DIR, output, output_chapter, dl.quality, dl.format, ytdl_options, dl) + is_upcoming = ( + getattr(dl, 'live_status', None) == 'is_upcoming' + or getattr(dl, 'status', None) == 'scheduled' + ) if auto_start is True: - self.queue.put(download) - asyncio.create_task(self.__start_download(download)) + if is_upcoming: + self._schedule_upcoming_download(download) + else: + self.queue.put(download) + bg_tasks.create_task(self.__start_download(download), name="start_download") else: self.pending.put(download) await self.notifier.added(dl) @@ -640,17 +1296,22 @@ class DownloadQueue: async def __add_entry( self, entry, - quality, + download_type, + codec, format, + quality, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, - subtitle_format, subtitle_language, subtitle_mode, + ytdl_options_presets, + ytdl_options_overrides, + clip_start, + clip_end, already, _add_gen=None, ): @@ -659,7 +1320,9 @@ class DownloadQueue: error = None if "live_status" in entry and "release_timestamp" in entry and entry.get("live_status") == "is_upcoming": - dt_ts = datetime.fromtimestamp(entry.get("release_timestamp")).strftime('%Y-%m-%d %H:%M:%S %z') + # astimezone() makes this an aware datetime in the server's local + # zone; a naive datetime's %z renders as an empty string. + dt_ts = datetime.fromtimestamp(entry.get("release_timestamp")).astimezone().strftime('%Y-%m-%d %H:%M:%S %z') error = f"Live stream is scheduled to start at {dt_ts}" else: if "msg" in entry: @@ -671,28 +1334,36 @@ class DownloadQueue: log.debug('Processing as a url') return await self.add( entry['url'], - quality, + download_type, + codec, format, + quality, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, - subtitle_format, subtitle_language, subtitle_mode, + ytdl_options_presets, + ytdl_options_overrides, + clip_start, + clip_end, already, _add_gen, ) elif etype == 'playlist' or etype == 'channel': + if etype == 'playlist' and self.__is_channel_extraction(entry): + etype = 'channel' log.debug(f'Processing as a {etype}') entries = entry['entries'] # Convert generator to list if needed (for len() and slicing operations) if isinstance(entries, types.GeneratorType): entries = list(entries) - log.info(f'{etype} detected with {len(entries)} entries') - index_digits = len(str(len(entries))) + total_entries = len(entries) + log.info(f'{etype} detected with {total_entries} entries') + index_digits = len(str(total_entries)) results = [] if playlist_item_limit > 0: log.info(f'Item limit is set. Processing only first {playlist_item_limit} entries') @@ -701,26 +1372,47 @@ class DownloadQueue: if _add_gen is not None and self._add_generation != _add_gen: log.info(f'Playlist add canceled after processing {len(already)} entries') return {'status': 'ok', 'msg': f'Canceled - added {len(already)} items before cancel'} + if "id" not in etr: + etr["id"] = _entry_id(etr) etr["_type"] = "video" - etr[etype] = entry.get("id") or entry.get("channel_id") or entry.get("channel") + if etype == 'channel': + etr["channel"] = ( + entry.get("channel") + or entry.get("uploader") + or entry.get("title") + or entry.get("id") + ) + else: + etr["playlist"] = entry.get("id") or entry.get("channel_id") or entry.get("channel") etr[f"{etype}_index"] = '{{0:0{0:d}d}}'.format(index_digits).format(index) + etr[f"{etype}_count"] = total_entries + etr[f"{etype}_autonumber"] = index + # n_entries: standard yt-dlp field for total count (used by template engine) + # __last_playlist_index: yt-dlp internal field for auto-padding autonumber + etr["n_entries"] = total_entries + etr["__last_playlist_index"] = total_entries for property in ("id", "title", "uploader", "uploader_id"): if property in entry: etr[f"{etype}_{property}"] = entry[property] results.append( await self.__add_entry( etr, - quality, + download_type, + codec, format, + quality, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, - subtitle_format, subtitle_language, subtitle_mode, + ytdl_options_presets, + ytdl_options_overrides, + clip_start, + clip_end, already, _add_gen, ) @@ -728,55 +1420,74 @@ class DownloadQueue: if any(res['status'] == 'error' for res in results): return {'status': 'error', 'msg': ', '.join(res['msg'] for res in results if res['status'] == 'error' and 'msg' in res)} return {'status': 'ok'} - elif etype == 'video' or (etype.startswith('url') and 'id' in entry and 'title' in entry): + elif etype == 'video': log.debug('Processing as a video') key = entry.get('webpage_url') or entry['url'] if key in self._canceled_urls: log.info(f'Skipping canceled URL: {entry.get("title") or key}') return {'status': 'ok'} - if not self.queue.exists(key): - dl = DownloadInfo( - entry['id'], - entry.get('title') or entry['id'], - key, - quality, - format, - folder, - custom_name_prefix, - error, - entry, - playlist_item_limit, - split_by_chapters, - chapter_template, - subtitle_format, - subtitle_language, - subtitle_mode, - ) - await self.__add_download(dl, auto_start) + if self.queue.exists(key) or self.pending.exists(key): + # Surface the skip instead of silently no-op'ing, and avoid + # clobbering an existing pending entry's options with a + # fresh DownloadInfo built from possibly-different args. + title = entry.get('title') or key + return {'status': 'ok', 'msg': f'Already in queue: {title}'} + dl = DownloadInfo( + id=entry['id'], + title=entry.get('title') or entry['id'], + url=key, + quality=quality, + download_type=download_type, + codec=codec, + format=format, + folder=folder, + custom_name_prefix=custom_name_prefix, + error=error, + entry=entry, + playlist_item_limit=playlist_item_limit, + split_by_chapters=split_by_chapters, + chapter_template=chapter_template, + subtitle_language=subtitle_language, + subtitle_mode=subtitle_mode, + ytdl_options_presets=ytdl_options_presets, + ytdl_options_overrides=ytdl_options_overrides, + clip_start=clip_start, + clip_end=clip_end, + live_status=entry.get('live_status'), + live_release_timestamp=entry.get('release_timestamp'), + ) + await self.__add_download(dl, auto_start) return {'status': 'ok'} return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'} async def add( self, url, - quality, + download_type, + codec, format, + quality, folder, custom_name_prefix, playlist_item_limit, auto_start=True, split_by_chapters=False, chapter_template=None, - subtitle_format="srt", subtitle_language="en", subtitle_mode="prefer_manual", + ytdl_options_presets=None, + ytdl_options_overrides=None, + clip_start=None, + clip_end=None, already=None, _add_gen=None, ): + if ytdl_options_presets is None: + ytdl_options_presets = [] log.info( - f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=} ' + f'adding {url}: {download_type=} {codec=} {format=} {quality=} {already=} {folder=} {custom_name_prefix=} ' f'{playlist_item_limit=} {auto_start=} {split_by_chapters=} {chapter_template=} ' - f'{subtitle_format=} {subtitle_language=} {subtitle_mode=}' + f'{subtitle_language=} {subtitle_mode=} {ytdl_options_presets=} {clip_start=} {clip_end=}' ) if already is None: _add_gen = self._add_generation @@ -787,36 +1498,105 @@ class DownloadQueue: return {'status': 'ok'} else: already.add(url) + # SSRF guard: reject non-http(s) schemes and hosts resolving to + # internal/loopback/link-local/metadata addresses before yt-dlp fetches + # anything. run_in_executor because validate_url may perform a DNS lookup. + url_error = await asyncio.get_running_loop().run_in_executor(None, validate_url, url) + if url_error is not None: + log.warning('Rejected URL "%s": %s', url, url_error) + return {'status': 'error', 'msg': url_error} try: - entry = await asyncio.get_running_loop().run_in_executor(None, self.__extract_info, url) + entry = await asyncio.get_running_loop().run_in_executor( + None, + partial(self.__extract_info, url, ytdl_options_presets, ytdl_options_overrides), + ) except yt_dlp.utils.YoutubeDLError as exc: return {'status': 'error', 'msg': str(exc)} return await self.__add_entry( entry, - quality, + download_type, + codec, format, + quality, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, - subtitle_format, subtitle_language, subtitle_mode, + ytdl_options_presets, + ytdl_options_overrides, + clip_start, + clip_end, already, _add_gen, ) + async def add_entry( + self, + entry, + download_type, + codec, + format, + quality, + folder, + custom_name_prefix, + playlist_item_limit, + auto_start=True, + split_by_chapters=False, + chapter_template=None, + subtitle_language="en", + subtitle_mode="prefer_manual", + ytdl_options_presets=None, + ytdl_options_overrides=None, + clip_start=None, + clip_end=None, + ): + if ytdl_options_presets is None: + ytdl_options_presets = [] + normalized_entry = copy.deepcopy(entry) if isinstance(entry, dict) else entry + already = set() + return await self.__add_entry( + normalized_entry, + download_type, + codec, + format, + quality, + folder, + custom_name_prefix, + playlist_item_limit, + auto_start, + split_by_chapters, + chapter_template, + subtitle_language, + subtitle_mode, + ytdl_options_presets, + ytdl_options_overrides, + clip_start, + clip_end, + already, + None, + ) + async def start_pending(self, ids): for id in ids: - if not self.pending.exists(id): - log.warn(f'requested start for non-existent download {id}') + if self.pending.exists(id): + dl = self.pending.get(id) + self.pending.delete(id) + if getattr(dl.info, 'live_status', None) == 'is_upcoming': + self._schedule_upcoming_download(dl) + else: + self.queue.put(dl) + bg_tasks.create_task(self.__start_download(dl), name="start_download") continue - dl = self.pending.get(id) - self.queue.put(dl) - self.pending.delete(id) - asyncio.create_task(self.__start_download(dl)) + if self.queue.exists(id): + dl = self.queue.get(id) + if dl.info.status == 'scheduled': + self._force_start_scheduled(dl) + continue + log.warning(f'requested start for non-existent download {id}') return {'status': 'ok'} async def cancel(self, ids): @@ -828,11 +1608,15 @@ class DownloadQueue: await self.notifier.canceled(id) continue if not self.queue.exists(id): - log.warn(f'requested cancel for non-existent download {id}') + log.warning(f'requested cancel for non-existent download {id}') continue - if self.queue.get(id).started(): - self.queue.get(id).cancel() + dl = self.queue.get(id) + if dl.info.status == 'scheduled': + self._unregister_scheduled(id) + if dl.started(): + dl.cancel() else: + dl.canceled = True self.queue.delete(id) await self.notifier.canceled(id) return {'status': 'ok'} @@ -840,15 +1624,37 @@ class DownloadQueue: async def clear(self, ids): for id in ids: if not self.done.exists(id): - log.warn(f'requested delete for non-existent download {id}') + log.warning(f'requested delete for non-existent download {id}') continue if self.config.DELETE_FILE_ON_TRASHCAN: dl = self.done.get(id) - try: - dldirectory, _ = self.__calc_download_path(dl.info.quality, dl.info.format, dl.info.folder) - os.remove(os.path.join(dldirectory, dl.info.filename)) - except Exception as e: - log.warn(f'deleting file for download {id} failed with error message {e!r}') + dldirectory, calc_error = self.__calc_download_path(dl.info.download_type, dl.info.folder) + if calc_error is not None or not dldirectory: + log.warning(f'deleting files for download {id} skipped: could not resolve download directory') + else: + # Remove the primary output plus any per-chapter / per-subtitle + # outputs. Each filename is relative to the download directory. + rel_names = [] + if getattr(dl.info, 'filename', None): + rel_names.append(dl.info.filename) + for extra in (getattr(dl.info, 'chapter_files', None) or []): + if isinstance(extra, dict) and extra.get('filename'): + rel_names.append(extra['filename']) + for extra in (getattr(dl.info, 'subtitle_files', None) or []): + if isinstance(extra, dict) and extra.get('filename'): + rel_names.append(extra['filename']) + real_base_directory = os.path.realpath(dldirectory) + for rel_name in rel_names: + full_path = os.path.realpath(os.path.join(dldirectory, rel_name)) + if not _is_within_directory(real_base_directory, full_path): + log.warning(f'skipping deletion of "{rel_name}" for download {id}: resolves outside the download directory') + continue + try: + os.remove(full_path) + except FileNotFoundError: + pass + except OSError as e: + log.warning(f'deleting file "{rel_name}" for download {id} failed with error message {e!r}') self.done.delete(id) await self.notifier.cleared(id) return {'status': 'ok'} @@ -857,3 +1663,13 @@ class DownloadQueue: return (list((k, v.info) for k, v in self.queue.items()) + list((k, v.info) for k, v in self.pending.items()), list((k, v.info) for k, v in self.done.items())) + + def close(self): + # Kill any still-running download subprocesses (and their ffmpeg + # children) before tearing down the executor, so they aren't orphaned + # when the server exits. Their queue entries stay persisted and are + # re-imported/restarted on next startup. + for _key, download in list(self.queue.items()): + if download.started() and download.running(): + download.cancel() + self._download_executor.shutdown(wait=False, cancel_futures=True) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 5f07bc5..ad74e2f 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -2,11 +2,54 @@ PUID="${UID:-$PUID}" PGID="${GID:-$PGID}" +AUDIO_DOWNLOAD_DIR="${AUDIO_DOWNLOAD_DIR:-$DOWNLOAD_DIR}" echo "Setting umask to ${UMASK}" umask ${UMASK} -echo "Creating download directory (${DOWNLOAD_DIR}), state directory (${STATE_DIR}), and temp dir (${TEMP_DIR})" -mkdir -p "${DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}" +echo "Creating download directory (${DOWNLOAD_DIR}), audio download directory (${AUDIO_DOWNLOAD_DIR}), state directory (${STATE_DIR}), and temp dir (${TEMP_DIR})" +mkdir -p "${DOWNLOAD_DIR}" "${AUDIO_DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}" + +do_upgrade() { + echo "Upgrading yt-dlp to nightly channel..." + if ! python3 -m pip --version >/dev/null 2>&1; then + echo "pip not found; attempting ensurepip" + python3 -m ensurepip --upgrade >/dev/null 2>&1 || true + fi + if ! python3 -m pip install -U --pre "yt-dlp[default,curl-cffi,deno]"; then + echo "Warning: yt-dlp nightly upgrade failed; continuing with existing installation" + return 1 + fi + echo "yt-dlp nightly upgrade complete" + return 0 +} + +run_supervised() { + while true; do + "$@" & + child_pid=$! + trap 'kill -TERM "$child_pid" 2>/dev/null; wait "$child_pid" 2>/dev/null' TERM INT + wait "$child_pid" + exit_code=$? + trap - TERM INT + if [ "$exit_code" -eq 42 ]; then + echo "MeTube requested yt-dlp update restart (exit 42)" + do_upgrade || true + continue + fi + return "$exit_code" + done +} + +nightly_enabled() { + [ -n "${YTDL_NIGHTLY_UPDATE_TIME}" ] +} + +disable_nightly_for_non_root() { + if nightly_enabled; then + echo "YTDL_NIGHTLY_UPDATE_TIME is set but this container runs as a non-root user; nightly yt-dlp updates are not supported. Ignoring YTDL_NIGHTLY_UPDATE_TIME." + unset YTDL_NIGHTLY_UPDATE_TIME + fi +} if [ `id -u` -eq 0 ] && [ `id -g` -eq 0 ]; then if [ "${PUID}" -eq 0 ]; then @@ -14,15 +57,22 @@ if [ `id -u` -eq 0 ] && [ `id -g` -eq 0 ]; then fi if [ "${CHOWN_DIRS:-true}" != "false" ]; then echo "Changing ownership of download and state directories to ${PUID}:${PGID}" - chown -R "${PUID}":"${PGID}" /app "${DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}" + chown -R "${PUID}":"${PGID}" /app "${DOWNLOAD_DIR}" "${AUDIO_DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}" + fi + if nightly_enabled; then + echo "YTDL_NIGHTLY_UPDATE_TIME is set to ${YTDL_NIGHTLY_UPDATE_TIME}; upgrading yt-dlp on startup" + do_upgrade || true fi echo "Starting BgUtils POT Provider" gosu "${PUID}":"${PGID}" bgutil-pot server >/tmp/bgutil-pot.log 2>&1 & echo "Running MeTube as user ${PUID}:${PGID}" - exec gosu "${PUID}":"${PGID}" python3 app/main.py + run_supervised gosu "${PUID}":"${PGID}" python3 app/main.py + exit $? else echo "User set by docker; running MeTube as `id -u`:`id -g`" + disable_nightly_for_non_root echo "Starting BgUtils POT Provider" bgutil-pot server >/tmp/bgutil-pot.log 2>&1 & - exec python3 app/main.py + run_supervised python3 app/main.py + exit $? fi diff --git a/pyproject.toml b/pyproject.toml index 54779a4..710988b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,4 +15,13 @@ dependencies = [ [dependency-groups] dev = [ "pylint", + "pytest>=8.0", + "pytest-aiohttp>=1.0", + "pytest-asyncio>=0.24", ] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["app/tests"] +pythonpath = [".", "app"] +addopts = "-v" diff --git a/ui/angular.json b/ui/angular.json index 229711e..11245d1 100644 --- a/ui/angular.json +++ b/ui/angular.json @@ -33,9 +33,7 @@ "node_modules/@ng-select/ng-select/themes/default.theme.css", "src/styles.sass" ], - "scripts": [ - "node_modules/bootstrap/dist/js/bootstrap.bundle.min.js" - ], + "scripts": [], "serviceWorker": "ngsw-config.json", "browser": "src/main.ts", "polyfills": [ @@ -77,7 +75,8 @@ "buildTarget": "metube:build:production" }, "development": { - "buildTarget": "metube:build:development" + "buildTarget": "metube:build:development", + "proxyConfig": "proxy.conf.json" } }, "defaultConfiguration": "development" diff --git a/ui/package.json b/ui/package.json index 280616f..2750d7c 100644 --- a/ui/package.json +++ b/ui/package.json @@ -5,7 +5,7 @@ "ng": "ng", "start": "ng serve", "build": "ng build", - "build:watch": "ng build --watch", + "build:watch": "ng build --watch --configuration development", "test": "ng test", "lint": "ng lint" }, @@ -21,43 +21,44 @@ } ] }, + "packageManager": "pnpm@11.5.2", "private": true, "dependencies": { - "@angular/animations": "^21.2.1", - "@angular/common": "^21.2.1", - "@angular/compiler": "^21.2.1", - "@angular/core": "^21.2.1", - "@angular/forms": "^21.2.1", - "@angular/platform-browser": "^21.2.1", - "@angular/platform-browser-dynamic": "^21.2.1", - "@angular/service-worker": "^21.2.1", + "@angular/animations": "^22.0.6", + "@angular/common": "^22.0.6", + "@angular/compiler": "^22.0.6", + "@angular/core": "^22.0.6", + "@angular/forms": "^22.0.6", + "@angular/platform-browser": "^22.0.6", + "@angular/platform-browser-dynamic": "^22.0.6", + "@angular/service-worker": "^22.0.6", "@fortawesome/angular-fontawesome": "~4.0.0", - "@fortawesome/fontawesome-svg-core": "^7.2.0", - "@fortawesome/free-brands-svg-icons": "^7.2.0", - "@fortawesome/free-regular-svg-icons": "^7.2.0", - "@fortawesome/free-solid-svg-icons": "^7.2.0", - "@ng-bootstrap/ng-bootstrap": "^20.0.0", - "@ng-select/ng-select": "^21.5.2", + "@fortawesome/fontawesome-svg-core": "^7.3.1", + "@fortawesome/free-brands-svg-icons": "^7.3.1", + "@fortawesome/free-regular-svg-icons": "^7.3.1", + "@fortawesome/free-solid-svg-icons": "^7.3.1", + "@ng-bootstrap/ng-bootstrap": "^21.0.0", + "@ng-select/ng-select": "^23.2.0", "@popperjs/core": "^2.11.8", "bootstrap": "^5.3.8", - "ngx-cookie-service": "^21.1.0", + "ngx-cookie-service": "^22.0.0", "ngx-socket-io": "~4.10.0", "rxjs": "~7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.0" }, "devDependencies": { - "@angular-eslint/builder": "21.1.0", - "@angular/build": "^21.2.1", - "@angular/cli": "^21.2.1", - "@angular/compiler-cli": "^21.2.1", - "@angular/localize": "^21.2.1", - "@eslint/js": "^9.39.3", - "angular-eslint": "21.1.0", - "eslint": "^9.39.3", + "@angular-eslint/builder": "22.0.0", + "@angular/build": "^22.0.7", + "@angular/cli": "^22.0.7", + "@angular/compiler-cli": "^22.0.6", + "@angular/localize": "^22.0.6", + "@eslint/js": "^9.39.5", + "angular-eslint": "22.0.0", + "eslint": "^9.39.5", "jsdom": "^27.4.0", - "typescript": "~5.9.3", - "typescript-eslint": "8.47.0", - "vitest": "^4.0.18" + "typescript": "~6.0.3", + "typescript-eslint": "8.62.0", + "vitest": "^4.1.10" } } diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 0f8c51b..8e263c6 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -9,50 +9,50 @@ importers: .: dependencies: '@angular/animations': - specifier: ^21.2.1 - version: 21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)) + specifier: ^22.0.6 + version: 22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)) '@angular/common': - specifier: ^21.2.1 - version: 21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + specifier: ^22.0.6 + version: 22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) '@angular/compiler': - specifier: ^21.2.1 - version: 21.2.1 + specifier: ^22.0.6 + version: 22.0.6 '@angular/core': - specifier: ^21.2.1 - version: 21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0) + specifier: ^22.0.6 + version: 22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0) '@angular/forms': - specifier: ^21.2.1 - version: 21.2.1(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.2.1(@angular/animations@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2) + specifier: ^22.0.6 + version: 22.0.6(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@22.0.6(@angular/animations@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2) '@angular/platform-browser': - specifier: ^21.2.1 - version: 21.2.1(@angular/animations@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)) + specifier: ^22.0.6 + version: 22.0.6(@angular/animations@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)) '@angular/platform-browser-dynamic': - specifier: ^21.2.1 - version: 21.2.1(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/compiler@21.2.1)(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.2.1(@angular/animations@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))) + specifier: ^22.0.6 + version: 22.0.6(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/compiler@22.0.6)(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@22.0.6(@angular/animations@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))) '@angular/service-worker': - specifier: ^21.2.1 - version: 21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + specifier: ^22.0.6 + version: 22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) '@fortawesome/angular-fontawesome': specifier: ~4.0.0 - version: 4.0.0(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)) + version: 4.0.0(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)) '@fortawesome/fontawesome-svg-core': - specifier: ^7.2.0 - version: 7.2.0 + specifier: ^7.3.1 + version: 7.3.1 '@fortawesome/free-brands-svg-icons': - specifier: ^7.2.0 - version: 7.2.0 + specifier: ^7.3.1 + version: 7.3.1 '@fortawesome/free-regular-svg-icons': - specifier: ^7.2.0 - version: 7.2.0 + specifier: ^7.3.1 + version: 7.3.1 '@fortawesome/free-solid-svg-icons': - specifier: ^7.2.0 - version: 7.2.0 + specifier: ^7.3.1 + version: 7.3.1 '@ng-bootstrap/ng-bootstrap': - specifier: ^20.0.0 - version: 20.0.0(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/forms@21.2.1(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.2.1(@angular/animations@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2))(@angular/localize@21.2.1(@angular/compiler-cli@21.2.1(@angular/compiler@21.2.1)(typescript@5.9.3))(@angular/compiler@21.2.1))(@popperjs/core@2.11.8)(rxjs@7.8.2) + specifier: ^21.0.0 + version: 21.0.0(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/forms@22.0.6(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@22.0.6(@angular/animations@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2))(@angular/localize@22.0.6(@angular/compiler-cli@22.0.6(@angular/compiler@22.0.6)(typescript@6.0.3))(@angular/compiler@22.0.6))(@popperjs/core@2.11.8)(rxjs@7.8.2) '@ng-select/ng-select': - specifier: ^21.5.2 - version: 21.5.2(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/forms@21.2.1(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.2.1(@angular/animations@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2)) + specifier: ^23.2.0 + version: 23.2.0(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/forms@22.0.6(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@22.0.6(@angular/animations@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2)) '@popperjs/core': specifier: ^2.11.8 version: 2.11.8 @@ -60,11 +60,11 @@ importers: specifier: ^5.3.8 version: 5.3.8(@popperjs/core@2.11.8) ngx-cookie-service: - specifier: ^21.1.0 - version: 21.1.0(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)) + specifier: ^22.0.0 + version: 22.0.0(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)) ngx-socket-io: specifier: ~4.10.0 - version: 4.10.0(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + version: 4.10.0(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) rxjs: specifier: ~7.8.2 version: 7.8.2 @@ -76,194 +76,196 @@ importers: version: 0.15.0 devDependencies: '@angular-eslint/builder': - specifier: 21.1.0 - version: 21.1.0(@angular/cli@21.2.1(@types/node@25.3.5)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.3)(typescript@5.9.3) + specifier: 22.0.0 + version: 22.0.0(@angular/cli@22.0.7(@types/node@26.1.1)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.5)(typescript@6.0.3) '@angular/build': - specifier: ^21.2.1 - version: 21.2.1(@angular/compiler-cli@21.2.1(@angular/compiler@21.2.1)(typescript@5.9.3))(@angular/compiler@21.2.1)(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/localize@21.2.1(@angular/compiler-cli@21.2.1(@angular/compiler@21.2.1)(typescript@5.9.3))(@angular/compiler@21.2.1))(@angular/platform-browser@21.2.1(@angular/animations@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/service-worker@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@types/node@25.3.5)(chokidar@5.0.0)(postcss@8.5.8)(tslib@2.8.1)(typescript@5.9.3)(vitest@4.0.18(@types/node@25.3.5)(jsdom@27.4.0)(sass@1.97.3)) + specifier: ^22.0.7 + version: 22.0.7(@angular/compiler-cli@22.0.6(@angular/compiler@22.0.6)(typescript@6.0.3))(@angular/compiler@22.0.6)(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/localize@22.0.6(@angular/compiler-cli@22.0.6(@angular/compiler@22.0.6)(typescript@6.0.3))(@angular/compiler@22.0.6))(@angular/platform-browser@22.0.6(@angular/animations@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/service-worker@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@types/node@26.1.1)(chokidar@5.0.0)(istanbul-lib-instrument@6.0.3)(postcss@8.5.19)(tslib@2.8.1)(typescript@6.0.3)(vitest@4.1.10(@types/node@26.1.1)(jsdom@27.4.0)(vite@7.3.5(@types/node@26.1.1)(sass@1.99.0))) '@angular/cli': - specifier: ^21.2.1 - version: 21.2.1(@types/node@25.3.5)(chokidar@5.0.0) + specifier: ^22.0.7 + version: 22.0.7(@types/node@26.1.1)(chokidar@5.0.0) '@angular/compiler-cli': - specifier: ^21.2.1 - version: 21.2.1(@angular/compiler@21.2.1)(typescript@5.9.3) + specifier: ^22.0.6 + version: 22.0.6(@angular/compiler@22.0.6)(typescript@6.0.3) '@angular/localize': - specifier: ^21.2.1 - version: 21.2.1(@angular/compiler-cli@21.2.1(@angular/compiler@21.2.1)(typescript@5.9.3))(@angular/compiler@21.2.1) + specifier: ^22.0.6 + version: 22.0.6(@angular/compiler-cli@22.0.6(@angular/compiler@22.0.6)(typescript@6.0.3))(@angular/compiler@22.0.6) '@eslint/js': - specifier: ^9.39.3 - version: 9.39.3 + specifier: ^9.39.5 + version: 9.39.5 angular-eslint: - specifier: 21.1.0 - version: 21.1.0(@angular/cli@21.2.1(@types/node@25.3.5)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.3)(typescript-eslint@8.47.0(eslint@9.39.3)(typescript@5.9.3))(typescript@5.9.3) + specifier: 22.0.0 + version: 22.0.0(@angular/cli@22.0.7(@types/node@26.1.1)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.5)(typescript-eslint@8.62.0(eslint@9.39.5)(typescript@6.0.3))(typescript@6.0.3) eslint: - specifier: ^9.39.3 - version: 9.39.3 + specifier: ^9.39.5 + version: 9.39.5 jsdom: specifier: ^27.4.0 version: 27.4.0 typescript: - specifier: ~5.9.3 - version: 5.9.3 + specifier: ~6.0.3 + version: 6.0.3 typescript-eslint: - specifier: 8.47.0 - version: 8.47.0(eslint@9.39.3)(typescript@5.9.3) + specifier: 8.62.0 + version: 8.62.0(eslint@9.39.5)(typescript@6.0.3) vitest: - specifier: ^4.0.18 - version: 4.0.18(@types/node@25.3.5)(jsdom@27.4.0)(sass@1.97.3) + specifier: ^4.1.10 + version: 4.1.10(@types/node@26.1.1)(jsdom@27.4.0)(vite@7.3.5(@types/node@26.1.1)(sass@1.99.0)) packages: '@acemir/cssom@0.9.31': resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} - '@algolia/abtesting@1.14.1': - resolution: {integrity: sha512-Dkj0BgPiLAaim9sbQ97UKDFHJE/880wgStAM18U++NaJ/2Cws34J5731ovJifr6E3Pv4T2CqvMXf8qLCC417Ew==} + '@algolia/abtesting@1.18.0': + resolution: {integrity: sha512-8siuLG+FIns1AjZ/g2SDVwHz9S+ObacDQISEJvS8XsNei1zl3FXqfqQrBpmrG7ACWCyesXHbicMJtvRbg00FEw==} engines: {node: '>= 14.0.0'} - '@algolia/client-abtesting@5.48.1': - resolution: {integrity: sha512-LV5qCJdj+/m9I+Aj91o+glYszrzd7CX6NgKaYdTOj4+tUYfbS62pwYgUfZprYNayhkQpVFcrW8x8ZlIHpS23Vw==} + '@algolia/client-abtesting@5.52.0': + resolution: {integrity: sha512-wtwPgyPmO7b7sQPVgoK29c1VpfS08DnnJCmxX/oU1pV2DlMRJCzQcLN7JSloYpodyKHwM8+9wOzlAM0co3TDmA==} engines: {node: '>= 14.0.0'} - '@algolia/client-analytics@5.48.1': - resolution: {integrity: sha512-/AVoMqHhPm14CcHq7mwB+bUJbfCv+jrxlNvRjXAuO+TQa+V37N8k1b0ijaRBPdmSjULMd8KtJbQyUyabXOu6Kg==} + '@algolia/client-analytics@5.52.0': + resolution: {integrity: sha512-9KY36bRl4AH7RjqSeDDOKnjsz4IxQFBEOB8/fWmEbdQe+Isbs5jGzVJu9NEPQ1Tgwxlf8Uf07Swj3jZyMNUZ2g==} engines: {node: '>= 14.0.0'} - '@algolia/client-common@5.48.1': - resolution: {integrity: sha512-VXO+qu2Ep6ota28ktvBm3sG53wUHS2n7bgLWmce5jTskdlCD0/JrV4tnBm1l7qpla1CeoQb8D7ShFhad+UoSOw==} + '@algolia/client-common@5.52.0': + resolution: {integrity: sha512-3a/qM3dzJqqfTx7Yrw7uGQ98I3Q0rDfb4Vkv0wEzko96l7YQMxfBVz/VbLq2N+c59GweYv6Vhp8mPeqnWJSITw==} engines: {node: '>= 14.0.0'} - '@algolia/client-insights@5.48.1': - resolution: {integrity: sha512-zl+Qyb0nLg+Y5YvKp1Ij+u9OaPaKg2/EPzTwKNiVyOHnQJlFxmXyUZL1EInczAZsEY8hVpPCLtNfhMhfxluXKQ==} + '@algolia/client-insights@5.52.0': + resolution: {integrity: sha512-Rki7ACbMcvbQW0BuM84x9dkGHY47ABmv4jU6tYssat2k02p3mIUms2YOLUAMeknhmnFsj6lb6ZzOXdMWMyc1sA==} engines: {node: '>= 14.0.0'} - '@algolia/client-personalization@5.48.1': - resolution: {integrity: sha512-r89Qf9Oo9mKWQXumRu/1LtvVJAmEDpn8mHZMc485pRfQUMAwSSrsnaw1tQ3sszqzEgAr1c7rw6fjBI+zrAXTOw==} + '@algolia/client-personalization@5.52.0': + resolution: {integrity: sha512-96s4Uzc3kk+/f4jJXIVVGWP5XlngOGNQ1x6hW9AT59pOixHlOs5tqJg+ZUS/GQ6h/iYP0ceQcmxDQeLyCLTaDQ==} engines: {node: '>= 14.0.0'} - '@algolia/client-query-suggestions@5.48.1': - resolution: {integrity: sha512-TPKNPKfghKG/bMSc7mQYD9HxHRUkBZA4q1PEmHgICaSeHQscGqL4wBrKkhfPlDV1uYBKW02pbFMUhsOt7p4ZpA==} + '@algolia/client-query-suggestions@5.52.0': + resolution: {integrity: sha512-lqeycNpSPe5Qa0OUWpejVvYQjQWV5nQuLT0a4aq7XzRAvCxprV/6Lf841EygdD2nrFnuS58ok7Au1uOtXzpnkg==} engines: {node: '>= 14.0.0'} - '@algolia/client-search@5.48.1': - resolution: {integrity: sha512-4Fu7dnzQyQmMFknYwTiN/HxPbH4DyxvQ1m+IxpPp5oslOgz8m6PG5qhiGbqJzH4HiT1I58ecDiCAC716UyVA8Q==} + '@algolia/client-search@5.52.0': + resolution: {integrity: sha512-ly1wETVGRo30cx61O7fetESN+ElL9c9K+bD/AVgnT1ar4c6v+/Yqjrhdtu6Fm4D0s4NZP081Isf6tunH1wUXHg==} engines: {node: '>= 14.0.0'} - '@algolia/ingestion@1.48.1': - resolution: {integrity: sha512-/RFq3TqtXDUUawwic/A9xylA2P3LDMO8dNhphHAUOU51b1ZLHrmZ6YYJm3df1APz7xLY1aht6okCQf+/vmrV9w==} + '@algolia/ingestion@1.52.0': + resolution: {integrity: sha512-U4EeTvgmluRjj39ykZSAd5X+a6LD5m7/mcOWDmB7hqm1R6QY0yT8jLxpNVEjYhzgEN5hcDGW6X67EWQY8KiYGQ==} engines: {node: '>= 14.0.0'} - '@algolia/monitoring@1.48.1': - resolution: {integrity: sha512-Of0jTeAZRyRhC7XzDSjJef0aBkgRcvRAaw0ooYRlOw57APii7lZdq+layuNdeL72BRq1snaJhoMMwkmLIpJScw==} + '@algolia/monitoring@1.52.0': + resolution: {integrity: sha512-FCPnDcILfpTE94u7BVlV4DmnSV5wE3+j25EEF+3dYPrVzkVCSoAHs318oWDGxnxsAgiL4HpL12Jc4XHmw9shpA==} engines: {node: '>= 14.0.0'} - '@algolia/recommend@5.48.1': - resolution: {integrity: sha512-bE7JcpFXzxF5zHwj/vkl2eiCBvyR1zQ7aoUdO+GDXxGp0DGw7nI0p8Xj6u8VmRQ+RDuPcICFQcCwRIJT5tDJFw==} + '@algolia/recommend@5.52.0': + resolution: {integrity: sha512-br3DO7n4N8CXwTRbZS0MnB4WQ9YHfNjCwkCEzVR/wek/qNTDQKDb0nROmkFaNZ8ucUqUVKZi074dbwMwRDlK8Q==} engines: {node: '>= 14.0.0'} - '@algolia/requester-browser-xhr@5.48.1': - resolution: {integrity: sha512-MK3wZ2koLDnvH/AmqIF1EKbJlhRS5j74OZGkLpxI4rYvNi9Jn/C7vb5DytBnQ4KUWts7QsmbdwHkxY5txQHXVw==} + '@algolia/requester-browser-xhr@5.52.0': + resolution: {integrity: sha512-b0T/Ca2c9KyEslKsVrGZvbe1UrrKKSdfXhBZ2pbpKahFUzJfziRZ0urbOm7V65O0tO/jwU+Lo/+bIiiyhzGt8w==} engines: {node: '>= 14.0.0'} - '@algolia/requester-fetch@5.48.1': - resolution: {integrity: sha512-2oDT43Y5HWRSIQMPQI4tA/W+TN/N2tjggZCUsqQV440kxzzoPGsvv9QP1GhQ4CoDa+yn6ygUsGp6Dr+a9sPPSg==} + '@algolia/requester-fetch@5.52.0': + resolution: {integrity: sha512-ozBT8J/mtD4H4IAojw8QPirlcL2gHrI1BGuZ4/ZXXO/rTE1yQ4VIPJj4mTTbwo4FbkS1MoJsD/DsrqLzhnc4/g==} engines: {node: '>= 14.0.0'} - '@algolia/requester-node-http@5.48.1': - resolution: {integrity: sha512-xcaCqbhupVWhuBP1nwbk1XNvwrGljozutEiLx06mvqDf3o8cHyEgQSHS4fKJM+UAggaWVnnFW+Nne5aQ8SUJXg==} + '@algolia/requester-node-http@5.52.0': + resolution: {integrity: sha512-gyyWcLD22tnabmoit4iukCXuoRc5HYJuUjPSEa8a0D/f/NlRafpWi52AlAaa4Uu/rsl7saHsJFTNjTptWbu2+A==} engines: {node: '>= 14.0.0'} '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} - '@angular-devkit/architect@0.2102.1': - resolution: {integrity: sha512-x2Qqz6oLYvEh9UBUG0AP1A4zROO/VP+k+zM9+4c2uZw1uqoBQFmutqgzncjVU7cR9R0RApgx9JRZHDFtQru68w==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + '@angular-devkit/architect@0.2200.7': + resolution: {integrity: sha512-t03YYe5amlcpsX5gpMGi96kpBNTMKY7/rJsRDNO3v98mSy7kzNXlgzy/jyfdFev7AJusuwVM5TKVsUAgQxCgCw==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} hasBin: true - '@angular-devkit/core@21.2.1': - resolution: {integrity: sha512-TpXGjERqVPN8EPt7LdmWAwh0oNQ/6uWFutzGZiXhJy81n1zb1O1XrqhRAmvP1cAo5O+na6IV2JkkCmxL6F8GUg==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + '@angular-devkit/core@22.0.7': + resolution: {integrity: sha512-r8XiflsVYcsvWp+zVvaNv5GsDoihaZ2OwWfn++N6YqTUZLcqDzqhsxeNk60sJ5V+Jn4ck1aKF4A9flmvSY+tpQ==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} peerDependencies: chokidar: ^5.0.0 peerDependenciesMeta: chokidar: optional: true - '@angular-devkit/schematics@21.2.1': - resolution: {integrity: sha512-CWoamHaasAHMjHcYqxbj0tMnoXxdGotcAz2SpiuWtH28Lnf5xfbTaJn/lwdMP8Wdh4tgA+uYh2l45A5auCwmkw==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + '@angular-devkit/schematics@22.0.7': + resolution: {integrity: sha512-bKgnBB0LPAj44uVXfW0UO1rQBb3HGXDZxa1bLtESr/KCK4j5iiaXlHqvJjc/z0em1Ds9WNQOfNXjV3IdJo9sSw==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} - '@angular-eslint/builder@21.1.0': - resolution: {integrity: sha512-pcUlDkGqeZ+oQC0oEjnkDDlB96gbgHQhnBUKdhYAiAOSuiBod4+npP0xQOq5chYtRNPBprhDqgrJrp5DBeDMOA==} + '@angular-eslint/builder@22.0.0': + resolution: {integrity: sha512-T2vWQYUhJs6iUlgocHV12OgoxbmN63f17a+tgW+3sYrKN0KAB3xuHsPOoYpRYoWqkVVC44HD441Ju4IDvo8vKg==} peerDependencies: - '@angular/cli': '>= 21.0.0 < 22.0.0' - eslint: ^8.57.0 || ^9.0.0 + '@angular/cli': '>= 22.0.0 < 23.0.0' + eslint: ^9.0.0 || ^10.0.0 typescript: '*' - '@angular-eslint/bundled-angular-compiler@21.1.0': - resolution: {integrity: sha512-t52J6FszgEHaJ+IjuzU9qaWfVxsjlVNkAP+B5z2t4NDgbbDDsmI+QJh0OtP1qdlqzjh2pbocEml30KhYmNZm/Q==} + '@angular-eslint/bundled-angular-compiler@22.0.0': + resolution: {integrity: sha512-rv15vGDpGW8zZFaLdhQ+iIO1f0bZds/xvuxoX277hFisXp5Kt6FumJNNIb4g/qxq3xsY46a7fD6R7KvGY3smHg==} - '@angular-eslint/eslint-plugin-template@21.1.0': - resolution: {integrity: sha512-FlbRfOCn8IUHvP1ebcCSQFVNh+4X/HqZqL7SW5oj9WIYPiOX9ijS03ndNbfX/pBPSIi8GHLKMjLt8zIy1l5Lww==} + '@angular-eslint/eslint-plugin-template@22.0.0': + resolution: {integrity: sha512-y6XL5HJ8C31NpBvkVHpU3bWc+Rk9g1zRtHrs39omhuT29eEUcS3zu47HMFV6tf8rHOI97B2Mstg6qYS5XL9ATg==} peerDependencies: - '@angular-eslint/template-parser': 21.1.0 - '@typescript-eslint/types': ^7.11.0 || ^8.0.0 - '@typescript-eslint/utils': ^7.11.0 || ^8.0.0 - eslint: ^8.57.0 || ^9.0.0 + '@angular-eslint/template-parser': 22.0.0 + '@typescript-eslint/types': ^8.0.0 + '@typescript-eslint/utils': ^8.0.0 + eslint: ^9.0.0 || ^10.0.0 typescript: '*' - '@angular-eslint/eslint-plugin@21.1.0': - resolution: {integrity: sha512-oNp+4UzN2M3KwGwEw03NUdXz93vqJd9sMzTbGXWF9+KVfA2LjckGDTrI6g6asGcJMdyTo07rDcnw0m0MkLB5VA==} + '@angular-eslint/eslint-plugin@22.0.0': + resolution: {integrity: sha512-mKLScPZhqG64ic0KIQoxqSqCdkPwtEZuTOuunvc9lYTw05MJSHRUM2yVFODlCGq97c6BN1F6KBk2I+a+KFnr1g==} peerDependencies: - '@typescript-eslint/utils': ^7.11.0 || ^8.0.0 - eslint: ^8.57.0 || ^9.0.0 + '@typescript-eslint/utils': ^8.0.0 + eslint: ^9.0.0 || ^10.0.0 typescript: '*' - '@angular-eslint/schematics@21.1.0': - resolution: {integrity: sha512-Hal1mYwx4MTjCcNHqfIlua31xrk2tZJoyTiXiGQ21cAeK4sFuY+9V7/8cxbwJMGftX0G4J7uhx8woOdIFuqiZw==} + '@angular-eslint/schematics@22.0.0': + resolution: {integrity: sha512-gsJQx6c+WIWC5d+NAqn4rRdUzwhinUCTNmCM9x4wygV9DrbAfVG+6OFPEbaDMryNvf0HYDcnGclbIbXjukGCaw==} peerDependencies: - '@angular/cli': '>= 21.0.0 < 22.0.0' + '@angular/cli': '>= 22.0.0 < 23.0.0' - '@angular-eslint/template-parser@21.1.0': - resolution: {integrity: sha512-PYVgNbjNtuD5/QOuS6cHR8A7bRqsVqxtUUXGqdv76FYMAajQcAvyfR0QxOkqf3NmYxgNgO3hlUHWq0ILjVbcow==} + '@angular-eslint/template-parser@22.0.0': + resolution: {integrity: sha512-jU5MKQ24bBB4J99gSSexmUrLm2LvTJZCuCHhNTQ1LavWX4e1lrIxhm+6pJILOm6Cixf8jyNXnHMty6nljX8J+Q==} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 + eslint: ^9.0.0 || ^10.0.0 typescript: '*' - '@angular-eslint/utils@21.1.0': - resolution: {integrity: sha512-rWINgxGREu+NFUPCpAVsBGG8B4hfXxyswM0N5GbjykvsfB5W6PUix2Gsoh++iEsZPT+c9lvgXL5GbpwfanjOow==} + '@angular-eslint/utils@22.0.0': + resolution: {integrity: sha512-VFodMojghnPYm+B3U+HRYrqebPMj8NyobNjVzDdY8V5XIBW+4ivOSEINIz81G48rmm/NZKwj56+bJ88bVX4KIw==} peerDependencies: - '@typescript-eslint/utils': ^7.11.0 || ^8.0.0 - eslint: ^8.57.0 || ^9.0.0 + '@typescript-eslint/utils': ^8.0.0 + eslint: ^9.0.0 || ^10.0.0 typescript: '*' - '@angular/animations@21.2.1': - resolution: {integrity: sha512-zT/S29pUTbziCLvZ2itBdNWd5i8tsXexofH7KA4n2yvYmK1EhNpE7TlHRjghmsHgtDt4VnGiMW4zXEyrl05Dwg==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + '@angular/animations@22.0.6': + resolution: {integrity: sha512-5bBAuKtawVK4KPq6U/yoE5yCyKymEnXNN7UJ1Lb7PUuuYmLnUXb7zJpC7HwtHTuV/0PTCkGlLE59NtcrrolTuA==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} + deprecated: '@angular/animations is deprecated. Use `animate.enter` and `animate.leave` instead. For more information see: https://v22.angular.dev/guide/animations.' peerDependencies: - '@angular/core': 21.2.1 + '@angular/core': 22.0.6 - '@angular/build@21.2.1': - resolution: {integrity: sha512-cUpLNHJp9taII/FOcJHHfQYlMcZSRaf6eIxgSNS6Xfx1CeGoJNDN+J8+GFk+H1CPJt1EvbfyZ+dE5DbsgTD/QQ==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + '@angular/build@22.0.7': + resolution: {integrity: sha512-cm/QJmrIgsjzZJeBeqeNwgdgK6BGBKk7ca21jnSsrwlae3F9u1E8cImFbLeZSjEGghx4wFUacFDG+6lFEICntw==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} peerDependencies: - '@angular/compiler': ^21.0.0 - '@angular/compiler-cli': ^21.0.0 - '@angular/core': ^21.0.0 - '@angular/localize': ^21.0.0 - '@angular/platform-browser': ^21.0.0 - '@angular/platform-server': ^21.0.0 - '@angular/service-worker': ^21.0.0 - '@angular/ssr': ^21.2.1 + '@angular/compiler': ^22.0.0 + '@angular/compiler-cli': ^22.0.0 + '@angular/core': ^22.0.0 + '@angular/localize': ^22.0.0 + '@angular/platform-browser': ^22.0.0 + '@angular/platform-server': ^22.0.0 + '@angular/service-worker': ^22.0.0 + '@angular/ssr': ^22.0.7 + istanbul-lib-instrument: ^6.0.0 karma: ^6.4.0 less: ^4.2.0 - ng-packagr: ^21.0.0 + ng-packagr: ^22.0.0 postcss: ^8.4.0 tailwindcss: ^2.0.0 || ^3.0.0 || ^4.0.0 tslib: ^2.3.0 - typescript: '>=5.9 <6.0' + typescript: '>=6.0 <6.1' vitest: ^4.0.8 peerDependenciesMeta: '@angular/core': @@ -278,6 +280,8 @@ packages: optional: true '@angular/ssr': optional: true + istanbul-lib-instrument: + optional: true karma: optional: true less: @@ -291,38 +295,38 @@ packages: vitest: optional: true - '@angular/cli@21.2.1': - resolution: {integrity: sha512-5SRfMTgwFj1zXOpfeZWHsxZBni0J4Xz7/CbewG47D6DmbstOrSdgt6eNzJ62R650t0G9dpri2YvToZgImtbjOQ==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + '@angular/cli@22.0.7': + resolution: {integrity: sha512-5r+fgP8ARnXZeylAGt5BuToVcR8Vn2QQZ6dPMe7V9o4NdvJqqOKmE7fEiRe7KaxIx5WSDhuAhgW9t+scLZQbvw==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} hasBin: true - '@angular/common@21.2.1': - resolution: {integrity: sha512-xhv2i1Q9s1kpGbGsfj+o36+XUC/TQLcZyRuRxn3GwaN7Rv34FabC88ycpvoE+sW/txj4JRx9yPA0dRSZjwZ+Gg==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + '@angular/common@22.0.6': + resolution: {integrity: sha512-+sC0W0FWGeZ/HKUhueCBX8kXdFQDlStbUSK6WF/32igfNYR/PdUrp2L5bU7q654yjxnMzFWDAB+Uvzv6YI/cQg==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} peerDependencies: - '@angular/core': 21.2.1 + '@angular/core': 22.0.6 rxjs: ^6.5.3 || ^7.4.0 - '@angular/compiler-cli@21.2.1': - resolution: {integrity: sha512-qYCWLGtEju4cDtYLi4ZzbwKoF0lcGs+Lc31kuESvAzYvWNgk2EUOtwWo8kbgpAzAwSYodtxW6Q90iWEwfU6elw==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + '@angular/compiler-cli@22.0.6': + resolution: {integrity: sha512-HPiceCIEyIcTmOuIFiyqu04kbLoJhSUavcOy/kzJkJ2OPXtPvGKaimC4lgs0C86uFy4P6rj6PDITBe3AVHkMTA==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} hasBin: true peerDependencies: - '@angular/compiler': 21.2.1 - typescript: '>=5.9 <6.1' + '@angular/compiler': 22.0.6 + typescript: '>=6.0 <6.1' peerDependenciesMeta: typescript: optional: true - '@angular/compiler@21.2.1': - resolution: {integrity: sha512-FxWaSaii1vfHIFA+JksqQ8NGB2frfqCrs7Ju50a44kbwR4fmanfn/VsiS/CbwBp9vcyT/Br9X/jAG4RuK/U2nw==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + '@angular/compiler@22.0.6': + resolution: {integrity: sha512-iFHsXOA14TsxK4jowmGfogI9Jb0ApL3z8EGTGvKMialqtw088HYjwJP1LT4OlO/wgmjSTq2peZ19jLPuhevNvw==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} - '@angular/core@21.2.1': - resolution: {integrity: sha512-pFTbg03s2ZI5cHNT+eWsGjwIIKiYkeAnodFbCAHjwFi9KCEYlTykFLjr9lcpGrBddfmAH7GE08Q73vgmsdcNHw==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + '@angular/core@22.0.6': + resolution: {integrity: sha512-9AYGxmZlJhjLSepZaF/cn1LTC9xRHpUPtXlyyJdauTb4f77X/P0kVdmADztgF1Qy5oMd1V9UKebAe81gmqOH2g==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} peerDependencies: - '@angular/compiler': 21.2.1 + '@angular/compiler': 22.0.6 rxjs: ^6.5.3 || ^7.4.0 zone.js: ~0.15.0 || ~0.16.0 peerDependenciesMeta: @@ -331,49 +335,50 @@ packages: zone.js: optional: true - '@angular/forms@21.2.1': - resolution: {integrity: sha512-6aqOPk9xoa0dfeUDeEbhaiPhmt6MQrdn59qbGAomn9RMXA925TrHbJhSIkp9tXc2Fr4aJRi8zkD/cdXEc1IYeA==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + '@angular/forms@22.0.6': + resolution: {integrity: sha512-pZGMk1TvT+m8z2ZGm90W0sBlo6fhtMAA2baZbMt3HbGRmY23vZv1Orv9npX3Z6a9CCNDY3XBMBvkuj8g7vde9Q==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} peerDependencies: - '@angular/common': 21.2.1 - '@angular/core': 21.2.1 - '@angular/platform-browser': 21.2.1 + '@angular/common': 22.0.6 + '@angular/core': 22.0.6 + '@angular/platform-browser': 22.0.6 rxjs: ^6.5.3 || ^7.4.0 - '@angular/localize@21.2.1': - resolution: {integrity: sha512-2QsN33fLO3N/RRFfUxDKHMX/Y/2TH90Tx51Wi6hi1do9IJdlfEe1qBw+5F0g1F1CuFEYgZWMJdZIK7LPHpuDzw==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + '@angular/localize@22.0.6': + resolution: {integrity: sha512-A4z/DD5IqqqiFUzb8tEStRX0zFu+ZvydVhLMOb9Zjun+n3h8hclEjIlK2fa1Gg/kuLy7DN8At2HOHz1BKCNHlA==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} hasBin: true peerDependencies: - '@angular/compiler': 21.2.1 - '@angular/compiler-cli': 21.2.1 + '@angular/compiler': 22.0.6 + '@angular/compiler-cli': 22.0.6 - '@angular/platform-browser-dynamic@21.2.1': - resolution: {integrity: sha512-J4KnrXjgSuk7KjEm79/RK1yyzR867sIyT5mcG6jx2KmkjspFJd4OeOux7Oj7lSBM7+nDEsKC9F6s0x3dC0hCPQ==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + '@angular/platform-browser-dynamic@22.0.6': + resolution: {integrity: sha512-VKf8mg+BviBp3wlElZYxWFuXtMLQqd8rfm6jEzwP1Emaz2MCx69Y54dXopD81KJCqRwTmJUVxOjoubqUOlJzRw==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} + deprecated: '@angular/platform-browser-dynamic is deprecated. Use `@angular/platform-browser` instead.' peerDependencies: - '@angular/common': 21.2.1 - '@angular/compiler': 21.2.1 - '@angular/core': 21.2.1 - '@angular/platform-browser': 21.2.1 + '@angular/common': 22.0.6 + '@angular/compiler': 22.0.6 + '@angular/core': 22.0.6 + '@angular/platform-browser': 22.0.6 - '@angular/platform-browser@21.2.1': - resolution: {integrity: sha512-k4SJLxIaLT26vLjLuFL+ho0BiG5PrdxEsjsXFC7w5iUhomeouzkHVTZ4t7gaLNKrdRD7QNtU4Faw0nL0yx0ZPQ==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + '@angular/platform-browser@22.0.6': + resolution: {integrity: sha512-DyyYRx4jXlfaenf0mwsp9jcIak/c17QCFgJKA7OauSkQnk3dL+voMYEwdigfnTIUdw+2Y9wYDywLeuM6NychNQ==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} peerDependencies: - '@angular/animations': 21.2.1 - '@angular/common': 21.2.1 - '@angular/core': 21.2.1 + '@angular/animations': 22.0.6 + '@angular/common': 22.0.6 + '@angular/core': 22.0.6 peerDependenciesMeta: '@angular/animations': optional: true - '@angular/service-worker@21.2.1': - resolution: {integrity: sha512-mFyEVh5KazB6wr9uoXhlDQQDaicH9/t2m6lsN+/t2y6iMPpTIuTbWYHXX1uVbLKcxne54ei78NgD3wNS7DMfmg==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + '@angular/service-worker@22.0.6': + resolution: {integrity: sha512-s0zE2MFVptHNd21c5sD2VXd9Dp5x6cgyDjIePKjRsfiILegX3i6pAj9xeZ6y160dsrLVmkHST+uY+oxOVMVUhg==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} hasBin: true peerDependencies: - '@angular/core': 21.2.1 + '@angular/core': 22.0.6 rxjs: ^6.5.3 || ^7.4.0 '@asamuzakjp/css-color@4.1.2': @@ -385,40 +390,40 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} engines: {node: '>=6.9.0'} - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} engines: {node: '>=6.9.0'} - '@babel/helper-annotate-as-pure@7.27.3': - resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + '@babel/helper-annotate-as-pure@7.29.7': + resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} engines: {node: '>=6.9.0'} - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 @@ -427,52 +432,52 @@ packages: resolution: {integrity: sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.28.6': - resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.0': - resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} engines: {node: '>=6.0.0'} hasBin: true - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} - '@csstools/color-helpers@6.0.2': - resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} engines: {node: '>=20.19.0'} - '@csstools/css-calc@3.1.1': - resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} engines: {node: '>=20.19.0'} peerDependencies: '@csstools/css-parser-algorithms': ^4.0.0 '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-color-parser@4.0.2': - resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==} + '@csstools/css-color-parser@4.1.9': + resolution: {integrity: sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==} engines: {node: '>=20.19.0'} peerDependencies: '@csstools/css-parser-algorithms': ^4.0.0 @@ -484,174 +489,326 @@ packages: peerDependencies: '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-syntax-patches-for-csstree@1.1.0': - resolution: {integrity: sha512-H4tuz2nhWgNKLt1inYpoVCfbJbMwX/lQKp3g69rrrIMIYlFD9+zTykOKhNR8uGrAmbS/kT9n6hTFkmDkxLgeTA==} + '@csstools/css-syntax-patches-for-csstree@1.1.6': + resolution: {integrity: sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==} + 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.8.1': - resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} - - '@emnapi/runtime@1.8.1': - resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} - - '@emnapi/wasi-threads@1.1.0': - resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} - - '@esbuild/aix-ppc64@0.27.3': - resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.27.3': - resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.27.3': - resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.27.3': - resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.27.3': - resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.27.3': - resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.27.3': - resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.3': - resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.27.3': - resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.27.3': - resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.27.3': - resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.27.3': - resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.27.3': - resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.27.3': - resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.27.3': - resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.27.3': - resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.27.3': - resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.27.3': - resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.3': - resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.27.3': - resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.3': - resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.27.3': - resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.27.3': - resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.27.3': - resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.27.3': - resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.27.3': - resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -666,8 +823,8 @@ packages: resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/config-array@0.21.1': - resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/config-helpers@0.4.2': @@ -678,12 +835,12 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.4': - resolution: {integrity: sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==} + '@eslint/eslintrc@3.3.6': + resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.39.3': - resolution: {integrity: sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==} + '@eslint/js@9.39.5': + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@2.1.7': @@ -694,8 +851,8 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@exodus/bytes@1.15.0': - resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + '@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 @@ -708,45 +865,49 @@ packages: peerDependencies: '@angular/core': ^21.0.0 - '@fortawesome/fontawesome-common-types@7.2.0': - resolution: {integrity: sha512-IpR0bER9FY25p+e7BmFH25MZKEwFHTfRAfhOyJubgiDnoJNsSvJ7nigLraHtp4VOG/cy8D7uiV0dLkHOne5Fhw==} + '@fortawesome/fontawesome-common-types@7.3.1': + resolution: {integrity: sha512-k0C0sdHmZtAo6dRDtd1Z/qcpyHbL0CKsjV8seMY/21xGhY5Wsv0XRmiI/xEEH4y2c9b1+jvgNs/3EqhV27yUEA==} engines: {node: '>=6'} - '@fortawesome/fontawesome-svg-core@7.2.0': - resolution: {integrity: sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q==} + '@fortawesome/fontawesome-svg-core@7.3.1': + resolution: {integrity: sha512-BoxVN3PKnMbgStHhjoaky/oWdxHomDqmBVA24IA3KEmssFGeI7u9YT/BJceOjIum/t6TpPa/vcMKaVYQeIQ/3Q==} engines: {node: '>=6'} - '@fortawesome/free-brands-svg-icons@7.2.0': - resolution: {integrity: sha512-VNG8xqOip1JuJcC3zsVsKRQ60oXG9+oYNDCosjoU/H9pgYmLTEwWw8pE0jhPz/JWdHeUuK6+NQ3qsM4gIbdbYQ==} + '@fortawesome/free-brands-svg-icons@7.3.1': + resolution: {integrity: sha512-8eiNzycuFhy3mZsi5EVO8JpO3H8l7/kSCl4Gk4iUew9hJuzYFqAAD6kqgeEup3+YZ9hL+5cjjorGzrRLkYuURg==} engines: {node: '>=6'} - '@fortawesome/free-regular-svg-icons@7.2.0': - resolution: {integrity: sha512-iycmlN51EULlQ4D/UU9WZnHiN0CvjJ2TuuCrAh+1MVdzD+4ViKYH2deNAll4XAAYlZa8WAefHR5taSK8hYmSMw==} + '@fortawesome/free-regular-svg-icons@7.3.1': + resolution: {integrity: sha512-q1EsmL7Q8DDnkRBUjSvrxbq7c9oVwwVjCn/xa5apKmdp65YSgzUg2y0Ltnd5aDbT6GdAQQdXql5Ha90ArqIReQ==} engines: {node: '>=6'} - '@fortawesome/free-solid-svg-icons@7.2.0': - resolution: {integrity: sha512-YTVITFGN0/24PxzXrwqCgnyd7njDuzp5ZvaCx5nq/jg55kUYd94Nj8UTchBdBofi/L0nwRfjGOg0E41d2u9T1w==} + '@fortawesome/free-solid-svg-icons@7.3.1': + resolution: {integrity: sha512-v0BLa0eqg7ubvVWeNSHVBs8fWH/GJicERZoJaxJ3FE/lj67VSqzoMg9pzfZVOfLMX10y0pGwQAuxoRVVH2patg==} engines: {node: '>=6'} - '@gar/promise-retry@1.0.2': - resolution: {integrity: sha512-Lm/ZLhDZcBECta3TmCQSngiQykFdfw+QtI1/GYMsZd4l3nG+P8WLB16XuS7WaBGLQ+9E+cOcWQsth9cayuGt8g==} + '@gar/promise-retry@1.0.3': + resolution: {integrity: sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==} engines: {node: ^20.17.0 || >=22.9.0} '@harperfast/extended-iterable@1.0.3': resolution: {integrity: sha512-sSAYhQca3rDWtQUHSAPeO7axFIUJOI6hn1gjRC5APVE1a90tuyT8f5WIgRsFhhWA7htNkju2veB9eWL6YHi/Lw==} - '@hono/node-server@1.19.11': - resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} peerDependencies: hono: ^4 - '@humanfs/core@0.19.1': - resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} - '@humanfs/node@0.16.7': - resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} engines: {node: '>=18.18.0'} '@humanwhocodes/module-importer@1.0.1': @@ -757,134 +918,143 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@inquirer/ansi@1.0.2': - resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} - engines: {node: '>=18'} + '@inquirer/ansi@2.0.7': + resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - '@inquirer/checkbox@4.3.2': - resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} - engines: {node: '>=18'} + '@inquirer/checkbox@5.2.1': + resolution: {integrity: sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/confirm@5.1.21': - resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} - engines: {node: '>=18'} + '@inquirer/confirm@6.0.12': + resolution: {integrity: sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/core@10.3.2': - resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} - engines: {node: '>=18'} + '@inquirer/confirm@6.1.1': + resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/editor@4.2.23': - resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} - engines: {node: '>=18'} + '@inquirer/core@11.2.1': + resolution: {integrity: sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/expand@4.0.23': - resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} - engines: {node: '>=18'} + '@inquirer/editor@5.2.2': + resolution: {integrity: sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/external-editor@1.0.3': - resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} - engines: {node: '>=18'} + '@inquirer/expand@5.1.1': + resolution: {integrity: sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/figures@1.0.15': - resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} - engines: {node: '>=18'} - - '@inquirer/input@4.3.1': - resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} - engines: {node: '>=18'} + '@inquirer/external-editor@3.0.3': + resolution: {integrity: sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/number@3.0.23': - resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} - engines: {node: '>=18'} + '@inquirer/figures@2.0.7': + resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/input@5.1.2': + resolution: {integrity: sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/password@4.0.23': - resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} - engines: {node: '>=18'} + '@inquirer/number@4.1.1': + resolution: {integrity: sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/prompts@7.10.1': - resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} - engines: {node: '>=18'} + '@inquirer/password@5.1.1': + resolution: {integrity: sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/rawlist@4.1.11': - resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} - engines: {node: '>=18'} + '@inquirer/prompts@8.4.2': + resolution: {integrity: sha512-XJmn/wY4AX56l1BRU+ZjDrFtg9+2uBEi4JvJQj82kwJDQKiPgSn4CEsbfGGygS4Gw6rkL4W18oATjfVfaqub2Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/search@3.2.2': - resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} - engines: {node: '>=18'} + '@inquirer/rawlist@5.3.1': + resolution: {integrity: sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/select@4.4.2': - resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} - engines: {node: '>=18'} + '@inquirer/search@4.2.1': + resolution: {integrity: sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/type@3.0.10': - resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} - engines: {node: '>=18'} + '@inquirer/select@5.2.1': + resolution: {integrity: sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@4.0.7': + resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: @@ -895,8 +1065,8 @@ packages: resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} - '@istanbuljs/schema@0.1.3': - resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} engines: {node: '>=8'} '@jridgewell/gen-mapping@0.3.13': @@ -915,50 +1085,50 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@listr2/prompt-adapter-inquirer@3.0.5': - resolution: {integrity: sha512-WELs+hj6xcilkloBXYf9XXK8tYEnKsgLj01Xl5ONUJpKjmT5hGVUzNUS5tooUxs7pGMrw+jFD/41WpqW4V3LDA==} - engines: {node: '>=20.0.0'} + '@listr2/prompt-adapter-inquirer@4.2.3': + resolution: {integrity: sha512-Co9U3AJ3LW0J8XBHjVoNKA79dMAyFt8EZH3OaKTMcDTj8r+6kG3vSUPq/eGLHT7P0iK3uLaFfhdFYd3033P24g==} + engines: {node: '>=22.13.0'} peerDependencies: - '@inquirer/prompts': '>= 3 < 8' - listr2: 9.0.5 + '@inquirer/prompts': '>= 3 < 9' + listr2: 10.2.1 - '@lmdb/lmdb-darwin-arm64@3.5.1': - resolution: {integrity: sha512-tpfN4kKrrMpQ+If1l8bhmoNkECJi0iOu6AEdrTJvWVC+32sLxTARX5Rsu579mPImRP9YFWfWgeRQ5oav7zApQQ==} + '@lmdb/lmdb-darwin-arm64@3.5.4': + resolution: {integrity: sha512-Kk4Kz3iyu1QiLsLZBS9Af1eSKUC8VR2T+/jyE2iAyuGw2VwK08pp5iTbZnXn6sWu0LogO/RFktMxOjiDA2sS3w==} cpu: [arm64] os: [darwin] - '@lmdb/lmdb-darwin-x64@3.5.1': - resolution: {integrity: sha512-+a2tTfc3rmWhLAolFUWRgJtpSuu+Fw/yjn4rF406NMxhfjbMuiOUTDRvRlMFV+DzyjkwnokisskHbCWkS3Ly5w==} + '@lmdb/lmdb-darwin-x64@3.5.4': + resolution: {integrity: sha512-BEe5Rp3trn26oxoXOVL5HVDoiYmjUDwr8NRPkBOdUdCSBEorKI+7JrZLRKAdxO+G6cGQLgseXk0gR7qIQa7aGw==} cpu: [x64] os: [darwin] - '@lmdb/lmdb-linux-arm64@3.5.1': - resolution: {integrity: sha512-aoERa5B6ywXdyFeYGQ1gbQpkMkDbEo45qVoXE5QpIRavqjnyPwjOulMkmkypkmsbJ5z4Wi0TBztON8agCTG0Vg==} + '@lmdb/lmdb-linux-arm64@3.5.4': + resolution: {integrity: sha512-cUXEengO8o60v1SWerJTH4/RH4U3+9jC0/4njp2Z9NdmvaGzhKsbRM2wpXuRYrN8tytsoJCg0SvWEWwHAwLbCA==} cpu: [arm64] os: [linux] - '@lmdb/lmdb-linux-arm@3.5.1': - resolution: {integrity: sha512-0EgcE6reYr8InjD7V37EgXcYrloqpxVPINy3ig1MwDSbl6LF/vXTYRH9OE1Ti1D8YZnB35ZH9aTcdfSb5lql2A==} + '@lmdb/lmdb-linux-arm@3.5.4': + resolution: {integrity: sha512-SGbFR7816uBcTHc2ZY4S6WyOkl9bICnzqTQd2Mv4V/j24cfds88xx2nC6cm/y8zGQL7Ds31YF/5NGxjgcdM5Hw==} cpu: [arm] os: [linux] - '@lmdb/lmdb-linux-x64@3.5.1': - resolution: {integrity: sha512-SqNDY1+vpji7bh0sFH5wlWyFTOzjbDOl0/kB5RLLYDAFyd/uw3n7wyrmas3rYPpAW7z18lMOi1yKlTPv967E3g==} + '@lmdb/lmdb-linux-x64@3.5.4': + resolution: {integrity: sha512-Gxq8jpgOWXwd0PUR+c9R2Ik1/uBnGd5GMIIzRRDqABCkvmjtC3KWcyhesV9jSPCz759isl0NlbsstZ2oyvk8lA==} cpu: [x64] os: [linux] - '@lmdb/lmdb-win32-arm64@3.5.1': - resolution: {integrity: sha512-50v0O1Lt37cwrmR9vWZK5hRW0Aw+KEmxJJ75fge/zIYdvNKB/0bSMSVR5Uc2OV9JhosIUyklOmrEvavwNJ8D6w==} + '@lmdb/lmdb-win32-arm64@3.5.4': + resolution: {integrity: sha512-pKv1DJ1bPZAaHkdFsSz5IDfUG8x9vntgquXF9/Dm2xuupcIe/EkLzylpoBxppFVK5vzbV561Dq26jNY2fIMA7g==} cpu: [arm64] os: [win32] - '@lmdb/lmdb-win32-x64@3.5.1': - resolution: {integrity: sha512-qwosvPyl+zpUlp3gRb7UcJ3H8S28XHCzkv0Y0EgQToXjQP91ZD67EHSCDmaLjtKhe+GVIW5om1KUpzVLA0l6pg==} + '@lmdb/lmdb-win32-x64@3.5.4': + resolution: {integrity: sha512-JF1BmLCm9kGEVZgYmJq43zeQVdHVgAJnTi/NURWEsy6L1ZrrlSmdltS+D17QN4LODwf+1LMXAA9auIZVXtWwzw==} cpu: [x64] os: [win32] - '@modelcontextprotocol/sdk@1.26.0': - resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -967,33 +1137,33 @@ packages: '@cfworker/json-schema': optional: true - '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': - resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} cpu: [arm64] os: [darwin] - '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': - resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} cpu: [x64] os: [darwin] - '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': - resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} cpu: [arm64] os: [linux] - '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': - resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} cpu: [arm] os: [linux] - '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': - resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} cpu: [x64] os: [linux] - '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': - resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} cpu: [x64] os: [win32] @@ -1110,41 +1280,26 @@ packages: resolution: {integrity: sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==} engines: {node: '>= 10'} - '@napi-rs/wasm-runtime@1.1.1': - resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} - - '@ng-bootstrap/ng-bootstrap@20.0.0': - resolution: {integrity: sha512-Jt+GUQ0PdM8VsOUUVr7vTQXhwcGwe2DCe1mmfS21vz9pLSOtGRz41ohZKc1egUevj5Rxm2sHVq5Sve68/nTMfA==} + '@ng-bootstrap/ng-bootstrap@21.0.0': + resolution: {integrity: sha512-H7lGYGQGERoX5PeU1nf99U8tvPBJwQod6G99NoaJYwhBtKDoA/W0auUJqcbPMOGnO3gHtjtnOCji49tRbUIbPw==} peerDependencies: - '@angular/common': ^21.0.0 - '@angular/core': ^21.0.0 - '@angular/forms': ^21.0.0 - '@angular/localize': ^21.0.0 + '@angular/common': ^22.0.0 + '@angular/core': ^22.0.0 + '@angular/forms': ^22.0.0 + '@angular/localize': ^22.0.0 '@popperjs/core': ^2.11.8 rxjs: ^6.5.3 || ^7.4.0 - '@ng-select/ng-select@21.5.2': - resolution: {integrity: sha512-QFfZ00Z/UfZikK3BCyl4rWpZGd7W5YE2ScTbRcC2jH6QN8E1pNgxH1bBS05p2YEYZIA6RZ5/HcJguj3ipPy5Jw==} - engines: {node: ^20.19.0 || ^22.12.0 || ^24.0.0} + '@ng-select/ng-select@23.2.0': + resolution: {integrity: sha512-AM1/60UnZmowGvehrr4f8Ff0PJlK5doQh1LH6rTche+DhquaSkG2WBEWFfyK0ujIXNdjeWx+9yf2RcFSZWZ+Wg==} + engines: {node: ^22.12.0 || ^24.0.0 || ^26.0.0} peerDependencies: - '@angular/common': ^21.0.0 - '@angular/core': ^21.0.0 - '@angular/forms': ^21.0.0 + '@angular/common': ^22.0.0 + '@angular/core': ^22.0.0 + '@angular/forms': ^22.0.0 - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - - '@npmcli/agent@4.0.0': - resolution: {integrity: sha512-kAQTcEN9E8ERLVg5AsGwLNoFb+oEG6engbqAU2P43gD4JEIkNGMHdVQ096FsOAAYpZPB0RSt0zgInKIAS1l5QA==} + '@npmcli/agent@4.0.2': + resolution: {integrity: sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg==} engines: {node: ^20.17.0 || >=22.9.0} '@npmcli/fs@5.0.0': @@ -1180,9 +1335,6 @@ packages: resolution: {integrity: sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg==} engines: {node: ^20.17.0 || >=22.9.0} - '@oxc-project/types@0.113.0': - resolution: {integrity: sha512-Tp3XmgxwNQ9pEN9vxgJBAqdRamHibi76iowQ38O2I4PMpcvNRQNVsU2n1x1nv9yh0XoTrGFzf7cZSGxmixxrhA==} - '@parcel/watcher-android-arm64@2.5.6': resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} engines: {node: '>= 10.0.0'} @@ -1274,254 +1426,170 @@ packages: '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} - '@rolldown/binding-android-arm64@1.0.0-rc.4': - resolution: {integrity: sha512-vRq9f4NzvbdZavhQbjkJBx7rRebDKYR9zHfO/Wg486+I7bSecdUapzCm5cyXoK+LHokTxgSq7A5baAXUZkIz0w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@rolldown/binding-darwin-arm64@1.0.0-rc.4': - resolution: {integrity: sha512-kFgEvkWLqt3YCgKB5re9RlIrx9bRsvyVUnaTakEpOPuLGzLpLapYxE9BufJNvPg8GjT6mB1alN4yN1NjzoeM8Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@rolldown/binding-darwin-x64@1.0.0-rc.4': - resolution: {integrity: sha512-JXmaOJGsL/+rsmMfutcDjxWM2fTaVgCHGoXS7nE8Z3c9NAYjGqHvXrAhMUZvMpHS/k7Mg+X7n/MVKb7NYWKKww==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@rolldown/binding-freebsd-x64@1.0.0-rc.4': - resolution: {integrity: sha512-ep3Catd6sPnHTM0P4hNEvIv5arnDvk01PfyJIJ+J3wVCG1eEaPo09tvFqdtcaTrkwQy0VWR24uz+cb4IsK53Qw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.4': - resolution: {integrity: sha512-LwA5ayKIpnsgXJEwWc3h8wPiS33NMIHd9BhsV92T8VetVAbGe2qXlJwNVDGHN5cOQ22R9uYvbrQir2AB+ntT2w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.4': - resolution: {integrity: sha512-AC1WsGdlV1MtGay/OQ4J9T7GRadVnpYRzTcygV1hKnypbYN20Yh4t6O1Sa2qRBMqv1etulUknqXjc3CTIsBu6A==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.4': - resolution: {integrity: sha512-lU+6rgXXViO61B4EudxtVMXSOfiZONR29Sys5VGSetUY7X8mg9FCKIIjcPPj8xNDeYzKl+H8F/qSKOBVFJChCQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.4': - resolution: {integrity: sha512-DZaN1f0PGp/bSvKhtw50pPsnln4T13ycDq1FrDWRiHmWt1JeW+UtYg9touPFf8yt993p8tS2QjybpzKNTxYEwg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-x64-musl@1.0.0-rc.4': - resolution: {integrity: sha512-RnGxwZLN7fhMMAItnD6dZ7lvy+TI7ba+2V54UF4dhaWa/p8I/ys1E73KO6HmPmgz92ZkfD8TXS1IMV8+uhbR9g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rolldown/binding-openharmony-arm64@1.0.0-rc.4': - resolution: {integrity: sha512-6lcI79+X8klGiGd8yHuTgQRjuuJYNggmEml+RsyN596P23l/zf9FVmJ7K0KVKkFAeYEdg0iMUKyIxiV5vebDNQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@rolldown/binding-wasm32-wasi@1.0.0-rc.4': - resolution: {integrity: sha512-wz7ohsKCAIWy91blZ/1FlpPdqrsm1xpcEOQVveWoL6+aSPKL4VUcoYmmzuLTssyZxRpEwzuIxL/GDsvpjaBtOw==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.4': - resolution: {integrity: sha512-cfiMrfuWCIgsFmcVG0IPuO6qTRHvF7NuG3wngX1RZzc6dU8FuBFb+J3MIR5WrdTNozlumfgL4cvz+R4ozBCvsQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.4': - resolution: {integrity: sha512-p6UeR9y7ht82AH57qwGuFYn69S6CZ7LLKdCKy/8T3zS9VTrJei2/CGsTUV45Da4Z9Rbhc7G4gyWQ/Ioamqn09g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@rolldown/pluginutils@1.0.0-rc.4': - resolution: {integrity: sha512-1BrrmTu0TWfOP1riA8uakjFc9bpIUGzVKETsOtzY39pPga8zELGDl8eu1Dx7/gjM5CAz14UknsUMpBO8L+YntQ==} - - '@rollup/rollup-android-arm-eabi@4.59.0': - resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + '@rollup/rollup-android-arm-eabi@4.60.2': + resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.59.0': - resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + '@rollup/rollup-android-arm64@4.60.2': + resolution: {integrity: sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.59.0': - resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + '@rollup/rollup-darwin-arm64@4.60.2': + resolution: {integrity: sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.59.0': - resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + '@rollup/rollup-darwin-x64@4.60.2': + resolution: {integrity: sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.59.0': - resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + '@rollup/rollup-freebsd-arm64@4.60.2': + resolution: {integrity: sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.59.0': - resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + '@rollup/rollup-freebsd-x64@4.60.2': + resolution: {integrity: sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.59.0': - resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': + resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} cpu: [arm] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.59.0': - resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + '@rollup/rollup-linux-arm-musleabihf@4.60.2': + resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} cpu: [arm] os: [linux] libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.59.0': - resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + '@rollup/rollup-linux-arm64-gnu@4.60.2': + resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.59.0': - resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + '@rollup/rollup-linux-arm64-musl@4.60.2': + resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.59.0': - resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + '@rollup/rollup-linux-loong64-gnu@4.60.2': + resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} cpu: [loong64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.59.0': - resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + '@rollup/rollup-linux-loong64-musl@4.60.2': + resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} cpu: [loong64] os: [linux] libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.59.0': - resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + '@rollup/rollup-linux-ppc64-gnu@4.60.2': + resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.59.0': - resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + '@rollup/rollup-linux-ppc64-musl@4.60.2': + resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} cpu: [ppc64] os: [linux] libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.59.0': - resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + '@rollup/rollup-linux-riscv64-gnu@4.60.2': + resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} cpu: [riscv64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.59.0': - resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + '@rollup/rollup-linux-riscv64-musl@4.60.2': + resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} cpu: [riscv64] os: [linux] libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.59.0': - resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + '@rollup/rollup-linux-s390x-gnu@4.60.2': + resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} cpu: [s390x] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.59.0': - resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + '@rollup/rollup-linux-x64-gnu@4.60.2': + resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} cpu: [x64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.59.0': - resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + '@rollup/rollup-linux-x64-musl@4.60.2': + resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openbsd-x64@4.59.0': - resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + '@rollup/rollup-openbsd-x64@4.60.2': + resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.59.0': - resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + '@rollup/rollup-openharmony-arm64@4.60.2': + resolution: {integrity: sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.59.0': - resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + '@rollup/rollup-win32-arm64-msvc@4.60.2': + resolution: {integrity: sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.59.0': - resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + '@rollup/rollup-win32-ia32-msvc@4.60.2': + resolution: {integrity: sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.59.0': - resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + '@rollup/rollup-win32-x64-gnu@4.60.2': + resolution: {integrity: sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.59.0': - resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + '@rollup/rollup-win32-x64-msvc@4.60.2': + resolution: {integrity: sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==} cpu: [x64] os: [win32] - '@schematics/angular@21.2.1': - resolution: {integrity: sha512-DjrHRMoILhbZ6tc7aNZWuHA1wCm1iU/JN1TxAwNEyIBgyU3Fx8Z5baK4w0TCpOIPt0RLWVgP2L7kka9aXWCUFA==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + '@schematics/angular@22.0.7': + resolution: {integrity: sha512-/GVLhB5zaxYGIaC4GFfsfk81SDq9ht0HbEHhHB+t1SqNPV8gnULDV2ZE0Cg+LGj+3YnPtSwpb7tS0vaHUcihVg==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} '@sigstore/bundle@4.0.0': resolution: {integrity: sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A==} engines: {node: ^20.17.0 || >=22.9.0} - '@sigstore/core@3.1.0': - resolution: {integrity: sha512-o5cw1QYhNQ9IroioJxpzexmPjfCe7gzafd2RY3qnMpxr4ZEja+Jad/U8sgFpaue6bOaF+z7RVkyKVV44FN+N8A==} + '@sigstore/core@3.2.1': + resolution: {integrity: sha512-qRsxPnCrbC/puegGxKuynfnxgLiHqWStrSjxkoB4YKqq3Z3s4cyZyj42ZdWFAEblNP65C+rBH8EuREHIXoi83g==} engines: {node: ^20.17.0 || >=22.9.0} - '@sigstore/protobuf-specs@0.5.0': - resolution: {integrity: sha512-MM8XIwUjN2bwvCg1QvrMtbBmpcSHrkhFSCu1D11NyPvDQ25HEc4oG5/OcQfd/Tlf/OxmKWERDj0zGE23jQaMwA==} + '@sigstore/protobuf-specs@0.5.1': + resolution: {integrity: sha512-/ScWUhhoFasJsSRGTVBwId1loQjjnjAfE4djL6ZhrXRpNCmPTnUKF5Jokd58ILseOMjzET3UrMOtJPS9sYeI0g==} engines: {node: ^18.17.0 || >=20.5.0} - '@sigstore/sign@4.1.0': - resolution: {integrity: sha512-Vx1RmLxLGnSUqx/o5/VsCjkuN5L7y+vxEEwawvc7u+6WtX2W4GNa7b9HEjmcRWohw/d6BpATXmvOwc78m+Swdg==} + '@sigstore/sign@4.1.1': + resolution: {integrity: sha512-Hf4xglukg0XXQ2RiD5vSoLjdPe8OBUPA8XeVjUObheuDcWdYWrnH/BNmxZCzkAy68MzmNCxXLeurJvs6hcP2OQ==} engines: {node: ^20.17.0 || >=22.9.0} - '@sigstore/tuf@4.0.1': - resolution: {integrity: sha512-OPZBg8y5Vc9yZjmWCHrlWPMBqW5yd8+wFNl+thMdtcWz3vjVSoJQutF8YkrzI0SLGnkuFof4HSsWUhXrf219Lw==} + '@sigstore/tuf@4.0.2': + resolution: {integrity: sha512-TCAzTy0xzdP79EnxSjq9KQ3eaR7+FmudLC6eRKknVKZbV7ZNlGLClAAQb/HMNJ5n2OBNk2GT1tEmU0xuPr+SLQ==} engines: {node: ^20.17.0 || >=22.9.0} - '@sigstore/verify@3.1.0': - resolution: {integrity: sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag==} + '@sigstore/verify@3.1.1': + resolution: {integrity: sha512-qv7+G3J2cc6wwFj3yKvXOamzqhMwSk1ogPGmhpS8iXllcPrJaIIBA+4HbttlHVu1pqWTdmaCH/WE7UOC51kdoA==} engines: {node: ^20.17.0 || >=22.9.0} '@socket.io/component-emitter@3.1.2': @@ -1538,9 +1606,6 @@ packages: resolution: {integrity: sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww==} engines: {node: ^20.17.0 || >=22.9.0} - '@tybys/wasm-util@0.10.1': - resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} - '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1568,142 +1633,148 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node@25.3.5': - resolution: {integrity: sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==} + '@types/node@26.1.1': + resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} - '@typescript-eslint/eslint-plugin@8.47.0': - resolution: {integrity: sha512-fe0rz9WJQ5t2iaLfdbDc9T80GJy0AeO453q8C3YCilnGozvOyCG5t+EZtg7j7D88+c3FipfP/x+wzGnh1xp8ZA==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@typescript-eslint/eslint-plugin@8.62.0': + resolution: {integrity: sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.47.0 - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/parser': ^8.62.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.47.0': - resolution: {integrity: sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/project-service@8.47.0': - resolution: {integrity: sha512-2X4BX8hUeB5JcA1TQJ7GjcgulXQ+5UkNb0DL8gHsHUHdFoiCTJoYLTpib3LtSDPZsRET5ygN4qqIWrHyYIKERA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/project-service@8.56.1': - resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/scope-manager@8.47.0': - resolution: {integrity: sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/scope-manager@8.56.1': - resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/tsconfig-utils@8.47.0': - resolution: {integrity: sha512-ybUAvjy4ZCL11uryalkKxuT3w3sXJAuWhOoGS3T/Wu+iUu1tGJmk5ytSY8gbdACNARmcYEB0COksD2j6hfGK2g==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/tsconfig-utils@8.56.1': - resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/type-utils@8.47.0': - resolution: {integrity: sha512-QC9RiCmZ2HmIdCEvhd1aJELBlD93ErziOXXlHEZyuBo3tBiAZieya0HLIxp+DoDWlsQqDawyKuNEhORyku+P8A==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/types@8.47.0': - resolution: {integrity: sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/types@8.56.1': - resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/typescript-estree@8.47.0': - resolution: {integrity: sha512-k6ti9UepJf5NpzCjH31hQNLHQWupTRPhZ+KFF8WtTuTpy7uHPfeg2NM7cP27aCGajoEplxJDFVCEm9TGPYyiVg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/typescript-estree@8.56.1': - resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/utils@8.47.0': - resolution: {integrity: sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/utils@8.56.1': - resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} + '@typescript-eslint/parser@8.62.0': + resolution: {integrity: sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.47.0': - resolution: {integrity: sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==} + '@typescript-eslint/project-service@8.62.0': + resolution: {integrity: sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.64.0': + resolution: {integrity: sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.62.0': + resolution: {integrity: sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.56.1': - resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} + '@typescript-eslint/scope-manager@8.64.0': + resolution: {integrity: sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@vitejs/plugin-basic-ssl@2.1.4': - resolution: {integrity: sha512-HXciTXN/sDBYWgeAD4V4s0DN0g72x5mlxQhHxtYu3Tt8BLa6MzcJZUyDVFCdtjNs3bfENVHVzOsmooTVuNgAAw==} + '@typescript-eslint/tsconfig-utils@8.62.0': + resolution: {integrity: sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/tsconfig-utils@8.64.0': + resolution: {integrity: sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.62.0': + resolution: {integrity: sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.62.0': + resolution: {integrity: sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/types@8.64.0': + resolution: {integrity: sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.62.0': + resolution: {integrity: sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/typescript-estree@8.64.0': + resolution: {integrity: sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.62.0': + resolution: {integrity: sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.64.0': + resolution: {integrity: sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.62.0': + resolution: {integrity: sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/visitor-keys@8.64.0': + resolution: {integrity: sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-basic-ssl@2.3.0': + resolution: {integrity: sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} peerDependencies: - vite: ^6.0.0 || ^7.0.0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - '@vitest/expect@4.0.18': - resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} - '@vitest/mocker@4.0.18': - resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} peerDependencies: msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@4.0.18': - resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} - '@vitest/runner@4.0.18': - resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} - '@vitest/snapshot@4.0.18': - resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} - '@vitest/spy@4.0.18': - resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} - '@vitest/utils@4.0.18': - resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} '@yarnpkg/lockfile@1.1.0': resolution: {integrity: sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==} @@ -1725,8 +1796,8 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} engines: {node: '>=0.4.0'} hasBin: true @@ -1734,6 +1805,10 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + agent-base@9.0.0: + resolution: {integrity: sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==} + engines: {node: '>= 20'} + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -1742,21 +1817,21 @@ packages: ajv: optional: true - ajv@6.14.0: - resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} - ajv@8.18.0: - resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} - algoliasearch@5.48.1: - resolution: {integrity: sha512-Rf7xmeuIo7nb6S4mp4abW2faW8DauZyE2faBIKFaUfP3wnpOvNSbiI5AwVhqBNj0jPgBWEvhyCu0sLjN2q77Rg==} + algoliasearch@5.52.0: + resolution: {integrity: sha512-0ZzY9mjqV7gop/AH8pIBiAS8giXP7WcSiUfoFYIzYAK9QC5c37E4SIVtJVBMwlURc0/uNt2o4RcNRvdHa4CJ5w==} engines: {node: '>= 14.0.0'} - angular-eslint@21.1.0: - resolution: {integrity: sha512-qXpIEBNYpfgpBaFblnyFegVSQjWCVUdCXTHvMcvtNtmMgtPwIDKvG8wuJo5BbQ/MNt2d8npmnRUaS2ddzdCzww==} + angular-eslint@22.0.0: + resolution: {integrity: sha512-6tHLndzM6rU+2iuICakJS/hD1scK5sWLkcD7828zStT1ViA9zX8z9g/V1IlBiKEdZeMsl+m7K2DlNc34AkYyoQ==} peerDependencies: - '@angular/cli': '>= 21.0.0 < 22.0.0' - eslint: ^8.57.0 || ^9.0.0 + '@angular/cli': '>= 22.0.0 < 23.0.0' + eslint: ^9.0.0 || ^10.0.0 typescript: '*' typescript-eslint: ^8.0.0 @@ -1764,10 +1839,6 @@ packages: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - ansi-regex@6.2.2: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} @@ -1806,20 +1877,20 @@ packages: resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} engines: {node: ^4.5.0 || >= 5.9} - baseline-browser-mapping@2.10.0: - resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} + baseline-browser-mapping@2.10.43: + resolution: {integrity: sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==} engines: {node: '>=6.0.0'} hasBin: true - beasties@0.4.1: - resolution: {integrity: sha512-2Imdcw3LznDuxAbJM26RHniOLAzE6WgrK8OuvVXCQtNBS8rsnD9zsSEa3fHl4hHpUY7BYTlrpvtPVbvu9G6neg==} + beasties@0.4.2: + resolution: {integrity: sha512-NvcGjG/7AVUAfRbvrJmHunDQS9uHnE6Q/7AkaPr8oKE8HjOlpjRG5075z/th2Tmlezk3VlaaS8+X9I1RwHJMQw==} engines: {node: '>=18.0.0'} bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} - body-parser@2.2.2: - resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} boolbase@1.0.0: @@ -1830,22 +1901,15 @@ packages: peerDependencies: '@popperjs/core': ^2.11.8 - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + brace-expansion@1.1.16: + resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} - - brace-expansion@5.0.4: - resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} engines: {node: 18 || 20 || >=22} - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - browserslist@4.28.1: - resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + browserslist@4.28.6: + resolution: {integrity: sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -1856,8 +1920,8 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} - cacache@20.0.3: - resolution: {integrity: sha512-3pUp4e8hv07k1QlijZu6Kn7c9+ZpWWk4j3F8N3xPuCExULobqJydKYOTj1FTq58srkJsXvO7LbGAH4C0ZU3WGw==} + cacache@20.0.4: + resolution: {integrity: sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA==} engines: {node: ^20.17.0 || >=22.9.0} call-bind-apply-helpers@1.0.2: @@ -1872,8 +1936,8 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-lite@1.0.30001777: - resolution: {integrity: sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==} + caniuse-lite@1.0.30001805: + resolution: {integrity: sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==} chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} @@ -1887,8 +1951,8 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - chardet@2.1.1: - resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + chardet@2.2.0: + resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} @@ -1929,20 +1993,21 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - colorette@2.0.20: - resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - content-disposition@1.0.1: - resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} content-type@1.0.5: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + convert-source-map@1.9.0: resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} @@ -1957,8 +2022,8 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} - core-js@3.48.0: - resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==} + core-js@3.49.0: + resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} @@ -2030,42 +2095,39 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.307: - resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==} + electron-to-chromium@1.5.392: + resolution: {integrity: sha512-1yQq3VQCZRwsnYc67Oc+1fge6Lwtn0hzi6zmEVkB61Zx21kTbwJAW4dFLadl5Rc1tKhG/kSpYXnfiAhu0f0a1g==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} - engine.io-client@6.6.4: - resolution: {integrity: sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==} + engine.io-client@6.6.6: + resolution: {integrity: sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==} engine.io-parser@5.2.3: resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} engines: {node: '>=10.0.0'} - engine.io@6.6.5: - resolution: {integrity: sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==} + engine.io@6.6.9: + resolution: {integrity: sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==} engines: {node: '>=10.2.0'} entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} - entities@6.0.1: - resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} - engines: {node: '>=0.12'} - entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -2074,9 +2136,6 @@ packages: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} - err-code@2.0.3: - resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} - es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -2085,15 +2144,20 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} - esbuild@0.27.3: - resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} hasBin: true @@ -2112,8 +2176,8 @@ packages: resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint-scope@9.1.1: - resolution: {integrity: sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==} + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} eslint-visitor-keys@3.4.3: @@ -2128,8 +2192,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@9.39.3: - resolution: {integrity: sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==} + eslint@9.39.5: + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -2168,23 +2232,23 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} - eventsource-parser@3.0.6: - resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} engines: {node: '>=18.0.0'} eventsource@3.0.7: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} - expect-type@1.3.0: - resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} - express-rate-limit@8.3.0: - resolution: {integrity: sha512-KJzBawY6fB9FiZGdE/0aftepZ91YlaGIrV8vgblRM3J8X+dHx/aiowJWwkx6LIGyuqGiANsjSwwrbb8mifOJ4Q==} + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} engines: {node: '>= 16'} peerDependencies: express: '>= 4.11' @@ -2196,21 +2260,23 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-uri@3.1.3: + resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} @@ -2225,10 +2291,6 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} @@ -2241,8 +2303,8 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.3.4: - resolution: {integrity: sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==} + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} @@ -2272,8 +2334,8 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-east-asian-width@1.5.0: - resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} engines: {node: '>=18'} get-intrinsic@1.3.0: @@ -2284,10 +2346,6 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -2310,9 +2368,6 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - graphemer@1.4.0: - resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -2321,16 +2376,16 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} - hono@4.12.5: - resolution: {integrity: sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==} + hono@4.12.30: + resolution: {integrity: sha512-emn+JoJjrN9YTpRDS5it/UI2SO9BAE37T6I3d963RxcZ81G9A4pr2SZTEiiaiKbzx+NKRg5BZ89fCL7gCJCUog==} engines: {node: '>=16.9.0'} - hosted-git-info@9.0.2: - resolution: {integrity: sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==} + hosted-git-info@9.0.3: + resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==} engines: {node: ^20.17.0 || >=22.9.0} html-encoding-sniffer@6.0.0: @@ -2355,8 +2410,12 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} - iconv-lite@0.7.2: - resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + https-proxy-agent@9.0.0: + resolution: {integrity: sha512-/MVmHp58WkOypgFhCLk4fzpPcFQvTJ/e6LBI7irpIO2HfxUbpmYoHF+KzipzJpxxzJu7aJNWQ0xojJ/dzV2G5g==} + engines: {node: '>= 20'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} ignore-walk@8.0.0: @@ -2371,8 +2430,12 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} - immutable@5.1.5: - resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==} + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + immutable@5.1.9: + resolution: {integrity: sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==} import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} @@ -2389,8 +2452,8 @@ packages: resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} engines: {node: ^20.17.0 || >=22.9.0} - ip-address@10.1.0: - resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -2401,10 +2464,6 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - is-fullwidth-code-point@5.1.0: resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} engines: {node: '>=18'} @@ -2417,10 +2476,6 @@ packages: resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} engines: {node: '>=12'} - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -2446,14 +2501,14 @@ packages: resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} engines: {node: '>=10'} - jose@6.2.0: - resolution: {integrity: sha512-xsfE1TcSCbUdo6U07tR0mvhg0flGxU8tPLbF03mirl2ukGQENhUg4ubGYQnhVH0b5stLlPM+WOqDkEl1R1y5sQ==} + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true jsdom@27.4.0: @@ -2508,12 +2563,12 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - listr2@9.0.5: - resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} - engines: {node: '>=20.0.0'} + listr2@10.2.1: + resolution: {integrity: sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==} + engines: {node: '>=22.13.0'} - lmdb@3.5.1: - resolution: {integrity: sha512-NYHA0MRPjvNX+vSw8Xxg6FLKxzAG+e7Pt8RqAQA/EehzHVXq9SxDqJIN3JL1hK0dweb884y8kIh6rkWvPyg9Wg==} + lmdb@3.5.4: + resolution: {integrity: sha512-9FKQA6G1MMtqNxfxvSBNXD/axeG2QRjYbNh0/ykRL5xYcRbCm2vXq7B9bhc7nSuKdHzr8/BHIwfPuYYH1UsXXw==} hasBin: true locate-path@6.0.0: @@ -2531,8 +2586,8 @@ packages: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} - lru-cache@11.2.6: - resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} lru-cache@5.1.1: @@ -2541,8 +2596,8 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - make-fetch-happen@15.0.4: - resolution: {integrity: sha512-vM2sG+wbVeVGYcCm16mM3d5fuem9oC28n436HjsGO3LcxoTI8LNVa4rwZDn3f76+cWyT4GGJDxjTYU1I2nr6zw==} + make-fetch-happen@15.0.6: + resolution: {integrity: sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw==} engines: {node: ^20.17.0 || >=22.9.0} math-intrinsics@1.1.0: @@ -2560,14 +2615,6 @@ packages: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -2588,17 +2635,13 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} - minimatch@10.2.4: - resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - minimatch@9.0.9: - resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} - engines: {node: '>=16 || 14 >=14.17'} - minipass-collect@2.0.1: resolution: {integrity: sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==} engines: {node: '>=16 || 14 >=14.17'} @@ -2607,8 +2650,8 @@ packages: resolution: {integrity: sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==} engines: {node: ^20.17.0 || >=22.9.0} - minipass-flush@1.0.5: - resolution: {integrity: sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==} + minipass-flush@1.0.7: + resolution: {integrity: sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==} engines: {node: '>= 8'} minipass-pipeline@1.2.4: @@ -2638,19 +2681,19 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - msgpackr-extract@3.0.3: - resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} hasBin: true - msgpackr@1.11.8: - resolution: {integrity: sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA==} + msgpackr@1.12.1: + resolution: {integrity: sha512-4EUH9tQHnMmEgzW/MdAP0KIfa1T9AF+htl0ffe2n5vb2EKn9y2co8ccpgWko6S52Jy1PQZKwRnx5/KkYjtd9MQ==} - mute-stream@2.0.0: - resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} - engines: {node: ^18.17.0 || >=20.5.0} + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -2665,11 +2708,11 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} - ngx-cookie-service@21.1.0: - resolution: {integrity: sha512-W3IsoMImUVNt0ZuMesEuQJUGrEgKJaPkQGGfCg7zjUGM/EaeAIbIkfNktNIgUDmeUYapZQJa2pSg4YuK4v1gVQ==} + ngx-cookie-service@22.0.0: + resolution: {integrity: sha512-7V8am8MqTr7R1xCePfo3w+AfbunNQl+vaWPAeLtIruhsAqBXp0uunh4mYv+ikrYrdPg9pqBjckCX3ubNv8HX7w==} peerDependencies: - '@angular/common': ^21.0.0 - '@angular/core': ^21.0.0 + '@angular/common': ^22.0.0 + '@angular/core': ^22.0.0 ngx-socket-io@4.10.0: resolution: {integrity: sha512-FuOF/IX4mYyRHjfFi5osBgo8uN+/+zyRhYnAtuOBLOLpRo/uNS/6kggwLGKainOdieJs3vmreSP6lQEWRS4otw==} @@ -2688,13 +2731,14 @@ packages: resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} hasBin: true - node-gyp@12.2.0: - resolution: {integrity: sha512-q23WdzrQv48KozXlr0U1v9dwO/k59NHeSzn6loGcasyf0UnSrtzs8kRxM+mfwJSf0DkX0s43hcqgnSO4/VNthQ==} + node-gyp@12.4.0: + resolution: {integrity: sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==} engines: {node: ^20.17.0 || >=22.9.0} hasBin: true - node-releases@2.0.36: - resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} nopt@9.0.0: resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} @@ -2740,8 +2784,9 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} - obug@2.1.1: - resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} @@ -2758,8 +2803,8 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} - ora@9.3.0: - resolution: {integrity: sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==} + ora@9.4.0: + resolution: {integrity: sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==} engines: {node: '>=20'} ordered-binary@1.6.1: @@ -2773,12 +2818,12 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - p-map@7.0.4: - resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} + p-map@7.0.5: + resolution: {integrity: sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA==} engines: {node: '>=18'} - pacote@21.3.1: - resolution: {integrity: sha512-O0EDXi85LF4AzdjG74GUwEArhdvawi/YOHcsW6IijKNj7wm8IvEWNF5GnfuxNpQ/ZpO3L37+v8hqdVh8GgWYhg==} + pacote@21.5.1: + resolution: {integrity: sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg==} engines: {node: ^20.17.0 || >=22.9.0} hasBin: true @@ -2786,14 +2831,14 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} - parse5-html-rewriting-stream@8.0.0: - resolution: {integrity: sha512-wzh11mj8KKkno1pZEu+l2EVeWsuKDfR5KNWZOTsslfUX8lPDZx77m9T0kIoAVkFtD1nx6YF8oh4BnPHvxMtNMw==} + parse5-html-rewriting-stream@8.0.1: + resolution: {integrity: sha512-NaRku2aMpUN1Sh1Gyk1KWUh2A7EJx2c6qYzvwsPtqhoHoaURshdrceYK3LunVCm3WHhm6FS7Vcczbvdh3/UIVw==} parse5-sax-parser@8.0.0: resolution: {integrity: sha512-/dQ8UzHZwnrzs3EvDj6IkKrD/jIZyTlB+8XrHJvcjNgRdmWruNdN9i9RK/JtxakmlUdPwKubKPTCqvbTgzGhrw==} - parse5@8.0.0: - resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} @@ -2811,8 +2856,8 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} - path-to-regexp@8.3.0: - resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -2820,16 +2865,16 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} - - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} - piscina@5.1.4: - resolution: {integrity: sha512-7uU4ZnKeQq22t9AsmHGD2w4OYQGonwFnTypDypaWi7Qr2EvQIFVtG8J5D/3bE7W123Wdc9+v4CZDu5hJXVCtBg==} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + piscina@5.2.0: + resolution: {integrity: sha512-DszUCKeVN/5G5QKo6jAVHL8fmKnkJvQ0ACiVgY7YGCq3TUB2oznAOayvZPIAdEThvhczkXR+qm3IHsNXpFCYfA==} engines: {node: '>=20.x'} pkce-challenge@5.0.1: @@ -2845,8 +2890,8 @@ packages: peerDependencies: postcss: ^8.4.31 - postcss@8.5.8: - resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + postcss@8.5.19: + resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -2857,10 +2902,6 @@ packages: resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} engines: {node: ^20.17.0 || >=22.9.0} - promise-retry@2.0.1: - resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} - engines: {node: '>=10'} - proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -2869,15 +2910,12 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qs@6.15.0: - resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - - range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} engines: {node: '>= 0.6'} raw-body@3.0.2: @@ -2907,28 +2945,11 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} - retry@0.12.0: - resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} - engines: {node: '>= 4'} - - retry@0.13.1: - resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} - engines: {node: '>= 4'} - - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - rolldown@1.0.0-rc.4: - resolution: {integrity: sha512-V2tPDUrY3WSevrvU2E41ijZlpF+5PbZu4giH+VpNraaadsJGHa4fR6IFwsocVwEXDoAdIv5qgPPxgrvKAOIPtA==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - - rollup@4.59.0: - resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + rollup@4.60.2: + resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -2936,17 +2957,14 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - sass@1.97.3: - resolution: {integrity: sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==} + sass@1.99.0: + resolution: {integrity: sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==} engines: {node: '>=14.0.0'} hasBin: true @@ -2958,13 +2976,18 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} hasBin: true - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true @@ -2987,8 +3010,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} side-channel-map@1.0.1: @@ -2999,8 +3022,8 @@ packages: resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} engines: {node: '>= 0.4'} - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} siginfo@2.0.0: @@ -3010,8 +3033,8 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - sigstore@4.1.0: - resolution: {integrity: sha512-/fUgUhYghuLzVT/gaJoeVehLCgZiUxPCPMcyVNY0lIf/cTCz58K/WTI7PefDarXxp9nUKpEwg1yyz3eSBMTtgA==} + sigstore@4.1.1: + resolution: {integrity: sha512-endqECJkfhozrXMK5ngu/UAA0xVcVEFdnHJCElGaExypjW+HK5i6zu3NteLoaX/iFbRUbC3+DjttQs0GARr+5w==} engines: {node: ^20.17.0 || >=22.9.0} slice-ansi@7.1.2: @@ -3026,15 +3049,15 @@ packages: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - socket.io-adapter@2.5.6: - resolution: {integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==} + socket.io-adapter@2.5.8: + resolution: {integrity: sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==} socket.io-client@4.8.3: resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==} engines: {node: '>=10.0.0'} - socket.io-parser@4.2.5: - resolution: {integrity: sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==} + socket.io-parser@4.2.7: + resolution: {integrity: sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==} engines: {node: '>=10.0.0'} socket.io@4.8.3: @@ -3045,8 +3068,8 @@ packages: resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} engines: {node: '>= 14'} - socks@2.8.7: - resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} + socks@2.8.9: + resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} source-map-js@1.2.1: @@ -3084,29 +3107,21 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} - stdin-discarder@0.3.1: - resolution: {integrity: sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==} + stdin-discarder@0.3.2: + resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==} engines: {node: '>=18'} - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} - string-width@8.2.0: - resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} + string-width@8.2.2: + resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} engines: {node: '>=20'} - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - strip-ansi@7.2.0: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} @@ -3122,50 +3137,50 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - tar@7.5.10: - resolution: {integrity: sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw==} + tar@7.5.20: + resolution: {integrity: sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==} engines: {node: '>=18'} tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyexec@1.0.2: - resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} engines: {node: '>=18'} - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} - tinyrainbow@3.0.3: - resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} - tldts-core@7.0.24: - resolution: {integrity: sha512-pj7yygNMoMRqG7ML2SDQ0xNIOfN3IBDUcPVM2Sg6hP96oFNN2nqnzHreT3z9xLq85IWJyNTvD38O002DdOrPMw==} + tldts-core@7.4.8: + resolution: {integrity: sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==} - tldts@7.0.24: - resolution: {integrity: sha512-1r6vQTTt1rUiJkI5vX7KG8PR342Ru/5Oh13kEQP2SMbRSZpOey9SrBe27IDxkoWulx8ShWu4K6C0BkctP8Z1bQ==} + tldts@7.4.8: + resolution: {integrity: sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==} hasBin: true - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} - tough-cookie@6.0.0: - resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + 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.4.0: - resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' @@ -3181,36 +3196,28 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-is@2.0.1: - resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} - engines: {node: '>= 0.6'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} - typescript-eslint@8.47.0: - resolution: {integrity: sha512-Lwe8i2XQ3WoMjua/r1PHrCTpkubPYJCAfOurtn+mtTzqB6jNd+14n9UN1bJ4s3F49x9ixAm0FLflB/JzQ57M8Q==} + typescript-eslint@8.62.0: + resolution: {integrity: sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} hasBin: true - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} - undici@7.22.0: - resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} - engines: {node: '>=20.18.1'} - - unique-filename@5.0.0: - resolution: {integrity: sha512-2RaJTAvAb4owyjllTfXzFClJ7WsGxlykkPvCr9pA//LD9goVq+m4PPAeBgNodGZ7nSrntT/auWpJ6Y5IFXcfjg==} - engines: {node: ^20.17.0 || >=22.9.0} - - unique-slug@6.0.0: - resolution: {integrity: sha512-4Lup7Ezn8W3d52/xBhZBVdx323ckxa7DEvd9kPQHppTkLoJXw6ltrBCyj5pnrxj0qKDxYMJ56CoxNuFCscdTiw==} - engines: {node: ^20.17.0 || >=22.9.0} + undici@6.27.0: + resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==} + engines: {node: '>=18.17'} unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} @@ -3233,8 +3240,8 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - vite@7.3.1: - resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + vite@7.3.5: + resolution: {integrity: sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -3273,20 +3280,23 @@ packages: yaml: optional: true - vitest@4.0.18: - resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.0.18 - '@vitest/browser-preview': 4.0.18 - '@vitest/browser-webdriverio': 4.0.18 - '@vitest/ui': 4.0.18 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 happy-dom: '*' jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -3300,6 +3310,10 @@ packages: optional: true '@vitest/browser-webdriverio': optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true '@vitest/ui': optional: true happy-dom: @@ -3353,9 +3367,9 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - wrap-ansi@6.2.0: - resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} - engines: {node: '>=8'} + wrap-ansi@10.0.0: + resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==} + engines: {node: '>=20'} wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} @@ -3364,20 +3378,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.18.3: - resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - - ws@8.19.0: - resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -3425,21 +3427,20 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - yoctocolors-cjs@2.1.3: - resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} - engines: {node: '>=18'} - yoctocolors@2.1.2: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} - zod-to-json-schema@3.25.1: - resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: - zod: ^3.25 || ^4 + zod: ^3.25.28 || ^4 - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zod@4.4.2: + resolution: {integrity: sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} zone.js@0.15.0: resolution: {integrity: sha512-9oxn0IIjbCZkJ67L+LkhYWRyAy7axphb3VgE2MBDlOqnmHMPWGYMxJxBYFueFq/JGY2GMwS0rU+UCLunEmy5UA==} @@ -3448,165 +3449,165 @@ snapshots: '@acemir/cssom@0.9.31': {} - '@algolia/abtesting@1.14.1': + '@algolia/abtesting@1.18.0': dependencies: - '@algolia/client-common': 5.48.1 - '@algolia/requester-browser-xhr': 5.48.1 - '@algolia/requester-fetch': 5.48.1 - '@algolia/requester-node-http': 5.48.1 + '@algolia/client-common': 5.52.0 + '@algolia/requester-browser-xhr': 5.52.0 + '@algolia/requester-fetch': 5.52.0 + '@algolia/requester-node-http': 5.52.0 - '@algolia/client-abtesting@5.48.1': + '@algolia/client-abtesting@5.52.0': dependencies: - '@algolia/client-common': 5.48.1 - '@algolia/requester-browser-xhr': 5.48.1 - '@algolia/requester-fetch': 5.48.1 - '@algolia/requester-node-http': 5.48.1 + '@algolia/client-common': 5.52.0 + '@algolia/requester-browser-xhr': 5.52.0 + '@algolia/requester-fetch': 5.52.0 + '@algolia/requester-node-http': 5.52.0 - '@algolia/client-analytics@5.48.1': + '@algolia/client-analytics@5.52.0': dependencies: - '@algolia/client-common': 5.48.1 - '@algolia/requester-browser-xhr': 5.48.1 - '@algolia/requester-fetch': 5.48.1 - '@algolia/requester-node-http': 5.48.1 + '@algolia/client-common': 5.52.0 + '@algolia/requester-browser-xhr': 5.52.0 + '@algolia/requester-fetch': 5.52.0 + '@algolia/requester-node-http': 5.52.0 - '@algolia/client-common@5.48.1': {} + '@algolia/client-common@5.52.0': {} - '@algolia/client-insights@5.48.1': + '@algolia/client-insights@5.52.0': dependencies: - '@algolia/client-common': 5.48.1 - '@algolia/requester-browser-xhr': 5.48.1 - '@algolia/requester-fetch': 5.48.1 - '@algolia/requester-node-http': 5.48.1 + '@algolia/client-common': 5.52.0 + '@algolia/requester-browser-xhr': 5.52.0 + '@algolia/requester-fetch': 5.52.0 + '@algolia/requester-node-http': 5.52.0 - '@algolia/client-personalization@5.48.1': + '@algolia/client-personalization@5.52.0': dependencies: - '@algolia/client-common': 5.48.1 - '@algolia/requester-browser-xhr': 5.48.1 - '@algolia/requester-fetch': 5.48.1 - '@algolia/requester-node-http': 5.48.1 + '@algolia/client-common': 5.52.0 + '@algolia/requester-browser-xhr': 5.52.0 + '@algolia/requester-fetch': 5.52.0 + '@algolia/requester-node-http': 5.52.0 - '@algolia/client-query-suggestions@5.48.1': + '@algolia/client-query-suggestions@5.52.0': dependencies: - '@algolia/client-common': 5.48.1 - '@algolia/requester-browser-xhr': 5.48.1 - '@algolia/requester-fetch': 5.48.1 - '@algolia/requester-node-http': 5.48.1 + '@algolia/client-common': 5.52.0 + '@algolia/requester-browser-xhr': 5.52.0 + '@algolia/requester-fetch': 5.52.0 + '@algolia/requester-node-http': 5.52.0 - '@algolia/client-search@5.48.1': + '@algolia/client-search@5.52.0': dependencies: - '@algolia/client-common': 5.48.1 - '@algolia/requester-browser-xhr': 5.48.1 - '@algolia/requester-fetch': 5.48.1 - '@algolia/requester-node-http': 5.48.1 + '@algolia/client-common': 5.52.0 + '@algolia/requester-browser-xhr': 5.52.0 + '@algolia/requester-fetch': 5.52.0 + '@algolia/requester-node-http': 5.52.0 - '@algolia/ingestion@1.48.1': + '@algolia/ingestion@1.52.0': dependencies: - '@algolia/client-common': 5.48.1 - '@algolia/requester-browser-xhr': 5.48.1 - '@algolia/requester-fetch': 5.48.1 - '@algolia/requester-node-http': 5.48.1 + '@algolia/client-common': 5.52.0 + '@algolia/requester-browser-xhr': 5.52.0 + '@algolia/requester-fetch': 5.52.0 + '@algolia/requester-node-http': 5.52.0 - '@algolia/monitoring@1.48.1': + '@algolia/monitoring@1.52.0': dependencies: - '@algolia/client-common': 5.48.1 - '@algolia/requester-browser-xhr': 5.48.1 - '@algolia/requester-fetch': 5.48.1 - '@algolia/requester-node-http': 5.48.1 + '@algolia/client-common': 5.52.0 + '@algolia/requester-browser-xhr': 5.52.0 + '@algolia/requester-fetch': 5.52.0 + '@algolia/requester-node-http': 5.52.0 - '@algolia/recommend@5.48.1': + '@algolia/recommend@5.52.0': dependencies: - '@algolia/client-common': 5.48.1 - '@algolia/requester-browser-xhr': 5.48.1 - '@algolia/requester-fetch': 5.48.1 - '@algolia/requester-node-http': 5.48.1 + '@algolia/client-common': 5.52.0 + '@algolia/requester-browser-xhr': 5.52.0 + '@algolia/requester-fetch': 5.52.0 + '@algolia/requester-node-http': 5.52.0 - '@algolia/requester-browser-xhr@5.48.1': + '@algolia/requester-browser-xhr@5.52.0': dependencies: - '@algolia/client-common': 5.48.1 + '@algolia/client-common': 5.52.0 - '@algolia/requester-fetch@5.48.1': + '@algolia/requester-fetch@5.52.0': dependencies: - '@algolia/client-common': 5.48.1 + '@algolia/client-common': 5.52.0 - '@algolia/requester-node-http@5.48.1': + '@algolia/requester-node-http@5.52.0': dependencies: - '@algolia/client-common': 5.48.1 + '@algolia/client-common': 5.52.0 '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 - '@angular-devkit/architect@0.2102.1(chokidar@5.0.0)': + '@angular-devkit/architect@0.2200.7(chokidar@5.0.0)': dependencies: - '@angular-devkit/core': 21.2.1(chokidar@5.0.0) + '@angular-devkit/core': 22.0.7(chokidar@5.0.0) rxjs: 7.8.2 transitivePeerDependencies: - chokidar - '@angular-devkit/core@21.2.1(chokidar@5.0.0)': + '@angular-devkit/core@22.0.7(chokidar@5.0.0)': dependencies: - ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) jsonc-parser: 3.3.1 - picomatch: 4.0.3 + picomatch: 4.0.4 rxjs: 7.8.2 source-map: 0.7.6 optionalDependencies: chokidar: 5.0.0 - '@angular-devkit/schematics@21.2.1(chokidar@5.0.0)': + '@angular-devkit/schematics@22.0.7(chokidar@5.0.0)': dependencies: - '@angular-devkit/core': 21.2.1(chokidar@5.0.0) + '@angular-devkit/core': 22.0.7(chokidar@5.0.0) jsonc-parser: 3.3.1 magic-string: 0.30.21 - ora: 9.3.0 + ora: 9.4.0 rxjs: 7.8.2 transitivePeerDependencies: - chokidar - '@angular-eslint/builder@21.1.0(@angular/cli@21.2.1(@types/node@25.3.5)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.3)(typescript@5.9.3)': + '@angular-eslint/builder@22.0.0(@angular/cli@22.0.7(@types/node@26.1.1)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.5)(typescript@6.0.3)': dependencies: - '@angular-devkit/architect': 0.2102.1(chokidar@5.0.0) - '@angular-devkit/core': 21.2.1(chokidar@5.0.0) - '@angular/cli': 21.2.1(@types/node@25.3.5)(chokidar@5.0.0) - eslint: 9.39.3 - typescript: 5.9.3 + '@angular-devkit/architect': 0.2200.7(chokidar@5.0.0) + '@angular-devkit/core': 22.0.7(chokidar@5.0.0) + '@angular/cli': 22.0.7(@types/node@26.1.1)(chokidar@5.0.0) + eslint: 9.39.5 + typescript: 6.0.3 transitivePeerDependencies: - chokidar - '@angular-eslint/bundled-angular-compiler@21.1.0': {} + '@angular-eslint/bundled-angular-compiler@22.0.0': {} - '@angular-eslint/eslint-plugin-template@21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.3)(typescript@5.9.3))(@typescript-eslint/types@8.56.1)(@typescript-eslint/utils@8.56.1(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3)(typescript@5.9.3)': + '@angular-eslint/eslint-plugin-template@22.0.0(@angular-eslint/template-parser@22.0.0(eslint@9.39.5)(typescript@6.0.3))(@typescript-eslint/types@8.64.0)(@typescript-eslint/utils@8.64.0(eslint@9.39.5)(typescript@6.0.3))(eslint@9.39.5)(typescript@6.0.3)': dependencies: - '@angular-eslint/bundled-angular-compiler': 21.1.0 - '@angular-eslint/template-parser': 21.1.0(eslint@9.39.3)(typescript@5.9.3) - '@angular-eslint/utils': 21.1.0(@typescript-eslint/utils@8.56.1(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3)(typescript@5.9.3) - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/utils': 8.56.1(eslint@9.39.3)(typescript@5.9.3) + '@angular-eslint/bundled-angular-compiler': 22.0.0 + '@angular-eslint/template-parser': 22.0.0(eslint@9.39.5)(typescript@6.0.3) + '@angular-eslint/utils': 22.0.0(@typescript-eslint/utils@8.64.0(eslint@9.39.5)(typescript@6.0.3))(eslint@9.39.5)(typescript@6.0.3) + '@typescript-eslint/types': 8.64.0 + '@typescript-eslint/utils': 8.64.0(eslint@9.39.5)(typescript@6.0.3) aria-query: 5.3.2 axobject-query: 4.1.0 - eslint: 9.39.3 - typescript: 5.9.3 + eslint: 9.39.5 + typescript: 6.0.3 - '@angular-eslint/eslint-plugin@21.1.0(@typescript-eslint/utils@8.56.1(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3)(typescript@5.9.3)': + '@angular-eslint/eslint-plugin@22.0.0(@typescript-eslint/utils@8.64.0(eslint@9.39.5)(typescript@6.0.3))(eslint@9.39.5)(typescript@6.0.3)': dependencies: - '@angular-eslint/bundled-angular-compiler': 21.1.0 - '@angular-eslint/utils': 21.1.0(@typescript-eslint/utils@8.56.1(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3)(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.39.3)(typescript@5.9.3) - eslint: 9.39.3 - ts-api-utils: 2.4.0(typescript@5.9.3) - typescript: 5.9.3 + '@angular-eslint/bundled-angular-compiler': 22.0.0 + '@angular-eslint/utils': 22.0.0(@typescript-eslint/utils@8.64.0(eslint@9.39.5)(typescript@6.0.3))(eslint@9.39.5)(typescript@6.0.3) + '@typescript-eslint/utils': 8.64.0(eslint@9.39.5)(typescript@6.0.3) + eslint: 9.39.5 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 - '@angular-eslint/schematics@21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.3)(typescript@5.9.3))(@angular/cli@21.2.1(@types/node@25.3.5)(chokidar@5.0.0))(@typescript-eslint/types@8.56.1)(@typescript-eslint/utils@8.56.1(eslint@9.39.3)(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.3)(typescript@5.9.3)': + '@angular-eslint/schematics@22.0.0(@angular-eslint/template-parser@22.0.0(eslint@9.39.5)(typescript@6.0.3))(@angular/cli@22.0.7(@types/node@26.1.1)(chokidar@5.0.0))(@typescript-eslint/types@8.64.0)(@typescript-eslint/utils@8.64.0(eslint@9.39.5)(typescript@6.0.3))(chokidar@5.0.0)(eslint@9.39.5)(typescript@6.0.3)': dependencies: - '@angular-devkit/core': 21.2.1(chokidar@5.0.0) - '@angular-devkit/schematics': 21.2.1(chokidar@5.0.0) - '@angular-eslint/eslint-plugin': 21.1.0(@typescript-eslint/utils@8.56.1(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3)(typescript@5.9.3) - '@angular-eslint/eslint-plugin-template': 21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.3)(typescript@5.9.3))(@typescript-eslint/types@8.56.1)(@typescript-eslint/utils@8.56.1(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3)(typescript@5.9.3) - '@angular/cli': 21.2.1(@types/node@25.3.5)(chokidar@5.0.0) + '@angular-devkit/core': 22.0.7(chokidar@5.0.0) + '@angular-devkit/schematics': 22.0.7(chokidar@5.0.0) + '@angular-eslint/eslint-plugin': 22.0.0(@typescript-eslint/utils@8.64.0(eslint@9.39.5)(typescript@6.0.3))(eslint@9.39.5)(typescript@6.0.3) + '@angular-eslint/eslint-plugin-template': 22.0.0(@angular-eslint/template-parser@22.0.0(eslint@9.39.5)(typescript@6.0.3))(@typescript-eslint/types@8.64.0)(@typescript-eslint/utils@8.64.0(eslint@9.39.5)(typescript@6.0.3))(eslint@9.39.5)(typescript@6.0.3) + '@angular/cli': 22.0.7(@types/node@26.1.1)(chokidar@5.0.0) ignore: 7.0.5 - semver: 7.7.3 + semver: 7.8.0 strip-json-comments: 3.1.1 transitivePeerDependencies: - '@angular-eslint/template-parser' @@ -3616,66 +3617,65 @@ snapshots: - eslint - typescript - '@angular-eslint/template-parser@21.1.0(eslint@9.39.3)(typescript@5.9.3)': + '@angular-eslint/template-parser@22.0.0(eslint@9.39.5)(typescript@6.0.3)': dependencies: - '@angular-eslint/bundled-angular-compiler': 21.1.0 - eslint: 9.39.3 - eslint-scope: 9.1.1 - typescript: 5.9.3 + '@angular-eslint/bundled-angular-compiler': 22.0.0 + eslint: 9.39.5 + eslint-scope: 9.1.2 + typescript: 6.0.3 - '@angular-eslint/utils@21.1.0(@typescript-eslint/utils@8.56.1(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3)(typescript@5.9.3)': + '@angular-eslint/utils@22.0.0(@typescript-eslint/utils@8.64.0(eslint@9.39.5)(typescript@6.0.3))(eslint@9.39.5)(typescript@6.0.3)': dependencies: - '@angular-eslint/bundled-angular-compiler': 21.1.0 - '@typescript-eslint/utils': 8.56.1(eslint@9.39.3)(typescript@5.9.3) - eslint: 9.39.3 - typescript: 5.9.3 + '@angular-eslint/bundled-angular-compiler': 22.0.0 + '@typescript-eslint/utils': 8.64.0(eslint@9.39.5)(typescript@6.0.3) + eslint: 9.39.5 + typescript: 6.0.3 - '@angular/animations@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))': + '@angular/animations@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))': dependencies: - '@angular/core': 21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/core': 22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0) tslib: 2.8.1 - '@angular/build@21.2.1(@angular/compiler-cli@21.2.1(@angular/compiler@21.2.1)(typescript@5.9.3))(@angular/compiler@21.2.1)(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/localize@21.2.1(@angular/compiler-cli@21.2.1(@angular/compiler@21.2.1)(typescript@5.9.3))(@angular/compiler@21.2.1))(@angular/platform-browser@21.2.1(@angular/animations@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/service-worker@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@types/node@25.3.5)(chokidar@5.0.0)(postcss@8.5.8)(tslib@2.8.1)(typescript@5.9.3)(vitest@4.0.18(@types/node@25.3.5)(jsdom@27.4.0)(sass@1.97.3))': + '@angular/build@22.0.7(@angular/compiler-cli@22.0.6(@angular/compiler@22.0.6)(typescript@6.0.3))(@angular/compiler@22.0.6)(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/localize@22.0.6(@angular/compiler-cli@22.0.6(@angular/compiler@22.0.6)(typescript@6.0.3))(@angular/compiler@22.0.6))(@angular/platform-browser@22.0.6(@angular/animations@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/service-worker@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@types/node@26.1.1)(chokidar@5.0.0)(istanbul-lib-instrument@6.0.3)(postcss@8.5.19)(tslib@2.8.1)(typescript@6.0.3)(vitest@4.1.10(@types/node@26.1.1)(jsdom@27.4.0)(vite@7.3.5(@types/node@26.1.1)(sass@1.99.0)))': dependencies: '@ampproject/remapping': 2.3.0 - '@angular-devkit/architect': 0.2102.1(chokidar@5.0.0) - '@angular/compiler': 21.2.1 - '@angular/compiler-cli': 21.2.1(@angular/compiler@21.2.1)(typescript@5.9.3) - '@babel/core': 7.29.0 - '@babel/helper-annotate-as-pure': 7.27.3 + '@angular-devkit/architect': 0.2200.7(chokidar@5.0.0) + '@angular/compiler': 22.0.6 + '@angular/compiler-cli': 22.0.6(@angular/compiler@22.0.6)(typescript@6.0.3) + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 '@babel/helper-split-export-declaration': 7.24.7 - '@inquirer/confirm': 5.1.21(@types/node@25.3.5) - '@vitejs/plugin-basic-ssl': 2.1.4(vite@7.3.1(@types/node@25.3.5)(sass@1.97.3)) - beasties: 0.4.1 - browserslist: 4.28.1 - esbuild: 0.27.3 - https-proxy-agent: 7.0.6 - istanbul-lib-instrument: 6.0.3 + '@inquirer/confirm': 6.0.12(@types/node@26.1.1) + '@vitejs/plugin-basic-ssl': 2.3.0(vite@7.3.5(@types/node@26.1.1)(sass@1.99.0)) + beasties: 0.4.2 + browserslist: 4.28.6 + esbuild: 0.28.1 + https-proxy-agent: 9.0.0 jsonc-parser: 3.3.1 - listr2: 9.0.5 + listr2: 10.2.1 magic-string: 0.30.21 mrmime: 2.0.1 - parse5-html-rewriting-stream: 8.0.0 - picomatch: 4.0.3 - piscina: 5.1.4 - rolldown: 1.0.0-rc.4 - sass: 1.97.3 + parse5-html-rewriting-stream: 8.0.1 + picomatch: 4.0.4 + piscina: 5.2.0 + rollup: 4.60.2 + sass: 1.99.0 semver: 7.7.4 source-map-support: 0.5.21 - tinyglobby: 0.2.15 + tinyglobby: 0.2.16 tslib: 2.8.1 - typescript: 5.9.3 - undici: 7.22.0 - vite: 7.3.1(@types/node@25.3.5)(sass@1.97.3) + typescript: 6.0.3 + vite: 7.3.5(@types/node@26.1.1)(sass@1.99.0) watchpack: 2.5.1 optionalDependencies: - '@angular/core': 21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0) - '@angular/localize': 21.2.1(@angular/compiler-cli@21.2.1(@angular/compiler@21.2.1)(typescript@5.9.3))(@angular/compiler@21.2.1) - '@angular/platform-browser': 21.2.1(@angular/animations@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)) - '@angular/service-worker': 21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) - lmdb: 3.5.1 - postcss: 8.5.8 - vitest: 4.0.18(@types/node@25.3.5)(jsdom@27.4.0)(sass@1.97.3) + '@angular/core': 22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/localize': 22.0.6(@angular/compiler-cli@22.0.6(@angular/compiler@22.0.6)(typescript@6.0.3))(@angular/compiler@22.0.6) + '@angular/platform-browser': 22.0.6(@angular/animations@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)) + '@angular/service-worker': 22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + istanbul-lib-instrument: 6.0.3 + lmdb: 3.5.4 + postcss: 8.5.19 + vitest: 4.1.10(@types/node@26.1.1)(jsdom@27.4.0)(vite@7.3.5(@types/node@26.1.1)(sass@1.99.0)) transitivePeerDependencies: - '@types/node' - chokidar @@ -3689,115 +3689,116 @@ snapshots: - tsx - yaml - '@angular/cli@21.2.1(@types/node@25.3.5)(chokidar@5.0.0)': + '@angular/cli@22.0.7(@types/node@26.1.1)(chokidar@5.0.0)': dependencies: - '@angular-devkit/architect': 0.2102.1(chokidar@5.0.0) - '@angular-devkit/core': 21.2.1(chokidar@5.0.0) - '@angular-devkit/schematics': 21.2.1(chokidar@5.0.0) - '@inquirer/prompts': 7.10.1(@types/node@25.3.5) - '@listr2/prompt-adapter-inquirer': 3.0.5(@inquirer/prompts@7.10.1(@types/node@25.3.5))(@types/node@25.3.5)(listr2@9.0.5) - '@modelcontextprotocol/sdk': 1.26.0(zod@4.3.6) - '@schematics/angular': 21.2.1(chokidar@5.0.0) + '@angular-devkit/architect': 0.2200.7(chokidar@5.0.0) + '@angular-devkit/core': 22.0.7(chokidar@5.0.0) + '@angular-devkit/schematics': 22.0.7(chokidar@5.0.0) + '@inquirer/prompts': 8.4.2(@types/node@26.1.1) + '@listr2/prompt-adapter-inquirer': 4.2.3(@inquirer/prompts@8.4.2(@types/node@26.1.1))(@types/node@26.1.1)(listr2@10.2.1) + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.2) + '@schematics/angular': 22.0.7(chokidar@5.0.0) '@yarnpkg/lockfile': 1.1.0 - algoliasearch: 5.48.1 + algoliasearch: 5.52.0 ini: 6.0.0 jsonc-parser: 3.3.1 - listr2: 9.0.5 + listr2: 10.2.1 npm-package-arg: 13.0.2 - pacote: 21.3.1 - parse5-html-rewriting-stream: 8.0.0 + pacote: 21.5.1 + parse5-html-rewriting-stream: 8.0.1 semver: 7.7.4 yargs: 18.0.0 - zod: 4.3.6 + zod: 4.4.2 transitivePeerDependencies: - '@cfworker/json-schema' - '@types/node' - chokidar - supports-color - '@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2)': + '@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2)': dependencies: - '@angular/core': 21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/core': 22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0) rxjs: 7.8.2 tslib: 2.8.1 - '@angular/compiler-cli@21.2.1(@angular/compiler@21.2.1)(typescript@5.9.3)': + '@angular/compiler-cli@22.0.6(@angular/compiler@22.0.6)(typescript@6.0.3)': dependencies: - '@angular/compiler': 21.2.1 - '@babel/core': 7.29.0 + '@angular/compiler': 22.0.6 + '@babel/core': 7.29.7 '@jridgewell/sourcemap-codec': 1.5.5 chokidar: 5.0.0 convert-source-map: 1.9.0 reflect-metadata: 0.2.2 - semver: 7.7.4 + semver: 7.8.5 tslib: 2.8.1 yargs: 18.0.0 optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@angular/compiler@21.2.1': + '@angular/compiler@22.0.6': dependencies: tslib: 2.8.1 - '@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)': + '@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)': dependencies: rxjs: 7.8.2 tslib: 2.8.1 optionalDependencies: - '@angular/compiler': 21.2.1 + '@angular/compiler': 22.0.6 zone.js: 0.15.0 - '@angular/forms@21.2.1(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.2.1(@angular/animations@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2)': + '@angular/forms@22.0.6(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@22.0.6(@angular/animations@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2)': dependencies: - '@angular/common': 21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) - '@angular/core': 21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0) - '@angular/platform-browser': 21.2.1(@angular/animations@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)) + '@angular/common': 22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + '@angular/core': 22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/platform-browser': 22.0.6(@angular/animations@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)) '@standard-schema/spec': 1.1.0 rxjs: 7.8.2 tslib: 2.8.1 + zod: 4.4.3 - '@angular/localize@21.2.1(@angular/compiler-cli@21.2.1(@angular/compiler@21.2.1)(typescript@5.9.3))(@angular/compiler@21.2.1)': + '@angular/localize@22.0.6(@angular/compiler-cli@22.0.6(@angular/compiler@22.0.6)(typescript@6.0.3))(@angular/compiler@22.0.6)': dependencies: - '@angular/compiler': 21.2.1 - '@angular/compiler-cli': 21.2.1(@angular/compiler@21.2.1)(typescript@5.9.3) - '@babel/core': 7.29.0 + '@angular/compiler': 22.0.6 + '@angular/compiler-cli': 22.0.6(@angular/compiler@22.0.6)(typescript@6.0.3) + '@babel/core': 7.29.7 '@types/babel__core': 7.20.5 - tinyglobby: 0.2.15 + tinyglobby: 0.2.17 yargs: 18.0.0 transitivePeerDependencies: - supports-color - '@angular/platform-browser-dynamic@21.2.1(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/compiler@21.2.1)(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.2.1(@angular/animations@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)))': + '@angular/platform-browser-dynamic@22.0.6(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/compiler@22.0.6)(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@22.0.6(@angular/animations@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)))': dependencies: - '@angular/common': 21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) - '@angular/compiler': 21.2.1 - '@angular/core': 21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0) - '@angular/platform-browser': 21.2.1(@angular/animations@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)) + '@angular/common': 22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + '@angular/compiler': 22.0.6 + '@angular/core': 22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/platform-browser': 22.0.6(@angular/animations@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)) tslib: 2.8.1 - '@angular/platform-browser@21.2.1(@angular/animations@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))': + '@angular/platform-browser@22.0.6(@angular/animations@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))': dependencies: - '@angular/common': 21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) - '@angular/core': 21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/common': 22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + '@angular/core': 22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0) tslib: 2.8.1 optionalDependencies: - '@angular/animations': 21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)) + '@angular/animations': 22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)) - '@angular/service-worker@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2)': + '@angular/service-worker@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2)': dependencies: - '@angular/core': 21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/core': 22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0) rxjs: 7.8.2 tslib: 2.8.1 '@asamuzakjp/css-color@4.1.2': dependencies: - '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.9(@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.2.6 + lru-cache: 11.5.2 '@asamuzakjp/dom-selector@6.8.1': dependencies: @@ -3805,29 +3806,29 @@ snapshots: bidi-js: 1.0.3 css-tree: 3.2.1 is-potential-custom-element-name: 1.0.1 - lru-cache: 11.2.6 + lru-cache: 11.5.2 '@asamuzakjp/nwsapi@2.3.9': {} - '@babel/code-frame@7.29.0': + '@babel/code-frame@7.29.7': dependencies: - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.29.0': {} + '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.0': + '@babel/core@7.29.7': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.28.6 - '@babel/parser': 7.29.0 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3 @@ -3837,97 +3838,97 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.29.1': + '@babel/generator@7.29.7': dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/helper-annotate-as-pure@7.27.3': + '@babel/helper-annotate-as-pure@7.29.7': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 - '@babel/helper-compilation-targets@7.28.6': + '@babel/helper-compilation-targets@7.29.7': dependencies: - '@babel/compat-data': 7.29.0 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.1 + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.6 lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-globals@7.28.0': {} + '@babel/helper-globals@7.29.7': {} - '@babel/helper-module-imports@7.28.6': + '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color '@babel/helper-split-export-declaration@7.24.7': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 - '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} - '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} - '@babel/helper-validator-option@7.27.1': {} + '@babel/helper-validator-option@7.29.7': {} - '@babel/helpers@7.28.6': + '@babel/helpers@7.29.7': dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 - '@babel/parser@7.29.0': + '@babel/parser@7.29.7': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 - '@babel/template@7.28.6': + '@babel/template@7.29.7': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 - '@babel/traverse@7.29.0': + '@babel/traverse@7.29.7': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.0 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 debug: 4.4.3 transitivePeerDependencies: - supports-color - '@babel/types@7.29.0': + '@babel/types@7.29.7': dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 - '@csstools/color-helpers@6.0.2': {} + '@csstools/color-helpers@6.1.0': {} - '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + '@csstools/css-calc@3.2.1(@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.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + '@csstools/css-color-parser@4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: - '@csstools/color-helpers': 6.0.2 - '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.2.1(@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 @@ -3935,112 +3936,176 @@ snapshots: dependencies: '@csstools/css-tokenizer': 4.0.0 - '@csstools/css-syntax-patches-for-csstree@1.1.0': {} + '@csstools/css-syntax-patches-for-csstree@1.1.6(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 '@csstools/css-tokenizer@4.0.0': {} - '@emnapi/core@1.8.1': + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.5)': dependencies: - '@emnapi/wasi-threads': 1.1.0 - tslib: 2.8.1 - optional: true - - '@emnapi/runtime@1.8.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@emnapi/wasi-threads@1.1.0': - dependencies: - tslib: 2.8.1 - optional: true - - '@esbuild/aix-ppc64@0.27.3': - optional: true - - '@esbuild/android-arm64@0.27.3': - optional: true - - '@esbuild/android-arm@0.27.3': - optional: true - - '@esbuild/android-x64@0.27.3': - optional: true - - '@esbuild/darwin-arm64@0.27.3': - optional: true - - '@esbuild/darwin-x64@0.27.3': - optional: true - - '@esbuild/freebsd-arm64@0.27.3': - optional: true - - '@esbuild/freebsd-x64@0.27.3': - optional: true - - '@esbuild/linux-arm64@0.27.3': - optional: true - - '@esbuild/linux-arm@0.27.3': - optional: true - - '@esbuild/linux-ia32@0.27.3': - optional: true - - '@esbuild/linux-loong64@0.27.3': - optional: true - - '@esbuild/linux-mips64el@0.27.3': - optional: true - - '@esbuild/linux-ppc64@0.27.3': - optional: true - - '@esbuild/linux-riscv64@0.27.3': - optional: true - - '@esbuild/linux-s390x@0.27.3': - optional: true - - '@esbuild/linux-x64@0.27.3': - optional: true - - '@esbuild/netbsd-arm64@0.27.3': - optional: true - - '@esbuild/netbsd-x64@0.27.3': - optional: true - - '@esbuild/openbsd-arm64@0.27.3': - optional: true - - '@esbuild/openbsd-x64@0.27.3': - optional: true - - '@esbuild/openharmony-arm64@0.27.3': - optional: true - - '@esbuild/sunos-x64@0.27.3': - optional: true - - '@esbuild/win32-arm64@0.27.3': - optional: true - - '@esbuild/win32-ia32@0.27.3': - optional: true - - '@esbuild/win32-x64@0.27.3': - optional: true - - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.3)': - dependencies: - eslint: 9.39.3 + eslint: 9.39.5 eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.1': + '@eslint/config-array@0.21.2': dependencies: '@eslint/object-schema': 2.1.7 debug: 4.4.3 @@ -4056,21 +4121,21 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.4': + '@eslint/eslintrc@3.3.6': dependencies: - ajv: 6.14.0 + ajv: 6.15.0 debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.3.0 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color - '@eslint/js@9.39.3': {} + '@eslint/js@9.39.5': {} '@eslint/object-schema@2.1.7': {} @@ -4079,184 +4144,189 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@exodus/bytes@1.15.0': {} + '@exodus/bytes@1.15.1': {} - '@fortawesome/angular-fontawesome@4.0.0(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))': + '@fortawesome/angular-fontawesome@4.0.0(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))': dependencies: - '@angular/core': 21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0) - '@fortawesome/fontawesome-svg-core': 7.2.0 + '@angular/core': 22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0) + '@fortawesome/fontawesome-svg-core': 7.3.1 tslib: 2.8.1 - '@fortawesome/fontawesome-common-types@7.2.0': {} + '@fortawesome/fontawesome-common-types@7.3.1': {} - '@fortawesome/fontawesome-svg-core@7.2.0': + '@fortawesome/fontawesome-svg-core@7.3.1': dependencies: - '@fortawesome/fontawesome-common-types': 7.2.0 + '@fortawesome/fontawesome-common-types': 7.3.1 - '@fortawesome/free-brands-svg-icons@7.2.0': + '@fortawesome/free-brands-svg-icons@7.3.1': dependencies: - '@fortawesome/fontawesome-common-types': 7.2.0 + '@fortawesome/fontawesome-common-types': 7.3.1 - '@fortawesome/free-regular-svg-icons@7.2.0': + '@fortawesome/free-regular-svg-icons@7.3.1': dependencies: - '@fortawesome/fontawesome-common-types': 7.2.0 + '@fortawesome/fontawesome-common-types': 7.3.1 - '@fortawesome/free-solid-svg-icons@7.2.0': + '@fortawesome/free-solid-svg-icons@7.3.1': dependencies: - '@fortawesome/fontawesome-common-types': 7.2.0 + '@fortawesome/fontawesome-common-types': 7.3.1 - '@gar/promise-retry@1.0.2': - dependencies: - retry: 0.13.1 + '@gar/promise-retry@1.0.3': {} '@harperfast/extended-iterable@1.0.3': optional: true - '@hono/node-server@1.19.11(hono@4.12.5)': + '@hono/node-server@1.19.14(hono@4.12.30)': dependencies: - hono: 4.12.5 + hono: 4.12.30 - '@humanfs/core@0.19.1': {} - - '@humanfs/node@0.16.7': + '@humanfs/core@0.19.2': dependencies: - '@humanfs/core': 0.19.1 + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 '@humanwhocodes/retry': 0.4.3 + '@humanfs/types@0.15.0': {} + '@humanwhocodes/module-importer@1.0.1': {} '@humanwhocodes/retry@0.4.3': {} - '@inquirer/ansi@1.0.2': {} + '@inquirer/ansi@2.0.7': {} - '@inquirer/checkbox@4.3.2(@types/node@25.3.5)': + '@inquirer/checkbox@5.2.1(@types/node@26.1.1)': dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@25.3.5) - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@25.3.5) - yoctocolors-cjs: 2.1.3 + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@26.1.1) optionalDependencies: - '@types/node': 25.3.5 + '@types/node': 26.1.1 - '@inquirer/confirm@5.1.21(@types/node@25.3.5)': + '@inquirer/confirm@6.0.12(@types/node@26.1.1)': dependencies: - '@inquirer/core': 10.3.2(@types/node@25.3.5) - '@inquirer/type': 3.0.10(@types/node@25.3.5) + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/type': 4.0.7(@types/node@26.1.1) optionalDependencies: - '@types/node': 25.3.5 + '@types/node': 26.1.1 - '@inquirer/core@10.3.2(@types/node@25.3.5)': + '@inquirer/confirm@6.1.1(@types/node@26.1.1)': dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@25.3.5) + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/type': 4.0.7(@types/node@26.1.1) + optionalDependencies: + '@types/node': 26.1.1 + + '@inquirer/core@11.2.1(@types/node@26.1.1)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@26.1.1) cli-width: 4.1.0 - mute-stream: 2.0.0 + fast-wrap-ansi: 0.2.2 + mute-stream: 3.0.0 signal-exit: 4.1.0 - wrap-ansi: 6.2.0 - yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 25.3.5 + '@types/node': 26.1.1 - '@inquirer/editor@4.2.23(@types/node@25.3.5)': + '@inquirer/editor@5.2.2(@types/node@26.1.1)': dependencies: - '@inquirer/core': 10.3.2(@types/node@25.3.5) - '@inquirer/external-editor': 1.0.3(@types/node@25.3.5) - '@inquirer/type': 3.0.10(@types/node@25.3.5) + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/external-editor': 3.0.3(@types/node@26.1.1) + '@inquirer/type': 4.0.7(@types/node@26.1.1) optionalDependencies: - '@types/node': 25.3.5 + '@types/node': 26.1.1 - '@inquirer/expand@4.0.23(@types/node@25.3.5)': + '@inquirer/expand@5.1.1(@types/node@26.1.1)': dependencies: - '@inquirer/core': 10.3.2(@types/node@25.3.5) - '@inquirer/type': 3.0.10(@types/node@25.3.5) - yoctocolors-cjs: 2.1.3 + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/type': 4.0.7(@types/node@26.1.1) optionalDependencies: - '@types/node': 25.3.5 + '@types/node': 26.1.1 - '@inquirer/external-editor@1.0.3(@types/node@25.3.5)': + '@inquirer/external-editor@3.0.3(@types/node@26.1.1)': dependencies: - chardet: 2.1.1 - iconv-lite: 0.7.2 + chardet: 2.2.0 + iconv-lite: 0.7.3 optionalDependencies: - '@types/node': 25.3.5 + '@types/node': 26.1.1 - '@inquirer/figures@1.0.15': {} + '@inquirer/figures@2.0.7': {} - '@inquirer/input@4.3.1(@types/node@25.3.5)': + '@inquirer/input@5.1.2(@types/node@26.1.1)': dependencies: - '@inquirer/core': 10.3.2(@types/node@25.3.5) - '@inquirer/type': 3.0.10(@types/node@25.3.5) + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/type': 4.0.7(@types/node@26.1.1) optionalDependencies: - '@types/node': 25.3.5 + '@types/node': 26.1.1 - '@inquirer/number@3.0.23(@types/node@25.3.5)': + '@inquirer/number@4.1.1(@types/node@26.1.1)': dependencies: - '@inquirer/core': 10.3.2(@types/node@25.3.5) - '@inquirer/type': 3.0.10(@types/node@25.3.5) + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/type': 4.0.7(@types/node@26.1.1) optionalDependencies: - '@types/node': 25.3.5 + '@types/node': 26.1.1 - '@inquirer/password@4.0.23(@types/node@25.3.5)': + '@inquirer/password@5.1.1(@types/node@26.1.1)': dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@25.3.5) - '@inquirer/type': 3.0.10(@types/node@25.3.5) + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/type': 4.0.7(@types/node@26.1.1) optionalDependencies: - '@types/node': 25.3.5 + '@types/node': 26.1.1 - '@inquirer/prompts@7.10.1(@types/node@25.3.5)': + '@inquirer/prompts@8.4.2(@types/node@26.1.1)': dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@25.3.5) - '@inquirer/confirm': 5.1.21(@types/node@25.3.5) - '@inquirer/editor': 4.2.23(@types/node@25.3.5) - '@inquirer/expand': 4.0.23(@types/node@25.3.5) - '@inquirer/input': 4.3.1(@types/node@25.3.5) - '@inquirer/number': 3.0.23(@types/node@25.3.5) - '@inquirer/password': 4.0.23(@types/node@25.3.5) - '@inquirer/rawlist': 4.1.11(@types/node@25.3.5) - '@inquirer/search': 3.2.2(@types/node@25.3.5) - '@inquirer/select': 4.4.2(@types/node@25.3.5) + '@inquirer/checkbox': 5.2.1(@types/node@26.1.1) + '@inquirer/confirm': 6.1.1(@types/node@26.1.1) + '@inquirer/editor': 5.2.2(@types/node@26.1.1) + '@inquirer/expand': 5.1.1(@types/node@26.1.1) + '@inquirer/input': 5.1.2(@types/node@26.1.1) + '@inquirer/number': 4.1.1(@types/node@26.1.1) + '@inquirer/password': 5.1.1(@types/node@26.1.1) + '@inquirer/rawlist': 5.3.1(@types/node@26.1.1) + '@inquirer/search': 4.2.1(@types/node@26.1.1) + '@inquirer/select': 5.2.1(@types/node@26.1.1) optionalDependencies: - '@types/node': 25.3.5 + '@types/node': 26.1.1 - '@inquirer/rawlist@4.1.11(@types/node@25.3.5)': + '@inquirer/rawlist@5.3.1(@types/node@26.1.1)': dependencies: - '@inquirer/core': 10.3.2(@types/node@25.3.5) - '@inquirer/type': 3.0.10(@types/node@25.3.5) - yoctocolors-cjs: 2.1.3 + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/type': 4.0.7(@types/node@26.1.1) optionalDependencies: - '@types/node': 25.3.5 + '@types/node': 26.1.1 - '@inquirer/search@3.2.2(@types/node@25.3.5)': + '@inquirer/search@4.2.1(@types/node@26.1.1)': dependencies: - '@inquirer/core': 10.3.2(@types/node@25.3.5) - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@25.3.5) - yoctocolors-cjs: 2.1.3 + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@26.1.1) optionalDependencies: - '@types/node': 25.3.5 + '@types/node': 26.1.1 - '@inquirer/select@4.4.2(@types/node@25.3.5)': + '@inquirer/select@5.2.1(@types/node@26.1.1)': dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@25.3.5) - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@25.3.5) - yoctocolors-cjs: 2.1.3 + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@26.1.1) optionalDependencies: - '@types/node': 25.3.5 + '@types/node': 26.1.1 - '@inquirer/type@3.0.10(@types/node@25.3.5)': + '@inquirer/type@4.0.7(@types/node@26.1.1)': optionalDependencies: - '@types/node': 25.3.5 + '@types/node': 26.1.1 '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.3 - '@istanbuljs/schema@0.1.3': {} + '@istanbuljs/schema@0.1.6': + optional: true '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -4277,73 +4347,73 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@listr2/prompt-adapter-inquirer@3.0.5(@inquirer/prompts@7.10.1(@types/node@25.3.5))(@types/node@25.3.5)(listr2@9.0.5)': + '@listr2/prompt-adapter-inquirer@4.2.3(@inquirer/prompts@8.4.2(@types/node@26.1.1))(@types/node@26.1.1)(listr2@10.2.1)': dependencies: - '@inquirer/prompts': 7.10.1(@types/node@25.3.5) - '@inquirer/type': 3.0.10(@types/node@25.3.5) - listr2: 9.0.5 + '@inquirer/prompts': 8.4.2(@types/node@26.1.1) + '@inquirer/type': 4.0.7(@types/node@26.1.1) + listr2: 10.2.1 transitivePeerDependencies: - '@types/node' - '@lmdb/lmdb-darwin-arm64@3.5.1': + '@lmdb/lmdb-darwin-arm64@3.5.4': optional: true - '@lmdb/lmdb-darwin-x64@3.5.1': + '@lmdb/lmdb-darwin-x64@3.5.4': optional: true - '@lmdb/lmdb-linux-arm64@3.5.1': + '@lmdb/lmdb-linux-arm64@3.5.4': optional: true - '@lmdb/lmdb-linux-arm@3.5.1': + '@lmdb/lmdb-linux-arm@3.5.4': optional: true - '@lmdb/lmdb-linux-x64@3.5.1': + '@lmdb/lmdb-linux-x64@3.5.4': optional: true - '@lmdb/lmdb-win32-arm64@3.5.1': + '@lmdb/lmdb-win32-arm64@3.5.4': optional: true - '@lmdb/lmdb-win32-x64@3.5.1': + '@lmdb/lmdb-win32-x64@3.5.4': optional: true - '@modelcontextprotocol/sdk@1.26.0(zod@4.3.6)': + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.2)': dependencies: - '@hono/node-server': 1.19.11(hono@4.12.5) - ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) + '@hono/node-server': 1.19.14(hono@4.12.30) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 cors: 2.8.6 cross-spawn: 7.0.6 eventsource: 3.0.7 - eventsource-parser: 3.0.6 + eventsource-parser: 3.1.0 express: 5.2.1 - express-rate-limit: 8.3.0(express@5.2.1) - hono: 4.12.5 - jose: 6.2.0 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.30 + jose: 6.2.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 - zod: 4.3.6 - zod-to-json-schema: 3.25.1(zod@4.3.6) + zod: 4.4.2 + zod-to-json-schema: 3.25.2(zod@4.4.2) transitivePeerDependencies: - supports-color - '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': optional: true - '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': optional: true - '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': optional: true - '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': optional: true - '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': optional: true - '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': optional: true '@napi-rs/nice-android-arm-eabi@1.1.1': @@ -4418,48 +4488,29 @@ snapshots: '@napi-rs/nice-win32-x64-msvc': 1.1.1 optional: true - '@napi-rs/wasm-runtime@1.1.1': + '@ng-bootstrap/ng-bootstrap@21.0.0(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/forms@22.0.6(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@22.0.6(@angular/animations@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2))(@angular/localize@22.0.6(@angular/compiler-cli@22.0.6(@angular/compiler@22.0.6)(typescript@6.0.3))(@angular/compiler@22.0.6))(@popperjs/core@2.11.8)(rxjs@7.8.2)': dependencies: - '@emnapi/core': 1.8.1 - '@emnapi/runtime': 1.8.1 - '@tybys/wasm-util': 0.10.1 - optional: true - - '@ng-bootstrap/ng-bootstrap@20.0.0(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/forms@21.2.1(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.2.1(@angular/animations@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2))(@angular/localize@21.2.1(@angular/compiler-cli@21.2.1(@angular/compiler@21.2.1)(typescript@5.9.3))(@angular/compiler@21.2.1))(@popperjs/core@2.11.8)(rxjs@7.8.2)': - dependencies: - '@angular/common': 21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) - '@angular/core': 21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0) - '@angular/forms': 21.2.1(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.2.1(@angular/animations@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2) - '@angular/localize': 21.2.1(@angular/compiler-cli@21.2.1(@angular/compiler@21.2.1)(typescript@5.9.3))(@angular/compiler@21.2.1) + '@angular/common': 22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + '@angular/core': 22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/forms': 22.0.6(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@22.0.6(@angular/animations@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2) + '@angular/localize': 22.0.6(@angular/compiler-cli@22.0.6(@angular/compiler@22.0.6)(typescript@6.0.3))(@angular/compiler@22.0.6) '@popperjs/core': 2.11.8 rxjs: 7.8.2 tslib: 2.8.1 - '@ng-select/ng-select@21.5.2(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/forms@21.2.1(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.2.1(@angular/animations@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2))': + '@ng-select/ng-select@23.2.0(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/forms@22.0.6(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@22.0.6(@angular/animations@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2))': dependencies: - '@angular/common': 21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) - '@angular/core': 21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0) - '@angular/forms': 21.2.1(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.2.1(@angular/animations@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2) + '@angular/common': 22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + '@angular/core': 22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/forms': 22.0.6(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@22.0.6(@angular/animations@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2) tslib: 2.8.1 - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 - - '@npmcli/agent@4.0.0': + '@npmcli/agent@4.0.2': dependencies: agent-base: 7.1.4 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 - lru-cache: 11.2.6 + lru-cache: 11.5.2 socks-proxy-agent: 8.0.5 transitivePeerDependencies: - supports-color @@ -4470,10 +4521,10 @@ snapshots: '@npmcli/git@7.0.2': dependencies: - '@gar/promise-retry': 1.0.2 + '@gar/promise-retry': 1.0.3 '@npmcli/promise-spawn': 9.0.1 ini: 6.0.0 - lru-cache: 11.2.6 + lru-cache: 11.5.2 npm-pick-manifest: 11.0.3 proc-log: 6.1.0 semver: 7.7.4 @@ -4490,7 +4541,7 @@ snapshots: dependencies: '@npmcli/git': 7.0.2 glob: 13.0.6 - hosted-git-info: 9.0.2 + hosted-git-info: 9.0.3 json-parse-even-better-errors: 5.0.0 proc-log: 6.1.0 semver: 7.7.4 @@ -4507,12 +4558,8 @@ snapshots: '@npmcli/node-gyp': 5.0.0 '@npmcli/package-json': 7.0.5 '@npmcli/promise-spawn': 9.0.1 - node-gyp: 12.2.0 + node-gyp: 12.4.0 proc-log: 6.1.0 - transitivePeerDependencies: - - supports-color - - '@oxc-project/types@0.113.0': {} '@parcel/watcher-android-arm64@2.5.6': optional: true @@ -4558,7 +4605,7 @@ snapshots: detect-libc: 2.1.2 is-glob: 4.0.3 node-addon-api: 7.1.1 - picomatch: 4.0.3 + picomatch: 4.0.4 optionalDependencies: '@parcel/watcher-android-arm64': 2.5.6 '@parcel/watcher-darwin-arm64': 2.5.6 @@ -4577,163 +4624,121 @@ snapshots: '@popperjs/core@2.11.8': {} - '@rolldown/binding-android-arm64@1.0.0-rc.4': + '@rollup/rollup-android-arm-eabi@4.60.2': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-rc.4': + '@rollup/rollup-android-arm64@4.60.2': optional: true - '@rolldown/binding-darwin-x64@1.0.0-rc.4': + '@rollup/rollup-darwin-arm64@4.60.2': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-rc.4': + '@rollup/rollup-darwin-x64@4.60.2': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.4': + '@rollup/rollup-freebsd-arm64@4.60.2': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.4': + '@rollup/rollup-freebsd-x64@4.60.2': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.4': + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.4': + '@rollup/rollup-linux-arm-musleabihf@4.60.2': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-rc.4': + '@rollup/rollup-linux-arm64-gnu@4.60.2': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-rc.4': + '@rollup/rollup-linux-arm64-musl@4.60.2': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.4': + '@rollup/rollup-linux-loong64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.2': + optional: true + + '@schematics/angular@22.0.7(chokidar@5.0.0)': dependencies: - '@napi-rs/wasm-runtime': 1.1.1 - optional: true - - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.4': - optional: true - - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.4': - optional: true - - '@rolldown/pluginutils@1.0.0-rc.4': {} - - '@rollup/rollup-android-arm-eabi@4.59.0': - optional: true - - '@rollup/rollup-android-arm64@4.59.0': - optional: true - - '@rollup/rollup-darwin-arm64@4.59.0': - optional: true - - '@rollup/rollup-darwin-x64@4.59.0': - optional: true - - '@rollup/rollup-freebsd-arm64@4.59.0': - optional: true - - '@rollup/rollup-freebsd-x64@4.59.0': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.59.0': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.59.0': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.59.0': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.59.0': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.59.0': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.59.0': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-x64-musl@4.59.0': - optional: true - - '@rollup/rollup-openbsd-x64@4.59.0': - optional: true - - '@rollup/rollup-openharmony-arm64@4.59.0': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.59.0': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.59.0': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.59.0': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.59.0': - optional: true - - '@schematics/angular@21.2.1(chokidar@5.0.0)': - dependencies: - '@angular-devkit/core': 21.2.1(chokidar@5.0.0) - '@angular-devkit/schematics': 21.2.1(chokidar@5.0.0) + '@angular-devkit/core': 22.0.7(chokidar@5.0.0) + '@angular-devkit/schematics': 22.0.7(chokidar@5.0.0) jsonc-parser: 3.3.1 + typescript: 6.0.3 transitivePeerDependencies: - chokidar '@sigstore/bundle@4.0.0': dependencies: - '@sigstore/protobuf-specs': 0.5.0 + '@sigstore/protobuf-specs': 0.5.1 - '@sigstore/core@3.1.0': {} + '@sigstore/core@3.2.1': {} - '@sigstore/protobuf-specs@0.5.0': {} + '@sigstore/protobuf-specs@0.5.1': {} - '@sigstore/sign@4.1.0': + '@sigstore/sign@4.1.1': dependencies: + '@gar/promise-retry': 1.0.3 '@sigstore/bundle': 4.0.0 - '@sigstore/core': 3.1.0 - '@sigstore/protobuf-specs': 0.5.0 - make-fetch-happen: 15.0.4 + '@sigstore/core': 3.2.1 + '@sigstore/protobuf-specs': 0.5.1 + make-fetch-happen: 15.0.6 proc-log: 6.1.0 - promise-retry: 2.0.1 transitivePeerDependencies: - supports-color - '@sigstore/tuf@4.0.1': + '@sigstore/tuf@4.0.2': dependencies: - '@sigstore/protobuf-specs': 0.5.0 + '@sigstore/protobuf-specs': 0.5.1 tuf-js: 4.1.0 transitivePeerDependencies: - supports-color - '@sigstore/verify@3.1.0': + '@sigstore/verify@3.1.1': dependencies: '@sigstore/bundle': 4.0.0 - '@sigstore/core': 3.1.0 - '@sigstore/protobuf-specs': 0.5.0 + '@sigstore/core': 3.2.1 + '@sigstore/protobuf-specs': 0.5.1 '@socket.io/component-emitter@3.1.2': {} @@ -4744,33 +4749,28 @@ snapshots: '@tufjs/models@4.1.0': dependencies: '@tufjs/canonical-json': 2.0.0 - minimatch: 10.2.4 - - '@tybys/wasm-util@0.10.1': - dependencies: - tslib: 2.8.1 - optional: true + minimatch: 10.2.5 '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/chai@5.2.3': dependencies: @@ -4779,7 +4779,7 @@ snapshots: '@types/cors@2.8.19': dependencies: - '@types/node': 25.3.5 + '@types/node': 26.1.1 '@types/deep-eql@4.0.2': {} @@ -4787,198 +4787,204 @@ snapshots: '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} + '@types/json-schema@7.0.15': {} - '@types/node@25.3.5': + '@types/node@26.1.1': dependencies: - undici-types: 7.18.2 + undici-types: 8.3.0 - '@typescript-eslint/eslint-plugin@8.47.0(@typescript-eslint/parser@8.47.0(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3)(typescript@5.9.3)': + '@types/ws@8.18.1': + dependencies: + '@types/node': 26.1.1 + + '@typescript-eslint/eslint-plugin@8.62.0(@typescript-eslint/parser@8.62.0(eslint@9.39.5)(typescript@6.0.3))(eslint@9.39.5)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.47.0(eslint@9.39.3)(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.47.0 - '@typescript-eslint/type-utils': 8.47.0(eslint@9.39.3)(typescript@5.9.3) - '@typescript-eslint/utils': 8.47.0(eslint@9.39.3)(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.47.0 - eslint: 9.39.3 - graphemer: 1.4.0 - ignore: 7.0.5 + '@typescript-eslint/parser': 8.62.0(eslint@9.39.5)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.62.0 + '@typescript-eslint/type-utils': 8.62.0(eslint@9.39.5)(typescript@6.0.3) + '@typescript-eslint/utils': 8.62.0(eslint@9.39.5)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.62.0 + eslint: 9.39.5 + ignore: 7.0.6 natural-compare: 1.4.0 - ts-api-utils: 2.4.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.47.0(eslint@9.39.3)(typescript@5.9.3)': + '@typescript-eslint/parser@8.62.0(eslint@9.39.5)(typescript@6.0.3)': dependencies: - '@typescript-eslint/scope-manager': 8.47.0 - '@typescript-eslint/types': 8.47.0 - '@typescript-eslint/typescript-estree': 8.47.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.47.0 + '@typescript-eslint/scope-manager': 8.62.0 + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/typescript-estree': 8.62.0(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.62.0 debug: 4.4.3 - eslint: 9.39.3 - typescript: 5.9.3 + eslint: 9.39.5 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.47.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.62.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.47.0(typescript@5.9.3) - '@typescript-eslint/types': 8.47.0 + '@typescript-eslint/tsconfig-utils': 8.62.0(typescript@6.0.3) + '@typescript-eslint/types': 8.62.0 debug: 4.4.3 - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.56.1(typescript@5.9.3)': + '@typescript-eslint/project-service@8.64.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) - '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/tsconfig-utils': 8.64.0(typescript@6.0.3) + '@typescript-eslint/types': 8.64.0 debug: 4.4.3 - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.47.0': + '@typescript-eslint/scope-manager@8.62.0': dependencies: - '@typescript-eslint/types': 8.47.0 - '@typescript-eslint/visitor-keys': 8.47.0 + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/visitor-keys': 8.62.0 - '@typescript-eslint/scope-manager@8.56.1': + '@typescript-eslint/scope-manager@8.64.0': dependencies: - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/visitor-keys': 8.56.1 + '@typescript-eslint/types': 8.64.0 + '@typescript-eslint/visitor-keys': 8.64.0 - '@typescript-eslint/tsconfig-utils@8.47.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.62.0(typescript@6.0.3)': dependencies: - typescript: 5.9.3 + typescript: 6.0.3 - '@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.64.0(typescript@6.0.3)': dependencies: - typescript: 5.9.3 + typescript: 6.0.3 - '@typescript-eslint/type-utils@8.47.0(eslint@9.39.3)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.62.0(eslint@9.39.5)(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.47.0 - '@typescript-eslint/typescript-estree': 8.47.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.47.0(eslint@9.39.3)(typescript@5.9.3) + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/typescript-estree': 8.62.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.62.0(eslint@9.39.5)(typescript@6.0.3) debug: 4.4.3 - eslint: 9.39.3 - ts-api-utils: 2.4.0(typescript@5.9.3) - typescript: 5.9.3 + eslint: 9.39.5 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.47.0': {} + '@typescript-eslint/types@8.62.0': {} - '@typescript-eslint/types@8.56.1': {} + '@typescript-eslint/types@8.64.0': {} - '@typescript-eslint/typescript-estree@8.47.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.62.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.47.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.47.0(typescript@5.9.3) - '@typescript-eslint/types': 8.47.0 - '@typescript-eslint/visitor-keys': 8.47.0 + '@typescript-eslint/project-service': 8.62.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.62.0(typescript@6.0.3) + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/visitor-keys': 8.62.0 debug: 4.4.3 - fast-glob: 3.3.3 - is-glob: 4.0.3 - minimatch: 9.0.9 - semver: 7.7.4 - ts-api-utils: 2.4.0(typescript@5.9.3) - typescript: 5.9.3 + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.64.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.56.1(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/visitor-keys': 8.56.1 + '@typescript-eslint/project-service': 8.64.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.64.0(typescript@6.0.3) + '@typescript-eslint/types': 8.64.0 + '@typescript-eslint/visitor-keys': 8.64.0 debug: 4.4.3 - minimatch: 10.2.4 - semver: 7.7.4 - tinyglobby: 0.2.15 - ts-api-utils: 2.4.0(typescript@5.9.3) - typescript: 5.9.3 + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.47.0(eslint@9.39.3)(typescript@5.9.3)': + '@typescript-eslint/utils@8.62.0(eslint@9.39.5)(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3) - '@typescript-eslint/scope-manager': 8.47.0 - '@typescript-eslint/types': 8.47.0 - '@typescript-eslint/typescript-estree': 8.47.0(typescript@5.9.3) - eslint: 9.39.3 - typescript: 5.9.3 + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5) + '@typescript-eslint/scope-manager': 8.62.0 + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/typescript-estree': 8.62.0(typescript@6.0.3) + eslint: 9.39.5 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.56.1(eslint@9.39.3)(typescript@5.9.3)': + '@typescript-eslint/utils@8.64.0(eslint@9.39.5)(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3) - '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - eslint: 9.39.3 - typescript: 5.9.3 + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5) + '@typescript-eslint/scope-manager': 8.64.0 + '@typescript-eslint/types': 8.64.0 + '@typescript-eslint/typescript-estree': 8.64.0(typescript@6.0.3) + eslint: 9.39.5 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.47.0': + '@typescript-eslint/visitor-keys@8.62.0': dependencies: - '@typescript-eslint/types': 8.47.0 - eslint-visitor-keys: 4.2.1 - - '@typescript-eslint/visitor-keys@8.56.1': - dependencies: - '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/types': 8.62.0 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-basic-ssl@2.1.4(vite@7.3.1(@types/node@25.3.5)(sass@1.97.3))': + '@typescript-eslint/visitor-keys@8.64.0': dependencies: - vite: 7.3.1(@types/node@25.3.5)(sass@1.97.3) + '@typescript-eslint/types': 8.64.0 + eslint-visitor-keys: 5.0.1 - '@vitest/expect@4.0.18': + '@vitejs/plugin-basic-ssl@2.3.0(vite@7.3.5(@types/node@26.1.1)(sass@1.99.0))': + dependencies: + vite: 7.3.5(@types/node@26.1.1)(sass@1.99.0) + + '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.0.18 - '@vitest/utils': 4.0.18 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 chai: 6.2.2 - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.3.5)(sass@1.97.3))': + '@vitest/mocker@4.1.10(vite@7.3.5(@types/node@26.1.1)(sass@1.99.0))': dependencies: - '@vitest/spy': 4.0.18 + '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.3.5)(sass@1.97.3) + vite: 7.3.5(@types/node@26.1.1)(sass@1.99.0) - '@vitest/pretty-format@4.0.18': + '@vitest/pretty-format@4.1.10': dependencies: - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 - '@vitest/runner@4.0.18': + '@vitest/runner@4.1.10': dependencies: - '@vitest/utils': 4.0.18 + '@vitest/utils': 4.1.10 pathe: 2.0.3 - '@vitest/snapshot@4.0.18': + '@vitest/snapshot@4.1.10': dependencies: - '@vitest/pretty-format': 4.0.18 + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.0.18': {} + '@vitest/spy@4.1.10': {} - '@vitest/utils@4.0.18': + '@vitest/utils@4.1.10': dependencies: - '@vitest/pretty-format': 4.0.18 - tinyrainbow: 3.0.3 + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 '@yarnpkg/lockfile@1.1.0': {} @@ -4994,64 +5000,66 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 - acorn-jsx@5.3.2(acorn@8.16.0): + acorn-jsx@5.3.2(acorn@8.17.0): dependencies: - acorn: 8.16.0 + acorn: 8.17.0 - acorn@8.16.0: {} + acorn@8.17.0: {} agent-base@7.1.4: {} - ajv-formats@3.0.1(ajv@8.18.0): - optionalDependencies: - ajv: 8.18.0 + agent-base@9.0.0: {} - ajv@6.14.0: + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.18.0: + ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 + fast-uri: 3.1.3 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - algoliasearch@5.48.1: + algoliasearch@5.52.0: dependencies: - '@algolia/abtesting': 1.14.1 - '@algolia/client-abtesting': 5.48.1 - '@algolia/client-analytics': 5.48.1 - '@algolia/client-common': 5.48.1 - '@algolia/client-insights': 5.48.1 - '@algolia/client-personalization': 5.48.1 - '@algolia/client-query-suggestions': 5.48.1 - '@algolia/client-search': 5.48.1 - '@algolia/ingestion': 1.48.1 - '@algolia/monitoring': 1.48.1 - '@algolia/recommend': 5.48.1 - '@algolia/requester-browser-xhr': 5.48.1 - '@algolia/requester-fetch': 5.48.1 - '@algolia/requester-node-http': 5.48.1 + '@algolia/abtesting': 1.18.0 + '@algolia/client-abtesting': 5.52.0 + '@algolia/client-analytics': 5.52.0 + '@algolia/client-common': 5.52.0 + '@algolia/client-insights': 5.52.0 + '@algolia/client-personalization': 5.52.0 + '@algolia/client-query-suggestions': 5.52.0 + '@algolia/client-search': 5.52.0 + '@algolia/ingestion': 1.52.0 + '@algolia/monitoring': 1.52.0 + '@algolia/recommend': 5.52.0 + '@algolia/requester-browser-xhr': 5.52.0 + '@algolia/requester-fetch': 5.52.0 + '@algolia/requester-node-http': 5.52.0 - angular-eslint@21.1.0(@angular/cli@21.2.1(@types/node@25.3.5)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.3)(typescript-eslint@8.47.0(eslint@9.39.3)(typescript@5.9.3))(typescript@5.9.3): + angular-eslint@22.0.0(@angular/cli@22.0.7(@types/node@26.1.1)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.5)(typescript-eslint@8.62.0(eslint@9.39.5)(typescript@6.0.3))(typescript@6.0.3): dependencies: - '@angular-devkit/core': 21.2.1(chokidar@5.0.0) - '@angular-devkit/schematics': 21.2.1(chokidar@5.0.0) - '@angular-eslint/builder': 21.1.0(@angular/cli@21.2.1(@types/node@25.3.5)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.3)(typescript@5.9.3) - '@angular-eslint/eslint-plugin': 21.1.0(@typescript-eslint/utils@8.56.1(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3)(typescript@5.9.3) - '@angular-eslint/eslint-plugin-template': 21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.3)(typescript@5.9.3))(@typescript-eslint/types@8.56.1)(@typescript-eslint/utils@8.56.1(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3)(typescript@5.9.3) - '@angular-eslint/schematics': 21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.3)(typescript@5.9.3))(@angular/cli@21.2.1(@types/node@25.3.5)(chokidar@5.0.0))(@typescript-eslint/types@8.56.1)(@typescript-eslint/utils@8.56.1(eslint@9.39.3)(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.3)(typescript@5.9.3) - '@angular-eslint/template-parser': 21.1.0(eslint@9.39.3)(typescript@5.9.3) - '@angular/cli': 21.2.1(@types/node@25.3.5)(chokidar@5.0.0) - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/utils': 8.56.1(eslint@9.39.3)(typescript@5.9.3) - eslint: 9.39.3 - typescript: 5.9.3 - typescript-eslint: 8.47.0(eslint@9.39.3)(typescript@5.9.3) + '@angular-devkit/core': 22.0.7(chokidar@5.0.0) + '@angular-devkit/schematics': 22.0.7(chokidar@5.0.0) + '@angular-eslint/builder': 22.0.0(@angular/cli@22.0.7(@types/node@26.1.1)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.5)(typescript@6.0.3) + '@angular-eslint/eslint-plugin': 22.0.0(@typescript-eslint/utils@8.64.0(eslint@9.39.5)(typescript@6.0.3))(eslint@9.39.5)(typescript@6.0.3) + '@angular-eslint/eslint-plugin-template': 22.0.0(@angular-eslint/template-parser@22.0.0(eslint@9.39.5)(typescript@6.0.3))(@typescript-eslint/types@8.64.0)(@typescript-eslint/utils@8.64.0(eslint@9.39.5)(typescript@6.0.3))(eslint@9.39.5)(typescript@6.0.3) + '@angular-eslint/schematics': 22.0.0(@angular-eslint/template-parser@22.0.0(eslint@9.39.5)(typescript@6.0.3))(@angular/cli@22.0.7(@types/node@26.1.1)(chokidar@5.0.0))(@typescript-eslint/types@8.64.0)(@typescript-eslint/utils@8.64.0(eslint@9.39.5)(typescript@6.0.3))(chokidar@5.0.0)(eslint@9.39.5)(typescript@6.0.3) + '@angular-eslint/template-parser': 22.0.0(eslint@9.39.5)(typescript@6.0.3) + '@angular/cli': 22.0.7(@types/node@26.1.1)(chokidar@5.0.0) + '@typescript-eslint/types': 8.64.0 + '@typescript-eslint/utils': 8.64.0(eslint@9.39.5)(typescript@6.0.3) + eslint: 9.39.5 + typescript: 6.0.3 + typescript-eslint: 8.62.0(eslint@9.39.5)(typescript@6.0.3) transitivePeerDependencies: - chokidar - supports-color @@ -5060,8 +5068,6 @@ snapshots: dependencies: environment: 1.1.0 - ansi-regex@5.0.1: {} - ansi-regex@6.2.2: {} ansi-styles@4.3.0: @@ -5084,9 +5090,9 @@ snapshots: base64id@2.0.0: {} - baseline-browser-mapping@2.10.0: {} + baseline-browser-mapping@2.10.43: {} - beasties@0.4.1: + beasties@0.4.2: dependencies: css-select: 6.0.0 css-what: 7.0.0 @@ -5094,25 +5100,25 @@ snapshots: domhandler: 5.0.3 htmlparser2: 10.1.0 picocolors: 1.1.1 - postcss: 8.5.8 + postcss: 8.5.19 postcss-media-query-parser: 0.2.3 - postcss-safe-parser: 7.0.1(postcss@8.5.8) + postcss-safe-parser: 7.0.1(postcss@8.5.19) bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 - body-parser@2.2.2: + body-parser@2.3.0: dependencies: bytes: 3.1.2 - content-type: 1.0.5 + content-type: 2.0.0 debug: 4.4.3 http-errors: 2.0.1 - iconv-lite: 0.7.2 + iconv-lite: 0.7.3 on-finished: 2.4.1 - qs: 6.15.0 + qs: 6.15.3 raw-body: 3.0.2 - type-is: 2.0.1 + type-is: 2.1.0 transitivePeerDependencies: - supports-color @@ -5122,48 +5128,39 @@ snapshots: dependencies: '@popperjs/core': 2.11.8 - brace-expansion@1.1.12: + brace-expansion@1.1.16: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.0.2: - dependencies: - balanced-match: 1.0.2 - - brace-expansion@5.0.4: + brace-expansion@5.0.7: dependencies: balanced-match: 4.0.4 - braces@3.0.3: + browserslist@4.28.6: dependencies: - fill-range: 7.1.1 - - browserslist@4.28.1: - dependencies: - baseline-browser-mapping: 2.10.0 - caniuse-lite: 1.0.30001777 - electron-to-chromium: 1.5.307 - node-releases: 2.0.36 - update-browserslist-db: 1.2.3(browserslist@4.28.1) + baseline-browser-mapping: 2.10.43 + caniuse-lite: 1.0.30001805 + electron-to-chromium: 1.5.392 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.6) buffer-from@1.1.2: {} bytes@3.1.2: {} - cacache@20.0.3: + cacache@20.0.4: dependencies: '@npmcli/fs': 5.0.0 fs-minipass: 3.0.3 glob: 13.0.6 - lru-cache: 11.2.6 + lru-cache: 11.5.2 minipass: 7.1.3 minipass-collect: 2.0.1 - minipass-flush: 1.0.5 + minipass-flush: 1.0.7 minipass-pipeline: 1.2.4 - p-map: 7.0.4 + p-map: 7.0.5 ssri: 13.0.1 - unique-filename: 5.0.0 call-bind-apply-helpers@1.0.2: dependencies: @@ -5177,7 +5174,7 @@ snapshots: callsites@3.1.0: {} - caniuse-lite@1.0.30001777: {} + caniuse-lite@1.0.30001805: {} chai@6.2.2: {} @@ -5188,7 +5185,7 @@ snapshots: chalk@5.6.2: {} - chardet@2.1.1: {} + chardet@2.2.0: {} chokidar@4.0.3: dependencies: @@ -5209,7 +5206,7 @@ snapshots: cli-truncate@5.2.0: dependencies: slice-ansi: 8.0.0 - string-width: 8.2.0 + string-width: 8.2.2 cli-width@4.1.0: {} @@ -5225,14 +5222,14 @@ snapshots: color-name@1.1.4: {} - colorette@2.0.20: {} - concat-map@0.0.1: {} - content-disposition@1.0.1: {} + content-disposition@1.1.0: {} content-type@1.0.5: {} + content-type@2.0.0: {} + convert-source-map@1.9.0: {} convert-source-map@2.0.0: {} @@ -5241,7 +5238,7 @@ snapshots: cookie@0.7.2: {} - core-js@3.48.0: {} + core-js@3.49.0: {} cors@2.8.6: dependencies: @@ -5272,9 +5269,9 @@ snapshots: cssstyle@5.3.7: dependencies: '@asamuzakjp/css-color': 4.1.2 - '@csstools/css-syntax-patches-for-csstree': 1.1.0 + '@csstools/css-syntax-patches-for-csstree': 1.1.6(css-tree@3.2.1) css-tree: 3.2.1 - lru-cache: 11.2.6 + lru-cache: 11.5.2 data-urls@6.0.1: dependencies: @@ -5320,20 +5317,18 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.307: {} + electron-to-chromium@1.5.392: {} emoji-regex@10.6.0: {} - emoji-regex@8.0.0: {} - encodeurl@2.0.0: {} - engine.io-client@6.6.4: + engine.io-client@6.6.6: dependencies: '@socket.io/component-emitter': 3.1.2 debug: 4.4.3 engine.io-parser: 5.2.3 - ws: 8.18.3 + ws: 8.21.1 xmlhttprequest-ssl: 2.1.2 transitivePeerDependencies: - bufferutil @@ -5342,17 +5337,18 @@ snapshots: engine.io-parser@5.2.3: {} - engine.io@6.6.5: + engine.io@6.6.9: dependencies: '@types/cors': 2.8.19 - '@types/node': 25.3.5 + '@types/node': 26.1.1 + '@types/ws': 8.18.1 accepts: 1.3.8 base64id: 2.0.0 cookie: 0.7.2 cors: 2.8.6 debug: 4.4.3 engine.io-parser: 5.2.3 - ws: 8.18.3 + ws: 8.21.1 transitivePeerDependencies: - bufferutil - supports-color @@ -5360,54 +5356,81 @@ snapshots: entities@4.5.0: {} - entities@6.0.1: {} - entities@7.0.1: {} + entities@8.0.0: {} + env-paths@2.2.1: {} environment@1.1.0: {} - err-code@2.0.3: {} - es-define-property@1.0.1: {} es-errors@1.3.0: {} - es-module-lexer@1.7.0: {} + es-module-lexer@2.3.1: {} - es-object-atoms@1.1.1: + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 - esbuild@0.27.3: + esbuild@0.27.7: optionalDependencies: - '@esbuild/aix-ppc64': 0.27.3 - '@esbuild/android-arm': 0.27.3 - '@esbuild/android-arm64': 0.27.3 - '@esbuild/android-x64': 0.27.3 - '@esbuild/darwin-arm64': 0.27.3 - '@esbuild/darwin-x64': 0.27.3 - '@esbuild/freebsd-arm64': 0.27.3 - '@esbuild/freebsd-x64': 0.27.3 - '@esbuild/linux-arm': 0.27.3 - '@esbuild/linux-arm64': 0.27.3 - '@esbuild/linux-ia32': 0.27.3 - '@esbuild/linux-loong64': 0.27.3 - '@esbuild/linux-mips64el': 0.27.3 - '@esbuild/linux-ppc64': 0.27.3 - '@esbuild/linux-riscv64': 0.27.3 - '@esbuild/linux-s390x': 0.27.3 - '@esbuild/linux-x64': 0.27.3 - '@esbuild/netbsd-arm64': 0.27.3 - '@esbuild/netbsd-x64': 0.27.3 - '@esbuild/openbsd-arm64': 0.27.3 - '@esbuild/openbsd-x64': 0.27.3 - '@esbuild/openharmony-arm64': 0.27.3 - '@esbuild/sunos-x64': 0.27.3 - '@esbuild/win32-arm64': 0.27.3 - '@esbuild/win32-ia32': 0.27.3 - '@esbuild/win32-x64': 0.27.3 + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 escalade@3.2.0: {} @@ -5420,10 +5443,10 @@ snapshots: esrecurse: 4.3.0 estraverse: 5.3.0 - eslint-scope@9.1.1: + eslint-scope@9.1.2: dependencies: '@types/esrecurse': 4.3.1 - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 esrecurse: 4.3.0 estraverse: 5.3.0 @@ -5433,21 +5456,21 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.3: + eslint@9.39.5: dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.1 + '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.4 - '@eslint/js': 9.39.3 + '@eslint/eslintrc': 3.3.6 + '@eslint/js': 9.39.5 '@eslint/plugin-kit': 0.4.1 - '@humanfs/node': 0.16.7 + '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 - ajv: 6.14.0 + '@types/estree': 1.0.9 + ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 @@ -5474,8 +5497,8 @@ snapshots: espree@10.4.0: dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) eslint-visitor-keys: 4.2.1 esquery@1.7.0: @@ -5490,7 +5513,7 @@ snapshots: estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 esutils@2.0.3: {} @@ -5498,26 +5521,26 @@ snapshots: eventemitter3@5.0.4: {} - eventsource-parser@3.0.6: {} + eventsource-parser@3.1.0: {} eventsource@3.0.7: dependencies: - eventsource-parser: 3.0.6 + eventsource-parser: 3.1.0 - expect-type@1.3.0: {} + expect-type@1.4.0: {} exponential-backoff@3.1.3: {} - express-rate-limit@8.3.0(express@5.2.1): + express-rate-limit@8.5.2(express@5.2.1): dependencies: express: 5.2.1 - ip-address: 10.1.0 + ip-address: 10.2.0 express@5.2.1: dependencies: accepts: 2.0.0 - body-parser: 2.2.2 - content-disposition: 1.0.1 + body-parser: 2.3.0 + content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 @@ -5535,49 +5558,47 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.15.0 - range-parser: 1.2.1 + qs: 6.15.3 + range-parser: 1.3.0 router: 2.2.0 send: 1.2.1 serve-static: 2.2.1 statuses: 2.0.2 - type-is: 2.0.1 + type-is: 2.1.0 vary: 1.1.2 transitivePeerDependencies: - supports-color fast-deep-equal@3.1.3: {} - fast-glob@3.3.3: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} - fast-uri@3.1.0: {} + fast-string-truncated-width@3.0.3: {} - fastq@1.20.1: + fast-string-width@3.0.2: dependencies: - reusify: 1.1.0 + fast-string-truncated-width: 3.0.3 - fdir@6.5.0(picomatch@4.0.3): + fast-uri@3.1.3: {} + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.4 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - finalhandler@2.1.1: dependencies: debug: 4.4.3 @@ -5596,10 +5617,10 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.3.4 + flatted: 3.4.2 keyv: 4.5.4 - flatted@3.3.4: {} + flatted@3.4.2: {} forwarded@0.2.0: {} @@ -5618,29 +5639,25 @@ snapshots: get-caller-file@2.0.5: {} - get-east-asian-width@1.5.0: {} + get-east-asian-width@1.6.0: {} get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 es-define-property: 1.0.1 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 function-bind: 1.1.2 get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.4 math-intrinsics: 1.1.0 get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 + es-object-atoms: 1.1.2 glob-parent@6.0.2: dependencies: @@ -5650,7 +5667,7 @@ snapshots: glob@13.0.6: dependencies: - minimatch: 10.2.4 + minimatch: 10.2.5 minipass: 7.1.3 path-scurry: 2.0.2 @@ -5660,25 +5677,23 @@ snapshots: graceful-fs@4.2.11: {} - graphemer@1.4.0: {} - has-flag@4.0.0: {} has-symbols@1.1.0: {} - hasown@2.0.2: + hasown@2.0.4: dependencies: function-bind: 1.1.2 - hono@4.12.5: {} + hono@4.12.30: {} - hosted-git-info@9.0.2: + hosted-git-info@9.0.3: dependencies: - lru-cache: 11.2.6 + lru-cache: 11.5.2 html-encoding-sniffer@6.0.0: dependencies: - '@exodus/bytes': 1.15.0 + '@exodus/bytes': 1.15.1 transitivePeerDependencies: - '@noble/hashes' @@ -5713,19 +5728,28 @@ snapshots: transitivePeerDependencies: - supports-color - iconv-lite@0.7.2: + https-proxy-agent@9.0.0: + dependencies: + agent-base: 9.0.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 ignore-walk@8.0.0: dependencies: - minimatch: 10.2.4 + minimatch: 10.2.5 ignore@5.3.2: {} ignore@7.0.5: {} - immutable@5.1.5: {} + ignore@7.0.6: {} + + immutable@5.1.9: {} import-fresh@3.3.1: dependencies: @@ -5738,17 +5762,15 @@ snapshots: ini@6.0.0: {} - ip-address@10.1.0: {} + ip-address@10.2.0: {} ipaddr.js@1.9.1: {} is-extglob@2.1.1: {} - is-fullwidth-code-point@3.0.0: {} - is-fullwidth-code-point@5.1.0: dependencies: - get-east-asian-width: 1.5.0 + get-east-asian-width: 1.6.0 is-glob@4.0.3: dependencies: @@ -5756,8 +5778,6 @@ snapshots: is-interactive@2.0.0: {} - is-number@7.0.0: {} - is-potential-custom-element-name@1.0.1: {} is-promise@4.0.0: {} @@ -5768,23 +5788,25 @@ snapshots: isexe@4.0.0: {} - istanbul-lib-coverage@3.2.2: {} + istanbul-lib-coverage@3.2.2: + optional: true istanbul-lib-instrument@6.0.3: dependencies: - '@babel/core': 7.29.0 - '@babel/parser': 7.29.0 - '@istanbuljs/schema': 0.1.3 + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + '@istanbuljs/schema': 0.1.6 istanbul-lib-coverage: 3.2.2 - semver: 7.7.4 + semver: 7.8.5 transitivePeerDependencies: - supports-color + optional: true - jose@6.2.0: {} + jose@6.2.3: {} js-tokens@4.0.0: {} - js-yaml@4.1.1: + js-yaml@4.3.0: dependencies: argparse: 2.0.1 @@ -5792,7 +5814,7 @@ snapshots: dependencies: '@acemir/cssom': 0.9.31 '@asamuzakjp/dom-selector': 6.8.1 - '@exodus/bytes': 1.15.0 + '@exodus/bytes': 1.15.1 cssstyle: 5.3.7 data-urls: 6.0.1 decimal.js: 10.6.0 @@ -5800,15 +5822,15 @@ snapshots: http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 is-potential-custom-element-name: 1.0.1 - parse5: 8.0.0 + parse5: 8.0.1 saxes: 6.0.0 symbol-tree: 3.2.4 - tough-cookie: 6.0.0 + tough-cookie: 6.0.2 w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 4.0.0 whatwg-url: 15.1.0 - ws: 8.19.0 + ws: 8.21.1 xml-name-validator: 5.0.0 transitivePeerDependencies: - '@noble/hashes' @@ -5845,31 +5867,30 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - listr2@9.0.5: + listr2@10.2.1: dependencies: cli-truncate: 5.2.0 - colorette: 2.0.20 eventemitter3: 5.0.4 log-update: 6.1.0 rfdc: 1.4.1 - wrap-ansi: 9.0.2 + wrap-ansi: 10.0.0 - lmdb@3.5.1: + lmdb@3.5.4: dependencies: '@harperfast/extended-iterable': 1.0.3 - msgpackr: 1.11.8 + msgpackr: 1.12.1 node-addon-api: 6.1.0 node-gyp-build-optional-packages: 5.2.2 ordered-binary: 1.6.1 weak-lru-cache: 1.2.2 optionalDependencies: - '@lmdb/lmdb-darwin-arm64': 3.5.1 - '@lmdb/lmdb-darwin-x64': 3.5.1 - '@lmdb/lmdb-linux-arm': 3.5.1 - '@lmdb/lmdb-linux-arm64': 3.5.1 - '@lmdb/lmdb-linux-x64': 3.5.1 - '@lmdb/lmdb-win32-arm64': 3.5.1 - '@lmdb/lmdb-win32-x64': 3.5.1 + '@lmdb/lmdb-darwin-arm64': 3.5.4 + '@lmdb/lmdb-darwin-x64': 3.5.4 + '@lmdb/lmdb-linux-arm': 3.5.4 + '@lmdb/lmdb-linux-arm64': 3.5.4 + '@lmdb/lmdb-linux-x64': 3.5.4 + '@lmdb/lmdb-win32-arm64': 3.5.4 + '@lmdb/lmdb-win32-x64': 3.5.4 optional: true locate-path@6.0.0: @@ -5891,7 +5912,7 @@ snapshots: strip-ansi: 7.2.0 wrap-ansi: 9.0.2 - lru-cache@11.2.6: {} + lru-cache@11.5.2: {} lru-cache@5.1.1: dependencies: @@ -5901,15 +5922,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - make-fetch-happen@15.0.4: + make-fetch-happen@15.0.6: dependencies: - '@gar/promise-retry': 1.0.2 - '@npmcli/agent': 4.0.0 - cacache: 20.0.3 + '@gar/promise-retry': 1.0.3 + '@npmcli/agent': 4.0.2 + '@npmcli/redact': 4.0.0 + cacache: 20.0.4 http-cache-semantics: 4.2.0 minipass: 7.1.3 minipass-fetch: 5.0.2 - minipass-flush: 1.0.5 + minipass-flush: 1.0.7 minipass-pipeline: 1.2.4 negotiator: 1.0.0 proc-log: 6.1.0 @@ -5925,13 +5947,6 @@ snapshots: merge-descriptors@2.0.0: {} - merge2@1.4.1: {} - - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.1 - mime-db@1.52.0: {} mime-db@1.54.0: {} @@ -5946,17 +5961,13 @@ snapshots: mimic-function@5.0.1: {} - minimatch@10.2.4: + minimatch@10.2.5: dependencies: - brace-expansion: 5.0.4 + brace-expansion: 5.0.7 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.12 - - minimatch@9.0.9: - dependencies: - brace-expansion: 2.0.2 + brace-expansion: 1.1.16 minipass-collect@2.0.1: dependencies: @@ -5968,9 +5979,9 @@ snapshots: minipass-sized: 2.0.0 minizlib: 3.1.0 optionalDependencies: - iconv-lite: 0.7.2 + iconv-lite: 0.7.3 - minipass-flush@1.0.5: + minipass-flush@1.0.7: dependencies: minipass: 3.3.6 @@ -5996,26 +6007,26 @@ snapshots: ms@2.1.3: {} - msgpackr-extract@3.0.3: + msgpackr-extract@3.0.4: dependencies: node-gyp-build-optional-packages: 5.2.2 optionalDependencies: - '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3 - '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3 - '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3 - '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3 - '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3 - '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 optional: true - msgpackr@1.11.8: + msgpackr@1.12.1: optionalDependencies: - msgpackr-extract: 3.0.3 + msgpackr-extract: 3.0.4 optional: true - mute-stream@2.0.0: {} + mute-stream@3.0.0: {} - nanoid@3.3.11: {} + nanoid@3.3.16: {} natural-compare@1.4.0: {} @@ -6023,17 +6034,17 @@ snapshots: negotiator@1.0.0: {} - ngx-cookie-service@21.1.0(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0)): + ngx-cookie-service@22.0.0(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0)): dependencies: - '@angular/common': 21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) - '@angular/core': 21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/common': 22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + '@angular/core': 22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0) tslib: 2.8.1 - ngx-socket-io@4.10.0(@angular/common@21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2): + ngx-socket-io@4.10.0(@angular/common@22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2): dependencies: - '@angular/common': 21.2.1(@angular/core@21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) - '@angular/core': 21.2.1(@angular/compiler@21.2.1)(rxjs@7.8.2)(zone.js@0.15.0) - core-js: 3.48.0 + '@angular/common': 22.0.6(@angular/core@22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + '@angular/core': 22.0.6(@angular/compiler@22.0.6)(rxjs@7.8.2)(zone.js@0.15.0) + core-js: 3.49.0 reflect-metadata: 0.2.2 rxjs: 7.8.2 socket.io: 4.8.3 @@ -6055,22 +6066,20 @@ snapshots: detect-libc: 2.1.2 optional: true - node-gyp@12.2.0: + node-gyp@12.4.0: dependencies: env-paths: 2.2.1 exponential-backoff: 3.1.3 graceful-fs: 4.2.11 - make-fetch-happen: 15.0.4 nopt: 9.0.0 proc-log: 6.1.0 semver: 7.7.4 - tar: 7.5.10 - tinyglobby: 0.2.15 + tar: 7.5.20 + tinyglobby: 0.2.17 + undici: 6.27.0 which: 6.0.1 - transitivePeerDependencies: - - supports-color - node-releases@2.0.36: {} + node-releases@2.0.51: {} nopt@9.0.0: dependencies: @@ -6088,7 +6097,7 @@ snapshots: npm-package-arg@13.0.2: dependencies: - hosted-git-info: 9.0.2 + hosted-git-info: 9.0.3 proc-log: 6.1.0 semver: 7.7.4 validate-npm-package-name: 7.0.2 @@ -6109,7 +6118,7 @@ snapshots: dependencies: '@npmcli/redact': 4.0.0 jsonparse: 1.3.1 - make-fetch-happen: 15.0.4 + make-fetch-happen: 15.0.6 minipass: 7.1.3 minipass-fetch: 5.0.2 minizlib: 3.1.0 @@ -6126,7 +6135,7 @@ snapshots: object-inspect@1.13.4: {} - obug@2.1.1: {} + obug@2.1.3: {} on-finished@2.4.1: dependencies: @@ -6149,7 +6158,7 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 - ora@9.3.0: + ora@9.4.0: dependencies: chalk: 5.6.2 cli-cursor: 5.0.0 @@ -6157,8 +6166,8 @@ snapshots: is-interactive: 2.0.0 is-unicode-supported: 2.1.0 log-symbols: 7.0.1 - stdin-discarder: 0.3.1 - string-width: 8.2.0 + stdin-discarder: 0.3.2 + string-width: 8.2.2 ordered-binary@1.6.1: optional: true @@ -6171,16 +6180,17 @@ snapshots: dependencies: p-limit: 3.1.0 - p-map@7.0.4: {} + p-map@7.0.5: {} - pacote@21.3.1: + pacote@21.5.1: dependencies: + '@gar/promise-retry': 1.0.3 '@npmcli/git': 7.0.2 '@npmcli/installed-package-contents': 4.0.0 '@npmcli/package-json': 7.0.5 '@npmcli/promise-spawn': 9.0.1 '@npmcli/run-script': 10.0.4 - cacache: 20.0.3 + cacache: 20.0.4 fs-minipass: 3.0.3 minipass: 7.1.3 npm-package-arg: 13.0.2 @@ -6188,10 +6198,9 @@ snapshots: npm-pick-manifest: 11.0.3 npm-registry-fetch: 19.1.1 proc-log: 6.1.0 - promise-retry: 2.0.1 - sigstore: 4.1.0 + sigstore: 4.1.1 ssri: 13.0.1 - tar: 7.5.10 + tar: 7.5.20 transitivePeerDependencies: - supports-color @@ -6199,19 +6208,19 @@ snapshots: dependencies: callsites: 3.1.0 - parse5-html-rewriting-stream@8.0.0: + parse5-html-rewriting-stream@8.0.1: dependencies: - entities: 6.0.1 - parse5: 8.0.0 + entities: 8.0.0 + parse5: 8.0.1 parse5-sax-parser: 8.0.0 parse5-sax-parser@8.0.0: dependencies: - parse5: 8.0.0 + parse5: 8.0.1 - parse5@8.0.0: + parse5@8.0.1: dependencies: - entities: 6.0.1 + entities: 8.0.0 parseurl@1.3.3: {} @@ -6221,20 +6230,20 @@ snapshots: path-scurry@2.0.2: dependencies: - lru-cache: 11.2.6 + lru-cache: 11.5.2 minipass: 7.1.3 - path-to-regexp@8.3.0: {} + path-to-regexp@8.4.2: {} pathe@2.0.3: {} picocolors@1.1.1: {} - picomatch@2.3.1: {} + picomatch@4.0.4: {} - picomatch@4.0.3: {} + picomatch@4.0.5: {} - piscina@5.1.4: + piscina@5.2.0: optionalDependencies: '@napi-rs/nice': 1.1.1 @@ -6242,13 +6251,13 @@ snapshots: postcss-media-query-parser@0.2.3: {} - postcss-safe-parser@7.0.1(postcss@8.5.8): + postcss-safe-parser@7.0.1(postcss@8.5.19): dependencies: - postcss: 8.5.8 + postcss: 8.5.19 - postcss@8.5.8: + postcss@8.5.19: dependencies: - nanoid: 3.3.11 + nanoid: 3.3.16 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -6256,11 +6265,6 @@ snapshots: proc-log@6.1.0: {} - promise-retry@2.0.1: - dependencies: - err-code: 2.0.3 - retry: 0.12.0 - proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -6268,19 +6272,18 @@ snapshots: punycode@2.3.1: {} - qs@6.15.0: + qs@6.15.3: dependencies: - side-channel: 1.1.0 + es-define-property: 1.0.1 + side-channel: 1.1.1 - queue-microtask@1.2.3: {} - - range-parser@1.2.1: {} + range-parser@1.3.0: {} raw-body@3.0.2: dependencies: bytes: 3.1.2 http-errors: 2.0.1 - iconv-lite: 0.7.2 + iconv-lite: 0.7.3 unpipe: 1.0.0 readdirp@4.1.2: {} @@ -6298,62 +6301,37 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 - retry@0.12.0: {} - - retry@0.13.1: {} - - reusify@1.1.0: {} - rfdc@1.4.1: {} - rolldown@1.0.0-rc.4: - dependencies: - '@oxc-project/types': 0.113.0 - '@rolldown/pluginutils': 1.0.0-rc.4 - optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.4 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.4 - '@rolldown/binding-darwin-x64': 1.0.0-rc.4 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.4 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.4 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.4 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.4 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.4 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.4 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.4 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.4 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.4 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.4 - - rollup@4.59.0: + rollup@4.60.2: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.59.0 - '@rollup/rollup-android-arm64': 4.59.0 - '@rollup/rollup-darwin-arm64': 4.59.0 - '@rollup/rollup-darwin-x64': 4.59.0 - '@rollup/rollup-freebsd-arm64': 4.59.0 - '@rollup/rollup-freebsd-x64': 4.59.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 - '@rollup/rollup-linux-arm-musleabihf': 4.59.0 - '@rollup/rollup-linux-arm64-gnu': 4.59.0 - '@rollup/rollup-linux-arm64-musl': 4.59.0 - '@rollup/rollup-linux-loong64-gnu': 4.59.0 - '@rollup/rollup-linux-loong64-musl': 4.59.0 - '@rollup/rollup-linux-ppc64-gnu': 4.59.0 - '@rollup/rollup-linux-ppc64-musl': 4.59.0 - '@rollup/rollup-linux-riscv64-gnu': 4.59.0 - '@rollup/rollup-linux-riscv64-musl': 4.59.0 - '@rollup/rollup-linux-s390x-gnu': 4.59.0 - '@rollup/rollup-linux-x64-gnu': 4.59.0 - '@rollup/rollup-linux-x64-musl': 4.59.0 - '@rollup/rollup-openbsd-x64': 4.59.0 - '@rollup/rollup-openharmony-arm64': 4.59.0 - '@rollup/rollup-win32-arm64-msvc': 4.59.0 - '@rollup/rollup-win32-ia32-msvc': 4.59.0 - '@rollup/rollup-win32-x64-gnu': 4.59.0 - '@rollup/rollup-win32-x64-msvc': 4.59.0 + '@rollup/rollup-android-arm-eabi': 4.60.2 + '@rollup/rollup-android-arm64': 4.60.2 + '@rollup/rollup-darwin-arm64': 4.60.2 + '@rollup/rollup-darwin-x64': 4.60.2 + '@rollup/rollup-freebsd-arm64': 4.60.2 + '@rollup/rollup-freebsd-x64': 4.60.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.2 + '@rollup/rollup-linux-arm-musleabihf': 4.60.2 + '@rollup/rollup-linux-arm64-gnu': 4.60.2 + '@rollup/rollup-linux-arm64-musl': 4.60.2 + '@rollup/rollup-linux-loong64-gnu': 4.60.2 + '@rollup/rollup-linux-loong64-musl': 4.60.2 + '@rollup/rollup-linux-ppc64-gnu': 4.60.2 + '@rollup/rollup-linux-ppc64-musl': 4.60.2 + '@rollup/rollup-linux-riscv64-gnu': 4.60.2 + '@rollup/rollup-linux-riscv64-musl': 4.60.2 + '@rollup/rollup-linux-s390x-gnu': 4.60.2 + '@rollup/rollup-linux-x64-gnu': 4.60.2 + '@rollup/rollup-linux-x64-musl': 4.60.2 + '@rollup/rollup-openbsd-x64': 4.60.2 + '@rollup/rollup-openharmony-arm64': 4.60.2 + '@rollup/rollup-win32-arm64-msvc': 4.60.2 + '@rollup/rollup-win32-ia32-msvc': 4.60.2 + '@rollup/rollup-win32-x64-gnu': 4.60.2 + '@rollup/rollup-win32-x64-msvc': 4.60.2 fsevents: 2.3.3 router@2.2.0: @@ -6362,24 +6340,20 @@ snapshots: depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 - path-to-regexp: 8.3.0 + path-to-regexp: 8.4.2 transitivePeerDependencies: - supports-color - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - rxjs@7.8.2: dependencies: tslib: 2.8.1 safer-buffer@2.1.2: {} - sass@1.97.3: + sass@1.99.0: dependencies: chokidar: 4.0.3 - immutable: 5.1.5 + immutable: 5.1.9 source-map-js: 1.2.1 optionalDependencies: '@parcel/watcher': 2.5.6 @@ -6390,10 +6364,12 @@ snapshots: semver@6.3.1: {} - semver@7.7.3: {} - semver@7.7.4: {} + semver@7.8.0: {} + + semver@7.8.5: {} + send@1.2.1: dependencies: debug: 4.4.3 @@ -6405,7 +6381,7 @@ snapshots: mime-types: 3.0.2 ms: 2.1.3 on-finished: 2.4.1 - range-parser: 1.2.1 + range-parser: 1.3.0 statuses: 2.0.2 transitivePeerDependencies: - supports-color @@ -6427,7 +6403,7 @@ snapshots: shebang-regex@3.0.0: {} - side-channel-list@1.0.0: + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 @@ -6447,11 +6423,11 @@ snapshots: object-inspect: 1.13.4 side-channel-map: 1.0.1 - side-channel@1.1.0: + side-channel@1.1.1: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 - side-channel-list: 1.0.0 + side-channel-list: 1.0.1 side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 @@ -6459,14 +6435,14 @@ snapshots: signal-exit@4.1.0: {} - sigstore@4.1.0: + sigstore@4.1.1: dependencies: '@sigstore/bundle': 4.0.0 - '@sigstore/core': 3.1.0 - '@sigstore/protobuf-specs': 0.5.0 - '@sigstore/sign': 4.1.0 - '@sigstore/tuf': 4.0.1 - '@sigstore/verify': 3.1.0 + '@sigstore/core': 3.2.1 + '@sigstore/protobuf-specs': 0.5.1 + '@sigstore/sign': 4.1.1 + '@sigstore/tuf': 4.0.2 + '@sigstore/verify': 3.1.1 transitivePeerDependencies: - supports-color @@ -6482,10 +6458,10 @@ snapshots: smart-buffer@4.2.0: {} - socket.io-adapter@2.5.6: + socket.io-adapter@2.5.8: dependencies: debug: 4.4.3 - ws: 8.18.3 + ws: 8.21.1 transitivePeerDependencies: - bufferutil - supports-color @@ -6495,14 +6471,14 @@ snapshots: dependencies: '@socket.io/component-emitter': 3.1.2 debug: 4.4.3 - engine.io-client: 6.6.4 - socket.io-parser: 4.2.5 + engine.io-client: 6.6.6 + socket.io-parser: 4.2.7 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - socket.io-parser@4.2.5: + socket.io-parser@4.2.7: dependencies: '@socket.io/component-emitter': 3.1.2 debug: 4.4.3 @@ -6515,9 +6491,9 @@ snapshots: base64id: 2.0.0 cors: 2.8.6 debug: 4.4.3 - engine.io: 6.6.5 - socket.io-adapter: 2.5.6 - socket.io-parser: 4.2.5 + engine.io: 6.6.9 + socket.io-adapter: 2.5.8 + socket.io-parser: 4.2.7 transitivePeerDependencies: - bufferutil - supports-color @@ -6527,13 +6503,13 @@ snapshots: dependencies: agent-base: 7.1.4 debug: 4.4.3 - socks: 2.8.7 + socks: 2.8.9 transitivePeerDependencies: - supports-color - socks@2.8.7: + socks@2.8.9: dependencies: - ip-address: 10.1.0 + ip-address: 10.2.0 smart-buffer: 4.2.0 source-map-js@1.2.1: {} @@ -6564,31 +6540,21 @@ snapshots: statuses@2.0.2: {} - std-env@3.10.0: {} + std-env@4.2.0: {} - stdin-discarder@0.3.1: {} - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 + stdin-discarder@0.3.2: {} string-width@7.2.0: dependencies: emoji-regex: 10.6.0 - get-east-asian-width: 1.5.0 + get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 - string-width@8.2.0: + string-width@8.2.2: dependencies: - get-east-asian-width: 1.5.0 + get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - strip-ansi@7.2.0: dependencies: ansi-regex: 6.2.2 @@ -6601,7 +6567,7 @@ snapshots: symbol-tree@3.2.4: {} - tar@7.5.10: + tar@7.5.20: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 @@ -6611,38 +6577,39 @@ snapshots: tinybench@2.9.0: {} - tinyexec@1.0.2: {} + tinyexec@1.2.4: {} - tinyglobby@0.2.15: + tinyglobby@0.2.16: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 - tinyrainbow@3.0.3: {} - - tldts-core@7.0.24: {} - - tldts@7.0.24: + tinyglobby@0.2.17: dependencies: - tldts-core: 7.0.24 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 - to-regex-range@5.0.1: + tinyrainbow@3.1.0: {} + + tldts-core@7.4.8: {} + + tldts@7.4.8: dependencies: - is-number: 7.0.0 + tldts-core: 7.4.8 toidentifier@1.0.1: {} - tough-cookie@6.0.0: + tough-cookie@6.0.2: dependencies: - tldts: 7.0.24 + tldts: 7.4.8 tr46@6.0.0: dependencies: punycode: 2.3.1 - ts-api-utils@2.4.0(typescript@5.9.3): + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: - typescript: 5.9.3 + typescript: 6.0.3 tslib@2.8.1: {} @@ -6650,7 +6617,7 @@ snapshots: dependencies: '@tufjs/models': 4.1.0 debug: 4.4.3 - make-fetch-happen: 15.0.4 + make-fetch-happen: 15.0.6 transitivePeerDependencies: - supports-color @@ -6658,42 +6625,34 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-is@2.0.1: + type-is@2.1.0: dependencies: - content-type: 1.0.5 + content-type: 2.0.0 media-typer: 1.1.0 mime-types: 3.0.2 - typescript-eslint@8.47.0(eslint@9.39.3)(typescript@5.9.3): + typescript-eslint@8.62.0(eslint@9.39.5)(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.47.0(@typescript-eslint/parser@8.47.0(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3)(typescript@5.9.3) - '@typescript-eslint/parser': 8.47.0(eslint@9.39.3)(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.47.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.47.0(eslint@9.39.3)(typescript@5.9.3) - eslint: 9.39.3 - typescript: 5.9.3 + '@typescript-eslint/eslint-plugin': 8.62.0(@typescript-eslint/parser@8.62.0(eslint@9.39.5)(typescript@6.0.3))(eslint@9.39.5)(typescript@6.0.3) + '@typescript-eslint/parser': 8.62.0(eslint@9.39.5)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.62.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.62.0(eslint@9.39.5)(typescript@6.0.3) + eslint: 9.39.5 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - typescript@5.9.3: {} + typescript@6.0.3: {} - undici-types@7.18.2: {} + undici-types@8.3.0: {} - undici@7.22.0: {} - - unique-filename@5.0.0: - dependencies: - unique-slug: 6.0.0 - - unique-slug@6.0.0: - dependencies: - imurmurhash: 0.1.4 + undici@6.27.0: {} unpipe@1.0.0: {} - update-browserslist-db@1.2.3(browserslist@4.28.1): + update-browserslist-db@1.2.3(browserslist@4.28.6): dependencies: - browserslist: 4.28.1 + browserslist: 4.28.6 escalade: 3.2.0 picocolors: 1.1.1 @@ -6705,56 +6664,46 @@ snapshots: vary@1.1.2: {} - vite@7.3.1(@types/node@25.3.5)(sass@1.97.3): + vite@7.3.5(@types/node@26.1.1)(sass@1.99.0): dependencies: - esbuild: 0.27.3 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.8 - rollup: 4.59.0 - tinyglobby: 0.2.15 + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.19 + rollup: 4.60.2 + tinyglobby: 0.2.16 optionalDependencies: - '@types/node': 25.3.5 + '@types/node': 26.1.1 fsevents: 2.3.3 - sass: 1.97.3 + sass: 1.99.0 - vitest@4.0.18(@types/node@25.3.5)(jsdom@27.4.0)(sass@1.97.3): + vitest@4.1.10(@types/node@26.1.1)(jsdom@27.4.0)(vite@7.3.5(@types/node@26.1.1)(sass@1.99.0)): dependencies: - '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.5)(sass@1.97.3)) - '@vitest/pretty-format': 4.0.18 - '@vitest/runner': 4.0.18 - '@vitest/snapshot': 4.0.18 - '@vitest/spy': 4.0.18 - '@vitest/utils': 4.0.18 - es-module-lexer: 1.7.0 - expect-type: 1.3.0 + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@7.3.5(@types/node@26.1.1)(sass@1.99.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 magic-string: 0.30.21 - obug: 2.1.1 + obug: 2.1.3 pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 3.10.0 + picomatch: 4.0.5 + std-env: 4.2.0 tinybench: 2.9.0 - tinyexec: 1.0.2 - tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@25.3.5)(sass@1.97.3) + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 7.3.5(@types/node@26.1.1)(sass@1.99.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 25.3.5 + '@types/node': 26.1.1 jsdom: 27.4.0 transitivePeerDependencies: - - jiti - - less - - lightningcss - msw - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - yaml w3c-xmlserializer@5.0.0: dependencies: @@ -6794,11 +6743,11 @@ snapshots: word-wrap@1.2.5: {} - wrap-ansi@6.2.0: + wrap-ansi@10.0.0: dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 + ansi-styles: 6.2.3 + string-width: 8.2.2 + strip-ansi: 7.2.0 wrap-ansi@9.0.2: dependencies: @@ -6808,9 +6757,7 @@ snapshots: wrappy@1.0.2: {} - ws@8.18.3: {} - - ws@8.19.0: {} + ws@8.21.1: {} xml-name-validator@5.0.0: {} @@ -6839,14 +6786,14 @@ snapshots: yocto-queue@0.1.0: {} - yoctocolors-cjs@2.1.3: {} - yoctocolors@2.1.2: {} - zod-to-json-schema@3.25.1(zod@4.3.6): + zod-to-json-schema@3.25.2(zod@4.4.2): dependencies: - zod: 4.3.6 + zod: 4.4.2 - zod@4.3.6: {} + zod@4.4.2: {} + + zod@4.4.3: {} zone.js@0.15.0: {} diff --git a/ui/pnpm-workspace.yaml b/ui/pnpm-workspace.yaml new file mode 100644 index 0000000..fde66ed --- /dev/null +++ b/ui/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +allowBuilds: + '@parcel/watcher': true + core-js: true + esbuild: true + lmdb: true + msgpackr-extract: true diff --git a/ui/src/app/app.config.ts b/ui/src/app/app.config.ts index b5fb6c0..a2611b0 100644 --- a/ui/src/app/app.config.ts +++ b/ui/src/app/app.config.ts @@ -1,6 +1,6 @@ -import { ApplicationConfig, provideBrowserGlobalErrorListeners, isDevMode, provideZonelessChangeDetection, provideZoneChangeDetection } from '@angular/core'; +import { ApplicationConfig, provideBrowserGlobalErrorListeners, isDevMode, provideZoneChangeDetection } from '@angular/core'; import { provideServiceWorker } from '@angular/service-worker'; -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { provideHttpClient, withInterceptorsFromDi, withXhr } from '@angular/common/http'; export const appConfig: ApplicationConfig = { providers: [ @@ -12,6 +12,6 @@ export const appConfig: ApplicationConfig = { // or after 30 seconds (whichever comes first). registrationStrategy: 'registerWhenStable:30000' }), - provideHttpClient(withInterceptorsFromDi()), + provideHttpClient(withXhr(), withInterceptorsFromDi()), ] }; diff --git a/ui/src/app/app.html b/ui/src/app/app.html index 6504932..5e875f3 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -49,22 +49,22 @@ -->