diff --git a/.github/autofix/auto_fix.py b/.github/autofix/auto_fix.py index a4fb85713..f0bae658c 100644 --- a/.github/autofix/auto_fix.py +++ b/.github/autofix/auto_fix.py @@ -9,10 +9,10 @@ import sys import textwrap import uuid +from collections.abc import Iterable from dataclasses import dataclass from io import BytesIO from pathlib import Path -from typing import Iterable from zipfile import ZipFile import requests diff --git a/.github/scripts/update_releases.py b/.github/scripts/update_releases.py index ff2bff8c7..fe25d634b 100755 --- a/.github/scripts/update_releases.py +++ b/.github/scripts/update_releases.py @@ -7,10 +7,9 @@ import re import subprocess import sys -from typing import Dict, List -def extract_changelog_entries() -> Dict[str, str]: +def extract_changelog_entries() -> dict[str, str]: """Extract all changelog entries from CHANGELOG.md""" with open("CHANGELOG.md") as f: content = f.read() @@ -31,7 +30,7 @@ def extract_changelog_entries() -> Dict[str, str]: return entries -def get_existing_releases() -> List[str]: +def get_existing_releases() -> list[str]: """Get list of existing GitHub releases""" result = subprocess.run( ["gh", "release", "list", "--limit", "100"], capture_output=True, text=True, check=True diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b320cc14e..107f82821 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -21,6 +21,7 @@ jobs: matrix: language: - python + - javascript-typescript steps: - name: Checkout repository uses: actions/checkout@v6 diff --git a/.github/workflows/dockerhub-description.yml b/.github/workflows/dockerhub-description.yml index c83f26019..841f27bac 100644 --- a/.github/workflows/dockerhub-description.yml +++ b/.github/workflows/dockerhub-description.yml @@ -13,6 +13,8 @@ jobs: publish-descriptions: name: Update Docker Hub and PyPI metadata runs-on: ubuntu-latest + permissions: + contents: write steps: - name: Check out repository uses: actions/checkout@v6 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index c656e2c4c..b78523dda 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -55,6 +55,13 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" + - name: Ensure changelog page exists + run: | + if [ ! -f docs/changelog.md ] || [ -L docs/changelog.md ]; then + rm -f docs/changelog.md + cp CHANGELOG.md docs/changelog.md + fi + - name: Build documentation run: mkdocs build --strict --verbose @@ -99,6 +106,13 @@ jobs: pip install -r requirements.docs.txt pip install linkchecker + - name: Ensure changelog page exists + run: | + if [ ! -f docs/changelog.md ] || [ -L docs/changelog.md ]; then + rm -f docs/changelog.md + cp CHANGELOG.md docs/changelog.md + fi + - name: Build docs run: mkdocs build diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index c13ada6ee..6e1541051 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -23,9 +23,6 @@ jobs: packages: write contents: read if: ${{ ! startsWith(github.event.head_commit.message, '[patch]') && ! startsWith(github.event.head_commit.message, '[minor]') && ! startsWith(github.event.head_commit.message, '[major]') }} - strategy: - matrix: - platform: [amd64, arm64] steps: - id: string uses: ASzc/change-string-case-action@v6 @@ -65,7 +62,7 @@ jobs: uses: docker/build-push-action@v6 with: context: . - platforms: linux/amd64 + platforms: linux/amd64,linux/arm64 push: true tags: feramance/qbitrr:nightly,ghcr.io/${{ steps.string.outputs.lowercase }}:nightly labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml index 3894c28c2..5b90c3ae6 100644 --- a/.github/workflows/pre-commit.yaml +++ b/.github/workflows/pre-commit.yaml @@ -8,10 +8,14 @@ on: jobs: pre-commit: runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: python-version: '3.12' + - name: Install project (for absolufy-imports and import resolution) + run: pip install -e . - uses: pre-commit/action@v3.0.1 diff --git a/.github/workflows/pull_requests.yml b/.github/workflows/pull_requests.yml index eb3c395cd..0ddbb6dd8 100644 --- a/.github/workflows/pull_requests.yml +++ b/.github/workflows/pull_requests.yml @@ -43,11 +43,12 @@ jobs: ref: ${{ github.event.pull_request.head.ref }} - name: Install Homebrew dependencies if: runner.os == 'macOS' - run: "/bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"\n" + run: | + brew install sdl2 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v6 with: - python-version: ${{ matrix.python}} + python-version: ${{ matrix.python }} architecture: ${{ matrix.arch }} cache: pip - name: Install APT dependencies @@ -64,7 +65,7 @@ jobs: RELEASE_HASH: ${{ steps.git_hash.outputs.HASH }} run: "python - <<'PY'\nimport os\nfrom pathlib import Path\nimport re\n\nrelease_hash = os.environ[\"RELEASE_HASH\"]\npath = Path(\"qBitrr/bundled_data.py\")\ncontent = path.read_text()\nupdated = re.sub(r'git_hash = \".*\"', f'git_hash = \"{release_hash}\"', content)\npath.write_text(updated)\nPY\n" - name: Retrieve current version - run: "echo \"Current version: $(python setup.py --version)\"\necho \"VERSION=$(python setup.py --version)\" >> $GITHUB_OUTPUT\n" + run: "VERSION=$(python -c \"import configparser; c = configparser.ConfigParser(); c.read('setup.cfg'); print(c['metadata']['version'])\")\necho \"Current version: ${VERSION}\"\necho \"VERSION=${VERSION}\" >> $GITHUB_OUTPUT\n" id: current_version - name: Set up Node.js uses: actions/setup-node@v6 @@ -102,6 +103,7 @@ jobs: docker_image: name: Build Docker Images runs-on: ubuntu-latest + if: github.event.pull_request.head.repo.full_name == github.repository permissions: packages: write contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eeca74fbf..eefb5aa34 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,7 +61,7 @@ jobs: run: "bump2version \"${RELEASE_TYPE}\"\n" - name: Capture new version id: version - run: "VERSION=$(python setup.py --version)\necho \"value=${VERSION}\" >> \"${GITHUB_OUTPUT}\"\necho \"New version: ${VERSION}\"\n" + run: "VERSION=$(python -c \"import configparser; c = configparser.ConfigParser(); c.read('setup.cfg'); print(c['metadata']['version'])\")\necho \"value=${VERSION}\" >> \"${GITHUB_OUTPUT}\"\necho \"New version: ${VERSION}\"\n" - name: Import GPG key id: import_gpg uses: crazy-max/ghaction-import-gpg@v6.3.0 diff --git a/.gitignore b/.gitignore index b12aa26cf..3b755f5e1 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ .run/ .vscode/ .vs/ +.cursor # Testing /qBitrr/testing/test.py @@ -20,6 +21,7 @@ __pycache__/ *.so *.egg *.egg-info/ +qBitrr2.egg-info/ .Python # Build artifacts @@ -69,7 +71,6 @@ test_webui.sh /scripts/temp/ # Development -CLAUDE.md -compose.yml opencodetmp/ .claude +CLAUDE.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ba124fa16..5b91854cd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,7 +19,7 @@ repos: rev: v3.21.0 hooks: - id: pyupgrade - args: [--py38-plus] + args: [--py311-plus] - repo: https://github.com/pycqa/isort rev: 7.0.0 hooks: diff --git a/AGENTS.md b/AGENTS.md index 227cb123c..5adcf5020 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -307,7 +307,7 @@ Each release produces: - **Tagging**: Radarr/Sonarr downloads MUST have tags matching the category configured for qBitrr to track them - **Paths**: qBit's "Save Path" must be accessible to Radarr/Sonarr (common issue in Docker with mismatched volumes) - **WebUI Token**: If `Settings.WebUIToken` is set, all `/api/*` calls require `X-API-Token` header -- **Database Locks**: Use `db_lock.py:locked_database()` context manager for all Peewee queries to avoid conflicts +- **Database Locks**: Use `db_lock.py:database_lock()` context manager for all Peewee queries to avoid conflicts ## Documentation Maintenance diff --git a/Dockerfile b/Dockerfile index ceeb46e65..9f27c263c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,14 +6,17 @@ RUN cd webui && npm ci COPY webui webui RUN mkdir -p qBitrr/static && cd webui && npm run build -# Pin Python to the latest supported version we support in production -FROM python:3.14 +FROM python:3.12-slim LABEL Name="qBitrr" LABEL Maintainer="feramance" LABEL Version="5.9.1" LABEL org.opencontainers.image.source=https://github.com/feramance/qbitrr +# Install tini (PID 1, SIGTERM forwarding), gosu (drop privileges after chown), and curl (health checks) +RUN apt-get update && apt-get install -y --no-install-recommends tini gosu curl \ + && rm -rf /var/lib/apt/lists/* + # Env used by the script to determine if it's inside a docker - # if this is set to 69420 it will change the working dir for docker specific values ENV QBITRR_DOCKER_RUNNING=69420 @@ -21,14 +24,21 @@ ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 ENV PYTHONOPTIMIZE=1 -RUN pip install --quiet -U pip wheel +RUN pip install --no-cache-dir --quiet -U pip wheel WORKDIR /app COPY . /app COPY --from=webui-build /src/qBitrr/static/ /app/qBitrr/static/ -RUN rm -rf qBitrr2.egg-info *.egg-info && pip install --quiet ".[fast]" +RUN rm -rf qBitrr2.egg-info *.egg-info && pip install --no-cache-dir --quiet ".[fast]" + +RUN groupadd -r qbitrr && useradd -r -g qbitrr -d /config -s /sbin/nologin qbitrr \ + && mkdir -p /config && chown -R qbitrr:qbitrr /config /app + +COPY docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh WORKDIR /config EXPOSE 6969 -ENTRYPOINT ["python", "-m", "qBitrr.main"] +ENTRYPOINT ["/docker-entrypoint.sh"] +CMD ["python", "-m", "qBitrr.main"] diff --git a/README.md b/README.md index f65dff01a..24bdedad5 100644 --- a/README.md +++ b/README.md @@ -186,4 +186,4 @@ Released under the [MIT License](LICENSE). Use it, modify it, share it—commerc Star History Chart - \ No newline at end of file + diff --git a/config.example.toml b/config.example.toml index 6d19ca028..71daf0a1c 100644 --- a/config.example.toml +++ b/config.example.toml @@ -155,8 +155,8 @@ MaxSeedingTime = -1 # 4 = Remove when both conditions are met (AND) RemoveTorrent = -1 -# Enable Hit and Run protection for managed category torrents -HitAndRunMode = false +# Hit and Run mode: and = require both ratio and time; or = either clears; disabled = no HnR +HitAndRunMode = "disabled" # Minimum seed ratio before removal allowed (HnR protection) MinSeedRatio = 1.0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..ad4f09cb6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +services: + qbitrr: + container_name: qbitrr + build: + context: . + dockerfile: Dockerfile + environment: + - UMASK=000 + - TZ=${TZ} + - PUID=${USER_ID} + - PGID=${USER_ID} + restart: always + tty: true + stop_grace_period: 30s + volumes: + - torrents:/torrents + - .config:/config + logging: + driver: json-file + options: + max-size: 50m + max-file: 1 + ports: + - 6969:6969 + +volumes: + torrents: + driver: local + driver_opts: + type: cifs + device: ${CIFS_PATH} + o: username=${CIFS_USER},password=${CIFS_PASS},vers=3.1.1,iocharset=utf8 diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 000000000..6afe7eb1c --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -e +# Default to 1000:1000 when PUID/PGID are not set (e.g. docker run without -e) +export PUID="${PUID:-1000}" +export PGID="${PGID:-1000}" + +# When running as root, ensure /config is owned by PUID:PGID so the app can write +# (mounted volumes often appear as root-owned; this restores writability) +if [ "$(id -u)" = "0" ] && [ "${PUID}" != "0" ]; then + chown -R "${PUID}:${PGID}" /config 2>/dev/null || true +fi + +# Run the container CMD as the specified user (tini + python) +exec gosu "${PUID}:${PGID}" /usr/bin/tini -- "$@" diff --git a/docs/advanced/architecture.md b/docs/advanced/architecture.md index b5eb332f8..1ed109287 100644 --- a/docs/advanced/architecture.md +++ b/docs/advanced/architecture.md @@ -939,7 +939,7 @@ def run_loop(self): time.sleep(LOOP_SLEEP_TIMER) except DelayLoopException as e: - logger.warning(f"Delaying loop for {e.length}s: {e.type}") + logger.warning(f"Delaying loop for {e.length}s: {e.error_type}") time.sleep(e.length) except RestartLoopException: @@ -970,12 +970,12 @@ if torrent['category'] not in self.categories: ```python class DelayLoopException(qBitManagerError): - def __init__(self, length: int, type: str): - self.type = type # Reason for delay - self.length = length # Seconds to delay + def __init__(self, length: int, error_type: str): + self.error_type = error_type # Reason for delay + self.length = length # Seconds to delay # Usage: raised when qBittorrent or an Arr API is unreachable -raise DelayLoopException(length=60, type="qbittorrent_offline") +raise DelayLoopException(length=60, error_type="qbittorrent_offline") ``` **RestartLoopException** -- restart from the beginning after a config reload: @@ -989,9 +989,9 @@ class RestartLoopException(ArrManagerException): ```python class NoConnectionrException(qBitManagerError): - def __init__(self, message: str, type: str = "delay"): + def __init__(self, message: str, error_type: str = "delay"): self.message = message - self.type = type # "delay" or "fatal" + self.error_type = error_type # "delay" or "fatal" ``` ### Torrent State Machine diff --git a/docs/advanced/index.md b/docs/advanced/index.md index a72d7ec73..7c0f7ee1b 100644 --- a/docs/advanced/index.md +++ b/docs/advanced/index.md @@ -345,7 +345,7 @@ External monitoring can check: ```bash # Check if qBitrr is running -curl http://localhost:6969/api/health +curl http://localhost:6969/health # Expected response: {"status": "healthy", "version": "5.5.5"} diff --git a/docs/changelog.md b/docs/changelog.md index 04c99a55c..e137197fb 120000 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1 +1 @@ -../CHANGELOG.md \ No newline at end of file +{% include-markdown "../CHANGELOG.md" %} diff --git a/docs/configuration/config-file.md b/docs/configuration/config-file.md index b07b5fc77..a26bf5586 100644 --- a/docs/configuration/config-file.md +++ b/docs/configuration/config-file.md @@ -46,6 +46,21 @@ name = "first" name = "second" ``` +### Time values (durations) + +Many settings accept **time durations**. You can use: + +- **Integers (legacy)**: Plain numbers in the key's base unit (seconds or minutes, depending on the setting). Example: `LoopSleepTimer = 5` (5 seconds), `StalledDelay = 15` (15 minutes). +- **Suffixed strings**: A number followed by a unit letter for human-readable values: + - `s` = seconds + - `m` = minutes + - `h` = hours + - `d` = days + - `w` = weeks + - `M` = months (30 days; uppercase to distinguish from minutes) + +Examples: `MaxSeedingTime = "1w"` (1 week in seconds), `StalledDelay = "1440m"` (1440 minutes). Unsupplied or empty suffix is treated as the key's base unit (seconds or minutes). Use `-1` or `"-1"` where a setting supports "disabled". + --- ## Configuration Sections @@ -69,8 +84,8 @@ The `[Settings]` section contains global configuration that applies to all qBitr ```toml [Settings] -# Internal config schema version - DO NOT MODIFY -ConfigVersion = 3 +# Internal config schema version - DO NOT MODIFY (managed automatically) +# ConfigVersion = "5.9.2" # Logging ConsoleLevel = "INFO" @@ -119,11 +134,11 @@ ProcessRestartDelay = 5 ### ConfigVersion ```toml -ConfigVersion = 3 +ConfigVersion = "5.9.2" ``` -**Type:** Integer -**Default:** `3` +**Type:** String +**Default:** Set automatically by qBitrr (e.g. `"5.9.2"`). Legacy integer values 1–4 are accepted and mapped to semver strings. **Required:** Yes (managed automatically) Internal configuration schema version. **DO NOT MODIFY** this value manually. qBitrr uses it to detect when config migrations are needed. @@ -270,7 +285,7 @@ FreeSpaceFolder = "/data/downloads" ``` **Type:** String (path) -**Default:** Same as `CompletedDownloadFolder` +**Default:** `"CHANGE_ME"` in generated config. Must be set explicitly when `FreeSpace != "-1"`. Folder to monitor for free space checks. Usually the same as `CompletedDownloadFolder`. @@ -412,6 +427,8 @@ qBittorrent category for manually marking torrents as failed. - Wrong content - Manual intervention needed +Torrents in this category receive the same qBit-side behaviour as Arr-managed categories (recheck on error, missing files handling, stalled detection, etc.). Only research and blocklisting (notifying Arr, re-search) are Arr-specific; for the `failed` category, qBitrr still marks the release as failed in Arr and removes the torrent when so configured. + --- ### RecheckCategory @@ -438,6 +455,8 @@ qBittorrent category for forcing torrent recheck. - After restoring from backup - After moving download location +Torrents in this category receive the same qBit-side behaviour as Arr-managed categories (recheck on error, missing files handling, stalled detection, etc.). Only research and blocklisting remain Arr-specific; for the `recheck` category, qBitrr forces a full recheck and moves the torrent back to its original category. + --- ### Tagless @@ -736,6 +755,7 @@ LiveArr = true GroupSonarr = true GroupLidarr = true Theme = "Dark" +ViewDensity = "Comfortable" ``` --- @@ -933,6 +953,20 @@ WebUI color theme. --- +### ViewDensity + +```toml +ViewDensity = "Comfortable" +``` + +**Type:** String +**Default:** `"Comfortable"` +**Options:** `Comfortable`, `Compact` + +List view density in the WebUI (e.g. Arr views, process list). `Comfortable` uses more spacing; `Compact` fits more rows on screen. + +--- + ## qBit Section The `[qBit]` section configures the connection to qBittorrent. @@ -946,9 +980,10 @@ Host = "localhost" Port = 8080 UserName = "admin" Password = "adminpass" +ManagedCategories = [] ``` -For detailed qBittorrent configuration, see the [qBittorrent Configuration Guide](qbittorrent.md). +For detailed qBittorrent configuration, including `[qBit.CategorySeeding]` for per-category seeding settings, see the [qBittorrent Configuration Guide](qbittorrent.md). --- @@ -1170,11 +1205,10 @@ Host = "localhost" # Required ## Complete Minimal Config -Absolute minimum configuration to get started: +Absolute minimum configuration to get started. `ConfigVersion` is managed automatically by qBitrr; omit it or leave as set by `--gen-config`. ```toml [Settings] -ConfigVersion = 3 CompletedDownloadFolder = "/data/downloads" [WebUI] diff --git a/docs/configuration/environment.md b/docs/configuration/environment.md index cb16d36dc..e49a1f3cc 100644 --- a/docs/configuration/environment.md +++ b/docs/configuration/environment.md @@ -169,9 +169,9 @@ QBITRR_SETTINGS_LOOP_SLEEP_TIMER=10 ### QBITRR_SETTINGS_SEARCH_LOOP_DELAY **Type:** Integer -**Default:** `-1` (disabled) +**Default:** `-1` (use internal 30-second delay between each search command) -Seconds to sleep between posting search commands. +Seconds between posting individual search commands when processing a batch. When `-1`, qBitrr uses an internal 30-second delay. ```bash QBITRR_SETTINGS_SEARCH_LOOP_DELAY=300 @@ -841,6 +841,8 @@ QBITRR_QBIT_PASSWORD: ### Complete Environment Variable List +The following environment variables are supported by qBitrr. Process management and WebUI settings (e.g. `AutoRestartProcesses`, `WebUI.Port`, `WebUI.Token`) are not currently overridable via environment variables; use `config.toml` for those. + | Environment Variable | Type | Default | Description | |---------------------|------|---------|-------------| | `QBITRR_SETTINGS_CONSOLE_LEVEL` | String | `INFO` | Console logging level | @@ -851,7 +853,7 @@ QBITRR_QBIT_PASSWORD: | `QBITRR_SETTINGS_AUTO_PAUSE_RESUME` | Boolean | `true` | Auto pause/resume torrents | | `QBITRR_SETTINGS_NO_INTERNET_SLEEP_TIMER` | Integer | `15` | Sleep seconds when no internet | | `QBITRR_SETTINGS_LOOP_SLEEP_TIMER` | Integer | `5` | Sleep seconds between loops | -| `QBITRR_SETTINGS_SEARCH_LOOP_DELAY` | Integer | `-1` | Delay between search commands | +| `QBITRR_SETTINGS_SEARCH_LOOP_DELAY` | Integer | `-1` | Seconds between search commands (-1 = internal 30s delay) | | `QBITRR_SETTINGS_FAILED_CATEGORY` | String | `failed` | Failed torrents category | | `QBITRR_SETTINGS_RECHECK_CATEGORY` | String | `recheck` | Recheck torrents category | | `QBITRR_SETTINGS_TAGLESS` | Boolean | `false` | Process all torrents (ignore tags) | diff --git a/docs/configuration/seeding.md b/docs/configuration/seeding.md index f517ebe5e..ddee25849 100644 --- a/docs/configuration/seeding.md +++ b/docs/configuration/seeding.md @@ -370,7 +370,7 @@ RemoveIfExists = false SuperSeedMode = false # Hit and Run protection -HitAndRunMode = true +HitAndRunMode = "and" MinSeedRatio = 1.0 MinSeedingTimeDays = 5 TrackerUpdateBuffer = 3600 @@ -603,13 +603,21 @@ Automatically add tags to torrents with this tracker. #### HitAndRunMode ```toml -HitAndRunMode = true +HitAndRunMode = "and" # or "or", "disabled" ``` -**Type:** Boolean -**Default:** `false` +**Type:** String: `"and"` | `"or"` | `"disabled"` +**Default:** `"disabled"` (no HnR) + +Single setting for Hit and Run (HnR) protection for this tracker: + +| Value | Meaning | +|-------|---------| +| `"and"` | HnR enabled; clears only when **both** minimum ratio **and** minimum time are met | +| `"or"` | HnR enabled; clears when **either** minimum ratio **or** minimum time is met | +| `"disabled"` | No HnR protection; torrents can be removed per `RemoveTorrent` only | -Enable Hit and Run (HnR) protection for this tracker. When `true`, torrents with this tracker will **not** be removed until HnR obligations are met, regardless of `RemoveTorrent` settings. See [Hit and Run Protection](#hit-and-run-protection) for details on how obligations are calculated. +When `"and"` or `"or"`, torrents with this tracker will **not** be removed until HnR obligations are met, regardless of `RemoveTorrent` settings. When only one of ratio or time is set (e.g. `MinSeedingTimeDays = 0`), that single condition is used. Partial downloads always use `HitAndRunPartialSeedRatio` only. --- @@ -622,7 +630,7 @@ MinSeedRatio = 1.0 **Type:** Float **Default:** `1.0` -Minimum upload ratio required to clear HnR. A value of `1.0` means you must upload at least as much as you downloaded. For full downloads, **either** ratio OR time clears the obligation (whichever comes first). +Minimum upload ratio required to clear HnR. A value of `1.0` means you must upload at least as much as you downloaded. For full downloads, whether **both** ratio and time are required or **either** clears the obligation is controlled by [`HitAndRunMode`](#hitandrunmode) (`"and"` vs `"or"`). --- @@ -726,7 +734,7 @@ Priority = 10 URI = "https://tracker.beyond-hd.me/announce" MaxUploadRatio = 1.5 MaxSeedingTime = 432000 # 5 days -HitAndRunMode = true +HitAndRunMode = "and" MinSeedRatio = 1.0 MinSeedingTimeDays = 5 AddTags = ["private", "BeyondHD"] @@ -764,7 +772,7 @@ Priority = 10 URI = "https://flacsfor.me/announce" MaxUploadRatio = -1 # Never remove based on ratio MaxSeedingTime = 259200 # 72 hours -HitAndRunMode = true +HitAndRunMode = "and" MinSeedRatio = 1.0 MinSeedingTimeDays = 3 AddTags = ["RED", "music", "lossless"] @@ -971,6 +979,7 @@ tail -f ~/logs/Radarr-Movies.log | grep -i "remov\|seed\|ratio" MaxUploadRatio = 2.0 # Must not be -1 MaxSeedingTime = 604800 # Must not be -1 ``` + If `RemoveTorrent` is 1–4 but **neither** `MaxUploadRatio` nor `MaxSeedingTime` is set (both -1 or unset), qBitrr will **not** remove torrents for seeding limits and will log a single warning per run. Set at least one limit to enable ratio/time-based removal. 3. **Check import mode:** ```toml @@ -1134,7 +1143,7 @@ qBitrr's HnR protection acts as a **safety layer** on top of the existing seedin - **Downloads below [`HitAndRunMinimumDownloadPercent`](#hitandrunminimumdownloadpercent)%** (default 10%): Not subject to HnR (most trackers don't count these) - **[`TrackerUpdateBuffer`](#trackerupdatebuffer)**: Extra seconds subtracted from seeding time to account for tracker stats lag (~30 min behind the client) -When [`HitAndRunMode`](#hitandrunmode) = `false` (default), behavior is identical to previous versions. +When [`HitAndRunMode`](#hitandrunmode) = `"disabled"` (default), behavior is identical to previous versions. **Automatic HnR bypass:** If a tracker reports the torrent as unregistered, unauthorized, or not found, HnR protection is automatically bypassed — the torrent no longer exists on the tracker, so seeding obligations no longer apply. @@ -1168,7 +1177,7 @@ RemoveTorrent = 3 # Remove when either met Name = "TorrentLeech" URI = "tracker.torrentleech.org" Priority = 10 -HitAndRunMode = true +HitAndRunMode = "and" MinSeedRatio = 1.0 MinSeedingTimeDays = 10 # Registered class requirement HitAndRunMinimumDownloadPercent = 10 # TL counts HnR at >=10% downloaded @@ -1188,7 +1197,7 @@ RemoveTorrent = 3 Name = "TorrentLeech" URI = "tracker.torrentleech.org" Priority = 10 -HitAndRunMode = true +HitAndRunMode = "and" MinSeedRatio = 1.0 MinSeedingTimeDays = 4 # TL God class requirement TrackerUpdateBuffer = 3600 @@ -1207,7 +1216,7 @@ RemoveTorrent = 3 Name = "TorrentLeech" URI = "tracker.torrentleech.org" Priority = 10 -HitAndRunMode = true +HitAndRunMode = "and" MinSeedRatio = 1.0 MinSeedingTimeDays = 10 TrackerUpdateBuffer = 3600 @@ -1217,7 +1226,7 @@ TrackerUpdateBuffer = 3600 Name = "IPTorrents" URI = "tracker.iptorrents.com" Priority = 8 -HitAndRunMode = true +HitAndRunMode = "and" MinSeedRatio = 1.0 MinSeedingTimeDays = 4 TrackerUpdateBuffer = 3600 @@ -1227,7 +1236,7 @@ TrackerUpdateBuffer = 3600 Name = "Public" URI = "udp://open.stealth.si:80/announce" Priority = 1 -HitAndRunMode = false +HitAndRunMode = "disabled" ``` --- diff --git a/docs/configuration/torrents.md b/docs/configuration/torrents.md index c10049152..35fe3c811 100644 --- a/docs/configuration/torrents.md +++ b/docs/configuration/torrents.md @@ -333,7 +333,7 @@ DoNotRemoveSlow = true - Slow/stalled torrents are removed based on `StalledDelay` -**Recommendation:** `true` to avoid removing torrents on slow but working trackers +**Recommendation:** `true` to avoid removing torrents on slow but working trackers. When `false`, torrents that exceed `MaximumETA` are removed **after** they complete downloading (see [MaximumETA](#maximumeta)). --- diff --git a/docs/features/disk-space.md b/docs/features/disk-space.md index 6692708ad..fe28a0902 100644 --- a/docs/features/disk-space.md +++ b/docs/features/disk-space.md @@ -31,7 +31,7 @@ graph TD A[qBitrr monitoring loop] --> B{Check free space} B --> C{Space < FreeSpace?} C -->|No| D[Continue normally] - C -->|Yes| E[Pause all downloads] + C -->|Yes| E[Pause downloads in managed categories] E --> F[Log warning] F --> G[Keep seeding active] G --> H[Wait for next loop] @@ -46,13 +46,22 @@ graph TD 1. qBitrr checks free space every `LoopSleepTimer` seconds (default: 5) 2. Compares available space to `FreeSpace` threshold 3. If space < threshold: - - Pauses all downloading torrents + - Pauses **downloading** torrents in **managed** categories (Arr-managed and qBit-managed) so that free space would not fall below the threshold - Keeps seeding torrents active - Logs warning message 4. When space frees up (files imported/deleted): - Automatically resumes paused downloads - Continues normal operation +**Which torrents are monitored:** + +The Free Space Manager monitors torrents in **all categories** that qBitrr manages: + +- **Arr-managed categories** — The `Category` setting for each Radarr, Sonarr, and Lidarr instance (e.g. `radarr`, `sonarr`, `radarr4k`). +- **qBit-managed categories** — Categories listed in `[qBit].ManagedCategories` (e.g. `autobrr`). + +Torrents in any of these categories are included in free-space checks and can be paused when disk is low. Torrents in other qBittorrent categories (not used by any Arr instance and not in `ManagedCategories`) are not monitored. + --- ## Configuration @@ -130,7 +139,7 @@ FreeSpaceFolder = "/data/downloads" ``` **Type:** String (path) -**Default:** Same as `CompletedDownloadFolder` +**Default:** `"CHANGE_ME"` in generated config; must be set when `FreeSpace != "-1"` Folder to monitor for free space. @@ -161,6 +170,8 @@ CompletedDownloadFolder = "/downloads" FreeSpaceFolder = "/downloads" ``` +**Docker:** The path is **inside the container**. You must mount your host torrent directory at that path (e.g. `-v /host/torrents:/torrents`). If the path does not exist in the container, qBitrr falls back to `/` and reports the container root filesystem's free space (usually very small), which will trigger constant pausing. Ensure your compose/run mounts the volume at the same path as `FreeSpaceFolder`. + --- ### AutoPauseResume @@ -298,7 +309,7 @@ State: qBitrr Actions: 1. Detects: 48 GB < 50 GB threshold -2. Pauses all downloading torrents +2. Pauses downloading torrents in managed categories (see "Which torrents are monitored" below) 3. Logs: "Disk space low, pausing downloads" 4. Keeps seeding torrents active 5. Waits for space to free up @@ -456,9 +467,14 @@ Check paused torrents in qBittorrent: df -h /data/downloads ``` -4. **Check logs for errors:** +4. **Ensure torrents are in monitored categories:** + Free Space Manager only considers torrents in Arr-managed categories (each instance's `Category`) and in `[qBit].ManagedCategories`. If your torrents use a different qBittorrent category that is not in either set, they will not be paused. Add that category to an Arr instance's `Category` or to `qBit.ManagedCategories`. + +5. **Check logs for errors:** ```bash grep -i "space\|error" ~/logs/Main.log + # Or FreeSpaceManager log + tail -f ~/config/logs/FreeSpaceManager.log ``` --- @@ -498,6 +514,14 @@ Check paused torrents in qBittorrent: FreeSpaceFolder = "/downloads" ``` +4. **Windows: Use the actual drive path** + If `FreeSpaceFolder` is set to a Unix-style path (e.g. `/torrents`) and qBitrr runs natively on Windows, that path is resolved to the current drive (e.g. `C:\torrents`). If that path does not exist, qBitrr falls back to the drive root (e.g. `C:\`) and reports that drive's free space. Set `FreeSpaceFolder` (and `CompletedDownloadFolder`) to the real path where torrents are stored, for example: + ```toml + CompletedDownloadFolder = "D:\\torrents" + FreeSpaceFolder = "D:\\torrents" + ``` + Check the FreeSpaceManager log (TRACE) for the line `Path: ...` to see which path is being used for the free-space check. + --- ### Imports Failing Due to Space diff --git a/docs/features/health-monitoring.md b/docs/features/health-monitoring.md index 26f012d9c..bc93de1e9 100644 --- a/docs/features/health-monitoring.md +++ b/docs/features/health-monitoring.md @@ -807,6 +807,9 @@ FFprobe is pre-installed in the official qBitrr Docker image. ### Validation Rules +!!! note "Advanced / optional" + The `[Settings.FFprobe]` section and the options below are **not** generated by `qbitrr --gen-config`. The only FFprobe-related setting in the generated config is `Settings.FFprobeAutoUpdate`. If your codebase reads additional FFprobe options (e.g. `FFprobePath`, `CheckVideoCodec`, `Timeout`), add them manually to `config.toml`. See the [Configuration File Reference](../configuration/config-file.md) for supported keys. + #### Video Validation ```toml diff --git a/docs/features/process-management.md b/docs/features/process-management.md index 1716663ec..f214ffde7 100644 --- a/docs/features/process-management.md +++ b/docs/features/process-management.md @@ -267,7 +267,7 @@ qBitrr implements graceful shutdown to ensure data consistency: ### Shutdown Timeout -If a process doesn't terminate within 30 seconds, it's forcefully killed (SIGKILL). +If a process doesn't terminate within a short period after receiving SIGTERM, it is forcefully killed (SIGKILL). The exact delay is implementation-defined (e.g. a few seconds in recovery paths). --- @@ -369,7 +369,7 @@ If a process doesn't terminate within 30 seconds, it's forcefully killed (SIGKIL 1. **Enable Auto-Restart**: `AutoRestartProcesses = true` 2. **Set Reasonable Limits**: Default settings (5 restarts in 5 minutes) work for most cases 3. **Monitor Logs**: Set up log aggregation or alerts for restart events -4. **Use External Health Checks**: Monitor qBitrr's WebUI endpoint (`/api/health`) with Uptime Kuma, Healthchecks.io, etc. +4. **Use External Health Checks**: Monitor qBitrr's WebUI endpoint (`/health`) with Uptime Kuma, Healthchecks.io, etc. --- @@ -391,7 +391,7 @@ services: image: feramance/qbitrr:latest restart: unless-stopped # Container-level restart environment: - - QBITRR_SETTINGS__AUTORESTARTPROCESSES=true # Process-level restart + - QBITRR_SETTINGS_AUTO_RESTART_PROCESSES=true # Process-level restart ``` **Layered Restart:** @@ -441,13 +441,13 @@ services: image: feramance/qbitrr:latest restart: unless-stopped healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:6969/api/health"] + test: ["CMD", "curl", "-f", "http://localhost:6969/health"] interval: 30s timeout: 10s retries: 3 start_period: 40s environment: - - QBITRR_SETTINGS__AUTORESTARTPROCESSES=true + - QBITRR_SETTINGS_AUTO_RESTART_PROCESSES=true ``` --- @@ -463,14 +463,14 @@ services: # Monitor qBitrr WebUI - name: qBitrr type: HTTP - url: http://localhost:6969/api/health + url: http://localhost:6969/health interval: 60 ``` **Healthchecks.io:** ```bash # Cron job to ping healthchecks.io if qBitrr is running -*/5 * * * * curl -fsS --retry 3 http://localhost:6969/api/health && curl -fsS https://hc-ping.com/YOUR-UUID +*/5 * * * * curl -fsS --retry 3 http://localhost:6969/health && curl -fsS https://hc-ping.com/YOUR-UUID ``` --- @@ -529,13 +529,24 @@ GET /api/processes { "processes": [ { - "name": "Radarr-Movies", "category": "radarr-movies", - "status": "running", + "name": "Radarr-Movies", + "kind": "search", "pid": 12345, - "uptime": 3600, - "restart_count": 2, - "last_restart": "2024-01-15T10:30:00Z" + "alive": true, + "rebuilding": false, + "searchSummary": "Found 12 missing movies, searched 5", + "searchTimestamp": "2025-11-27T12:00:00Z" + }, + { + "category": "radarr-movies", + "name": "Radarr-Movies", + "kind": "torrent", + "pid": 12346, + "alive": true, + "rebuilding": false, + "queueCount": 3, + "categoryCount": 8 } ] } diff --git a/docs/getting-started/comparison-huntarr.md b/docs/getting-started/comparison-huntarr.md new file mode 100644 index 000000000..983485bc4 --- /dev/null +++ b/docs/getting-started/comparison-huntarr.md @@ -0,0 +1,109 @@ +# Comparison: qBitrr vs Huntarr + +Huntarr automates hunting for missing and upgradeable content across *Arr instances and offers optional Swaparr (stalled-download swapping) and Movie Hunt (standalone movie grabber). qBitrr focuses on **qBittorrent–\*Arr glue**: torrent health, instant import, and automated search. This page highlights where qBitrr is **as good or better** for missing-item search and torrent handling. + +--- + +## Missing-items search: qBitrr's strengths + +### Continuous, configurable search loop + +qBitrr queries each Arr instance for missing items, sorts them by priority (year, release date, etc.), and runs searches with a configurable delay (`SearchLoopDelay`) between individual search commands. Optional `SearchAgainOnSearchCompletion` restarts the loop so newly available releases are found without manual intervention. + +See [Automated Search](../features/automated-search.md) for full details. + +### Per-Arr, per-instance control + +Each Radarr, Sonarr, and Lidarr instance has its own `EntrySearch` settings: `SearchMissing`, `DoUpgradeSearch`, custom format options, and temporary quality profiles. You can tune search behavior separately for 4K vs 1080p, TV vs anime, or music vs movies. + +### Request prioritization + +[Overseerr](../configuration/search/overseerr.md) and [Ombi](../configuration/search/ombi.md) integration lets qBitrr search for **requested** items before general missing/upgrade queues, so user requests are fulfilled sooner. + +### Quality and custom formats + +qBitrr supports quality upgrade search, custom format scoring (including TRaSH-style guides), and temporary quality profiles for hard-to-find content. Optional force-reset on startup returns items to their main profile after testing. + +### No separate scheduler + +Search runs in the **same process** as torrent monitoring. One config file (TOML), one deployment—no separate hunt service to install or keep in sync. + +### Comparison note (Huntarr) + +Huntarr uses scheduled hunt cycles (e.g. every 30 minutes, N missing + M upgrade items per cycle). qBitrr uses a continuous loop with configurable delay between individual searches and optional restart-on-completion. Both support multi-instance and quality upgrades; qBitrr adds request integration and lives in the same stack as torrent handling. + +--- + +## Torrent handling: qBitrr's strengths + +### Instant import + +When a torrent completes in qBittorrent, qBitrr triggers the Arr's download scan **immediately** (within seconds) instead of waiting for Arr's periodic 1–5 minute poll. Media appears in your library faster. + +See [Instant Imports](../features/instant-imports.md) for details. + +### Stalled detection and action + +qBitrr uses time-based stalled detection (`StalledDelay`, MaxETA). When a torrent is stalled beyond the threshold, you can optionally re-search **before** removal (`ReSearchStalled`). Then qBitrr removes it from qBittorrent and the Arr queue and triggers a new search if `ReSearch` is enabled, so the Arr finds an alternative release. + +See [Health Monitoring](../features/health-monitoring.md) for the full workflow. + +### Failed download handling + +Failed imports are blacklisted in the Arr so the same bad release is not grabbed again. With `ReSearch` enabled, qBitrr triggers a new search so the Arr can grab a different release. + +### FFprobe verification + +qBitrr can validate media files with FFprobe before import. Invalid or corrupted files are rejected and re-search is triggered when enabled, reducing bad imports. + +### Single process, single config + +Health checks and search run together. There is no separate Swaparr-like service to install or tune—stalled and failed handling are built in. + +### Stalled handling comparison (Swaparr) + +Swaparr (often used with Huntarr) offers strike-based removal and an "ignore above size" option; qBitrr uses a single time/ETA threshold and does not have size exemption or dry run. For many users, qBitrr's time-based stalled handling plus instant import and failed/blacklist handling is sufficient and simpler to operate. + +--- + +## Feature snapshot + +| Feature | qBitrr | Huntarr / related | +| ------- | ------ | ----------------- | +| Missing media search | Yes; continuous loop, configurable delay | Yes; scheduled hunt cycles | +| Quality upgrade search | Yes; per-instance, custom formats | Yes | +| Request prioritization (Overseerr/Ombi) | Yes; built-in | No (separate request flow) | +| Instant import on completion | Yes; triggers Arr scan within seconds | No; relies on Arr polling | +| Stalled torrent handling | Yes; time-based, optional re-search before removal | Swaparr: strike-based, optional | +| Failed/blacklist + re-search | Yes; blacklist in Arr, trigger new search | Varies | +| FFprobe verification | Yes; validate before import | No | +| Multi-instance *Arr | Yes | Yes | +| Single config / single process | Yes; one TOML, one process | Huntarr + optional Swaparr/Movie Hunt | + +--- + +## What Huntarr has that qBitrr doesn't + +- **Swaparr:** Strike-based removal (multiple checks before removal), "ignore above size" for large files, and dry-run mode. If you need those, you can run Swaparr alongside qBitrr. +- **Movie Hunt:** A standalone movie grabber with its own indexers and download client—you can search and grab movies without adding them to Radarr. qBitrr does not add or grab content on its own; it only manages torrents that were added by an Arr. + +If you rely on standalone grabbing or Swaparr's strike/size options, you can run Huntarr (or Swaparr) alongside qBitrr. qBitrr does not replace standalone grabbing. + +--- + +## When to choose qBitrr + +- You want **one tool** for missing-item search and torrent health in a qBittorrent + *Arr setup. +- You care about **instant import**, **stalled/failed handling**, and **request prioritization** without adding another scheduler or client. +- You prefer a **single config and process** and a [Web UI](../webui/index.md) for monitoring. + +--- + +## Migrating from Huntarr + +1. **Install qBitrr** — [Installation](installation/index.md) (Docker, pip, or binary). +2. **Configure** — Point qBitrr at the same qBittorrent and Arr instances; set categories to match your Arr download clients. +3. **Enable search** — In each Arr's `EntrySearch` section, set `SearchMissing = true` and `DoUpgradeSearch = true` as needed. Optionally add [Overseerr](../configuration/search/overseerr.md) or [Ombi](../configuration/search/ombi.md). +4. **Disable or repurpose Huntarr** — Turn off Huntarr hunt cycles (or stop Huntarr) once qBitrr is handling search and torrents to your satisfaction. + +For backup, rollback, and detailed migration steps, see the [Migration Guide](migration.md). diff --git a/docs/getting-started/installation/docker.md b/docs/getting-started/installation/docker.md index b503df875..f35d63c5d 100644 --- a/docs/getting-started/installation/docker.md +++ b/docs/getting-started/installation/docker.md @@ -319,13 +319,13 @@ services: ### Health Checks -Add a health check: +Add a health check (the official image includes `curl` for this): ```yaml services: qbitrr: healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:6969/api/health"] + test: ["CMD", "curl", "-f", "http://localhost:6969/health"] interval: 30s timeout: 10s retries: 3 @@ -579,11 +579,13 @@ services: ### 3. Use Health Checks +The official image includes `curl` for the health check. Example: + ```yaml services: qbitrr: healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:6969/api/health"] + test: ["CMD", "curl", "-f", "http://localhost:6969/health"] interval: 30s timeout: 10s retries: 3 diff --git a/docs/getting-started/migration.md b/docs/getting-started/migration.md index 4b7ba281f..6c52e5852 100644 --- a/docs/getting-started/migration.md +++ b/docs/getting-started/migration.md @@ -305,6 +305,19 @@ qBittorrent-Manager users will find qBitrr familiar but with many enhancements. --- +### From Huntarr + +Huntarr focuses on scheduled hunt cycles for missing/upgrade content and optional Swaparr (stalled-download swapping) and Movie Hunt (standalone grabber). qBitrr focuses on missing-item search and torrent handling—instant import, stalled/failed handling—in one place with a single config. For a focused comparison of what qBitrr does as well or better for search and torrents, see [Comparison: qBitrr vs Huntarr](comparison-huntarr.md). + +#### Migration steps + +1. **Install qBitrr** — Follow the [Installation Guide](installation/index.md). +2. **Configure** — Point qBitrr at the same qBittorrent and Arr instances; set categories to match your Arr download clients. +3. **Enable search** — Set `SearchMissing = true` and `DoUpgradeSearch = true` in each Arr's `EntrySearch` section; optionally add [Overseerr](../configuration/search/overseerr.md) or [Ombi](../configuration/search/ombi.md). +4. **Disable Huntarr hunt cycles** (or stop Huntarr) once qBitrr is handling search and torrents to your satisfaction. + +--- + ### From Arr-Scripts Users running custom Arr scripts can consolidate functionality into qBitrr. @@ -531,7 +544,7 @@ tail -f ~/logs/Main.log # Native docker logs -f qbitrr # Docker # Test API -curl http://localhost:6969/api/health +curl http://localhost:6969/health # Test qBittorrent connection curl http://localhost:6969/api/qbittorrent/status diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index fa64fb111..d7ae6191e 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -544,7 +544,7 @@ curl -H "X-Api-Key: your-api-key" http://sonarr:8989/api/v3/system/status ### Test qBitrr WebUI ```bash -curl http://localhost:6969/api/health +curl http://localhost:6969/health # Expected: {"status":"healthy","version":"5.5.5"} ``` diff --git a/docs/index.md b/docs/index.md index dfa25404d..58bd5fed4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -349,7 +349,7 @@ Optimize for private tracker requirements: ### Current Version -**Latest Release**: v5.9.1 (January 2025) +**Latest Release**: v5.9.1 (February 2026) - ✅ Production ready - ✅ Active development @@ -457,7 +457,7 @@ copies of the Software. - [Python](https://python.org) - Backend language - [Flask](https://flask.palletsprojects.com/) - API framework - [React](https://react.dev/) - WebUI framework -- [Mantine](https://mantine.dev/) - UI component library +- Custom CSS - Theming and layout (no external UI runtime) **Thanks To**: diff --git a/docs/troubleshooting/common-issues.md b/docs/troubleshooting/common-issues.md index 7ce4e2fcc..7c41fdcae 100644 --- a/docs/troubleshooting/common-issues.md +++ b/docs/troubleshooting/common-issues.md @@ -676,7 +676,7 @@ The #1 reason qBitrr doesn't process torrents is **mismatched categories**. 2. **Test connection:** ```bash - curl http://localhost:6969/api/health + curl http://localhost:6969/health ``` 3. **Check firewall:** diff --git a/docs/troubleshooting/database.md b/docs/troubleshooting/database.md index 22c7be092..4151b7f01 100644 --- a/docs/troubleshooting/database.md +++ b/docs/troubleshooting/database.md @@ -314,14 +314,14 @@ sqlite3.OperationalError: database is locked ``` sqlite3.DatabaseError: database disk image is malformed sqlite3.OperationalError: disk I/O error +PRAGMA integrity_check: Freelist: size is 1 but should be 3 / btreeInitPage() returns error code 11 ``` -**Causes**: +**Why this happens**: -- Unexpected shutdown (power loss, container crash) -- Disk full during write operation -- Hardware failures (bad sectors, memory errors) -- File system corruption (ext4, btrfs, zfs) +- **Unclean shutdown** — Container or process killed (e.g. `docker kill`, OOM, or Docker stop before cleanup runs) so the database WAL is never checkpointed. This is the most common cause in Docker when the main process is PID 1 and does not receive SIGTERM. +- **Storage or filesystem** — NFS/CIFS, failing disk, full disk, or power loss during a write. +- **Multiple writers / locking** — Rare if only qBitrr uses the DB; ensure no other app touches `qbitrr.db`. **Automatic Recovery**: @@ -396,11 +396,12 @@ qBitrr automatically attempts recovery when corruption is detected: Follow these practices to minimize corruption risk: -1. **Use local storage** — SQLite on NFS/CIFS is unreliable due to file locking issues -2. **Graceful shutdown** — Always use `docker stop` (not `docker kill`); set `stop_grace_period: 30s` in Docker Compose -3. **Ensure adequate disk space** — WAL operations need temporary space; configure `FreeSpace` in config -4. **Regular backups** — Set up daily backups of `qbitrr.db` (see [Backup & Restore](#database-backup-restore)) -5. **Proper permissions** — Ensure the qBitrr user owns the database files (`chown 1000:1000 /config/qBitManager/qbitrr.db`) +1. **Use local storage** — SQLite on NFS/CIFS is unreliable due to file locking and sync semantics. Keep `qbitrr.db` on a local disk or a volume backed by local storage. +2. **Graceful shutdown** — Always use `docker stop` (not `docker kill`). Use a stop grace period so the app has time to run its shutdown handler and checkpoint the DB (see Docker section below). +3. **Docker: init process and stop_grace_period** — The official image uses **tini** as PID 1 so that when Docker sends SIGTERM, it is forwarded to the Python process. That allows the app to run its cleanup (checkpoint WAL, then exit). Set `stop_grace_period: 30s` (or more) in your Compose file so Docker does not send SIGKILL before cleanup finishes. +4. **Ensure adequate disk space** — WAL operations need temporary space; configure `FreeSpace` in config. +5. **Regular backups** — Set up daily backups of `qbitrr.db` (see [Backup & Restore](#database-backup-restore)). +6. **Proper permissions** — Ensure the qBitrr user owns the database files (`chown 1000:1000 /config/qBitManager/qbitrr.db`). !!! note "Historical Fix: synchronous Setting" Prior to the fix in commit `465c306d`, qBitrr used `PRAGMA synchronous=0` (OFF), which traded data integrity for write speed. This was changed to `synchronous=1` (NORMAL), which prevents corruption from power loss/crashes with minimal performance impact (~5-10% write latency increase, mitigated by WAL mode). diff --git a/docs/troubleshooting/docker.md b/docs/troubleshooting/docker.md index 3451b298f..12a83bdc9 100644 --- a/docs/troubleshooting/docker.md +++ b/docs/troubleshooting/docker.md @@ -166,7 +166,7 @@ services: **Test from host:** ```bash -curl http://localhost:6969/api/health +curl http://localhost:6969/health ``` **If using custom port:** @@ -308,6 +308,31 @@ docker exec qbitrr printenv | grep -i qbit ## Container Health +### Database Corruption After Restart (Docker) + +**Symptom:** After `docker stop` / `docker restart`, logs show "database disk image is malformed" or integrity check failures. + +**Cause:** The main process did not receive SIGTERM (or was killed before cleanup), so the database WAL was never checkpointed. This used to happen when Python was PID 1 in the container. + +**What qBitrr does to prevent it:** + +- The official image runs **tini** as PID 1 so SIGTERM is forwarded to the Python process. On SIGTERM, qBitrr checkpoints the database WAL and then exits. +- Use **`stop_grace_period: 30s`** (or more) in your Compose file so Docker waits long enough for cleanup before sending SIGKILL. + +**Example:** + +```yaml +services: + qbitrr: + image: feramance/qbitrr:latest + stop_grace_period: 30s # Give time for DB checkpoint on stop + # ... rest of config +``` + +**Do not:** Use `docker kill` or `docker stop -t 0`; that skips graceful shutdown and can corrupt the database. + +--- + ### Container Constantly Restarting **Check restart policy:** @@ -534,7 +559,7 @@ docker exec qbitrr ps aux | grep qbitrr ```bash # From host to container -curl http://localhost:6969/api/health +curl http://localhost:6969/health # From container to qBittorrent docker exec qbitrr curl -v http://qbittorrent:8080/api/v2/app/version diff --git a/docs/troubleshooting/index.md b/docs/troubleshooting/index.md index 92ffcd4eb..ecf028189 100644 --- a/docs/troubleshooting/index.md +++ b/docs/troubleshooting/index.md @@ -312,7 +312,7 @@ top -p $(pidof qbitrr) 2. **Check API response times:** ```bash - time curl http://localhost:6969/api/health +time curl http://localhost:6969/health ``` 3. **Database optimization:** diff --git a/docs/webui/api.md b/docs/webui/api.md index d722969bf..12ee9f506 100644 --- a/docs/webui/api.md +++ b/docs/webui/api.md @@ -514,6 +514,26 @@ Restart specific Arr instance (both search and torrent processes). --- +### Get qBit Categories + +Get qBittorrent categories managed by qBitrr (qBit-managed and Arr-managed) with seeding statistics. + +**Endpoint**: +- `GET /web/qbit/categories` (public only; no `/api/` variant) + +**Authentication**: None (public endpoint). + +**Response**: Array of category objects, each including: +- `category` - Category name +- `instance` - qBittorrent instance name +- `managedBy` - `"qbit"` or `"arr"` +- `torrentCount`, `seedingCount`, `totalSize`, `avgRatio`, `avgSeedingTime` +- `seedingConfig` - Per-category seeding limits (e.g. `maxRatio`, `maxTime`, `removeMode`, `downloadLimit`, `uploadLimit`) + +**Use Case**: Category management UI, seeding stats display. + +--- + ### Get Torrent Distribution Get torrent distribution statistics across all categories. diff --git a/docs/webui/config-editor.md b/docs/webui/config-editor.md index a6905f229..02ac704a9 100644 --- a/docs/webui/config-editor.md +++ b/docs/webui/config-editor.md @@ -99,7 +99,9 @@ AutoUpdateCron = "0 3 * * 0" - **WebUI Token**: Optional bearer token for API/UI authentication (auto-generated if empty) - **Live Arr**: Enable real-time Arr data (bypasses database cache, increases API load) - **Group Sonarr by Series**: Group episodes by series and seasons in collapsible sections +- **Group Lidarr by Artist**: Group Lidarr albums by artist in collapsible sections - **Theme**: Visual theme (`Light` or `Dark`) — **changes apply immediately** +- **View Density**: List density (`Comfortable` or `Compact`) — applied on next load **Example**: ```toml @@ -109,7 +111,9 @@ Port = 6969 Token = "abc123def456..." LiveArr = false GroupSonarr = true +GroupLidarr = true Theme = "Dark" +ViewDensity = "Comfortable" ``` **Validation**: @@ -117,10 +121,12 @@ Theme = "Dark" - `Host` must not be empty - `Port` must be between 1 and 65535 - `Token` accepts any string (empty = no authentication) +- `ViewDensity` must be `Comfortable` or `Compact` **Special Behavior**: - **Theme**: Changes apply immediately via JavaScript (no save required) +- **ViewDensity**: Persisted to config; applied when the page is loaded - **Host/Port/Token**: Trigger WebUI restart after save --- @@ -398,7 +404,7 @@ RemoveTrackerWithMessage = ["skipping tracker announce (unreachable)", "No such **Validation**: - All numeric fields must be non-negative -- `MaximumDeletablePercentage` must be between 0 and 100 +- `MaximumDeletablePercentage` must be a decimal between 0 and 1 (e.g. 0.99 = 99%) --- @@ -408,8 +414,8 @@ Configure per-instance seeding policies. **Key Fields**: -- **Download Rate Limit Per Torrent**: Bytes/s download limit (-1 = unlimited) -- **Upload Rate Limit Per Torrent**: Bytes/s upload limit (-1 = unlimited) +- **Download Rate Limit Per Torrent**: KB/s download limit (-1 = unlimited). qBitrr converts to bytes/s for qBittorrent. +- **Upload Rate Limit Per Torrent**: KB/s upload limit (-1 = unlimited). qBitrr converts to bytes/s for qBittorrent. - **Max Upload Ratio**: Maximum upload ratio (-1 = unlimited) - **Max Seeding Time (s)**: Maximum seeding duration (-1 = unlimited) - **Remove Torrent (policy)**: Removal policy: @@ -501,6 +507,9 @@ Numeric field with spinner controls. ``` +### Duration Input +Time fields use a **number input plus unit dropdown** (s, m, h, d, w, M) instead of raw seconds or minutes. When you change a duration and save, the config may store it as a suffixed string (e.g. `MaxSeedingTime = "1w"`, `StalledDelay = "60m"`) for readability; the backend accepts both integers and suffixed strings. Disabled values (e.g. `-1`) appear as "Disabled" where supported. + ### Checkbox Toggle boolean values. @@ -584,7 +593,7 @@ The editor validates fields **on change** and **before save**, displaying inline | `Arr.Category` | Must not be empty | | `EntrySearch.SearchLimit` | Must be ≥ 1 | | `AutoUpdateCron` | Must contain 5 or 6 space-separated fields | -| `Torrent.MaximumDeletablePercentage` | Must be 0-100 | +| `Torrent.MaximumDeletablePercentage` | Decimal 0–1 (e.g. 0.99 = 99%) | | `Torrent.SeedingMode.RemoveTorrent` | Must be -1, 1, 2, 3, or 4 | ### Error Display @@ -623,7 +632,7 @@ The backend uses **intelligent reload detection** to minimize disruption: | Change Type | Reload Type | Behavior | |-------------|-------------|----------| -| **Frontend-only** (`WebUI.Theme`, `WebUI.LiveArr`, `WebUI.GroupSonarr`) | `frontend` | No reload (changes apply in browser) | +| **Frontend-only** (`WebUI.Theme`, `WebUI.LiveArr`, `WebUI.GroupSonarr`, `WebUI.GroupLidarr`, `WebUI.ViewDensity`) | `frontend` | No backend reload (Theme applies immediately; ViewDensity applies on next load) | | **WebUI Server** (`WebUI.Host`, `WebUI.Port`, `WebUI.Token`) | `webui` | Restart WebUI server (brief downtime) | | **Single Arr Instance** (e.g., `Radarr-4K.*`) | `single_arr` | Reload only that Arr instance | | **Multiple Arr Instances** (e.g., `Radarr-4K.*` + `Sonarr-TV.*`) | `multi_arr` | Reload each affected instance sequentially | diff --git a/docs/webui/index.md b/docs/webui/index.md index 0fe6edfef..9edfd5241 100644 --- a/docs/webui/index.md +++ b/docs/webui/index.md @@ -1,6 +1,6 @@ # WebUI Overview -qBitrr includes a modern, React-based web interface for real-time monitoring, configuration management, and system control. Built with TypeScript and Mantine UI components, the WebUI provides a responsive, intuitive interface for managing your qBitrr instance. +qBitrr includes a modern, React-based web interface for real-time monitoring, configuration management, and system control. Built with TypeScript and custom CSS, the WebUI provides a responsive, intuitive interface for managing your qBitrr instance. --- @@ -655,7 +655,7 @@ GET /api/sonarr/series # List Sonarr series GET /api/lidarr/albums # List Lidarr albums GET /api/config # Get configuration POST /api/config # Update configuration -GET /api/health # Health check +GET /health # Health check ``` ### Authentication @@ -737,10 +737,10 @@ All responses are JSON: ```bash # From server - curl http://localhost:6969/api/health + curl http://localhost:6969/health # From remote machine - curl http://SERVER_IP:6969/api/health + curl http://SERVER_IP:6969/health ``` 5. **Check firewall rules:** @@ -883,7 +883,7 @@ All responses are JSON: ```bash # Test API response time - time curl http://localhost:6969/api/health + time curl http://localhost:6969/health ``` 5. **Database size:** diff --git a/mkdocs.yml b/mkdocs.yml index a071c25b8..c0aa55691 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -12,13 +12,13 @@ exclude_docs: | includes/abbreviations.md # Copyright -copyright: Copyright © 2025 Feramance +copyright: Copyright © 2026 Feramance # Configuration theme: name: material language: en - logo: assets/logov2-clean.png + logo: assets/logov2-clean.svg favicon: assets/favicon.ico icon: repo: fontawesome/brands/github @@ -120,9 +120,11 @@ markdown_extensions: plugins: - search: separator: '[\s\-,:!=\[\]()"`/]+|\.(?!\d)|&[lg]t;|(?!\b)(?=[A-Z][a-z])' +- include-markdown - git-revision-date-localized: enable_creation_date: true type: timeago + enable_parallel_processing: false - minify: minify_html: true minify_js: true @@ -197,7 +199,6 @@ nav: - Changelog: changelog.md - Getting Started: - - getting-started/index.md - Installation: - getting-started/installation/index.md - getting-started/installation/docker.md @@ -206,9 +207,10 @@ nav: - getting-started/installation/binary.md - Quick Start: getting-started/quickstart.md - Migration Guide: getting-started/migration.md + - 'Comparison: qBitrr vs Huntarr': getting-started/comparison-huntarr.md - Configuration: - - configuration/index.md + - Overview: configuration/index.md - Configuration File: configuration/config-file.md - qBittorrent: configuration/qbittorrent.md - Arr Instances: @@ -228,7 +230,6 @@ nav: - Environment Variables: configuration/environment.md - Features: - - features/index.md - Torrent Health Monitoring: features/health-monitoring.md - Instant Imports: features/instant-imports.md - Automated Search: features/automated-search.md diff --git a/pyproject.toml b/pyproject.toml index 4754efd86..f621e64bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,39 +26,6 @@ known_local_folder = ["qBitrr"] line-length = 99 target-version = ['py311'] -[tool.poetry] -name = "pypi-public" -version = "5.9.1" -description = "Intelligent automation for qBittorrent and *Arr apps (Radarr/Sonarr/Lidarr) - health monitoring, instant imports, quality upgrades, request integration" -authors = ["Drapersniper", "Feramance"] -readme = "README.md" -repository = "https://github.com/Feramance/qBitrr" -documentation = "https://github.com/Feramance/qBitrr/blob/master/API_DOCUMENTATION.md" -homepage = "https://github.com/Feramance/qBitrr" -url = "https://pypi.org/simple/" -keywords = ["qbittorrent", "radarr", "sonarr", "lidarr", "arr", "automation", "torrent", "media", "plex", "jellyfin"] -classifiers = [ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", - "Intended Audience :: End Users/Desktop", - "Intended Audience :: System Administrators", - "License :: OSI Approved :: MIT License", - "Natural Language :: English", - "Operating System :: MacOS :: MacOS X", - "Operating System :: Microsoft :: Windows", - "Operating System :: POSIX :: Linux", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: Implementation :: CPython", - "Topic :: Communications", - "Topic :: Internet", - "Topic :: Multimedia :: Video", - "Topic :: System :: Monitoring", - "Topic :: Utilities", - "Typing :: Typed", -] - [tool.autopep8] max_line_length = 99 ignore = "E712" diff --git a/qBitrr/arss.py b/qBitrr/arss.py index bfbc4c29a..b8792a1e7 100755 --- a/qBitrr/arss.py +++ b/qBitrr/arss.py @@ -10,9 +10,10 @@ import sys import time from collections import defaultdict +from collections.abc import Callable, Iterable, Iterator from copy import copy from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING, Callable, Iterable, Iterator, NoReturn +from typing import TYPE_CHECKING, NoReturn from urllib.parse import urlparse import ffmpeg @@ -20,6 +21,7 @@ import qbittorrentapi import qbittorrentapi.exceptions import requests +from jaraco.docker import is_docker from packaging import version as version_parser from peewee import DatabaseError, Model, OperationalError, SqliteDatabase from pyarr import LidarrAPI, RadarrAPI, SonarrAPI @@ -45,7 +47,7 @@ SEARCH_ONLY, TAGLESS, ) -from qBitrr.db_lock import with_database_retry +from qBitrr.db_lock import database_lock, with_database_retry from qBitrr.errors import ( DelayLoopException, NoConnectionrException, @@ -82,6 +84,21 @@ with_retry, ) +_ARR_RETRY_EXCEPTIONS = ( + requests.exceptions.ChunkedEncodingError, + requests.exceptions.ContentDecodingError, + requests.exceptions.ConnectionError, + JSONDecodeError, +) + +_ARR_RETRY_EXCEPTIONS_EXTENDED = ( + requests.exceptions.ChunkedEncodingError, + requests.exceptions.ContentDecodingError, + requests.exceptions.ConnectionError, + JSONDecodeError, + requests.exceptions.RequestException, +) + def _extract_tracker_host(uri: str) -> str: """Extract the hostname from a tracker URI for matching purposes. @@ -114,12 +131,6 @@ def _tracker_host_matches(config_uri: str, tracker_url: str) -> bool: ) -def _mask_secret(secret: str | None) -> str: - if not secret: - return "" - return "[redacted]" - - def _normalize_media_status(value: int | str | None) -> str: """Normalise Overseerr media status values across API versions.""" int_mapping = { @@ -215,8 +226,8 @@ def __init__( if not self.completed_folder.exists() and not SEARCH_ONLY: try: self.completed_folder.mkdir(parents=True, exist_ok=True) - self.completed_folder.chmod(mode=0o777) - except BaseException: + self.completed_folder.chmod(mode=0o755) + except Exception: self.logger.warning( "%s completed folder is a soft requirement. The specified folder does not exist %s and cannot be created. This will disable all file monitoring.", self._name, @@ -227,11 +238,15 @@ def __init__( self.import_mode = CONFIG.get(f"{name}.importMode", fallback="Auto") if self.import_mode == "Hardlink": self.import_mode = "Auto" - self.refresh_downloads_timer = CONFIG.get(f"{name}.RefreshDownloadsTimer", fallback=1) + self.refresh_downloads_timer = CONFIG.get_duration( + f"{name}.RefreshDownloadsTimer", fallback=1, unit="minutes" + ) self.arr_error_codes_to_blocklist = CONFIG.get( f"{name}.ArrErrorCodesToBlocklist", fallback=[] ) - self.rss_sync_timer = CONFIG.get(f"{name}.RssSyncTimer", fallback=15) + self.rss_sync_timer = CONFIG.get_duration( + f"{name}.RssSyncTimer", fallback=15, unit="minutes" + ) self.case_sensitive_matches = CONFIG.get( f"{name}.Torrent.CaseSensitiveMatches", fallback=False @@ -263,7 +278,7 @@ def __init__( self.seeding_mode_global_max_upload_ratio = CONFIG.get( f"{name}.Torrent.SeedingMode.MaxUploadRatio", fallback=-1 ) - self.seeding_mode_global_max_seeding_time = CONFIG.get( + self.seeding_mode_global_max_seeding_time = CONFIG.get_duration( f"{name}.Torrent.SeedingMode.MaxSeedingTime", fallback=-1 ) self.seeding_mode_global_remove_torrent = CONFIG.get( @@ -339,10 +354,10 @@ def __init__( f"{name}.EntrySearch.ForceMinimumCustomFormat", fallback=False ) - self.ignore_torrents_younger_than = CONFIG.get( + self.ignore_torrents_younger_than = CONFIG.get_duration( f"{name}.Torrent.IgnoreTorrentsYoungerThan", fallback=600 ) - self.maximum_eta = CONFIG.get(f"{name}.Torrent.MaximumETA", fallback=86400) + self.maximum_eta = CONFIG.get_duration(f"{name}.Torrent.MaximumETA", fallback=86400) self.maximum_deletable_percentage = CONFIG.get( f"{name}.Torrent.MaximumDeletablePercentage", fallback=0.95 ) @@ -361,7 +376,9 @@ def __init__( self.do_not_remove_slow = CONFIG.get(f"{name}.Torrent.DoNotRemoveSlow", fallback=False) self.re_search_stalled = CONFIG.get(f"{name}.Torrent.ReSearchStalled", fallback=False) - self.stalled_delay = CONFIG.get(f"{name}.Torrent.StalledDelay", fallback=15) + self.stalled_delay = CONFIG.get_duration( + f"{name}.Torrent.StalledDelay", fallback=15, unit="minutes" + ) self.allowed_stalled = True if self.stalled_delay != -1 else False self.search_current_year = None @@ -412,7 +429,7 @@ def __init__( self.overseerr_approved_only = CONFIG.get( f"{name}.EntrySearch.Overseerr.ApprovedOnly", fallback=True ) - self.search_requests_every_x_seconds = CONFIG.get( + self.search_requests_every_x_seconds = CONFIG.get_duration( f"{name}.EntrySearch.SearchRequestsEvery", fallback=300 ) self._temp_overseer_request_cache: dict[str, set[int | str]] = defaultdict(set) @@ -520,8 +537,8 @@ def __init__( self.profile_switch_retry_attempts = CONFIG.get( f"{name}.EntrySearch.ProfileSwitchRetryAttempts", fallback=3 ) - self.temp_profile_timeout_minutes = CONFIG.get( - f"{name}.EntrySearch.TempProfileResetTimeoutMinutes", fallback=0 + self.temp_profile_timeout_minutes = CONFIG.get_duration( + f"{name}.EntrySearch.TempProfileResetTimeoutMinutes", fallback=0, unit="minutes" ) self.logger.info( "Parsed quality profile mappings: %s", @@ -569,12 +586,14 @@ def __init__( self.delete = set() self.resume = set() self.remove_from_qbit = set() + self.remove_from_qbit_by_instance: dict[str, set[str]] = {} self.overseerr_requests_release_cache = {} self.files_to_explicitly_delete: Iterator = iter([]) self.files_to_cleanup = set() self.missing_files_post_delete = set() self.downloads_with_bad_error_message_blocklist = set() self.needs_cleanup = False + self._warned_no_seeding_limits = False self.last_search_description: str | None = None self.last_search_timestamp: str | None = None @@ -591,6 +610,7 @@ def __init__( self.special_casing_file_check = ExpiringSet(max_age_seconds=10) self.expiring_bool = ExpiringSet(max_age_seconds=10) self.session = requests.Session() + atexit.register(self.session.close) self.cleaned_torrents = set() self.search_api_command = None @@ -598,6 +618,7 @@ def __init__( self.manager.completed_folders.add(self.completed_folder) self.manager.category_allowlist.add(self.category) + _masked_apikey = "[redacted]" if self.apikey else "" self.logger.debug( "%s Config: " "Managed: %s, " @@ -614,7 +635,7 @@ def __init__( self.re_search, self.category, self.uri, - _mask_secret(self.apikey), + _masked_apikey, self.refresh_downloads_timer, self.rss_sync_timer, ) @@ -660,16 +681,16 @@ def __init__( self.logger.debug("Script Config: SearchOmbiRequests=%s", self.ombi_search_requests) if self.ombi_search_requests: self.logger.debug("Script Config: OmbiURI=%s", self.ombi_uri) - self.logger.debug("Script Config: OmbiAPIKey=%s", _mask_secret(self.ombi_api_key)) + _masked_ombi = "[redacted]" if self.ombi_api_key else "" + self.logger.debug("Script Config: OmbiAPIKey=%s", _masked_ombi) self.logger.debug("Script Config: ApprovedOnly=%s", self.ombi_approved_only) self.logger.debug( "Script Config: SearchOverseerrRequests=%s", self.overseerr_requests ) if self.overseerr_requests: self.logger.debug("Script Config: OverseerrURI=%s", self.overseerr_uri) - self.logger.debug( - "Script Config: OverseerrAPIKey=%s", _mask_secret(self.overseerr_api_key) - ) + _masked_overseerr = "[redacted]" if self.overseerr_api_key else "" + self.logger.debug("Script Config: OverseerrAPIKey=%s", _masked_overseerr) if self.ombi_search_requests or self.overseerr_requests: self.logger.debug( "Script Config: SearchRequestsEvery=%s", self.search_requests_every_x_seconds @@ -680,7 +701,7 @@ def __init__( self.quality_unmet_search or self.do_upgrade_search or self.custom_format_unmet_search - or self.series_search == True + or self.series_search is True ): self.search_api_command = "SeriesSearch" elif self.series_search == "smart": @@ -874,7 +895,9 @@ def is_alive(self) -> bool: self.expiring_bool.add(1) return True req = self.session.get( - f"{self.uri}/api/v3/system/status", timeout=10, params={"apikey": self.apikey} + f"{self.uri}/api/v3/system/status", + timeout=10, + headers={"X-Api-Key": self.apikey}, ) req.raise_for_status() self.logger.trace("Successfully connected to %s", self.uri) @@ -886,7 +909,7 @@ def is_alive(self) -> bool: except requests.RequestException: self.logger.warning("Could not connect to %s", self.uri) # Clear the cache to ensure we retry on next check - if 1 in self.expiring_bool.container: + with contextlib.suppress(KeyError): self.expiring_bool.remove(1) return False @@ -903,6 +926,19 @@ def is_ignored_state(torrent: TorrentDictionary) -> bool: TorrentStates.QUEUED_DOWNLOAD, ) + @staticmethod + def _is_missing_files_torrent(torrent: TorrentDictionary) -> bool: + """True if torrent is in missing-files state (delete from client, no blacklist).""" + if torrent.state_enum == TorrentStates.MISSING_FILES: + return True + if torrent.state_enum == TorrentStates.ERROR: + raw = getattr(torrent, "state", None) + if raw is None and hasattr(torrent, "get"): + raw = torrent.get("state") + if isinstance(raw, str): + return raw == "missingFiles" or "missing" in raw.lower() + return False + @staticmethod def is_uploading_state(torrent: TorrentDictionary) -> bool: return torrent.state_enum in ( @@ -926,6 +962,42 @@ def is_downloading_state(torrent: TorrentDictionary) -> bool: """Returns True if the State is categorized as Downloading.""" return torrent.state_enum in (TorrentStates.DOWNLOADING, TorrentStates.PAUSED_DOWNLOAD) + _TAGLESS_FIELD_MAP = { + "qBitrr-allowed_seeding": "AllowedSeeding", + "qBitrr-imported": "Imported", + "qBitrr-allowed_stalled": "AllowedStalled", + "qBitrr-free_space_paused": "FreeSpacePaused", + } + + def _ensure_torrent_row( + self, torrent: TorrentDictionary, instance_name: str = "default" + ) -> None: + """Ensure a TorrentLibrary row exists for the given torrent.""" + with database_lock(): + query = ( + self.torrents.select() + .where( + (self.torrents.Hash == torrent.hash) + & (self.torrents.Category == torrent.category) + & (self.torrents.QbitInstance == instance_name) + ) + .execute() + ) + if not query: + self.torrents.insert( + Hash=torrent.hash, + Category=torrent.category, + QbitInstance=instance_name, + ).on_conflict_ignore().execute() + + def _torrent_condition(self, torrent: TorrentDictionary, instance_name: str = "default"): + """Return the base WHERE condition for a torrent row.""" + return ( + (self.torrents.Hash == torrent.hash) + & (self.torrents.Category == torrent.category) + & (self.torrents.QbitInstance == instance_name) + ) + def in_tags( self, torrent: TorrentDictionary, tag: str, instance_name: str = "default" ) -> bool: @@ -934,51 +1006,21 @@ def in_tags( if tag == "qBitrr-ignored": return_value = "qBitrr-ignored" in torrent.tags else: - query = ( - self.torrents.select() - .where( - (self.torrents.Hash == torrent.hash) - & (self.torrents.Category == torrent.category) - & (self.torrents.QbitInstance == instance_name) - ) - .execute() - ) - if not query: - self.torrents.insert( - Hash=torrent.hash, - Category=torrent.category, - QbitInstance=instance_name, - ).on_conflict_ignore().execute() - condition = ( - (self.torrents.Hash == torrent.hash) - & (self.torrents.Category == torrent.category) - & (self.torrents.QbitInstance == instance_name) - ) - if tag == "qBitrr-allowed_seeding": - condition &= self.torrents.AllowedSeeding == True - elif tag == "qBitrr-imported": - condition &= self.torrents.Imported == True - elif tag == "qBitrr-allowed_stalled": - condition &= self.torrents.AllowedStalled == True - elif tag == "qBitrr-free_space_paused": - condition &= self.torrents.FreeSpacePaused == True - query = self.torrents.select().where(condition).execute() - if query: - return_value = True - else: - return_value = False + with database_lock(): + self._ensure_torrent_row(torrent, instance_name) + condition = self._torrent_condition(torrent, instance_name) + field_name = self._TAGLESS_FIELD_MAP.get(tag) + if field_name: + condition &= getattr(self.torrents, field_name) == True + return_value = bool(self.torrents.select().where(condition).execute()) else: - if tag in torrent.tags: - return_value = True - else: - return_value = False + return_value = tag in torrent.tags if return_value: self.logger.trace("Tag %s in %s", tag, torrent.name) - return True else: self.logger.trace("Tag %s not in %s", tag, torrent.name) - return False + return return_value def remove_tags( self, torrent: TorrentDictionary, tags: list, instance_name: str = "default" @@ -986,46 +1028,15 @@ def remove_tags( for tag in tags: self.logger.trace("Removing tag %s from %s", tag, torrent.name) if TAGLESS: - for tag in tags: - query = ( - self.torrents.select() - .where( - (self.torrents.Hash == torrent.hash) - & (self.torrents.Category == torrent.category) - & (self.torrents.QbitInstance == instance_name) - ) - .execute() - ) - if not query: - self.torrents.insert( - Hash=torrent.hash, - Category=torrent.category, - QbitInstance=instance_name, - ).on_conflict_ignore().execute() - if tag == "qBitrr-allowed_seeding": - self.torrents.update(AllowedSeeding=False).where( - (self.torrents.Hash == torrent.hash) - & (self.torrents.Category == torrent.category) - & (self.torrents.QbitInstance == instance_name) - ).execute() - elif tag == "qBitrr-imported": - self.torrents.update(Imported=False).where( - (self.torrents.Hash == torrent.hash) - & (self.torrents.Category == torrent.category) - & (self.torrents.QbitInstance == instance_name) - ).execute() - elif tag == "qBitrr-allowed_stalled": - self.torrents.update(AllowedStalled=False).where( - (self.torrents.Hash == torrent.hash) - & (self.torrents.Category == torrent.category) - & (self.torrents.QbitInstance == instance_name) - ).execute() - elif tag == "qBitrr-free_space_paused": - self.torrents.update(FreeSpacePaused=False).where( - (self.torrents.Hash == torrent.hash) - & (self.torrents.Category == torrent.category) - & (self.torrents.QbitInstance == instance_name) - ).execute() + with database_lock(): + self._ensure_torrent_row(torrent, instance_name) + condition = self._torrent_condition(torrent, instance_name) + for tag in tags: + field_name = self._TAGLESS_FIELD_MAP.get(tag) + if field_name: + self.torrents.update({getattr(self.torrents, field_name): False}).where( + condition + ).execute() else: with contextlib.suppress(Exception): with_retry( @@ -1046,46 +1057,15 @@ def add_tags( for tag in tags: self.logger.trace("Adding tag %s from %s", tag, torrent.name) if TAGLESS: - for tag in tags: - query = ( - self.torrents.select() - .where( - (self.torrents.Hash == torrent.hash) - & (self.torrents.Category == torrent.category) - & (self.torrents.QbitInstance == instance_name) - ) - .execute() - ) - if not query: - self.torrents.insert( - Hash=torrent.hash, - Category=torrent.category, - QbitInstance=instance_name, - ).on_conflict_ignore().execute() - if tag == "qBitrr-allowed_seeding": - self.torrents.update(AllowedSeeding=True).where( - (self.torrents.Hash == torrent.hash) - & (self.torrents.Category == torrent.category) - & (self.torrents.QbitInstance == instance_name) - ).execute() - elif tag == "qBitrr-imported": - self.torrents.update(Imported=True).where( - (self.torrents.Hash == torrent.hash) - & (self.torrents.Category == torrent.category) - & (self.torrents.QbitInstance == instance_name) - ).execute() - elif tag == "qBitrr-allowed_stalled": - self.torrents.update(AllowedStalled=True).where( - (self.torrents.Hash == torrent.hash) - & (self.torrents.Category == torrent.category) - & (self.torrents.QbitInstance == instance_name) - ).execute() - elif tag == "qBitrr-free_space_paused": - self.torrents.update(FreeSpacePaused=True).where( - (self.torrents.Hash == torrent.hash) - & (self.torrents.Category == torrent.category) - & (self.torrents.QbitInstance == instance_name) - ).execute() + with database_lock(): + self._ensure_torrent_row(torrent, instance_name) + condition = self._torrent_condition(torrent, instance_name) + for tag in tags: + field_name = self._TAGLESS_FIELD_MAP.get(tag) + if field_name: + self.torrents.update({getattr(self.torrents, field_name): True}).where( + condition + ).execute() else: with contextlib.suppress(Exception): with_retry( @@ -1354,66 +1334,29 @@ def _process_imports(self) -> None: continue self.sent_to_scan_hashes.add(torrent.hash) try: - if self.type == "sonarr": - with_retry( - lambda: self.client.post_command( - "DownloadedEpisodesScan", - path=str(path), - downloadClientId=torrent.hash.upper(), - importMode=self.import_mode, - ), - retries=3, - backoff=0.5, - max_backoff=3, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - requests.exceptions.RequestException, - ), - ) - self.logger.success("DownloadedEpisodesScan: %s", path) - elif self.type == "radarr": - with_retry( - lambda: self.client.post_command( - "DownloadedMoviesScan", - path=str(path), - downloadClientId=torrent.hash.upper(), - importMode=self.import_mode, - ), - retries=3, - backoff=0.5, - max_backoff=3, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - requests.exceptions.RequestException, - ), - ) - self.logger.success("DownloadedMoviesScan: %s", path) - elif self.type == "lidarr": + scan_commands = { + "sonarr": "DownloadedEpisodesScan", + "radarr": "DownloadedMoviesScan", + "lidarr": "DownloadedAlbumsScan", + } + scan_cmd = scan_commands.get(self.type) + if scan_cmd: + _path = str(path) + _hash = torrent.hash.upper() + _mode = self.import_mode with_retry( lambda: self.client.post_command( - "DownloadedAlbumsScan", - path=str(path), - downloadClientId=torrent.hash.upper(), - importMode=self.import_mode, + scan_cmd, + path=_path, + downloadClientId=_hash, + importMode=_mode, ), retries=3, backoff=0.5, max_backoff=3, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - requests.exceptions.RequestException, - ), + exceptions=_ARR_RETRY_EXCEPTIONS_EXTENDED, ) - self.logger.success("DownloadedAlbumsScan: %s", path) + self.logger.success("%s: %s", scan_cmd, path) except Exception as ex: self.logger.error( "Downloaded scan error: [%s][%s][%s][%s]", @@ -1451,56 +1394,48 @@ def _process_failed_individual( self.logger.trace("Requeue cache entry list: %s", object_ids) if self.series_search: series_id = None - while True: - try: - data = self.client.get_series(object_ids[0]) - name = data["title"] - series_id = data["id"] - if name: - year = data.get("year", 0) - tvdbId = data.get("tvdbId", 0) - self.logger.notice( - "Re-Searching series: %s (%s) | [tvdbId=%s|id=%s]", - name, - year, - tvdbId, - series_id, - ) - else: - self.logger.notice("Re-Searching series: %s", series_id) - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ): - continue - except PyarrResourceNotFound as e: - self.logger.warning( - "Series %s not found in Sonarr (likely removed): %s", - object_ids[0], - str(e), + try: + data = with_retry( + lambda: self.client.get_series(object_ids[0]), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_ARR_RETRY_EXCEPTIONS, + ) + name = data["title"] + series_id = data["id"] + if name: + year = data.get("year", 0) + tvdbId = data.get("tvdbId", 0) + self.logger.notice( + "Re-Searching series: %s (%s) | [tvdbId=%s|id=%s]", + name, + year, + tvdbId, + series_id, ) - break + else: + self.logger.notice("Re-Searching series: %s", series_id) + except PyarrResourceNotFound as e: + self.logger.warning( + "Series %s not found in Sonarr (likely removed): %s", + object_ids[0], + str(e), + ) for object_id in object_ids: if object_id in self.queue_file_ids: self.queue_file_ids.remove(object_id) if series_id: self.logger.trace("Research series id: %s", series_id) - while True: - try: - self.client.post_command( - self.search_api_command, seriesId=series_id - ) - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ): - continue + with_retry( + lambda: self.client.post_command( + self.search_api_command, seriesId=series_id + ), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_ARR_RETRY_EXCEPTIONS, + ) if self.persistent_queue: self.persistent_queue.insert( EntryId=series_id, ArrInstance=self._name @@ -1508,69 +1443,66 @@ def _process_failed_individual( else: for object_id in object_ids: episode_found = False - while True: - try: - data = self.client.get_episode(object_id) - name = data.get("title") - series_id = data.get("series", {}).get("id") - if name: - episodeNumber = data.get("episodeNumber", 0) - absoluteEpisodeNumber = data.get("absoluteEpisodeNumber", 0) - seasonNumber = data.get("seasonNumber", 0) - seriesTitle = data.get("series", {}).get("title") - year = data.get("series", {}).get("year", 0) - tvdbId = data.get("series", {}).get("tvdbId", 0) - self.logger.notice( - "Re-Searching episode: %s (%s) | " - "S%02dE%03d " - "(E%04d) | " - "%s | " - "[tvdbId=%s|id=%s]", - seriesTitle, - year, - seasonNumber, - episodeNumber, - absoluteEpisodeNumber, - name, - tvdbId, - object_id, - ) - else: - self.logger.notice("Re-Searching episode: %s", object_id) - episode_found = True - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - AttributeError, - ): - continue - except PyarrResourceNotFound as e: - self.logger.warning( - "Episode %s not found in Sonarr (likely removed): %s", + try: + data = with_retry( + lambda oid=object_id: self.client.get_episode(oid), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( + requests.exceptions.ChunkedEncodingError, + requests.exceptions.ContentDecodingError, + requests.exceptions.ConnectionError, + JSONDecodeError, + AttributeError, + ), + ) + name = data.get("title") + series_id = data.get("series", {}).get("id") + if name: + episodeNumber = data.get("episodeNumber", 0) + absoluteEpisodeNumber = data.get("absoluteEpisodeNumber", 0) + seasonNumber = data.get("seasonNumber", 0) + seriesTitle = data.get("series", {}).get("title") + year = data.get("series", {}).get("year", 0) + tvdbId = data.get("series", {}).get("tvdbId", 0) + self.logger.notice( + "Re-Searching episode: %s (%s) | " + "S%02dE%03d " + "(E%04d) | " + "%s | " + "[tvdbId=%s|id=%s]", + seriesTitle, + year, + seasonNumber, + episodeNumber, + absoluteEpisodeNumber, + name, + tvdbId, object_id, - str(e), ) - break + else: + self.logger.notice("Re-Searching episode: %s", object_id) + episode_found = True + except PyarrResourceNotFound as e: + self.logger.warning( + "Episode %s not found in Sonarr (likely removed): %s", + object_id, + str(e), + ) if object_id in self.queue_file_ids: self.queue_file_ids.remove(object_id) if episode_found: - while True: - try: - self.client.post_command( - "EpisodeSearch", episodeIds=[object_id] - ) - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ): - continue + with_retry( + lambda oid=object_id: self.client.post_command( + "EpisodeSearch", episodeIds=[oid] + ), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_ARR_RETRY_EXCEPTIONS, + ) if self.persistent_queue: self.persistent_queue.insert( EntryId=object_id, ArrInstance=self._name @@ -1578,51 +1510,48 @@ def _process_failed_individual( elif self.type == "radarr": self.logger.trace("Requeue cache entry: %s", object_id) movie_found = False - while True: - try: - data = self.client.get_movie(object_id) - name = data.get("title") - if name: - year = data.get("year", 0) - tmdbId = data.get("tmdbId", 0) - self.logger.notice( - "Re-Searching movie: %s (%s) | [tmdbId=%s|id=%s]", - name, - year, - tmdbId, - object_id, - ) - else: - self.logger.notice("Re-Searching movie: %s", object_id) - movie_found = True - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - AttributeError, - ): - continue - except PyarrResourceNotFound as e: - self.logger.warning( - "Movie %s not found in Radarr (likely removed): %s", object_id, str(e) - ) - break - if object_id in self.queue_file_ids: - self.queue_file_ids.remove(object_id) - if movie_found: - while True: - try: - self.client.post_command("MoviesSearch", movieIds=[object_id]) - break - except ( + try: + data = with_retry( + lambda: self.client.get_movie(object_id), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( requests.exceptions.ChunkedEncodingError, requests.exceptions.ContentDecodingError, requests.exceptions.ConnectionError, JSONDecodeError, - ): - continue + AttributeError, + ), + ) + name = data.get("title") + if name: + year = data.get("year", 0) + tmdbId = data.get("tmdbId", 0) + self.logger.notice( + "Re-Searching movie: %s (%s) | [tmdbId=%s|id=%s]", + name, + year, + tmdbId, + object_id, + ) + else: + self.logger.notice("Re-Searching movie: %s", object_id) + movie_found = True + except PyarrResourceNotFound as e: + self.logger.warning( + "Movie %s not found in Radarr (likely removed): %s", object_id, str(e) + ) + if object_id in self.queue_file_ids: + self.queue_file_ids.remove(object_id) + if movie_found: + with_retry( + lambda: self.client.post_command("MoviesSearch", movieIds=[object_id]), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_ARR_RETRY_EXCEPTIONS, + ) if self.persistent_queue: self.persistent_queue.insert( EntryId=object_id, ArrInstance=self._name @@ -1630,51 +1559,48 @@ def _process_failed_individual( elif self.type == "lidarr": self.logger.trace("Requeue cache entry: %s", object_id) album_found = False - while True: - try: - data = self.client.get_album(object_id) - name = data.get("title") - if name: - artist_title = data.get("artist", {}).get("artistName", "") - foreign_album_id = data.get("foreignAlbumId", "") - self.logger.notice( - "Re-Searching album: %s - %s | [foreignAlbumId=%s|id=%s]", - artist_title, - name, - foreign_album_id, - object_id, - ) - else: - self.logger.notice("Re-Searching album: %s", object_id) - album_found = True - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - AttributeError, - ): - continue - except PyarrResourceNotFound as e: - self.logger.warning( - "Album %s not found in Lidarr (likely removed): %s", object_id, str(e) - ) - break - if object_id in self.queue_file_ids: - self.queue_file_ids.remove(object_id) - if album_found: - while True: - try: - self.client.post_command("AlbumSearch", albumIds=[object_id]) - break - except ( + try: + data = with_retry( + lambda: self.client.get_album(object_id), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( requests.exceptions.ChunkedEncodingError, requests.exceptions.ContentDecodingError, requests.exceptions.ConnectionError, JSONDecodeError, - ): - continue + AttributeError, + ), + ) + name = data.get("title") + if name: + artist_title = data.get("artist", {}).get("artistName", "") + foreign_album_id = data.get("foreignAlbumId", "") + self.logger.notice( + "Re-Searching album: %s - %s | [foreignAlbumId=%s|id=%s]", + artist_title, + name, + foreign_album_id, + object_id, + ) + else: + self.logger.notice("Re-Searching album: %s", object_id) + album_found = True + except PyarrResourceNotFound as e: + self.logger.warning( + "Album %s not found in Lidarr (likely removed): %s", object_id, str(e) + ) + if object_id in self.queue_file_ids: + self.queue_file_ids.remove(object_id) + if album_found: + with_retry( + lambda: self.client.post_command("AlbumSearch", albumIds=[object_id]), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_ARR_RETRY_EXCEPTIONS, + ) if self.persistent_queue: self.persistent_queue.insert( EntryId=object_id, ArrInstance=self._name @@ -1699,6 +1625,35 @@ def _process_failed(self) -> None: skip_blacklist = { i.upper() for i in self.skip_blacklist.union(self.missing_files_post_delete) } + if ( + to_delete_all + or self.remove_from_qbit + or self.skip_blacklist + or self.remove_from_qbit_by_instance + ): + n_delete = len(self.delete) + n_missing = len(self.missing_files_post_delete) + n_bad_msg = len(self.downloads_with_bad_error_message_blocklist) + n_remove = len(self.remove_from_qbit) + n_remove_by_inst = sum(len(s) for s in self.remove_from_qbit_by_instance.values()) + n_skip = len(self.skip_blacklist) + self.logger.info( + "Deletion summary: delete=%d, missing_files=%d, bad_error_blocklist=%d, " + "remove_from_qbit=%d, remove_by_instance=%d, skip_blacklist=%d", + n_delete, + n_missing, + n_bad_msg, + n_remove, + n_remove_by_inst, + n_skip, + ) + if to_delete_all and self.logger.isEnabledFor(10): # DEBUG + sample = list(to_delete_all)[:5] + names = [self.manager.qbit_manager.name_cache.get(h, h) for h in sample] + self.logger.debug( + "Deletion sample (first 5): %s", + list(zip(sample, names)), + ) if to_delete_all: self.needs_cleanup = True payload = self.process_entries(to_delete_all) @@ -1707,17 +1662,113 @@ def _process_failed(self) -> None: self._process_failed_individual( hash_=hash_, entry=entry, skip_blacklist=skip_blacklist ) + # Delete missing-files torrents from the correct qBit instance (multi-instance). + per_instance_hashes = set() + for hashes in self.remove_from_qbit_by_instance.values(): + per_instance_hashes.update(hashes) + qbit_manager = self.manager.qbit_manager + for inst_name, hashes in self.remove_from_qbit_by_instance.items(): + client = qbit_manager.get_client(inst_name) + if client is None: + self.logger.warning( + "Cannot delete %d torrent(s) from qBit instance '%s': no client", + len(hashes), + inst_name, + ) + continue + try: + with_retry( + lambda c=client, h=list(hashes): c.torrents_delete( + hashes=h, delete_files=True + ), + retries=3, + backoff=0.5, + max_backoff=3, + exceptions=( + qbittorrentapi.exceptions.APIError, + qbittorrentapi.exceptions.APIConnectionError, + requests.exceptions.RequestException, + ), + ) + for h in hashes: + self.cleaned_torrents.discard(h) + self.sent_to_scan_hashes.discard(h) + if h in qbit_manager.name_cache: + del qbit_manager.name_cache[h] + if h in qbit_manager.cache: + del qbit_manager.cache[h] + except ( + qbittorrentapi.exceptions.APIError, + qbittorrentapi.exceptions.APIConnectionError, + requests.exceptions.RequestException, + ) as e: + self.logger.error( + "Failed to delete %d torrent(s) from qBit instance '%s': %s", + len(hashes), + inst_name, + e, + ) + self.remove_from_qbit_by_instance.clear() + to_delete_all = to_delete_all - per_instance_hashes if self.remove_from_qbit or self.skip_blacklist or to_delete_all: # Remove all bad torrents from the Client. - temp_to_delete = set() + deleted_hashes: set[str] = set() if to_delete_all: - self.manager.qbit.torrents_delete(hashes=to_delete_all, delete_files=True) + try: + with_retry( + lambda: self.manager.qbit.torrents_delete( + hashes=to_delete_all, delete_files=True + ), + retries=3, + backoff=0.5, + max_backoff=3, + exceptions=( + qbittorrentapi.exceptions.APIError, + qbittorrentapi.exceptions.APIConnectionError, + requests.exceptions.RequestException, + ), + ) + except ( + qbittorrentapi.exceptions.APIError, + qbittorrentapi.exceptions.APIConnectionError, + requests.exceptions.RequestException, + ) as e: + self.logger.error( + "Failed to delete %d torrent(s) from qBit: %s", + len(to_delete_all), + e, + ) + else: + deleted_hashes.update(to_delete_all) if self.remove_from_qbit or self.skip_blacklist: temp_to_delete = self.remove_from_qbit.union(self.skip_blacklist) - self.manager.qbit.torrents_delete(hashes=temp_to_delete, delete_files=True) - - to_delete_all = to_delete_all.union(temp_to_delete) - for h in to_delete_all: + try: + with_retry( + lambda: self.manager.qbit.torrents_delete( + hashes=temp_to_delete, delete_files=True + ), + retries=3, + backoff=0.5, + max_backoff=3, + exceptions=( + qbittorrentapi.exceptions.APIError, + qbittorrentapi.exceptions.APIConnectionError, + requests.exceptions.RequestException, + ), + ) + except ( + qbittorrentapi.exceptions.APIError, + qbittorrentapi.exceptions.APIConnectionError, + requests.exceptions.RequestException, + ) as e: + self.logger.error( + "Failed to delete %d torrent(s) from qBit: %s", + len(temp_to_delete), + e, + ) + else: + deleted_hashes.update(temp_to_delete) + for h in deleted_hashes: self.cleaned_torrents.discard(h) self.sent_to_scan_hashes.discard(h) if h in self.manager.qbit_manager.name_cache: @@ -1786,13 +1837,7 @@ def api_calls(self) -> None: retries=3, backoff=0.5, max_backoff=3, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - requests.exceptions.RequestException, - ), + exceptions=_ARR_RETRY_EXCEPTIONS_EXTENDED, ) self.rss_sync_timer_last_checked = now @@ -1806,13 +1851,7 @@ def api_calls(self) -> None: retries=3, backoff=0.5, max_backoff=3, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - requests.exceptions.RequestException, - ), + exceptions=_ARR_RETRY_EXCEPTIONS_EXTENDED, ) self.refresh_downloads_timer_last_checked = now @@ -1820,21 +1859,16 @@ def arr_db_query_commands_count(self) -> int: search_commands = 0 if not (self.search_missing or self.do_upgrade_search): return 0 - while True: - try: - commands = self.client.get_command() - for command in commands: - # Count all active search commands (including MissingEpisodeSearch) - if command["name"].endswith("Search") and command["status"] != "completed": - search_commands = search_commands + 1 - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ): - continue + commands = with_retry( + lambda: self.client.get_command(), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_ARR_RETRY_EXCEPTIONS, + ) + for command in commands: + if command["name"].endswith("Search") and command["status"] != "completed": + search_commands = search_commands + 1 return search_commands @@ -1874,7 +1908,7 @@ def db_get_files( ) -> Iterable[ tuple[MoviesFilesModel | EpisodeFilesModel | SeriesFilesModel, bool, bool, bool, int] ]: - if self.type == "sonarr" and self.series_search == True: + if self.type == "sonarr" and self.series_search is True: serieslist = self.db_get_files_series() for series in serieslist: yield series[0], series[1], series[2], series[2] is not True, len(serieslist) @@ -1955,27 +1989,25 @@ def db_reset__series_searched_state(self): if ( self.loop_completed and self.reset_on_completion and self.series_search ): # Only wipe if a loop completed was tagged - self.series_file_model.update(Searched=False, Upgrade=False).where( - (self.series_file_model.Searched == True) - & (self.series_file_model.ArrInstance == self._name) - ).execute() - while True: - try: - series = self.client.get_series() - for s in series: - ids.append(s["id"]) - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ): - continue - self.series_file_model.delete().where( - (self.series_file_model.EntryId.not_in(ids)) - & (self.series_file_model.ArrInstance == self._name) - ).execute() + with database_lock(): + self.series_file_model.update(Searched=False, Upgrade=False).where( + (self.series_file_model.Searched == True) + & (self.series_file_model.ArrInstance == self._name) + ).execute() + series = with_retry( + lambda: self.client.get_series(), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_ARR_RETRY_EXCEPTIONS, + ) + for s in series: + ids.append(s["id"]) + with database_lock(): + self.series_file_model.delete().where( + (self.series_file_model.EntryId.not_in(ids)) + & (self.series_file_model.ArrInstance == self._name) + ).execute() self.loop_completed = False def db_reset__episode_searched_state(self): @@ -1984,27 +2016,33 @@ def db_reset__episode_searched_state(self): if ( self.loop_completed is True and self.reset_on_completion ): # Only wipe if a loop completed was tagged - self.model_file.update(Searched=False, Upgrade=False).where( - self.model_file.Searched == True - ).execute() - while True: - try: - series = self.client.get_series() - for s in series: - episodes = self.client.get_episode(s["id"], True) - for e in episodes: - ids.append(e["id"]) - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ) as e: - continue - self.model_file.delete().where( - (self.model_file.EntryId.not_in(ids)) & (self.model_file.ArrInstance == self._name) - ).execute() + with database_lock(): + self.model_file.update(Searched=False, Upgrade=False).where( + (self.model_file.Searched == True) + & (self.model_file.ArrInstance == self._name) + ).execute() + series = with_retry( + lambda: self.client.get_series(), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_ARR_RETRY_EXCEPTIONS, + ) + for s in series: + episodes = with_retry( + lambda s=s: self.client.get_episode(s["id"], True), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_ARR_RETRY_EXCEPTIONS, + ) + for e in episodes: + ids.append(e["id"]) + with database_lock(): + self.model_file.delete().where( + (self.model_file.EntryId.not_in(ids)) + & (self.model_file.ArrInstance == self._name) + ).execute() self.loop_completed = False def db_reset__movie_searched_state(self): @@ -2013,25 +2051,25 @@ def db_reset__movie_searched_state(self): if ( self.loop_completed is True and self.reset_on_completion ): # Only wipe if a loop completed was tagged - self.model_file.update(Searched=False, Upgrade=False).where( - (self.model_file.Searched == True) & (self.model_file.ArrInstance == self._name) - ).execute() - while True: - try: - movies = self.client.get_movie() - for m in movies: - ids.append(m["id"]) - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ): - continue - self.model_file.delete().where( - (self.model_file.EntryId.not_in(ids)) & (self.model_file.ArrInstance == self._name) - ).execute() + with database_lock(): + self.model_file.update(Searched=False, Upgrade=False).where( + (self.model_file.Searched == True) + & (self.model_file.ArrInstance == self._name) + ).execute() + movies = with_retry( + lambda: self.client.get_movie(), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_ARR_RETRY_EXCEPTIONS, + ) + for m in movies: + ids.append(m["id"]) + with database_lock(): + self.model_file.delete().where( + (self.model_file.EntryId.not_in(ids)) + & (self.model_file.ArrInstance == self._name) + ).execute() self.loop_completed = False def db_reset__album_searched_state(self): @@ -2040,27 +2078,33 @@ def db_reset__album_searched_state(self): if ( self.loop_completed is True and self.reset_on_completion ): # Only wipe if a loop completed was tagged - self.model_file.update(Searched=False, Upgrade=False).where( - (self.model_file.Searched == True) & (self.model_file.ArrInstance == self._name) - ).execute() - while True: - try: - artists = self.client.get_artist() - for artist in artists: - albums = self.client.get_album(artistId=artist["id"]) - for album in albums: - ids.append(album["id"]) - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ): - continue - self.model_file.delete().where( - (self.model_file.EntryId.not_in(ids)) & (self.model_file.ArrInstance == self._name) - ).execute() + with database_lock(): + self.model_file.update(Searched=False, Upgrade=False).where( + (self.model_file.Searched == True) + & (self.model_file.ArrInstance == self._name) + ).execute() + artists = with_retry( + lambda: self.client.get_artist(), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_ARR_RETRY_EXCEPTIONS, + ) + for artist in artists: + albums = with_retry( + lambda a=artist: self.client.get_album(artistId=a["id"]), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_ARR_RETRY_EXCEPTIONS, + ) + for album in albums: + ids.append(album["id"]) + with database_lock(): + self.model_file.delete().where( + (self.model_file.EntryId.not_in(ids)) + & (self.model_file.ArrInstance == self._name) + ).execute() self.loop_completed = False def db_get_files_series(self) -> list[list[SeriesFilesModel, bool, bool]] | None: @@ -2224,14 +2268,14 @@ def db_get_files_episodes(self) -> list[list[EpisodeFilesModel, bool, bool]] | N for entry in ( self.model_file.select() .where(condition) + .group_by(self.model_file.SeriesId) .order_by( - reason_priority.asc(), # Primary: order by reason priority + reason_priority.asc(), + self.model_file.EpisodeFileId.asc(), self.model_file.SeriesTitle, self.model_file.SeasonNumber.desc(), self.model_file.AirDateUtc.desc(), ) - .group_by(self.model_file.SeriesId) - .order_by(self.model_file.EpisodeFileId.asc()) .execute() ): entries.append([entry, False, False]) @@ -2396,17 +2440,13 @@ def _db_request_update(self, request_ids: dict[str, set[int | str]]): if self.type == "sonarr" and any(i in request_ids for i in ["ImdbId", "TvdbId"]): TvdbIds = request_ids.get("TvdbId") ImdbIds = request_ids.get("ImdbId") - while True: - try: - series = self.client.get_series() - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ): - continue + series = with_retry( + lambda: self.client.get_series(), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_ARR_RETRY_EXCEPTIONS, + ) for s in series: episodes = self.client.get_episode(s["id"], True) for e in episodes: @@ -2434,19 +2474,15 @@ def _db_request_update(self, request_ids: dict[str, set[int | str]]): elif self.type == "radarr" and any(i in request_ids for i in ["ImdbId", "TmdbId"]): ImdbIds = request_ids.get("ImdbId") TmdbIds = request_ids.get("TmdbId") - while True: - try: - movies = self.client.get_movie() - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ): - continue + movies = with_retry( + lambda: self.client.get_movie(), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_ARR_RETRY_EXCEPTIONS, + ) for m in movies: - if m["year"] > datetime.now().year and m["year"] == 0: + if m["year"] > datetime.now().year or m["year"] == 0: continue if TmdbIds and ImdbIds and "tmdbId" in m and "imdbId" in m: if m["tmdbId"] not in TmdbIds or m["imdbId"] not in ImdbIds: @@ -2492,17 +2528,13 @@ def db_update_todays_releases(self): return if self.type == "sonarr": try: - while True: - try: - series = self.client.get_series() - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ): - continue + series = with_retry( + lambda: self.client.get_series(), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_ARR_RETRY_EXCEPTIONS, + ) for s in series: episodes = self.client.get_episode(s["id"], True) for e in episodes: @@ -2526,7 +2558,7 @@ def db_update_todays_releases(self): continue self.logger.trace("Updating todays releases") self.db_update_single_series(db_entry=e) - except BaseException: + except Exception: self.logger.debug("No episode releases found for today") def db_update(self): @@ -2552,17 +2584,13 @@ def db_update(self): self.logger.info("Started updating database") if self.type == "sonarr": # Always fetch series list for both episode and series-level tracking - while True: - try: - series = self.client.get_series() - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ): - continue + series = with_retry( + lambda: self.client.get_series(), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_ARR_RETRY_EXCEPTIONS, + ) # Process episodes for episode-level tracking (all episodes) for s in series: @@ -2589,17 +2617,13 @@ def db_update(self): self.db_update_processed = True elif self.type == "radarr": - while True: - try: - movies = self.client.get_movie() - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ): - continue + movies = with_retry( + lambda: self.client.get_movie(), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_ARR_RETRY_EXCEPTIONS, + ) # Process all movies for m in movies: if isinstance(m, str): @@ -2607,34 +2631,25 @@ def db_update(self): self.db_update_single_series(db_entry=m) self.db_update_processed = True elif self.type == "lidarr": - while True: - try: - artists = self.client.get_artist() - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ): - continue + artists = with_retry( + lambda: self.client.get_artist(), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_ARR_RETRY_EXCEPTIONS, + ) for artist in artists: if isinstance(artist, str): continue - while True: - try: - # allArtistAlbums=True includes full album data with media/tracks - albums = self.client.get_album( - artistId=artist["id"], allArtistAlbums=True - ) - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ): - continue + albums = with_retry( + lambda a=artist: self.client.get_album( + artistId=a["id"], allArtistAlbums=True + ), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_ARR_RETRY_EXCEPTIONS, + ) for album in albums: if isinstance(album, str): continue @@ -2920,17 +2935,13 @@ def db_update_single_series( (self.model_file.EntryId == db_entry["id"]) & (self.model_file.ArrInstance == self._name) ) - while True: - try: - episode = self.client.get_episode(db_entry["id"]) - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ): - continue + episode = with_retry( + lambda: self.client.get_episode(db_entry["id"]), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_ARR_RETRY_EXCEPTIONS, + ) # Validate episode object has required fields if not episode or not isinstance(episode, dict): @@ -2961,67 +2972,65 @@ def db_update_single_series( return if episode.get("monitored", True) or self.search_unmonitored: - while True: - try: - series_info = episode.get("series") or {} - if isinstance(series_info, dict): - quality_profile_id = series_info.get("qualityProfileId") - else: - quality_profile_id = getattr( - series_info, "qualityProfileId", None + series_info = episode.get("series") or {} + if isinstance(series_info, dict): + quality_profile_id = series_info.get("qualityProfileId") + else: + quality_profile_id = getattr(series_info, "qualityProfileId", None) + if not quality_profile_id: + quality_profile_id = db_entry.get("qualityProfileId") + minCustomFormat = ( + getattr(episodeData, "MinCustomFormatScore", 0) if episodeData else 0 + ) + if not minCustomFormat: + if quality_profile_id: + profile = ( + with_retry( + lambda qpid=quality_profile_id: self.client.get_quality_profile( + qpid + ), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_ARR_RETRY_EXCEPTIONS, ) - if not quality_profile_id: - quality_profile_id = db_entry.get("qualityProfileId") - minCustomFormat = ( - getattr(episodeData, "MinCustomFormatScore", 0) - if episodeData - else 0 + or {} ) - if not minCustomFormat: - if quality_profile_id: - profile = ( - self.client.get_quality_profile(quality_profile_id) - or {} - ) - minCustomFormat = profile.get("minFormatScore") or 0 - else: - self.logger.warning( - "Episode %s missing qualityProfileId; defaulting custom format threshold to 0", - episode.get("id"), - ) - minCustomFormat = 0 - episode_file = episode.get("episodeFile") or {} - if isinstance(episode_file, dict): - episode_file_id = episode_file.get("id") - else: - episode_file_id = getattr(episode_file, "id", None) - has_file = bool(episode.get("hasFile")) - episode_data_file_id = ( - getattr(episodeData, "EpisodeFileId", None) - if episodeData - else None + minCustomFormat = profile.get("minFormatScore") or 0 + else: + self.logger.warning( + "Episode %s missing qualityProfileId; defaulting custom format threshold to 0", + episode.get("id"), ) - if has_file and episode_file_id: - if ( - episode_data_file_id - and episode_file_id == episode_data_file_id - ): - customFormat = getattr(episodeData, "CustomFormatScore", 0) - else: - file_info = ( - self.client.get_episode_file(episode_file_id) or {} - ) - customFormat = file_info.get("customFormatScore") or 0 - else: - customFormat = 0 - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ): - continue + minCustomFormat = 0 + episode_file = episode.get("episodeFile") or {} + if isinstance(episode_file, dict): + episode_file_id = episode_file.get("id") + else: + episode_file_id = getattr(episode_file, "id", None) + has_file = bool(episode.get("hasFile")) + episode_data_file_id = ( + getattr(episodeData, "EpisodeFileId", None) if episodeData else None + ) + if has_file and episode_file_id: + if episode_data_file_id and episode_file_id == episode_data_file_id: + customFormat = getattr(episodeData, "CustomFormatScore", 0) + else: + file_info = ( + with_retry( + lambda efid=episode_file_id: self.client.get_episode_file( + efid + ), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_ARR_RETRY_EXCEPTIONS, + ) + or {} + ) + customFormat = file_info.get("customFormatScore") or 0 + else: + customFormat = 0 QualityUnmet = ( episode["episodeFile"]["qualityCutoffNotMet"] @@ -3081,8 +3090,6 @@ def db_update_single_series( new_profile_id, ) # Reverting to main - clear tracking fields - from datetime import datetime - profile_switch_timestamp = datetime.now() original_profile_for_db = None current_profile_for_db = None @@ -3100,8 +3107,6 @@ def db_update_single_series( new_profile_id, ) # Downgrading to temp - track original and switch time - from datetime import datetime - profile_switch_timestamp = datetime.now() original_profile_for_db = quality_profile_id current_profile_for_db = new_profile_id @@ -3259,43 +3264,46 @@ def db_update_single_series( & (self.series_file_model.ArrInstance == self._name) ) if db_entry["monitored"] or self.search_unmonitored: - while True: - try: - seriesMetadata = self.client.get_series(id_=EntryId) or {} - quality_profile_id = None - if isinstance(seriesMetadata, dict): - quality_profile_id = seriesMetadata.get("qualityProfileId") - else: - quality_profile_id = getattr( - seriesMetadata, "qualityProfileId", None - ) - if not seriesData: - if quality_profile_id: - profile = ( - self.client.get_quality_profile(quality_profile_id) - or {} - ) - minCustomFormat = profile.get("minFormatScore") or 0 - else: - self.logger.warning( - "Series %s (%s) missing qualityProfileId; " - "defaulting custom format score to 0", - db_entry.get("title"), - EntryId, - ) - minCustomFormat = 0 - else: - minCustomFormat = getattr( - seriesData, "MinCustomFormatScore", 0 + seriesMetadata = ( + with_retry( + lambda eid=EntryId: self.client.get_series(id_=eid), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_ARR_RETRY_EXCEPTIONS, + ) + or {} + ) + quality_profile_id = None + if isinstance(seriesMetadata, dict): + quality_profile_id = seriesMetadata.get("qualityProfileId") + else: + quality_profile_id = getattr(seriesMetadata, "qualityProfileId", None) + if not seriesData: + if quality_profile_id: + profile = ( + with_retry( + lambda qpid=quality_profile_id: self.client.get_quality_profile( + qpid + ), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_ARR_RETRY_EXCEPTIONS, ) - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ): - continue + or {} + ) + minCustomFormat = profile.get("minFormatScore") or 0 + else: + self.logger.warning( + "Series %s (%s) missing qualityProfileId; " + "defaulting custom format score to 0", + db_entry.get("title"), + EntryId, + ) + minCustomFormat = 0 + else: + minCustomFormat = getattr(seriesData, "MinCustomFormatScore", 0) episodeCount = 0 episodeFileCount = 0 totalEpisodeCount = 0 @@ -3449,48 +3457,68 @@ def db_update_single_series( if self.minimum_availability_check(db_entry) and ( db_entry["monitored"] or self.search_unmonitored ): - while True: - try: - if movieData: - if not movieData.MinCustomFormatScore: - profile = ( - self.client.get_quality_profile( - db_entry["qualityProfileId"] - ) - or {} - ) - minCustomFormat = profile.get("minFormatScore", 0) - else: - minCustomFormat = movieData.MinCustomFormatScore - if db_entry["hasFile"]: - if db_entry["movieFile"]["id"] != movieData.MovieFileId: - customFormat = self.client.get_movie_file( - db_entry["movieFile"]["id"] - )["customFormatScore"] - else: - customFormat = movieData.CustomFormatScore - else: - customFormat = 0 - else: - profile = ( - self.client.get_quality_profile(db_entry["qualityProfileId"]) - or {} + _retry_exc = ( + requests.exceptions.ChunkedEncodingError, + requests.exceptions.ContentDecodingError, + requests.exceptions.ConnectionError, + JSONDecodeError, + ) + if movieData: + if not movieData.MinCustomFormatScore: + profile = ( + with_retry( + lambda: self.client.get_quality_profile( + db_entry["qualityProfileId"] + ), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_retry_exc, ) - minCustomFormat = profile.get("minFormatScore", 0) - if db_entry["hasFile"]: - customFormat = self.client.get_movie_file( + or {} + ) + minCustomFormat = profile.get("minFormatScore", 0) + else: + minCustomFormat = movieData.MinCustomFormatScore + if db_entry["hasFile"]: + if db_entry["movieFile"]["id"] != movieData.MovieFileId: + customFormat = with_retry( + lambda: self.client.get_movie_file( db_entry["movieFile"]["id"] - ).get("customFormatScore", 0) - else: - customFormat = 0 - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ): - continue + ), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_retry_exc, + )["customFormatScore"] + else: + customFormat = movieData.CustomFormatScore + else: + customFormat = 0 + else: + profile = ( + with_retry( + lambda: self.client.get_quality_profile( + db_entry["qualityProfileId"] + ), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_retry_exc, + ) + or {} + ) + minCustomFormat = profile.get("minFormatScore", 0) + if db_entry["hasFile"]: + customFormat = with_retry( + lambda: self.client.get_movie_file(db_entry["movieFile"]["id"]), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_retry_exc, + ).get("customFormatScore", 0) + else: + customFormat = 0 QualityUnmet = ( db_entry["movieFile"]["qualityCutoffNotMet"] if "movieFile" in db_entry @@ -3531,8 +3559,6 @@ def db_update_single_series( new_main_id, ) # Reverting to main - clear tracking fields - from datetime import datetime - profile_switch_timestamp = datetime.now() original_profile_for_db = None current_profile_for_db = None @@ -3549,8 +3575,6 @@ def db_update_single_series( new_temp_id, ) # Downgrading to temp - track original and switch time - from datetime import datetime - profile_switch_timestamp = datetime.now() original_profile_for_db = quality_profile_id current_profile_for_db = new_temp_id @@ -3689,107 +3713,91 @@ def db_update_single_series( & (self.model_file.ArrInstance == self._name) ) if db_entry["monitored"] or self.search_unmonitored: - while True: - try: - if albumData: - if not albumData.MinCustomFormatScore: - try: - profile_id = db_entry["profileId"] - # Check if this profile ID is known to be invalid - if profile_id in self._invalid_quality_profiles: - minCustomFormat = 0 - # Check cache first - elif profile_id in self._quality_profile_cache: - minCustomFormat = self._quality_profile_cache[ - profile_id - ].get("minFormatScore", 0) - else: - # Fetch from API and cache - try: - profile = self.client.get_quality_profile( - profile_id - ) - self._quality_profile_cache[profile_id] = ( - profile - ) - minCustomFormat = profile.get( - "minFormatScore", 0 - ) - except PyarrResourceNotFound: - # Mark as invalid to avoid repeated warnings - self._invalid_quality_profiles.add(profile_id) - self.logger.warning( - "Quality profile %s not found for album %s, defaulting to 0", - db_entry.get("profileId"), - db_entry.get("title", "Unknown"), - ) - minCustomFormat = 0 - except Exception: - minCustomFormat = 0 + _retry_exc = ( + requests.exceptions.ChunkedEncodingError, + requests.exceptions.ContentDecodingError, + requests.exceptions.ConnectionError, + JSONDecodeError, + ) + if albumData: + if not albumData.MinCustomFormatScore: + try: + profile_id = db_entry["profileId"] + if profile_id in self._invalid_quality_profiles: + minCustomFormat = 0 + elif profile_id in self._quality_profile_cache: + minCustomFormat = self._quality_profile_cache[ + profile_id + ].get("minFormatScore", 0) else: - minCustomFormat = albumData.MinCustomFormatScore - if ( - db_entry.get("statistics", {}).get("percentOfTracks", 0) - == 100 - ): - # Album has files - albumFileId = db_entry.get("statistics", {}).get( - "sizeOnDisk", 0 - ) - if albumFileId != albumData.AlbumFileId: - # Get custom format score from album files - customFormat = ( - 0 # Lidarr may not have customFormatScore + try: + profile = with_retry( + lambda pid=profile_id: self.client.get_quality_profile( + pid + ), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_retry_exc, ) - else: - customFormat = albumData.CustomFormatScore - else: - customFormat = 0 + self._quality_profile_cache[profile_id] = profile + minCustomFormat = profile.get("minFormatScore", 0) + except PyarrResourceNotFound: + self._invalid_quality_profiles.add(profile_id) + self.logger.warning( + "Quality profile %s not found for album %s, defaulting to 0", + db_entry.get("profileId"), + db_entry.get("title", "Unknown"), + ) + minCustomFormat = 0 + except Exception: + minCustomFormat = 0 + else: + minCustomFormat = albumData.MinCustomFormatScore + if db_entry.get("statistics", {}).get("percentOfTracks", 0) == 100: + albumFileId = db_entry.get("statistics", {}).get("sizeOnDisk", 0) + if albumFileId != albumData.AlbumFileId: + customFormat = 0 # Lidarr may not have customFormatScore + else: + customFormat = albumData.CustomFormatScore + else: + customFormat = 0 + else: + try: + profile_id = db_entry["profileId"] + if profile_id in self._invalid_quality_profiles: + minCustomFormat = 0 + elif profile_id in self._quality_profile_cache: + minCustomFormat = self._quality_profile_cache[profile_id].get( + "minFormatScore", 0 + ) else: try: - profile_id = db_entry["profileId"] - # Check if this profile ID is known to be invalid - if profile_id in self._invalid_quality_profiles: - minCustomFormat = 0 - # Check cache first - elif profile_id in self._quality_profile_cache: - minCustomFormat = self._quality_profile_cache[ - profile_id - ].get("minFormatScore", 0) - else: - # Fetch from API and cache - try: - profile = self.client.get_quality_profile( - profile_id - ) - self._quality_profile_cache[profile_id] = profile - minCustomFormat = profile.get("minFormatScore", 0) - except PyarrResourceNotFound: - # Mark as invalid to avoid repeated warnings - self._invalid_quality_profiles.add(profile_id) - self.logger.warning( - "Quality profile %s not found for album %s, defaulting to 0", - db_entry.get("profileId"), - db_entry.get("title", "Unknown"), - ) - minCustomFormat = 0 - except Exception: + profile = with_retry( + lambda pid=profile_id: self.client.get_quality_profile( + pid + ), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_retry_exc, + ) + self._quality_profile_cache[profile_id] = profile + minCustomFormat = profile.get("minFormatScore", 0) + except PyarrResourceNotFound: + self._invalid_quality_profiles.add(profile_id) + self.logger.warning( + "Quality profile %s not found for album %s, defaulting to 0", + db_entry.get("profileId"), + db_entry.get("title", "Unknown"), + ) minCustomFormat = 0 - if ( - db_entry.get("statistics", {}).get("percentOfTracks", 0) - == 100 - ): - customFormat = 0 # Lidarr may not have customFormatScore - else: - customFormat = 0 - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ): - continue + except Exception: + minCustomFormat = 0 + if db_entry.get("statistics", {}).get("percentOfTracks", 0) == 100: + customFormat = 0 # Lidarr may not have customFormatScore + else: + customFormat = 0 # Determine if album has all tracks hasAllTracks = ( @@ -4048,43 +4056,52 @@ def db_update_single_series( & (self.artists_file_model.ArrInstance == self._name) ) if db_entry["monitored"] or self.search_unmonitored: - while True: - try: - artistMetadata = self.client.get_artist(id_=EntryId) or {} - quality_profile_id = None - if isinstance(artistMetadata, dict): - quality_profile_id = artistMetadata.get("qualityProfileId") - else: - quality_profile_id = getattr( - artistMetadata, "qualityProfileId", None - ) - if not artistData: - if quality_profile_id: - profile = ( - self.client.get_quality_profile(quality_profile_id) - or {} - ) - minCustomFormat = profile.get("minFormatScore") or 0 - else: - self.logger.warning( - "Artist %s (%s) missing qualityProfileId; " - "defaulting custom format score to 0", - db_entry.get("artistName"), - EntryId, - ) - minCustomFormat = 0 - else: - minCustomFormat = getattr( - artistData, "MinCustomFormatScore", 0 + _retry_exc = ( + requests.exceptions.ChunkedEncodingError, + requests.exceptions.ContentDecodingError, + requests.exceptions.ConnectionError, + JSONDecodeError, + ) + artistMetadata = ( + with_retry( + lambda eid=EntryId: self.client.get_artist(id_=eid), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_retry_exc, + ) + or {} + ) + quality_profile_id = None + if isinstance(artistMetadata, dict): + quality_profile_id = artistMetadata.get("qualityProfileId") + else: + quality_profile_id = getattr(artistMetadata, "qualityProfileId", None) + if not artistData: + if quality_profile_id: + profile = ( + with_retry( + lambda qpid=quality_profile_id: self.client.get_quality_profile( + qpid + ), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_retry_exc, ) - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ): - continue + or {} + ) + minCustomFormat = profile.get("minFormatScore") or 0 + else: + self.logger.warning( + "Artist %s (%s) missing qualityProfileId; " + "defaulting custom format score to 0", + db_entry.get("artistName"), + EntryId, + ) + minCustomFormat = 0 + else: + minCustomFormat = getattr(artistData, "MinCustomFormatScore", 0) # Calculate if artist is fully searched based on album statistics statistics = artistMetadata.get("statistics", {}) albumCount = statistics.get("albumCount", 0) @@ -4180,7 +4197,7 @@ def db_update_single_series( db_entry["title"], exc_info=e, ) - raise DelayLoopException(length=300, type=self._name) + raise DelayLoopException(length=300, error_type=self._name) except JSONDecodeError: if self.type == "sonarr": if self.series_search: @@ -4200,21 +4217,13 @@ def db_update_single_series( def delete_from_queue(self, id_, remove_from_client=True, blacklist=True): try: - while True: - try: - res = self.client.del_queue(id_, remove_from_client, blacklist) - # res = self.client._delete( - # f"queue/{id_}?removeFromClient={remove_from_client}&blocklist={blacklist}", - # self.client.ver_uri, - # ) - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ): - continue + res = with_retry( + lambda: self.client.del_queue(id_, remove_from_client, blacklist), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_ARR_RETRY_EXCEPTIONS, + ) except PyarrResourceNotFound as e: # Queue item not found - this is expected when Arr has already auto-imported # and removed the item, or if it was manually removed. Clean up internal tracking. @@ -4262,7 +4271,7 @@ def file_is_probeable(self, file: pathlib.Path) -> bool: return False self.files_probed.add(file) return True - except BaseException as e: + except Exception as e: error = e.stderr.decode() self.logger.trace( "Not probeable: Probe returned an error: %s:\n%s", @@ -4407,7 +4416,7 @@ def maybe_do_search( if not features_enabled and not (request or todays): return None elif not self.is_alive: - raise NoConnectionrException(f"Could not connect to {self.uri}", type="arr") + raise NoConnectionrException(f"Could not connect to {self.uri}", error_type="arr") elif self.type == "sonarr": if not series_search: file_model: EpisodeFilesModel @@ -4463,19 +4472,15 @@ def maybe_do_search( Completed=False, EntryId=file_model.EntryId, ArrInstance=self._name ).on_conflict_replace().execute() if file_model.EntryId not in self.queue_file_ids: - while True: - try: - self.client.post_command( - "EpisodeSearch", episodeIds=[file_model.EntryId] - ) - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ): - continue + with_retry( + lambda: self.client.post_command( + "EpisodeSearch", episodeIds=[file_model.EntryId] + ), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_ARR_RETRY_EXCEPTIONS, + ) self.model_file.update(Searched=True, Upgrade=True).where( (self.model_file.EntryId == file_model.EntryId) & (self.model_file.ArrInstance == self._name) @@ -4535,19 +4540,15 @@ def maybe_do_search( self.model_queue.insert( Completed=False, EntryId=file_model.EntryId, ArrInstance=self._name ).on_conflict_replace().execute() - while True: - try: - self.client.post_command( - self.search_api_command, seriesId=file_model.EntryId - ) - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ): - continue + with_retry( + lambda: self.client.post_command( + self.search_api_command, seriesId=file_model.EntryId + ), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_ARR_RETRY_EXCEPTIONS, + ) self.model_file.update(Searched=True, Upgrade=True).where( (self.model_file.EntryId == file_model.EntryId) & (self.model_file.ArrInstance == self._name) @@ -4611,17 +4612,15 @@ def maybe_do_search( Completed=False, EntryId=file_model.EntryId, ArrInstance=self._name ).on_conflict_replace().execute() if file_model.EntryId: - while True: - try: - self.client.post_command("MoviesSearch", movieIds=[file_model.EntryId]) - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ): - continue + with_retry( + lambda: self.client.post_command( + "MoviesSearch", movieIds=[file_model.EntryId] + ), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_ARR_RETRY_EXCEPTIONS, + ) self.model_file.update(Searched=True, Upgrade=True).where( (self.model_file.EntryId == file_model.EntryId) & (self.model_file.ArrInstance == self._name) @@ -4699,17 +4698,13 @@ def maybe_do_search( Completed=False, EntryId=file_model.EntryId, ArrInstance=self._name ).on_conflict_replace().execute() if file_model.EntryId: - while True: - try: - self.client.post_command("AlbumSearch", albumIds=[file_model.EntryId]) - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ): - continue + with_retry( + lambda: self.client.post_command("AlbumSearch", albumIds=[file_model.EntryId]), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_ARR_RETRY_EXCEPTIONS, + ) self.model_file.update(Searched=True, Upgrade=True).where( (self.model_file.EntryId == file_model.EntryId) & (self.model_file.ArrInstance == self._name) @@ -4818,16 +4813,13 @@ def _get_torrents_from_all_instances( def process_torrents(self): try: try: - while True: - try: - # Multi-instance: Scan all qBit instances for category-matching torrents - torrents_with_instances = self._get_torrents_from_all_instances() - break - except (qbittorrentapi.exceptions.APIError, JSONDecodeError) as e: - if "JSONDecodeError" in str(e): - continue - else: - raise qbittorrentapi.exceptions.APIError + torrents_with_instances = with_retry( + lambda: self._get_torrents_from_all_instances(), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=(JSONDecodeError,), + ) # Filter torrents that have category attribute torrents_with_instances = [ @@ -4835,16 +4827,17 @@ def process_torrents(self): for instance, t in torrents_with_instances if hasattr(t, "category") ] + self._warned_no_seeding_limits = False self.category_torrent_count = len(torrents_with_instances) if not len(torrents_with_instances): - raise DelayLoopException(length=LOOP_SLEEP_TIMER, type="no_downloads") + raise DelayLoopException(length=LOOP_SLEEP_TIMER, error_type="no_downloads") # Internet check: use the first available qBit client if not has_internet(self.manager.qbit_manager.client): self.manager.qbit_manager.should_delay_torrent_scan = True - raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, type="internet") + raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, error_type="internet") if self.manager.qbit_manager.should_delay_torrent_scan: - raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, type="delay") + raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, error_type="delay") # Initialize database error tracking for exponential backoff if not hasattr(self, "_db_error_count"): @@ -4892,7 +4885,7 @@ def process_torrents(self): except qbittorrentapi.exceptions.APIError: self.logger.error("The qBittorrent API returned an unexpected error") self.logger.debug("Unexpected APIError from qBitTorrent") # , exc_info=e) - raise DelayLoopException(length=300, type="qbit") + raise DelayLoopException(length=300, error_type="qbit") except (OperationalError, DatabaseError) as e: # Database errors after retry exhaustion - implement automatic recovery with backoff error_msg = str(e).lower() @@ -4990,7 +4983,7 @@ def process_torrents(self): ) # Delay processing to avoid hammering failing database - raise DelayLoopException(length=delay_seconds, type="database") + raise DelayLoopException(length=delay_seconds, error_type="database") except DelayLoopException: raise except KeyboardInterrupt: @@ -5119,8 +5112,11 @@ def _enhanced_database_recovery(self): raise DatabaseRecoveryError("All automatic recovery methods failed") def _process_single_torrent_failed_cat(self, torrent: qbittorrentapi.TorrentDictionary): + self._mark_for_deletion(torrent, "manually failed") + + def _process_single_torrent_recheck_cat(self, torrent: qbittorrentapi.TorrentDictionary): self.logger.notice( - "Deleting manually failed torrent: " + "Re-checking manually set torrent: " "[Progress: %s%%][Added On: %s]" "[Availability: %s%%][Time Left: %s]" "[Last active: %s] " @@ -5134,25 +5130,38 @@ def _process_single_torrent_failed_cat(self, torrent: qbittorrentapi.TorrentDict torrent.name, torrent.hash, ) - self.delete.add(torrent.hash) + self.recheck.add(torrent.hash) - def _process_single_torrent_recheck_cat(self, torrent: qbittorrentapi.TorrentDictionary): - self.logger.notice( - "Re-checking manually set torrent: " - "[Progress: %s%%][Added On: %s]" - "[Availability: %s%%][Time Left: %s]" - "[Last active: %s] " - "| [%s] | %s (%s)", + def _mark_for_deletion( + self, + torrent: qbittorrentapi.TorrentDictionary, + reason: str, + ratio_limit=None, + seeding_time_limit=None, + ) -> None: + """Mark torrent for deletion and log reason with current stats and effective limits.""" + extra = "" + if ratio_limit is not None or seeding_time_limit is not None: + parts = [] + if ratio_limit is not None: + parts.append("ratio_limit=%s" % (ratio_limit if ratio_limit > 0 else "unset")) + if seeding_time_limit is not None: + parts.append( + "seeding_time_limit=%s" + % (seeding_time_limit if seeding_time_limit > 0 else "unset") + ) + extra = " [%s]" % ", ".join(parts) + self.logger.info( + "Marking for deletion (%s): [Progress: %s%%][Ratio: %s][Seeding time: %s] | %s (%s)%s", + reason, round(torrent.progress * 100, 2), - datetime.fromtimestamp(torrent.added_on), - round(torrent.availability * 100, 2), - timedelta(seconds=torrent.eta), - datetime.fromtimestamp(torrent.last_activity), - torrent.state_enum, + torrent.ratio, + timedelta(seconds=torrent.seeding_time), torrent.name, torrent.hash, + extra, ) - self.recheck.add(torrent.hash) + self.delete.add(torrent.hash) def _process_single_torrent_ignored(self, torrent: qbittorrentapi.TorrentDictionary): # Do not touch torrents that are currently being ignored. @@ -5238,31 +5247,16 @@ def _process_single_torrent_stalled_torrent( torrent.added_on < time.time() - self.ignore_torrents_younger_than and torrent.last_activity < (time.time() - self.ignore_torrents_younger_than) ): - self.logger.info( - "Deleting Stale torrent: %s | " - "[Progress: %s%%][Added On: %s]" - "[Availability: %s%%][Time Left: %s]" - "[Last active: %s] " - "| [%s] | %s (%s)", - extra, - round(torrent.progress * 100, 2), - datetime.fromtimestamp(torrent.added_on), - round(torrent.availability * 100, 2), - timedelta(seconds=torrent.eta), - datetime.fromtimestamp(torrent.last_activity), - torrent.state_enum, - torrent.name, - torrent.hash, - ) - if self._hnr_allows_delete(torrent, "stale torrent deletion"): - self.delete.add(torrent.hash) + if self._hnr_allows_delete(torrent, extra): + self._mark_for_deletion(torrent, extra) else: self.logger.trace( - "Ignoring Stale torrent: " + "Ignoring Stale torrent (%s): " "[Progress: %s%%][Added On: %s]" "[Availability: %s%%][Time Left: %s]" "[Last active: %s] " "| [%s] | %s (%s)", + extra, round(torrent.progress * 100, 2), datetime.fromtimestamp(torrent.added_on), round(torrent.availability * 100, 2), @@ -5287,23 +5281,8 @@ def _process_single_torrent_percentage_threshold( # However if its completely dead and no activity is observed, then lets # remove it and requeue a new torrent. if maximum_eta > 0 and torrent.last_activity < (time.time() - maximum_eta): - self.logger.info( - "Deleting Stale torrent: Last activity is older than Maximum ETA " - "[Progress: %s%%][Added On: %s]" - "[Availability: %s%%][Time Left: %s]" - "[Last active: %s] " - "| [%s] | %s (%s)", - round(torrent.progress * 100, 2), - datetime.fromtimestamp(torrent.added_on), - round(torrent.availability * 100, 2), - timedelta(seconds=torrent.eta), - datetime.fromtimestamp(torrent.last_activity), - torrent.state_enum, - torrent.name, - torrent.hash, - ) if self._hnr_allows_delete(torrent, "stale high-percentage deletion"): - self.delete.add(torrent.hash) + self._mark_for_deletion(torrent, "stale high-percentage deletion") else: self.logger.trace( "Skipping torrent: Reached Maximum completed " @@ -5427,7 +5406,11 @@ def _process_single_torrent_fully_completed_torrent( self.files_to_cleanup.add((torrent.hash, torrent_folder)) self.import_torrents.append((torrent, instance_name)) - def _process_single_torrent_missing_files(self, torrent: qbittorrentapi.TorrentDictionary): + def _process_single_torrent_missing_files( + self, + torrent: qbittorrentapi.TorrentDictionary, + instance_name: str = "default", + ): # Sometimes Sonarr/Radarr does not automatically remove the # torrent for some reason, # this ensures that we can safely remove it if the client is reporting @@ -5448,7 +5431,7 @@ def _process_single_torrent_missing_files(self, torrent: qbittorrentapi.TorrentD torrent.hash, ) # We do not want to blacklist these!! - self.remove_from_qbit.add(torrent.hash) + self.remove_from_qbit_by_instance.setdefault(instance_name, set()).add(torrent.hash) def _process_single_torrent_uploading( self, torrent: qbittorrentapi.TorrentDictionary, leave_alone: bool @@ -5523,47 +5506,38 @@ def _process_single_torrent_delete_slow(self, torrent: qbittorrentapi.TorrentDic torrent.hash, ) if self._hnr_allows_delete(torrent, "slow torrent deletion"): - self.delete.add(torrent.hash) + self._mark_for_deletion(torrent, "slow torrent deletion") def _process_single_torrent_delete_cfunmet( self, torrent: qbittorrentapi.TorrentDictionary, instance_name: str = "" ): - self.logger.info( - "Removing CF unmet torrent: " - "[Progress: %s%%][Added On: %s]" - "[Ratio: %s%%][Seeding time: %s]" - "[Last active: %s] " - "| [%s] | %s (%s)", - round(torrent.progress * 100, 2), - datetime.fromtimestamp(torrent.added_on), - torrent.ratio, - timedelta(seconds=torrent.seeding_time), - datetime.fromtimestamp(torrent.last_activity), - torrent.state_enum, - torrent.name, - torrent.hash, - ) if self._hnr_allows_delete(torrent, "CF unmet deletion"): - self.delete.add(torrent.hash) + self._mark_for_deletion(torrent, "CF unmet deletion") - def _process_single_torrent_delete_ratio_seed(self, torrent: qbittorrentapi.TorrentDictionary): - self.logger.info( - "Removing completed torrent: " - "[Progress: %s%%][Added On: %s]" - "[Ratio: %s%%][Seeding time: %s]" - "[Last active: %s] " - "| [%s] | %s (%s)", - round(torrent.progress * 100, 2), - datetime.fromtimestamp(torrent.added_on), - torrent.ratio, - timedelta(seconds=torrent.seeding_time), - datetime.fromtimestamp(torrent.last_activity), - torrent.state_enum, - torrent.name, - torrent.hash, - ) - if self._hnr_allows_delete(torrent, "ratio/seed limit deletion"): - self.delete.add(torrent.hash) + def _process_single_torrent_delete_ratio_seed( + self, + torrent: qbittorrentapi.TorrentDictionary, + limit_meta: tuple[dict, dict] | None = None, + ): + if limit_meta is not None: + data_settings, data_torrent = limit_meta + else: + data_settings, data_torrent = self._get_torrent_limit_meta(torrent) + r_dat = data_settings.get("ratio_limit", -5) + r_tor = data_torrent.get("ratio_limit", -5) + t_dat = data_settings.get("seeding_time_limit", -5) + t_tor = data_torrent.get("seeding_time_limit", -5) + ratio_limit = max(r_dat, r_tor) if (r_dat > 0 or r_tor > 0) else -5 + seeding_time_limit = max(t_dat, t_tor) if (t_dat > 0 or t_tor > 0) else -5 + if self._hnr_allows_delete( + torrent, "ratio/seed limit deletion", data_settings=data_settings + ): + self._mark_for_deletion( + torrent, + "ratio/seed limit deletion", + ratio_limit=ratio_limit, + seeding_time_limit=seeding_time_limit, + ) def _process_single_torrent_process_files( self, torrent: qbittorrentapi.TorrentDictionary, special_case: bool = False @@ -5629,23 +5603,8 @@ def _process_single_torrent_process_files( # If all files in the torrent are marked for exclusion then delete the # torrent. if total == 0: - self.logger.info( - "Deleting All files ignored: " - "[Progress: %s%%][Added On: %s]" - "[Availability: %s%%][Time Left: %s]" - "[Last active: %s] " - "| [%s] | %s (%s)", - round(torrent.progress * 100, 2), - datetime.fromtimestamp(torrent.added_on), - round(torrent.availability * 100, 2), - timedelta(seconds=torrent.eta), - datetime.fromtimestamp(torrent.last_activity), - torrent.state_enum, - torrent.name, - torrent.hash, - ) if self._hnr_allows_delete(torrent, "all-files-excluded deletion"): - self.delete.add(torrent.hash) + self._mark_for_deletion(torrent, "all-files-excluded deletion") # Mark all bad files and folder for exclusion. elif _remove_files and torrent.hash not in self.change_priority: self.change_priority[torrent.hash] = list(_remove_files) @@ -5718,7 +5677,7 @@ def _get_torrent_important_trackers( except qbittorrentapi.exceptions.APIError as e: self.logger.error("The qBittorrent API returned an unexpected error") self.logger.debug("Unexpected APIError from qBitTorrent", exc_info=e) - raise DelayLoopException(length=300, type="qbit") + raise DelayLoopException(length=300, error_type="qbit") # Host-based matching: resolve qBit announce URLs to their config URIs. # Supports apex/suffix matching so that an announce URL using a subdomain # (e.g. "tracker.torrentleech.org") matches a config URI that is the apex @@ -5763,6 +5722,16 @@ def _get_most_important_tracker_and_tags( max_item = max(new_list, key=self.__return_max) if new_list else {} return max_item, set(itertools.chain.from_iterable(_list_of_tags)) + def _resolve_hnr_clear_mode(self, tracker_or_config: dict) -> str: + """Resolve HnR mode from single HitAndRunMode key: 'and' | 'or' | 'disabled'.""" + raw = tracker_or_config.get("HitAndRunMode") + if isinstance(raw, str) and raw.strip().lower() in ("and", "or", "disabled"): + return raw.strip().lower() + # Legacy: boolean HitAndRunMode (pre-migration) + if raw is True: + return "and" + return "disabled" + def _get_torrent_limit_meta(self, torrent: qbittorrentapi.TorrentDictionary): _, monitored_trackers = self._get_torrent_important_trackers(torrent) most_important_tracker, _unique_tags = self._get_most_important_tracker_and_tags( @@ -5812,7 +5781,7 @@ def _get_torrent_limit_meta(self, torrent: qbittorrentapi.TorrentDictionary): ), "super_seeding": most_important_tracker.get("SuperSeedMode", torrent.super_seeding), "max_eta": most_important_tracker.get("MaximumETA", self.maximum_eta), - "hnr_mode": most_important_tracker.get("HitAndRunMode", False), + "hnr_clear_mode": self._resolve_hnr_clear_mode(most_important_tracker), "hnr_min_seed_ratio": most_important_tracker.get("MinSeedRatio", 1.0), "hnr_min_seeding_time_days": most_important_tracker.get("MinSeedingTimeDays", 0), "hnr_min_download_percent": most_important_tracker.get( @@ -5833,11 +5802,11 @@ def _get_torrent_limit_meta(self, torrent: qbittorrentapi.TorrentDictionary): def _should_leave_alone( self, torrent: qbittorrentapi.TorrentDictionary, instance_name: str = "default" - ) -> tuple[bool, int, bool]: + ) -> tuple[bool, int, bool, dict | None, dict | None]: return_value = True remove_torrent = False if torrent.super_seeding or torrent.state_enum == TorrentStates.FORCED_UPLOAD: - return return_value, -1, remove_torrent + return return_value, -1, remove_torrent, None, None is_uploading = torrent.state_enum in ( TorrentStates.UPLOADING, @@ -5917,6 +5886,8 @@ def _should_leave_alone( return_value, data_settings.get("max_eta", self.maximum_eta), remove_torrent, + data_settings, + data_torrent, ) def _process_single_torrent_trackers( @@ -5960,21 +5931,19 @@ def _process_single_torrent_trackers( monitored_trackers, _remove_urls ) if monitored_trackers and most_important_tracker: - if ( - r := most_important_tracker.get( - "DownloadRateLimit", self.seeding_mode_global_download_limit - ) - ) != 0 and torrent.dl_limit != r: - torrent.set_download_limit(limit=r) - elif r < 0: - torrent.set_upload_limit(limit=-1) - if ( - r := most_important_tracker.get( - "UploadRateLimit", self.seeding_mode_global_upload_limit - ) - ) != 0 and torrent.up_limit != r: - torrent.set_upload_limit(limit=r) - elif r < 0: + dl_r = most_important_tracker.get( + "DownloadRateLimit", self.seeding_mode_global_download_limit + ) + if dl_r != 0 and torrent.dl_limit != dl_r: + torrent.set_download_limit(limit=dl_r) + elif dl_r < 0: + torrent.set_download_limit(limit=-1) + ul_r = most_important_tracker.get( + "UploadRateLimit", self.seeding_mode_global_upload_limit + ) + if ul_r != 0 and torrent.up_limit != ul_r: + torrent.set_upload_limit(limit=ul_r) + elif ul_r < 0: torrent.set_upload_limit(limit=-1) if ( r := most_important_tracker.get("SuperSeedMode", False) @@ -5983,13 +5952,15 @@ def _process_single_torrent_trackers( torrent.set_super_seeding(enabled=r) else: - if (r := self.seeding_mode_global_download_limit) != 0 and torrent.dl_limit != r: - torrent.set_download_limit(limit=r) - elif r < 0: + dl_r = self.seeding_mode_global_download_limit + if dl_r != 0 and torrent.dl_limit != dl_r: + torrent.set_download_limit(limit=dl_r) + elif dl_r < 0: torrent.set_download_limit(limit=-1) - if (r := self.seeding_mode_global_upload_limit) != 0 and torrent.up_limit != r: - torrent.set_upload_limit(limit=r) - elif r < 0: + ul_r = self.seeding_mode_global_upload_limit + if ul_r != 0 and torrent.up_limit != ul_r: + torrent.set_upload_limit(limit=ul_r) + elif ul_r < 0: torrent.set_upload_limit(limit=-1) if unique_tags: @@ -6008,15 +5979,14 @@ def _stalled_check( if not self.allowed_stalled: self.logger.trace("Stalled check: Stalled delay disabled") return False + stalled_delay_seconds = int(timedelta(minutes=self.stalled_delay).total_seconds()) if time_now < torrent.added_on + self.ignore_torrents_younger_than: self.logger.trace( "Stalled check: In recent queue %s [Current:%s][Added:%s][Starting:%s]", torrent.name, datetime.fromtimestamp(time_now), datetime.fromtimestamp(torrent.added_on), - datetime.fromtimestamp( - torrent.added_on + timedelta(minutes=self.stalled_delay).seconds - ), + datetime.fromtimestamp(torrent.added_on + self.ignore_torrents_younger_than), ) return True if self.stalled_delay == 0: @@ -6032,9 +6002,7 @@ def _stalled_check( torrent.name, datetime.fromtimestamp(time_now), datetime.fromtimestamp(torrent.last_activity), - datetime.fromtimestamp( - torrent.last_activity + timedelta(minutes=self.stalled_delay).seconds - ), + datetime.fromtimestamp(torrent.last_activity + stalled_delay_seconds), ) if ( ( @@ -6053,8 +6021,7 @@ def _stalled_check( ) and self.allowed_stalled: if ( self.stalled_delay > 0 - and time_now - >= torrent.last_activity + timedelta(minutes=self.stalled_delay).seconds + and time_now >= torrent.last_activity + stalled_delay_seconds ): stalled_ignore = False self.logger.trace("Process stalled, delay expired: %s", torrent.name) @@ -6082,9 +6049,7 @@ def _stalled_check( torrent.name, datetime.fromtimestamp(time_now), datetime.fromtimestamp(torrent.last_activity), - datetime.fromtimestamp( - torrent.last_activity + timedelta(minutes=self.stalled_delay).seconds - ), + datetime.fromtimestamp(torrent.last_activity + stalled_delay_seconds), ) elif self.in_tags(torrent, "qBitrr-allowed_stalled", instance_name): @@ -6104,8 +6069,8 @@ def _process_single_torrent( self._process_single_torrent_trackers(torrent, instance_name) self.manager.qbit_manager.name_cache[torrent.hash] = torrent.name time_now = time.time() - leave_alone, _tracker_max_eta, remove_torrent = self._should_leave_alone( - torrent, instance_name + leave_alone, _tracker_max_eta, remove_torrent, _data_settings, _data_torrent = ( + self._should_leave_alone(torrent, instance_name) ) self.logger.trace( "Torrent [%s]: Leave Alone (allow seeding): %s, Max ETA: %s, State[%s]", @@ -6138,13 +6103,18 @@ def _process_single_torrent( ): self._process_single_torrent_delete_cfunmet(torrent, instance_name) elif remove_torrent and not leave_alone and torrent.amount_left == 0: - self._process_single_torrent_delete_ratio_seed(torrent) + self._process_single_torrent_delete_ratio_seed( + torrent, limit_meta=(_data_settings, _data_torrent) + ) elif torrent.category == FAILED_CATEGORY: # Bypass everything if manually marked as failed self._process_single_torrent_failed_cat(torrent) elif torrent.category == RECHECK_CATEGORY: # Bypass everything else if manually marked for rechecking self._process_single_torrent_recheck_cat(torrent) + elif self._is_missing_files_torrent(torrent): + # Missing-files (and ERROR+missingFiles): bypass all other processing, delete from client. + self._process_single_torrent_missing_files(torrent, instance_name) elif self.is_ignored_state(torrent): self._process_single_torrent_ignored(torrent) elif ( @@ -6232,8 +6202,6 @@ def _process_single_torrent( and torrent.completion_on < time_now - 60 ): self._process_single_torrent_fully_completed_torrent(torrent, leave_alone) - elif torrent.state_enum == TorrentStates.MISSING_FILES: - self._process_single_torrent_missing_files(torrent) # If a torrent is Uploading Pause it, as long as its not being Forced Uploaded. elif ( self.is_uploading_state(torrent) @@ -6285,18 +6253,13 @@ def _process_single_torrent( def custom_format_unmet_check(self, torrent: qbittorrentapi.TorrentDictionary) -> bool: try: - # Retry getting the queue until it succeeds - while True: - try: - queue = self.client.get_queue() - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ): - continue # Retry on exceptions + queue = with_retry( + lambda: self.client.get_queue(), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_ARR_RETRY_EXCEPTIONS, + ) if not queue.get("records"): return False @@ -6362,13 +6325,19 @@ def custom_format_unmet_check(self, torrent: qbittorrentapi.TorrentDictionary) - except Exception: return False - def _hnr_allows_delete(self, torrent: qbittorrentapi.TorrentDictionary, reason: str) -> bool: + def _hnr_allows_delete( + self, + torrent: qbittorrentapi.TorrentDictionary, + reason: str, + *, + data_settings: dict | None = None, + ) -> bool: """Check if HnR obligations allow deleting this torrent. Fetches tracker metadata and checks HnR. Returns True if deletion is allowed, False if HnR protection blocks it. """ - if not any(t.get("HitAndRunMode", False) for t in self.monitored_trackers): + if not any(self._resolve_hnr_clear_mode(t) != "disabled" for t in self.monitored_trackers): return True # Fast path: no HnR on any tracker # If the HnR-enabled tracker reports the torrent as unregistered/dead, @@ -6380,7 +6349,8 @@ def _hnr_allows_delete(self, torrent: qbittorrentapi.TorrentDictionary, reason: ) return True - data_settings, _ = self._get_torrent_limit_meta(torrent) + if data_settings is None: + data_settings, _ = self._get_torrent_limit_meta(torrent) if self._hnr_safe_to_remove(torrent, data_settings): return True self.logger.info( @@ -6411,7 +6381,7 @@ def _hnr_tracker_is_dead(self, torrent: qbittorrentapi.TorrentDictionary) -> boo hnr_hosts = { _extract_tracker_host(t.get("URI") or "") for t in self.monitored_trackers - if t.get("HitAndRunMode", False) + if self._resolve_hnr_clear_mode(t) != "disabled" } - {""} if not hnr_hosts: return False @@ -6431,8 +6401,8 @@ def _hnr_safe_to_remove( self, torrent: qbittorrentapi.TorrentDictionary, tracker_meta: dict ) -> bool: """Returns True only if Hit and Run obligations are met.""" - hnr_enabled = tracker_meta.get("hnr_mode", False) - if not hnr_enabled: + clear_mode = (tracker_meta.get("hnr_clear_mode") or "disabled").strip().lower() + if clear_mode == "disabled": return True min_ratio = tracker_meta.get("hnr_min_seed_ratio", 1.0) @@ -6452,12 +6422,22 @@ def _hnr_safe_to_remove( ratio_met = torrent.ratio >= min_ratio if min_ratio > 0 else False time_met = effective_seeding_time >= min_time_secs if min_time_secs > 0 else False - if min_ratio > 0 and min_time_secs > 0: - return ratio_met or time_met # Either clears HnR - elif min_ratio > 0: - return ratio_met - elif min_time_secs > 0: - return time_met + if clear_mode == "and": + if min_ratio > 0 and min_time_secs > 0: + return ratio_met and time_met + if min_ratio > 0: + return ratio_met + if min_time_secs > 0: + return time_met + return True + if clear_mode == "or": + if min_ratio > 0 and min_time_secs > 0: + return ratio_met or time_met + if min_ratio > 0: + return ratio_met + if min_time_secs > 0: + return time_met + return True return True def torrent_limit_check( @@ -6466,30 +6446,34 @@ def torrent_limit_check( # -1 = Never remove (regardless of ratio/time limits) if self.seeding_mode_global_remove_torrent == -1: return False - # 4 = AND (remove when BOTH ratio AND time limits met) - elif ( - self.seeding_mode_global_remove_torrent == 4 - and torrent.ratio >= ratio_limit - and torrent.seeding_time >= seeding_time_limit - ): - return True - # 3 = OR (remove when EITHER ratio OR time limit met) - elif self.seeding_mode_global_remove_torrent == 3 and ( - torrent.ratio >= ratio_limit or torrent.seeding_time >= seeding_time_limit - ): - return True - # 2 = Time only (remove when seeding time limit met) - elif ( - self.seeding_mode_global_remove_torrent == 2 - and torrent.seeding_time >= seeding_time_limit - ): - return True - # 1 = Ratio only (remove when upload ratio limit met) - elif self.seeding_mode_global_remove_torrent == 1 and torrent.ratio >= ratio_limit: - return True - else: + + # Treat limits <= 0 as unset; only consider a limit "met" when it is set (>0) and satisfied + ratio_limit_valid = ratio_limit is not None and ratio_limit > 0 + time_limit_valid = seeding_time_limit is not None and seeding_time_limit > 0 + ratio_met = ratio_limit_valid and torrent.ratio >= ratio_limit + time_met = time_limit_valid and torrent.seeding_time >= seeding_time_limit + + mode = self.seeding_mode_global_remove_torrent + if mode in (1, 2, 3, 4) and not ratio_limit_valid and not time_limit_valid: + if not self._warned_no_seeding_limits: + self.logger.warning( + "RemoveTorrent=%s but neither MaxUploadRatio nor MaxSeedingTime is set; " + "skipping seeding-based removal until at least one limit is configured", + mode, + ) + self._warned_no_seeding_limits = True return False + if mode == 4: + return ratio_met and time_met + if mode == 3: + return ratio_met or time_met + if mode == 2: + return time_met + if mode == 1: + return ratio_met + return False + def refresh_download_queue(self): self.queue = self.get_queue() or [] self.queue_active_count = len(self.queue) @@ -6508,15 +6492,16 @@ def refresh_download_queue(self): entry["episodeId"] for entry in self.queue if entry.get("episodeId") } if self.model_queue: - with_database_retry( - lambda: self.model_queue.delete() - .where( - (self.model_queue.EntryId.not_in(list(self.queue_file_ids))) - & (self.model_queue.ArrInstance == self._name) + with database_lock(): + with_database_retry( + lambda: self.model_queue.delete() + .where( + (self.model_queue.EntryId.not_in(list(self.queue_file_ids))) + & (self.model_queue.ArrInstance == self._name) + ) + .execute(), + logger=self.logger, ) - .execute(), - logger=self.logger, - ) else: for entry in self.queue: if r := entry.get("seriesId"): @@ -6525,15 +6510,16 @@ def refresh_download_queue(self): entry["seriesId"] for entry in self.queue if entry.get("seriesId") } if self.model_queue: - with_database_retry( - lambda: self.model_queue.delete() - .where( - (self.model_queue.EntryId.not_in(list(self.queue_file_ids))) - & (self.model_queue.ArrInstance == self._name) + with database_lock(): + with_database_retry( + lambda: self.model_queue.delete() + .where( + (self.model_queue.EntryId.not_in(list(self.queue_file_ids))) + & (self.model_queue.ArrInstance == self._name) + ) + .execute(), + logger=self.logger, ) - .execute(), - logger=self.logger, - ) elif self.type == "radarr": self.requeue_cache = { entry["id"]: entry["movieId"] for entry in self.queue if entry.get("movieId") @@ -6542,12 +6528,13 @@ def refresh_download_queue(self): entry["movieId"] for entry in self.queue if entry.get("movieId") } if self.model_queue: - with_database_retry( - lambda: self.model_queue.delete() - .where(self.model_queue.EntryId.not_in(list(self.queue_file_ids))) - .execute(), - logger=self.logger, - ) + with database_lock(): + with_database_retry( + lambda: self.model_queue.delete() + .where(self.model_queue.EntryId.not_in(list(self.queue_file_ids))) + .execute(), + logger=self.logger, + ) elif self.type == "lidarr": self.requeue_cache = { entry["id"]: entry["albumId"] for entry in self.queue if entry.get("albumId") @@ -6556,12 +6543,13 @@ def refresh_download_queue(self): entry["albumId"] for entry in self.queue if entry.get("albumId") } if self.model_queue: - with_database_retry( - lambda: self.model_queue.delete() - .where(self.model_queue.EntryId.not_in(list(self.queue_file_ids))) - .execute(), - logger=self.logger, - ) + with database_lock(): + with_database_retry( + lambda: self.model_queue.delete() + .where(self.model_queue.EntryId.not_in(list(self.queue_file_ids))) + .execute(), + logger=self.logger, + ) self._update_bad_queue_items() @@ -6573,13 +6561,7 @@ def get_queue(self, page=1, page_size=1000, sort_direction="ascending", sort_key retries=3, backoff=0.5, max_backoff=3, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - requests.exceptions.RequestException, - ), + exceptions=_ARR_RETRY_EXCEPTIONS_EXTENDED, ) try: res = res.get("records", []) @@ -6633,37 +6615,24 @@ def parse_quality_profiles(self) -> dict[int, int]: self.quality_profile_mappings, ) - while True: - try: - profiles = self.client.get_quality_profile() - self.logger.debug("Fetched %d quality profiles from API", len(profiles)) - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ) as e: - # transient network/encoding issues; retry - self.logger.warning( - "Transient error fetching quality profiles, retrying: %s", type(e).__name__ - ) - continue - except PyarrServerError as e: - # Server-side error (e.g., Radarr DB disk I/O). Log and wait 5 minutes before retrying. - self.logger.error( - "Failed to get quality profiles (server error): %s -- retrying in 5 minutes", e - ) - try: - time.sleep(300) - except Exception: - pass - continue - except Exception as e: - # Unexpected error; log and continue without profiles. - self.logger.error("Unexpected error getting quality profiles: %s", e) - profiles = [] - break + try: + profiles = with_retry( + lambda: self.client.get_quality_profile(), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( + requests.exceptions.ChunkedEncodingError, + requests.exceptions.ContentDecodingError, + requests.exceptions.ConnectionError, + JSONDecodeError, + PyarrServerError, + ), + ) + self.logger.debug("Fetched %d quality profiles from API", len(profiles)) + except Exception as e: + self.logger.error("Unexpected error getting quality profiles: %s", e) + profiles = [] # Build a lookup dict for profile name -> ID profile_name_to_id = {p["name"]: p["id"] for p in profiles} @@ -6728,10 +6697,10 @@ def _reset_all_temp_profiles(self): items = self.client.get_artist() item_type = "artist" else: - self.logger.warning(f"Unknown Arr type for temp profile reset: {self._name}") + self.logger.warning("Unknown Arr type for temp profile reset: %s", self._name) return - self.logger.info(f"Checking {len(items)} {item_type}s for temp profile resets...") + self.logger.info("Checking %d %ss for temp profile resets...", len(items), item_type) for item in items: profile_id = item.get("qualityProfileId") @@ -6782,15 +6751,13 @@ def _reset_all_temp_profiles(self): ) except Exception as e: - self.logger.error(f"Error during temp profile reset: {e}", exc_info=True) + self.logger.error("Error during temp profile reset: %s", e, exc_info=True) def _check_temp_profile_timeouts(self): """Check for items with temp profiles that have exceeded the timeout and reset them.""" if self.temp_profile_timeout_minutes == 0: return # Feature disabled - from datetime import timedelta - timeout_threshold = datetime.now() - timedelta(minutes=self.temp_profile_timeout_minutes) reset_count = 0 @@ -6878,7 +6845,7 @@ def _check_temp_profile_timeouts(self): ) except Exception as e: - self.logger.error(f"Error checking temp profile timeouts: {e}", exc_info=True) + self.logger.error("Error checking temp profile timeouts: %s", e, exc_info=True) def register_search_mode(self): """Initialize database models using the single shared database.""" @@ -7004,33 +6971,33 @@ def run_request_search(self): self.request_search_timer = time.time() except NoConnectionrException as e: self.logger.error(e.message) - raise DelayLoopException(length=300, type=e.type) + raise DelayLoopException(length=300, error_type=e.error_type) except DelayLoopException: raise except Exception as e: self.logger.exception(e, exc_info=sys.exc_info()) except DelayLoopException as e: - if e.type == "qbit": + if e.error_type == "qbit": self.logger.critical( "Failed to connected to qBit client, sleeping for %s", timedelta(seconds=e.length), ) - elif e.type == "internet": + elif e.error_type == "internet": self.logger.critical( "Failed to connected to the internet, sleeping for %s", timedelta(seconds=e.length), ) - elif e.type == "arr": + elif e.error_type == "arr": self.logger.critical( "Failed to connected to the Arr instance, sleeping for %s", timedelta(seconds=e.length), ) - elif e.type == "delay": + elif e.error_type == "delay": self.logger.critical( "Forced delay due to temporary issue with environment, sleeping for %s", timedelta(seconds=e.length), ) - elif e.type == "no_downloads": + elif e.error_type == "no_downloads": self.logger.debug( "No downloads in category, sleeping for %s", timedelta(seconds=e.length) ) @@ -7046,13 +7013,7 @@ def get_year_search(self) -> tuple[list[int], int]: retries=3, backoff=0.5, max_backoff=3, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - requests.exceptions.RequestException, - ), + exceptions=_ARR_RETRY_EXCEPTIONS_EXTENDED, ) for m in movies: @@ -7067,28 +7028,16 @@ def get_year_search(self) -> tuple[list[int], int]: retries=3, backoff=0.5, max_backoff=3, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - requests.exceptions.RequestException, - ), + exceptions=_ARR_RETRY_EXCEPTIONS_EXTENDED, ) for s in series: episodes = with_retry( - lambda: self.client.get_episode(s["id"], True), + lambda s=s: self.client.get_episode(s["id"], True), retries=3, backoff=0.5, max_backoff=3, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - requests.exceptions.RequestException, - ), + exceptions=_ARR_RETRY_EXCEPTIONS_EXTENDED, ) for e in episodes: if "airDateUtc" in e: @@ -7157,7 +7106,7 @@ def run_search_loop(self) -> NoReturn: years, years_count = self.get_year_search() try: self.search_current_year = years[years_index] - except BaseException: + except Exception: self.search_current_year = years[: years_index + 1] self.logger.debug("Current year %s", self.search_current_year) try: @@ -7249,7 +7198,7 @@ def run_search_loop(self) -> NoReturn: except NoConnectionrException as e: self.logger.error(e.message) self.manager.qbit_manager.should_delay_torrent_scan = True - raise DelayLoopException(length=300, type=e.type) + raise DelayLoopException(length=300, error_type=e.error_type) except DelayLoopException: raise except ValueError: @@ -7257,27 +7206,27 @@ def run_search_loop(self) -> NoReturn: self.loop_completed = True except qbittorrentapi.exceptions.APIConnectionError as e: self.logger.warning(e) - raise DelayLoopException(length=300, type="qbit") + raise DelayLoopException(length=300, error_type="qbit") except Exception as e: self.logger.exception(e, exc_info=sys.exc_info()) event.wait(LOOP_SLEEP_TIMER) except DelayLoopException as e: - if e.type == "qbit": + if e.error_type == "qbit": self.logger.critical( "Failed to connected to qBit client, sleeping for %s", timedelta(seconds=e.length), ) - elif e.type == "internet": + elif e.error_type == "internet": self.logger.critical( "Failed to connected to the internet, sleeping for %s", timedelta(seconds=e.length), ) - elif e.type == "arr": + elif e.error_type == "arr": self.logger.critical( "Failed to connected to the Arr instance, sleeping for %s", timedelta(seconds=e.length), ) - elif e.type == "delay": + elif e.error_type == "delay": self.logger.critical( "Forced delay due to temporary issue with environment, " "sleeping for %s", @@ -7324,13 +7273,13 @@ def run_torrent_loop(self) -> NoReturn: except NoConnectionrException as e: self.logger.error(e.message) self.manager.qbit_manager.should_delay_torrent_scan = True - raise DelayLoopException(length=300, type="arr") + raise DelayLoopException(length=300, error_type="arr") except qbittorrentapi.exceptions.APIConnectionError as e: self.logger.warning(e) - raise DelayLoopException(length=300, type="qbit") + raise DelayLoopException(length=300, error_type="qbit") except qbittorrentapi.exceptions.APIError as e: self.logger.warning(e) - raise DelayLoopException(length=300, type="qbit") + raise DelayLoopException(length=300, error_type="qbit") except DelayLoopException: raise except KeyboardInterrupt: @@ -7340,28 +7289,28 @@ def run_torrent_loop(self) -> NoReturn: self.logger.error(e, exc_info=sys.exc_info()) event.wait(LOOP_SLEEP_TIMER) except DelayLoopException as e: - if e.type == "qbit": + if e.error_type == "qbit": self.logger.critical( "Failed to connected to qBit client, sleeping for %s", timedelta(seconds=e.length), ) - elif e.type == "internet": + elif e.error_type == "internet": self.logger.critical( "Failed to connected to the internet, sleeping for %s", timedelta(seconds=e.length), ) - elif e.type == "arr": + elif e.error_type == "arr": self.logger.critical( "Failed to connected to the Arr instance, sleeping for %s", timedelta(seconds=e.length), ) - elif e.type == "delay": + elif e.error_type == "delay": self.logger.critical( "Forced delay due to temporary issue with environment, " "sleeping for %s.", timedelta(seconds=e.length), ) - elif e.type == "no_downloads": + elif e.error_type == "no_downloads": self.logger.debug( "No downloads in category, sleeping for %s", timedelta(seconds=e.length), @@ -7397,6 +7346,7 @@ class PlaceHolderArr(Arr): def __init__(self, name: str, manager: ArrManager): if name in manager.groups: raise OSError(f"Group '{name}' has already been registered.") + self.type = "placeholder" self._name = name.title() self.category = name self.manager = manager @@ -7406,24 +7356,69 @@ def __init__(self, name: str, manager: ArrManager): self.sent_to_scan = set() self.sent_to_scan_hashes = set() self.files_probed = set() + self.files_to_cleanup = set() self.import_torrents = [] self.change_priority = {} self.recheck = set() self.pause = set() self.skip_blacklist = set() self.remove_from_qbit = set() + self.remove_from_qbit_by_instance: dict[str, set[str]] = {} self.delete = set() self.resume = set() self.expiring_bool = ExpiringSet(max_age_seconds=10) - self.ignore_torrents_younger_than = CONFIG.get( - "Settings.IgnoreTorrentsYoungerThan", fallback=600 + self.ignore_torrents_younger_than = CONFIG.get_duration( + "Settings.IgnoreTorrentsYoungerThan", fallback=180 ) self.timed_ignore_cache = ExpiringSet(max_age_seconds=self.ignore_torrents_younger_than) + self.timed_ignore_cache_2 = ExpiringSet( + max_age_seconds=self.ignore_torrents_younger_than * 2 + ) self.timed_skip = ExpiringSet(max_age_seconds=self.ignore_torrents_younger_than) self.tracker_delay = ExpiringSet(max_age_seconds=600) - self._LOG_LEVEL = self.manager.qbit_manager.logger.level - self.logger = logging.getLogger(f"qBitrr.{self._name}") + self.special_casing_file_check = ExpiringSet(max_age_seconds=10) + self.cleaned_torrents = set() + self.missing_files_post_delete = set() + self.downloads_with_bad_error_message_blocklist = set() + self.needs_cleanup = False + self._warned_no_seeding_limits = False + self.custom_format_unmet_search = False + self.do_not_remove_slow = False + self.maximum_eta = CONFIG.get_duration("Settings.Torrent.MaximumETA", fallback=86400) + self.maximum_deletable_percentage = CONFIG.get( + "Settings.Torrent.MaximumDeletablePercentage", fallback=0.95 + ) + self.folder_exclusion_regex = None + self.file_name_exclusion_regex = None + self.file_extension_allowlist = None + self.folder_exclusion_regex_re = None + self.file_name_exclusion_regex_re = None + self.file_extension_allowlist_re = None + self.re_search_stalled = False + self.monitored_trackers = [] + self._host_to_config_uri = {} + self._add_trackers_if_missing = set() + self._remove_trackers_if_exists = set() + self._monitored_tracker_urls = set() + self.remove_dead_trackers = False + self._remove_tracker_hosts = set() + self._normalized_bad_tracker_msgs = set() + self.seeding_mode_global_remove_torrent = -1 + self.seeding_mode_global_max_upload_ratio = -1 + self.seeding_mode_global_max_seeding_time = -1 + self.seeding_mode_global_download_limit = -1 + self.seeding_mode_global_upload_limit = -1 + self.seeding_mode_global_bad_tracker_msg = [] + self.completed_folder = pathlib.Path(COMPLETED_DOWNLOAD_FOLDER).joinpath(self.category) + self._LOG_LEVEL = self.manager.qbit_manager.logger.level + self.logger = logging.getLogger(f"qBitrr.{self._name}") run_logs(self.logger, self._name) + self.manager.completed_folders.add(self.completed_folder) + self.manager.category_allowlist.add(self.category) + self.stalled_delay = -1 + self.allowed_stalled = False + if self.category in self.manager.qbit_managed_categories: + self._apply_qbit_seeding_config() self.search_missing = False self.session = None self.search_setup_completed = False @@ -7432,8 +7427,281 @@ def __init__(self, name: str, manager: ArrManager): self.queue_active_count: int = 0 self.category_torrent_count: int = 0 self.free_space_tagged_count: int = 0 + if TAGLESS: + self.register_search_mode() + else: + self.torrents = None + self.db = None + self.search_setup_completed = True self.logger.hnotice("Starting %s monitor", self._name) + def _get_models( + self, + ) -> tuple[ + None, + None, + None, + None, + type[TorrentLibrary] | None, + ]: + """PlaceHolderArr has no file/queue models; only TorrentLibrary when TAGLESS.""" + return None, None, None, None, (TorrentLibrary if TAGLESS else None) + + def _process_single_torrent_missing_files( + self, + torrent: qbittorrentapi.TorrentDictionary, + instance_name: str = "default", + ) -> None: + """Track which qBit instance the torrent is on so we delete from the correct client.""" + super()._process_single_torrent_missing_files(torrent, instance_name) + + def custom_format_unmet_check(self, torrent: qbittorrentapi.TorrentDictionary) -> bool: + """PlaceHolderArr does not use Arr queue; never trigger custom-format branch.""" + return False + + def _apply_qbit_seeding_config(self) -> None: + """Load qBit CategorySeeding/Trackers for this category's owning qBit section.""" + section = self.manager.qbit_managed_category_sections.get(self.category, "qBit") + seeding_keys = [ + "DownloadRateLimitPerTorrent", + "UploadRateLimitPerTorrent", + "MaxUploadRatio", + "MaxSeedingTime", + "RemoveTorrent", + ] + default_seeding = {} + for key in seeding_keys: + if key == "MaxSeedingTime": + default_seeding[key] = CONFIG.get_duration( + f"{section}.CategorySeeding.{key}", fallback=-1 + ) + else: + default_seeding[key] = CONFIG.get(f"{section}.CategorySeeding.{key}", fallback=-1) + for key, fallback in ( + ("HitAndRunMode", "disabled"), + ("MinSeedRatio", 1.0), + ("MinSeedingTimeDays", 0), + ("HitAndRunPartialSeedRatio", 1.0), + ("TrackerUpdateBuffer", 0), + ): + if key == "TrackerUpdateBuffer": + default_seeding[key] = CONFIG.get_duration( + f"{section}.CategorySeeding.{key}", fallback=fallback + ) + else: + default_seeding[key] = CONFIG.get( + f"{section}.CategorySeeding.{key}", fallback=fallback + ) + category_overrides = {} + for cat_config in CONFIG.get(f"{section}.CategorySeeding.Categories", fallback=[]): + if isinstance(cat_config, dict) and "Name" in cat_config: + category_overrides[cat_config["Name"]] = cat_config + effective = dict(default_seeding) + if self.category in category_overrides: + effective.update(category_overrides[self.category]) + self.seeding_mode_global_remove_torrent = effective.get("RemoveTorrent", -1) + self.seeding_mode_global_max_upload_ratio = effective.get("MaxUploadRatio", -1) + self.seeding_mode_global_max_seeding_time = effective.get("MaxSeedingTime", -1) + self.seeding_mode_global_download_limit = effective.get("DownloadRateLimitPerTorrent", -1) + self.seeding_mode_global_upload_limit = effective.get("UploadRateLimitPerTorrent", -1) + self.stalled_delay = CONFIG.get_duration( + f"{section}.CategorySeeding.StalledDelay", fallback=-1, unit="minutes" + ) + self.allowed_stalled = self.stalled_delay != -1 + self.monitored_trackers = CONFIG.get(f"{section}.Trackers", fallback=[]) + self._remove_trackers_if_exists = { + uri + for i in self.monitored_trackers + if i.get("RemoveIfExists") is True + and (uri := (i.get("URI") or "").strip().rstrip("/")) + } + self._monitored_tracker_urls = { + uri + for i in self.monitored_trackers + if (uri := (i.get("URI") or "").strip().rstrip("/")) + and uri not in self._remove_trackers_if_exists + } + self._add_trackers_if_missing = { + uri + for i in self.monitored_trackers + if i.get("AddTrackerIfMissing") is True + and (uri := (i.get("URI") or "").strip().rstrip("/")) + } + self._host_to_config_uri = {} + for _uri in self._monitored_tracker_urls: + _host = _extract_tracker_host(_uri) + if _host: + self._host_to_config_uri[_host] = _uri + self._remove_tracker_hosts = { + h for u in self._remove_trackers_if_exists if (h := _extract_tracker_host(u)) + } + self.logger.debug( + "Applied qBit seeding config from section '%s' for category '%s': " + "RemoveTorrent=%s, StalledDelay=%s", + section, + self.category, + self.seeding_mode_global_remove_torrent, + self.stalled_delay, + ) + + def _process_failed(self) -> None: + """Delete torrents from the correct qBit instance and log any delete failures.""" + to_delete_all = self.delete.union( + self.missing_files_post_delete, self.downloads_with_bad_error_message_blocklist + ) + skip_blacklist = { + i.upper() for i in self.skip_blacklist.union(self.missing_files_post_delete) + } + if not ( + to_delete_all + or self.remove_from_qbit + or self.skip_blacklist + or self.remove_from_qbit_by_instance + ): + return + n_delete = len(self.delete) + n_missing = len(self.missing_files_post_delete) + n_bad_msg = len(self.downloads_with_bad_error_message_blocklist) + n_remove = len(self.remove_from_qbit) + n_remove_by_inst = sum(len(s) for s in self.remove_from_qbit_by_instance.values()) + n_skip = len(self.skip_blacklist) + self.logger.info( + "Deletion summary: delete=%d, missing_files=%d, bad_error_blocklist=%d, " + "remove_from_qbit=%d, remove_by_instance=%d, skip_blacklist=%d", + n_delete, + n_missing, + n_bad_msg, + n_remove, + n_remove_by_inst, + n_skip, + ) + if to_delete_all: + for arr in self.manager.managed_objects.values(): + if payload := arr.process_entries(to_delete_all): + for entry, hash_ in payload: + if hash_ in arr.cache: + arr._process_failed_individual( + hash_=hash_, entry=entry, skip_blacklist=skip_blacklist + ) + deleted_hashes: set[str] = set() + qbit_manager = self.manager.qbit_manager + # Delete per-instance (e.g. missing-files) so we use the correct qBit client. + for instance_name, hashes in self.remove_from_qbit_by_instance.items(): + client = qbit_manager.get_client(instance_name) + if client is None: + self.logger.warning( + "Cannot delete %d torrent(s) from qBit instance '%s': no client", + len(hashes), + instance_name, + ) + continue + try: + with_retry( + lambda c=client, h=hashes: c.torrents_delete(hashes=h, delete_files=True), + retries=3, + backoff=0.5, + max_backoff=3, + exceptions=( + qbittorrentapi.exceptions.APIError, + qbittorrentapi.exceptions.APIConnectionError, + requests.exceptions.RequestException, + ), + ) + deleted_hashes.update(hashes) + except ( + qbittorrentapi.exceptions.APIError, + qbittorrentapi.exceptions.APIConnectionError, + requests.exceptions.RequestException, + ) as e: + self.logger.error( + "Failed to delete %d torrent(s) from qBit instance '%s': %s", + len(hashes), + instance_name, + e, + ) + # Remaining remove_from_qbit/skip_blacklist and to_delete_all via default client. + if self.remove_from_qbit or self.skip_blacklist or to_delete_all: + temp_to_delete = set() + if to_delete_all: + if self.manager.qbit: + try: + with_retry( + lambda: self.manager.qbit.torrents_delete( + hashes=to_delete_all, delete_files=True + ), + retries=3, + backoff=0.5, + max_backoff=3, + exceptions=( + qbittorrentapi.exceptions.APIError, + qbittorrentapi.exceptions.APIConnectionError, + requests.exceptions.RequestException, + ), + ) + temp_to_delete.update(to_delete_all) + except ( + qbittorrentapi.exceptions.APIError, + qbittorrentapi.exceptions.APIConnectionError, + requests.exceptions.RequestException, + ) as e: + self.logger.error( + "Failed to delete %d torrent(s) from qBit (to_delete_all): %s", + len(to_delete_all), + e, + ) + else: + self.logger.warning( + "Cannot delete to_delete_all: no qBit client (manager.qbit is None)" + ) + if self.remove_from_qbit or self.skip_blacklist: + rest = (self.remove_from_qbit.union(self.skip_blacklist)) - deleted_hashes + if rest and self.manager.qbit: + try: + with_retry( + lambda: self.manager.qbit.torrents_delete( + hashes=rest, delete_files=True + ), + retries=3, + backoff=0.5, + max_backoff=3, + exceptions=( + qbittorrentapi.exceptions.APIError, + qbittorrentapi.exceptions.APIConnectionError, + requests.exceptions.RequestException, + ), + ) + temp_to_delete.update(rest) + except ( + qbittorrentapi.exceptions.APIError, + qbittorrentapi.exceptions.APIConnectionError, + requests.exceptions.RequestException, + ) as e: + self.logger.error( + "Failed to delete %d torrent(s) from qBit (remove/blacklist): %s", + len(rest), + e, + ) + elif rest: + self.logger.warning( + "Cannot delete %d torrent(s): no qBit client (manager.qbit is None)", + len(rest), + ) + cleaned_hashes = deleted_hashes.union(temp_to_delete) + for h in cleaned_hashes: + self.cleaned_torrents.discard(h) + self.sent_to_scan_hashes.discard(h) + if h in self.manager.qbit_manager.name_cache: + del self.manager.qbit_manager.name_cache[h] + if h in self.manager.qbit_manager.cache: + del self.manager.qbit_manager.cache[h] + if self.missing_files_post_delete or self.downloads_with_bad_error_message_blocklist: + self.missing_files_post_delete.clear() + self.downloads_with_bad_error_message_blocklist.clear() + self.skip_blacklist.clear() + self.remove_from_qbit.clear() + self.remove_from_qbit_by_instance.clear() + self.delete.clear() + def _process_errored(self): # Recheck all torrents marked for rechecking. if not self.recheck: @@ -7474,123 +7742,56 @@ def _process_errored(self): self.timed_ignore_cache.add(k) self.recheck.clear() - def _process_failed(self): - if not (self.delete or self.skip_blacklist): - return - to_delete_all = self.delete - skip_blacklist = {i.upper() for i in self.skip_blacklist} - if to_delete_all: - for arr in self.manager.managed_objects.values(): - if payload := arr.process_entries(to_delete_all): - for entry, hash_ in payload: - if hash_ in arr.cache: - arr._process_failed_individual( - hash_=hash_, entry=entry, skip_blacklist=skip_blacklist - ) - if self.remove_from_qbit or self.skip_blacklist or to_delete_all: - # Remove all bad torrents from the Client. - temp_to_delete = set() - if to_delete_all: - with contextlib.suppress(Exception): - with_retry( - lambda: self.manager.qbit.torrents_delete( - hashes=to_delete_all, delete_files=True - ), - retries=3, - backoff=0.5, - max_backoff=3, - exceptions=( - qbittorrentapi.exceptions.APIError, - qbittorrentapi.exceptions.APIConnectionError, - requests.exceptions.RequestException, - ), - ) - if self.remove_from_qbit or self.skip_blacklist: - temp_to_delete = self.remove_from_qbit.union(self.skip_blacklist) - with contextlib.suppress(Exception): - with_retry( - lambda: self.manager.qbit.torrents_delete( - hashes=temp_to_delete, delete_files=True - ), - retries=3, - backoff=0.5, - max_backoff=3, - exceptions=( - qbittorrentapi.exceptions.APIError, - qbittorrentapi.exceptions.APIConnectionError, - requests.exceptions.RequestException, - ), - ) - to_delete_all = to_delete_all.union(temp_to_delete) - for h in to_delete_all: - if h in self.manager.qbit_manager.name_cache: - del self.manager.qbit_manager.name_cache[h] - if h in self.manager.qbit_manager.cache: - del self.manager.qbit_manager.cache[h] - self.skip_blacklist.clear() - self.remove_from_qbit.clear() - self.delete.clear() - def process(self): + self._process_resume() + self._process_paused() self._process_errored() + self._process_file_priority() self._process_failed() + self.import_torrents.clear() + with contextlib.suppress(AttributeError): + self.files_to_cleanup.clear() def process_torrents(self): try: try: - _client = self.manager.qbit_manager.client - if _client is None: - raise DelayLoopException(length=LOOP_SLEEP_TIMER, type="no_downloads") - while True: - try: - torrents = with_retry( - lambda: _client.torrents.info( - status_filter="all", - category=self.category, - sort="added_on", - reverse=False, - ), - retries=3, - backoff=0.5, - max_backoff=3, - exceptions=( - qbittorrentapi.exceptions.APIError, - qbittorrentapi.exceptions.APIConnectionError, - requests.exceptions.RequestException, - ), - ) - break - except qbittorrentapi.exceptions.APIError: - continue - torrents = [t for t in torrents if hasattr(t, "category")] - self.category_torrent_count = len(torrents) - if not len(torrents): - raise DelayLoopException(length=LOOP_SLEEP_TIMER, type="no_downloads") - if not has_internet(self.manager.qbit_manager): + torrents_with_instances = with_retry( + lambda: self._get_torrents_from_all_instances(), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=(JSONDecodeError,), + ) + + torrents_with_instances = [ + (instance, t) + for instance, t in torrents_with_instances + if getattr(t, "category", None) == self.category + ] + self._warned_no_seeding_limits = False + self.category_torrent_count = len(torrents_with_instances) + if not torrents_with_instances: + raise DelayLoopException(length=LOOP_SLEEP_TIMER, error_type="no_downloads") + + if not has_internet(self.manager.qbit_manager.client): self.manager.qbit_manager.should_delay_torrent_scan = True - raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, type="internet") + raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, error_type="internet") if self.manager.qbit_manager.should_delay_torrent_scan: - raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, type="delay") - for torrent in torrents: - if torrent.category != RECHECK_CATEGORY: - self.manager.qbit_manager.cache[torrent.hash] = torrent.category - self.manager.qbit_manager.name_cache[torrent.hash] = torrent.name - if torrent.category == FAILED_CATEGORY: - # Bypass everything if manually marked as failed - self._process_single_torrent_failed_cat(torrent) - elif torrent.category == RECHECK_CATEGORY: - # Bypass everything else if manually marked for rechecking - self._process_single_torrent_recheck_cat(torrent) + raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, error_type="delay") + + for instance_name, torrent in torrents_with_instances: + with contextlib.suppress(qbittorrentapi.NotFound404Error): + self._process_single_torrent(torrent, instance_name=instance_name) self.process() except NoConnectionrException as e: self.logger.error(e.message) except qbittorrentapi.exceptions.APIError as e: self.logger.error("The qBittorrent API returned an unexpected error") self.logger.debug("Unexpected APIError from qBitTorrent", exc_info=e) - raise DelayLoopException(length=300, type="qbit") + raise DelayLoopException(length=300, error_type="qbit") except qbittorrentapi.exceptions.APIConnectionError: self.logger.warning("Max retries exceeded") - raise DelayLoopException(length=300, type="qbit") + raise DelayLoopException(length=300, error_type="qbit") except DelayLoopException: raise except KeyboardInterrupt: @@ -7613,13 +7814,14 @@ def __init__(self, categories: set[str], manager: ArrManager): self.logger = logging.getLogger(f"qBitrr.{self._name}") self._LOG_LEVEL = self.manager.qbit_manager.logger.level run_logs(self.logger, self._name) + self.cache = {} self.categories = categories self.logger.trace("Categories: %s", self.categories) self.pause = set() self.resume = set() self.expiring_bool = ExpiringSet(max_age_seconds=10) - self.ignore_torrents_younger_than = CONFIG.get( - "Settings.IgnoreTorrentsYoungerThan", fallback=600 + self.ignore_torrents_younger_than = CONFIG.get_duration( + "Settings.IgnoreTorrentsYoungerThan", fallback=180 ) self.timed_ignore_cache = ExpiringSet(max_age_seconds=self.ignore_torrents_younger_than) self.needs_cleanup = False @@ -7627,21 +7829,57 @@ def __init__(self, categories: set[str], manager: ArrManager): # Track search setup state to cooperate with Arr.register_search_mode self.search_setup_completed = False if FREE_SPACE_FOLDER == "CHANGE_ME": - self.completed_folder = pathlib.Path(COMPLETED_DOWNLOAD_FOLDER).joinpath( - next(iter(self.categories)) - ) + # Prefer an Arr-managed category so the path exists (Arr uses category subdirs). + # qBit-managed-only categories may have no subdir under COMPLETED_DOWNLOAD_FOLDER. + arr_cats = self.categories & self.manager.arr_categories + chosen = next(iter(arr_cats), None) or next(iter(self.categories)) + self.completed_folder = pathlib.Path(COMPLETED_DOWNLOAD_FOLDER).joinpath(chosen) + # Use the main completed-download folder for disk usage so we report the same + # filesystem the user expects (doc default: "Same as CompletedDownloadFolder"). + # A category subdir may be a different mount and report much less free space. + self._disk_usage_path = pathlib.Path(COMPLETED_DOWNLOAD_FOLDER).resolve() else: self.completed_folder = pathlib.Path(FREE_SPACE_FOLDER) + self._disk_usage_path = pathlib.Path(FREE_SPACE_FOLDER).resolve() + self._free_space_folder_is_auto = FREE_SPACE_FOLDER == "CHANGE_ME" self.min_free_space = FREE_SPACE # Parse once to avoid repeated conversions self._min_free_space_bytes = ( parse_size(self.min_free_space) if self.min_free_space != "-1" else 0 ) + if FREE_SPACE_FOLDER == "CHANGE_ME" and not self.completed_folder.exists(): + # Fallback to parent when chosen category subdir doesn't exist (e.g. qBit-only). + parent = pathlib.Path(COMPLETED_DOWNLOAD_FOLDER) + if parent.exists(): + self.completed_folder = parent + # else: keep completed_folder and let disk_usage raise so the user sees the error + # Path for disk usage: use resolved path. Only when path was auto-chosen (CHANGE_ME), + # allow falling back to first existing parent so we still report the correct volume; + # when the user explicitly set FreeSpaceFolder, do not substitute—missing path should + # error so they fix typo/mount instead of monitoring the wrong filesystem. + self._path_for_disk_usage = self._disk_usage_path + if self._free_space_folder_is_auto: + _p = self._first_existing_parent(self._disk_usage_path) + if _p: + self._path_for_disk_usage = _p + if self._path_for_disk_usage != self._disk_usage_path: + self.logger.warning( + "FreeSpaceFolder path does not exist, using parent for disk space check | " + "Configured: %s | Using: %s%s", + self._disk_usage_path, + self._path_for_disk_usage, + ( + " | In Docker: ensure the host volume is mounted at the configured path (e.g. -v /host/torrents:/torrents)" + if is_docker() + else "" + ), + ) self.current_free_space = ( - shutil.disk_usage(self.completed_folder).free - self._min_free_space_bytes + shutil.disk_usage(self._path_for_disk_usage).free - self._min_free_space_bytes ) self.logger.trace( - "Free space monitor initialized | Available: %s | Threshold: %s", + "Free space monitor initialized | Path: %s | Available: %s | Threshold: %s", + self._path_for_disk_usage, format_bytes(self.current_free_space + self._min_free_space_bytes), format_bytes(self._min_free_space_bytes), ) @@ -7674,6 +7912,17 @@ def __init__(self, categories: set[str], manager: ArrManager): ) ) + @staticmethod + def _first_existing_parent(path: pathlib.Path) -> pathlib.Path | None: + """Return the nearest existing parent path, or None if none exist (e.g. path below root).""" + current = path + while not current.exists(): + parent = current.parent + if parent == current: + return None + current = parent + return current + def _get_models( self, ) -> tuple[ @@ -7768,24 +8017,29 @@ def process_torrents(self): try: _client = self.manager.qbit_manager.client if _client is None: - raise DelayLoopException(length=LOOP_SLEEP_TIMER, type="no_downloads") - while True: - try: - # Fetch per category to reduce client-side filtering - torrents = [] - for cat in self.categories: - with contextlib.suppress(qbittorrentapi.exceptions.APIError): - torrents.extend( - _client.torrents.info( - status_filter="all", - category=cat, - sort="added_on", - reverse=False, - ) + raise DelayLoopException(length=LOOP_SLEEP_TIMER, error_type="no_downloads") + + def _fetch_free_space_torrents(): + result = [] + for cat in self.categories: + with contextlib.suppress(qbittorrentapi.exceptions.APIError): + result.extend( + _client.torrents.info( + status_filter="all", + category=cat, + sort="added_on", + reverse=False, ) - break - except qbittorrentapi.exceptions.APIError: - continue + ) + return result + + torrents = with_retry( + _fetch_free_space_torrents, + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=(qbittorrentapi.exceptions.APIError,), + ) torrents = [t for t in torrents if hasattr(t, "category")] torrents = [t for t in torrents if t.category in self.categories] torrents = [t for t in torrents if "qBitrr-ignored" not in t.tags] @@ -7797,14 +8051,21 @@ def process_torrents(self): if self.in_tags(t, "qBitrr-free_space_paused", _first_instance) ) if not len(torrents): - raise DelayLoopException(length=LOOP_SLEEP_TIMER, type="no_downloads") - if not has_internet(self.manager.qbit_manager): + raise DelayLoopException(length=LOOP_SLEEP_TIMER, error_type="no_downloads") + if not has_internet(self.manager.qbit_manager.client): self.manager.qbit_manager.should_delay_torrent_scan = True - raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, type="internet") + raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, error_type="internet") if self.manager.qbit_manager.should_delay_torrent_scan: - raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, type="delay") + raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, error_type="delay") + # Re-resolve path each loop so we use the configured path once it appears + # (e.g. Docker volume mounted after process start). Only when path was + # auto-chosen (CHANGE_ME); explicit FreeSpaceFolder must exist or we error. + if self._free_space_folder_is_auto: + _p = self._first_existing_parent(self._disk_usage_path) + if _p: + self._path_for_disk_usage = _p self.current_free_space = ( - shutil.disk_usage(self.completed_folder).free - self._min_free_space_bytes + shutil.disk_usage(self._path_for_disk_usage).free - self._min_free_space_bytes ) self.logger.trace( "Processing torrents | Available: %s | Threshold: %s | Usable: %s | Torrents: %d | Paused for space: %d", @@ -7826,10 +8087,10 @@ def process_torrents(self): except qbittorrentapi.exceptions.APIError as e: self.logger.error("The qBittorrent API returned an unexpected error") self.logger.debug("Unexpected APIError from qBitTorrent", exc_info=e) - raise DelayLoopException(length=300, type="qbit") + raise DelayLoopException(length=300, error_type="qbit") except qbittorrentapi.exceptions.APIConnectionError: self.logger.warning("Max retries exceeded") - raise DelayLoopException(length=300, type="qbit") + raise DelayLoopException(length=300, error_type="qbit") except DelayLoopException: raise except KeyboardInterrupt: @@ -7854,6 +8115,7 @@ def __init__(self, qbitmanager: qBitManager): self.special_categories: set[str] = {FAILED_CATEGORY, RECHECK_CATEGORY} self.arr_categories: set[str] = set() self.qbit_managed_categories: set[str] = set() + self.qbit_managed_category_sections: dict[str, str] = {} self.category_allowlist: set[str] = self.special_categories.copy() self.completed_folders: set[pathlib.Path] = set() self.managed_objects: dict[str, Arr] = {} @@ -7880,12 +8142,25 @@ def _validate_category_assignments(self): ValueError: If any category is managed by both Arr and qBit """ # Collect qBit-managed categories from all instances + self.qbit_managed_categories.clear() + self.qbit_managed_category_sections.clear() for section in CONFIG.sections(): # Check default qBit section if section == "qBit": managed_cats = CONFIG.get("qBit.ManagedCategories", fallback=[]) if managed_cats: self.qbit_managed_categories.update(managed_cats) + for category in managed_cats: + owner = self.qbit_managed_category_sections.setdefault(category, section) + if owner != section: + self.logger.warning( + "Category '%s' is managed by both '%s' and '%s'; " + "PlaceHolderArr will use '%s' seeding config", + category, + owner, + section, + owner, + ) self.logger.debug( "qBit instance 'default' manages categories: %s", ", ".join(managed_cats), @@ -7896,6 +8171,17 @@ def _validate_category_assignments(self): managed_cats = CONFIG.get(f"{section}.ManagedCategories", fallback=[]) if managed_cats: self.qbit_managed_categories.update(managed_cats) + for category in managed_cats: + owner = self.qbit_managed_category_sections.setdefault(category, section) + if owner != section: + self.logger.warning( + "Category '%s' is managed by both '%s' and '%s'; " + "PlaceHolderArr will use '%s' seeding config", + category, + owner, + section, + owner, + ) self.logger.debug( "qBit instance '%s' manages categories: %s", instance_name, @@ -7955,15 +8241,23 @@ def build_arr_instances(self): # Validate category assignments after all Arr instances are initialized self._validate_category_assignments() + # FreeSpaceManager monitors both Arr-managed and qBit-managed categories + all_monitored_categories = self.arr_categories | self.qbit_managed_categories if ( FREE_SPACE != "-1" and AUTO_PAUSE_RESUME and not QBIT_DISABLED - and len(self.arr_categories) > 0 + and len(all_monitored_categories) > 0 ): - managed_object = FreeSpaceManager(self.arr_categories, self) + managed_object = FreeSpaceManager(all_monitored_categories, self) self.managed_objects["FreeSpaceManager"] = managed_object for cat in self.special_categories: managed_object = PlaceHolderArr(cat, self) self.managed_objects[cat] = managed_object + # qBit-managed categories get the same torrent behaviour (recheck, missing files, + # stalled, etc.) via PlaceHolderArr when not already an Arr category. + for cat in self.qbit_managed_categories: + if cat not in self.managed_objects: + managed_object = PlaceHolderArr(cat, self) + self.managed_objects[cat] = managed_object return self diff --git a/qBitrr/auto_update.py b/qBitrr/auto_update.py index 6fc1e380d..bfeffe241 100644 --- a/qBitrr/auto_update.py +++ b/qBitrr/auto_update.py @@ -5,9 +5,10 @@ import subprocess import sys import threading +from collections.abc import Callable from datetime import datetime from pathlib import Path -from typing import Any, Callable +from typing import Any import requests from croniter import croniter @@ -288,7 +289,6 @@ def perform_self_update(logger: logging.Logger, target_version: str | None = Non # GIT INSTALLATION elif install_type == "git": repo_root = Path(__file__).resolve().parent.parent - repo_root / ".git" logger.debug("Git repository detected at %s", repo_root) if target_version: diff --git a/qBitrr/bundled_data.py b/qBitrr/bundled_data.py index 6af898500..89761e46a 100644 --- a/qBitrr/bundled_data.py +++ b/qBitrr/bundled_data.py @@ -4,4 +4,4 @@ "Licence can be found on:\n\nhttps://github.com/Feramance/qBitrr/blob/master/LICENSE" ) patched_version = f"{version}-{git_hash}" -tagged_version = f"{version}" +tagged_version = version diff --git a/qBitrr/config.py b/qBitrr/config.py index 9bc9362e0..f5f519279 100644 --- a/qBitrr/config.py +++ b/qBitrr/config.py @@ -11,6 +11,8 @@ from qBitrr.gen_config import MyConfig, _write_config_file, apply_config_migrations, generate_doc from qBitrr.home_path import APPDATA_FOLDER, HOME_PATH +CHANGE_ME_SENTINEL = "CHANGE_ME" + def process_flags() -> argparse.Namespace | bool: parser = argparse.ArgumentParser(description="An interface to interact with qBit and *arrs.") @@ -45,8 +47,6 @@ def process_flags() -> argparse.Namespace | bool: args = parser.parse_args() if args.gen_config: - from qBitrr.gen_config import _write_config_file - _write_config_file() return True elif args.license: @@ -97,7 +97,8 @@ def process_flags() -> argparse.Namespace | bool: ): # If file already exist or can't copy to APPDATA_FOLDER ignore the exception shutil.copy(CONFIG_PATH, CONFIG_FILE) COPIED_TO_NEW_DIR = True - CONFIG = MyConfig("./config.toml") + # Load from CONFIG_FILE after copy so we use the same path regardless of cwd + CONFIG = MyConfig(CONFIG_FILE if CONFIG_FILE.exists() else CONFIG_PATH.resolve()) if COPIED_TO_NEW_DIR is not None: # print(f"STARTING QBITRR | {CONFIG.path} |\n{CONFIG}") @@ -135,13 +136,13 @@ def process_flags() -> argparse.Namespace | bool: if FREE_SPACE != "-1" else None ) -NO_INTERNET_SLEEP_TIMER = ENVIRO_CONFIG.settings.no_internet_sleep_timer or CONFIG.get( +NO_INTERNET_SLEEP_TIMER = ENVIRO_CONFIG.settings.no_internet_sleep_timer or CONFIG.get_duration( "Settings.NoInternetSleepTimer", fallback=15 ) -LOOP_SLEEP_TIMER = ENVIRO_CONFIG.settings.loop_sleep_timer or CONFIG.get( +LOOP_SLEEP_TIMER = ENVIRO_CONFIG.settings.loop_sleep_timer or CONFIG.get_duration( "Settings.LoopSleepTimer", fallback=5 ) -SEARCH_LOOP_DELAY = ENVIRO_CONFIG.settings.search_loop_delay or CONFIG.get( +SEARCH_LOOP_DELAY = ENVIRO_CONFIG.settings.search_loop_delay or CONFIG.get_duration( "Settings.SearchLoopDelay", fallback=-1 ) AUTO_PAUSE_RESUME = ENVIRO_CONFIG.settings.auto_pause_resume or CONFIG.get( @@ -150,8 +151,9 @@ def process_flags() -> argparse.Namespace | bool: PING_URLS = ENVIRO_CONFIG.settings.ping_urls or CONFIG.get( "Settings.PingURLS", fallback=["one.one.one.one", "dns.google.com"] ) -IGNORE_TORRENTS_YOUNGER_THAN = ENVIRO_CONFIG.settings.ignore_torrents_younger_than or CONFIG.get( - "Settings.IgnoreTorrentsYoungerThan", fallback=180 +IGNORE_TORRENTS_YOUNGER_THAN = ( + ENVIRO_CONFIG.settings.ignore_torrents_younger_than + or CONFIG.get_duration("Settings.IgnoreTorrentsYoungerThan", fallback=180) ) @@ -176,7 +178,7 @@ def _has_any_qbit_section() -> bool: print("Exiting...") sys.exit(1) -if SEARCH_ONLY and QBIT_DISABLED is False: +if SEARCH_ONLY and not QBIT_DISABLED: QBIT_DISABLED = True print("QBITRR_OVERRIDES_SEARCH_ONLY is enabled, forcing qBitTorrent setting off") diff --git a/qBitrr/config_version.py b/qBitrr/config_version.py index 73f983b45..e22c18795 100644 --- a/qBitrr/config_version.py +++ b/qBitrr/config_version.py @@ -16,7 +16,7 @@ from qBitrr.gen_config import MyConfig # Current expected config version - updated automatically by bump2version -EXPECTED_CONFIG_VERSION = "5.9.1" +EXPECTED_CONFIG_VERSION = "5.9.2" # Legacy integer version → semver mapping _LEGACY_VERSION_MAP = { @@ -36,7 +36,7 @@ def _parse_version(v: str | int) -> Version: try: return Version(str(v)) except InvalidVersion: - logger.warning(f"Invalid version string: {v}, falling back to 0.0.1") + logger.warning("Invalid version string: %s, falling back to 0.0.1", v) return Version("0.0.1") @@ -57,7 +57,7 @@ def get_config_version(config: MyConfig) -> str: Version(str(version)) return str(version) except InvalidVersion: - logger.warning(f"Invalid ConfigVersion value: {version}, defaulting to 0.0.1") + logger.warning("Invalid ConfigVersion value: %s, defaulting to 0.0.1", version) return "0.0.1" @@ -75,7 +75,7 @@ def set_config_version(config: MyConfig, version: str) -> None: config.config["Settings"] = table() config.config["Settings"]["ConfigVersion"] = version - logger.info(f"Set ConfigVersion to {version}") + logger.info("Set ConfigVersion to %s", version) def validate_config_version(config: MyConfig) -> tuple[bool, str | None]: @@ -95,12 +95,12 @@ def validate_config_version(config: MyConfig) -> tuple[bool, str | None]: expected = _parse_version(EXPECTED_CONFIG_VERSION) if current == expected: - logger.debug(f"Config version matches expected: {EXPECTED_CONFIG_VERSION}") + logger.debug("Config version matches expected: %s", EXPECTED_CONFIG_VERSION) return True, None if current < expected: logger.info( - f"Config version {current} is older than expected {expected}, " "migration needed" + "Config version %s is older than expected %s, migration needed", current, expected ) return True, "migration_needed" @@ -125,7 +125,7 @@ def backup_config(config_path: Path) -> Path | None: Path to backup file, or None if backup failed """ if not config_path.exists(): - logger.warning(f"Config file not found for backup: {config_path}") + logger.warning("Config file not found for backup: %s", config_path) return None timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") @@ -135,10 +135,10 @@ def backup_config(config_path: Path) -> Path | None: import shutil shutil.copy2(config_path, backup_path) - logger.info(f"Created config backup: {backup_path}") + logger.info("Created config backup: %s", backup_path) return backup_path except Exception as e: - logger.error(f"Failed to create config backup: {e}") + logger.error("Failed to create config backup: %s", e) return None @@ -154,15 +154,15 @@ def restore_config_backup(backup_path: Path, config_path: Path) -> bool: True if restore succeeded, False otherwise """ if not backup_path.exists(): - logger.error(f"Backup file not found: {backup_path}") + logger.error("Backup file not found: %s", backup_path) return False try: import shutil shutil.copy2(backup_path, config_path) - logger.info(f"Restored config from backup: {backup_path}") + logger.info("Restored config from backup: %s", backup_path) return True except Exception as e: - logger.error(f"Failed to restore config from backup: {e}") + logger.error("Failed to restore config from backup: %s", e) return False diff --git a/qBitrr/database.py b/qBitrr/database.py index f666457b8..62f91dab0 100644 --- a/qBitrr/database.py +++ b/qBitrr/database.py @@ -3,12 +3,15 @@ from __future__ import annotations import logging +import os +import re +import sqlite3 from pathlib import Path -from peewee import SqliteDatabase +from peewee import OperationalError, SqliteDatabase from qBitrr.config import APPDATA_FOLDER -from qBitrr.db_lock import with_database_retry +from qBitrr.db_lock import database_lock, with_database_retry from qBitrr.tables import ( AlbumFilesModel, AlbumQueueModel, @@ -29,12 +32,130 @@ _db: SqliteDatabase | None = None +def _ensure_db_directory_writable(db_path: Path) -> None: + """ + Try to make the database directory and file writable (e.g. when running in + Docker with a mounted volume that had wrong permissions). Ignore errors. + """ + try: + os.chmod(db_path.parent, 0o755) + except (PermissionError, OSError): + pass + if db_path.exists(): + try: + os.chmod(db_path, 0o644) + except (PermissionError, OSError): + pass + for extra in (f"{db_path.name}-wal", f"{db_path.name}-shm"): + p = db_path.parent / extra + if p.exists(): + try: + os.chmod(p, 0o644) + except (PermissionError, OSError): + pass + + +def _offer_fresh_start(db_path: Path) -> bool: + """ + Delete corrupt database so a fresh one is created on connect. + Use only after repair has failed; causes loss of existing DB data. + + Returns: + True if all files were successfully removed, False otherwise. + """ + all_removed = True + for candidate in ( + db_path, + db_path.parent / f"{db_path.name}-wal", + db_path.parent / f"{db_path.name}-shm", + db_path.with_suffix(".db.temp-journal"), + ): + try: + candidate.unlink() + except FileNotFoundError: + continue + except Exception as e: + logger.error("Failed to delete corrupt database file %s: %s", candidate, e) + all_removed = False + if all_removed: + logger.warning( + "Deleted corrupt database and auxiliary files; " + "a fresh database will be created on connect.", + ) + else: + logger.critical( + "Could not fully remove corrupt database files. " + "The database may still be corrupt on next connection.", + ) + return all_removed + + +def _startup_integrity_check_and_repair(db_path: Path) -> bool: + """ + Run PRAGMA integrity_check on existing database; if not ok, try WAL checkpoint + then full repair. As a last resort, delete the database for a fresh start. + + Returns: + True if the database is healthy (repaired or fresh), False if corrupt + and could not be fixed or removed. + """ + if not db_path.exists(): + return True + + result = "unknown" + try: + with sqlite3.connect(str(db_path), timeout=10.0) as conn: + cursor = conn.cursor() + cursor.execute("PRAGMA integrity_check") + result = cursor.fetchone()[0] + if result == "ok": + return True + except Exception as e: + logger.warning("Startup integrity check failed (will attempt repair): %s", e) + + logger.warning( + "Database integrity check failed (result=%s). Attempting automatic repair...", + result, + ) + from qBitrr.db_recovery import checkpoint_wal, repair_database + + if checkpoint_wal(db_path, logger_override=logger): + try: + with sqlite3.connect(str(db_path), timeout=10.0) as conn: + cursor = conn.cursor() + cursor.execute("PRAGMA integrity_check") + result = cursor.fetchone()[0] + if result == "ok": + logger.info("WAL checkpoint resolved database issue - integrity ok") + return True + except Exception as e: + logger.debug("Integrity re-check after checkpoint failed: %s", e) + + logger.warning("Attempting full database repair (dump/restore)...") + try: + if repair_database(db_path, backup=True, logger_override=logger): + logger.info("Database repair successful - integrity verified") + return True + except Exception as e: + logger.critical( + "Automatic database repair failed: %s. " + "Corrupt database will be removed for a fresh start (data loss).", + e, + ) + + logger.warning("All repair attempts failed. Removing corrupt database for fresh start...") + if db_path.exists(): + return _offer_fresh_start(db_path) + return True + + def get_database() -> SqliteDatabase: """Get or create the global database instance.""" global _db if _db is None: db_path = Path(APPDATA_FOLDER) / "qbitrr.db" db_path.parent.mkdir(parents=True, exist_ok=True) + _ensure_db_directory_writable(db_path) _db = SqliteDatabase( str(db_path), @@ -51,34 +172,65 @@ def get_database() -> SqliteDatabase: timeout=15, ) - # Connect with retry logic - with_database_retry( - lambda: _db.connect(reuse_if_open=True), - logger=logger, - ) + if not _startup_integrity_check_and_repair(db_path): + logger.critical( + "Database is corrupt and could not be repaired or removed. " + "Please manually delete '%s' and restart.", + db_path, + ) - # Bind models to database - models = [ - MoviesFilesModel, - EpisodeFilesModel, - AlbumFilesModel, - SeriesFilesModel, - ArtistFilesModel, - TrackFilesModel, - MovieQueueModel, - EpisodeQueueModel, - AlbumQueueModel, - FilesQueued, - TorrentLibrary, - ] - _db.bind(models) - - # Create all tables - _db.create_tables(models, safe=True) - - # Run migrations - _migrate_arrinstance_field(models) - _create_arrinstance_indexes(_db, models) + with database_lock(): + with_database_retry( + lambda: _db.connect(reuse_if_open=True), + logger=logger, + ) + + # Bind models to database + models = [ + MoviesFilesModel, + EpisodeFilesModel, + AlbumFilesModel, + SeriesFilesModel, + ArtistFilesModel, + TrackFilesModel, + MovieQueueModel, + EpisodeQueueModel, + AlbumQueueModel, + FilesQueued, + TorrentLibrary, + ] + _db.bind(models) + + # Create all tables (and indexes); retry once if readonly (e.g. Docker volume perms) + def _create_tables_and_indexes() -> None: + _db.create_tables(models, safe=True) + _migrate_arrinstance_field(models) + _create_arrinstance_indexes(_db, models) + + try: + _create_tables_and_indexes() + except OperationalError as e: + if "readonly" in str(e).lower(): + logger.warning( + "Database is read-only (often due to volume permissions). " + "Attempting to fix permissions and retry..." + ) + _ensure_db_directory_writable(db_path) + try: + _create_tables_and_indexes() + except OperationalError as e2: + if "readonly" in str(e2).lower(): + logger.critical( + "Database directory or file is read-only. " + "Ensure the config volume is writable by the container user (e.g. set PUID/PGID in docker-compose). " + "On the host: chown -R PUID:PGID /path/to/config. " + "Alternatively remove the database directory and restart to recreate it (data loss)." + ) + _db.close() + _db = None + raise + else: + raise logger.info("Initialized single database: %s", db_path) @@ -129,25 +281,29 @@ def _create_arrinstance_indexes(db: SqliteDatabase, models: list) -> None: which is done on every WebUI page load. """ try: - cursor = db.cursor() - for model in models: - if hasattr(model, "ArrInstance"): - table_name = model._meta.table_name - index_name = f"idx_arrinstance_{table_name}" - - # Check if index already exists - cursor.execute( - "SELECT name FROM sqlite_master WHERE type='index' AND name=?", - (index_name,), - ) - if cursor.fetchone(): - continue # Index already exists - - # Create index - cursor.execute(f"CREATE INDEX {index_name} ON {table_name}(ArrInstance)") - logger.info("Created index: %s on %s.ArrInstance", index_name, table_name) + with db.atomic(): + cursor = db.cursor() + for model in models: + if hasattr(model, "ArrInstance"): + table_name = model._meta.table_name + index_name = f"idx_arrinstance_{table_name}" + + if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", index_name): + logger.warning("Skipping invalid index name: %s", index_name) + continue + if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", table_name): + logger.warning("Skipping invalid table name: %s", table_name) + continue + + cursor.execute( + "SELECT name FROM sqlite_master WHERE type='index' AND name=?", + (index_name,), + ) + if cursor.fetchone(): + continue - db.commit() + cursor.execute(f"CREATE INDEX {index_name} ON {table_name}(ArrInstance)") + logger.info("Created index: %s on %s.ArrInstance", index_name, table_name) except Exception as e: logger.error("Error creating ArrInstance indexes: %s", e) diff --git a/qBitrr/db_lock.py b/qBitrr/db_lock.py index 12c52f859..2c0deb926 100644 --- a/qBitrr/db_lock.py +++ b/qBitrr/db_lock.py @@ -2,9 +2,9 @@ import os import threading +from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path -from typing import Iterator from qBitrr.db_recovery import checkpoint_wal, repair_database from qBitrr.home_path import APPDATA_FOLDER @@ -382,15 +382,13 @@ def check_database_health(db_path: Path, logger=None) -> tuple[bool, str]: import sqlite3 try: - # Use a short timeout to avoid blocking conn = sqlite3.connect(str(db_path), timeout=5.0) - cursor = conn.cursor() - - # Quick integrity check (fast, catches major corruption) - cursor.execute("PRAGMA quick_check") - result = cursor.fetchone()[0] - - conn.close() + try: + cursor = conn.cursor() + cursor.execute("PRAGMA quick_check") + result = cursor.fetchone()[0] + finally: + conn.close() if result != "ok": error_msg = f"PRAGMA quick_check failed: {result}" diff --git a/qBitrr/db_recovery.py b/qBitrr/db_recovery.py index a5f38c8c5..3c4cbe1dc 100644 --- a/qBitrr/db_recovery.py +++ b/qBitrr/db_recovery.py @@ -34,14 +34,12 @@ def checkpoint_wal(db_path: Path, logger_override=None) -> bool: try: log.info("Starting WAL checkpoint for database: %s", db_path) conn = sqlite3.connect(str(db_path), timeout=10.0) - cursor = conn.cursor() - - # Force WAL checkpoint with TRUNCATE mode - # This checkpoints all frames and truncates the WAL file - cursor.execute("PRAGMA wal_checkpoint(TRUNCATE)") - result = cursor.fetchone() - - conn.close() + try: + cursor = conn.cursor() + cursor.execute("PRAGMA wal_checkpoint(TRUNCATE)") + result = cursor.fetchone() + finally: + conn.close() # Result is (busy, log_pages, checkpointed_pages) # If busy=0, checkpoint was fully successful @@ -73,7 +71,7 @@ def repair_database(db_path: Path, backup: bool = True, logger_override=None) -> This operation: 1. Creates a backup of the corrupted database - 2. Dumps all recoverable data to a temporary database + 2. Dumps all recoverable data to a temporary database (handles partial dumps) 3. Replaces the original with the repaired copy 4. Verifies integrity of the repaired database @@ -94,73 +92,93 @@ def repair_database(db_path: Path, backup: bool = True, logger_override=None) -> backup_path = db_path.with_suffix(".db.backup") temp_path = db_path.with_suffix(".db.temp") + source_conn = None + temp_conn = None try: - # Step 1: Backup original if backup: log.info("Creating backup: %s", backup_path) shutil.copy2(db_path, backup_path) - # Step 2: Dump recoverable data log.info("Dumping recoverable data from corrupted database...") source_conn = sqlite3.connect(str(db_path)) - temp_conn = sqlite3.connect(str(temp_path)) - # Dump schema and data + recovered_count = 0 skipped_rows = 0 - for line in source_conn.iterdump(): - try: - temp_conn.execute(line) - except sqlite3.Error as e: - # Log but continue - recover what we can - skipped_rows += 1 - log.debug("Skipping corrupted row during dump: %s", e) + dump_interrupted = False + try: + for line in source_conn.iterdump(): + try: + temp_conn.execute(line) + recovered_count += 1 + except sqlite3.Error as e: + skipped_rows += 1 + log.debug("Skipping corrupted row during dump: %s", e) + except Exception as e: + dump_interrupted = True + log.warning( + "iterdump stopped early due to corruption (%s recovered, %s skipped): %s", + recovered_count, + skipped_rows, + e, + ) + + temp_conn.commit() if skipped_rows > 0: log.warning("Skipped %s corrupted rows during dump", skipped_rows) - temp_conn.commit() - temp_conn.close() source_conn.close() + source_conn = None + temp_conn.close() + temp_conn = None - # Step 3: Replace original with repaired copy - log.info("Replacing database with repaired version...") - db_path.unlink() - shutil.move(str(temp_path), str(db_path)) + if recovered_count == 0: + raise DatabaseRecoveryError("No data could be recovered from corrupt database") - # Step 4: Verify integrity - log.info("Verifying integrity of repaired database...") - verify_conn = sqlite3.connect(str(db_path)) + verify_conn = sqlite3.connect(str(temp_path)) cursor = verify_conn.cursor() cursor.execute("PRAGMA integrity_check") result = cursor.fetchone()[0] verify_conn.close() if result != "ok": - raise DatabaseRecoveryError(f"Repair verification failed: {result}") + raise DatabaseRecoveryError(f"Repaired database failed integrity check: {result}") - log.info("Database repair successful!") + log.info("Replacing database with repaired version...") + for stale in ( + db_path, + db_path.parent / f"{db_path.name}-wal", + db_path.parent / f"{db_path.name}-shm", + ): + try: + stale.unlink() + except FileNotFoundError: + pass + shutil.move(str(temp_path), str(db_path)) + + status = "with partial data (dump interrupted)" if dump_interrupted else "fully" + log.info( + "Database repair successful (%s) - %s statements recovered", status, recovered_count + ) return True except Exception as e: log.error("Database repair failed: %s", e) - # Attempt to restore backup - if backup and backup_path.exists(): - log.warning("Restoring from backup...") - try: - shutil.copy2(backup_path, db_path) - log.info("Backup restored successfully") - except Exception as restore_error: - log.error("Failed to restore backup: %s", restore_error) + for conn in (source_conn, temp_conn): + if conn is not None: + try: + conn.close() + except Exception: + pass - # Cleanup temp files if temp_path.exists(): try: temp_path.unlink() except Exception: - pass # Best effort cleanup + pass raise DatabaseRecoveryError(f"Repair failed: {e}") from e @@ -187,9 +205,10 @@ def vacuum_database(db_path: Path, logger_override=None) -> bool: try: log.info("Running VACUUM on database: %s", db_path) conn = sqlite3.connect(str(db_path), timeout=30.0) - - conn.execute("VACUUM") - conn.close() + try: + conn.execute("VACUUM") + finally: + conn.close() log.info("VACUUM completed successfully") return True diff --git a/qBitrr/duration_config.py b/qBitrr/duration_config.py new file mode 100644 index 000000000..819088418 --- /dev/null +++ b/qBitrr/duration_config.py @@ -0,0 +1,98 @@ +""" +Parse duration values from config: integers (legacy) or suffixed strings (e.g. "1w", "60m"). + +Used by MyConfig.get_duration() so time-related keys can be stored as human-readable +strings (s/m/h/d/w/M) while remaining backwards compatible with plain numbers. +""" + +from __future__ import annotations + +import re +from typing import Any + +# Multipliers for suffix -> seconds +SUFFIX_TO_SECONDS = { + "s": 1, + "m": 60, + "h": 3600, + "d": 86400, + "w": 604800, + "M": 2592000, # 30 days +} + +# Same suffixes -> minutes (for keys that use minutes as base unit) +SUFFIX_TO_MINUTES = { + "s": 1 / 60, + "m": 1, + "h": 60, + "d": 1440, + "w": 10080, + "M": 43200, # 30 days +} + +_DURATION_PATTERN = re.compile(r"^\s*(-?\d+)\s*([sSmMhHdDwWM]?)\s*$") + + +def parse_duration_to_seconds(value: Any, fallback: int = -1) -> int: + """ + Parse a config value to seconds. Accepts int (return as-is) or str with optional suffix. + + Suffixes: s=seconds, m=minutes, h=hours, d=days, w=weeks, M=months (30 days). + Plain number or unsuffixed string is treated as seconds (backwards compatibility). + -1 / "-1" is allowed for "disabled" semantics. + """ + if value is None: + return fallback + if isinstance(value, int): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + s = str(value).strip() + if not s: + return fallback + m = _DURATION_PATTERN.match(s) + if not m: + try: + return int(float(s)) + except (ValueError, TypeError): + return fallback + num = int(m.group(1)) + raw_suffix = (m.group(2) or "s").strip() + # Uppercase M = month (30 days); lowercase m = minute + if raw_suffix == "M": + mult = SUFFIX_TO_SECONDS["M"] + else: + mult = SUFFIX_TO_SECONDS.get(raw_suffix.lower(), 1) + return num * mult + + +def parse_duration_to_minutes(value: Any, fallback: int = -1) -> int: + """ + Parse a config value to minutes. Same rules as parse_duration_to_seconds but + returns minutes (for keys like StalledDelay, RssSyncTimer). + """ + if value is None: + return fallback + if isinstance(value, int): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + s = str(value).strip() + if not s: + return fallback + m = _DURATION_PATTERN.match(s) + if not m: + try: + return int(float(s)) + except (ValueError, TypeError): + return fallback + num = int(m.group(1)) + raw_suffix = (m.group(2) or "m").strip() + if raw_suffix == "M": + mult = SUFFIX_TO_MINUTES["M"] + else: + mult = SUFFIX_TO_MINUTES.get(raw_suffix.lower(), 1) + minutes = num * mult + if 0 < minutes < 1: + return 1 + return int(minutes) diff --git a/qBitrr/errors.py b/qBitrr/errors.py index d105ba997..62a212342 100644 --- a/qBitrr/errors.py +++ b/qBitrr/errors.py @@ -23,18 +23,35 @@ class RequireConfigValue(qBitManagerError): def __init__(self, config_class: str, config_key: str): self.message = f"Config key '{config_key}' in '{config_class}' requires a value." + super().__init__(self.message) class NoConnectionrException(qBitManagerError): - def __init__(self, message: str, type: str = "delay"): + """Raised when a connection to a service cannot be established. + + The ``error_type`` attribute (``"delay"`` or ``"no_internet"``) controls + whether the caller should retry after a short delay or treat the outage + as a prolonged connectivity failure. + """ + + def __init__(self, message: str, error_type: str = "delay"): self.message = message - self.type = type + self.error_type = error_type + super().__init__(message) class DelayLoopException(qBitManagerError): - def __init__(self, length: int, type: str): - self.type = type + """Raised to signal that the current event-loop iteration should pause. + + This is a control-flow exception, not an error. ``length`` is the + requested sleep duration in seconds and ``error_type`` indicates the + reason (e.g. ``"delay"`` or ``"no_internet"``). + """ + + def __init__(self, length: int, error_type: str): + self.error_type = error_type self.length = length + super().__init__(f"Delay loop: {error_type}") class RestartLoopException(ArrManagerException): diff --git a/qBitrr/ffprobe.py b/qBitrr/ffprobe.py index cdd83e58f..07557afa8 100644 --- a/qBitrr/ffprobe.py +++ b/qBitrr/ffprobe.py @@ -67,16 +67,18 @@ def update(self): self.logger.debug("Updating local version of FFprobe: %s", upstream_version) self.version_file.write_text(json.dumps({"version": upstream_version})) try: - os.chmod(self.probe_path, 0o777) + os.chmod(self.probe_path, 0o755) self.logger.debug("Successfully changed permissions for ffprobe") except Exception as e: self.logger.debug("Failed to change permissions for ffprobe, %s", e) def download_and_extract(self, ffprobe_url): - r = requests.get(ffprobe_url) - z = zipfile.ZipFile(io.BytesIO(r.content)) - self.logger.debug("Extracting downloaded FFprobe to: %s", FF_PROBE.parent) - z.extract(member=self.probe_path.name, path=FF_PROBE.parent) + r = requests.get(ffprobe_url, timeout=(10, 300)) + r.raise_for_status() + with zipfile.ZipFile(io.BytesIO(r.content)) as z: + self.logger.debug("Extracting downloaded FFprobe to: %s", FF_PROBE.parent) + z.extract(member=self.probe_path.name, path=FF_PROBE.parent) + r.close() def get_arch(self): part1 = None diff --git a/qBitrr/gen_config.py b/qBitrr/gen_config.py index 74c03e57f..d2972295c 100755 --- a/qBitrr/gen_config.py +++ b/qBitrr/gen_config.py @@ -8,12 +8,18 @@ from tomlkit.items import Table from tomlkit.toml_document import TOMLDocument +from qBitrr.duration_config import parse_duration_to_minutes, parse_duration_to_seconds from qBitrr.env_config import ENVIRO_CONFIG from qBitrr.home_path import APPDATA_FOLDER, HOME_PATH T = TypeVar("T") +def _default(value, fallback): + """Return value if not None, otherwise fallback. Unlike ``or``, preserves falsy values like False and 0.""" + return value if value is not None else fallback + + def _add_web_settings_section(config: TOMLDocument): web_settings = table() _gen_default_line( @@ -102,67 +108,70 @@ def _add_settings_section(config: TOMLDocument): settings, "Level of logging; One of CRITICAL, ERROR, WARNING, NOTICE, INFO, DEBUG, TRACE", "ConsoleLevel", - ENVIRO_CONFIG.settings.console_level or "INFO", + _default(ENVIRO_CONFIG.settings.console_level, "INFO"), ) _gen_default_line( - settings, "Enable logging to files", "Logging", ENVIRO_CONFIG.settings.logging or True + settings, + "Enable logging to files", + "Logging", + _default(ENVIRO_CONFIG.settings.logging, True), ) _gen_default_line( settings, "Folder where your completed downloads are put into. Can be found in qBitTorrent -> Options -> Downloads -> Default Save Path (Please note, replace all '\\' with '/')", "CompletedDownloadFolder", - ENVIRO_CONFIG.settings.completed_download_folder or "CHANGE_ME", + _default(ENVIRO_CONFIG.settings.completed_download_folder, "CHANGE_ME"), ) _gen_default_line( settings, "The desired amount of free space in the downloads directory [K=kilobytes, M=megabytes, G=gigabytes, T=terabytes] (set to -1 to disable, this bypasses AutoPauseResume)", "FreeSpace", - ENVIRO_CONFIG.settings.free_space or "-1", + _default(ENVIRO_CONFIG.settings.free_space, "-1"), ) _gen_default_line( settings, "Folder where the free space handler will check for free space (Please note, replace all '' with '/')", "FreeSpaceFolder", - ENVIRO_CONFIG.settings.free_space_folder or "CHANGE_ME", + _default(ENVIRO_CONFIG.settings.free_space_folder, "CHANGE_ME"), ) _gen_default_line( settings, "Enable automation of pausing and resuming torrents as needed (Required enabled for the FreeSpace logic to function)", "AutoPauseResume", - ENVIRO_CONFIG.settings.auto_pause_resume or True, + _default(ENVIRO_CONFIG.settings.auto_pause_resume, True), ) _gen_default_line( settings, "Time to sleep for if there is no internet (in seconds: 600 = 10 Minutes)", "NoInternetSleepTimer", - ENVIRO_CONFIG.settings.no_internet_sleep_timer or 15, + _default(ENVIRO_CONFIG.settings.no_internet_sleep_timer, 15), ) _gen_default_line( settings, "Time to sleep between reprocessing torrents (in seconds: 600 = 10 Minutes)", "LoopSleepTimer", - ENVIRO_CONFIG.settings.loop_sleep_timer or 5, + _default(ENVIRO_CONFIG.settings.loop_sleep_timer, 5), ) _gen_default_line( settings, "Time to sleep between posting search commands (in seconds: 600 = 10 Minutes)", "SearchLoopDelay", - ENVIRO_CONFIG.settings.search_loop_delay or -1, + _default(ENVIRO_CONFIG.settings.search_loop_delay, -1), ) _gen_default_line( settings, "Add torrents to this category to mark them as failed", "FailedCategory", - ENVIRO_CONFIG.settings.failed_category or "failed", + _default(ENVIRO_CONFIG.settings.failed_category, "failed"), ) _gen_default_line( settings, "Add torrents to this category to trigger them to be rechecked properly", "RecheckCategory", - ENVIRO_CONFIG.settings.recheck_category or "recheck", + _default(ENVIRO_CONFIG.settings.recheck_category, "recheck"), ) _gen_default_line( - settings, "Tagless operation", "Tagless", ENVIRO_CONFIG.settings.tagless or False + settings, "Tagless operation", "Tagless", _default(ENVIRO_CONFIG.settings.tagless, False) ) _gen_default_line( settings, @@ -171,7 +180,7 @@ def _add_settings_section(config: TOMLDocument): "Only applicable to Re-check and failed categories", ], "IgnoreTorrentsYoungerThan", - ENVIRO_CONFIG.settings.ignore_torrents_younger_than or 180, + _default(ENVIRO_CONFIG.settings.ignore_torrents_younger_than, 180), ) _gen_default_line( settings, @@ -180,7 +189,7 @@ def _add_settings_section(config: TOMLDocument): "These will be pinged a **LOT** make sure the service is okay with you sending all the continuous pings.", ], "PingURLS", - ENVIRO_CONFIG.settings.ping_urls or ["one.one.one.one", "dns.google.com"], + _default(ENVIRO_CONFIG.settings.ping_urls, ["one.one.one.one", "dns.google.com"]), ) _gen_default_line( settings, @@ -219,7 +228,7 @@ def _add_settings_section(config: TOMLDocument): "Default is weekly Sunday at 03:00 (0 3 * * 0).", ], "AutoUpdateCron", - ENVIRO_CONFIG.settings.auto_update_cron or "0 3 * * 0", + _default(ENVIRO_CONFIG.settings.auto_update_cron, "0 3 * * 0"), ) _gen_default_line( settings, @@ -273,25 +282,25 @@ def _add_qbit_section(config: TOMLDocument): qbit, 'qbittorrent WebUI URL/IP - Can be found in Options > Web UI (called "IP Address")', "Host", - ENVIRO_CONFIG.qbit.host or "CHANGE_ME", + _default(ENVIRO_CONFIG.qbit.host, "CHANGE_ME"), ) _gen_default_line( qbit, 'qbittorrent WebUI Port - Can be found in Options > Web UI (called "Port" on top right corner of the window)', "Port", - ENVIRO_CONFIG.qbit.port or 8080, + _default(ENVIRO_CONFIG.qbit.port, 8080), ) _gen_default_line( qbit, "qbittorrent WebUI Authentication - Can be found in Options > Web UI > Authentication", "UserName", - ENVIRO_CONFIG.qbit.username or "CHANGE_ME", + _default(ENVIRO_CONFIG.qbit.username, "CHANGE_ME"), ) _gen_default_line( qbit, 'If you set "Bypass authentication on localhost or whitelisted IPs" remove this field.', "Password", - ENVIRO_CONFIG.qbit.password or "CHANGE_ME", + _default(ENVIRO_CONFIG.qbit.password, "CHANGE_ME"), ) _gen_default_line( qbit, @@ -345,9 +354,11 @@ def _add_qbit_section(config: TOMLDocument): ) _gen_default_line( category_seeding, - "Enable Hit and Run protection for managed category torrents", + [ + "Hit and Run mode: and = require both ratio and time; or = either clears; disabled = no HnR.", + ], "HitAndRunMode", - False, + "disabled", ) _gen_default_line( category_seeding, @@ -379,6 +390,18 @@ def _add_qbit_section(config: TOMLDocument): "TrackerUpdateBuffer", 0, ) + _gen_default_line( + category_seeding, + "Maximum time stalled downloads can sit before removal, in minutes (-1 = disabled, 0 = infinite).", + "StalledDelay", + -1, + ) + _gen_default_line( + category_seeding, + "Ignore torrents younger than this (seconds). Stalled removal also requires last_activity older than this.", + "IgnoreTorrentsYoungerThan", + 180, + ) _gen_qbit_tracker_tables(qbit) qbit.add("CategorySeeding", category_seeding) @@ -997,6 +1020,18 @@ def _value_error(self, err, arg1): def get(self, section: str, fallback: Any = None) -> T: return self._deep_get(section, default=fallback) + def get_duration(self, dotted_key: str, fallback: int = -1, unit: str = "seconds") -> int: + """ + Get a time value in seconds or minutes. Accepts int or suffixed string (e.g. "1w", "60m"). + Plain numbers are treated as the key's base unit (seconds or minutes). + """ + raw = self._deep_get(dotted_key, default=fallback) + if raw is ... or raw is None: + return fallback + if unit == "minutes": + return parse_duration_to_minutes(raw, fallback) + return parse_duration_to_seconds(raw, fallback) + def get_or_raise(self, section: str) -> T: if (r := self._deep_get(section, default=KeyError)) is KeyError: raise KeyError(f"{section} does not exist") @@ -1181,12 +1216,12 @@ def _migrate_quality_profile_mappings(config: MyConfig) -> bool: inline_mappings.update(mappings) config.config[str(key)]["EntrySearch"]["QualityProfileMappings"] = inline_mappings changes_made = True - logger.info(f"Migrated {key} to QualityProfileMappings: {mappings}") + logger.info("Migrated %s to QualityProfileMappings: %s", key, mappings) # Remove old format del config.config[str(key)]["EntrySearch"]["MainQualityProfile"] del config.config[str(key)]["EntrySearch"]["TempQualityProfile"] - logger.debug(f"Removed legacy profile lists from {key}") + logger.debug("Removed legacy profile lists from %s", key) return changes_made @@ -1234,7 +1269,7 @@ def _migrate_qbit_category_settings(config: MyConfig) -> bool: seeding["MaxUploadRatio"] = -1 seeding["MaxSeedingTime"] = -1 seeding["RemoveTorrent"] = -1 - seeding["HitAndRunMode"] = False + seeding["HitAndRunMode"] = "disabled" seeding["MinSeedRatio"] = 1.0 seeding["MinSeedingTimeDays"] = 0 seeding["HitAndRunMinimumDownloadPercent"] = 10 @@ -1251,7 +1286,7 @@ def _migrate_qbit_category_settings(config: MyConfig) -> bool: if "ManagedCategories" not in qbit_section: qbit_section["ManagedCategories"] = [] changes_made = True - logger.info(f"Added ManagedCategories = [] to [{section}]") + logger.info("Added ManagedCategories = [] to [%s]", section) if "CategorySeeding" not in qbit_section: seeding = table() @@ -1260,7 +1295,7 @@ def _migrate_qbit_category_settings(config: MyConfig) -> bool: seeding["MaxUploadRatio"] = -1 seeding["MaxSeedingTime"] = -1 seeding["RemoveTorrent"] = -1 - seeding["HitAndRunMode"] = False + seeding["HitAndRunMode"] = "disabled" seeding["MinSeedRatio"] = 1.0 seeding["MinSeedingTimeDays"] = 0 seeding["HitAndRunMinimumDownloadPercent"] = 10 @@ -1268,7 +1303,7 @@ def _migrate_qbit_category_settings(config: MyConfig) -> bool: seeding["TrackerUpdateBuffer"] = 0 qbit_section["CategorySeeding"] = seeding changes_made = True - logger.info(f"Added CategorySeeding configuration to [{section}]") + logger.info("Added CategorySeeding configuration to [%s]", section) if changes_made: print("Migration v3→v4: Added qBit category management settings") @@ -1301,7 +1336,7 @@ def _migrate_hnr_settings(config: MyConfig) -> bool: changes_made = False arr_types = ["Radarr", "Sonarr", "Lidarr", "Animarr"] hnr_seeding_defaults = { - "HitAndRunMode": False, + "HitAndRunMode": "disabled", "MinSeedRatio": 1.0, "MinSeedingTimeDays": 0, "HitAndRunMinimumDownloadPercent": 10, @@ -1438,6 +1473,101 @@ def _migrate_hnr_settings(config: MyConfig) -> bool: return changes_made +def _migrate_hnr_single_key(config: MyConfig) -> bool: + """ + Consolidate HitAndRunMode (bool) + HitAndRunClearMode (string) into single HitAndRunMode string. + + Runs when ConfigVersion < "5.9.2" or when already 5.9.2 but HitAndRunClearMode is present. + Sets HitAndRunMode = "and" | "or" | "disabled", removes HitAndRunClearMode. + Returns True if any change was made. + """ + from qBitrr.config_version import _parse_version, get_config_version + + current_version = _parse_version(get_config_version(config)) + valid_modes = ("and", "or", "disabled") + + def _has_hnr_clear_mode_anywhere() -> bool: + for key in list(config.config.keys()): + section = config.config.get(str(key)) + if not isinstance(section, dict): + continue + if "CategorySeeding" in section: + cs = section["CategorySeeding"] + if isinstance(cs, dict) and "HitAndRunClearMode" in cs: + return True + if "Trackers" in section and isinstance(section["Trackers"], list): + for t in section["Trackers"]: + if isinstance(t, dict) and "HitAndRunClearMode" in t: + return True + if "Torrent" in section and isinstance(section["Torrent"], dict): + tt = section["Torrent"] + if "Trackers" in tt and isinstance(tt["Trackers"], list): + for t in tt["Trackers"]: + if isinstance(t, dict) and "HitAndRunClearMode" in t: + return True + return False + + if current_version >= _parse_version("5.9.2") and not _has_hnr_clear_mode_anywhere(): + return False + + def _resolve(d: dict) -> str: + raw_clear = d.get("HitAndRunClearMode") + if isinstance(raw_clear, str) and raw_clear.strip().lower() in valid_modes: + return raw_clear.strip().lower() + raw_mode = d.get("HitAndRunMode") + if isinstance(raw_mode, str) and raw_mode.strip().lower() in valid_modes: + return raw_mode.strip().lower() + # Legacy boolean HitAndRunMode + if raw_mode is True: + return "and" + return "disabled" + + changes_made = False + for key in list(config.config.keys()): + section = config.config[str(key)] + if not isinstance(section, dict): + continue + if "CategorySeeding" in section: + cs = section["CategorySeeding"] + if isinstance(cs, dict): + had_clear = "HitAndRunClearMode" in cs + had_bool = isinstance(cs.get("HitAndRunMode"), bool) + resolved = _resolve(cs) + cs["HitAndRunMode"] = resolved + if had_clear: + del cs["HitAndRunClearMode"] + if had_clear or had_bool: + changes_made = True + if "Trackers" in section and isinstance(section["Trackers"], list): + for tracker in section["Trackers"]: + if isinstance(tracker, dict): + had_clear = "HitAndRunClearMode" in tracker + had_bool = isinstance(tracker.get("HitAndRunMode"), bool) + resolved = _resolve(tracker) + tracker["HitAndRunMode"] = resolved + if had_clear: + del tracker["HitAndRunClearMode"] + if had_clear or had_bool: + changes_made = True + if "Torrent" in section and isinstance(section["Torrent"], dict): + tt = section["Torrent"] + if "Trackers" in tt and isinstance(tt["Trackers"], list): + for tracker in tt["Trackers"]: + if isinstance(tracker, dict): + had_clear = "HitAndRunClearMode" in tracker + had_bool = isinstance(tracker.get("HitAndRunMode"), bool) + resolved = _resolve(tracker) + tracker["HitAndRunMode"] = resolved + if had_clear: + del tracker["HitAndRunClearMode"] + if had_clear or had_bool: + changes_made = True + + if changes_made: + print("Migration 5.9.x→5.9.2: Consolidated HitAndRunMode to single key (and/or/disabled)") + return changes_made + + def _normalize_theme_value(value: Any) -> str: """ Normalize theme value to always be 'Light' or 'Dark' (case insensitive input). @@ -1516,7 +1646,7 @@ def ensure_value(config_section: str, key: str, default_value: Any) -> bool: ("FailedCategory", "failed"), ("RecheckCategory", "recheck"), ("Tagless", False), - ("IgnoreTorrentsYoungerThan", 600), + ("IgnoreTorrentsYoungerThan", 180), ("PingURLS", ["one.one.one.one", "dns.google.com"]), ("FFprobeAutoUpdate", True), ("AutoUpdateEnabled", False), @@ -1609,7 +1739,7 @@ def ensure_value(config_section: str, key: str, default_value: Any) -> bool: # Validate HnR fields on CategorySeeding and Tracker sections hnr_category_defaults = { - "HitAndRunMode": False, + "HitAndRunMode": "disabled", "MinSeedRatio": 1.0, "MinSeedingTimeDays": 0, "HitAndRunMinimumDownloadPercent": 10, @@ -1617,7 +1747,7 @@ def ensure_value(config_section: str, key: str, default_value: Any) -> bool: "TrackerUpdateBuffer": 0, } hnr_tracker_defaults = { - "HitAndRunMode": False, + "HitAndRunMode": "disabled", "MinSeedRatio": 1.0, "MinSeedingTimeDays": 0, "HitAndRunMinimumDownloadPercent": 10, @@ -1722,6 +1852,10 @@ def apply_config_migrations(config: MyConfig) -> None: if _migrate_hnr_settings(config): changes_made = True + # Consolidate HitAndRunMode to single key and/or/disabled (< 5.9.2 or 5.9.2 with ClearMode) + if _migrate_hnr_single_key(config): + changes_made = True + # Validate and fill config (this also ensures ConfigVersion field exists) if _validate_and_fill_config(config): changes_made = True diff --git a/qBitrr/home_path.py b/qBitrr/home_path.py index 3cff3147e..e6e98a816 100644 --- a/qBitrr/home_path.py +++ b/qBitrr/home_path.py @@ -20,4 +20,7 @@ APPDATA_FOLDER = HOME_PATH.joinpath("qBitManager") APPDATA_FOLDER.mkdir(parents=True, exist_ok=True) -APPDATA_FOLDER.chmod(mode=0o777) +try: + APPDATA_FOLDER.chmod(mode=0o755) +except (PermissionError, OSError): + pass # Mounted volumes (e.g. Docker /config) may not allow chmod diff --git a/qBitrr/logger.py b/qBitrr/logger.py index eb28b7b20..b2d6b027d 100644 --- a/qBitrr/logger.py +++ b/qBitrr/logger.py @@ -126,7 +126,10 @@ def run_logs(logger: Logger, _name: str = None) -> None: if ALL_LOGS_HANDLER is None: logs_folder = HOME_PATH.joinpath("logs") logs_folder.mkdir(parents=True, exist_ok=True) - logs_folder.chmod(mode=0o777) + try: + logs_folder.chmod(mode=0o755) + except (PermissionError, OSError): + pass # Mounted volumes (e.g. Docker /config) may not allow chmod all_logfile = logs_folder.joinpath("All.log") # Rotate old All.log if it exists if all_logfile.exists(): @@ -168,7 +171,10 @@ def run_logs(logger: Logger, _name: str = None) -> None: if _name: logs_folder = HOME_PATH.joinpath("logs") logs_folder.mkdir(parents=True, exist_ok=True) - logs_folder.chmod(mode=0o777) + try: + logs_folder.chmod(mode=0o755) + except (PermissionError, OSError): + pass # Mounted volumes (e.g. Docker /config) may not allow chmod logfile = logs_folder.joinpath(_name + ".log") if pathlib.Path(logfile).is_file(): logold = logs_folder.joinpath(_name + ".log.old") diff --git a/qBitrr/main.py b/qBitrr/main.py index 3e0b52087..cca8ae789 100644 --- a/qBitrr/main.py +++ b/qBitrr/main.py @@ -5,6 +5,7 @@ import glob import logging import os +import re import signal import sys import time @@ -43,16 +44,13 @@ if CONFIG_EXISTS: from qBitrr.arss import ArrManager else: - sys.exit(0) + print("Configuration not found. Please create a config file and restart.") + sys.exit(1) logger = logging.getLogger("qBitrr") run_logs(logger, "Main") -def _mask_secret(value: str | None) -> str: - return "[redacted]" if value else "" - - def _delete_all_databases() -> None: """ Delete old per-instance database files from the APPDATA_FOLDER on startup. @@ -101,12 +99,14 @@ def __init__(self): self.qBit_Password = CONFIG.get("qBit.Password", fallback=None) self.logger = logging.getLogger(f"qBitrr.{self._name}") run_logs(self.logger, self._name) + _masked_pwd = "[redacted]" if self.qBit_Password else "" + _masked_user = "[redacted]" if self.qBit_UserName else "" self.logger.debug( "qBitTorrent Config: Host: %s Port: %s, Username: %s, Password: %s", self.qBit_Host, self.qBit_Port, - self.qBit_UserName, - _mask_secret(self.qBit_Password), + _masked_user, + _masked_pwd, ) self._validated_version = False self.current_qbit_version = None @@ -142,8 +142,12 @@ def __init__(self): self._pending_spawns: list[tuple] = [] # (arr_instance, meta) tuples to retry self.auto_restart_enabled = CONFIG.get("Settings.AutoRestartProcesses", fallback=True) self.max_process_restarts = CONFIG.get("Settings.MaxProcessRestarts", fallback=5) - self.process_restart_window = CONFIG.get("Settings.ProcessRestartWindow", fallback=300) - self.process_restart_delay = CONFIG.get("Settings.ProcessRestartDelay", fallback=5) + self.process_restart_window = CONFIG.get_duration( + "Settings.ProcessRestartWindow", fallback=300 + ) + self.process_restart_delay = CONFIG.get_duration( + "Settings.ProcessRestartDelay", fallback=5 + ) # Start WebUI immediately, before any blocking network calls try: web_port = int(CONFIG.get("WebUI.Port", fallback=6969) or 6969) @@ -278,10 +282,10 @@ def _restart(): # Force kill any remaining child processes for proc in list(self.child_processes): - with contextlib.suppress(Exception): - proc.kill() with contextlib.suppress(Exception): proc.terminate() + with contextlib.suppress(Exception): + proc.kill() # Close database connections explicitly try: @@ -330,7 +334,12 @@ def _restart(): def _prepare_arr_processes(self, arr, timeout_seconds: int = 30) -> None: timeout = max( - 1, int(CONFIG.get("Settings.ProcessSpawnTimeoutSeconds", fallback=timeout_seconds)) + 1, + int( + CONFIG.get_duration( + "Settings.ProcessSpawnTimeoutSeconds", fallback=timeout_seconds + ) + ), ) result_queue: SimpleQueue = SimpleQueue() @@ -560,21 +569,31 @@ def _init_instance(self, section_name: str, instance_name: str) -> None: "RemoveTorrent", ] for key in seeding_keys: - value = CONFIG.get(f"{section_name}.CategorySeeding.{key}", fallback=-1) + if key == "MaxSeedingTime": + value = CONFIG.get_duration( + f"{section_name}.CategorySeeding.{key}", fallback=-1 + ) + else: + value = CONFIG.get(f"{section_name}.CategorySeeding.{key}", fallback=-1) default_seeding[key] = value # Load HnR protection settings hnr_keys = { - "HitAndRunMode": False, + "HitAndRunMode": "disabled", "MinSeedRatio": 1.0, "MinSeedingTimeDays": 0, "HitAndRunPartialSeedRatio": 1.0, "TrackerUpdateBuffer": 0, } for key, fallback in hnr_keys.items(): - default_seeding[key] = CONFIG.get( - f"{section_name}.CategorySeeding.{key}", fallback=fallback - ) + if key == "TrackerUpdateBuffer": + default_seeding[key] = CONFIG.get_duration( + f"{section_name}.CategorySeeding.{key}", fallback=fallback + ) + else: + default_seeding[key] = CONFIG.get( + f"{section_name}.CategorySeeding.{key}", fallback=fallback + ) # Load per-category overrides category_overrides = {} @@ -587,12 +606,23 @@ def _init_instance(self, section_name: str, instance_name: str) -> None: # Load qBit-level shared trackers instance_trackers = CONFIG.get(f"{section_name}.Trackers", fallback=[]) + # Stalled handling for qBit-managed categories (same semantics as Arr) + stalled_delay = CONFIG.get_duration( + f"{section_name}.CategorySeeding.StalledDelay", fallback=-1, unit="minutes" + ) + ignore_younger = CONFIG.get_duration( + f"{section_name}.CategorySeeding.IgnoreTorrentsYoungerThan", + fallback=CONFIG.get_duration("Settings.IgnoreTorrentsYoungerThan", fallback=180), + ) + # Store config for later initialization self.qbit_category_configs[instance_name] = { "managed_categories": managed_categories, "default_seeding": default_seeding, "category_overrides": category_overrides, "trackers": instance_trackers, + "stalled_delay": stalled_delay, + "ignore_torrents_younger_than": ignore_younger, } self.logger.debug( "Loaded qBit category config for '%s': %d managed categories", @@ -696,7 +726,7 @@ def _reload_qbit_category_configs(self) -> None: "RemoveTorrent", ] hnr_keys = { - "HitAndRunMode": False, + "HitAndRunMode": "disabled", "MinSeedRatio": 1.0, "MinSeedingTimeDays": 0, "HitAndRunPartialSeedRatio": 1.0, @@ -709,13 +739,23 @@ def _load_category_config(section_name: str, instance_name: str): return default_seeding = {} for key in seeding_keys: - default_seeding[key] = CONFIG.get( - f"{section_name}.CategorySeeding.{key}", fallback=-1 - ) + if key == "MaxSeedingTime": + default_seeding[key] = CONFIG.get_duration( + f"{section_name}.CategorySeeding.{key}", fallback=-1 + ) + else: + default_seeding[key] = CONFIG.get( + f"{section_name}.CategorySeeding.{key}", fallback=-1 + ) for key, fallback in hnr_keys.items(): - default_seeding[key] = CONFIG.get( - f"{section_name}.CategorySeeding.{key}", fallback=fallback - ) + if key == "TrackerUpdateBuffer": + default_seeding[key] = CONFIG.get_duration( + f"{section_name}.CategorySeeding.{key}", fallback=fallback + ) + else: + default_seeding[key] = CONFIG.get( + f"{section_name}.CategorySeeding.{key}", fallback=fallback + ) category_overrides = {} for cat_config in CONFIG.get( f"{section_name}.CategorySeeding.Categories", fallback=[] @@ -723,11 +763,20 @@ def _load_category_config(section_name: str, instance_name: str): if isinstance(cat_config, dict) and "Name" in cat_config: category_overrides[cat_config["Name"]] = cat_config trackers = CONFIG.get(f"{section_name}.Trackers", fallback=[]) + stalled_delay = CONFIG.get_duration( + f"{section_name}.CategorySeeding.StalledDelay", fallback=-1, unit="minutes" + ) + ignore_younger = CONFIG.get_duration( + f"{section_name}.CategorySeeding.IgnoreTorrentsYoungerThan", + fallback=CONFIG.get_duration("Settings.IgnoreTorrentsYoungerThan", fallback=180), + ) self.qbit_category_configs[instance_name] = { "managed_categories": managed_categories, "default_seeding": default_seeding, "category_overrides": category_overrides, "trackers": trackers, + "stalled_delay": stalled_delay, + "ignore_torrents_younger_than": ignore_younger, } for section in CONFIG.sections(): @@ -1269,7 +1318,7 @@ def _restart_process( """ category = meta.get("category", "") role = meta.get("role", "worker") - meta.get("name", "") + name = meta.get("name", "") try: # Wait before restarting @@ -1359,9 +1408,7 @@ def _report_config_issues(): issues.append("Settings.FreeSpaceFolder must be set when FreeSpace is enabled") # Check Arr sections for key in CONFIG.sections(): - import re - - m = re.match(r"radarr.*", key, re.IGNORECASE) + m = re.match(r"(rad|son|anim|lid)arr.*", key, re.IGNORECASE) if not m: continue managed = CONFIG.get(f"{key}.Managed", fallback=False) @@ -1390,10 +1437,9 @@ def run(): # Delete all databases on startup _delete_all_databases() - try: - manager = qBitManager() - except NameError: - sys.exit(0) + if not CONFIG_EXISTS: + sys.exit(1) + manager = qBitManager() run_logs(logger) # Early consolidated config validation feedback _report_config_issues() @@ -1430,10 +1476,10 @@ def _cleanup(): with contextlib.suppress(Exception): p.join(timeout=5) for p in manager.child_processes: - with contextlib.suppress(Exception): - p.kill() with contextlib.suppress(Exception): p.terminate() + with contextlib.suppress(Exception): + p.kill() # Register signal handlers for graceful shutdown def _signal_handler(signum, frame): diff --git a/qBitrr/qbit_category_manager.py b/qBitrr/qbit_category_manager.py index d92878b28..2b39da38b 100644 --- a/qBitrr/qbit_category_manager.py +++ b/qBitrr/qbit_category_manager.py @@ -6,8 +6,6 @@ import time from typing import TYPE_CHECKING -from qbittorrentapi import TorrentStates - from qBitrr.arss import _extract_tracker_host from qBitrr.errors import DelayLoopException @@ -34,6 +32,9 @@ def __init__(self, instance_name: str, qbit_manager: qBitManager, config: dict): - managed_categories: List of category names - default_seeding: Default seeding settings - category_overrides: Per-category seeding overrides + - trackers: Tracker config list + - stalled_delay: Minutes before removing stalled downloads (-1 = disabled) + - ignore_torrents_younger_than: Seconds; don't remove torrents younger than this """ self.instance_name = instance_name self.qbit_manager = qbit_manager @@ -41,6 +42,9 @@ def __init__(self, instance_name: str, qbit_manager: qBitManager, config: dict): self.default_seeding = config.get("default_seeding", {}) self.category_overrides = config.get("category_overrides", {}) self.trackers = self._build_merged_trackers(config.get("trackers", [])) + self.stalled_delay = config.get("stalled_delay", -1) + self.ignore_torrents_younger_than = config.get("ignore_torrents_younger_than", 600) + self.allowed_stalled = self.stalled_delay != -1 self.logger = logging.getLogger(f"qBitrr.qBitCategory.{instance_name}") self.logger.info( @@ -133,7 +137,7 @@ def process_torrents(self): def _process_single_torrent(self, torrent: TorrentDictionary, category: str): """ - Process a single torrent - apply seeding settings and check removal. + Process a single torrent - apply seeding settings, stalled removal, and ratio/time removal. Args: torrent: qBittorrent torrent object @@ -173,10 +177,8 @@ def _process_single_torrent(self, torrent: TorrentDictionary, category: str): # Apply seeding limits using effective (possibly tracker-overridden) config self._apply_seeding_limits(torrent, effective_config) - # Check if torrent should be removed - if self._should_remove_torrent(torrent, effective_config): - self._remove_torrent(torrent, category) - + # PlaceHolderArr runs the full state machine for qBit-managed categories (stalled, + # ratio/time removal, missing files, recheck). No duplicate removal here. except Exception as e: self.logger.error( "Error processing torrent '%s' in category '%s': %s", @@ -260,84 +262,6 @@ def _apply_seeding_limits(self, torrent: TorrentDictionary, config: dict): e, ) - def _should_remove_torrent(self, torrent: TorrentDictionary, config: dict) -> bool: - """ - Check if torrent meets removal conditions. - - - Seeding limits (ratio/time) only apply to COMPLETED/UPLOADING torrents - - HnR protection only applies to DOWNLOADING torrents - - Args: - torrent: qBittorrent torrent object - config: Seeding configuration dict - - Returns: - True if torrent should be removed, False otherwise - """ - remove_mode = config.get("RemoveTorrent", -1) - - if remove_mode == -1: - return False - - is_uploading = torrent.state_enum in ( - TorrentStates.UPLOADING, - TorrentStates.STALLED_UPLOAD, - TorrentStates.QUEUED_UPLOAD, - TorrentStates.PAUSED_UPLOAD, - TorrentStates.FORCED_UPLOAD, - ) - is_downloading = torrent.state_enum in ( - TorrentStates.DOWNLOADING, - TorrentStates.STALLED_DOWNLOAD, - TorrentStates.QUEUED_DOWNLOAD, - TorrentStates.PAUSED_DOWNLOAD, - TorrentStates.FORCED_DOWNLOAD, - TorrentStates.METADATA_DOWNLOAD, - ) - - if not is_uploading: - return False - - ratio_limit = config.get("MaxUploadRatio", -1) - time_limit = config.get("MaxSeedingTime", -1) - - ratio_met = ratio_limit > 0 and torrent.ratio >= ratio_limit - time_met = time_limit > 0 and torrent.seeding_time >= time_limit - - should_remove = False - if remove_mode == 1: - should_remove = ratio_met - elif remove_mode == 2: - should_remove = time_met - elif remove_mode == 3: - should_remove = ratio_met or time_met - elif remove_mode == 4: - should_remove = ratio_met and time_met - - if should_remove: - tracker_config = self._get_tracker_config(torrent) - hnr_config = tracker_config if tracker_config else config - - if hnr_config.get("HitAndRunMode", False) and self._hnr_tracker_is_dead( - torrent, hnr_config - ): - self.logger.debug( - "HnR bypass: tracker reports torrent as unregistered/dead '%s'", - torrent.name, - ) - return True - - if is_downloading and not self._hnr_safe_to_remove(torrent, hnr_config): - self.logger.debug( - "HnR protection: keeping downloading torrent '%s' (ratio=%.2f, seeding=%ds)", - torrent.name, - torrent.ratio, - torrent.seeding_time, - ) - return False - - return should_remove - def _get_tracker_config(self, torrent: TorrentDictionary) -> dict | None: """Find the highest-priority matching tracker config for this torrent.""" if not self.trackers: @@ -378,97 +302,6 @@ def _get_tracker_config(self, torrent: TorrentDictionary) -> dict | None: best_priority = priority return best - def _hnr_tracker_is_dead(self, torrent: TorrentDictionary, config: dict) -> bool: - """Check if the HnR-enabled tracker reports the torrent as unregistered.""" - _dead_keywords = { - "unregistered torrent", - "torrent not registered", - "info hash is not authorized", - "torrent is not authorized", - "not found", - "torrent not found", - } - uri = (config.get("URI") or "").strip().rstrip("/") - cfg_host = _extract_tracker_host(uri) - if not cfg_host: - return False - try: - for tracker in torrent.trackers: - tracker_url = (getattr(tracker, "url", None) or "").rstrip("/") - if _extract_tracker_host(tracker_url) != cfg_host: - continue - message_text = (getattr(tracker, "msg", "") or "").lower() - if any(keyword in message_text for keyword in _dead_keywords): - return True - except Exception: - pass - return False - - def _hnr_safe_to_remove(self, torrent: TorrentDictionary, config: dict) -> bool: - """ - Check if Hit and Run obligations are met for this torrent. - - Args: - torrent: qBittorrent torrent object - config: Seeding configuration dict - - Returns: - True if HnR obligations are met (safe to remove), False otherwise - """ - if not config.get("HitAndRunMode", False): - return True - - min_ratio = config.get("MinSeedRatio", 1.0) - min_time_secs = config.get("MinSeedingTimeDays", 0) * 86400 - min_dl_pct = config.get("HitAndRunMinimumDownloadPercent", 10) / 100.0 - partial_ratio = config.get("HitAndRunPartialSeedRatio", 1.0) - buffer_secs = config.get("TrackerUpdateBuffer", 0) - - is_partial = torrent.progress < 1.0 and torrent.progress >= min_dl_pct - effective_seeding_time = torrent.seeding_time - buffer_secs - - if torrent.progress < min_dl_pct: - return True # Below minimum download threshold, no HnR obligation - if is_partial: - return torrent.ratio >= partial_ratio # Partial: ratio only - - ratio_met = torrent.ratio >= min_ratio if min_ratio > 0 else False - time_met = effective_seeding_time >= min_time_secs if min_time_secs > 0 else False - - if min_ratio > 0 and min_time_secs > 0: - return ratio_met or time_met # Either clears HnR - elif min_ratio > 0: - return ratio_met - elif min_time_secs > 0: - return time_met - return True - - def _remove_torrent(self, torrent: TorrentDictionary, category: str): - """ - Remove torrent that met seeding goals. - - Args: - torrent: qBittorrent torrent object - category: Category name - """ - try: - self.logger.info( - "Removing torrent '%s' from category '%s' " "(ratio: %.2f, seeding time: %ds)", - torrent.name, - category, - torrent.ratio, - torrent.seeding_time, - ) - # Remove from qBit but keep files - torrent.delete(delete_files=False) - except Exception as e: - self.logger.error( - "Failed to remove torrent '%s': %s", - torrent.name, - e, - exc_info=True, - ) - def _create_dedicated_client(self): """Create a dedicated qBit client for this process to avoid HTTP session sharing.""" import qbittorrentapi diff --git a/qBitrr/search_activity_store.py b/qBitrr/search_activity_store.py index e33bba819..a7871769f 100644 --- a/qBitrr/search_activity_store.py +++ b/qBitrr/search_activity_store.py @@ -1,11 +1,15 @@ from __future__ import annotations +import logging from threading import RLock from typing import Any from peewee import Model, TextField from qBitrr.database import get_database +from qBitrr.db_lock import database_lock + +logger = logging.getLogger("qBitrr.SearchActivityStore") _DB_LOCK = RLock() @@ -18,11 +22,12 @@ class SearchActivity(Model): def _ensure_tables() -> None: db = get_database() - with _DB_LOCK: - # Bind model to database if not already bound - if not SearchActivity._meta.database: - db.bind([SearchActivity]) - db.create_tables([SearchActivity], safe=True) + with database_lock(): + with _DB_LOCK: + # Bind model to database if not already bound + if not SearchActivity._meta.database: + db.bind([SearchActivity]) + db.create_tables([SearchActivity], safe=True) def record_search_activity(category: str, summary: str | None, timestamp: str | None) -> None: @@ -32,25 +37,29 @@ def record_search_activity(category: str, summary: str | None, timestamp: str | if timestamp is not None and not isinstance(timestamp, str): timestamp = str(timestamp) data: dict[str, Any] = {"summary": summary, "timestamp": timestamp} - with get_database().atomic(): - SearchActivity.insert(category=category, **data).on_conflict( - conflict_target=[SearchActivity.category], - update=data, - ).execute() + with database_lock(): + with _DB_LOCK: + with get_database().atomic(): + SearchActivity.insert(category=category, **data).on_conflict( + conflict_target=[SearchActivity.category], + update=data, + ).execute() def fetch_search_activities() -> dict[str, dict[str, str | None]]: _ensure_tables() activities: dict[str, dict[str, str | None]] = {} try: - query = SearchActivity.select() - except Exception: - return activities - for row in query: - activities[str(row.category)] = { - "summary": row.summary, - "timestamp": row.timestamp, - } + with database_lock(): + with _DB_LOCK: + query = SearchActivity.select() + for row in query: + activities[str(row.category)] = { + "summary": row.summary, + "timestamp": row.timestamp, + } + except Exception as e: + logger.error("Failed to fetch search activities: %s", e) return activities @@ -58,5 +67,7 @@ def clear_search_activity(category: str) -> None: if not category: return _ensure_tables() - with get_database().atomic(): - SearchActivity.delete().where(SearchActivity.category == category).execute() + with database_lock(): + with _DB_LOCK: + with get_database().atomic(): + SearchActivity.delete().where(SearchActivity.category == category).execute() diff --git a/qBitrr/tables.py b/qBitrr/tables.py index 1c139bdcb..5840ee744 100644 --- a/qBitrr/tables.py +++ b/qBitrr/tables.py @@ -12,7 +12,7 @@ class MoviesFilesModel(Model): TmdbId = IntegerField() Year = IntegerField() ArrInstance = CharField(null=True, default="") - EntryId = IntegerField(unique=True) + EntryId = IntegerField(primary_key=True) Searched = BooleanField(default=False) MovieFileId = IntegerField() IsRequest = BooleanField(default=False) @@ -30,6 +30,12 @@ class MoviesFilesModel(Model): CurrentProfileId = IntegerField(null=True) OriginalProfileId = IntegerField(null=True) + class Meta: + indexes = ( + (("ArrInstance",), False), + (("Searched",), False), + ) + class EpisodeFilesModel(Model): EntryId = IntegerField(primary_key=True) @@ -60,6 +66,13 @@ class EpisodeFilesModel(Model): CurrentProfileId = IntegerField(null=True) OriginalProfileId = IntegerField(null=True) + class Meta: + indexes = ( + (("SeriesId", "SeasonNumber"), False), + (("ArrInstance",), False), + (("Searched",), False), + ) + class SeriesFilesModel(Model): EntryId = IntegerField(primary_key=True) @@ -73,6 +86,9 @@ class SeriesFilesModel(Model): QualityProfileId = IntegerField(null=True) QualityProfileName = TextField(null=True) + class Meta: + indexes = ((("ArrInstance",), False),) + class MovieQueueModel(Model): EntryId = IntegerField(unique=True) @@ -91,7 +107,7 @@ class AlbumFilesModel(Model): Monitored = BooleanField() ForeignAlbumId = CharField() ReleaseDate = DateTimeField(formats=["%Y-%m-%d %H:%M:%S.%f"], null=True) - EntryId = IntegerField(unique=True) + EntryId = IntegerField(primary_key=True) ArrInstance = CharField(null=True, default="") Searched = BooleanField(default=False) AlbumFileId = IntegerField() @@ -112,6 +128,13 @@ class AlbumFilesModel(Model): CurrentProfileId = IntegerField(null=True) OriginalProfileId = IntegerField(null=True) + class Meta: + indexes = ( + (("ArrInstance",), False), + (("ArtistId",), False), + (("Searched",), False), + ) + class TrackFilesModel(Model): EntryId = IntegerField(primary_key=True) @@ -124,6 +147,12 @@ class TrackFilesModel(Model): TrackFileId = IntegerField(null=True) Monitored = BooleanField(default=False) + class Meta: + indexes = ( + (("AlbumId",), False), + (("ArrInstance",), False), + ) + class ArtistFilesModel(Model): EntryId = IntegerField(primary_key=True) @@ -137,6 +166,9 @@ class ArtistFilesModel(Model): QualityProfileId = IntegerField(null=True) QualityProfileName = TextField(null=True) + class Meta: + indexes = ((("ArrInstance",), False),) + class AlbumQueueModel(Model): EntryId = IntegerField(unique=True) diff --git a/qBitrr/utils.py b/qBitrr/utils.py index da44b8439..b637b5b82 100644 --- a/qBitrr/utils.py +++ b/qBitrr/utils.py @@ -6,7 +6,7 @@ import re import socket import time -from typing import Iterator +from collections.abc import Iterator import ping3 import qbittorrentapi @@ -64,46 +64,26 @@ def absolute_file_paths(directory: pathlib.Path | str) -> Iterator[pathlib.Path] file_counter += 1 if file_counter == 1: logger.warning("%s - %s", e.strerror, e.filename) + time.sleep(0.1) def validate_and_return_torrent_file(file: str) -> pathlib.Path: path = pathlib.Path(file) if path.is_file(): path = path.parent.absolute() - count = 9 - while not path.exists(): + for attempt in range(10): + if path.exists() and str(path) != ".": + return path logger.debug( "Attempt %s/10: File does not yet exist! (Possibly being moved?) | " "%s | Sleeping for 0.1s", - 10 - count, + attempt + 1, path, ) time.sleep(0.1) - if count == 0: - break - count -= 1 - else: - count = 0 - while str(path) == ".": path = pathlib.Path(file) if path.is_file(): path = path.parent.absolute() - while not path.exists(): - logger.debug( - "Attempt %s/10: File does not yet exist! (Possibly being moved?) | " - "%s | Sleeping for 0.1s", - 10 - count, - path, - ) - time.sleep(0.1) - if count == 0: - break - count -= 1 - else: - count = 0 - if count == 0: - break - count -= 1 return path @@ -162,6 +142,7 @@ def is_connected(hostname): return True except ping3.errors.PingError as e: # All ping3 errors are subclasses of `PingError`. logger.debug("Error when connecting to host: %s %s", hostname, e) + return False except ( Exception ): # Ping3 is far more robust but may requite root access, if root access is not available then run the basic mode @@ -264,7 +245,6 @@ def __update__(self): for k, b in self.container.copy().items(): if time.time() - b > self.age: del self.container[k] - return False def __eq__(self, other): if not isinstance(other, ExpiringSet): @@ -272,3 +252,8 @@ def __eq__(self, other): self.__update__() other.__update__() return set(self.container.keys()) == set(other.container.keys()) + + +def mask_secret(value: str | None) -> str: + """Return '[redacted]' if value is truthy, else empty string.""" + return "[redacted]" if value else "" diff --git a/qBitrr/webui.py b/qBitrr/webui.py index 0ee5705de..29447c98c 100644 --- a/qBitrr/webui.py +++ b/qBitrr/webui.py @@ -18,6 +18,7 @@ from qBitrr.arss import FreeSpaceManager, PlaceHolderArr from qBitrr.bundled_data import patched_version, tagged_version from qBitrr.config import CONFIG, HOME_PATH +from qBitrr.db_lock import database_lock from qBitrr.logger import run_logs from qBitrr.search_activity_store import ( clear_search_activity, @@ -65,6 +66,38 @@ def _toml_delete(doc, dotted_key: str) -> None: break +_SENSITIVE_KEY_PATTERNS = re.compile( + r"(apikey|api_key|token|password|secret|passkey|credential)", re.IGNORECASE +) + +# Placeholder returned by API/Web UI for sensitive values; never send real secrets. +# When config update sends this value for a sensitive key, the existing secret is left unchanged. +REDACTED_PLACEHOLDER = "[redacted]" + + +def _is_sensitive_dotted_key(dotted_key: str) -> bool: + """Return True if the config key is considered sensitive (e.g. qBit.Password, Radarr-x.APIKey).""" + if not dotted_key or "." not in dotted_key: + return bool(_SENSITIVE_KEY_PATTERNS.search(dotted_key)) + return bool(_SENSITIVE_KEY_PATTERNS.search(dotted_key.split(".")[-1])) + + +def _strip_sensitive_keys(obj: Any, _parent_key: str = "") -> Any: + """Recursively redact values whose keys look like secrets.""" + if isinstance(obj, dict): + return { + k: ( + REDACTED_PLACEHOLDER + if isinstance(v, str) and _SENSITIVE_KEY_PATTERNS.search(k) + else _strip_sensitive_keys(v, k) + ) + for k, v in obj.items() + } + if isinstance(obj, list): + return [_strip_sensitive_keys(v, _parent_key) for v in obj] + return obj + + def _toml_to_jsonable(obj: Any) -> Any: try: if hasattr(obj, "unwrap"): @@ -75,6 +108,7 @@ def _toml_to_jsonable(obj: Any) -> Any: return [_toml_to_jsonable(v) for v in obj] return obj except Exception: + logging.getLogger("qBitrr.WebUI").debug("_toml_to_jsonable failed", exc_info=True) return obj @@ -118,7 +152,9 @@ def add_cache_headers(response): _toml_set(CONFIG.config, "WebUI.Token", self.token) CONFIG.save() except Exception: - pass + self.logger.warning( + "Failed to persist generated WebUI token to config", exc_info=True + ) else: self.logger.notice("Generated new WebUI token") self._github_repo = "Feramance/qBitrr" @@ -140,7 +176,7 @@ def add_cache_headers(response): "binary_download_size": None, "binary_download_error": None, } - self._version_cache_expiry = datetime.utcnow() - timedelta(seconds=1) + self._version_cache_expiry = datetime.now(timezone.utc) - timedelta(seconds=1) self._update_state = { "in_progress": False, "last_result": None, @@ -206,7 +242,7 @@ def _fetch_current_version_changelog(self) -> dict[str, Any]: } def _ensure_version_info(self, force: bool = False) -> dict[str, Any]: - now = datetime.utcnow() + now = datetime.now(timezone.utc) with self._version_lock: if not force and now < self._version_cache_expiry: snapshot = dict(self._version_cache) @@ -302,7 +338,7 @@ def _run_manual_update(self) -> None: error_message = str(exc) self.logger.exception("Manual update failed") finally: - completed_at = datetime.utcnow().isoformat() + completed_at = datetime.now(timezone.utc).isoformat() with self._version_lock: self._update_state.update( { @@ -313,7 +349,7 @@ def _run_manual_update(self) -> None: } ) self._update_thread = None - self._version_cache_expiry = datetime.utcnow() - timedelta(seconds=1) + self._version_cache_expiry = datetime.now(timezone.utc) - timedelta(seconds=1) try: self.manager.configure_auto_update() except Exception: @@ -334,6 +370,9 @@ def _ensure_arr_db(self, arr) -> bool: try: arr.register_search_mode() except Exception: + self.logger.debug( + "register_search_mode failed for %s", getattr(arr, "_name", arr), exc_info=True + ) return False if not getattr(arr, "search_setup_completed", False): return False @@ -342,6 +381,9 @@ def _ensure_arr_db(self, arr) -> bool: arr.db_update() arr._webui_db_loaded = True except Exception: + self.logger.debug( + "db_update failed for %s", getattr(arr, "_name", arr), exc_info=True + ) arr._webui_db_loaded = False return True @@ -395,107 +437,110 @@ def _radarr_movies_from_db( page = max(page, 0) page_size = max(page_size, 1) arr_instance = getattr(arr, "_name", "") - with db.connection_context(): - # Filter by ArrInstance - base_query = model.select().where(model.ArrInstance == arr_instance) - - # Calculate counts - monitored_count = ( - model.select(fn.COUNT(model.EntryId)) - .where( - (model.ArrInstance == arr_instance) & (model.Monitored == True) - ) # noqa: E712 - .scalar() - or 0 - ) - available_count = ( - model.select(fn.COUNT(model.EntryId)) - .where( - (model.ArrInstance == arr_instance) - & (model.Monitored == True) # noqa: E712 - & (model.MovieFileId.is_null(False)) - & (model.MovieFileId != 0) + with database_lock(): + with db.connection_context(): + # Filter by ArrInstance + base_query = model.select().where(model.ArrInstance == arr_instance) + + # Calculate counts + monitored_count = ( + model.select(fn.COUNT(model.EntryId)) + .where( + (model.ArrInstance == arr_instance) & (model.Monitored == True) + ) # noqa: E712 + .scalar() + or 0 ) - .scalar() - or 0 - ) - missing_count = max(monitored_count - available_count, 0) - quality_met_count = ( - model.select(fn.COUNT(model.EntryId)) - .where( - (model.ArrInstance == arr_instance) & (model.QualityMet == True) - ) # noqa: E712 - .scalar() - or 0 - ) - request_count = ( - model.select(fn.COUNT(model.EntryId)) - .where( - (model.ArrInstance == arr_instance) & (model.IsRequest == True) - ) # noqa: E712 - .scalar() - or 0 - ) - - # Build filtered query - query = base_query - if search: - query = query.where(model.Title.contains(search)) - if year_min is not None: - query = query.where(model.Year >= year_min) - if year_max is not None: - query = query.where(model.Year <= year_max) - if monitored is not None: - query = query.where(model.Monitored == monitored) - if has_file is not None: - if has_file: - query = query.where( - (model.MovieFileId.is_null(False)) & (model.MovieFileId != 0) - ) - else: - query = query.where( - (model.MovieFileId.is_null(True)) | (model.MovieFileId == 0) + available_count = ( + model.select(fn.COUNT(model.EntryId)) + .where( + (model.ArrInstance == arr_instance) + & (model.Monitored == True) # noqa: E712 + & (model.MovieFileId.is_null(False)) + & (model.MovieFileId != 0) ) - if quality_met is not None: - query = query.where(model.QualityMet == quality_met) - if is_request is not None: - query = query.where(model.IsRequest == is_request) - - # Total should be ALL items for this instance, not filtered results - total = base_query.count() - page_items = query.order_by(model.Title.asc()).paginate(page + 1, page_size).iterator() - movies = [] - for movie in page_items: - # Read quality profile from database - quality_profile_id = ( - getattr(movie, "QualityProfileId", None) - if hasattr(model, "QualityProfileId") - else None + .scalar() + or 0 ) - quality_profile_name = ( - getattr(movie, "QualityProfileName", None) - if hasattr(model, "QualityProfileName") - else None + missing_count = max(monitored_count - available_count, 0) + quality_met_count = ( + model.select(fn.COUNT(model.EntryId)) + .where( + (model.ArrInstance == arr_instance) & (model.QualityMet == True) + ) # noqa: E712 + .scalar() + or 0 + ) + request_count = ( + model.select(fn.COUNT(model.EntryId)) + .where( + (model.ArrInstance == arr_instance) & (model.IsRequest == True) + ) # noqa: E712 + .scalar() + or 0 ) - movies.append( - { - "id": movie.EntryId, - "title": movie.Title or "", - "year": movie.Year, - "monitored": self._safe_bool(movie.Monitored), - "hasFile": self._safe_bool(movie.MovieFileId), - "qualityMet": self._safe_bool(movie.QualityMet), - "isRequest": self._safe_bool(movie.IsRequest), - "upgrade": self._safe_bool(movie.Upgrade), - "customFormatScore": movie.CustomFormatScore, - "minCustomFormatScore": movie.MinCustomFormatScore, - "customFormatMet": self._safe_bool(movie.CustomFormatMet), - "reason": movie.Reason, - "qualityProfileId": quality_profile_id, - "qualityProfileName": quality_profile_name, - } + # Build filtered query + query = base_query + if search: + query = query.where(model.Title.contains(search)) + if year_min is not None: + query = query.where(model.Year >= year_min) + if year_max is not None: + query = query.where(model.Year <= year_max) + if monitored is not None: + query = query.where(model.Monitored == monitored) + if has_file is not None: + if has_file: + query = query.where( + (model.MovieFileId.is_null(False)) & (model.MovieFileId != 0) + ) + else: + query = query.where( + (model.MovieFileId.is_null(True)) | (model.MovieFileId == 0) + ) + if quality_met is not None: + query = query.where(model.QualityMet == quality_met) + if is_request is not None: + query = query.where(model.IsRequest == is_request) + + # Total should be ALL items for this instance, not filtered results + total = base_query.count() + page_items = ( + query.order_by(model.Title.asc()).paginate(page + 1, page_size).iterator() ) + movies = [] + for movie in page_items: + # Read quality profile from database + quality_profile_id = ( + getattr(movie, "QualityProfileId", None) + if hasattr(model, "QualityProfileId") + else None + ) + quality_profile_name = ( + getattr(movie, "QualityProfileName", None) + if hasattr(model, "QualityProfileName") + else None + ) + + movies.append( + { + "id": movie.EntryId, + "title": movie.Title or "", + "year": movie.Year, + "monitored": self._safe_bool(movie.Monitored), + "hasFile": self._safe_bool(movie.MovieFileId), + "qualityMet": self._safe_bool(movie.QualityMet), + "isRequest": self._safe_bool(movie.IsRequest), + "upgrade": self._safe_bool(movie.Upgrade), + "customFormatScore": movie.CustomFormatScore, + "minCustomFormatScore": movie.MinCustomFormatScore, + "customFormatMet": self._safe_bool(movie.CustomFormatMet), + "reason": movie.Reason, + "qualityProfileId": quality_profile_id, + "qualityProfileName": quality_profile_name, + } + ) return { "counts": { "available": available_count, @@ -559,189 +604,199 @@ def _lidarr_albums_from_db( # Quality profiles are now stored in the database # No need to fetch from API - with db.connection_context(): - # Filter by ArrInstance - base_query = model.select().where(model.ArrInstance == arr_instance) + with database_lock(): + with db.connection_context(): + # Filter by ArrInstance + base_query = model.select().where(model.ArrInstance == arr_instance) - # Calculate counts - monitored_count = ( - model.select(fn.COUNT(model.EntryId)) - .where( - (model.ArrInstance == arr_instance) & (model.Monitored == True) - ) # noqa: E712 - .scalar() - or 0 - ) - available_count = ( - model.select(fn.COUNT(model.EntryId)) - .where( - (model.ArrInstance == arr_instance) - & (model.Monitored == True) # noqa: E712 - & (model.AlbumFileId.is_null(False)) - & (model.AlbumFileId != 0) + # Calculate counts + monitored_count = ( + model.select(fn.COUNT(model.EntryId)) + .where( + (model.ArrInstance == arr_instance) & (model.Monitored == True) + ) # noqa: E712 + .scalar() + or 0 ) - .scalar() - or 0 - ) - missing_count = max(monitored_count - available_count, 0) - quality_met_count = ( - model.select(fn.COUNT(model.EntryId)) - .where( - (model.ArrInstance == arr_instance) & (model.QualityMet == True) - ) # noqa: E712 - .scalar() - or 0 - ) - request_count = ( - model.select(fn.COUNT(model.EntryId)) - .where( - (model.ArrInstance == arr_instance) & (model.IsRequest == True) - ) # noqa: E712 - .scalar() - or 0 - ) - - # Build filtered query - query = base_query - if search: - query = query.where(model.Title.contains(search)) - if monitored is not None: - query = query.where(model.Monitored == monitored) - if has_file is not None: - if has_file: - query = query.where( - (model.AlbumFileId.is_null(False)) & (model.AlbumFileId != 0) - ) - else: - query = query.where( - (model.AlbumFileId.is_null(True)) | (model.AlbumFileId == 0) + available_count = ( + model.select(fn.COUNT(model.EntryId)) + .where( + (model.ArrInstance == arr_instance) + & (model.Monitored == True) # noqa: E712 + & (model.AlbumFileId.is_null(False)) + & (model.AlbumFileId != 0) ) - if quality_met is not None: - query = query.where(model.QualityMet == quality_met) - if is_request is not None: - query = query.where(model.IsRequest == is_request) - - albums = [] - - # Total should be ALL albums for this instance, not filtered results - total = base_query.count() - - if group_by_artist: - # Paginate by artists: Two-pass approach with Peewee - # First, get all distinct artist names from the filtered query - # Use a subquery to get distinct artists efficiently - artists_subquery = ( - query.select(model.ArtistTitle).distinct().order_by(model.ArtistTitle) + .scalar() + or 0 + ) + missing_count = max(monitored_count - available_count, 0) + quality_met_count = ( + model.select(fn.COUNT(model.EntryId)) + .where( + (model.ArrInstance == arr_instance) & (model.QualityMet == True) + ) # noqa: E712 + .scalar() + or 0 + ) + request_count = ( + model.select(fn.COUNT(model.EntryId)) + .where( + (model.ArrInstance == arr_instance) & (model.IsRequest == True) + ) # noqa: E712 + .scalar() + or 0 ) - # Convert to list to avoid multiple iterations - all_artists = [row.ArtistTitle for row in artists_subquery] - len(all_artists) + # Build filtered query + query = base_query + if search: + query = query.where(model.Title.contains(search)) + if monitored is not None: + query = query.where(model.Monitored == monitored) + if has_file is not None: + if has_file: + query = query.where( + (model.AlbumFileId.is_null(False)) & (model.AlbumFileId != 0) + ) + else: + query = query.where( + (model.AlbumFileId.is_null(True)) | (model.AlbumFileId == 0) + ) + if quality_met is not None: + query = query.where(model.QualityMet == quality_met) + if is_request is not None: + query = query.where(model.IsRequest == is_request) + + albums = [] + + # Total should be ALL albums for this instance, not filtered results + total = base_query.count() + + if group_by_artist: + # Paginate by artists: Two-pass approach with Peewee + # First, get all distinct artist names from the filtered query + # Use a subquery to get distinct artists efficiently + artists_subquery = ( + query.select(model.ArtistTitle).distinct().order_by(model.ArtistTitle) + ) + + # Convert to list to avoid multiple iterations + all_artists = [row.ArtistTitle for row in artists_subquery] - # Paginate the artist list in Python - start_idx = page * page_size - end_idx = start_idx + page_size - paginated_artists = all_artists[start_idx:end_idx] + # Paginate the artist list in Python + start_idx = page * page_size + end_idx = start_idx + page_size + paginated_artists = all_artists[start_idx:end_idx] - # Fetch all albums for these paginated artists - if paginated_artists: - album_results = list( - query.where(model.ArtistTitle.in_(paginated_artists)).order_by( - model.ArtistTitle, model.ReleaseDate + # Fetch all albums for these paginated artists + if paginated_artists: + album_results = list( + query.where(model.ArtistTitle.in_(paginated_artists)).order_by( + model.ArtistTitle, model.ReleaseDate + ) ) - ) + else: + album_results = [] else: - album_results = [] - else: - # Flat mode: paginate by albums as before - # Note: total is already set to base_query.count() above - album_results = list(query.order_by(model.Title).paginate(page + 1, page_size)) - - for album in album_results: - # Always fetch tracks from database (Lidarr only) - track_model = getattr(arr, "track_file_model", None) - tracks_list = [] - track_monitored_count = 0 - track_available_count = 0 - - if track_model: - try: - # Query tracks from database for this album - track_query = ( - track_model.select() - .where(track_model.AlbumId == album.EntryId) - .order_by(track_model.TrackNumber) - ) - track_count = track_query.count() - self.logger.debug( - f"Album {album.EntryId} ({album.Title}): Found {track_count} tracks in database" - ) + # Flat mode: paginate by albums as before + # Note: total is already set to base_query.count() above + album_results = list(query.order_by(model.Title).paginate(page + 1, page_size)) + + for album in album_results: + # Always fetch tracks from database (Lidarr only) + track_model = getattr(arr, "track_file_model", None) + tracks_list = [] + track_monitored_count = 0 + track_available_count = 0 + + if track_model: + try: + # Query tracks from database for this album + track_query = ( + track_model.select() + .where(track_model.AlbumId == album.EntryId) + .order_by(track_model.TrackNumber) + ) + track_count = track_query.count() + self.logger.debug( + "Album %s (%s): Found %d tracks in database", + album.EntryId, + album.Title, + track_count, + ) - for track in track_query: - is_monitored = self._safe_bool(track.Monitored) - has_file = self._safe_bool(track.HasFile) + for track in track_query: + is_monitored = self._safe_bool(track.Monitored) + has_file = self._safe_bool(track.HasFile) - if is_monitored: - track_monitored_count += 1 - if has_file: - track_available_count += 1 + if is_monitored: + track_monitored_count += 1 + if has_file: + track_available_count += 1 - tracks_list.append( - { - "id": track.EntryId, - "trackNumber": track.TrackNumber, - "title": track.Title, - "duration": track.Duration, - "hasFile": has_file, - "trackFileId": track.TrackFileId, - "monitored": is_monitored, - } + tracks_list.append( + { + "id": track.EntryId, + "trackNumber": track.TrackNumber, + "title": track.Title, + "duration": track.Duration, + "hasFile": has_file, + "trackFileId": track.TrackFileId, + "monitored": is_monitored, + } + ) + except Exception as e: + self.logger.warning( + "Failed to fetch tracks for album %s (%s): %s", + album.EntryId, + album.Title, + e, ) - except Exception as e: - self.logger.warning( - f"Failed to fetch tracks for album {album.EntryId} ({album.Title}): {e}" - ) - track_missing_count = max(track_monitored_count - track_available_count, 0) - - # Get quality profile from database model - quality_profile_id = getattr(album, "QualityProfileId", None) - quality_profile_name = getattr(album, "QualityProfileName", None) - - # Build album data in Sonarr-like structure - album_item = { - "album": { - "id": album.EntryId, - "title": album.Title, - "artistId": album.ArtistId, - "artistName": album.ArtistTitle, - "monitored": self._safe_bool(album.Monitored), - "hasFile": bool(album.AlbumFileId and album.AlbumFileId != 0), - "foreignAlbumId": album.ForeignAlbumId, - "releaseDate": ( - album.ReleaseDate.isoformat() - if album.ReleaseDate and hasattr(album.ReleaseDate, "isoformat") - else album.ReleaseDate if isinstance(album.ReleaseDate, str) else None - ), - "qualityMet": self._safe_bool(album.QualityMet), - "isRequest": self._safe_bool(album.IsRequest), - "upgrade": self._safe_bool(album.Upgrade), - "customFormatScore": album.CustomFormatScore, - "minCustomFormatScore": album.MinCustomFormatScore, - "customFormatMet": self._safe_bool(album.CustomFormatMet), - "reason": album.Reason, - "qualityProfileId": quality_profile_id, - "qualityProfileName": quality_profile_name, - }, - "totals": { - "available": track_available_count, - "monitored": track_monitored_count, - "missing": track_missing_count, - }, - "tracks": tracks_list, - } + track_missing_count = max(track_monitored_count - track_available_count, 0) + + # Get quality profile from database model + quality_profile_id = getattr(album, "QualityProfileId", None) + quality_profile_name = getattr(album, "QualityProfileName", None) + + # Build album data in Sonarr-like structure + album_item = { + "album": { + "id": album.EntryId, + "title": album.Title, + "artistId": album.ArtistId, + "artistName": album.ArtistTitle, + "monitored": self._safe_bool(album.Monitored), + "hasFile": bool(album.AlbumFileId and album.AlbumFileId != 0), + "foreignAlbumId": album.ForeignAlbumId, + "releaseDate": ( + album.ReleaseDate.isoformat() + if album.ReleaseDate and hasattr(album.ReleaseDate, "isoformat") + else ( + album.ReleaseDate + if isinstance(album.ReleaseDate, str) + else None + ) + ), + "qualityMet": self._safe_bool(album.QualityMet), + "isRequest": self._safe_bool(album.IsRequest), + "upgrade": self._safe_bool(album.Upgrade), + "customFormatScore": album.CustomFormatScore, + "minCustomFormatScore": album.MinCustomFormatScore, + "customFormatMet": self._safe_bool(album.CustomFormatMet), + "reason": album.Reason, + "qualityProfileId": quality_profile_id, + "qualityProfileName": quality_profile_name, + }, + "totals": { + "available": track_available_count, + "monitored": track_monitored_count, + "missing": track_missing_count, + }, + "tracks": tracks_list, + } - albums.append(album_item) + albums.append(album_item) return { "counts": { "available": available_count, @@ -890,7 +945,7 @@ def _lidarr_tracks_from_db( "tracks": tracks, } except Exception as e: - self.logger.error(f"Error fetching Lidarr tracks: {e}") + self.logger.error("Error fetching Lidarr tracks: %s", e) return { "counts": {"available": 0, "monitored": 0, "missing": 0}, "total": 0, @@ -935,16 +990,17 @@ def _sonarr_series_from_db( episodes_model.EpisodeFileId == 0 ) - with db.connection_context(): - monitored_count = ( - episodes_model.select(fn.COUNT(episodes_model.EntryId)) - .where( - (episodes_model.ArrInstance == arr_instance) - & (episodes_model.Monitored == True) # noqa: E712 + with database_lock(): + with db.connection_context(): + monitored_count = ( + episodes_model.select(fn.COUNT(episodes_model.EntryId)) + .where( + (episodes_model.ArrInstance == arr_instance) + & (episodes_model.Monitored == True) # noqa: E712 + ) + .scalar() + or 0 ) - .scalar() - or 0 - ) available_count = ( episodes_model.select(fn.COUNT(episodes_model.EntryId)) .where( @@ -1024,7 +1080,11 @@ def _sonarr_series_from_db( episodes = episodes_query.iterator() episodes_list = list(episodes) self.logger.debug( - f"[Sonarr Series] Series {getattr(series, 'Title', 'unknown')} (ID {getattr(series, 'EntryId', '?')}) has {len(episodes_list)} episodes (missing_only={missing_only})" + "[Sonarr Series] Series %s (ID %s) has %d episodes (missing_only=%s)", + getattr(series, "Title", "unknown"), + getattr(series, "EntryId", "?"), + len(episodes_list), + missing_only, ) seasons: dict[str, dict[str, Any]] = {} series_monitored = 0 @@ -1282,9 +1342,12 @@ def _sonarr_series_from_db( profile.get("name") if profile else None ) except Exception: - pass + self.logger.debug( + "Sonarr quality profile lookup failed", + exc_info=True, + ) except Exception: - pass + self.logger.debug("Sonarr series payload build failed", exc_info=True) payload.append( { @@ -1328,10 +1391,12 @@ def _sonarr_series_from_db( len(season.get("episodes", [])) for season in first_seasons.values() ) self.logger.info( - f"[Sonarr API] Returning {len(payload)} series, " - f"first series '{first_series.get('series', {}).get('title', '?')}' has " - f"{len(first_seasons)} seasons, {total_episodes_in_response} episodes " - f"(missing_only={missing_only})" + "[Sonarr API] Returning %d series, first series '%s' has %d seasons, %d episodes (missing_only=%s)", + len(payload), + first_series.get("series", {}).get("title", "?"), + len(first_seasons), + total_episodes_in_response, + missing_only, ) return result @@ -1341,9 +1406,17 @@ def _register_routes(self): logs_root = (HOME_PATH / "logs").resolve() def _resolve_log_file(name: str) -> Path | None: + # Restrict to safe log file names (alphanumeric, dash, underscore, dot) + if not name or not name.strip(): + return None + safe = "".join(c for c in name if c.isalnum() or c in "._-").strip() or None + if safe is None or safe != name: + self.logger.debug("Rejected log file name (invalid characters): %r", name) + return None try: - candidate = (logs_root / name).resolve(strict=False) + candidate = (logs_root / safe).resolve(strict=False) except Exception: + self.logger.debug("Failed to resolve log path for %r", safe, exc_info=True) return None try: candidate.relative_to(logs_root) @@ -1372,7 +1445,7 @@ def _authorized(): supplied = request.headers.get("Authorization", "").removeprefix( "Bearer " ) or request.args.get("token") - return supplied == self.token + return secrets.compare_digest(supplied, self.token) if supplied else False def require_token(): if not _authorized(): @@ -1515,7 +1588,9 @@ def _collect_metrics(arr_obj): metrics["category"] = count metrics["queue"] = count except Exception: - pass + self.logger.debug( + "Process metrics (free_space) fetch failed", exc_info=True + ) return metrics if isinstance(arr_obj, PlaceHolderArr): @@ -1533,7 +1608,9 @@ def _collect_metrics(arr_obj): metrics["queue"] = count metrics["category"] = count except Exception: - pass + self.logger.debug( + "Process metrics (PlaceHolderArr) fetch failed", exc_info=True + ) return metrics # Standard Arr (Radarr/Sonarr) @@ -1553,7 +1630,6 @@ def _collect_metrics(arr_obj): queue_count = len(records) if queue_count: metrics["queue"] = queue_count - records[0] if qbit_client and category: try: torrents = qbit_client.torrents_info( @@ -1565,7 +1641,7 @@ def _collect_metrics(arr_obj): if getattr(torrent, "category", None) == category ) except Exception: - pass + self.logger.debug("Process metrics (category count) failed", exc_info=True) category_key = getattr(arr_obj, "category", None) if category_key: entry = search_activity_map.get(str(category_key)) @@ -1678,10 +1754,15 @@ def _populate_process_metadata(arr_obj, proc_kind, payload_dict): except Exception: alive = False pid = None + display_name = ( + instance_name + if instance_name.lower().startswith("qbit") + else f"qBit-{instance_name}" + ) procs.append( { "category": cat, - "name": f"qBit-{instance_name}", + "name": display_name, "kind": "category", "pid": pid, "alive": alive, @@ -1721,15 +1802,23 @@ def _restart_process(category: str, kind: str): try: target_proc.kill() except Exception: - pass + self.logger.debug( + "Category manager process kill failed for %s", category, exc_info=True + ) try: target_proc.terminate() except Exception: - pass + self.logger.debug( + "Category manager process terminate failed for %s", category, exc_info=True + ) try: self.manager.child_processes.remove(target_proc) except Exception: - pass + self.logger.debug( + "child_processes.remove failed for category manager %s", + category, + exc_info=True, + ) self.manager._process_registry.pop(target_proc, None) manager = self.manager.qbit_category_managers.get(instance_name) if manager is None: @@ -1770,15 +1859,27 @@ def _restart_process(category: str, kind: str): try: process.kill() except Exception: - pass + self.logger.debug( + "Process kill failed for %s %s", category, loop_kind, exc_info=True + ) try: process.terminate() except Exception: - pass + self.logger.debug( + "Process terminate failed for %s %s", + category, + loop_kind, + exc_info=True, + ) try: self.manager.child_processes.remove(process) except Exception: - pass + self.logger.debug( + "child_processes.remove failed for %s %s", + category, + loop_kind, + exc_info=True, + ) self.manager._process_registry.pop(process, None) target = getattr(arr, f"run_{loop_kind}_loop", None) if target is None: @@ -1805,6 +1906,8 @@ def api_restart_process(category: str, kind: str): @app.post("/web/processes///restart") def web_restart_process(category: str, kind: str): + if (resp := require_token()) is not None: + return resp return _restart_process(category, kind) @app.post("/api/processes/restart_all") @@ -1816,13 +1919,12 @@ def api_restart_all(): @app.post("/web/processes/restart_all") def web_restart_all(): + if (resp := require_token()) is not None: + return resp self._reload_all() return jsonify({"status": "ok"}) - @app.post("/api/loglevel") - def api_loglevel(): - if (resp := require_token()) is not None: - return resp + def _handle_loglevel(): body = request.get_json(silent=True) or {} level = str(body.get("level", "INFO")).upper() valid = {"CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG", "TRACE"} @@ -1837,27 +1939,20 @@ def api_loglevel(): _toml_set(CONFIG.config, "Settings.ConsoleLevel", level) CONFIG.save() except Exception: - pass + self.logger.debug("Failed to persist log level to config", exc_info=True) return jsonify({"status": "ok", "level": level}) + @app.post("/api/loglevel") + def api_loglevel(): + if (resp := require_token()) is not None: + return resp + return _handle_loglevel() + @app.post("/web/loglevel") def web_loglevel(): - body = request.get_json(silent=True) or {} - level = str(body.get("level", "INFO")).upper() - valid = {"CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG", "TRACE"} - if level not in valid: - return jsonify({"error": f"invalid level {level}"}), 400 - target_level = getattr(logging, level, logging.INFO) - logging.getLogger().setLevel(target_level) - for name, lg in logging.root.manager.loggerDict.items(): - if isinstance(lg, logging.Logger) and str(name).startswith("qBitrr"): - lg.setLevel(target_level) - try: - _toml_set(CONFIG.config, "Settings.ConsoleLevel", level) - CONFIG.save() - except Exception: - pass - return jsonify({"status": "ok", "level": level}) + if (resp := require_token()) is not None: + return resp + return _handle_loglevel() @app.post("/api/arr/rebuild") def api_arr_rebuild(): @@ -1868,6 +1963,8 @@ def api_arr_rebuild(): @app.post("/web/arr/rebuild") def web_arr_rebuild(): + if (resp := require_token()) is not None: + return resp self._reload_all() return jsonify({"status": "ok"}) @@ -1887,18 +1984,62 @@ def api_logs(): def web_logs(): return jsonify({"files": _list_logs()}) - @app.get("/api/logs/") - def api_log(name: str): - if (resp := require_token()) is not None: - return resp + def _read_tail(path: Path, n: int, offset: int = 0) -> str: + """Read n lines from the end of the file, optionally skipping the last `offset` lines. + So offset=0 returns the last n lines; offset=2000 returns the n lines before that. + """ + if n <= 0: + return "" + to_read = n + offset + if to_read <= 0: + return "" + try: + size = path.stat().st_size + except OSError: + return "" + if size == 0: + return "" + chunk_size = 65536 + with path.open("rb") as f: + buf = b"" + pos = size + while pos > 0: + read_size = min(chunk_size, pos) + pos -= read_size + f.seek(pos) + buf = f.read(read_size) + buf + text = buf.decode("utf-8", errors="ignore") + if text.count("\n") + (1 if text.rstrip("\n") else 0) >= to_read: + break + text = buf.decode("utf-8", errors="ignore") + lines = text.splitlines() + total = len(lines) + if total <= offset: + return "" + # Return the n lines ending at (end - offset): lines[-(offset+n):-offset] or lines[-n:] when offset==0 + start = -(offset + n) if (offset + n) <= total else 0 + end = -offset if offset > 0 else total + if start >= end: + return "" + return "\n".join(lines[start:end]) + + def _serve_log_content(name: str): file = _resolve_log_file(name) if file is None or not file.exists(): return jsonify({"error": "not found"}), 404 - - # Stream full log file to support dynamic loading in LazyLog + lines_param = request.args.get("lines", type=int) + offset_param = request.args.get("offset", default=0, type=int) try: - content = file.read_text(encoding="utf-8", errors="ignore") + if lines_param is not None and lines_param > 0: + content = _read_tail( + file, + min(lines_param, 50000), + offset=max(0, offset_param), + ) + else: + content = file.read_text(encoding="utf-8", errors="ignore") except Exception: + self.logger.debug("Failed to read log file %s", file, exc_info=True) content = "" response = send_file( io.BytesIO(content.encode("utf-8")), @@ -1909,26 +2050,15 @@ def api_log(name: str): response.headers["Cache-Control"] = "no-cache" return response + @app.get("/api/logs/") + def api_log(name: str): + if (resp := require_token()) is not None: + return resp + return _serve_log_content(name) + @app.get("/web/logs/") def web_log(name: str): - # Public endpoint for Authentik bypass - no token required - file = _resolve_log_file(name) - if file is None or not file.exists(): - return jsonify({"error": "not found"}), 404 - - # Stream full log file to support dynamic loading in LazyLog - try: - content = file.read_text(encoding="utf-8", errors="ignore") - except Exception: - content = "" - response = send_file( - io.BytesIO(content.encode("utf-8")), - mimetype="text/plain", - as_attachment=False, - ) - response.headers["Content-Type"] = "text/plain; charset=utf-8" - response.headers["Cache-Control"] = "no-cache" - return response + return _serve_log_content(name) @app.get("/api/logs//download") def api_log_download(name: str): @@ -1946,10 +2076,7 @@ def web_log_download(name: str): return jsonify({"error": "not found"}), 404 return send_file(file, as_attachment=True) - @app.get("/api/radarr//movies") - def api_radarr_movies(category: str): - if (resp := require_token()) is not None: - return resp + def _handle_radarr_movies(category: str): managed = _managed_objects() if not managed: if not _ensure_arr_manager_ready(): @@ -1959,7 +2086,7 @@ def api_radarr_movies(category: str): return jsonify({"error": f"Unknown radarr category {category}"}), 404 q = request.args.get("q", default=None, type=str) page = request.args.get("page", default=0, type=int) - page_size = request.args.get("page_size", default=50, type=int) + page_size = min(request.args.get("page_size", default=50, type=int), 1000) year_min = request.args.get("year_min", default=None, type=int) year_max = request.args.get("year_max", default=None, type=int) monitored = ( @@ -1997,51 +2124,32 @@ def api_radarr_movies(category: str): payload["category"] = category return jsonify(payload) + @app.get("/api/radarr//movies") + def api_radarr_movies(category: str): + if (resp := require_token()) is not None: + return resp + return _handle_radarr_movies(category) + @app.get("/web/radarr//movies") def web_radarr_movies(category: str): + return _handle_radarr_movies(category) + + def _handle_sonarr_series(category: str): managed = _managed_objects() if not managed: if not _ensure_arr_manager_ready(): return jsonify({"error": "Arr manager is still initialising"}), 503 arr = managed.get(category) - if arr is None or getattr(arr, "type", None) != "radarr": - return jsonify({"error": f"Unknown radarr category {category}"}), 404 + if arr is None or getattr(arr, "type", None) != "sonarr": + return jsonify({"error": f"Unknown sonarr category {category}"}), 404 q = request.args.get("q", default=None, type=str) page = request.args.get("page", default=0, type=int) - page_size = request.args.get("page_size", default=50, type=int) - year_min = request.args.get("year_min", default=None, type=int) - year_max = request.args.get("year_max", default=None, type=int) - monitored = ( - self._safe_bool(request.args.get("monitored")) - if "monitored" in request.args - else None + page_size = min(request.args.get("page_size", default=25, type=int), 1000) + missing_only = self._safe_bool( + request.args.get("missing") or request.args.get("only_missing") ) - has_file = ( - self._safe_bool(request.args.get("has_file")) - if "has_file" in request.args - else None - ) - quality_met = ( - self._safe_bool(request.args.get("quality_met")) - if "quality_met" in request.args - else None - ) - is_request = ( - self._safe_bool(request.args.get("is_request")) - if "is_request" in request.args - else None - ) - payload = self._radarr_movies_from_db( - arr, - q, - page, - page_size, - year_min=year_min, - year_max=year_max, - monitored=monitored, - has_file=has_file, - quality_met=quality_met, - is_request=is_request, + payload = self._sonarr_series_from_db( + arr, q, page, page_size, missing_only=missing_only ) payload["category"] = category return jsonify(payload) @@ -2050,45 +2158,11 @@ def web_radarr_movies(category: str): def api_sonarr_series(category: str): if (resp := require_token()) is not None: return resp - managed = _managed_objects() - if not managed: - if not _ensure_arr_manager_ready(): - return jsonify({"error": "Arr manager is still initialising"}), 503 - arr = managed.get(category) - if arr is None or getattr(arr, "type", None) != "sonarr": - return jsonify({"error": f"Unknown sonarr category {category}"}), 404 - q = request.args.get("q", default=None, type=str) - page = request.args.get("page", default=0, type=int) - page_size = request.args.get("page_size", default=25, type=int) - missing_only = self._safe_bool( - request.args.get("missing") or request.args.get("only_missing") - ) - payload = self._sonarr_series_from_db( - arr, q, page, page_size, missing_only=missing_only - ) - payload["category"] = category - return jsonify(payload) + return _handle_sonarr_series(category) @app.get("/web/sonarr//series") def web_sonarr_series(category: str): - managed = _managed_objects() - if not managed: - if not _ensure_arr_manager_ready(): - return jsonify({"error": "Arr manager is still initialising"}), 503 - arr = managed.get(category) - if arr is None or getattr(arr, "type", None) != "sonarr": - return jsonify({"error": f"Unknown sonarr category {category}"}), 404 - q = request.args.get("q", default=None, type=str) - page = request.args.get("page", default=0, type=int) - page_size = request.args.get("page_size", default=25, type=int) - missing_only = self._safe_bool( - request.args.get("missing") or request.args.get("only_missing") - ) - payload = self._sonarr_series_from_db( - arr, q, page, page_size, missing_only=missing_only - ) - payload["category"] = category - return jsonify(payload) + return _handle_sonarr_series(category) @app.get("/web/lidarr//albums") def web_lidarr_albums(category: str): @@ -2102,6 +2176,7 @@ def web_lidarr_albums(category: str): q = request.args.get("q", default=None, type=str) page = request.args.get("page", default=0, type=int) page_size = request.args.get("page_size", default=50, type=int) + page_size = min(page_size, 1000) monitored = ( self._safe_bool(request.args.get("monitored")) if "monitored" in request.args @@ -2309,28 +2384,25 @@ def web_meta(): force = self._safe_bool(request.args.get("force")) return jsonify(self._ensure_version_info(force=force)) - @app.post("/api/update") - def api_update(): - if (resp := require_token()) is not None: - return resp + def _handle_update(): ok, message = self._trigger_manual_update() if not ok: return jsonify({"error": message}), 409 return jsonify({"status": "started"}) + @app.post("/api/update") + def api_update(): + if (resp := require_token()) is not None: + return resp + return _handle_update() + @app.post("/web/update") def web_update(): - ok, message = self._trigger_manual_update() - if not ok: - return jsonify({"error": message}), 409 - return jsonify({"status": "started"}) - - @app.get("/api/download-update") - def api_download_update(): - """Redirect to binary download URL for current platform.""" if (resp := require_token()) is not None: return resp + return _handle_update() + def _handle_download_update(): from qBitrr.auto_update import get_installation_type install_type = get_installation_type() @@ -2338,7 +2410,6 @@ def api_download_update(): if install_type != "binary": return jsonify({"error": "Download only available for binary installations"}), 400 - # Get latest version info version_info = self._ensure_version_info() if not version_info.get("update_available"): @@ -2351,38 +2422,19 @@ def api_download_update(): ) return jsonify({"error": error}), 404 - # Redirect to GitHub download URL from flask import redirect return redirect(download_url) + @app.get("/api/download-update") + def api_download_update(): + if (resp := require_token()) is not None: + return resp + return _handle_download_update() + @app.get("/web/download-update") def web_download_update(): - """Redirect to binary download URL for current platform.""" - from qBitrr.auto_update import get_installation_type - - install_type = get_installation_type() - - if install_type != "binary": - return jsonify({"error": "Download only available for binary installations"}), 400 - - # Get latest version info - version_info = self._ensure_version_info() - - if not version_info.get("update_available"): - return jsonify({"error": "No update available"}), 404 - - download_url = version_info.get("binary_download_url") - if not download_url: - error = version_info.get( - "binary_download_error", "No binary available for your platform" - ) - return jsonify({"error": error}), 404 - - # Redirect to GitHub download URL - from flask import redirect - - return redirect(download_url) + return _handle_download_update() def _status_payload() -> dict[str, Any]: # Legacy single-instance qBit info (for backward compatibility) @@ -2422,7 +2474,9 @@ def _status_payload() -> dict[str, Any]: alive = True break except Exception: - pass + self.logger.debug( + "Process is_alive check failed for %s", k, exc_info=True + ) name = getattr(arr, "_name", k) category = getattr(arr, "category", k) arrs.append({"category": category, "name": name, "type": t, "alive": alive}) @@ -2499,15 +2553,30 @@ def _restart_arr_instance(arr): try: p.kill() except Exception: - pass + self.logger.debug( + "Process kill failed for %s %s", + getattr(arr, "_name", ""), + k, + exc_info=True, + ) try: p.terminate() except Exception: - pass + self.logger.debug( + "Process terminate failed for %s %s", + getattr(arr, "_name", ""), + k, + exc_info=True, + ) try: self.manager.child_processes.remove(p) except Exception: - pass + self.logger.debug( + "child_processes.remove failed for %s %s", + getattr(arr, "_name", ""), + k, + exc_info=True, + ) self.manager._process_registry.pop(p, None) import pathos @@ -2542,6 +2611,8 @@ def api_arr_restart(section: str): @app.post("/web/arr/
/restart") def web_arr_restart(section: str): + if (resp := require_token()) is not None: + return resp managed = _managed_objects() if not managed: if not _ensure_arr_manager_ready(): @@ -2560,12 +2631,14 @@ def api_get_config(): try: CONFIG.load() except Exception: - pass - # Render current config as a JSON-able dict via tomlkit + self.logger.debug("CONFIG.load failed in api_get_config", exc_info=True) + # Render current config as a JSON-able dict via tomlkit; never expose secrets data = _toml_to_jsonable(CONFIG.config) + data = _strip_sensitive_keys(data) return jsonify(data) - except Exception as e: - return jsonify({"error": str(e)}), 500 + except Exception: + self.logger.debug("api_get_config failed", exc_info=True) + return jsonify({"error": "Failed to load config"}), 500 @app.get("/web/config") def web_get_config(): @@ -2573,8 +2646,10 @@ def web_get_config(): try: CONFIG.load() except Exception: - pass + self.logger.debug("CONFIG.load failed in web_get_config", exc_info=True) data = _toml_to_jsonable(CONFIG.config) + # Always redact secrets so API/Web UI never expose qBit passwords or Arr API keys + data = _strip_sensitive_keys(data) # Check config version and add warning if mismatch from qBitrr.config_version import get_config_version, validate_config_version @@ -2593,8 +2668,9 @@ def web_get_config(): return jsonify(response_data) return jsonify(data) - except Exception as e: - return jsonify({"error": str(e)}), 500 + except Exception: + self.logger.debug("web_get_config failed", exc_info=True) + return jsonify({"error": "Failed to load config"}), 500 def _handle_config_update(): """Common handler for config updates with intelligent reload detection.""" @@ -2657,6 +2733,9 @@ def _handle_config_update(): if key == "WebUI.Token": self.token = "" continue + # Never overwrite a real secret with the redaction placeholder from the client + if _is_sensitive_dotted_key(key) and str(val).strip() == REDACTED_PLACEHOLDER: + continue _toml_set(CONFIG.config, key, val) if key == "WebUI.Token": # Update in-memory token immediately @@ -2665,8 +2744,9 @@ def _handle_config_update(): # Persist config try: CONFIG.save() - except Exception as e: - return jsonify({"error": f"Failed to save config: {e}"}), 500 + except Exception: + self.logger.debug("Failed to save config", exc_info=True) + return jsonify({"error": "Failed to save config"}), 500 # Determine reload strategy reload_type = "none" @@ -2690,7 +2770,9 @@ def _handle_config_update(): affected_instances_list = sorted(affected_arr_instances) self.logger.notice( - f"Reloading {len(affected_instances_list)} Arr instance(s): {', '.join(affected_instances_list)}" + "Reloading %d Arr instance(s): %s", + len(affected_instances_list), + ", ".join(affected_instances_list), ) # Reload each affected instance in sequence @@ -2741,17 +2823,11 @@ def api_update_config(): @app.post("/web/config") def web_update_config(): - return _handle_config_update() - - @app.post("/api/arr/test-connection") - def api_arr_test_connection(): - """ - Test connection to Arr instance without saving config. - Accepts temporary URI/APIKey and returns connection status + quality profiles. - """ if (resp := require_token()) is not None: return resp + return _handle_config_update() + def _handle_test_connection(): try: data = request.get_json() if not data: @@ -2773,172 +2849,20 @@ def api_arr_test_connection(): 400, ) - # Try to find existing Arr instance with matching URI - existing_arr = None - managed = _managed_objects() - for group_name, arr_instance in managed.items(): - if hasattr(arr_instance, "uri") and hasattr(arr_instance, "apikey"): - if arr_instance.uri == uri and arr_instance.apikey == api_key: - existing_arr = arr_instance - self.logger.info(f"Using existing Arr instance: {group_name}") - break + from urllib.parse import urlparse as _urlparse - # Use existing client if available, otherwise create temporary one - if existing_arr and hasattr(existing_arr, "client"): - client = existing_arr.client - self.logger.info(f"Reusing existing client for {existing_arr._name}") - else: - # Create temporary Arr API client - self.logger.info(f"Creating temporary {arr_type} client for {uri}") - if arr_type == "radarr": - from pyarr import RadarrAPI - - client = RadarrAPI(uri, api_key) - elif arr_type == "sonarr": - from pyarr import SonarrAPI - - client = SonarrAPI(uri, api_key) - elif arr_type == "lidarr": - from pyarr import LidarrAPI - - client = LidarrAPI(uri, api_key) - else: - return ( - jsonify({"success": False, "message": f"Invalid arrType: {arr_type}"}), - 400, - ) - - # Test connection (no timeout - Flask/Waitress handles this) - try: - self.logger.info(f"Testing connection to {arr_type} at {uri}") - - # Get system info to verify connection - system_info = client.get_system_status() - self.logger.info( - f"System status retrieved: {system_info.get('version', 'unknown')}" - ) - - # Fetch quality profiles with retry logic (same as backend) - from json import JSONDecodeError - - import requests - from pyarr.exceptions import PyarrServerError - - max_retries = 3 - retry_count = 0 - quality_profiles = [] - - while retry_count < max_retries: - try: - quality_profiles = client.get_quality_profile() - self.logger.info( - f"Quality profiles retrieved: {len(quality_profiles)} profiles" - ) - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ) as e: - retry_count += 1 - self.logger.warning( - f"Transient error fetching quality profiles (attempt {retry_count}/{max_retries}): {e}" - ) - if retry_count >= max_retries: - self.logger.error("Failed to fetch quality profiles after retries") - quality_profiles = [] - break - time.sleep(1) - except PyarrServerError as e: - self.logger.error(f"Server error fetching quality profiles: {e}") - quality_profiles = [] - break - except Exception as e: - self.logger.error(f"Unexpected error fetching quality profiles: {e}") - quality_profiles = [] - break - - # Format response - return jsonify( - { - "success": True, - "message": "Connected successfully", - "systemInfo": { - "version": system_info.get("version", "unknown"), - "branch": system_info.get("branch"), - }, - "qualityProfiles": [ - {"id": p["id"], "name": p["name"]} for p in quality_profiles - ], - } + parsed = _urlparse(uri) + if parsed.scheme not in ("http", "https"): + return ( + jsonify( + {"success": False, "message": "URI must use http or https scheme"} + ), + 400, ) - - except Exception as e: - # Handle specific error types - error_msg = str(e) - # Log full error for debugging but sanitize user-facing message - self.logger.error(f"Connection test failed: {error_msg}") - - if "401" in error_msg or "Unauthorized" in error_msg: - return ( - jsonify( - {"success": False, "message": "Unauthorized: Invalid API key"} - ), - 401, - ) - elif "404" in error_msg: - return ( - jsonify( - {"success": False, "message": f"Not found: Check URI ({uri})"} - ), - 404, - ) - elif "Connection refused" in error_msg or "ConnectionError" in error_msg: - return ( - jsonify( - { - "success": False, - "message": f"Connection refused: Cannot reach {uri}", - } - ), - 503, - ) - else: - # Generic error message - details logged above - return ( - jsonify({"success": False, "message": "Connection test failed"}), - 500, - ) - - except Exception as e: - self.logger.error("Test connection error: %s", e) - return jsonify({"success": False, "message": "Connection test failed"}), 500 - - @app.post("/web/arr/test-connection") - def web_arr_test_connection(): - """ - Test connection to Arr instance without saving config. - Accepts temporary URI/APIKey and returns connection status + quality profiles. - Public endpoint (mirrors /api/arr/test-connection). - """ - try: - data = request.get_json() - if not data: - return jsonify({"success": False, "message": "Missing request body"}), 400 - - arr_type = data.get("arrType") # "radarr" | "sonarr" | "lidarr" - uri = data.get("uri") - api_key = data.get("apiKey") - - # Validate inputs - if not all([arr_type, uri, api_key]): + if not parsed.hostname: return ( jsonify( - { - "success": False, - "message": "Missing required fields: arrType, uri, or apiKey", - } + {"success": False, "message": "URI must contain a valid hostname"} ), 400, ) @@ -2950,16 +2874,16 @@ def web_arr_test_connection(): if hasattr(arr_instance, "uri") and hasattr(arr_instance, "apikey"): if arr_instance.uri == uri and arr_instance.apikey == api_key: existing_arr = arr_instance - self.logger.info(f"Using existing Arr instance: {group_name}") + self.logger.info("Using existing Arr instance: %s", group_name) break # Use existing client if available, otherwise create temporary one if existing_arr and hasattr(existing_arr, "client"): client = existing_arr.client - self.logger.info(f"Reusing existing client for {existing_arr._name}") + self.logger.info("Reusing existing client for %s", existing_arr._name) else: # Create temporary Arr API client - self.logger.info(f"Creating temporary {arr_type} client for {uri}") + self.logger.info("Creating temporary %s client for %s", arr_type, uri) if arr_type == "radarr": from pyarr import RadarrAPI @@ -2980,12 +2904,12 @@ def web_arr_test_connection(): # Test connection (no timeout - Flask/Waitress handles this) try: - self.logger.info(f"Testing connection to {arr_type} at {uri}") + self.logger.info("Testing connection to %s at %s", arr_type, uri) # Get system info to verify connection system_info = client.get_system_status() self.logger.info( - f"System status retrieved: {system_info.get('version', 'unknown')}" + "System status retrieved: %s", system_info.get("version", "unknown") ) # Fetch quality profiles with retry logic (same as backend) @@ -3002,7 +2926,7 @@ def web_arr_test_connection(): try: quality_profiles = client.get_quality_profile() self.logger.info( - f"Quality profiles retrieved: {len(quality_profiles)} profiles" + "Quality profiles retrieved: %d profiles", len(quality_profiles) ) break except ( @@ -3013,7 +2937,10 @@ def web_arr_test_connection(): ) as e: retry_count += 1 self.logger.warning( - f"Transient error fetching quality profiles (attempt {retry_count}/{max_retries}): {e}" + "Transient error fetching quality profiles (attempt %d/%d): %s", + retry_count, + max_retries, + e, ) if retry_count >= max_retries: self.logger.error("Failed to fetch quality profiles after retries") @@ -3021,11 +2948,11 @@ def web_arr_test_connection(): break time.sleep(1) except PyarrServerError as e: - self.logger.error(f"Server error fetching quality profiles: {e}") + self.logger.error("Server error fetching quality profiles: %s", e) quality_profiles = [] break except Exception as e: - self.logger.error(f"Unexpected error fetching quality profiles: {e}") + self.logger.error("Unexpected error fetching quality profiles: %s", e) quality_profiles = [] break @@ -3048,7 +2975,7 @@ def web_arr_test_connection(): # Handle specific error types error_msg = str(e) # Log full error for debugging but sanitize user-facing message - self.logger.error(f"Connection test failed: {error_msg}") + self.logger.error("Connection test failed: %s", error_msg) if "401" in error_msg or "Unauthorized" in error_msg: return ( @@ -3085,6 +3012,18 @@ def web_arr_test_connection(): self.logger.error("Test connection error: %s", e) return jsonify({"success": False, "message": "Connection test failed"}), 500 + @app.post("/api/arr/test-connection") + def api_arr_test_connection(): + if (resp := require_token()) is not None: + return resp + return _handle_test_connection() + + @app.post("/web/arr/test-connection") + def web_arr_test_connection(): + if (resp := require_token()) is not None: + return resp + return _handle_test_connection() + def _reload_all(self): # Set rebuilding flag self._rebuilding_arrs = True @@ -3094,11 +3033,11 @@ def _reload_all(self): try: p.kill() except Exception: - pass + self.logger.debug("Reload: process kill failed", exc_info=True) try: p.terminate() except Exception: - pass + self.logger.debug("Reload: process terminate failed", exc_info=True) self.manager.child_processes.clear() self.manager._process_registry.clear() @@ -3109,22 +3048,22 @@ def _reload_all(self): if hasattr(arr, "search_db_file") and arr.search_db_file: # Delete main database file if arr.search_db_file.exists(): - self.logger.info(f"Deleting database file: {arr.search_db_file}") + self.logger.info("Deleting database file: %s", arr.search_db_file) arr.search_db_file.unlink() - self.logger.success(f"Deleted database file for {arr._name}") + self.logger.success("Deleted database file for %s", arr._name) # Delete WAL file (Write-Ahead Log) wal_file = arr.search_db_file.with_suffix(".db-wal") if wal_file.exists(): - self.logger.info(f"Deleting WAL file: {wal_file}") + self.logger.info("Deleting WAL file: %s", wal_file) wal_file.unlink() # Delete SHM file (Shared Memory) shm_file = arr.search_db_file.with_suffix(".db-shm") if shm_file.exists(): - self.logger.info(f"Deleting SHM file: {shm_file}") + self.logger.info("Deleting SHM file: %s", shm_file) shm_file.unlink() except Exception as e: self.logger.warning( - f"Failed to delete database files for {arr._name}: {e}" + "Failed to delete database files for %s: %s", arr._name, e ) # Rebuild arr manager from config and spawn fresh @@ -3139,7 +3078,11 @@ def _reload_all(self): try: p.start() except Exception: - pass + self.logger.debug( + "Reload: failed to start process for %s", + getattr(arr, "_name", ""), + exc_info=True, + ) # Rebuild qBit category managers from fresh config self.manager.qbit_category_configs.clear() @@ -3162,7 +3105,7 @@ def _restart_webui(self): try: CONFIG.load() except Exception as e: - self.logger.warning(f"Failed to reload config: {e}") + self.logger.warning("Failed to reload config: %s", e) # Update in-memory values new_host = CONFIG.get("WebUI.Host", fallback="0.0.0.0") @@ -3189,11 +3132,11 @@ def _restart_webui(self): self._restart_requested = True self._shutdown_event.set() - self.logger.info(f"WebUI will restart on {self.host}:{self.port}") + self.logger.info("WebUI will restart on %s:%s", self.host, self.port) def _stop_arr_instance(self, arr, category: str): """Stop and cleanup a single Arr instance.""" - self.logger.info(f"Stopping Arr instance: {category}") + self.logger.info("Stopping Arr instance: %s", category) # Stop processes for loop_kind in ("search", "torrent"): @@ -3203,35 +3146,50 @@ def _stop_arr_instance(self, arr, category: str): try: process.kill() except Exception: - pass + self.logger.debug( + "Stop instance: process kill failed for %s %s", + category, + loop_kind, + exc_info=True, + ) try: process.terminate() except Exception: - pass + self.logger.debug( + "Stop instance: process terminate failed for %s %s", + category, + loop_kind, + exc_info=True, + ) try: self.manager.child_processes.remove(process) except Exception: - pass - self.logger.debug(f"Stopped {loop_kind} process for {category}") + self.logger.debug( + "Stop instance: child_processes.remove failed for %s %s", + category, + loop_kind, + exc_info=True, + ) + self.logger.debug("Stopped %s process for %s", loop_kind, category) # Delete database files try: if hasattr(arr, "search_db_file") and arr.search_db_file: if arr.search_db_file.exists(): - self.logger.info(f"Deleting database file: {arr.search_db_file}") + self.logger.info("Deleting database file: %s", arr.search_db_file) arr.search_db_file.unlink() self.logger.success( - f"Deleted database file for {getattr(arr, '_name', category)}" + "Deleted database file for %s", getattr(arr, "_name", category) ) # Delete WAL and SHM files for suffix in (".db-wal", ".db-shm"): aux_file = arr.search_db_file.with_suffix(suffix) if aux_file.exists(): - self.logger.debug(f"Deleting auxiliary file: {aux_file}") + self.logger.debug("Deleting auxiliary file: %s", aux_file) aux_file.unlink() except Exception as e: self.logger.warning( - f"Failed to delete database files for {getattr(arr, '_name', category)}: {e}" + "Failed to delete database files for %s: %s", getattr(arr, "_name", category), e ) # Remove from managed_objects @@ -3240,15 +3198,15 @@ def _stop_arr_instance(self, arr, category: str): self.manager.arr_manager.uris.discard(getattr(arr, "uri", "")) self.manager.arr_manager.arr_categories.discard(category) - self.logger.success(f"Stopped and cleaned up Arr instance: {category}") + self.logger.success("Stopped and cleaned up Arr instance: %s", category) def _start_arr_instance(self, instance_name: str): """Create and start a single Arr instance.""" - self.logger.info(f"Starting Arr instance: {instance_name}") + self.logger.info("Starting Arr instance: %s", instance_name) # Check if instance is managed if not CONFIG.get(f"{instance_name}.Managed", fallback=False): - self.logger.info(f"Instance {instance_name} is not managed, skipping") + self.logger.info("Instance %s is not managed, skipping", instance_name) return # Determine client class based on name @@ -3266,7 +3224,7 @@ def _start_arr_instance(self, instance_name: str): client_cls = LidarrAPI else: - self.logger.error(f"Unknown Arr type for instance: {instance_name}") + self.logger.error("Unknown Arr type for instance: %s", instance_name) return try: @@ -3287,22 +3245,24 @@ def _start_arr_instance(self, instance_name: str): for p in procs: try: p.start() - self.logger.debug(f"Started process (PID: {p.pid}) for {instance_name}") + self.logger.debug("Started process (PID: %s) for %s", p.pid, instance_name) except Exception as e: - self.logger.error(f"Failed to start process for {instance_name}: {e}") + self.logger.error("Failed to start process for %s: %s", instance_name, e) self.logger.success( - f"Started Arr instance: {instance_name} (category: {new_arr.category})" + "Started Arr instance: %s (category: %s)", instance_name, new_arr.category ) except SkipException: - self.logger.info(f"Instance {instance_name} skipped (not managed or disabled)") + self.logger.info("Instance %s skipped (not managed or disabled)", instance_name) except Exception as e: - self.logger.error(f"Failed to start Arr instance {instance_name}: {e}", exc_info=True) + self.logger.error( + "Failed to start Arr instance %s: %s", instance_name, e, exc_info=True + ) def _reload_arr_instance(self, instance_name: str): """Reload a single Arr instance without affecting others.""" - self.logger.notice(f"Reloading Arr instance: {instance_name}") + self.logger.notice("Reloading Arr instance: %s", instance_name) if not hasattr(self.manager, "arr_manager") or not self.manager.arr_manager: self.logger.warning("Cannot reload Arr instance: ArrManager not initialized") @@ -3325,19 +3285,19 @@ def _reload_arr_instance(self, instance_name: str): # Handle deletion case if not instance_exists_in_config: if old_arr: - self.logger.info(f"Instance {instance_name} removed from config, stopping...") + self.logger.info("Instance %s removed from config, stopping...", instance_name) self._stop_arr_instance(old_arr, old_category) else: - self.logger.debug(f"Instance {instance_name} not found in config or memory") + self.logger.debug("Instance %s not found in config or memory", instance_name) return # Handle update/addition if old_arr: # Update existing - stop old processes first - self.logger.info(f"Updating existing Arr instance: {instance_name}") + self.logger.info("Updating existing Arr instance: %s", instance_name) self._stop_arr_instance(old_arr, old_category) else: - self.logger.info(f"Adding new Arr instance: {instance_name}") + self.logger.info("Adding new Arr instance: %s", instance_name) # Small delay to ensure cleanup completes time.sleep(0.5) @@ -3345,7 +3305,7 @@ def _reload_arr_instance(self, instance_name: str): # Create new instance self._start_arr_instance(instance_name) - self.logger.success(f"Successfully reloaded Arr instance: {instance_name}") + self.logger.success("Successfully reloaded Arr instance: %s", instance_name) def start(self): if self._thread and self._thread.is_alive(): diff --git a/qBitrr2.egg-info/PKG-INFO b/qBitrr2.egg-info/PKG-INFO deleted file mode 100644 index 48d44d186..000000000 --- a/qBitrr2.egg-info/PKG-INFO +++ /dev/null @@ -1,274 +0,0 @@ -Metadata-Version: 2.4 -Name: qBitrr2 -Version: 5.4.5 -Summary: Intelligent automation for qBittorrent and *Arr apps (Radarr/Sonarr/Lidarr) - health monitoring, instant imports, quality upgrades, request integration -Home-page: https://github.com/Feramance/qBitrr -Author: Feramance -Author-email: fera@fera.wtf -License: MIT -Project-URL: Homepage, https://github.com/Feramance/qBitrr -Project-URL: Documentation, https://github.com/Feramance/qBitrr/blob/master/API_DOCUMENTATION.md -Project-URL: Issue Tracker, https://github.com/Feramance/qBitrr/issues -Project-URL: Source Code, https://github.com/Feramance/qBitrr -Project-URL: Changelog, https://github.com/Feramance/qBitrr/blob/master/CHANGELOG.md -Project-URL: Docker Hub, https://hub.docker.com/r/feramance/qbitrr -Project-URL: PyPI, https://pypi.org/project/qBitrr2/ -Project-URL: Systemd Guide, https://github.com/Feramance/qBitrr/blob/master/SYSTEMD_SERVICE.md -Keywords: qbittorrent,radarr,sonarr,lidarr,arr,automation,torrent,media,plex,jellyfin,overseerr,ombi -Classifier: Development Status :: 5 - Production/Stable -Classifier: Intended Audience :: Developers -Classifier: Intended Audience :: End Users/Desktop -Classifier: Intended Audience :: System Administrators -Classifier: License :: OSI Approved :: MIT License -Classifier: Natural Language :: English -Classifier: Operating System :: MacOS :: MacOS X -Classifier: Operating System :: Microsoft :: Windows -Classifier: Operating System :: POSIX :: Linux -Classifier: Programming Language :: Python :: 3 :: Only -Classifier: Programming Language :: Python :: 3.11 -Classifier: Programming Language :: Python :: 3.12 -Classifier: Programming Language :: Python :: Implementation :: CPython -Classifier: Programming Language :: Python :: Implementation :: PyPy -Classifier: Topic :: Communications -Classifier: Topic :: Internet -Classifier: Topic :: Multimedia :: Video -Classifier: Topic :: System :: Monitoring -Classifier: Topic :: Terminals -Classifier: Topic :: Utilities -Classifier: Typing :: Typed -Requires-Python: <4,>=3.11 -Description-Content-Type: text/markdown -License-File: LICENSE -Requires-Dist: cachetools -Requires-Dist: colorama -Requires-Dist: coloredlogs -Requires-Dist: flask -Requires-Dist: environ-config -Requires-Dist: ffmpeg-python -Requires-Dist: jaraco.docker -Requires-Dist: packaging -Requires-Dist: pathos -Requires-Dist: peewee -Requires-Dist: ping3 -Requires-Dist: pyarr -Requires-Dist: qbittorrent-api -Requires-Dist: requests -Requires-Dist: tomlkit -Requires-Dist: waitress -Requires-Dist: croniter -Provides-Extra: dev -Requires-Dist: black==24.3.0; extra == "dev" -Requires-Dist: bump2version==1.0.1; extra == "dev" -Requires-Dist: isort==5.10.1; extra == "dev" -Requires-Dist: pip-tools==7.3.0; extra == "dev" -Requires-Dist: pre-commit==3.3.3; extra == "dev" -Requires-Dist: pyinstaller==5.13.1; extra == "dev" -Requires-Dist: pyupgrade==2.31.0; extra == "dev" -Requires-Dist: twine==3.7.1; extra == "dev" -Requires-Dist: ujson==5.10.0; extra == "dev" -Requires-Dist: upgrade-pip==0.1.4; extra == "dev" -Provides-Extra: fast -Requires-Dist: ujson==5.10.0; extra == "fast" -Provides-Extra: all -Requires-Dist: black==24.3.0; extra == "all" -Requires-Dist: bump2version==1.0.1; extra == "all" -Requires-Dist: isort==5.10.1; extra == "all" -Requires-Dist: pip-tools==7.3.0; extra == "all" -Requires-Dist: pre-commit==3.3.3; extra == "all" -Requires-Dist: pyinstaller==5.13.1; extra == "all" -Requires-Dist: pyupgrade==2.31.0; extra == "all" -Requires-Dist: twine==3.7.1; extra == "all" -Requires-Dist: ujson==5.10.0; extra == "all" -Requires-Dist: upgrade-pip==0.1.4; extra == "all" -Requires-Dist: ujson==5.10.0; extra == "all" -Dynamic: license-file - -# qBitrr Logo qBitrr - -[![PyPI](https://img.shields.io/pypi/v/qBitrr2?label=PyPI)](https://pypi.org/project/qBitrr2/) -[![Downloads](https://img.shields.io/pypi/dm/qBitrr2)](https://pypi.org/project/qBitrr2/) -[![Docker Pulls](https://img.shields.io/docker/pulls/feramance/qbitrr.svg)](https://hub.docker.com/r/feramance/qbitrr) -[![CodeQL](https://github.com/Feramance/qBitrr/actions/workflows/codeql.yml/badge.svg?branch=master)](https://github.com/Feramance/qBitrr/actions/workflows/codeql.yml) -[![Nightly Build](https://github.com/Feramance/qBitrr/actions/workflows/nightly.yml/badge.svg?branch=master)](https://github.com/Feramance/qBitrr/actions/workflows/nightly.yml) -[![pre-commit.ci](https://results.pre-commit.ci/badge/github/Feramance/qBitrr/master.svg)](https://results.pre-commit.ci/latest/github/Feramance/qBitrr/master) -[![License: MIT](https://img.shields.io/pypi/l/qbitrr)](LICENSE) - -> 🧩 The intelligent glue between qBittorrent and the *Arr ecosystem (Radarr, Sonarr, Lidarr). Monitors torrent health, triggers instant imports, automates quality upgrades, manages disk space, integrates with request systems (Overseerr/Ombi), and provides a modern React dashboard for complete visibility and control. - -## 📚 Documentation - -**Full documentation is available at: https://feramance.github.io/qBitrr/** - -- [Getting Started](https://feramance.github.io/qBitrr/getting-started/) – Installation guides for pip, Docker, and native setups -- [Configuration](https://feramance.github.io/qBitrr/configuration/) – qBittorrent, Arr instances, quality profiles, and more -- [Features](https://feramance.github.io/qBitrr/features/) – Health monitoring, automated search, quality management, disk space, auto-updates -- [WebUI](https://feramance.github.io/qBitrr/webui/) – Built-in React dashboard with live monitoring and config editor -- [Troubleshooting](https://feramance.github.io/qBitrr/troubleshooting/) – Common issues and debug logging -- [API Reference](https://feramance.github.io/qBitrr/reference/api/) – REST API documentation - -## ⚡ Quick Start - -### 🐍 Install with pip -```bash -python -m venv .venv -source .venv/bin/activate # Windows: .venv\Scripts\activate -pip install qBitrr2 - -# First run creates ~/config/config.toml -qbitrr -``` - -### 🐳 Run with Docker -```bash -docker run -d \ - --name qbitrr \ - --tty \ - -e TZ=Europe/London \ - -p 6969:6969 \ - -v /path/to/appdata/qbitrr:/config \ - -v /path/to/completed/downloads:/completed_downloads:rw \ - --restart unless-stopped \ - feramance/qbitrr:latest -``` - -**Docker Compose:** -```yaml -services: - qbitrr: - image: feramance/qbitrr:latest - container_name: qbitrr - restart: unless-stopped - tty: true - environment: - TZ: Europe/London - ports: - - "6969:6969" - volumes: - - /path/to/appdata/qbitrr:/config - - /path/to/completed/downloads:/completed_downloads:rw -``` - -Access the WebUI at `http://:6969/ui` after startup. - -## ✨ Key Features - -- **🚀 Multi-qBittorrent Support (v5.7.x+)** – Manage torrents across multiple qBittorrent instances for load balancing, redundancy, and VPN isolation -- **🚑 Torrent Health Monitoring** – Detect stalled/failed downloads, auto-blacklist, trigger re-searches -- **🔍 Automated Search** – Missing media, quality upgrades, custom format scoring -- **🎯 Request Integration** – Pull requests from Overseerr/Ombi, prioritize user-requested media -- **📊 Quality Management** – RSS sync, queue refresh, profile switching, custom format enforcement -- **🌱 Seeding Control** – Per-tracker settings, ratio/time limits, tracker injection -- **🛡️ Hit and Run Protection** – Automatic HnR obligation tracking with configurable thresholds, partial download handling, and dead tracker bypass -- **💾 Disk Space Management** – Auto-pause when low on space, configurable thresholds -- **🔄 Auto-Updates** – GitHub release-based updates with scheduled cron support -- **💻 Modern WebUI** – Live process monitoring, log viewer, Arr insights, config editor - -## 🛠️ Essential Configuration - -1. **Configure qBittorrent** in `~/config/config.toml`: - ```toml - [qBit] - Host = "localhost" - Port = 8080 - UserName = "admin" - Password = "adminpass" - ``` - -2. **Add Arr instances**: - ```toml - [Radarr-Movies] - URI = "http://localhost:7878" - APIKey = "your-radarr-api-key" - Category = "radarr-movies" - ``` - -3. **Set completed folder**: - ```toml - [Settings] - CompletedDownloadFolder = "/path/to/completed" - ``` - -### 🆕 Multi-qBittorrent (v5.7.x+) - -Manage torrents across multiple qBittorrent instances: - -```toml -[qBit] # Default instance (required) -Host = "localhost" -Port = 8080 -UserName = "admin" -Password = "password" - -[qBit-seedbox] # Additional instance (optional) -Host = "192.168.1.100" -Port = 8080 -UserName = "admin" -Password = "seedboxpass" -``` - -See [Multi-qBittorrent Guide](MULTI_QBIT_V3_USER_GUIDE.md) for complete documentation. - ---- - -See [Configuration Guide](https://feramance.github.io/qBitrr/configuration/) and [config.example.toml](config.example.toml) for all available options. - -## 📖 Resources - -- **Documentation:** https://feramance.github.io/qBitrr/ -- **PyPI Package:** https://pypi.org/project/qBitrr2/ -- **Docker Hub:** https://hub.docker.com/r/feramance/qbitrr -- **Example Config:** [config.example.toml](config.example.toml) -- **API Documentation:** [docs/reference/api.md](docs/reference/api.md) -- **Systemd Setup:** [docs/getting-started/installation/systemd.md](docs/getting-started/installation/systemd.md) - -## 🐛 Issues & Support - -- **Report Bugs:** [Bug Report Template](.github/ISSUE_TEMPLATE/bug_report.yml) -- **Request Features:** [Feature Request Template](.github/ISSUE_TEMPLATE/feature_request.yml) -- **Discussions:** [GitHub Discussions](https://github.com/Feramance/qBitrr/discussions) -- **Troubleshooting:** [Common Issues](https://feramance.github.io/qBitrr/troubleshooting/) - -## 🤝 Contributing - -Contributions welcome! See [docs/development/contributing.md](docs/development/contributing.md) for coding guidelines and development setup. - -**Development setup:** -```bash -# Python backend -make newenv && make syncenv -make reformat # Format and lint - -# WebUI -cd webui && npm ci -npm run dev # Dev server at localhost:5173 -``` - -## ❤️ Support - -If qBitrr saves you time and headaches: -- ⭐ **Star the repo** – helps others discover qBitrr -- 💰 **Sponsor:** [Patreon](https://patreon.com/qBitrr) | [PayPal](https://www.paypal.me/feramance) - -## 📄 License - -Released under the [MIT License](LICENSE). Use it, modify it, share it—commercially or personally. - ---- - -
- -**Made with ❤️ by the qBitrr community** - -[Documentation](https://feramance.github.io/qBitrr/) • [PyPI](https://pypi.org/project/qBitrr2/) • [Docker](https://hub.docker.com/r/feramance/qbitrr) • [GitHub](https://github.com/Feramance/qBitrr) - -
- -## Star History - - - - - - Star History Chart - - diff --git a/qBitrr2.egg-info/SOURCES.txt b/qBitrr2.egg-info/SOURCES.txt deleted file mode 100644 index d29af816d..000000000 --- a/qBitrr2.egg-info/SOURCES.txt +++ /dev/null @@ -1,69 +0,0 @@ -LICENSE -MANIFEST.in -README.md -config.example.toml -pyproject.toml -setup.cfg -setup.py -qBitrr/__init__.py -qBitrr/arss.py -qBitrr/auto_update.py -qBitrr/bundled_data.py -qBitrr/config.py -qBitrr/config_version.py -qBitrr/db_lock.py -qBitrr/env_config.py -qBitrr/errors.py -qBitrr/ffprobe.py -qBitrr/gen_config.py -qBitrr/home_path.py -qBitrr/logger.py -qBitrr/main.py -qBitrr/search_activity_store.py -qBitrr/tables.py -qBitrr/utils.py -qBitrr/versioning.py -qBitrr/webui.py -qBitrr/static/index.html -qBitrr/static/manifest.json -qBitrr/static/sw.js -qBitrr/static/vite.svg -qBitrr/static/assets/ArrView.js -qBitrr/static/assets/ArrView.js.map -qBitrr/static/assets/ConfigView.js -qBitrr/static/assets/ConfigView.js.map -qBitrr/static/assets/LogsView.js -qBitrr/static/assets/LogsView.js.map -qBitrr/static/assets/ProcessesView.js -qBitrr/static/assets/ProcessesView.js.map -qBitrr/static/assets/app.css -qBitrr/static/assets/app.js -qBitrr/static/assets/app.js.map -qBitrr/static/assets/build.svg -qBitrr/static/assets/check-mark.svg -qBitrr/static/assets/close.svg -qBitrr/static/assets/download.svg -qBitrr/static/assets/gear.svg -qBitrr/static/assets/lidarr.svg -qBitrr/static/assets/live-streaming.svg -qBitrr/static/assets/log.svg -qBitrr/static/assets/plus.svg -qBitrr/static/assets/process.svg -qBitrr/static/assets/react-select.esm.js -qBitrr/static/assets/react-select.esm.js.map -qBitrr/static/assets/refresh-arrow.svg -qBitrr/static/assets/table.js -qBitrr/static/assets/table.js.map -qBitrr/static/assets/trash.svg -qBitrr/static/assets/up-arrow.svg -qBitrr/static/assets/useInterval.js -qBitrr/static/assets/useInterval.js.map -qBitrr/static/assets/vendor.js -qBitrr/static/assets/vendor.js.map -qBitrr/static/assets/visibility.svg -qBitrr2.egg-info/PKG-INFO -qBitrr2.egg-info/SOURCES.txt -qBitrr2.egg-info/dependency_links.txt -qBitrr2.egg-info/entry_points.txt -qBitrr2.egg-info/requires.txt -qBitrr2.egg-info/top_level.txt diff --git a/qBitrr2.egg-info/dependency_links.txt b/qBitrr2.egg-info/dependency_links.txt deleted file mode 100644 index e69de29bb..000000000 diff --git a/qBitrr2.egg-info/entry_points.txt b/qBitrr2.egg-info/entry_points.txt deleted file mode 100644 index 1169dee4f..000000000 --- a/qBitrr2.egg-info/entry_points.txt +++ /dev/null @@ -1,2 +0,0 @@ -[console_scripts] -qbitrr = qBitrr.main:run diff --git a/qBitrr2.egg-info/requires.txt b/qBitrr2.egg-info/requires.txt deleted file mode 100644 index 4fecb58e2..000000000 --- a/qBitrr2.egg-info/requires.txt +++ /dev/null @@ -1,44 +0,0 @@ -cachetools -colorama -coloredlogs -flask -environ-config -ffmpeg-python -jaraco.docker -packaging -pathos -peewee -ping3 -pyarr -qbittorrent-api -requests -tomlkit -waitress -croniter - -[all] -black==24.3.0 -bump2version==1.0.1 -isort==5.10.1 -pip-tools==7.3.0 -pre-commit==3.3.3 -pyinstaller==5.13.1 -pyupgrade==2.31.0 -twine==3.7.1 -ujson==5.10.0 -upgrade-pip==0.1.4 - -[dev] -black==24.3.0 -bump2version==1.0.1 -isort==5.10.1 -pip-tools==7.3.0 -pre-commit==3.3.3 -pyinstaller==5.13.1 -pyupgrade==2.31.0 -twine==3.7.1 -ujson==5.10.0 -upgrade-pip==0.1.4 - -[fast] -ujson==5.10.0 diff --git a/qBitrr2.egg-info/top_level.txt b/qBitrr2.egg-info/top_level.txt deleted file mode 100644 index 9a7700f4c..000000000 --- a/qBitrr2.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -qBitrr diff --git a/requirements.docs.txt b/requirements.docs.txt index 84d563ab6..efaf3e9a3 100644 --- a/requirements.docs.txt +++ b/requirements.docs.txt @@ -1,5 +1,5 @@ markdown-include>=0.8.0 -mkdocs>=1.5.3 +mkdocs>=1.5.3,<1.6 mkdocs-git-revision-date-localized-plugin>=1.2.0 mkdocs-include-markdown-plugin>=6.0.0 mkdocs-material>=9.5.0 diff --git a/scripts/README.md b/scripts/README.md index b80083e83..5270ea883 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,6 +1,55 @@ # qBitrr Database Recovery Scripts -This directory contains utility scripts for database maintenance and recovery. +This directory contains utility scripts for database maintenance, recovery, and deployment. + +## Rebuild and deploy on git sync + +To **rebuild and deploy qBitrr automatically after every `git pull`**, install the post-merge hook. The hook runs the Python rebuild script when available (works on Windows, Linux, macOS); otherwise it falls back to the bash script (requires bash, e.g. Git Bash on Windows). + +```bash +# From repo root (Linux/macOS or Git Bash on Windows) +bash scripts/install-post-merge-hook.sh +``` + +After installation, each time you run `git pull` (and a merge happens), the hook will: + +- **If `docker-compose.yml` exists:** run `docker compose build --no-cache` and `docker compose up -d`. +- **Otherwise:** run `make syncenv` (sync venv and build WebUI). Restart qBitrr manually if you use systemd. + +**Override behaviour** with `DEPLOY_MODE`: + +- `DEPLOY_MODE=docker` — force Docker Compose rebuild and deploy. +- `DEPLOY_MODE=native` — force `make syncenv` only (no Docker). + +Example (before pulling): + +```bash +export DEPLOY_MODE=native +git pull +``` + +**Manual run** (without git), any environment: + +```bash +# Cross-platform (Windows, Linux, macOS) — use when bash is not available +python scripts/rebuild_and_deploy.py +# or +python3 scripts/rebuild_and_deploy.py +``` + +```bash +# Linux/macOS or Git Bash on Windows +bash scripts/rebuild-and-deploy.sh +``` + +**Files:** + +- `scripts/rebuild_and_deploy.py` — Rebuild and deploy logic (Python; works in any environment). +- `scripts/rebuild-and-deploy.sh` — Same logic for bash (Linux/macOS/Git Bash). +- `scripts/post-merge.hook` — Hook content (installed into `.git/hooks/post-merge`; runs Python script when available, else bash). +- `scripts/install-post-merge-hook.sh` — One-time installer for the hook. + +--- ## Scripts diff --git a/scripts/install-post-merge-hook.sh b/scripts/install-post-merge-hook.sh new file mode 100644 index 000000000..a0766b5c3 --- /dev/null +++ b/scripts/install-post-merge-hook.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Install the post-merge hook so that 'git pull' triggers rebuild and deploy. +# Run once from repo root: bash scripts/install-post-merge-hook.sh + +set -e + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +HOOK_SRC="$REPO_ROOT/scripts/post-merge.hook" +HOOK_DST="$REPO_ROOT/.git/hooks/post-merge" + +if [ ! -d "$REPO_ROOT/.git" ]; then + echo "Not a git repo: $REPO_ROOT" >&2 + exit 1 +fi + +if [ ! -f "$HOOK_SRC" ]; then + echo "Hook template missing: $HOOK_SRC" >&2 + exit 1 +fi + +cp "$HOOK_SRC" "$HOOK_DST" +chmod +x "$HOOK_DST" +echo "Installed post-merge hook at .git/hooks/post-merge" +echo "After 'git pull', qBitrr will rebuild and deploy (Docker or native per DEPLOY_MODE)." +echo "Hook uses Python script when available (any OS), else bash script." diff --git a/scripts/post-merge.hook b/scripts/post-merge.hook new file mode 100644 index 000000000..54ed60298 --- /dev/null +++ b/scripts/post-merge.hook @@ -0,0 +1,15 @@ +#!/bin/sh +# Git post-merge hook: rebuild and deploy qBitrr after every git pull (merge). +# Install with: scripts/install-post-merge-hook.sh +# Uses Python script when available (cross-platform); falls back to bash script. + +REPO_ROOT="$(git rev-parse --show-toplevel)" +cd "$REPO_ROOT" || exit 1 + +if command -v python3 >/dev/null 2>&1; then + exec python3 scripts/rebuild_and_deploy.py +fi +if command -v python >/dev/null 2>&1; then + exec python scripts/rebuild_and_deploy.py +fi +exec bash scripts/rebuild-and-deploy.sh diff --git a/scripts/rebuild-and-deploy.sh b/scripts/rebuild-and-deploy.sh new file mode 100644 index 000000000..6489fc93c --- /dev/null +++ b/scripts/rebuild-and-deploy.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Rebuild and deploy qBitrr (for use after git pull / sync). +# Supports: Docker Compose deploy, or native make syncenv only. +# Set DEPLOY_MODE=docker (default if docker-compose.yml present) or DEPLOY_MODE=native. + +set -e + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +echo "[rebuild-and-deploy] Working in $REPO_ROOT" + +# Default: use docker if docker-compose.yml exists +if [ -z "${DEPLOY_MODE}" ]; then + if [ -f "docker-compose.yml" ]; then + DEPLOY_MODE=docker + else + DEPLOY_MODE=native + fi +fi + +case "${DEPLOY_MODE}" in + docker) + echo "[rebuild-and-deploy] Rebuilding and deploying with Docker Compose..." + docker compose build --no-cache + docker compose up -d + echo "[rebuild-and-deploy] Done. Container status:" + docker compose ps + ;; + native) + echo "[rebuild-and-deploy] Syncing environment and building WebUI (make syncenv)..." + make syncenv + echo "[rebuild-and-deploy] Done. Restart qBitrr manually if needed (e.g. systemctl restart qbitrr)." + ;; + *) + echo "[rebuild-and-deploy] Unknown DEPLOY_MODE=${DEPLOY_MODE}. Use 'docker' or 'native'." >&2 + exit 1 + ;; +esac diff --git a/scripts/rebuild_and_deploy.py b/scripts/rebuild_and_deploy.py new file mode 100644 index 000000000..14d345aa9 --- /dev/null +++ b/scripts/rebuild_and_deploy.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +""" +Rebuild and deploy qBitrr (for use after git pull / sync). +Cross-platform: runs on Windows, Linux, and macOS without bash. + +Supports: Docker Compose deploy, or native make syncenv only. +Set DEPLOY_MODE=docker (default if docker-compose.yml present) or DEPLOY_MODE=native. +""" + +from __future__ import annotations + +import os +import subprocess +import sys + + +def main() -> int: + repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + os.chdir(repo_root) + + print(f"[rebuild-and-deploy] Working in {repo_root}") + + deploy_mode = os.environ.get("DEPLOY_MODE", "").strip().lower() + if not deploy_mode: + deploy_mode = "docker" if os.path.isfile("docker-compose.yml") else "native" + + if deploy_mode == "docker": + print("[rebuild-and-deploy] Rebuilding and deploying with Docker Compose...") + for cmd in [ + ["docker", "compose", "build", "--no-cache"], + ["docker", "compose", "up", "-d"], + ]: + ret = subprocess.run(cmd) + if ret.returncode != 0: + return ret.returncode + print("[rebuild-and-deploy] Done. Container status:") + subprocess.run(["docker", "compose", "ps"]) + elif deploy_mode == "native": + print("[rebuild-and-deploy] Syncing environment and building WebUI (make syncenv)...") + ret = subprocess.run(["make", "syncenv"]) + if ret.returncode != 0: + return ret.returncode + print( + "[rebuild-and-deploy] Done. Restart qBitrr manually if needed " + "(e.g. systemctl restart qbitrr)." + ) + else: + print( + f"[rebuild-and-deploy] Unknown DEPLOY_MODE={deploy_mode}. Use 'docker' or 'native'.", + file=sys.stderr, + ) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/setup.cfg b/setup.cfg index f35406380..264cf352e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -83,7 +83,6 @@ include_package_data = True [options.packages.find] include = qBitrr - config.example.toml [options.package_data] qBitrr = diff --git a/webui/package-lock.json b/webui/package-lock.json index 41048a148..0825c9a07 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -1,12 +1,8 @@ { - "name": "webui", - "version": "0.0.0", "lockfileVersion": 3, - "requires": true, + "name": "webui", "packages": { "": { - "name": "webui", - "version": "0.0.0", "dependencies": { "@mantine/core": "^8.3.15", "@mantine/dates": "^8.3.15", @@ -46,26 +42,24 @@ "engines": { "node": ">=20.0.0", "npm": ">=9.0.0" - } + }, + "name": "webui", + "version": "0.0.0" }, "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", "dev": true, - "license": "MIT", "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" - } + }, + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "version": "5.2.0" }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", @@ -73,24 +67,23 @@ }, "engines": { "node": ">=6.9.0" - } + }, + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "version": "7.29.0" }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "dev": true, - "license": "MIT", "engines": { "node": ">=6.9.0" - } + }, + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "version": "7.29.0" }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "dev": true, - "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -108,19 +101,20 @@ "json5": "^2.2.3", "semver": "^6.3.1" }, + "dev": true, "engines": { "node": ">=6.9.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/babel" - } + }, + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "version": "7.29.0" }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", - "license": "MIT", "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", @@ -130,14 +124,13 @@ }, "engines": { "node": ">=6.9.0" - } + }, + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "version": "7.29.1" }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", - "dev": true, - "license": "MIT", "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", @@ -145,163 +138,164 @@ "lru-cache": "^5.1.1", "semver": "^6.3.1" }, + "dev": true, "engines": { "node": ">=6.9.0" - } + }, + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "version": "7.28.6" }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "license": "MIT", "engines": { "node": ">=6.9.0" - } + }, + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "version": "7.28.0" }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", - "license": "MIT", "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" - } + }, + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "version": "7.28.6" }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", - "dev": true, - "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, + "dev": true, "engines": { "node": ">=6.9.0" }, + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "license": "MIT", "peerDependencies": { "@babel/core": "^7.0.0" - } + }, + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "version": "7.28.6" }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "dev": true, - "license": "MIT", "engines": { "node": ">=6.9.0" - } + }, + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "version": "7.28.6" }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "license": "MIT", "engines": { "node": ">=6.9.0" - } + }, + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "version": "7.27.1" }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "license": "MIT", "engines": { "node": ">=6.9.0" - } + }, + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "version": "7.28.5" }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, - "license": "MIT", "engines": { "node": ">=6.9.0" - } + }, + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "version": "7.27.1" }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", - "dev": true, - "license": "MIT", "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" }, + "dev": true, "engines": { "node": ">=6.9.0" - } + }, + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "version": "7.28.6" }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, "bin": { "parser": "bin/babel-parser.js" }, + "dependencies": { + "@babel/types": "^7.29.0" + }, "engines": { "node": ">=6.0.0" - } + }, + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "version": "7.29.0" }, "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, + "dev": true, "engines": { "node": ">=6.9.0" }, + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "license": "MIT", "peerDependencies": { "@babel/core": "^7.0.0-0" - } + }, + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "version": "7.27.1" }, "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, - "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, + "dev": true, "engines": { "node": ">=6.9.0" }, + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "license": "MIT", "peerDependencies": { "@babel/core": "^7.0.0-0" - } + }, + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "version": "7.27.1" }, "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", - "license": "MIT", "engines": { "node": ">=6.9.0" - } + }, + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "version": "7.28.6" }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "license": "MIT", "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", @@ -309,13 +303,13 @@ }, "engines": { "node": ">=6.9.0" - } + }, + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "version": "7.28.6" }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", - "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -327,26 +321,26 @@ }, "engines": { "node": ">=6.9.0" - } + }, + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "version": "7.29.0" }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" - } + }, + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "version": "7.29.0" }, "node_modules/@emotion/babel-plugin": { - "version": "11.13.5", - "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", - "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", - "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", @@ -359,44 +353,44 @@ "find-root": "^1.1.0", "source-map": "^0.5.7", "stylis": "4.2.0" - } + }, + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "version": "11.13.5" }, "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "version": "1.9.0" }, "node_modules/@emotion/cache": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", - "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", - "license": "MIT", "dependencies": { "@emotion/memoize": "^0.9.0", "@emotion/sheet": "^1.4.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "stylis": "4.2.0" - } + }, + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "version": "11.14.0" }, "node_modules/@emotion/hash": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "version": "0.9.2" }, "node_modules/@emotion/memoize": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "version": "0.9.0" }, "node_modules/@emotion/react": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", - "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -407,6 +401,8 @@ "@emotion/weak-memoize": "^0.4.0", "hoist-non-react-statics": "^3.3.1" }, + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", "peerDependencies": { "react": ">=16.8.0" }, @@ -414,591 +410,591 @@ "@types/react": { "optional": true } - } + }, + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "version": "11.14.0" }, "node_modules/@emotion/serialize": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", - "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", - "license": "MIT", "dependencies": { "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/unitless": "^0.10.0", "@emotion/utils": "^1.4.2", "csstype": "^3.0.2" - } + }, + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "version": "1.3.3" }, "node_modules/@emotion/sheet": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "version": "1.4.0" }, "node_modules/@emotion/unitless": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "version": "0.10.0" }, "node_modules/@emotion/use-insertion-effect-with-fallbacks": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", "license": "MIT", "peerDependencies": { "react": ">=16.8.0" - } + }, + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "version": "1.2.0" }, "node_modules/@emotion/utils": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "version": "1.4.2" }, "node_modules/@emotion/weak-memoize": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "version": "0.4.0" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "cpu": [ "ppc64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "license": "MIT", "optional": true, "os": [ "aix" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "cpu": [ "arm" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "license": "MIT", "optional": true, "os": [ "android" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", "cpu": [ "arm64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", "license": "MIT", "optional": true, "os": [ "android" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", "cpu": [ "x64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", "license": "MIT", "optional": true, "os": [ "android" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "cpu": [ "arm64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "license": "MIT", "optional": true, "os": [ "darwin" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", "cpu": [ "x64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", "license": "MIT", "optional": true, "os": [ "darwin" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "cpu": [ "arm64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "license": "MIT", "optional": true, "os": [ "freebsd" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", "cpu": [ "x64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", "license": "MIT", "optional": true, "os": [ "freebsd" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", "cpu": [ "arm" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "cpu": [ "arm64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "cpu": [ "ia32" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "cpu": [ "loong64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", "cpu": [ "mips64el" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", "cpu": [ "ppc64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", "cpu": [ "riscv64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", "cpu": [ "s390x" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "cpu": [ "x64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", "cpu": [ "arm64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", "license": "MIT", "optional": true, "os": [ "netbsd" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", "cpu": [ "x64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", "license": "MIT", "optional": true, "os": [ "netbsd" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", "cpu": [ "arm64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", "license": "MIT", "optional": true, "os": [ "openbsd" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", "cpu": [ "x64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", "license": "MIT", "optional": true, "os": [ "openbsd" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", "cpu": [ "arm64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", "license": "MIT", "optional": true, "os": [ "openharmony" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "cpu": [ "x64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "license": "MIT", "optional": true, "os": [ "sunos" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", "cpu": [ "arm64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", "license": "MIT", "optional": true, "os": [ "win32" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", "cpu": [ "ia32" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", "license": "MIT", "optional": true, "os": [ "win32" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", "cpu": [ "x64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", "license": "MIT", "optional": true, "os": [ "win32" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, - "license": "MIT", "dependencies": { "eslint-visitor-keys": "^3.4.3" }, + "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/eslint" }, + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "license": "MIT", "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } + }, + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "version": "4.9.1" }, "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/eslint" - } + }, + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "version": "3.4.3" }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, - "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } + }, + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "version": "4.12.2" }, "node_modules/@eslint/config-array": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.2.tgz", - "integrity": "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==", - "dev": true, - "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^3.0.2", "debug": "^4.3.1", "minimatch": "^10.2.1" }, + "dev": true, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" - } + }, + "integrity": "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.2.tgz", + "version": "0.23.2" }, "node_modules/@eslint/config-helpers": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.2.tgz", - "integrity": "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==", - "dev": true, - "license": "Apache-2.0", "dependencies": { "@eslint/core": "^1.1.0" }, + "dev": true, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" - } + }, + "integrity": "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.2.tgz", + "version": "0.5.2" }, "node_modules/@eslint/core": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz", - "integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==", - "dev": true, - "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, + "dev": true, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" - } + }, + "integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz", + "version": "1.1.0" }, "node_modules/@eslint/js": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", - "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, - "license": "MIT", "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" }, + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "license": "MIT", "peerDependencies": { "eslint": "^10.0.0" }, @@ -1006,188 +1002,186 @@ "eslint": { "optional": true } - } + }, + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "version": "10.0.1" }, "node_modules/@eslint/object-schema": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.2.tgz", - "integrity": "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" - } + }, + "integrity": "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.2.tgz", + "version": "3.0.2" }, "node_modules/@eslint/plugin-kit": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz", - "integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==", - "dev": true, - "license": "Apache-2.0", "dependencies": { "@eslint/core": "^1.1.0", "levn": "^0.4.1" }, + "dev": true, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" - } + }, + "integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz", + "version": "0.6.0" }, "node_modules/@floating-ui/core": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", - "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", - "license": "MIT", "dependencies": { "@floating-ui/utils": "^0.2.10" - } + }, + "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", + "version": "1.7.4" }, "node_modules/@floating-ui/dom": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", - "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", - "license": "MIT", "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" - } + }, + "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", + "version": "1.7.5" }, "node_modules/@floating-ui/react": { - "version": "0.27.17", - "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.17.tgz", - "integrity": "sha512-LGVZKHwmWGg6MRHjLLgsfyaX2y2aCNgnD1zT/E6B+/h+vxg+nIJUqHPAlTzsHDyqdgEpJ1Np5kxWuFEErXzoGg==", - "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.1.7", "@floating-ui/utils": "^0.2.10", "tabbable": "^6.0.0" }, + "integrity": "sha512-LGVZKHwmWGg6MRHjLLgsfyaX2y2aCNgnD1zT/E6B+/h+vxg+nIJUqHPAlTzsHDyqdgEpJ1Np5kxWuFEErXzoGg==", + "license": "MIT", "peerDependencies": { "react": ">=17.0.0", "react-dom": ">=17.0.0" - } + }, + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.17.tgz", + "version": "0.27.17" }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", - "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==", - "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.7.5" }, + "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==", + "license": "MIT", "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" - } + }, + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", + "version": "2.1.7" }, "node_modules/@floating-ui/utils": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "version": "0.2.10" }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "dev": true, - "license": "Apache-2.0", "engines": { "node": ">=18.18.0" - } + }, + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "version": "0.19.1" }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", - "dev": true, - "license": "Apache-2.0", "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" }, + "dev": true, "engines": { "node": ">=18.18.0" - } + }, + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "version": "0.16.7" }, "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, - "license": "Apache-2.0", "engines": { "node": ">=12.22" }, "funding": { "type": "github", "url": "https://github.com/sponsors/nzakas" - } + }, + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "version": "1.0.1" }, "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, - "license": "Apache-2.0", "engines": { "node": ">=18.18" }, "funding": { "type": "github", "url": "https://github.com/sponsors/nzakas" - } + }, + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "version": "0.4.3" }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" - } + }, + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "version": "0.3.13" }, "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" - } + }, + "dev": true, + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "version": "2.3.5" }, "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "license": "MIT", "engines": { "node": ">=6.0.0" - } + }, + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "version": "3.1.2" }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "version": "1.5.5" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" - } + }, + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "version": "0.3.31" }, "node_modules/@mantine/core": { - "version": "8.3.15", - "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.15.tgz", - "integrity": "sha512-wBn/GogB4x7a2Uj7Ztt3amRaApjED+9XqfE4wyCLh88R7KV55k9vnTdCx+irI/GLOOu9tXNUGm3a4t5sTajwkQ==", - "license": "MIT", "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", @@ -1196,42 +1190,42 @@ "react-textarea-autosize": "8.5.9", "type-fest": "^4.41.0" }, + "integrity": "sha512-wBn/GogB4x7a2Uj7Ztt3amRaApjED+9XqfE4wyCLh88R7KV55k9vnTdCx+irI/GLOOu9tXNUGm3a4t5sTajwkQ==", + "license": "MIT", "peerDependencies": { "@mantine/hooks": "8.3.15", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" - } + }, + "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.15.tgz", + "version": "8.3.15" }, "node_modules/@mantine/dates": { - "version": "8.3.15", - "resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-8.3.15.tgz", - "integrity": "sha512-4WlGHCOAE4in88rQFNlPVl14e7WFWb+YBqxmx4rvAXLj9xLgUxYJO44fva1eIOwNPlTqwbx+GgsEr/HwlcmDMg==", - "license": "MIT", "dependencies": { "clsx": "^2.1.1" }, + "integrity": "sha512-4WlGHCOAE4in88rQFNlPVl14e7WFWb+YBqxmx4rvAXLj9xLgUxYJO44fva1eIOwNPlTqwbx+GgsEr/HwlcmDMg==", + "license": "MIT", "peerDependencies": { "@mantine/core": "8.3.15", "@mantine/hooks": "8.3.15", "dayjs": ">=1.0.0", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" - } + }, + "resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-8.3.15.tgz", + "version": "8.3.15" }, "node_modules/@mantine/hooks": { - "version": "8.3.15", - "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.15.tgz", "integrity": "sha512-AUSnpUlzttHzJht3CJ1YWi16iy6NWRwtyWO5RLGHHsmiW05DyG0qOPKF8+R5dLHuOCnl3XOu4roI2Y1ku9U04Q==", "license": "MIT", "peerDependencies": { "react": "^18.x || ^19.x" - } + }, + "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.15.tgz", + "version": "8.3.15" }, "node_modules/@melloware/react-logviewer": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/@melloware/react-logviewer/-/react-logviewer-6.3.5.tgz", - "integrity": "sha512-Tl9shKsJYvZBxUxCVu1ddKWd8YN7RAAMjOj9upE3RHl+CTR6Py6ArCqpjQ7nGQM2bgao8qYG0VMHubTCPRPxqQ==", - "license": "MPL-2.0", "dependencies": { "hotkeys-js": "4.0.0", "immutable": "5.1.4", @@ -1239,374 +1233,373 @@ "react-string-replace": "2.0.1", "virtua": "0.48.3" }, + "integrity": "sha512-Tl9shKsJYvZBxUxCVu1ddKWd8YN7RAAMjOj9upE3RHl+CTR6Py6ArCqpjQ7nGQM2bgao8qYG0VMHubTCPRPxqQ==", + "license": "MPL-2.0", "peerDependencies": { "react": ">=17.0.0", "react-dom": ">=17.0.0" - } + }, + "resolved": "https://registry.npmjs.org/@melloware/react-logviewer/-/react-logviewer-6.3.5.tgz", + "version": "6.3.5" }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.3", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", - "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", "dev": true, - "license": "MIT" + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "version": "1.0.0-rc.3" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], "dev": true, + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "license": "MIT", "optional": true, "os": [ "android" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "version": "4.59.0" }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], "dev": true, + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "license": "MIT", "optional": true, "os": [ "android" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "version": "4.59.0" }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], "dev": true, + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "license": "MIT", "optional": true, "os": [ "darwin" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "version": "4.59.0" }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], "dev": true, + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "license": "MIT", "optional": true, "os": [ "darwin" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "version": "4.59.0" }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], "dev": true, + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "license": "MIT", "optional": true, "os": [ "freebsd" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "version": "4.59.0" }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], "dev": true, + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "license": "MIT", "optional": true, "os": [ "freebsd" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "version": "4.59.0" }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], "dev": true, + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "version": "4.59.0" }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], "dev": true, + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "version": "4.59.0" }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], "dev": true, + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "version": "4.59.0" }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], "dev": true, + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "version": "4.59.0" }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "cpu": [ "loong64" ], "dev": true, + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "version": "4.59.0" }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], "dev": true, + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "version": "4.59.0" }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "cpu": [ "ppc64" ], "dev": true, + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "version": "4.59.0" }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], "dev": true, + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "version": "4.59.0" }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], "dev": true, + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "version": "4.59.0" }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], "dev": true, + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "version": "4.59.0" }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], "dev": true, + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "version": "4.59.0" }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], "dev": true, + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "version": "4.59.0" }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], "dev": true, + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "version": "4.59.0" }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "cpu": [ "x64" ], "dev": true, + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "license": "MIT", "optional": true, "os": [ "openbsd" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "version": "4.59.0" }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], "dev": true, + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "license": "MIT", "optional": true, "os": [ "openharmony" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "version": "4.59.0" }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], "dev": true, + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "version": "4.59.0" }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], "dev": true, + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "version": "4.59.0" }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], "dev": true, + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "version": "4.59.0" }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], "dev": true, + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "version": "4.59.0" }, "node_modules/@tailwindcss/node": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.0.tgz", - "integrity": "sha512-Yv+fn/o2OmL5fh/Ir62VXItdShnUxfpkMA4Y7jdeC8O81WPB8Kf6TT6GSHvnqgSwDzlB5iT7kDpeXxLsUS0T6Q==", - "dev": true, - "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", @@ -1615,17 +1608,20 @@ "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.0" - } + }, + "dev": true, + "integrity": "sha512-Yv+fn/o2OmL5fh/Ir62VXItdShnUxfpkMA4Y7jdeC8O81WPB8Kf6TT6GSHvnqgSwDzlB5iT7kDpeXxLsUS0T6Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/@tailwindcss/oxide": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.0.tgz", - "integrity": "sha512-AZqQzADaj742oqn2xjl5JbIOzZB/DGCYF/7bpvhA8KvjUj9HJkag6bBuwZvH1ps6dfgxNHyuJVlzSr2VpMgdTQ==", "dev": true, - "license": "MIT", "engines": { "node": ">= 20" }, + "integrity": "sha512-AZqQzADaj742oqn2xjl5JbIOzZB/DGCYF/7bpvhA8KvjUj9HJkag6bBuwZvH1ps6dfgxNHyuJVlzSr2VpMgdTQ==", + "license": "MIT", "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.0", "@tailwindcss/oxide-darwin-arm64": "4.2.0", @@ -1639,165 +1635,164 @@ "@tailwindcss/oxide-wasm32-wasi": "4.2.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.0", "@tailwindcss/oxide-win32-x64-msvc": "4.2.0" - } + }, + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.0.tgz", - "integrity": "sha512-F0QkHAVaW/JNBWl4CEKWdZ9PMb0khw5DCELAOnu+RtjAfx5Zgw+gqCHFvqg3AirU1IAd181fwOtJQ5I8Yx5wtw==", "cpu": [ "arm64" ], "dev": true, + "engines": { + "node": ">= 20" + }, + "integrity": "sha512-F0QkHAVaW/JNBWl4CEKWdZ9PMb0khw5DCELAOnu+RtjAfx5Zgw+gqCHFvqg3AirU1IAd181fwOtJQ5I8Yx5wtw==", "license": "MIT", "optional": true, "os": [ "android" ], - "engines": { - "node": ">= 20" - } + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.0.tgz", - "integrity": "sha512-I0QylkXsBsJMZ4nkUNSR04p6+UptjcwhcVo3Zu828ikiEqHjVmQL9RuQ6uT/cVIiKpvtVA25msu/eRV97JeNSA==", "cpu": [ "arm64" ], "dev": true, + "engines": { + "node": ">= 20" + }, + "integrity": "sha512-I0QylkXsBsJMZ4nkUNSR04p6+UptjcwhcVo3Zu828ikiEqHjVmQL9RuQ6uT/cVIiKpvtVA25msu/eRV97JeNSA==", "license": "MIT", "optional": true, "os": [ "darwin" ], - "engines": { - "node": ">= 20" - } + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.0.tgz", - "integrity": "sha512-6TmQIn4p09PBrmnkvbYQ0wbZhLtbaksCDx7Y7R3FYYx0yxNA7xg5KP7dowmQ3d2JVdabIHvs3Hx4K3d5uCf8xg==", "cpu": [ "x64" ], "dev": true, + "engines": { + "node": ">= 20" + }, + "integrity": "sha512-6TmQIn4p09PBrmnkvbYQ0wbZhLtbaksCDx7Y7R3FYYx0yxNA7xg5KP7dowmQ3d2JVdabIHvs3Hx4K3d5uCf8xg==", "license": "MIT", "optional": true, "os": [ "darwin" ], - "engines": { - "node": ">= 20" - } + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.0.tgz", - "integrity": "sha512-qBudxDvAa2QwGlq9y7VIzhTvp2mLJ6nD/G8/tI70DCDoneaUeLWBJaPcbfzqRIWraj+o969aDQKvKW9dvkUizw==", "cpu": [ "x64" ], "dev": true, + "engines": { + "node": ">= 20" + }, + "integrity": "sha512-qBudxDvAa2QwGlq9y7VIzhTvp2mLJ6nD/G8/tI70DCDoneaUeLWBJaPcbfzqRIWraj+o969aDQKvKW9dvkUizw==", "license": "MIT", "optional": true, "os": [ "freebsd" ], - "engines": { - "node": ">= 20" - } + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.0.tgz", - "integrity": "sha512-7XKkitpy5NIjFZNUQPeUyNJNJn1CJeV7rmMR+exHfTuOsg8rxIO9eNV5TSEnqRcaOK77zQpsyUkBWmPy8FgdSg==", "cpu": [ "arm" ], "dev": true, + "engines": { + "node": ">= 20" + }, + "integrity": "sha512-7XKkitpy5NIjFZNUQPeUyNJNJn1CJeV7rmMR+exHfTuOsg8rxIO9eNV5TSEnqRcaOK77zQpsyUkBWmPy8FgdSg==", "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">= 20" - } + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.0.tgz", - "integrity": "sha512-Mff5a5Q3WoQR01pGU1gr29hHM1N93xYrKkGXfPw/aRtK4bOc331Ho4Tgfsm5WDGvpevqMpdlkCojT3qlCQbCpA==", "cpu": [ "arm64" ], "dev": true, + "engines": { + "node": ">= 20" + }, + "integrity": "sha512-Mff5a5Q3WoQR01pGU1gr29hHM1N93xYrKkGXfPw/aRtK4bOc331Ho4Tgfsm5WDGvpevqMpdlkCojT3qlCQbCpA==", "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">= 20" - } + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.0.tgz", - "integrity": "sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A==", "cpu": [ "arm64" ], "dev": true, + "engines": { + "node": ">= 20" + }, + "integrity": "sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A==", "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">= 20" - } + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.0.tgz", - "integrity": "sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA==", "cpu": [ "x64" ], "dev": true, + "engines": { + "node": ">= 20" + }, + "integrity": "sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA==", "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">= 20" - } + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.0.tgz", - "integrity": "sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw==", "cpu": [ "x64" ], "dev": true, + "engines": { + "node": ">= 20" + }, + "integrity": "sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw==", "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">= 20" - } + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.0.tgz", - "integrity": "sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -1809,9 +1804,6 @@ "cpu": [ "wasm32" ], - "dev": true, - "license": "MIT", - "optional": true, "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", @@ -1820,127 +1812,129 @@ "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, + "dev": true, "engines": { "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.8.1", - "dev": true, - "inBundle": true, + }, + "integrity": "sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw==", "license": "MIT", "optional": true, + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.0.tgz", + "version": "4.2.0" + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.8.1", + }, "dev": true, "inBundle": true, "license": "MIT", "optional": true, + "version": "1.8.1" + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { "dependencies": { "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", + }, "dev": true, "inBundle": true, "license": "MIT", "optional": true, + "version": "1.8.1" + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { "dependencies": { "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", + }, "dev": true, "inBundle": true, "license": "MIT", "optional": true, + "version": "1.1.0" + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, + "dev": true, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" - } + }, + "inBundle": true, + "license": "MIT", + "optional": true, + "version": "1.1.1" }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.1", + "dependencies": { + "tslib": "^2.4.0" + }, "dev": true, "inBundle": true, "license": "MIT", "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } + "version": "0.10.1" }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { - "version": "2.8.1", "dev": true, "inBundle": true, "license": "0BSD", - "optional": true + "optional": true, + "version": "2.8.1" }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.0.tgz", - "integrity": "sha512-2UU/15y1sWDEDNJXxEIrfWKC2Yb4YgIW5Xz2fKFqGzFWfoMHWFlfa1EJlGO2Xzjkq/tvSarh9ZTjvbxqWvLLXA==", "cpu": [ "arm64" ], "dev": true, + "engines": { + "node": ">= 20" + }, + "integrity": "sha512-2UU/15y1sWDEDNJXxEIrfWKC2Yb4YgIW5Xz2fKFqGzFWfoMHWFlfa1EJlGO2Xzjkq/tvSarh9ZTjvbxqWvLLXA==", "license": "MIT", "optional": true, "os": [ "win32" ], - "engines": { - "node": ">= 20" - } + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.0.tgz", - "integrity": "sha512-CrFadmFoc+z76EV6LPG1jx6XceDsaCG3lFhyLNo/bV9ByPrE+FnBPckXQVP4XRkN76h3Fjt/a+5Er/oA/nCBvQ==", "cpu": [ "x64" ], "dev": true, + "engines": { + "node": ">= 20" + }, + "integrity": "sha512-CrFadmFoc+z76EV6LPG1jx6XceDsaCG3lFhyLNo/bV9ByPrE+FnBPckXQVP4XRkN76h3Fjt/a+5Er/oA/nCBvQ==", "license": "MIT", "optional": true, "os": [ "win32" ], - "engines": { - "node": ">= 20" - } + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/@tailwindcss/postcss": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.0.tgz", - "integrity": "sha512-u6YBacGpOm/ixPfKqfgrJEjMfrYmPD7gEFRoygS/hnQaRtV0VCBdpkx5Ouw9pnaLRwwlgGCuJw8xLpaR0hOrQg==", - "dev": true, - "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.0", "@tailwindcss/oxide": "4.2.0", "postcss": "^8.5.6", "tailwindcss": "4.2.0" - } + }, + "dev": true, + "integrity": "sha512-u6YBacGpOm/ixPfKqfgrJEjMfrYmPD7gEFRoygS/hnQaRtV0VCBdpkx5Ouw9pnaLRwwlgGCuJw8xLpaR0hOrQg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/@tanstack/react-table": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", - "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", - "license": "MIT", "dependencies": { "@tanstack/table-core": "8.21.3" }, @@ -1951,221 +1945,220 @@ "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" - } + }, + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "version": "8.21.3" }, "node_modules/@tanstack/table-core": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", - "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", - "license": "MIT", "engines": { "node": ">=12" }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" - } + }, + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "version": "8.21.3" }, "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + }, "dev": true, + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "version": "7.20.5" + }, + "node_modules/@types/babel__generator": { "dependencies": { "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + }, "dev": true, + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "version": "7.27.0" + }, + "node_modules/@types/babel__template": { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + }, "dev": true, + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "version": "7.4.4" + }, + "node_modules/@types/babel__traverse": { "dependencies": { "@babel/types": "^7.28.2" - } + }, + "dev": true, + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "version": "7.28.0" }, "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", - "license": "MIT", "dependencies": { "@types/ms": "*" - } + }, + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "version": "4.1.12" }, "node_modules/@types/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", "dev": true, - "license": "MIT" + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "version": "4.3.1" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "version": "1.0.8" }, "node_modules/@types/estree-jsx": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", - "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", - "license": "MIT", "dependencies": { "@types/estree": "*" - } + }, + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "version": "1.0.5" }, "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", - "license": "MIT", "dependencies": { "@types/unist": "*" - } + }, + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "version": "3.0.4" }, "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, - "license": "MIT" + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "version": "7.0.15" }, "node_modules/@types/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", "dev": true, - "license": "MIT" + "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", + "version": "4.17.23" }, "node_modules/@types/lodash-es": { - "version": "4.17.12", - "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", - "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", - "dev": true, - "license": "MIT", "dependencies": { "@types/lodash": "*" - } + }, + "dev": true, + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "version": "4.17.12" }, "node_modules/@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "license": "MIT", "dependencies": { "@types/unist": "*" - } + }, + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "version": "4.0.4" }, "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "version": "2.1.0" }, "node_modules/@types/node": { - "version": "25.3.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", - "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", - "dev": true, - "license": "MIT", "dependencies": { "undici-types": "~7.18.0" - } + }, + "dev": true, + "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", + "version": "25.3.0" }, "node_modules/@types/parse-json": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "version": "4.0.2" }, "node_modules/@types/prismjs": { - "version": "1.26.6", - "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "version": "1.26.6" }, "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, - "license": "MIT", "dependencies": { "csstype": "^3.2.2" - } + }, + "dev": true, + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "version": "19.2.14" }, "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" - } + }, + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "version": "19.2.3" }, "node_modules/@types/react-syntax-highlighter": { - "version": "15.5.13", - "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz", - "integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==", - "dev": true, - "license": "MIT", "dependencies": { "@types/react": "*" - } + }, + "dev": true, + "integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz", + "version": "15.5.13" }, "node_modules/@types/react-transition-group": { - "version": "4.4.12", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", "license": "MIT", "peerDependencies": { "@types/react": "*" - } + }, + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "version": "4.4.12" }, "node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "version": "3.0.3" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", - "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==", - "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.0", @@ -2176,6 +2169,7 @@ "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, + "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2183,28 +2177,27 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, + "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==", + "license": "MIT", "peerDependencies": { "@typescript-eslint/parser": "^8.56.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" - } + }, + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", + "version": "8.56.0" }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, - "license": "MIT", "engines": { "node": ">= 4" - } + }, + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "version": "7.0.5" }, "node_modules/@typescript-eslint/parser": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", - "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", - "dev": true, - "license": "MIT", "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -2212,6 +2205,7 @@ "@typescript-eslint/visitor-keys": "8.56.0", "debug": "^4.4.3" }, + "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2219,22 +2213,22 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, + "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", + "license": "MIT", "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" - } + }, + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", + "version": "8.56.0" }, "node_modules/@typescript-eslint/project-service": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz", - "integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==", - "dev": true, - "license": "MIT", "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.56.0", "@typescript-eslint/types": "^8.56.0", "debug": "^4.4.3" }, + "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2242,34 +2236,34 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, + "integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==", + "license": "MIT", "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" - } + }, + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz", + "version": "8.56.0" }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz", - "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==", - "dev": true, - "license": "MIT", "dependencies": { "@typescript-eslint/types": "8.56.0", "@typescript-eslint/visitor-keys": "8.56.0" }, + "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" - } + }, + "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz", + "version": "8.56.0" }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz", - "integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==", "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2277,16 +2271,15 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, + "integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==", + "license": "MIT", "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" - } + }, + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz", + "version": "8.56.0" }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz", - "integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==", - "dev": true, - "license": "MIT", "dependencies": { "@typescript-eslint/types": "8.56.0", "@typescript-eslint/typescript-estree": "8.56.0", @@ -2294,6 +2287,7 @@ "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, + "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2301,31 +2295,30 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, + "integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==", + "license": "MIT", "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" - } + }, + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz", + "version": "8.56.0" }, "node_modules/@typescript-eslint/types": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", - "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" - } + }, + "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", + "version": "8.56.0" }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz", - "integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==", - "dev": true, - "license": "MIT", "dependencies": { "@typescript-eslint/project-service": "8.56.0", "@typescript-eslint/tsconfig-utils": "8.56.0", @@ -2337,6 +2330,7 @@ "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, + "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2344,68 +2338,68 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, + "integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==", + "license": "MIT", "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" - } + }, + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz", + "version": "8.56.0" }, "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, - "license": "MIT" + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "version": "1.0.2" }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" - } + }, + "dev": true, + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "version": "2.0.2" }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^2.0.2" }, + "dev": true, "engines": { "node": ">=16 || 14 >=14.17" }, "funding": { "url": "https://github.com/sponsors/isaacs" - } + }, + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "version": "9.0.9" }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", "bin": { "semver": "bin/semver.js" }, + "dev": true, "engines": { "node": ">=10" - } + }, + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "version": "7.7.4" }, "node_modules/@typescript-eslint/utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz", - "integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==", - "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", "@typescript-eslint/typescript-estree": "8.56.0" }, + "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2413,41 +2407,40 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, + "integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==", + "license": "MIT", "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" - } + }, + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz", + "version": "8.56.0" }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz", - "integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==", - "dev": true, - "license": "MIT", "dependencies": { "@typescript-eslint/types": "8.56.0", "eslint-visitor-keys": "^5.0.0" }, + "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" - } + }, + "integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz", + "version": "8.56.0" }, "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "license": "ISC" + "license": "ISC", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "version": "1.3.0" }, "node_modules/@vitejs/plugin-react": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", - "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", - "dev": true, - "license": "MIT", "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", @@ -2456,58 +2449,73 @@ "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, + "dev": true, "engines": { "node": "^20.19.0 || >=22.12.0" }, + "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", + "license": "MIT", "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } + }, + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", + "version": "5.1.4" }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", "bin": { "acorn": "bin/acorn" }, + "dev": true, "engines": { "node": ">=0.4.0" - } + }, + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "version": "8.16.0" }, "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } + }, + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "version": "5.3.2" }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" }, + "dev": true, "funding": { "type": "github", "url": "https://github.com/sponsors/epoberezkin" - } + }, + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "version": "6.12.6" }, "node_modules/autoprefixer": { - "version": "10.4.24", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", - "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001766", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, "dev": true, + "engines": { + "node": "^10 || ^12 || >=14" + }, "funding": [ { "type": "opencollective", @@ -2522,29 +2530,15 @@ "url": "https://github.com/sponsors/ai" } ], + "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", "license": "MIT", - "dependencies": { - "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001766", - "fraction.js": "^5.3.4", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" + "peerDependencies": { + "postcss": "^8.1.0" }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", + "version": "10.4.24" }, "node_modules/babel-plugin-macros": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", - "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", @@ -2553,56 +2547,70 @@ "engines": { "node": ">=10", "npm": ">=6" - } + }, + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "version": "3.1.0" }, "node_modules/bail": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", - "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "version": "2.0.2" }, "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT", "engines": { "node": "18 || 20 || >=22" - } + }, + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "version": "4.0.4" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", - "dev": true, - "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" - } + }, + "dev": true, + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "version": "2.9.19" }, "node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", - "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, + "dev": true, "engines": { "node": "18 || 20 || >=22" - } + }, + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "version": "5.0.3" }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "bin": { + "browserslist": "cli.js" + }, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, "dev": true, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + }, "funding": [ { "type": "opencollective", @@ -2617,34 +2625,21 @@ "url": "https://github.com/sponsors/ai" } ], + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "version": "4.28.1" }, "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "license": "MIT", "engines": { "node": ">=6" - } + }, + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "version": "3.1.0" }, "node_modules/caniuse-lite": { - "version": "1.0.30001769", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", - "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", "dev": true, "funding": [ { @@ -2660,89 +2655,88 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "CC-BY-4.0" + "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", + "license": "CC-BY-4.0", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", + "version": "1.0.30001769" }, "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "version": "2.0.1" }, "node_modules/character-entities": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", - "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "version": "2.0.2" }, "node_modules/character-entities-html4": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", - "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "version": "2.1.0" }, "node_modules/character-entities-legacy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", - "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "version": "3.0.0" }, "node_modules/character-reference-invalid": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", - "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "version": "2.0.1" }, "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", "engines": { "node": ">=6" - } + }, + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "version": "2.1.1" }, "node_modules/comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "version": "2.0.3" }, "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, - "license": "MIT" + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "version": "2.0.0" }, "node_modules/cosmiconfig": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", - "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", - "license": "MIT", "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", @@ -2752,157 +2746,159 @@ }, "engines": { "node": ">=10" - } + }, + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "version": "7.1.0" }, "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" }, + "dev": true, "engines": { "node": ">= 8" - } + }, + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "version": "7.0.6" }, "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "version": "3.2.3" }, "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", "dependencies": { "ms": "^2.1.3" }, "engines": { "node": ">=6.0" }, + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", "peerDependenciesMeta": { "supports-color": { "optional": true } - } + }, + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "version": "4.4.3" }, "node_modules/decode-named-character-reference": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", - "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", - "license": "MIT", "dependencies": { "character-entities": "^2.0.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "version": "1.3.0" }, "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, - "license": "MIT" + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "version": "0.1.4" }, "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "license": "MIT", "engines": { "node": ">=6" - } + }, + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "version": "2.0.3" }, "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, - "license": "Apache-2.0", "engines": { "node": ">=8" - } + }, + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "version": "2.1.2" }, "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "version": "1.1.0" }, "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "license": "MIT", "dependencies": { "dequal": "^2.0.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "version": "1.1.0" }, "node_modules/dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" - } + }, + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "version": "5.2.1" }, "node_modules/electron-to-chromium": { - "version": "1.5.286", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", - "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", "dev": true, - "license": "ISC" + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "version": "1.5.286" }, "node_modules/enhanced-resolve": { - "version": "5.19.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", - "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", - "dev": true, - "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" }, + "dev": true, "engines": { "node": ">=10.13.0" - } + }, + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "version": "5.19.0" }, "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" - } + }, + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "version": "1.3.4" }, "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, + "dev": true, "engines": { "node": ">=18" }, + "hasInstallScript": true, + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "license": "MIT", "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", @@ -2930,36 +2926,36 @@ "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" - } + }, + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" - } + }, + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "version": "3.2.0" }, "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "license": "MIT", "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" - } + }, + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "version": "4.0.0" }, "node_modules/eslint": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.1.tgz", - "integrity": "sha512-20MV9SUdeN6Jd84xESsKhRly+/vxI+hwvpBMA93s+9dAcjdCuCojn4IqUGS3lvVaqjVYGYHSRMCpeFtF2rQYxQ==", - "dev": true, - "license": "MIT", + "bin": { + "eslint": "bin/eslint.js" + }, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -2992,15 +2988,15 @@ "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, - "bin": { - "eslint": "bin/eslint.js" - }, + "dev": true, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" }, + "integrity": "sha512-20MV9SUdeN6Jd84xESsKhRly+/vxI+hwvpBMA93s+9dAcjdCuCojn4IqUGS3lvVaqjVYGYHSRMCpeFtF2rQYxQ==", + "license": "MIT", "peerDependencies": { "jiti": "*" }, @@ -3008,14 +3004,11 @@ "jiti": { "optional": true } - } + }, + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.1.tgz", + "version": "10.0.1" }, "node_modules/eslint-plugin-react-hooks": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", - "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", - "dev": true, - "license": "MIT", "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", @@ -3023,177 +3016,180 @@ "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, + "dev": true, "engines": { "node": ">=18" }, + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "license": "MIT", "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" - } + }, + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "version": "7.0.1" }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.1.tgz", - "integrity": "sha512-Y5sJsreCUdGcF4mLD70iJNa47Z6CX4MsqJoJBARDC/fBhmacSby7k73UuValr0F9M7GfWKpEqS4NMsniWkVxQw==", "dev": true, + "integrity": "sha512-Y5sJsreCUdGcF4mLD70iJNa47Z6CX4MsqJoJBARDC/fBhmacSby7k73UuValr0F9M7GfWKpEqS4NMsniWkVxQw==", "license": "MIT", "peerDependencies": { "eslint": "^9 || ^10" - } + }, + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.1.tgz", + "version": "0.5.1" }, "node_modules/eslint-scope": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.1.tgz", - "integrity": "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==", - "dev": true, - "license": "BSD-2-Clause", "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, + "dev": true, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" - } + }, + "integrity": "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==", + "license": "BSD-2-Clause", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.1.tgz", + "version": "9.1.1" }, "node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" - } + }, + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "version": "5.0.1" }, "node_modules/espree": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.1.1.tgz", - "integrity": "sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==", - "dev": true, - "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" }, + "dev": true, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" - } + }, + "integrity": "sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==", + "license": "BSD-2-Clause", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.1.1.tgz", + "version": "11.1.1" }, "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" }, + "dev": true, "engines": { "node": ">=0.10" - } + }, + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "license": "BSD-3-Clause", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "version": "1.7.0" }, "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, + "dev": true, "engines": { "node": ">=4.0" - } + }, + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "license": "BSD-2-Clause", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "version": "4.3.0" }, "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, - "license": "BSD-2-Clause", "engines": { "node": ">=4.0" - } + }, + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "version": "5.3.0" }, "node_modules/estree-util-is-identifier-name": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", - "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", - "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "version": "3.0.0" }, "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, - "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" - } + }, + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "version": "2.0.3" }, "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "version": "3.0.2" }, "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "version": "3.1.3" }, "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, - "license": "MIT" + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "version": "2.1.0" }, "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, - "license": "MIT" + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "version": "2.0.6" }, "node_modules/fault": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", - "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==", - "license": "MIT", "dependencies": { "format": "^0.2.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", + "version": "1.0.4" }, "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, - "license": "MIT", "engines": { "node": ">=12.0.0" }, + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -3201,193 +3197,191 @@ "picomatch": { "optional": true } - } + }, + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "version": "6.5.0" }, "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", "dependencies": { "flat-cache": "^4.0.0" }, + "dev": true, "engines": { "node": ">=16.0.0" - } + }, + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "version": "8.0.0" }, "node_modules/find-root": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "version": "1.1.0" }, "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" }, + "dev": true, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" - } + }, + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "version": "5.0.0" }, "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" }, + "dev": true, "engines": { "node": ">=16" - } + }, + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "version": "4.0.1" }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true, - "license": "ISC" + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "version": "3.3.3" }, "node_modules/format": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", - "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", "engines": { "node": ">=0.4.x" - } + }, + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "version": "0.2.2" }, "node_modules/fraction.js": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", - "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", "dev": true, - "license": "MIT", "engines": { "node": "*" }, "funding": { "type": "github", "url": "https://github.com/sponsors/rawify" - } + }, + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "version": "5.3.4" }, "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + }, "hasInstallScript": true, + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "license": "MIT", "optional": true, "os": [ "darwin" ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "version": "2.3.3" }, "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" - } + }, + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "version": "1.1.2" }, "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true, - "license": "MIT", "engines": { "node": ">=6.9.0" - } + }, + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "version": "1.0.0-beta.2" }, "node_modules/get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", - "license": "MIT", "engines": { "node": ">=6" - } + }, + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "version": "1.0.1" }, "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", "dependencies": { "is-glob": "^4.0.3" }, + "dev": true, "engines": { "node": ">=10.13.0" - } + }, + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "version": "6.0.2" }, "node_modules/globals": { - "version": "17.3.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz", - "integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==", "dev": true, - "license": "MIT", "engines": { "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" - } + }, + "integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz", + "version": "17.3.0" }, "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true, - "license": "ISC" + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "version": "4.2.11" }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", "dependencies": { "function-bind": "^1.1.2" }, "engines": { "node": ">= 0.4" - } + }, + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "version": "2.0.2" }, "node_modules/hast-util-parse-selector": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", - "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", - "license": "MIT", "dependencies": { "@types/hast": "^3.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "version": "4.0.0" }, "node_modules/hast-util-to-jsx-runtime": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", - "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", - "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", @@ -3408,26 +3402,26 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "version": "2.3.6" }, "node_modules/hast-util-whitespace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", - "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", - "license": "MIT", "dependencies": { "@types/hast": "^3.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "version": "3.0.0" }, "node_modules/hastscript": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", - "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", - "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", @@ -3438,99 +3432,99 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "version": "9.0.1" }, "node_modules/hermes-estree": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", - "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", "dev": true, - "license": "MIT" + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "version": "0.25.1" }, "node_modules/hermes-parser": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", - "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", - "dev": true, - "license": "MIT", "dependencies": { "hermes-estree": "0.25.1" - } + }, + "dev": true, + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "version": "0.25.1" }, "node_modules/highlight.js": { - "version": "10.7.3", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", - "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", - "license": "BSD-3-Clause", "engines": { "node": "*" - } + }, + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "version": "10.7.3" }, "node_modules/highlightjs-vue": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz", "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", - "license": "CC0-1.0" + "license": "CC0-1.0", + "resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz", + "version": "1.0.0" }, "node_modules/hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "license": "BSD-3-Clause", "dependencies": { "react-is": "^16.7.0" - } + }, + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "version": "3.3.2" }, "node_modules/hotkeys-js": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/hotkeys-js/-/hotkeys-js-4.0.0.tgz", - "integrity": "sha512-gIoeqMWYqPIItc4HaseVbtTRpEpBbeufZMUcoWtN62JZdDq3KadS1ijN6wpaDjTzRK7PjT3QOPUcx+yNT0rrZQ==", - "license": "MIT", "funding": { "url": "https://jaywcjlove.github.io/#/sponsor" - } + }, + "integrity": "sha512-gIoeqMWYqPIItc4HaseVbtTRpEpBbeufZMUcoWtN62JZdDq3KadS1ijN6wpaDjTzRK7PjT3QOPUcx+yNT0rrZQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/hotkeys-js/-/hotkeys-js-4.0.0.tgz", + "version": "4.0.0" }, "node_modules/html-url-attributes": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", - "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", - "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "version": "3.0.1" }, "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, - "license": "MIT", "engines": { "node": ">= 4" - } + }, + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "version": "5.3.2" }, "node_modules/immer": { - "version": "11.1.4", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", - "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", - "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" - } + }, + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "version": "11.1.4" }, "node_modules/immutable": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", + "version": "5.1.4" }, "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "license": "MIT", "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -3540,39 +3534,39 @@ }, "funding": { "url": "https://github.com/sponsors/sindresorhus" - } + }, + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "version": "3.3.1" }, "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.8.19" - } + }, + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "version": "0.1.4" }, "node_modules/inline-style-parser": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "version": "0.2.7" }, "node_modules/is-alphabetical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", - "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "version": "2.0.1" }, "node_modules/is-alphanumerical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", - "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", - "license": "MIT", "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" @@ -3580,19 +3574,19 @@ "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "version": "2.0.1" }, "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "version": "0.2.1" }, "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "license": "MIT", "dependencies": { "hasown": "^2.0.2" }, @@ -3601,171 +3595,171 @@ }, "funding": { "url": "https://github.com/sponsors/ljharb" - } + }, + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "version": "2.16.1" }, "node_modules/is-decimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", - "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "version": "2.0.1" }, "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" - } + }, + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "version": "2.1.1" }, "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" }, + "dev": true, "engines": { "node": ">=0.10.0" - } + }, + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "version": "4.0.3" }, "node_modules/is-hexadecimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", - "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "version": "2.0.1" }, "node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "license": "MIT", "engines": { "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" - } + }, + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "version": "4.1.0" }, "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, - "license": "ISC" + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "version": "2.0.0" }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, - "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" - } + }, + "dev": true, + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "version": "2.6.1" }, "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "version": "4.0.0" }, "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "license": "MIT", "bin": { "jsesc": "bin/jsesc" }, "engines": { "node": ">=6" - } + }, + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "version": "3.1.0" }, "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, - "license": "MIT" + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "version": "3.0.1" }, "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "version": "2.3.1" }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, - "license": "MIT" + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "version": "0.4.1" }, "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, - "license": "MIT" + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "version": "1.0.1" }, "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", "bin": { "json5": "lib/cli.js" }, + "dev": true, "engines": { "node": ">=6" - } + }, + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "version": "2.2.3" }, "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", "dependencies": { "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + }, "dev": true, + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "license": "MIT", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "version": "4.5.4" + }, + "node_modules/levn": { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" }, + "dev": true, "engines": { "node": ">= 0.8.0" - } + }, + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "version": "0.4.1" }, "node_modules/lightningcss": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", - "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", - "dev": true, - "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" }, + "dev": true, "engines": { "node": ">= 12.0.0" }, @@ -3773,6 +3767,8 @@ "type": "opencollective", "url": "https://opencollective.com/parcel" }, + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "license": "MPL-2.0", "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", @@ -3785,294 +3781,292 @@ "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" - } + }, + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "version": "1.31.1" }, "node_modules/lightningcss-android-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", - "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", "cpu": [ "arm64" ], "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], "engines": { "node": ">= 12.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" - } + }, + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "version": "1.31.1" }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", - "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", "cpu": [ "arm64" ], "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], "engines": { "node": ">= 12.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" - } + }, + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "version": "1.31.1" }, "node_modules/lightningcss-darwin-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", - "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", "cpu": [ "x64" ], "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], "engines": { "node": ">= 12.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" - } + }, + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "version": "1.31.1" }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", - "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", "cpu": [ "x64" ], "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], "engines": { "node": ">= 12.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" - } + }, + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "version": "1.31.1" }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", - "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", "cpu": [ "arm" ], "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], "engines": { "node": ">= 12.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" - } + }, + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "version": "1.31.1" }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", - "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", "cpu": [ "arm64" ], "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], "engines": { "node": ">= 12.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" - } + }, + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "version": "1.31.1" }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", - "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", "cpu": [ "arm64" ], "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], "engines": { "node": ">= 12.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" - } + }, + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "version": "1.31.1" }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", - "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", "cpu": [ "x64" ], "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], "engines": { "node": ">= 12.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" - } + }, + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "version": "1.31.1" }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", - "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", "cpu": [ "x64" ], "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], "engines": { "node": ">= 12.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" - } + }, + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "version": "1.31.1" }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", - "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", "cpu": [ "arm64" ], "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], "engines": { "node": ">= 12.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" - } + }, + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "version": "1.31.1" }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", - "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", "cpu": [ "x64" ], "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], "engines": { "node": ">= 12.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" - } + }, + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "version": "1.31.1" }, "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "version": "1.2.4" }, "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", "dependencies": { "p-locate": "^5.0.0" }, + "dev": true, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" - } + }, + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "version": "6.0.0" }, "node_modules/lodash-es": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "version": "4.17.23" }, "node_modules/longest-streak": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", - "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "version": "3.1.0" }, "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", + "bin": { + "loose-envify": "cli.js" + }, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, - "bin": { - "loose-envify": "cli.js" - } + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "version": "1.4.0" }, "node_modules/lowlight": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", - "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==", - "license": "MIT", "dependencies": { "fault": "^1.0.0", "highlight.js": "~10.7.0" @@ -4080,33 +4074,33 @@ "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", + "version": "1.20.0" }, "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", "dependencies": { "yallist": "^3.0.2" - } + }, + "dev": true, + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "version": "5.1.1" }, "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" - } + }, + "dev": true, + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "version": "0.30.21" }, "node_modules/mdast-util-from-markdown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", - "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", - "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", @@ -4124,13 +4118,13 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "version": "2.0.2" }, "node_modules/mdast-util-mdx-expression": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", - "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", - "license": "MIT", "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", @@ -4142,13 +4136,13 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "version": "2.0.1" }, "node_modules/mdast-util-mdx-jsx": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", - "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", - "license": "MIT", "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", @@ -4166,13 +4160,13 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "version": "3.2.0" }, "node_modules/mdast-util-mdxjs-esm": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", - "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", - "license": "MIT", "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", @@ -4184,13 +4178,13 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "version": "2.0.1" }, "node_modules/mdast-util-phrasing": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", - "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", - "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" @@ -4198,13 +4192,13 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "version": "4.1.0" }, "node_modules/mdast-util-to-hast": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", - "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", - "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", @@ -4219,13 +4213,13 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "version": "13.2.1" }, "node_modules/mdast-util-to-markdown": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", - "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", - "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", @@ -4240,42 +4234,32 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "version": "2.1.2" }, "node_modules/mdast-util-to-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", - "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", - "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "version": "4.0.0" }, "node_modules/memoize-one": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "version": "6.0.0" }, "node_modules/micromark": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", - "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", @@ -4294,12 +4278,7 @@ "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-core-commonmark": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", - "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + }, "funding": [ { "type": "GitHub Sponsors", @@ -4310,7 +4289,12 @@ "url": "https://opencollective.com/unified" } ], + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "version": "4.0.2" + }, + "node_modules/micromark-core-commonmark": { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", @@ -4328,12 +4312,7 @@ "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-destination": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", - "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + }, "funding": [ { "type": "GitHub Sponsors", @@ -4344,17 +4323,17 @@ "url": "https://opencollective.com/unified" } ], + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "version": "2.0.3" + }, + "node_modules/micromark-factory-destination": { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-label": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", - "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + }, "funding": [ { "type": "GitHub Sponsors", @@ -4365,18 +4344,18 @@ "url": "https://opencollective.com/unified" } ], + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "version": "2.0.1" + }, + "node_modules/micromark-factory-label": { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + }, "funding": [ { "type": "GitHub Sponsors", @@ -4387,16 +4366,16 @@ "url": "https://opencollective.com/unified" } ], + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "version": "2.0.1" + }, + "node_modules/micromark-factory-space": { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-title": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", - "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + }, "funding": [ { "type": "GitHub Sponsors", @@ -4407,18 +4386,18 @@ "url": "https://opencollective.com/unified" } ], + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "version": "2.0.1" + }, + "node_modules/micromark-factory-title": { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-whitespace": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", - "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + }, "funding": [ { "type": "GitHub Sponsors", @@ -4429,18 +4408,18 @@ "url": "https://opencollective.com/unified" } ], + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "version": "2.0.1" + }, + "node_modules/micromark-factory-whitespace": { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + }, "funding": [ { "type": "GitHub Sponsors", @@ -4451,16 +4430,16 @@ "url": "https://opencollective.com/unified" } ], + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "version": "2.0.1" + }, + "node_modules/micromark-util-character": { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-chunked": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", - "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + }, "funding": [ { "type": "GitHub Sponsors", @@ -4471,15 +4450,15 @@ "url": "https://opencollective.com/unified" } ], + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "version": "2.1.1" + }, + "node_modules/micromark-util-chunked": { "dependencies": { "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-classify-character": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", - "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + }, "funding": [ { "type": "GitHub Sponsors", @@ -4490,17 +4469,17 @@ "url": "https://opencollective.com/unified" } ], + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "version": "2.0.1" + }, + "node_modules/micromark-util-classify-character": { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-combine-extensions": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", - "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + }, "funding": [ { "type": "GitHub Sponsors", @@ -4511,16 +4490,16 @@ "url": "https://opencollective.com/unified" } ], + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "version": "2.0.1" + }, + "node_modules/micromark-util-combine-extensions": { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-numeric-character-reference": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", - "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + }, "funding": [ { "type": "GitHub Sponsors", @@ -4531,15 +4510,15 @@ "url": "https://opencollective.com/unified" } ], + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "version": "2.0.1" + }, + "node_modules/micromark-util-decode-numeric-character-reference": { "dependencies": { "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-string": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", - "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + }, "funding": [ { "type": "GitHub Sponsors", @@ -4550,18 +4529,18 @@ "url": "https://opencollective.com/unified" } ], + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "version": "2.0.2" + }, + "node_modules/micromark-util-decode-string": { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", - "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + }, "funding": [ { "type": "GitHub Sponsors", @@ -4572,12 +4551,12 @@ "url": "https://opencollective.com/unified" } ], - "license": "MIT" + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "version": "2.0.1" }, - "node_modules/micromark-util-html-tag-name": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", - "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "node_modules/micromark-util-encode": { "funding": [ { "type": "GitHub Sponsors", @@ -4588,12 +4567,12 @@ "url": "https://opencollective.com/unified" } ], - "license": "MIT" + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "version": "2.0.1" }, - "node_modules/micromark-util-normalize-identifier": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", - "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "node_modules/micromark-util-html-tag-name": { "funding": [ { "type": "GitHub Sponsors", @@ -4604,15 +4583,15 @@ "url": "https://opencollective.com/unified" } ], + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "version": "2.0.1" + }, + "node_modules/micromark-util-normalize-identifier": { "dependencies": { "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-resolve-all": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", - "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + }, "funding": [ { "type": "GitHub Sponsors", @@ -4623,15 +4602,15 @@ "url": "https://opencollective.com/unified" } ], + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "version": "2.0.1" + }, + "node_modules/micromark-util-resolve-all": { "dependencies": { "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", - "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + }, "funding": [ { "type": "GitHub Sponsors", @@ -4642,17 +4621,17 @@ "url": "https://opencollective.com/unified" } ], + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "version": "2.0.1" + }, + "node_modules/micromark-util-sanitize-uri": { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-subtokenize": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", - "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + }, "funding": [ { "type": "GitHub Sponsors", @@ -4663,18 +4642,34 @@ "url": "https://opencollective.com/unified" } ], + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "version": "2.0.1" + }, + "node_modules/micromark-util-subtokenize": { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" - } + }, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "version": "2.1.0" }, "node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", "funding": [ { "type": "GitHub Sponsors", @@ -4685,12 +4680,12 @@ "url": "https://opencollective.com/unified" } ], - "license": "MIT" + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "version": "2.0.1" }, "node_modules/micromark-util-types": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", - "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", "funding": [ { "type": "GitHub Sponsors", @@ -4701,84 +4696,82 @@ "url": "https://opencollective.com/unified" } ], - "license": "MIT" + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "version": "2.0.2" }, "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.2" }, + "dev": true, "engines": { "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" - } + }, + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "license": "BlueOak-1.0.0", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "version": "10.2.4" }, "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "version": "3.0.1" }, "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "version": "2.1.3" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, "dev": true, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + }, "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "version": "3.3.11" }, "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true, - "license": "MIT" + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "version": "1.4.0" }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "dev": true, - "license": "MIT" + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "version": "2.0.27" }, "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", "engines": { "node": ">=0.10.0" - } + }, + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "version": "4.1.1" }, "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -4787,59 +4780,60 @@ "type-check": "^0.4.0", "word-wrap": "^1.2.5" }, + "dev": true, "engines": { "node": ">= 0.8.0" - } + }, + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "version": "0.9.4" }, "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" }, + "dev": true, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" - } + }, + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "version": "3.1.0" }, "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", "dependencies": { "p-limit": "^3.0.2" }, + "dev": true, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" - } + }, + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "version": "5.0.0" }, "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "license": "MIT", "dependencies": { "callsites": "^3.0.0" }, "engines": { "node": ">=6" - } + }, + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "version": "1.0.1" }, "node_modules/parse-entities": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", - "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", - "license": "MIT", "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", @@ -4852,19 +4846,19 @@ "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "version": "4.0.2" }, "node_modules/parse-entities/node_modules/@types/unist": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "version": "2.0.11" }, "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", @@ -4876,67 +4870,76 @@ }, "funding": { "url": "https://github.com/sponsors/sindresorhus" - } + }, + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "version": "5.2.0" }, "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" - } + }, + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "version": "4.0.0" }, "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" - } + }, + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "version": "3.1.1" }, "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "version": "1.0.7" }, "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "license": "MIT", "engines": { "node": ">=8" - } + }, + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "version": "4.0.0" }, "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" + "license": "ISC", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "version": "1.1.1" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" - } + }, + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "version": "4.0.3" }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, "dev": true, + "engines": { + "node": "^10 || ^12 || >=14" + }, "funding": [ { "type": "opencollective", @@ -4951,99 +4954,90 @@ "url": "https://github.com/sponsors/ai" } ], + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "version": "8.5.6" }, "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "dev": true, - "license": "MIT" + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, - "license": "MIT", "engines": { "node": ">= 0.8.0" - } + }, + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "version": "1.2.1" }, "node_modules/prismjs": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", - "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", - "license": "MIT", "engines": { "node": ">=6" - } + }, + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "version": "1.30.0" }, "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" - } + }, + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "version": "15.8.1" }, "node_modules/property-information": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", - "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "version": "7.1.0" }, "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" - } + }, + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "version": "2.3.1" }, "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", - "license": "MIT", "engines": { "node": ">=0.10.0" - } + }, + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "version": "19.2.4" }, "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", - "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", "peerDependencies": { "react": "^19.2.4" - } + }, + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "version": "19.2.4" }, "node_modules/react-hook-form": { - "version": "7.71.2", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", - "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", - "license": "MIT", "engines": { "node": ">=18.0.0" }, @@ -5051,21 +5045,21 @@ "type": "opencollective", "url": "https://opencollective.com/react-hook-form" }, + "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", + "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" - } + }, + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", + "version": "7.71.2" }, "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "version": "16.13.1" }, "node_modules/react-markdown": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", - "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", - "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", @@ -5083,36 +5077,36 @@ "type": "opencollective", "url": "https://opencollective.com/unified" }, + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", "peerDependencies": { "@types/react": ">=18", "react": ">=18" - } + }, + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "version": "10.1.0" }, "node_modules/react-number-format": { - "version": "5.4.4", - "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.4.tgz", "integrity": "sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA==", "license": "MIT", "peerDependencies": { "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } + }, + "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.4.tgz", + "version": "5.4.4" }, "node_modules/react-refresh": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", - "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" - } + }, + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "version": "0.18.0" }, "node_modules/react-remove-scroll": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", - "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", - "license": "MIT", "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", @@ -5123,6 +5117,8 @@ "engines": { "node": ">=10" }, + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" @@ -5131,13 +5127,11 @@ "@types/react": { "optional": true } - } + }, + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "version": "2.7.2" }, "node_modules/react-remove-scroll-bar": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", - "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", - "license": "MIT", "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" @@ -5145,6 +5139,8 @@ "engines": { "node": ">=10" }, + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -5153,13 +5149,11 @@ "@types/react": { "optional": true } - } + }, + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "version": "2.3.8" }, "node_modules/react-select": { - "version": "5.10.2", - "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz", - "integrity": "sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.0", "@emotion/cache": "^11.4.0", @@ -5171,22 +5165,22 @@ "react-transition-group": "^4.3.0", "use-isomorphic-layout-effect": "^1.2.0" }, + "integrity": "sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ==", + "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } + }, + "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz", + "version": "5.10.2" }, "node_modules/react-string-replace": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/react-string-replace/-/react-string-replace-2.0.1.tgz", "integrity": "sha512-J7y/IxZkwhOgCexu6GtkNH2FIa9f/z6ykvK1YXS8VLIdvcP8Vyx3LAUFsfIFvq3aQbmin2d+clFR1J50LnR2yA==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/react-string-replace/-/react-string-replace-2.0.1.tgz", + "version": "2.0.1" }, "node_modules/react-style-singleton": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", - "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", - "license": "MIT", "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" @@ -5194,6 +5188,8 @@ "engines": { "node": ">=10" }, + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" @@ -5202,13 +5198,11 @@ "@types/react": { "optional": true } - } + }, + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "version": "2.2.3" }, "node_modules/react-syntax-highlighter": { - "version": "16.1.0", - "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.0.tgz", - "integrity": "sha512-E40/hBiP5rCNwkeBN1vRP+xow1X0pndinO+z3h7HLsHyjztbyjfzNWNKuAsJj+7DLam9iT4AaaOZnueCU+Nplg==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.4", "highlight.js": "^10.4.1", @@ -5220,15 +5214,15 @@ "engines": { "node": ">= 16.20.2" }, + "integrity": "sha512-E40/hBiP5rCNwkeBN1vRP+xow1X0pndinO+z3h7HLsHyjztbyjfzNWNKuAsJj+7DLam9iT4AaaOZnueCU+Nplg==", + "license": "MIT", "peerDependencies": { "react": ">= 0.14.0" - } + }, + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.0.tgz", + "version": "16.1.0" }, "node_modules/react-textarea-autosize": { - "version": "8.5.9", - "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz", - "integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.13", "use-composed-ref": "^1.3.0", @@ -5237,31 +5231,31 @@ "engines": { "node": ">=10" }, + "integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==", + "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } + }, + "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz", + "version": "8.5.9" }, "node_modules/react-transition-group": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", - "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", - "license": "BSD-3-Clause", "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" - } + }, + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "version": "4.4.5" }, "node_modules/refractor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz", - "integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==", - "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", "@types/prismjs": "^1.0.0", @@ -5271,13 +5265,13 @@ "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz", + "version": "5.0.0" }, "node_modules/remark-parse": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", - "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", - "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", @@ -5287,13 +5281,13 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "version": "11.0.0" }, "node_modules/remark-rehype": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", - "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", - "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", @@ -5304,53 +5298,55 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "version": "11.1.2" }, "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "license": "MIT", + "bin": { + "resolve": "bin/resolve" + }, "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, - "bin": { - "resolve": "bin/resolve" - }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" - } + }, + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "version": "1.22.11" }, "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "license": "MIT", "engines": { "node": ">=4" - } + }, + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "version": "4.0.0" }, "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, "bin": { "rollup": "dist/bin/rollup" }, + "dependencies": { + "@types/estree": "1.0.8" + }, + "dev": true, "engines": { "node": ">=18.0.0", "npm": ">=8.0.0" }, + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "license": "MIT", "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", @@ -5378,81 +5374,79 @@ "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" - } + }, + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "version": "4.59.0" }, "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "version": "0.27.0" }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", "bin": { "semver": "bin/semver.js" - } + }, + "dev": true, + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "version": "6.3.1" }, "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" }, + "dev": true, "engines": { "node": ">=8" - } + }, + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "version": "2.0.0" }, "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" - } + }, + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "version": "3.0.0" }, "node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", - "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" - } + }, + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "version": "0.5.7" }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, - "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" - } + }, + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "version": "1.2.1" }, "node_modules/space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "version": "2.0.2" }, "node_modules/stringify-entities": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", - "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", - "license": "MIT", "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" @@ -5460,178 +5454,178 @@ "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "version": "4.0.4" }, "node_modules/style-to-js": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", - "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", - "license": "MIT", "dependencies": { "style-to-object": "1.0.14" - } + }, + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "version": "1.1.21" }, "node_modules/style-to-object": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", - "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", - "license": "MIT", "dependencies": { "inline-style-parser": "0.2.7" - } + }, + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "version": "1.0.14" }, "node_modules/stylis": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "license": "MIT", "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" - } + }, + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "version": "1.0.0" }, "node_modules/tabbable": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "version": "6.4.0" }, "node_modules/tailwindcss": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.0.tgz", - "integrity": "sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q==", "dev": true, - "license": "MIT" + "integrity": "sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/webpack" - } + }, + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "version": "2.3.0" }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" }, + "dev": true, "engines": { "node": ">=12.0.0" }, "funding": { "url": "https://github.com/sponsors/SuperchupuDev" - } + }, + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "version": "0.2.15" }, "node_modules/trim-lines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "version": "3.0.1" }, "node_modules/trough": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", - "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "version": "2.2.0" }, "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, - "license": "MIT", "engines": { "node": ">=18.12" }, + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "license": "MIT", "peerDependencies": { "typescript": ">=4.8.4" - } + }, + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "version": "2.4.0" }, "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "version": "2.8.1" }, "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1" }, + "dev": true, "engines": { "node": ">= 0.8.0" - } + }, + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "version": "0.4.0" }, "node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" - } + }, + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "version": "4.41.0" }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, + "dev": true, "engines": { "node": ">=14.17" - } + }, + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "version": "5.9.3" }, "node_modules/typescript-eslint": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz", - "integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==", - "dev": true, - "license": "MIT", "dependencies": { "@typescript-eslint/eslint-plugin": "8.56.0", "@typescript-eslint/parser": "8.56.0", "@typescript-eslint/typescript-estree": "8.56.0", "@typescript-eslint/utils": "8.56.0" }, + "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -5639,23 +5633,23 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, + "integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==", + "license": "MIT", "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" - } + }, + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz", + "version": "8.56.0" }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, - "license": "MIT" + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "version": "7.18.2" }, "node_modules/unified": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", - "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", - "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", @@ -5668,52 +5662,52 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "version": "11.0.5" }, "node_modules/unist-util-is": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", - "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", - "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "version": "6.0.1" }, "node_modules/unist-util-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", - "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", - "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "version": "5.0.0" }, "node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "version": "4.0.0" }, "node_modules/unist-util-visit": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", - "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", - "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", @@ -5722,13 +5716,13 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "version": "5.1.0" }, "node_modules/unist-util-visit-parents": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", - "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", - "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" @@ -5736,12 +5730,20 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "version": "6.0.2" }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "bin": { + "update-browserslist-db": "cli.js" + }, + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, "dev": true, "funding": [ { @@ -5757,39 +5759,33 @@ "url": "https://github.com/sponsors/ai" } ], + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, "peerDependencies": { "browserslist": ">= 4.21.0" - } + }, + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "version": "1.2.3" }, "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" - } + }, + "dev": true, + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "version": "4.4.1" }, "node_modules/use-callback-ref": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", - "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", - "license": "MIT", "dependencies": { "tslib": "^2.0.0" }, "engines": { "node": ">=10" }, + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" @@ -5798,11 +5794,11 @@ "@types/react": { "optional": true } - } + }, + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "version": "1.3.3" }, "node_modules/use-composed-ref": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz", "integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==", "license": "MIT", "peerDependencies": { @@ -5812,11 +5808,11 @@ "@types/react": { "optional": true } - } + }, + "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz", + "version": "1.4.0" }, "node_modules/use-isomorphic-layout-effect": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", "license": "MIT", "peerDependencies": { @@ -5826,16 +5822,16 @@ "@types/react": { "optional": true } - } + }, + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", + "version": "1.2.1" }, "node_modules/use-latest": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz", - "integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==", - "license": "MIT", "dependencies": { "use-isomorphic-layout-effect": "^1.1.1" }, + "integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==", + "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, @@ -5843,13 +5839,11 @@ "@types/react": { "optional": true } - } + }, + "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz", + "version": "1.3.0" }, "node_modules/use-sidecar": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", - "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", - "license": "MIT", "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" @@ -5857,6 +5851,8 @@ "engines": { "node": ">=10" }, + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" @@ -5865,13 +5861,11 @@ "@types/react": { "optional": true } - } + }, + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "version": "1.1.3" }, "node_modules/vfile": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", - "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", - "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" @@ -5879,13 +5873,13 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "version": "6.0.3" }, "node_modules/vfile-message": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", - "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", - "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" @@ -5893,11 +5887,13 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "version": "4.0.3" }, "node_modules/virtua": { - "version": "0.48.3", - "resolved": "https://registry.npmjs.org/virtua/-/virtua-0.48.3.tgz", "integrity": "sha512-WTGPqMshLNtTQaIhvNo6EnjMvBP0GAYN8ZJsTY6HZqQCJlspW0L9IrIDi4vYV2J7uBlnuSqemnw40IKTg/aeGA==", "license": "MIT", "peerDependencies": { @@ -5923,14 +5919,14 @@ "vue": { "optional": true } - } + }, + "resolved": "https://registry.npmjs.org/virtua/-/virtua-0.48.3.tgz", + "version": "0.48.3" }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", - "dev": true, - "license": "MIT", + "bin": { + "vite": "bin/vite.js" + }, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -5939,15 +5935,15 @@ "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, - "bin": { - "vite": "bin/vite.js" - }, + "dev": true, "engines": { "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" }, + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "license": "MIT", "optionalDependencies": { "fsevents": "~2.3.3" }, @@ -5998,95 +5994,99 @@ "yaml": { "optional": true } - } + }, + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "version": "7.3.1" }, "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, "bin": { "node-which": "bin/node-which" }, + "dependencies": { + "isexe": "^2.0.0" + }, + "dev": true, "engines": { "node": ">= 8" - } + }, + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "version": "2.0.2" }, "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" - } + }, + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "version": "1.2.5" }, "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, - "license": "ISC" + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "version": "3.1.1" }, "node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "license": "ISC", "engines": { "node": ">= 6" - } + }, + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "version": "1.10.2" }, "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" - } + }, + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "version": "0.1.0" }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, - "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" - } + }, + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "version": "4.3.6" }, "node_modules/zod-validation-error": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", - "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=18.0.0" }, + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "license": "MIT", "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" - } + }, + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "version": "4.0.2" }, "node_modules/zwitch": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "version": "2.0.4" } - } + }, + "requires": true, + "version": "0.0.0" } diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 64780f05f..e747e6f0c 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type JSX, lazy, Suspense } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState, type JSX, lazy, Suspense } from "react"; const ProcessesView = lazy(() => import("./pages/ProcessesView").then(module => ({ default: module.ProcessesView }))); const LogsView = lazy(() => import("./pages/LogsView").then(module => ({ default: module.LogsView }))); const ArrView = lazy(() => import("./pages/ArrView").then(module => ({ default: module.ArrView }))); @@ -26,6 +26,38 @@ import QbitIcon from "./icons/qbittorrent.svg"; import ConfigIcon from "./icons/gear.svg"; import LogoIcon from "./icons/logo.svg"; +class ErrorBoundary extends React.Component< + { children: React.ReactNode }, + { hasError: boolean; error: Error | null } +> { + constructor(props: { children: React.ReactNode }) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error) { + return { hasError: true, error }; + } + + render() { + if (this.state.hasError) { + return ( +
+

Something went wrong

+

{this.state.error?.message}

+ +
+ ); + } + return this.props.children; + } +} + type Tab = "processes" | "logs" | "radarr" | "sonarr" | "lidarr" | "qbittorrent" | "config"; interface NavTab { @@ -83,9 +115,11 @@ function WelcomeModal({

Release Notes

-
-              {changelog?.trim() ? changelog.trim() : "No changelog available for this version."}
-            
+
+ + {changelog?.trim() ? changelog.trim() : "No changelog available for this version."} + +
@@ -113,6 +147,77 @@ function WelcomeModal({ ); } +interface AlreadyUpToDateModalProps { + currentVersion: string; + changelog: string | null; + changelogUrl: string | null; + repositoryUrl: string; + onClose: () => void; +} + +function AlreadyUpToDateModal({ + currentVersion, + changelog, + changelogUrl, + repositoryUrl, + onClose, +}: AlreadyUpToDateModalProps): JSX.Element { + return ( +
+
event.stopPropagation()} + > +
+

✓ You're on the latest version

+ +
+
+
+

+ Current version: {formatVersionLabel(currentVersion)} +

+
+ {changelog?.trim() ? ( +
+

Release Notes

+
+ {changelog.trim()} +
+
+ ) : null} +
+
+
+ {(changelogUrl || repositoryUrl) && ( + + + View on GitHub + + )} +
+
+ +
+
+
+
+ ); +} + interface ChangelogModalProps { currentVersion: string; latestVersion: string | null; @@ -327,6 +432,7 @@ function AppShell(): JSX.Element { const [meta, setMeta] = useState(null); const [metaLoading, setMetaLoading] = useState(false); const [showChangelog, setShowChangelog] = useState(false); + const [showAlreadyUpToDateModal, setShowAlreadyUpToDateModal] = useState(false); const [updateBusy, setUpdateBusy] = useState(false); const [backendRestarting, setBackendRestarting] = useState(false); const restartPollCount = useRef(0); @@ -357,7 +463,7 @@ function AppShell(): JSX.Element { }, []); const refreshMeta = useCallback( - async (options?: { force?: boolean; silent?: boolean }) => { + async (options?: { force?: boolean; silent?: boolean }): Promise => { const force = options?.force ?? false; const silent = options?.silent ?? !force; if (!silent) { @@ -366,12 +472,14 @@ function AppShell(): JSX.Element { try { const data = await getMeta({ force }); setMeta(data); + return data; } catch (error) { if (!silent) { const message = error instanceof Error ? error.message : "Failed to fetch version information"; push(message, "error"); } + return null; } finally { if (!silent) { setMetaLoading(false); @@ -650,8 +758,15 @@ function AppShell(): JSX.Element { } }, [tabs, activeTab]); - const handleCheckUpdates = useCallback(() => { - void refreshMeta({ force: true }); + const handleCheckUpdates = useCallback(async () => { + const data = await refreshMeta({ force: true }); + if (data) { + if (data.update_available) { + setShowChangelog(true); + } else { + setShowAlreadyUpToDateModal(true); + } + } }, [refreshMeta]); const handleOpenChangelog = useCallback(() => { @@ -665,6 +780,10 @@ function AppShell(): JSX.Element { setShowChangelog(false); }, []); + const handleCloseAlreadyUpToDateModal = useCallback(() => { + setShowAlreadyUpToDateModal(false); + }, []); + const handleCloseWelcomeChangelog = useCallback(() => { setShowWelcomeChangelog(false); // Mark this version as seen @@ -743,7 +862,7 @@ function AppShell(): JSX.Element {
); } export default function App(): JSX.Element { return ( - - - - - - - - + + + + + + + + + + ); } diff --git a/webui/src/api/client.ts b/webui/src/api/client.ts index 16b3f4b60..9cfa3df61 100644 --- a/webui/src/api/client.ts +++ b/webui/src/api/client.ts @@ -45,6 +45,9 @@ function resolveToken(): string | null { const fromQuery = params.get("token"); if (fromQuery) { localStorage.setItem("token", fromQuery); + const cleanUrl = new URL(window.location.href); + cleanUrl.searchParams.delete("token"); + window.history.replaceState({}, "", cleanUrl.toString()); return fromQuery; } } catch { @@ -202,8 +205,20 @@ export async function getLogs(): Promise { return fetchJson("/web/logs"); } -export async function getLogTail(name: string): Promise { - return fetchTextResponse(`/web/logs/${encodeURIComponent(name)}`); +export async function getLogTail( + name: string, + lines?: number, + offset?: number +): Promise { + const base = `/web/logs/${encodeURIComponent(name)}`; + if (lines == null || lines <= 0) { + return fetchTextResponse(base); + } + const params = new URLSearchParams({ lines: String(lines) }); + if (offset != null && offset > 0) { + params.set("offset", String(offset)); + } + return fetchTextResponse(`${base}?${params.toString()}`); } export function getLogDownloadUrl(name: string): string { @@ -313,37 +328,10 @@ export async function getConfig(): Promise { export async function updateConfig( payload: ConfigUpdatePayload ): Promise { - const token = resolveToken(); - const response = await fetch("/web/config", buildInit({ + return fetchJson("/web/config", { method: "POST", body: JSON.stringify(payload), - }, token)); - - if (!response.ok) { - let detail: unknown = null; - try { - detail = await response.json(); - } catch { - // ignore - } - let message = `${response.status} ${response.statusText}`; - if ( - detail && - typeof detail === "object" && - "error" in detail && - typeof (detail as Record).error === "string" - ) { - const errorText = (detail as Record).error as string; - if (errorText.trim()) { - message = errorText; - } - } - throw new Error(message); - } - - // Parse response body with full type information - const data = await response.json() as ConfigUpdateResponse; - return data; + }); } export async function triggerUpdate(): Promise { diff --git a/webui/src/api/types.ts b/webui/src/api/types.ts index d71949196..7c84d6571 100644 --- a/webui/src/api/types.ts +++ b/webui/src/api/types.ts @@ -1,4 +1,4 @@ -export type ArrType = "radarr" | "sonarr" | "lidarr" | string; +export type ArrType = "radarr" | "sonarr" | "lidarr"; export interface ProcessInfo { category: string; @@ -108,6 +108,7 @@ export interface RadarrCounts { } export interface RadarrMovie { + [key: string]: unknown; id?: number; title?: string; year?: number; @@ -116,7 +117,6 @@ export interface RadarrMovie { reason?: string | null; qualityProfileId?: number | null; qualityProfileName?: string | null; - [key: string]: unknown; } export interface RadarrMoviesResponse { @@ -137,7 +137,6 @@ export interface SonarrEpisode { hasFile?: boolean; airDateUtc?: string; reason?: string | null; - [key: string]: unknown; } export interface SonarrSeason { @@ -207,10 +206,10 @@ export interface LidarrAlbum { percentOfTracks?: number; qualityProfileId?: number | null; qualityProfileName?: string | null; - [key: string]: unknown; } export interface LidarrAlbumEntry { + [key: string]: unknown; album: Record & { qualityProfileId?: number | null; qualityProfileName?: string | null; @@ -221,7 +220,6 @@ export interface LidarrAlbumEntry { missing?: number; }; tracks: LidarrTrack[]; - [key: string]: unknown; } export interface LidarrAlbumsResponse { diff --git a/webui/src/components/ConfirmDialog.tsx b/webui/src/components/ConfirmDialog.tsx index 1c5070096..1b20f97ac 100644 --- a/webui/src/components/ConfirmDialog.tsx +++ b/webui/src/components/ConfirmDialog.tsx @@ -26,10 +26,13 @@ export function ConfirmDialog({
e.stopPropagation()} >
-

{title}

+

{title}

diff --git a/webui/src/components/CopyButton.tsx b/webui/src/components/CopyButton.tsx index ee6fe4ea0..4bfde1d32 100644 --- a/webui/src/components/CopyButton.tsx +++ b/webui/src/components/CopyButton.tsx @@ -1,4 +1,4 @@ -import { useState, type JSX } from "react"; +import { useEffect, useRef, useState, type JSX } from "react"; import { copyToClipboard } from "../utils/clipboard"; interface CopyButtonProps { @@ -15,13 +15,19 @@ export function CopyButton({ onCopy, }: CopyButtonProps): JSX.Element { const [copied, setCopied] = useState(false); + const timerRef = useRef>(undefined); + + useEffect(() => { + return () => { clearTimeout(timerRef.current); }; + }, []); const handleCopy = async () => { const success = await copyToClipboard(text); if (success) { setCopied(true); onCopy?.(); - setTimeout(() => setCopied(false), 2000); + clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => setCopied(false), 2000); } }; diff --git a/webui/src/components/TagInput.tsx b/webui/src/components/TagInput.tsx index b37698825..494f7b5ee 100644 --- a/webui/src/components/TagInput.tsx +++ b/webui/src/components/TagInput.tsx @@ -33,7 +33,7 @@ export function TagInput({ value, onChange, placeholder, disabled }: TagInputPro
{value.map((tag, index) => ( - + {tag} {!disabled && (
@@ -2564,7 +2721,7 @@ function FieldGroup({ localStorage.setItem("theme", theme); } }} - styles={getSelectStyles()} + styles={selectStyles} /> {description &&
{description}
} {isThemeField &&
Theme changes apply immediately
} @@ -2584,6 +2741,21 @@ function FieldGroup({
); } + if (field.type === "duration") { + return ( +
+ + onChange(path, field, v)} + placeholder={field.placeholder} + nativeUnit={field.nativeUnit ?? "seconds"} + allowNegative={field.allowNegative ?? false} + /> + {description &&
{description}
} +
+ ); + } if (field.type === "password") { return (
diff --git a/webui/src/pages/LidarrView.tsx b/webui/src/pages/LidarrView.tsx index 4a24d6ab1..de483abeb 100644 --- a/webui/src/pages/LidarrView.tsx +++ b/webui/src/pages/LidarrView.tsx @@ -32,7 +32,6 @@ import { useDebounce } from "../hooks/useDebounce"; import { useDataSync } from "../hooks/useDataSync"; import { IconImage } from "../components/IconImage"; import RefreshIcon from "../icons/refresh-arrow.svg"; -import RestartIcon from "../icons/refresh-arrow.svg"; interface LidarrAggRow extends LidarrAlbumEntry { __instance: string; @@ -573,26 +572,6 @@ function LidarrInstanceView({ const isFiltered = reasonFilter !== "all" || onlyMissing; const filteredCount = reasonFilteredAlbums.length; - // Count total tracks for instance view - // Track counts (for potential future use) - // const totalTracks = useMemo(() => { - // let count = 0; - // allAlbums.forEach(entry => { - // const tracks = entry.tracks || []; - // count += tracks.length; - // }); - // return count; - // }, [allAlbums]); - - // const filteredTracks = useMemo(() => { - // let count = 0; - // reasonFilteredAlbums.forEach(entry => { - // const tracks = entry.tracks || []; - // count += tracks.length; - // }); - // return count; - // }, [reasonFilteredAlbums]); - // Reset flat page when filters change useEffect(() => { setFlatPage(0); @@ -777,7 +756,7 @@ function LidarrInstanceView({ {lastUpdated ? ` (updated ${lastUpdated})` : ""}
@@ -1365,7 +1344,7 @@ export function LidarrView({ active }: { active: boolean }): JSX.Element { } finally { setAggLoading(false); } - }, [instances, globalSearch, push, aggFilter, groupLidarr]); + }, [instances, globalSearch, push, aggFilter, groupLidarr, aggSummary]); // LiveArr is now loaded via WebUIContext, no need to load config here diff --git a/webui/src/pages/LogsView.tsx b/webui/src/pages/LogsView.tsx index d75259730..418dfd3ea 100644 --- a/webui/src/pages/LogsView.tsx +++ b/webui/src/pages/LogsView.tsx @@ -1,7 +1,8 @@ -import { useCallback, useEffect, useRef, useState, type JSX } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type JSX } from "react"; import { LazyLog } from "@melloware/react-logviewer"; -import { getConfig, getLogDownloadUrl, getLogs } from "../api/client"; +import { getLogDownloadUrl, getLogTail, getLogs } from "../api/client"; import { useToast } from "../context/ToastContext"; +import { useWebUI } from "../context/WebUIContext"; import { useInterval } from "../hooks/useInterval"; import { IconImage } from "../components/IconImage"; import { CopyButton } from "../components/CopyButton"; @@ -12,9 +13,7 @@ interface LogOption { label: string; } -// Helper function for react-select theme-aware styles -const getSelectStyles = (): StylesConfig => { - const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; +const getSelectStyles = (isDark: boolean): StylesConfig => { return { control: (base: CSSObjectWithLabel) => ({ ...base, @@ -70,27 +69,37 @@ interface LogsViewProps { active: boolean; } +function describeError(reason: unknown, context: string): string { + if (reason instanceof Error && reason.message) { + return `${context}: ${reason.message}`; + } + if (typeof reason === "string" && reason.trim().length) { + return `${context}: ${reason}`; + } + return context; +} + +/** Number of lines to fetch per chunk for the log viewer; keeps load fast. */ +const DEFAULT_LOG_TAIL_LINES = 2000; + export function LogsView({ active }: LogsViewProps): JSX.Element { const [files, setFiles] = useState([]); const [selected, setSelected] = useState("All.log"); const [logContent, setLogContent] = useState(""); const [follow, setFollow] = useState(true); + /** When true, poll for new log content every second; when false, only refresh on manual Refresh. */ + const [liveUpdates, setLiveUpdates] = useState(true); + /** Number of lines we've loaded "above" the tail (for load-more). When 0, we only have the tail. */ + const [offsetFromEnd, setOffsetFromEnd] = useState(0); const [loadingList, setLoadingList] = useState(false); const [loadingContent, setLoadingContent] = useState(false); - const [tokenReady, setTokenReady] = useState(false); - const tokenRef = useRef(""); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMoreAbove, setHasMoreAbove] = useState(true); const lastLinesCountRef = useRef(0); const { push } = useToast(); - - const describeError = useCallback((reason: unknown, context: string): string => { - if (reason instanceof Error && reason.message) { - return `${context}: ${reason.message}`; - } - if (typeof reason === "string" && reason.trim().length) { - return `${context}: ${reason}`; - } - return context; - }, []); + const { theme } = useWebUI(); + const isDark = theme === 'dark'; + const selectStyles = useMemo(() => getSelectStyles(isDark), [isDark]); const loadList = useCallback(async () => { setLoadingList(true); @@ -115,55 +124,30 @@ export function LogsView({ active }: LogsViewProps): JSX.Element { } finally { setLoadingList(false); } - }, [describeError, push]); - - // Fetch and cache the WebUI token from config on mount - useEffect(() => { - const fetchToken = async () => { - try { - const config = await getConfig(); - // Extract token from WebUI.Token field - const webui = config?.WebUI as { Token?: string } | undefined; - tokenRef.current = webui?.Token || ""; - } catch { - // token fetch failed, will proceed without token - } finally { - setTokenReady(true); - } - }; - void fetchToken(); - }, []); + }, [push]); useEffect(() => { void loadList(); }, [loadList]); const fetchLogContent = useCallback( - async (showLoading: boolean = false) => { + async (showLoading: boolean = false, onlyIfTail: boolean = false) => { if (!selected) return; + if (onlyIfTail && offsetFromEnd > 0) return; if (showLoading) setLoadingContent(true); try { - const params = new URLSearchParams(); - if (tokenRef.current) { - params.set("token", tokenRef.current); - } - - const response = await fetch( - `/web/logs/${encodeURIComponent(selected)}?${params}` - ); - if (!response.ok) { - throw new Error(`${response.status} ${response.statusText}`); - } - - const newContent = await response.text(); - const newLines = newContent.split('\n'); + const newContent = await getLogTail(selected, DEFAULT_LOG_TAIL_LINES, 0); + const newLines = newContent.split("\n"); const currentLinesCount = newLines.length; - // Only update if content has changed (new lines added) - if (currentLinesCount !== lastLinesCountRef.current) { + if (currentLinesCount !== lastLinesCountRef.current || !onlyIfTail) { setLogContent(newContent); lastLinesCountRef.current = currentLinesCount; + if (!onlyIfTail) { + setOffsetFromEnd(0); + setHasMoreAbove(true); + } } } catch (error) { push(describeError(error, `Failed to read ${selected}`), "error"); @@ -171,24 +155,55 @@ export function LogsView({ active }: LogsViewProps): JSX.Element { if (showLoading) setLoadingContent(false); } }, - [selected, push, describeError] + [selected, push, offsetFromEnd] ); + const loadMoreAbove = useCallback(async () => { + if (!selected || loadingMore || !hasMoreAbove) return; + const nextOffset = offsetFromEnd + DEFAULT_LOG_TAIL_LINES; + setLoadingMore(true); + try { + const olderContent = await getLogTail( + selected, + DEFAULT_LOG_TAIL_LINES, + nextOffset + ); + if (olderContent.length === 0) { + setHasMoreAbove(false); + } else { + setLogContent((prev) => + prev ? `${olderContent}\n${prev}` : olderContent + ); + setOffsetFromEnd(nextOffset); + } + } catch (error) { + push( + describeError(error, `Failed to load older logs for ${selected}`), + "error" + ); + } finally { + setLoadingMore(false); + } + }, [selected, offsetFromEnd, loadingMore, hasMoreAbove, push]); + + const handleRefreshLogs = useCallback(() => { + void fetchLogContent(true, false); + }, [fetchLogContent]); + useEffect(() => { - if (selected && tokenReady) { + if (selected) { lastLinesCountRef.current = 0; + setOffsetFromEnd(0); + setHasMoreAbove(true); void fetchLogContent(true); } - }, [selected, tokenReady, fetchLogContent]); + }, [selected, fetchLogContent]); - // Refresh content periodically when tab is active useInterval( () => { - if (tokenReady) { - void fetchLogContent(false); - } + void fetchLogContent(false, true); }, - active ? 1000 : null + active && liveUpdates ? 1000 : null ); @@ -206,7 +221,7 @@ export function LogsView({ active }: LogsViewProps): JSX.Element { value={selected ? { value: selected, label: selected } : null} onChange={(option) => setSelected(option?.value || "")} isDisabled={!files.length} - styles={getSelectStyles()} + styles={selectStyles} />
@@ -217,6 +232,14 @@ export function LogsView({ active }: LogsViewProps): JSX.Element { Reload List + + + )} +
+ + /> +
+ ) : ( -
Select a log file to view...
+
Select a log file to view...
)} diff --git a/webui/src/pages/ProcessesView.tsx b/webui/src/pages/ProcessesView.tsx index b2fcf8d0b..7b08c3590 100644 --- a/webui/src/pages/ProcessesView.tsx +++ b/webui/src/pages/ProcessesView.tsx @@ -14,10 +14,9 @@ import { IconImage } from "../components/IconImage"; import { ConfirmDialog } from "../components/ConfirmDialog"; import RefreshIcon from "../icons/refresh-arrow.svg"; -import RestartIcon from "../icons/refresh-arrow.svg"; import ToolsIcon from "../icons/build.svg"; -const RELEASE_TOKEN_REGEX = +const QUALITY_TOKEN_REGEX = /\b(480p|576p|720p|1080p|2160p|4k|8k|web[-_. ]?(?:dl|rip)|hdrip|hdtv|bluray|bd(?:rip)?|brrip|webrip|remux|x264|x265|hevc|dts|truehd|atmos|proper|repack|dvdrip|hdr|amzn|nf)\b/i; const EPISODE_TOKEN_REGEX = /\bS\d{1,3}E\d{1,3}\b/i; const SEASON_TOKEN_REGEX = /\bSeason\s+\d+\b/i; @@ -40,7 +39,8 @@ function sanitizeSearchSummary(raw: string): string { const rest = releaseMatch.groups?.rest ?? ""; const looksLikeEpisode = EPISODE_TOKEN_REGEX.test(rest) || SEASON_TOKEN_REGEX.test(rest); - if (rest && !looksLikeEpisode && RELEASE_TOKEN_REGEX.test(rest)) { + if (rest && !looksLikeEpisode && + QUALITY_TOKEN_REGEX.test(rest)) { const rawTitle = releaseMatch.groups?.title ?? ""; const cleanedTitle = rawTitle .replace(/[-_.]/g, " ") @@ -191,7 +191,7 @@ export function ProcessesView({ active }: ProcessesViewProps): JSX.Element { [load, push] ); - const handleRestartAll = useCallback(async () => { + const handleRestartAll = useCallback(() => { setConfirmAction({ title: "Restart All Processes", message: "Are you sure you want to restart all processes? This will temporarily interrupt all operations.", @@ -214,7 +214,7 @@ export function ProcessesView({ active }: ProcessesViewProps): JSX.Element { }); }, [load, push]); - const handleRebuildArrs = useCallback(async () => { + const handleRebuildArrs = useCallback(() => { setConfirmAction({ title: "Rebuild Arrs", message: "Are you sure you want to rebuild all Arr instances? This will refresh all connections and may take some time.", @@ -271,6 +271,13 @@ export function ProcessesView({ active }: ProcessesViewProps): JSX.Element { const hasSonarr = arrs.some((arr) => arr.type === "sonarr"); const hasLidarr = arrs.some((arr) => arr.type === "lidarr"); + const qbitInstanceNames = statusData?.qbitInstances + ? Object.keys(statusData.qbitInstances) + : []; + const qbitCategoryNames = new Set( + qbitCategories.map((c) => c.category.toLowerCase()) + ); + processes.forEach((proc) => { const app = classifyApp(proc); @@ -279,6 +286,25 @@ export function ProcessesView({ active }: ProcessesViewProps): JSX.Element { if (app === "Sonarr" && !hasSonarr) return; if (app === "Lidarr" && !hasLidarr) return; + // Skip qBit category processes that would otherwise show as separate cards in Other. + // They are already represented by the category chips in the qBittorrent card. + const kindLower = (proc.kind ?? "").toLowerCase(); + const procCategoryLower = (proc.category ?? "").toLowerCase(); + if (app === "Other" && qbitInstanceNames.length > 0) { + const isQbitCategoryKind = kindLower === "category"; + const matchesQbitInstance = isQbitCategoryKind && qbitInstanceNames.some((inst) => { + const instLower = inst.toLowerCase(); + return ( + procCategoryLower === instLower || + procCategoryLower === `qbit-${instLower}` || + procCategoryLower.endsWith(`-${instLower}`) || + procCategoryLower.endsWith(`_${instLower}`) + ); + }); + const isConfiguredQbitCategory = qbitCategoryNames.has(procCategoryLower); + if (matchesQbitInstance || isConfiguredQbitCategory) return; + } + if (!appBuckets.has(app)) appBuckets.set(app, new Map()); const instances = appBuckets.get(app)!; const instanceKey = @@ -287,6 +313,21 @@ export function ProcessesView({ active }: ProcessesViewProps): JSX.Element { instances.get(instanceKey)!.push(proc); }); + // Ensure a qBittorrent card exists for every defined qBit instance (even with no processes) + if (qbitInstanceNames.length > 0) { + if (!appBuckets.has("qBittorrent")) appBuckets.set("qBittorrent", new Map()); + const qbitInstances = appBuckets.get("qBittorrent")!; + for (const instanceName of qbitInstanceNames) { + const displayName = + instanceName.toLowerCase().startsWith("qbit") + ? instanceName + : `qBit-${instanceName}`; + if (!qbitInstances.has(displayName)) { + qbitInstances.set(displayName, []); + } + } + } + const appOrder = ["Radarr", "Sonarr", "Lidarr", "qBittorrent", "Other"]; const result: AppGroup[] = Array.from(appBuckets.entries()) @@ -310,7 +351,7 @@ export function ProcessesView({ active }: ProcessesViewProps): JSX.Element { }); return result; - }, [processes, statusData]); + }, [processes, statusData, qbitCategories]); const handleRestartGroup = useCallback( async (items: ProcessInfo[]) => { @@ -356,10 +397,27 @@ export function ProcessesView({ active }: ProcessesViewProps): JSX.Element { : runningCount === 0 ? "status-indicator--bad" : ""; + // For qBittorrent with no processes, use instance alive from status + const qbitInstanceKey = + app === "qBittorrent" && totalCount === 0 + ? (instanceName.toLowerCase().startsWith("qbit-") + ? instanceName.slice(5) + : instanceName) + : null; + const qbitInstanceAlive = + qbitInstanceKey != null + ? statusData?.qbitInstances?.[qbitInstanceKey]?.alive ?? false + : null; const statusClass = ["status-indicator"]; if (tone) statusClass.push(tone); + else if (qbitInstanceAlive !== null) + statusClass.push(qbitInstanceAlive ? "status-indicator--ok" : "status-indicator--bad"); const statusLabel = - totalCount === 0 + totalCount === 0 && qbitInstanceAlive !== null + ? qbitInstanceAlive + ? "Instance running" + : "Instance stopped" + : totalCount === 0 ? "No processes" : runningCount === totalCount ? "All running" @@ -382,7 +440,7 @@ export function ProcessesView({ active }: ProcessesViewProps): JSX.Element {
{displayName}
{summaryLabel}
- {filteredKinds.length ? ( + {app !== "qBittorrent" && filteredKinds.length ? (
{filteredKinds.map((kind) => ( @@ -464,9 +522,6 @@ export function ProcessesView({ active }: ProcessesViewProps): JSX.Element {
{cat.category} - - {cat.managedBy === "arr" ? "Arr" : "qBit"} -
@@ -475,6 +530,7 @@ export function ProcessesView({ active }: ProcessesViewProps): JSX.Element { ); })}
+ {(items.length > 0 && (
+ )) || null}
); }); @@ -503,7 +560,7 @@ export function ProcessesView({ active }: ProcessesViewProps): JSX.Element {
@@ -758,7 +756,7 @@ export function RadarrView({ active }: { active: boolean }): JSX.Element { } finally { setAggLoading(false); } - }, [instances, globalSearch, push, aggFilter]); + }, [instances, globalSearch, push, aggFilter, aggSummary]); // LiveArr is now loaded via WebUIContext, no need to load config here @@ -963,15 +961,6 @@ export function RadarrView({ active }: { active: boolean }): JSX.Element { ); }, []); - const handleInstanceRefresh = useCallback(() => { - if (selection && selection !== "aggregate") { - void fetchInstance(selection, instancePage, instanceQuery, { - preloadAll: false, - showLoading: true, - }); - } - }, [selection, instancePage, instanceQuery, fetchInstance]); - const handleInstanceSelection = useCallback( (event: ChangeEvent) => { const next = (event.target.value || "aggregate") as string | "aggregate"; @@ -1100,7 +1089,6 @@ export function RadarrView({ active }: { active: boolean }): JSX.Element { preloadAll: true, }); }} - onRefresh={handleInstanceRefresh} onRestart={() => void handleRestart()} lastUpdated={lastUpdated} /> diff --git a/webui/src/pages/SonarrView.tsx b/webui/src/pages/SonarrView.tsx index 7853515e4..42d3e324f 100644 --- a/webui/src/pages/SonarrView.tsx +++ b/webui/src/pages/SonarrView.tsx @@ -81,7 +81,11 @@ function filterSeriesEntriesForMissing(seriesEntries: SonarrSeriesEntry[], onlyM } function createFilteredSignature(seriesEntries: SonarrSeriesEntry[], onlyMissing: boolean): string { - return JSON.stringify(filterSeriesEntriesForMissing(seriesEntries, onlyMissing)); + const filtered = filterSeriesEntriesForMissing(seriesEntries, onlyMissing); + if (filtered.length === 0) return "empty"; + const first = filtered[0]; + const last = filtered[filtered.length - 1]; + return `${filtered.length}:${first?.series?.["title"] ?? ''}:${last?.series?.["title"] ?? ''}:${onlyMissing}`; } export function SonarrView({ active }: SonarrViewProps): JSX.Element { @@ -426,7 +430,7 @@ export function SonarrView({ active }: SonarrViewProps): JSX.Element { } finally { setAggLoading(false); } - }, [instances, globalSearch, push, onlyMissing, aggFilter]); + }, [instances, globalSearch, push, onlyMissing, aggFilter, aggSummary]); useEffect(() => { if (!active) return; @@ -458,7 +462,8 @@ export function SonarrView({ active }: SonarrViewProps): JSX.Element { showLoading: true, missingOnly: onlyMissing, }); - }, [active, selection, onlyMissing, fetchInstance, instancePage]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- instancePage excluded to prevent infinite loop + }, [active, selection, onlyMissing, fetchInstance]); useEffect(() => { if (!active) return; @@ -557,21 +562,10 @@ export function SonarrView({ active }: SonarrViewProps): JSX.Element { const isAggFiltered = Boolean(debouncedAggFilter) || onlyMissing || reasonFilter !== "all"; - const sortedAggRows = filteredAggRows; - const aggPages = Math.max( 1, - Math.ceil(sortedAggRows.length / SONARR_AGG_PAGE_SIZE) + Math.ceil(filteredAggRows.length / SONARR_AGG_PAGE_SIZE) ); - // Paginated rows (for potential future use) - // const aggPageRows = useMemo( - // () => sortedAggRows.slice( - // aggPage * SONARR_AGG_PAGE_SIZE, - // aggPage * SONARR_AGG_PAGE_SIZE + SONARR_AGG_PAGE_SIZE - // ), - // [sortedAggRows, aggPage] - // ); - const currentSeries = instancePages[instancePage] ?? []; const allSeries = useMemo(() => { @@ -708,8 +702,8 @@ export function SonarrView({ active }: SonarrViewProps): JSX.Element { {isAggregate ? (