diff --git a/.env.deploy.example b/.env.deploy.example
index 4c74583..fe3e201 100644
--- a/.env.deploy.example
+++ b/.env.deploy.example
@@ -1,11 +1,14 @@
DISCORD_BOT_TOKEN=replace-with-your-discord-bot-token
DISCORD_GATEWAY_ENABLED=true
+ARCHIVE_BOT_DB_PATH=state/archive-bot.db
ARCHIVE_STATUS_CONFIG=services.json
ARCHIVE_STATUS_STATE=state/status-message.json
MEDIA_CATALOG_STATE=state/media-catalog.json
MEDIA_LIBRARY_STATE=state/media-library.json
BOT_SETTINGS_STATE=state/bot-settings.json
PUBLIC_CATALOG_URL=
+TMDB_API_READ_TOKEN=
+TMDB_API_KEY=
JELLYFIN_SYNC_INTERVAL_SECONDS=900
CHECK_INTERVAL_SECONDS=60
HTTP_USER_AGENT=ArchiveStatusBot/1.0
@@ -15,3 +18,14 @@ DASHBOARD_AUTH_DISABLED=true
DASHBOARD_HOST=0.0.0.0
DASHBOARD_PORT=8787
DASHBOARD_COOKIE_SECURE=true
+WATCHPARTY_WORKER_URL=http://orb-stream-worker:8790
+WATCHPARTY_WORKER_TOKEN=replace-with-random-worker-token
+WATCHPARTY_DISCORD_USER_TOKEN=
+WATCHPARTY_STREAM_WIDTH=1280
+WATCHPARTY_STREAM_HEIGHT=720
+WATCHPARTY_STREAM_FPS=30
+WATCHPARTY_STREAM_BITRATE_KBPS=2000
+WATCHPARTY_STREAM_MAX_BITRATE_KBPS=2500
+WATCHPARTY_STREAM_CODEC=H264
+WATCHPARTY_STREAM_PRESET=ultrafast
+WATCHPARTY_STREAM_HARDWARE_ACCELERATION=false
diff --git a/.env.example b/.env.example
index ee2e485..4192660 100644
--- a/.env.example
+++ b/.env.example
@@ -1,11 +1,14 @@
DISCORD_BOT_TOKEN=replace-with-your-discord-bot-token
DISCORD_GATEWAY_ENABLED=true
+ARCHIVE_BOT_DB_PATH=state/archive-bot.db
ARCHIVE_STATUS_CONFIG=services.json
ARCHIVE_STATUS_STATE=state/status-message.json
MEDIA_CATALOG_STATE=state/media-catalog.json
MEDIA_LIBRARY_STATE=state/media-library.json
BOT_SETTINGS_STATE=state/bot-settings.json
PUBLIC_CATALOG_URL=
+TMDB_API_READ_TOKEN=
+TMDB_API_KEY=
JELLYFIN_SYNC_INTERVAL_SECONDS=900
CHECK_INTERVAL_SECONDS=60
HTTP_USER_AGENT=ArchiveStatusBot/1.0
@@ -17,3 +20,14 @@ DASHBOARD_USERNAME=admin
DASHBOARD_PASSWORD_HASH=replace-with-generated-pbkdf2-hash
DASHBOARD_SESSION_TTL_SECONDS=28800
DASHBOARD_COOKIE_SECURE=false
+WATCHPARTY_WORKER_URL=http://orb-stream-worker:8790
+WATCHPARTY_WORKER_TOKEN=replace-with-random-worker-token
+WATCHPARTY_DISCORD_USER_TOKEN=
+WATCHPARTY_STREAM_WIDTH=1280
+WATCHPARTY_STREAM_HEIGHT=720
+WATCHPARTY_STREAM_FPS=30
+WATCHPARTY_STREAM_BITRATE_KBPS=2000
+WATCHPARTY_STREAM_MAX_BITRATE_KBPS=2500
+WATCHPARTY_STREAM_CODEC=H264
+WATCHPARTY_STREAM_PRESET=ultrafast
+WATCHPARTY_STREAM_HARDWARE_ACCELERATION=false
diff --git a/.gitignore b/.gitignore
index f773ace..13267d7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,4 @@ __pycache__/
*.py[cod]
.agents/
.codex/
+node_modules/
diff --git a/Dockerfile b/Dockerfile
index 8777d1c..43a0ccb 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -3,11 +3,15 @@ FROM python:3.12-alpine
WORKDIR /app
COPY requirements.txt /app/requirements.txt
+RUN pip install --no-cache-dir -r /app/requirements.txt
+RUN adduser -D -u 1001 -g "" -h /app archive-status
+RUN mkdir -p /app/state && chown -R 1001:1001 /app
+
+COPY --chown=1001:1001 archive_bot /app/archive_bot
COPY --chown=1001:1001 status_bot.py /app/status_bot.py
COPY --chown=1001:1001 dashboard.html /app/dashboard.html
COPY --chown=1001:1001 services.example.json /app/services.json
-RUN pip install --no-cache-dir -r /app/requirements.txt
-RUN adduser -D -u 1001 -g "" -h /app archive-status
+USER 1001:1001
CMD ["python", "/app/status_bot.py"]
diff --git a/README.md b/README.md
index d5c420f..560a600 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,7 @@ Required channel permissions for each dashboard-selected channel:
- Attach Files
- Read Message History
-Channel IDs are configured from the dashboard and stored in `state/bot-settings.json`.
+Channel IDs are configured from the dashboard and stored in the bot's SQLite database.
## Local Setup
@@ -42,6 +42,23 @@ Edit `.env` and set:
- `DASHBOARD_USERNAME`
- `DASHBOARD_PASSWORD_HASH`
+The bot now stores its editable runtime data in SQLite at:
+
+```env
+ARCHIVE_BOT_DB_PATH=state/archive-bot.db
+```
+
+`services.json` remains a seed/import-export file for monitored services. Existing JSON state files are migrated into SQLite automatically on startup when present.
+
+For TMDB poster fallback in the public catalog, set either:
+
+```env
+TMDB_API_READ_TOKEN=your-tmdb-read-token
+TMDB_API_KEY=your-tmdb-v3-api-key
+```
+
+The read token is preferred. If neither is set, the catalog only uses Jellyfin poster images.
+
Use the raw Discord bot token value. Do not include a `Bot ` prefix.
Generate the dashboard password hash:
@@ -116,7 +133,7 @@ mkdir -p state
chmod 755 state
```
-The container runs as `1001:1001` inside the image. If the mounted `services.json` or `state/` were created by another user, fix ownership once:
+The container runs as `1001:1001` inside the image. SQLite lives in `state/archive-bot.db`. If the mounted `services.json` or `state/` were created by another user, fix ownership once:
```sh
sudo chown -R 1001:1001 services.json state
@@ -128,7 +145,7 @@ If `state/` or `services.json` were created by a previous container as another u
sudo chown -R "$(id -u):$(id -g)" services.json state
```
-The bot stores channel settings in `state/bot-settings.json` and Discord message IDs in `state/status-message.json` and `state/media-catalog.json`. Keep `state/` mounted so the bot edits the same messages after restarts.
+The bot stores runtime state, settings, media library data, and Discord message IDs in SQLite. Keep `state/` mounted so the bot uses the same database after restarts. Legacy JSON files named by `ARCHIVE_STATUS_STATE`, `MEDIA_CATALOG_STATE`, `MEDIA_LIBRARY_STATE`, and `BOT_SETTINGS_STATE` are treated as one-time migration sources.
The deploy compose joins your existing reverse-proxy network:
@@ -185,7 +202,7 @@ The dashboard currently supports:
- selecting the Discord channel used by the status updater
- adding/removing service rows
- editing check URL, display URL, expected statuses, timeout, and keyword
-- saving `services.json`
+- saving services into SQLite and exporting the current config to `services.json`
- forcing an immediate check and Discord message update
- syncing movies and shows from Jellyfin
- tracking additions and removals between Jellyfin syncs
@@ -206,21 +223,17 @@ The dashboard also serves a read-only catalog page at `/catalog`. Set the public
In Jellyfin, create an API key from the admin dashboard, then enter the Jellyfin URL and key in the `Media` tab. If you have duplicate free/premium libraries, enter only the premium Jellyfin library names in the `Libraries` field, separated by commas. `Reset library` clears the saved media list and sync fingerprints. `Sync now` replaces the editable library with the current Jellyfin movies and shows while comparing against the previously saved library. `Auto-sync changes` checks Jellyfin periodically and republishes only when the catalog fingerprint changes. Jellyfin results are deduplicated across included libraries using provider IDs first, then normalized title and year.
-Channel selections are stored in:
+The main database path is:
+
+```env
+ARCHIVE_BOT_DB_PATH=state/archive-bot.db
+```
+
+Legacy migration/import paths remain configurable:
```env
BOT_SETTINGS_STATE=state/bot-settings.json
-```
-
-The bot stores media catalog message IDs in:
-
-```env
MEDIA_CATALOG_STATE=state/media-catalog.json
-```
-
-The editable media library is stored in:
-
-```env
MEDIA_LIBRARY_STATE=state/media-library.json
```
diff --git a/WATCHPARTY_IMPLEMENTATION_PLAN.md b/WATCHPARTY_IMPLEMENTATION_PLAN.md
new file mode 100644
index 0000000..43a42a0
--- /dev/null
+++ b/WATCHPARTY_IMPLEMENTATION_PLAN.md
@@ -0,0 +1,240 @@
+# Jellyfin VC Watch Party Plan
+
+## Goal
+
+Add Discord voice-channel watch parties to The Orb Bot where users choose media from the Jellyfin-backed catalog and a separate streaming worker pushes the selected video into a Discord VC.
+
+The existing Python bot remains the control plane. The streaming runtime is a separate worker service.
+
+## Why This Split Exists
+
+The current bot is responsible for:
+
+- dashboard and API
+- Jellyfin library sync
+- persistent state in SQLite
+- Discord message/status publishing
+
+It is not a video-streaming runtime. Discord VC video streaming needs a dedicated worker process with:
+
+- Discord streaming session handling
+- FFmpeg process control
+- queue playback
+- reconnect and health reporting
+
+## Target Architecture
+
+### 1. Orb Bot (`archive_bot`)
+
+Responsibilities:
+
+- create/manage watch-party sessions
+- browse Jellyfin media
+- queue items
+- persist session state
+- expose API/dashboard controls
+- send commands to the stream worker
+- read worker state
+
+### 2. Stream Worker (`orb-stream-worker`)
+
+Responsibilities:
+
+- join a Discord VC
+- start and stop the stream
+- pull the selected item from Jellyfin
+- transcode or normalize media as needed
+- report current playback state
+- handle pause/resume/seek/skip
+
+### 3. Jellyfin
+
+Responsibilities:
+
+- source media metadata
+- source playback URLs/files
+- optional transcoding source if needed
+
+## Current Foundation Implemented In This Repo
+
+This repo now contains the control-plane foundation:
+
+- SQLite-backed watch-party tables
+- session, queue, event, and worker-state models
+- HTTP APIs for watch-party orchestration
+
+The stream worker itself is intentionally not implemented inside the Python bot.
+
+## Data Model
+
+### `watch_party_sessions`
+
+- `id`
+- `guild_id`
+- `voice_channel_id`
+- `text_channel_id`
+- `owner_user_id`
+- `title`
+- `status` (`draft`, `queued`, `connecting`, `playing`, `paused`, `stopped`, `error`)
+- `worker_session_id`
+- `current_queue_entry_id`
+- `created_at`
+- `updated_at`
+
+### `watch_party_queue`
+
+- `id`
+- `session_id`
+- `position`
+- `media_type`
+- `jellyfin_source_id`
+- `title`
+- `year`
+- `runtime`
+- `summary`
+- `poster_url`
+- `status` (`queued`, `playing`, `played`, `skipped`, `failed`)
+- `created_at`
+
+### `watch_party_events`
+
+- `id`
+- `session_id`
+- `event_type`
+- `payload_json`
+- `created_at`
+
+### `watch_party_worker_state`
+
+- `session_id`
+- `worker_status`
+- `playback_state`
+- `current_title`
+- `position_seconds`
+- `duration_seconds`
+- `last_error`
+- `updated_at`
+
+## Worker Contract
+
+The Python bot should speak to the worker through a private HTTP API on the Docker network.
+
+### `POST /sessions`
+
+Create/connect a worker session.
+
+Request:
+
+```json
+{
+ "sessionId": "local-session-id",
+ "guildId": "123",
+ "voiceChannelId": "456",
+ "textChannelId": "789"
+}
+```
+
+### `POST /sessions/{sessionId}/play`
+
+Start one Jellyfin item.
+
+Request:
+
+```json
+{
+ "queueEntryId": 12,
+ "jellyfinSourceId": "abc123",
+ "title": "Movie Title",
+ "mediaType": "movie",
+ "playback": {
+ "itemId": "abc123",
+ "sourceId": "abc123",
+ "title": "Movie Title",
+ "mediaType": "movie",
+ "streamUrl": "http://jellyfin:8096/Videos/abc123/stream?...",
+ "downloadUrl": "http://jellyfin:8096/Items/abc123/Download?...",
+ "durationSeconds": 5420,
+ "posterUrl": "https://...",
+ "year": "2024",
+ "tmdbId": "1234",
+ "imdbId": "tt1234567",
+ "episode": null
+ }
+}
+```
+
+Notes:
+
+- Movies resolve directly to their Jellyfin item IDs.
+- Shows currently resolve to the first playable episode so the worker receives a real video target instead of a series ID.
+- The worker should treat `playback.streamUrl` as the primary source and `downloadUrl` as a fallback.
+
+### `POST /sessions/{sessionId}/control`
+
+Supported actions:
+
+- `pause`
+- `resume`
+- `seek`
+- `skip`
+- `stop`
+
+### `GET /sessions/{sessionId}`
+
+Worker status:
+
+```json
+{
+ "workerStatus": "connected",
+ "playbackState": "playing",
+ "currentTitle": "Movie Title",
+ "positionSeconds": 381,
+ "durationSeconds": 5420,
+ "lastError": null
+}
+```
+
+## Milestones
+
+### Milestone 1: Control Plane
+
+- DB schema
+- watch-party service layer
+- dashboard/API endpoints
+- queue and state transitions
+
+### Milestone 2: Worker Stub
+
+- second service in Docker Compose
+- authenticated private API
+- no real VC streaming yet
+
+### Milestone 3: One-Item Playback
+
+- connect to VC
+- start one playable Jellyfin item from the control-plane playback contract
+- stop/pause/resume
+
+### Milestone 4: Real Watch Parties
+
+- queue autoadvance
+- seek/skip
+- reconnect handling
+- worker heartbeats
+- dashboard controls
+
+## Deployment Shape
+
+Recommended services:
+
+- `archive-status-bot`
+- `orb-stream-worker`
+
+The worker should not be publicly exposed. It should only be reachable on the internal Docker network.
+
+## Known Risks
+
+- Discord streaming requires a non-standard worker model and operational care.
+- Some media will need transcoding.
+- Playback control is substantially harder than catalog sync.
+- Watch-party commands and Discord UX still need to be added after the worker exists.
diff --git a/archive_bot/__init__.py b/archive_bot/__init__.py
new file mode 100644
index 0000000..adbf540
--- /dev/null
+++ b/archive_bot/__init__.py
@@ -0,0 +1,2 @@
+"""Archive Bot package."""
+
diff --git a/archive_bot/auth.py b/archive_bot/auth.py
new file mode 100644
index 0000000..41210f1
--- /dev/null
+++ b/archive_bot/auth.py
@@ -0,0 +1,159 @@
+from __future__ import annotations
+
+import base64
+import hashlib
+import hmac
+import os
+import secrets
+import threading
+import time
+from dataclasses import dataclass
+
+from .core import PBKDF2_ITERATIONS, bool_env
+
+
+@dataclass(frozen=True)
+class DashboardAuthConfig:
+ username: str
+ password_hash: str
+ session_ttl_seconds: int
+ cookie_secure: bool
+
+
+@dataclass
+class DashboardSession:
+ username: str
+ csrf_token: str
+ expires_at: float
+
+
+class DashboardAuth:
+ def __init__(self, config: DashboardAuthConfig) -> None:
+ self.config = config
+ self.lock = threading.Lock()
+ self.sessions: dict[str, DashboardSession] = {}
+ self.failed_logins: dict[str, list[float]] = {}
+
+ def login_allowed(self, key: str) -> bool:
+ now = time.time()
+ window_start = now - 900
+ with self.lock:
+ attempts = [attempt for attempt in self.failed_logins.get(key, []) if attempt >= window_start]
+ self.failed_logins[key] = attempts
+ return len(attempts) < 10
+
+ def record_failed_login(self, key: str) -> None:
+ now = time.time()
+ with self.lock:
+ self.failed_logins.setdefault(key, []).append(now)
+
+ def clear_failed_login(self, key: str) -> None:
+ with self.lock:
+ self.failed_logins.pop(key, None)
+
+ def login(self, username: str, password: str) -> tuple[str, DashboardSession] | None:
+ if not hmac.compare_digest(username, self.config.username):
+ return None
+ if not verify_password_hash(self.config.password_hash, password):
+ return None
+
+ session_id = secrets.token_urlsafe(32)
+ session = DashboardSession(
+ username=username,
+ csrf_token=secrets.token_urlsafe(32),
+ expires_at=time.time() + self.config.session_ttl_seconds,
+ )
+ with self.lock:
+ self.sessions[session_id] = session
+ return session_id, session
+
+ def session_from_cookie(self, cookie_header: str | None) -> tuple[str, DashboardSession] | None:
+ from http.cookies import SimpleCookie
+
+ from .core import SESSION_COOKIE
+
+ if not cookie_header:
+ return None
+ cookie = SimpleCookie()
+ cookie.load(cookie_header)
+ morsel = cookie.get(SESSION_COOKIE)
+ if morsel is None:
+ return None
+
+ session_id = morsel.value
+ now = time.time()
+ with self.lock:
+ session = self.sessions.get(session_id)
+ if session is None:
+ return None
+ if session.expires_at <= now:
+ self.sessions.pop(session_id, None)
+ return None
+ session.expires_at = now + self.config.session_ttl_seconds
+ return session_id, session
+
+ def logout(self, session_id: str) -> None:
+ with self.lock:
+ self.sessions.pop(session_id, None)
+
+
+def password_hash(password: str) -> str:
+ salt = secrets.token_bytes(16)
+ digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, PBKDF2_ITERATIONS)
+ salt_text = base64.urlsafe_b64encode(salt).decode("ascii").rstrip("=")
+ digest_text = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
+ return f"pbkdf2_sha256${PBKDF2_ITERATIONS}${salt_text}${digest_text}"
+
+
+def decode_urlsafe_base64(value: str) -> bytes:
+ padding = "=" * (-len(value) % 4)
+ return base64.urlsafe_b64decode(value + padding)
+
+
+def verify_password_hash(encoded: str, password: str) -> bool:
+ try:
+ algorithm, iterations, salt_text, digest_text = encoded.split("$", 3)
+ if algorithm != "pbkdf2_sha256":
+ return False
+ salt = decode_urlsafe_base64(salt_text)
+ expected = decode_urlsafe_base64(digest_text)
+ actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, int(iterations))
+ except (ValueError, TypeError):
+ return False
+ return hmac.compare_digest(actual, expected)
+
+
+def dashboard_auth_from_env() -> DashboardAuth | None:
+ if bool_env("DASHBOARD_AUTH_DISABLED", False):
+ return None
+
+ username = os.getenv("DASHBOARD_USERNAME", "").strip()
+ encoded_hash = os.getenv("DASHBOARD_PASSWORD_HASH", "").strip()
+ if not username or not encoded_hash:
+ raise SystemExit(
+ "Dashboard auth is enabled but DASHBOARD_USERNAME or DASHBOARD_PASSWORD_HASH is missing. "
+ "Set credentials or explicitly set DASHBOARD_AUTH_DISABLED=true."
+ )
+
+ ttl = int(os.getenv("DASHBOARD_SESSION_TTL_SECONDS", "28800"))
+ secure = bool_env("DASHBOARD_COOKIE_SECURE", False)
+ return DashboardAuth(
+ DashboardAuthConfig(
+ username=username,
+ password_hash=encoded_hash,
+ session_ttl_seconds=ttl,
+ cookie_secure=secure,
+ )
+ )
+
+
+def print_password_hash() -> None:
+ import getpass
+
+ first = getpass.getpass("Dashboard password: ")
+ second = getpass.getpass("Confirm password: ")
+ if first != second:
+ raise SystemExit("Passwords did not match")
+ if len(first) < 12:
+ raise SystemExit("Use at least 12 characters")
+ print(password_hash(first))
diff --git a/archive_bot/core.py b/archive_bot/core.py
new file mode 100644
index 0000000..acef916
--- /dev/null
+++ b/archive_bot/core.py
@@ -0,0 +1,147 @@
+from __future__ import annotations
+
+import json
+import os
+import socket
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+DISCORD_API = "https://discord.com/api/v10"
+DEFAULT_INTERVAL_SECONDS = 60
+DEFAULT_TIMEOUT_SECONDS = 10
+MAX_DISCORD_EMBEDS = 10
+MAX_REQUEST_BYTES = 8_000_000
+SESSION_COOKIE = "archive_bot_session"
+PBKDF2_ITERATIONS = 390_000
+MEDIA_ITEMS_PER_EMBED = 10
+DEFAULT_JELLYFIN_SYNC_INTERVAL_SECONDS = 900
+
+
+@dataclass(frozen=True)
+class Service:
+ name: str
+ group: str
+ url: str
+ display_url: str
+ method: str
+ timeout: float
+ expected_statuses: set[int]
+ expected_min: int
+ expected_max: int
+ keyword: str | None
+
+
+@dataclass(frozen=True)
+class CheckResult:
+ service: Service
+ ok: bool
+ status: int | None
+ latency_ms: int | None
+ error: str | None
+
+
+@dataclass(frozen=True)
+class MediaItem:
+ title: str
+ media_type: str
+ source_id: str | None = None
+ tmdb_id: str | None = None
+ imdb_id: str | None = None
+ year: str | None = None
+ genres: str | None = None
+ rating: str | None = None
+ runtime: str | None = None
+ summary: str | None = None
+ seasons: int | None = None
+ episodes: int | None = None
+
+
+class BotRuntime:
+ def __init__(
+ self,
+ token: str,
+ channel_id: str,
+ config_path: Path,
+ state_path: Path,
+ media_state_path: Path,
+ media_library_path: Path,
+ settings_path: Path,
+ dry_run: bool = False,
+ ) -> None:
+ import threading
+
+ self.token = token
+ self.default_channel_id = channel_id
+ self.config_path = config_path
+ self.state_path = state_path
+ self.media_state_path = media_state_path
+ self.media_library_path = media_library_path
+ self.settings_path = settings_path
+ self.dry_run = dry_run
+ self.lock = threading.Lock()
+ self.last_results: list[CheckResult] = []
+ self.last_error: str | None = None
+ self.last_message_id: str | None = None
+ self.last_checked_at: datetime | None = None
+
+
+def env(name: str, default: str | None = None) -> str:
+ value = os.getenv(name, default)
+ if value is None or not value.strip():
+ raise SystemExit(f"Missing required environment variable: {name}")
+ return value.strip()
+
+
+def bool_env(name: str, default: bool = False) -> bool:
+ raw = os.getenv(name)
+ if raw is None:
+ return default
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
+
+
+def normalize_discord_token(token: str) -> str:
+ cleaned = token.strip().strip("\"'")
+ if cleaned.lower().startswith("bot "):
+ cleaned = cleaned[4:].strip()
+ return cleaned
+
+
+def load_dotenv(path: Path = Path(".env")) -> None:
+ if not path.exists():
+ return
+
+ for line in path.read_text(encoding="utf-8").splitlines():
+ stripped = line.strip()
+ if not stripped or stripped.startswith("#") or "=" not in stripped:
+ continue
+
+ key, value = stripped.split("=", 1)
+ key = key.strip()
+ value = value.strip().strip("\"'")
+ if key and key not in os.environ:
+ os.environ[key] = value
+
+
+def load_json(path: Path) -> dict[str, Any]:
+ try:
+ with path.open("r", encoding="utf-8") as handle:
+ data = json.load(handle)
+ except FileNotFoundError as exc:
+ raise ValueError(f"Config file not found: {path}") from exc
+ except PermissionError as exc:
+ raise ValueError(f"Config file is not readable: {path}") from exc
+ except json.JSONDecodeError as exc:
+ raise ValueError(f"Invalid JSON in {path}: {exc}") from exc
+
+ if not isinstance(data, dict):
+ raise ValueError(f"Config must be a JSON object: {path}")
+ return data
+
+
+def clean_error(exc: BaseException) -> str:
+ reason = getattr(exc, "reason", None)
+ if reason:
+ return str(reason)[:120]
+ return str(exc)[:120] or exc.__class__.__name__ or socket.__name__
diff --git a/archive_bot/dashboard.py b/archive_bot/dashboard.py
new file mode 100644
index 0000000..48bd943
--- /dev/null
+++ b/archive_bot/dashboard.py
@@ -0,0 +1,791 @@
+from __future__ import annotations
+
+import hmac
+import json
+import os
+import time
+import urllib.parse
+from http import HTTPStatus
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from typing import Any
+
+from .auth import DashboardAuth, DashboardSession, dashboard_auth_from_env
+from .core import BotRuntime, MAX_REQUEST_BYTES, SESSION_COOKIE, bool_env
+from .media import (
+ available_media_genres,
+ catalog_item_for_source_id,
+ catalog_known_source_ids,
+ fetch_jellyfin_image,
+ media_catalog_status,
+ load_media_library,
+ media_items_from_data,
+ publish_media_items,
+ recommend_media_items,
+ resolve_jellyfin_playback_source,
+ reset_media_library,
+ save_media_library,
+ sync_jellyfin_library,
+ update_jellyfin_sync_state,
+)
+from .status import result_to_jsonable, run_check_cycle, runtime_status, save_services_config, services_from_data
+from .storage import (
+ channel_settings,
+ jellyfin_settings,
+ save_catalog_url_setting,
+ save_channel_settings,
+ save_jellyfin_settings,
+)
+from .watchparty import (
+ add_watch_party_queue_item,
+ create_watch_party_session,
+ first_queued_entry,
+ get_watch_party_session,
+ list_media_candidates,
+ list_watch_party_sessions,
+ update_queue_entry_status,
+ update_watch_party_status,
+ update_worker_state,
+ worker_command_payload,
+)
+from .worker_client import (
+ worker_control,
+ worker_create_session,
+ worker_enabled,
+ worker_health,
+ worker_play,
+ worker_status,
+)
+
+
+def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> type[BaseHTTPRequestHandler]:
+ dashboard_path = Path(__file__).resolve().parent.parent / "dashboard.html"
+
+ class DashboardHandler(BaseHTTPRequestHandler):
+ server_version = "ArchiveStatusDashboard/1.0"
+
+ def log_message(self, format: str, *args: Any) -> None:
+ print(f"[dashboard] {self.address_string()} - {format % args}", flush=True)
+
+ def do_GET(self) -> None:
+ parsed_request = urllib.parse.urlparse(self.path)
+ path = parsed_request.path
+ query = urllib.parse.parse_qs(parsed_request.query)
+ if path in {"/", "/dashboard"}:
+ self.send_dashboard()
+ return
+ if path == "/catalog":
+ from .media import render_catalog_html
+
+ self.send_catalog(render_catalog_html(runtime))
+ return
+ if path.startswith("/catalog/poster/"):
+ item_id = urllib.parse.unquote(path.removeprefix("/catalog/poster/")).strip()
+ self.send_catalog_poster(item_id)
+ return
+ if path == "/favicon.ico":
+ self.send_response(HTTPStatus.NO_CONTENT)
+ self.end_headers()
+ return
+ if path == "/api/session":
+ session = self.require_auth()
+ if session is None:
+ return
+ _session_id, data = session
+ self.send_json(
+ HTTPStatus.OK,
+ {
+ "username": data.username,
+ "csrfToken": data.csrf_token,
+ },
+ )
+ return
+ if path == "/api/status":
+ if self.require_auth() is None:
+ return
+ self.send_json(HTTPStatus.OK, runtime_status(runtime))
+ return
+ if path == "/api/media":
+ if self.require_auth() is None:
+ return
+ self.send_json(HTTPStatus.OK, media_catalog_status(runtime))
+ return
+ if path == "/api/media/recommendations":
+ if self.require_auth() is None:
+ return
+ movies, shows = load_media_library(runtime)
+ media_type = str(query.get("type", ["all"])[0] or "all")
+ genre = str(query.get("genre", ["all"])[0] or "all")
+ mode = str(query.get("mode", ["balanced"])[0] or "balanced")
+ search = str(query.get("search", [""])[0] or "").strip()
+ try:
+ limit = int(str(query.get("limit", ["6"])[0] or "6"))
+ except ValueError:
+ limit = 6
+ self.send_json(
+ HTTPStatus.OK,
+ {
+ "genres": available_media_genres(movies, shows),
+ **recommend_media_items(
+ movies,
+ shows,
+ media_type=media_type,
+ genre=genre,
+ mode=mode,
+ search=search,
+ limit=limit,
+ ),
+ },
+ )
+ return
+ if path == "/api/settings":
+ if self.require_auth() is None:
+ return
+ self.send_json(HTTPStatus.OK, {"channels": channel_settings(runtime)})
+ return
+ if path == "/api/watchparty/sessions":
+ if self.require_auth() is None:
+ return
+ self.send_json(HTTPStatus.OK, {"sessions": list_watch_party_sessions()})
+ return
+ if path.startswith("/api/watchparty/sessions/"):
+ if self.require_auth() is None:
+ return
+ suffix = path.removeprefix("/api/watchparty/sessions/")
+ if suffix.isdigit():
+ self.send_json(HTTPStatus.OK, get_watch_party_session(int(suffix)))
+ return
+ if path == "/api/watchparty/media":
+ if self.require_auth() is None:
+ return
+ media_type = str(query.get("type", ["all"])[0] or "all")
+ search = str(query.get("search", [""])[0] or "").strip()
+ try:
+ limit = int(str(query.get("limit", ["25"])[0] or "25"))
+ except ValueError:
+ limit = 25
+ self.send_json(
+ HTTPStatus.OK,
+ {"items": list_media_candidates(runtime, media_type=media_type, search=search, limit=limit)},
+ )
+ return
+ if path == "/api/jellyfin":
+ if self.require_auth() is None:
+ return
+ self.send_json(HTTPStatus.OK, {"jellyfin": jellyfin_settings(runtime)})
+ return
+ if path == "/api/watchparty/worker-health":
+ if self.require_auth() is None:
+ return
+ if not worker_enabled():
+ self.send_json(HTTPStatus.OK, {"configured": False})
+ return
+ self.send_json(HTTPStatus.OK, {"configured": True, "worker": worker_health()})
+ return
+ self.send_error(HTTPStatus.NOT_FOUND)
+
+ def do_POST(self) -> None:
+ path = urllib.parse.urlparse(self.path).path
+ if path == "/api/login":
+ self.handle_login()
+ return
+ session = self.require_auth(require_csrf=True)
+ if session is None:
+ return
+ if path == "/api/logout":
+ self.handle_logout(session[0])
+ return
+ if path == "/api/check":
+ self.handle_check()
+ return
+ if path == "/api/services":
+ self.handle_services()
+ return
+ if path == "/api/media":
+ self.handle_media_catalog()
+ return
+ if path == "/api/media/library":
+ self.handle_media_library()
+ return
+ if path == "/api/media/reset":
+ self.handle_media_reset()
+ return
+ if path == "/api/settings":
+ self.handle_settings()
+ return
+ if path == "/api/jellyfin/settings":
+ self.handle_jellyfin_settings()
+ return
+ if path == "/api/jellyfin/sync":
+ self.handle_jellyfin_sync()
+ return
+ if path == "/api/watchparty/sessions":
+ self.handle_watchparty_sessions()
+ return
+ if path == "/api/watchparty/queue":
+ self.handle_watchparty_queue()
+ return
+ if path == "/api/watchparty/session-status":
+ self.handle_watchparty_session_status()
+ return
+ if path == "/api/watchparty/queue-status":
+ self.handle_watchparty_queue_status()
+ return
+ if path == "/api/watchparty/worker-state":
+ self.handle_watchparty_worker_state()
+ return
+ if path == "/api/watchparty/worker-command":
+ self.handle_watchparty_worker_command()
+ return
+ if path == "/api/watchparty/worker-start":
+ self.handle_watchparty_worker_start()
+ return
+ if path == "/api/watchparty/worker-play-next":
+ self.handle_watchparty_worker_play_next()
+ return
+ if path == "/api/watchparty/worker-control":
+ self.handle_watchparty_worker_control()
+ return
+ if path == "/api/watchparty/worker-status":
+ self.handle_watchparty_worker_status()
+ return
+ self.send_error(HTTPStatus.NOT_FOUND)
+
+ def require_auth(self, require_csrf: bool = False) -> tuple[str, DashboardSession] | None:
+ if auth is None:
+ return "disabled", DashboardSession("local", "disabled", time.time() + 3600)
+
+ session = auth.session_from_cookie(self.headers.get("Cookie"))
+ if session is None:
+ self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Login required"})
+ return None
+
+ if require_csrf:
+ csrf = self.headers.get("X-CSRF-Token", "")
+ if not hmac.compare_digest(csrf, session[1].csrf_token):
+ self.send_json(HTTPStatus.FORBIDDEN, {"error": "CSRF token mismatch"})
+ return None
+
+ return session
+
+ def cookie_attributes(self, max_age: int) -> str:
+ attrs = [
+ "Path=/",
+ "HttpOnly",
+ "SameSite=Strict",
+ f"Max-Age={max_age}",
+ ]
+ if auth is not None and auth.config.cookie_secure:
+ attrs.append("Secure")
+ return "; ".join(attrs)
+
+ def set_session_cookie(self, session_id: str) -> None:
+ ttl = auth.config.session_ttl_seconds if auth is not None else 3600
+ self.send_header(
+ "Set-Cookie",
+ f"{SESSION_COOKIE}={session_id}; {self.cookie_attributes(ttl)}",
+ )
+
+ def clear_session_cookie(self) -> None:
+ self.send_header(
+ "Set-Cookie",
+ f"{SESSION_COOKIE}=; {self.cookie_attributes(0)}",
+ )
+
+ def send_dashboard(self) -> None:
+ try:
+ body = dashboard_path.read_bytes()
+ except FileNotFoundError:
+ self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR, "dashboard.html missing")
+ return
+
+ self.send_response(HTTPStatus.OK)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def send_catalog(self, body: bytes) -> None:
+ self.send_response(HTTPStatus.OK)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Cache-Control", "no-store")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def send_catalog_poster(self, item_id: str) -> None:
+ if item_id not in catalog_known_source_ids(runtime):
+ self.send_error(HTTPStatus.NOT_FOUND)
+ return
+ try:
+ item = catalog_item_for_source_id(runtime, item_id)
+ image = fetch_jellyfin_image(runtime, item_id)
+ except Exception as exc:
+ self.send_json(HTTPStatus.BAD_GATEWAY, {"error": str(exc)})
+ return
+ if image is None and item is not None:
+ from .media import tmdb_fallback_poster_url
+
+ fallback_url = tmdb_fallback_poster_url(item)
+ if fallback_url:
+ self.send_response(HTTPStatus.FOUND)
+ self.send_header("Location", fallback_url)
+ self.send_header("Cache-Control", "public, max-age=3600")
+ self.end_headers()
+ return
+ if image is None:
+ self.send_error(HTTPStatus.NOT_FOUND)
+ return
+ body, content_type = image
+ self.send_response(HTTPStatus.OK)
+ self.send_header("Content-Type", content_type)
+ self.send_header("Cache-Control", "public, max-age=3600")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def read_json(self) -> dict[str, Any]:
+ raw_length = self.headers.get("Content-Length", "0")
+ try:
+ length = int(raw_length)
+ except ValueError as exc:
+ raise ValueError("Invalid Content-Length") from exc
+ if length > MAX_REQUEST_BYTES:
+ raise ValueError("Request body is too large")
+
+ body = self.rfile.read(length)
+ try:
+ data = json.loads(body.decode("utf-8"))
+ except json.JSONDecodeError as exc:
+ raise ValueError(f"Invalid JSON: {exc}") from exc
+ if not isinstance(data, dict):
+ raise ValueError("JSON body must be an object")
+ return data
+
+ def send_json(self, status: HTTPStatus, payload: dict[str, Any]) -> None:
+ body = json.dumps(payload, indent=2).encode("utf-8")
+ self.send_response(status)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Cache-Control", "no-store")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def handle_login(self) -> None:
+ if auth is None:
+ self.send_json(HTTPStatus.OK, {"username": "local", "csrfToken": "disabled"})
+ return
+
+ try:
+ data = self.read_json()
+ except ValueError as exc:
+ self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
+ return
+
+ username = str(data.get("username", ""))
+ password = str(data.get("password", ""))
+ throttle_key = f"{self.client_address[0]}:{username}"
+ if not auth.login_allowed(throttle_key):
+ self.send_json(HTTPStatus.TOO_MANY_REQUESTS, {"error": "Too many login attempts. Try again later."})
+ return
+
+ login = auth.login(username, password)
+ if login is None:
+ auth.record_failed_login(throttle_key)
+ self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Invalid username or password"})
+ return
+
+ auth.clear_failed_login(throttle_key)
+ session_id, session = login
+ payload = {
+ "username": session.username,
+ "csrfToken": session.csrf_token,
+ }
+ body = json.dumps(payload, indent=2).encode("utf-8")
+ self.send_response(HTTPStatus.OK)
+ self.set_session_cookie(session_id)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Cache-Control", "no-store")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def handle_logout(self, session_id: str) -> None:
+ if auth is not None:
+ auth.logout(session_id)
+ body = b'{\n "ok": true\n}'
+ self.send_response(HTTPStatus.OK)
+ self.clear_session_cookie()
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Cache-Control", "no-store")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def handle_check(self) -> None:
+ try:
+ message_id, results = run_check_cycle(runtime)
+ except Exception as exc:
+ with runtime.lock:
+ runtime.last_error = str(exc)
+ self.send_json(HTTPStatus.BAD_GATEWAY, {"error": str(exc)})
+ return
+
+ self.send_json(
+ HTTPStatus.OK,
+ {
+ "messageId": message_id,
+ "results": [result_to_jsonable(result) for result in results],
+ },
+ )
+
+ def handle_services(self) -> None:
+ try:
+ data = self.read_json()
+ services_from_data(data)
+ save_services_config(runtime.config_path, data)
+ message_id, results = run_check_cycle(runtime)
+ except Exception as exc:
+ with runtime.lock:
+ runtime.last_error = str(exc)
+ self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
+ return
+
+ self.send_json(
+ HTTPStatus.OK,
+ {
+ "messageId": message_id,
+ "services": data.get("services", []),
+ "results": [result_to_jsonable(result) for result in results],
+ },
+ )
+
+ def handle_media_catalog(self) -> None:
+ try:
+ data = self.read_json()
+ if "movies" in data or "shows" in data:
+ if "catalogUrl" in data:
+ save_catalog_url_setting(runtime, str(data.get("catalogUrl", "")))
+ movies = media_items_from_data(data.get("movies", []), "movie")
+ shows = media_items_from_data(data.get("shows", []), "show")
+ save_media_library(runtime, movies, shows)
+ result = publish_media_items(
+ runtime=runtime,
+ channel_id=str(data.get("channelId", "")),
+ movies_all=movies,
+ shows_all=shows,
+ )
+ self.send_json(HTTPStatus.OK, result)
+ return
+
+ raise ValueError("Publish requires the saved Jellyfin media library")
+ except Exception as exc:
+ self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
+ return
+
+ def handle_media_library(self) -> None:
+ try:
+ data = self.read_json()
+ movies = media_items_from_data(data.get("movies", []), "movie")
+ shows = media_items_from_data(data.get("shows", []), "show")
+ saved = save_media_library(runtime, movies, shows)
+ except Exception as exc:
+ self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
+ return
+
+ self.send_json(
+ HTTPStatus.OK,
+ {
+ "library": {
+ "movies": saved.get("movies", []),
+ "shows": saved.get("shows", []),
+ },
+ "movieCount": len(movies),
+ "showCount": len(shows),
+ },
+ )
+
+ def handle_media_reset(self) -> None:
+ try:
+ result = reset_media_library(runtime)
+ except Exception as exc:
+ self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
+ return
+
+ self.send_json(HTTPStatus.OK, result)
+
+ def handle_settings(self) -> None:
+ try:
+ data = self.read_json()
+ channels = data.get("channels", data)
+ if not isinstance(channels, dict):
+ raise ValueError("Settings payload must include a channels object")
+ result = save_channel_settings(
+ runtime,
+ str(channels.get("statusChannelId", "")),
+ str(channels.get("mediaChannelId", "")),
+ str(channels.get("catalogUrl", "")),
+ )
+ except Exception as exc:
+ self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
+ return
+
+ self.send_json(HTTPStatus.OK, {"channels": result})
+
+ def handle_jellyfin_settings(self) -> None:
+ try:
+ data = self.read_json()
+ result = save_jellyfin_settings(runtime, data)
+ except Exception as exc:
+ self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
+ return
+
+ self.send_json(HTTPStatus.OK, {"jellyfin": result})
+
+ def handle_jellyfin_sync(self) -> None:
+ try:
+ data = self.read_json()
+ if data:
+ save_jellyfin_settings(runtime, data)
+ result = sync_jellyfin_library(runtime, force_publish=bool(data.get("forcePublish", False)))
+ except Exception as exc:
+ update_jellyfin_sync_state(runtime, error=str(exc)[:240])
+ self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc), "jellyfin": jellyfin_settings(runtime)})
+ return
+
+ self.send_json(HTTPStatus.OK, result)
+
+ def handle_watchparty_sessions(self) -> None:
+ try:
+ data = self.read_json()
+ result = create_watch_party_session(
+ runtime,
+ guild_id=str(data.get("guildId", "")).strip(),
+ voice_channel_id=str(data.get("voiceChannelId", "")).strip(),
+ text_channel_id=str(data.get("textChannelId", "")).strip(),
+ owner_user_id=str(data.get("ownerUserId", "")).strip(),
+ title=str(data.get("title", "Watch Party")).strip() or "Watch Party",
+ )
+ except Exception as exc:
+ self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
+ return
+
+ self.send_json(HTTPStatus.OK, result)
+
+ def handle_watchparty_queue(self) -> None:
+ try:
+ data = self.read_json()
+ session_id = int(str(data.get("sessionId", "")).strip())
+ source_id = str(data.get("jellyfinSourceId", "")).strip()
+ result = add_watch_party_queue_item(runtime, session_id, source_id)
+ except Exception as exc:
+ self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
+ return
+
+ self.send_json(HTTPStatus.OK, result)
+
+ def handle_watchparty_session_status(self) -> None:
+ try:
+ data = self.read_json()
+ session_id = int(str(data.get("sessionId", "")).strip())
+ status = str(data.get("status", "")).strip()
+ worker_session_id = data.get("workerSessionId")
+ current_queue_entry_id = data.get("currentQueueEntryId")
+ result = update_watch_party_status(
+ session_id,
+ status,
+ worker_session_id=str(worker_session_id).strip() if worker_session_id is not None else None,
+ current_queue_entry_id=int(current_queue_entry_id) if current_queue_entry_id not in {None, ""} else None,
+ )
+ except Exception as exc:
+ self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
+ return
+
+ self.send_json(HTTPStatus.OK, result)
+
+ def handle_watchparty_queue_status(self) -> None:
+ try:
+ data = self.read_json()
+ session_id = int(str(data.get("sessionId", "")).strip())
+ queue_entry_id = int(str(data.get("queueEntryId", "")).strip())
+ status = str(data.get("status", "")).strip()
+ result = update_queue_entry_status(session_id, queue_entry_id, status)
+ except Exception as exc:
+ self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
+ return
+
+ self.send_json(HTTPStatus.OK, result)
+
+ def handle_watchparty_worker_state(self) -> None:
+ try:
+ data = self.read_json()
+ session_id = int(str(data.get("sessionId", "")).strip())
+ result = update_worker_state(
+ session_id,
+ worker_status=str(data.get("workerStatus", "idle")).strip() or "idle",
+ playback_state=str(data.get("playbackState", "idle")).strip() or "idle",
+ current_title=str(data.get("currentTitle", "")).strip(),
+ position_seconds=int(data.get("positionSeconds", 0) or 0),
+ duration_seconds=int(data.get("durationSeconds", 0) or 0),
+ last_error=str(data.get("lastError", "")).strip(),
+ )
+ except Exception as exc:
+ self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
+ return
+
+ self.send_json(HTTPStatus.OK, result)
+
+ def handle_watchparty_worker_command(self) -> None:
+ try:
+ data = self.read_json()
+ session_id = int(str(data.get("sessionId", "")).strip())
+ action = str(data.get("action", "")).strip()
+ command_data = data.get("data", {})
+ if not isinstance(command_data, dict):
+ raise ValueError("Worker command data must be an object")
+ result = worker_command_payload(session_id, action, command_data)
+ except Exception as exc:
+ self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
+ return
+
+ self.send_json(HTTPStatus.OK, result)
+
+ def handle_watchparty_worker_start(self) -> None:
+ try:
+ data = self.read_json()
+ session_id = int(str(data.get("sessionId", "")).strip())
+ session_payload = get_watch_party_session(session_id)
+ session = session_payload["session"]
+ worker = worker_create_session(
+ session_id=session_id,
+ guild_id=str(session["guildId"]),
+ voice_channel_id=str(session["voiceChannelId"]),
+ text_channel_id=str(session["textChannelId"]),
+ )
+ result = update_watch_party_status(
+ session_id,
+ "connecting",
+ worker_session_id=str(worker.get("workerSessionId", session_id)),
+ current_queue_entry_id=session["currentQueueEntryId"],
+ )
+ result = update_worker_state(
+ session_id,
+ worker_status=str(worker.get("workerStatus", "idle")),
+ playback_state=str(worker.get("playbackState", "idle")),
+ current_title=str(worker.get("currentTitle", "")),
+ position_seconds=int(worker.get("positionSeconds", 0) or 0),
+ duration_seconds=int(worker.get("durationSeconds", 0) or 0),
+ last_error=str(worker.get("lastError", "")),
+ )
+ except Exception as exc:
+ self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
+ return
+
+ self.send_json(HTTPStatus.OK, {"worker": worker, **result})
+
+ def handle_watchparty_worker_play_next(self) -> None:
+ try:
+ data = self.read_json()
+ session_id = int(str(data.get("sessionId", "")).strip())
+ queue_entry = first_queued_entry(session_id)
+ if queue_entry is None:
+ raise ValueError("No queued items available")
+ media_item = catalog_item_for_source_id(runtime, str(queue_entry["jellyfinSourceId"]))
+ if media_item is None:
+ raise ValueError(f"Queued media item no longer exists in the saved library: {queue_entry['jellyfinSourceId']}")
+ playback = resolve_jellyfin_playback_source(runtime, media_item)
+ worker = worker_play(
+ session_id=session_id,
+ queue_entry_id=int(queue_entry["id"]),
+ jellyfin_source_id=str(queue_entry["jellyfinSourceId"]),
+ title=str(queue_entry["title"]),
+ media_type=str(queue_entry["mediaType"]),
+ playback=playback,
+ )
+ update_queue_entry_status(session_id, int(queue_entry["id"]), "playing")
+ update_watch_party_status(
+ session_id,
+ "playing",
+ current_queue_entry_id=int(queue_entry["id"]),
+ )
+ result = update_worker_state(
+ session_id,
+ worker_status=str(worker.get("workerStatus", "idle")),
+ playback_state=str(worker.get("playbackState", "idle")),
+ current_title=str(worker.get("currentTitle", "")),
+ position_seconds=int(worker.get("positionSeconds", 0) or 0),
+ duration_seconds=int(worker.get("durationSeconds", 0) or 0),
+ last_error=str(worker.get("lastError", "")),
+ )
+ except Exception as exc:
+ self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
+ return
+
+ self.send_json(HTTPStatus.OK, {"worker": worker, "playback": playback, **result})
+
+ def handle_watchparty_worker_control(self) -> None:
+ try:
+ data = self.read_json()
+ session_id = int(str(data.get("sessionId", "")).strip())
+ action = str(data.get("action", "")).strip()
+ command_data = data.get("data", {})
+ if not isinstance(command_data, dict):
+ raise ValueError("Worker control data must be an object")
+ worker = worker_control(session_id=session_id, action=action, data=command_data)
+ if action == "pause":
+ update_watch_party_status(session_id, "paused")
+ elif action == "resume":
+ update_watch_party_status(session_id, "playing")
+ elif action == "stop":
+ update_watch_party_status(session_id, "stopped")
+ result = update_worker_state(
+ session_id,
+ worker_status=str(worker.get("workerStatus", "idle")),
+ playback_state=str(worker.get("playbackState", "idle")),
+ current_title=str(worker.get("currentTitle", "")),
+ position_seconds=int(worker.get("positionSeconds", 0) or 0),
+ duration_seconds=int(worker.get("durationSeconds", 0) or 0),
+ last_error=str(worker.get("lastError", "")),
+ )
+ except Exception as exc:
+ self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
+ return
+
+ self.send_json(HTTPStatus.OK, {"worker": worker, **result})
+
+ def handle_watchparty_worker_status(self) -> None:
+ try:
+ data = self.read_json()
+ session_id = int(str(data.get("sessionId", "")).strip())
+ worker = worker_status(session_id)
+ result = update_worker_state(
+ session_id,
+ worker_status=str(worker.get("workerStatus", "idle")),
+ playback_state=str(worker.get("playbackState", "idle")),
+ current_title=str(worker.get("currentTitle", "")),
+ position_seconds=int(worker.get("positionSeconds", 0) or 0),
+ duration_seconds=int(worker.get("durationSeconds", 0) or 0),
+ last_error=str(worker.get("lastError", "")),
+ )
+ except Exception as exc:
+ self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
+ return
+
+ self.send_json(HTTPStatus.OK, {"worker": worker, **result})
+
+ return DashboardHandler
+
+
+def maybe_start_dashboard(runtime: BotRuntime) -> ThreadingHTTPServer | None:
+ if not bool_env("DASHBOARD_ENABLED", False):
+ return None
+
+ host = os.getenv("DASHBOARD_HOST", "127.0.0.1").strip() or "127.0.0.1"
+ port = int(os.getenv("DASHBOARD_PORT", "8787"))
+ auth = dashboard_auth_from_env()
+ server = ThreadingHTTPServer((host, port), make_dashboard_handler(runtime, auth))
+ thread = __import__("threading").Thread(target=server.serve_forever, name="dashboard", daemon=True)
+ thread.start()
+ auth_note = "without auth" if auth is None else "with password sessions"
+ print(f"Dashboard running at http://{host}:{port} ({auth_note})", flush=True)
+ return server
diff --git a/archive_bot/db.py b/archive_bot/db.py
new file mode 100644
index 0000000..59e171a
--- /dev/null
+++ b/archive_bot/db.py
@@ -0,0 +1,111 @@
+from __future__ import annotations
+
+import json
+import os
+import sqlite3
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+DEFAULT_DB_PATH = Path("state/archive-bot.db")
+
+
+def database_path() -> Path:
+ raw = os.getenv("ARCHIVE_BOT_DB_PATH", "").strip()
+ return Path(raw) if raw else DEFAULT_DB_PATH
+
+
+def document_name(path: Path) -> str:
+ return path.as_posix()
+
+
+def connect_db() -> sqlite3.Connection:
+ db_path = database_path()
+ db_path.parent.mkdir(parents=True, exist_ok=True)
+ connection = sqlite3.connect(db_path)
+ connection.row_factory = sqlite3.Row
+ connection.execute("PRAGMA foreign_keys = ON")
+ return connection
+
+
+def initialize_database() -> None:
+ with connect_db() as connection:
+ connection.execute(
+ """
+ CREATE TABLE IF NOT EXISTS documents (
+ name TEXT PRIMARY KEY,
+ content TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ )
+ """
+ )
+ connection.execute(
+ """
+ CREATE TABLE IF NOT EXISTS metadata (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL
+ )
+ """
+ )
+ connection.execute(
+ """
+ INSERT INTO metadata(key, value)
+ VALUES ('schema_version', '1')
+ ON CONFLICT(key) DO UPDATE SET value=excluded.value
+ """
+ )
+
+
+def load_document(path: Path) -> dict[str, Any] | None:
+ initialize_database()
+ with connect_db() as connection:
+ row = connection.execute(
+ "SELECT content FROM documents WHERE name = ?",
+ (document_name(path),),
+ ).fetchone()
+ if row is None:
+ return None
+ try:
+ data = json.loads(str(row["content"]))
+ except json.JSONDecodeError:
+ return {}
+ return data if isinstance(data, dict) else {}
+
+
+def save_document(path: Path, data: dict[str, Any]) -> None:
+ initialize_database()
+ payload = json.dumps(data, indent=2, sort_keys=True)
+ now = datetime.now(timezone.utc).isoformat()
+ with connect_db() as connection:
+ connection.execute(
+ """
+ INSERT INTO documents(name, content, updated_at)
+ VALUES (?, ?, ?)
+ ON CONFLICT(name) DO UPDATE
+ SET content = excluded.content,
+ updated_at = excluded.updated_at
+ """,
+ (document_name(path), payload, now),
+ )
+ connection.commit()
+
+
+def migrate_json_document(path: Path) -> None:
+ existing = load_document(path)
+ if existing is not None:
+ return
+ if not path.exists():
+ return
+ try:
+ with path.open("r", encoding="utf-8") as handle:
+ data = json.load(handle)
+ except (OSError, json.JSONDecodeError):
+ return
+ if isinstance(data, dict):
+ save_document(path, data)
+
+
+def migrate_runtime_documents(paths: list[Path]) -> None:
+ initialize_database()
+ for path in paths:
+ migrate_json_document(path)
diff --git a/archive_bot/discord_api.py b/archive_bot/discord_api.py
new file mode 100644
index 0000000..eef038c
--- /dev/null
+++ b/archive_bot/discord_api.py
@@ -0,0 +1,178 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import secrets
+import sys
+import threading
+import urllib.error
+import urllib.request
+from typing import Any
+
+from .core import DISCORD_API
+
+
+class DiscordGatewayManager:
+ def __init__(self, token: str) -> None:
+ self.token = token
+ self.thread: threading.Thread | None = None
+ self.loop: asyncio.AbstractEventLoop | None = None
+ self.client: Any = None
+ self.ready = threading.Event()
+ self._disconnecting = threading.Event()
+
+ def start(self) -> None:
+ if self.thread is not None:
+ return
+ self.thread = threading.Thread(target=self._run, name="discord-gateway", daemon=True)
+ self.thread.start()
+
+ def stop(self) -> None:
+ self._disconnecting.set()
+ if self.loop is not None and self.client is not None:
+ asyncio.run_coroutine_threadsafe(self.client.close(), self.loop)
+ if self.thread is not None:
+ self.thread.join(timeout=15)
+
+ def _run(self) -> None:
+ try:
+ import discord
+ except ImportError:
+ print(
+ "discord.py is not installed. Install requirements.txt to keep the bot online.",
+ file=sys.stderr,
+ flush=True,
+ )
+ self.ready.set()
+ return
+
+ class GatewayClient(discord.Client):
+ def __init__(self, manager: DiscordGatewayManager) -> None:
+ intents = discord.Intents.default()
+ intents.guilds = True
+ super().__init__(intents=intents)
+ self.manager = manager
+
+ async def on_ready(self) -> None:
+ await self.change_presence(status=discord.Status.online)
+ user = self.user
+ name = user.name if user is not None else "unknown"
+ bot_id = user.id if user is not None else "unknown"
+ print(f"Discord gateway connected as {name} ({bot_id})", flush=True)
+ self.manager.ready.set()
+
+ async def on_disconnect(self) -> None:
+ self.manager.ready.clear()
+
+ self.loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(self.loop)
+ self.client = GatewayClient(self)
+
+ try:
+ self.loop.run_until_complete(self.client.start(self.token))
+ except Exception as exc:
+ if not self._disconnecting.is_set():
+ print(f"Discord gateway stopped: {exc}", file=sys.stderr, flush=True)
+ finally:
+ self.ready.set()
+ try:
+ pending = asyncio.all_tasks(self.loop)
+ for task in pending:
+ task.cancel()
+ if pending:
+ self.loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
+ finally:
+ self.loop.close()
+
+
+def discord_request(
+ method: str,
+ token: str,
+ path: str,
+ payload: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+ body = None
+ headers = {
+ "Authorization": f"Bot {token}",
+ "User-Agent": "ArchiveStatusBot/1.0",
+ }
+
+ if payload is not None:
+ body = json.dumps(payload).encode("utf-8")
+ headers["Content-Type"] = "application/json"
+
+ request = urllib.request.Request(
+ f"{DISCORD_API}{path}",
+ data=body,
+ headers=headers,
+ method=method,
+ )
+
+ try:
+ with urllib.request.urlopen(request, timeout=20) as response:
+ data = response.read()
+ if not data:
+ return {}
+ return json.loads(data.decode("utf-8"))
+ except urllib.error.HTTPError as exc:
+ detail = exc.read().decode("utf-8", errors="ignore")
+ raise RuntimeError(f"Discord API {method} {path} failed: {exc.code} {detail}") from exc
+
+
+def discord_multipart_request(
+ method: str,
+ token: str,
+ path: str,
+ payload: dict[str, Any],
+ files: list[tuple[str, str, str, bytes]],
+) -> dict[str, Any]:
+ boundary = f"----ArchiveBot{secrets.token_hex(16)}"
+ body = bytearray()
+
+ def add_part(name: str, content: bytes, content_type: str, filename: str | None = None) -> None:
+ body.extend(f"--{boundary}\r\n".encode("ascii"))
+ disposition = f'Content-Disposition: form-data; name="{name}"'
+ if filename is not None:
+ disposition += f'; filename="{filename}"'
+ body.extend(f"{disposition}\r\n".encode("utf-8"))
+ body.extend(f"Content-Type: {content_type}\r\n\r\n".encode("ascii"))
+ body.extend(content)
+ body.extend(b"\r\n")
+
+ add_part("payload_json", json.dumps(payload).encode("utf-8"), "application/json")
+ for field_name, filename, content_type, content in files:
+ add_part(field_name, content, content_type, filename)
+ body.extend(f"--{boundary}--\r\n".encode("ascii"))
+
+ request = urllib.request.Request(
+ f"{DISCORD_API}{path}",
+ data=bytes(body),
+ headers={
+ "Authorization": f"Bot {token}",
+ "User-Agent": "ArchiveStatusBot/1.0",
+ "Content-Type": f"multipart/form-data; boundary={boundary}",
+ },
+ method=method,
+ )
+
+ try:
+ with urllib.request.urlopen(request, timeout=30) as response:
+ data = response.read()
+ if not data:
+ return {}
+ return json.loads(data.decode("utf-8"))
+ except urllib.error.HTTPError as exc:
+ detail = exc.read().decode("utf-8", errors="ignore")
+ raise RuntimeError(f"Discord API {method} {path} failed: {exc.code} {detail}") from exc
+
+
+def discord_delete_message(token: str, channel_id: str, message_id: str) -> None:
+ try:
+ discord_request("DELETE", token, f"/channels/{channel_id}/messages/{message_id}")
+ except RuntimeError as exc:
+ print(f"Could not delete old media catalog message {message_id}: {exc}", file=sys.stderr)
+
+
+def discord_bot_identity(token: str) -> dict[str, Any]:
+ return discord_request("GET", token, "/users/@me")
+
diff --git a/archive_bot/main.py b/archive_bot/main.py
new file mode 100644
index 0000000..a13ae0f
--- /dev/null
+++ b/archive_bot/main.py
@@ -0,0 +1,116 @@
+from __future__ import annotations
+
+import os
+import signal
+import sys
+import time
+from pathlib import Path
+from typing import Any
+
+from .auth import print_password_hash
+from .core import BotRuntime, bool_env, env, load_dotenv, normalize_discord_token
+from .db import initialize_database, migrate_runtime_documents
+from .dashboard import maybe_start_dashboard
+from .discord_api import DiscordGatewayManager, discord_bot_identity
+from .media import maybe_run_jellyfin_sync
+from .status import load_services, print_preview, run_check_cycle
+from .watchparty import initialize_watchparty_schema
+
+
+def main() -> int:
+ load_dotenv()
+
+ if "--hash-password" in sys.argv:
+ print_password_hash()
+ return 0
+
+ if "--preview" in sys.argv:
+ config_path = Path(os.getenv("ARCHIVE_STATUS_CONFIG", "services.json"))
+ initialize_database()
+ migrate_runtime_documents([config_path])
+ print_preview(load_services(config_path))
+ return 0
+
+ token = normalize_discord_token(env("DISCORD_BOT_TOKEN"))
+ channel_id = os.getenv("DISCORD_CHANNEL_ID", "").strip()
+ config_path = Path(env("ARCHIVE_STATUS_CONFIG", "services.json"))
+ state_path = Path(env("ARCHIVE_STATUS_STATE", "state/status-message.json"))
+ media_state_path = Path(env("MEDIA_CATALOG_STATE", "state/media-catalog.json"))
+ media_library_path = Path(env("MEDIA_LIBRARY_STATE", "state/media-library.json"))
+ settings_path = Path(env("BOT_SETTINGS_STATE", "state/bot-settings.json"))
+ initialize_database()
+ initialize_watchparty_schema()
+ migrate_runtime_documents(
+ [
+ config_path,
+ state_path,
+ media_state_path,
+ media_library_path,
+ settings_path,
+ ]
+ )
+ interval = int(env("CHECK_INTERVAL_SECONDS", "60"))
+ runtime = BotRuntime(
+ token,
+ channel_id,
+ config_path,
+ state_path,
+ media_state_path,
+ media_library_path,
+ settings_path,
+ dry_run=bool_env("DISCORD_DRY_RUN", False),
+ )
+ gateway = None
+ if bool_env("DISCORD_GATEWAY_ENABLED", True) and not runtime.dry_run:
+ gateway = DiscordGatewayManager(token)
+ gateway.start()
+ dashboard = maybe_start_dashboard(runtime)
+
+ if runtime.dry_run:
+ print("Discord dry run is enabled; no Discord messages will be sent or edited.", flush=True)
+ else:
+ try:
+ identity = discord_bot_identity(token)
+ username = identity.get("username", "unknown")
+ bot_id = identity.get("id", "unknown")
+ print(f"Discord token authenticated as {username} ({bot_id})", flush=True)
+ except Exception as exc:
+ print(f"Could not verify Discord bot identity: {exc}", file=sys.stderr, flush=True)
+
+ stopped = False
+
+ def stop(_signum: int, _frame: Any) -> None:
+ nonlocal stopped
+ stopped = True
+
+ signal.signal(signal.SIGINT, stop)
+ signal.signal(signal.SIGTERM, stop)
+
+ while not stopped:
+ try:
+ message_id, results = run_check_cycle(runtime)
+ online = sum(1 for result in results if result.ok)
+ print(f"Updated Discord status message {message_id}: {online}/{len(results)} online", flush=True)
+ sync_result = maybe_run_jellyfin_sync(runtime)
+ if sync_result is not None:
+ action = "published" if sync_result["published"] else "checked"
+ print(
+ f"Jellyfin sync {action}: {sync_result['movieCount']} movies, {sync_result['showCount']} shows",
+ flush=True,
+ )
+ except Exception as exc:
+ with runtime.lock:
+ runtime.last_error = str(exc)
+ print(f"Status update failed: {exc}", file=sys.stderr, flush=True)
+
+ for _ in range(interval):
+ if stopped:
+ break
+ time.sleep(1)
+
+ if dashboard is not None:
+ dashboard.shutdown()
+ if gateway is not None:
+ gateway.stop()
+
+ return 0
diff --git a/archive_bot/media.py b/archive_bot/media.py
new file mode 100644
index 0000000..2990f73
--- /dev/null
+++ b/archive_bot/media.py
@@ -0,0 +1,1517 @@
+from __future__ import annotations
+
+import hashlib
+import html
+import json
+import os
+import re
+import socket
+import urllib.parse
+import urllib.parse
+import urllib.error
+import urllib.request
+from datetime import datetime, timezone
+from typing import Any
+
+from .core import (
+ BotRuntime,
+ DEFAULT_JELLYFIN_SYNC_INTERVAL_SECONDS,
+ MAX_DISCORD_EMBEDS,
+ MEDIA_ITEMS_PER_EMBED,
+ MediaItem,
+ clean_error,
+)
+from .discord_api import discord_delete_message, discord_multipart_request
+from .storage import (
+ channel_settings,
+ jellyfin_settings,
+ load_state,
+ save_catalog_url_setting,
+ save_media_channel_setting,
+ save_state,
+ validate_channel_id,
+)
+
+
+TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p/w342"
+TMDB_POSTER_CACHE: dict[str, str | None] = {}
+
+
+def parse_int_text(value: str) -> int | None:
+ digits = "".join(character for character in value if character.isdigit())
+ if not digits:
+ return None
+ try:
+ return int(digits)
+ except ValueError:
+ return None
+
+
+def parse_year_text(value: str) -> str | None:
+ match = re.search(r"(18|19|20|21)\d{2}", value)
+ return match.group(0) if match else None
+
+
+def clean_media_text(value: str, limit: int = 220) -> str | None:
+ cleaned = " ".join(str(value).replace("\r", " ").replace("\n", " ").split())
+ if not cleaned:
+ return None
+ if len(cleaned) <= limit:
+ return cleaned
+ return cleaned[: limit - 1].rstrip() + "…"
+
+
+def format_runtime(value: str) -> str | None:
+ cleaned = clean_media_text(value, 48)
+ if not cleaned:
+ return None
+ if not cleaned.isdigit():
+ return cleaned
+
+ minutes = int(cleaned)
+ if minutes <= 0:
+ return None
+ hours, remainder = divmod(minutes, 60)
+ if hours and remainder:
+ return f"{hours}h {remainder}m"
+ if hours:
+ return f"{hours}h"
+ return f"{remainder}m"
+
+
+def media_item_to_jsonable(item: MediaItem) -> dict[str, Any]:
+ data: dict[str, Any] = {
+ "title": item.title,
+ "mediaType": item.media_type,
+ "sourceId": item.source_id or "",
+ "tmdbId": item.tmdb_id or "",
+ "imdbId": item.imdb_id or "",
+ "year": item.year or "",
+ "genres": item.genres or "",
+ "rating": item.rating or "",
+ "runtime": item.runtime or "",
+ "summary": item.summary or "",
+ }
+ if item.media_type == "show":
+ data["seasons"] = item.seasons if item.seasons is not None else ""
+ data["episodes"] = item.episodes if item.episodes is not None else ""
+ return data
+
+
+def media_item_from_data(data: Any, media_type: str) -> MediaItem:
+ if not isinstance(data, dict):
+ raise ValueError(f"{media_type.title()} entries must be objects")
+
+ title = clean_media_text(str(data.get("title", "")), 120)
+ if not title:
+ raise ValueError(f"{media_type.title()} entries must include a title")
+
+ year = parse_year_text(str(data.get("year", "")))
+ seasons = parse_int_text(str(data.get("seasons", ""))) if media_type == "show" else None
+ episodes = parse_int_text(str(data.get("episodes", ""))) if media_type == "show" else None
+
+ return MediaItem(
+ title=title,
+ media_type=media_type,
+ source_id=clean_media_text(str(data.get("sourceId", "")), 120),
+ tmdb_id=clean_media_text(str(data.get("tmdbId", "")), 120),
+ imdb_id=clean_media_text(str(data.get("imdbId", "")), 120),
+ year=year,
+ genres=clean_media_text(str(data.get("genres", "")), 120),
+ rating=clean_media_text(str(data.get("rating", "")), 32),
+ runtime=format_runtime(str(data.get("runtime", ""))) if media_type == "movie" else None,
+ summary=clean_media_text(str(data.get("summary", ""))),
+ seasons=seasons,
+ episodes=episodes,
+ )
+
+
+def media_items_from_data(raw_items: Any, media_type: str) -> list[MediaItem]:
+ if raw_items is None:
+ return []
+ if not isinstance(raw_items, list):
+ raise ValueError(f"{media_type.title()} library must be a list")
+
+ deduped: dict[tuple[str, str], MediaItem] = {}
+ for raw_item in raw_items:
+ item = media_item_from_data(raw_item, media_type)
+ deduped[(item.title.casefold(), item.year or "")] = item
+
+ return sorted(deduped.values(), key=lambda item: (item.title.casefold(), item.year or ""))
+
+
+def media_library_to_jsonable(movies: list[MediaItem], shows: list[MediaItem]) -> dict[str, Any]:
+ return {
+ "movies": [media_item_to_jsonable(item) for item in movies],
+ "shows": [media_item_to_jsonable(item) for item in shows],
+ }
+
+
+def load_media_library(runtime: BotRuntime) -> tuple[list[MediaItem], list[MediaItem]]:
+ data = load_state(runtime.media_library_path)
+ return (
+ media_items_from_data(data.get("movies", []), "movie"),
+ media_items_from_data(data.get("shows", []), "show"),
+ )
+
+
+def save_media_library(runtime: BotRuntime, movies: list[MediaItem], shows: list[MediaItem]) -> dict[str, Any]:
+ payload = media_library_to_jsonable(movies, shows)
+ payload["updated_at"] = datetime.now(timezone.utc).isoformat()
+ save_state(runtime.media_library_path, payload)
+ return payload
+
+
+def reset_media_library(runtime: BotRuntime) -> dict[str, Any]:
+ save_media_library(runtime, [], [])
+ state = load_state(runtime.settings_path)
+ for key in (
+ "jellyfin_last_sync_at",
+ "jellyfin_last_sync_error",
+ "jellyfin_last_published_at",
+ "jellyfin_last_fingerprint",
+ "jellyfin_last_published_fingerprint",
+ "jellyfin_last_changes",
+ ):
+ state.pop(key, None)
+ state["updated_at"] = datetime.now(timezone.utc).isoformat()
+ save_state(runtime.settings_path, state)
+ return {
+ "library": media_library_to_jsonable([], []),
+ "movieCount": 0,
+ "showCount": 0,
+ "changes": {"added": [], "removed": [], "addedCount": 0, "removedCount": 0},
+ "jellyfin": jellyfin_settings(runtime),
+ }
+
+
+def jellyfin_runtime(runtime_ticks: Any) -> str | None:
+ try:
+ ticks = int(runtime_ticks or 0)
+ except (TypeError, ValueError):
+ return None
+ if ticks <= 0:
+ return None
+ minutes = round(ticks / 10_000_000 / 60)
+ if minutes <= 0:
+ return None
+ hours, remainder = divmod(minutes, 60)
+ if hours and remainder:
+ return f"{hours}h {remainder}m"
+ if hours:
+ return f"{hours}h"
+ return f"{remainder}m"
+
+
+def jellyfin_item_year(item: dict[str, Any]) -> str | None:
+ production_year = item.get("ProductionYear")
+ if production_year:
+ return parse_year_text(str(production_year))
+ return parse_year_text(str(item.get("PremiereDate", "")))
+
+
+def jellyfin_item_summary(item: dict[str, Any]) -> str | None:
+ return clean_media_text(str(item.get("Overview", "") or item.get("ShortOverview", "")))
+
+
+def jellyfin_movie_from_item(item: dict[str, Any]) -> MediaItem:
+ provider_ids = item.get("ProviderIds") if isinstance(item.get("ProviderIds"), dict) else {}
+ return MediaItem(
+ title=clean_media_text(str(item.get("Name", "")), 120) or "Untitled Movie",
+ media_type="movie",
+ source_id=clean_media_text(str(item.get("Id", "")), 120),
+ tmdb_id=clean_media_text(str(provider_ids.get("Tmdb", "")), 120),
+ imdb_id=clean_media_text(str(provider_ids.get("Imdb", "")), 120),
+ year=jellyfin_item_year(item),
+ genres=clean_media_text(", ".join(str(genre) for genre in item.get("Genres", []) if genre), 120),
+ rating=clean_media_text(str(item.get("OfficialRating", "")), 32),
+ runtime=jellyfin_runtime(item.get("RunTimeTicks")),
+ summary=jellyfin_item_summary(item),
+ )
+
+
+def jellyfin_show_from_item(item: dict[str, Any]) -> MediaItem:
+ provider_ids = item.get("ProviderIds") if isinstance(item.get("ProviderIds"), dict) else {}
+ return MediaItem(
+ title=clean_media_text(str(item.get("Name", "")), 120) or "Untitled Show",
+ media_type="show",
+ source_id=clean_media_text(str(item.get("Id", "")), 120),
+ tmdb_id=clean_media_text(str(provider_ids.get("Tmdb", "")), 120),
+ imdb_id=clean_media_text(str(provider_ids.get("Imdb", "")), 120),
+ year=jellyfin_item_year(item),
+ genres=clean_media_text(", ".join(str(genre) for genre in item.get("Genres", []) if genre), 120),
+ rating=clean_media_text(str(item.get("OfficialRating", "")), 32),
+ summary=jellyfin_item_summary(item),
+ seasons=parse_int_text(str(item.get("ChildCount", ""))),
+ episodes=parse_int_text(str(item.get("RecursiveItemCount", ""))),
+ )
+
+
+def jellyfin_request(settings: dict[str, Any], path: str, params: dict[str, Any]) -> dict[str, Any]:
+ base_url = str(settings.get("url", "")).strip().rstrip("/")
+ api_key = str(settings.get("apiKey", "")).strip()
+ if not base_url or not api_key:
+ raise ValueError("Jellyfin URL and API key are required")
+
+ query = urllib.parse.urlencode({key: value for key, value in params.items() if value is not None})
+ url = f"{base_url}{path}"
+ if query:
+ url = f"{url}?{query}"
+ request = urllib.request.Request(
+ url,
+ headers={
+ "Accept": "application/json",
+ "User-Agent": "ArchiveStatusBot/1.0",
+ "X-Emby-Token": api_key,
+ },
+ method="GET",
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=30) as response:
+ return json.loads(response.read().decode("utf-8"))
+ except urllib.error.HTTPError as exc:
+ detail = exc.read().decode("utf-8", errors="ignore")
+ raise RuntimeError(f"Jellyfin API failed: HTTP {exc.code} {detail}") from exc
+ except (urllib.error.URLError, TimeoutError, socket.timeout) as exc:
+ raise RuntimeError(f"Jellyfin API failed: {clean_error(exc)}") from exc
+
+
+def jellyfin_connection_settings(runtime: BotRuntime) -> dict[str, Any]:
+ state = load_state(runtime.settings_path)
+ return {
+ "url": str(state.get("jellyfin_url", "")).strip(),
+ "apiKey": str(state.get("jellyfin_api_key", "")).strip(),
+ }
+
+
+def media_item_duration_seconds(item: MediaItem) -> int:
+ runtime = str(item.runtime or "").strip().lower()
+ if not runtime:
+ return 0
+
+ hours = 0
+ minutes = 0
+ hour_match = re.search(r"(\d+)\s*h", runtime)
+ minute_match = re.search(r"(\d+)\s*m", runtime)
+ if hour_match:
+ hours = int(hour_match.group(1))
+ if minute_match:
+ minutes = int(minute_match.group(1))
+ elif runtime.isdigit():
+ minutes = int(runtime)
+ return max(0, hours * 3600 + minutes * 60)
+
+
+def jellyfin_stream_url(base_url: str, item_id: str, api_key: str) -> str:
+ query = urllib.parse.urlencode({"static": "true", "api_key": api_key})
+ return f"{base_url}/Videos/{urllib.parse.quote(item_id, safe='')}/stream?{query}"
+
+
+def jellyfin_download_url(base_url: str, item_id: str, api_key: str) -> str:
+ query = urllib.parse.urlencode({"api_key": api_key})
+ return f"{base_url}/Items/{urllib.parse.quote(item_id, safe='')}/Download?{query}"
+
+
+def jellyfin_first_episode(settings: dict[str, Any], series_id: str) -> dict[str, Any] | None:
+ data = jellyfin_request(
+ settings,
+ "/Items",
+ {
+ "ParentId": series_id,
+ "Recursive": "true",
+ "IncludeItemTypes": "Episode",
+ "Fields": "RunTimeTicks,ParentIndexNumber,IndexNumber",
+ "SortBy": "ParentIndexNumber,IndexNumber,SortName",
+ "SortOrder": "Ascending",
+ "Limit": 1,
+ },
+ )
+ items = data.get("Items", [])
+ if not isinstance(items, list):
+ raise RuntimeError("Jellyfin returned an invalid episode payload")
+ for item in items:
+ if isinstance(item, dict) and str(item.get("Id", "")).strip():
+ return item
+ return None
+
+
+def resolve_jellyfin_playback_source(runtime: BotRuntime, item: MediaItem) -> dict[str, Any]:
+ settings = jellyfin_connection_settings(runtime)
+ base_url = str(settings.get("url", "")).strip().rstrip("/")
+ api_key = str(settings.get("apiKey", "")).strip()
+ if not base_url or not api_key:
+ raise ValueError("Jellyfin playback is not configured")
+ if not item.source_id:
+ raise ValueError(f"Media item has no Jellyfin source ID: {item.title}")
+
+ playback_item_id = item.source_id
+ playback_title = item.title
+ duration_seconds = media_item_duration_seconds(item)
+ episode_meta: dict[str, Any] | None = None
+
+ if item.media_type == "show":
+ episode = jellyfin_first_episode(settings, item.source_id)
+ if episode is None:
+ raise ValueError(f"No playable episodes found for show: {item.title}")
+ playback_item_id = clean_media_text(str(episode.get("Id", "")), 120) or item.source_id
+ season_number = int(episode.get("ParentIndexNumber") or 0)
+ episode_number = int(episode.get("IndexNumber") or 0)
+ episode_title = clean_media_text(str(episode.get("Name", "")), 120) or "Episode"
+ playback_title = f"{item.title} - S{season_number:02d}E{episode_number:02d} - {episode_title}"
+ ticks = episode.get("RunTimeTicks")
+ try:
+ duration_seconds = max(0, round(int(ticks or 0) / 10_000_000))
+ except (TypeError, ValueError):
+ duration_seconds = 0
+ episode_meta = {
+ "seasonNumber": season_number,
+ "episodeNumber": episode_number,
+ "episodeTitle": episode_title,
+ "seriesId": item.source_id,
+ }
+
+ return {
+ "itemId": playback_item_id,
+ "sourceId": item.source_id,
+ "title": playback_title,
+ "mediaType": item.media_type,
+ "streamUrl": jellyfin_stream_url(base_url, playback_item_id, api_key),
+ "downloadUrl": jellyfin_download_url(base_url, playback_item_id, api_key),
+ "durationSeconds": duration_seconds,
+ "posterUrl": media_item_card_payload(item).get("posterUrl", ""),
+ "year": item.year or "",
+ "tmdbId": item.tmdb_id or "",
+ "imdbId": item.imdb_id or "",
+ "episode": episode_meta,
+ }
+
+
+def fetch_jellyfin_library_ids(settings: dict[str, Any], library_names: list[str]) -> list[str]:
+ if not library_names:
+ return []
+
+ requested = {name.casefold() for name in library_names}
+ data = jellyfin_request(settings, "/Library/MediaFolders", {})
+ items = data.get("Items", [])
+ if not isinstance(items, list):
+ raise RuntimeError("Jellyfin returned an invalid library folder payload")
+
+ matched: list[str] = []
+ available: list[str] = []
+ for item in items:
+ if not isinstance(item, dict):
+ continue
+ name = str(item.get("Name", "")).strip()
+ folder_id = str(item.get("Id", "")).strip()
+ if name:
+ available.append(name)
+ if name.casefold() in requested and folder_id:
+ matched.append(folder_id)
+
+ missing = sorted(set(library_names) - {name for name in available if name.casefold() in requested})
+ if missing:
+ raise ValueError(f"Jellyfin library not found: {', '.join(missing)}")
+ return matched
+
+
+def fetch_jellyfin_items(settings: dict[str, Any], item_type: str) -> list[dict[str, Any]]:
+ parent_ids = settings.get("ParentIds")
+ if isinstance(parent_ids, list) and parent_ids:
+ all_items: list[dict[str, Any]] = []
+ for parent_id in parent_ids:
+ scoped_settings = dict(settings)
+ scoped_settings["ParentId"] = str(parent_id)
+ scoped_settings.pop("ParentIds", None)
+ all_items.extend(fetch_jellyfin_items(scoped_settings, item_type))
+ return all_items
+
+ parent_id_value = str(settings.get("ParentId", "")).strip() or None
+ items: list[dict[str, Any]] = []
+ start_index = 0
+ limit = 200
+ while True:
+ data = jellyfin_request(
+ settings,
+ "/Items",
+ {
+ "Recursive": "true",
+ "IncludeItemTypes": item_type,
+ "Fields": "Genres,Overview,OfficialRating,RecursiveItemCount,ChildCount,PremiereDate,RunTimeTicks,ProviderIds",
+ "SortBy": "SortName",
+ "SortOrder": "Ascending",
+ "EnableImages": "false",
+ "ParentId": parent_id_value,
+ "StartIndex": start_index,
+ "Limit": limit,
+ },
+ )
+ page = data.get("Items", [])
+ if not isinstance(page, list):
+ raise RuntimeError("Jellyfin returned an invalid Items payload")
+ items.extend(item for item in page if isinstance(item, dict))
+ total = int(data.get("TotalRecordCount", len(items)) or len(items))
+ if not page or len(items) >= total:
+ return items
+ start_index += limit
+
+
+def jellyfin_item_dedupe_key(item: dict[str, Any], item_type: str) -> tuple[str, str]:
+ provider_ids = item.get("ProviderIds")
+ if isinstance(provider_ids, dict):
+ for provider in ("Tmdb", "Imdb", "Tvdb"):
+ value = str(provider_ids.get(provider, "")).strip().casefold()
+ if value:
+ return item_type.casefold(), f"{provider.casefold()}:{value}"
+
+ title = clean_media_text(str(item.get("Name", "")), 120) or ""
+ year = jellyfin_item_year(item) or ""
+ return item_type.casefold(), f"title:{title.casefold()}:{year}"
+
+
+def dedupe_jellyfin_items(items: list[dict[str, Any]], item_type: str) -> list[dict[str, Any]]:
+ provider_deduped: dict[tuple[str, str], dict[str, Any]] = {}
+ for item in items:
+ key = jellyfin_item_dedupe_key(item, item_type)
+ current = provider_deduped.get(key)
+ if current is None:
+ provider_deduped[key] = item
+ continue
+ current_overview = str(current.get("Overview", "") or current.get("ShortOverview", ""))
+ next_overview = str(item.get("Overview", "") or item.get("ShortOverview", ""))
+ if len(next_overview) > len(current_overview):
+ provider_deduped[key] = item
+
+ title_deduped: dict[tuple[str, str], dict[str, Any]] = {}
+ for item in provider_deduped.values():
+ title = clean_media_text(str(item.get("Name", "")), 120) or ""
+ key = (title.casefold(), jellyfin_item_year(item) or "")
+ current = title_deduped.get(key)
+ if current is None:
+ title_deduped[key] = item
+ continue
+ current_has_provider = bool(current.get("ProviderIds"))
+ next_has_provider = bool(item.get("ProviderIds"))
+ if next_has_provider and not current_has_provider:
+ title_deduped[key] = item
+ continue
+ current_overview = str(current.get("Overview", "") or current.get("ShortOverview", ""))
+ next_overview = str(item.get("Overview", "") or item.get("ShortOverview", ""))
+ if len(next_overview) > len(current_overview):
+ title_deduped[key] = item
+
+ return sorted(
+ title_deduped.values(),
+ key=lambda item: (str(item.get("SortName", item.get("Name", ""))).casefold(), str(item.get("ProductionYear", ""))),
+ )
+
+
+def fetch_jellyfin_library(runtime: BotRuntime) -> tuple[list[MediaItem], list[MediaItem]]:
+ state = load_state(runtime.settings_path)
+ settings = {
+ "url": str(state.get("jellyfin_url", "")).strip(),
+ "apiKey": str(state.get("jellyfin_api_key", "")).strip(),
+ }
+ library_names = jellyfin_settings(runtime).get("libraryNames", [])
+ if isinstance(library_names, list) and library_names:
+ settings["ParentIds"] = fetch_jellyfin_library_ids(settings, [str(name) for name in library_names])
+ movie_items = dedupe_jellyfin_items(fetch_jellyfin_items(settings, "Movie"), "Movie")
+ show_items = dedupe_jellyfin_items(fetch_jellyfin_items(settings, "Series"), "Series")
+ movies = [jellyfin_movie_from_item(item) for item in movie_items]
+ shows = [jellyfin_show_from_item(item) for item in show_items]
+ return (
+ sorted(movies, key=lambda item: (item.title.casefold(), item.year or "")),
+ sorted(shows, key=lambda item: (item.title.casefold(), item.year or "")),
+ )
+
+
+def media_library_fingerprint(movies: list[MediaItem], shows: list[MediaItem]) -> str:
+ payload = media_library_to_jsonable(movies, shows)
+ body = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
+ return hashlib.sha256(body).hexdigest()
+
+
+def media_item_tracking_key(item: MediaItem) -> str:
+ return f"{item.media_type}:{item.title.casefold()}:{item.year or ''}"
+
+
+def media_change_entry(item: MediaItem) -> dict[str, Any]:
+ return {
+ "title": item.title,
+ "year": item.year or "",
+ "mediaType": item.media_type,
+ }
+
+
+def catalog_poster_path(item: MediaItem) -> str:
+ if not item.source_id:
+ return ""
+ return f"/catalog/poster/{urllib.parse.quote(item.source_id, safe='')}"
+
+
+def tmdb_auth_headers() -> dict[str, str]:
+ bearer = os.getenv("TMDB_API_READ_TOKEN", "").strip()
+ if bearer:
+ return {
+ "Authorization": f"Bearer {bearer}",
+ "Accept": "application/json",
+ }
+ return {"Accept": "application/json"}
+
+
+def tmdb_api_key() -> str:
+ return os.getenv("TMDB_API_KEY", "").strip()
+
+
+def tmdb_enabled() -> bool:
+ return bool(os.getenv("TMDB_API_READ_TOKEN", "").strip() or tmdb_api_key())
+
+
+def tmdb_request(path: str, params: dict[str, Any]) -> dict[str, Any]:
+ if not tmdb_enabled():
+ raise RuntimeError("TMDB credentials are not configured")
+
+ query_params = {key: value for key, value in params.items() if value not in {None, ""}}
+ api_key = tmdb_api_key()
+ if api_key:
+ query_params["api_key"] = api_key
+ query = urllib.parse.urlencode(query_params)
+ url = f"https://api.themoviedb.org/3{path}"
+ if query:
+ url = f"{url}?{query}"
+ request = urllib.request.Request(
+ url,
+ headers={
+ "User-Agent": "ArchiveStatusBot/1.0",
+ **tmdb_auth_headers(),
+ },
+ method="GET",
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=20) as response:
+ return json.loads(response.read().decode("utf-8"))
+ except urllib.error.HTTPError as exc:
+ detail = exc.read().decode("utf-8", errors="ignore")
+ raise RuntimeError(f"TMDB API failed: HTTP {exc.code} {detail}") from exc
+ except (urllib.error.URLError, TimeoutError, socket.timeout) as exc:
+ raise RuntimeError(f"TMDB API failed: {clean_error(exc)}") from exc
+
+
+def tmdb_image_url(file_path: str | None) -> str | None:
+ if not file_path:
+ return None
+ return f"{TMDB_IMAGE_BASE_URL}{file_path}"
+
+
+def tmdb_cache_key(item: MediaItem) -> str:
+ return "|".join(
+ [
+ item.media_type,
+ item.source_id or "",
+ item.tmdb_id or "",
+ item.imdb_id or "",
+ item.title.casefold(),
+ item.year or "",
+ ]
+ )
+
+
+def tmdb_search_poster_path(item: MediaItem) -> str | None:
+ if item.tmdb_id:
+ endpoint = "/movie" if item.media_type == "movie" else "/tv"
+ data = tmdb_request(f"{endpoint}/{urllib.parse.quote(item.tmdb_id, safe='')}", {})
+ return data.get("poster_path")
+
+ if item.imdb_id:
+ data = tmdb_request(
+ f"/find/{urllib.parse.quote(item.imdb_id, safe='')}",
+ {"external_source": "imdb_id"},
+ )
+ results_key = "movie_results" if item.media_type == "movie" else "tv_results"
+ results = data.get(results_key, [])
+ if isinstance(results, list):
+ for result in results:
+ if isinstance(result, dict) and result.get("poster_path"):
+ return str(result["poster_path"])
+
+ search_params: dict[str, Any] = {"query": item.title}
+ year = media_item_year_number(item)
+ if item.media_type == "movie":
+ endpoint = "/search/movie"
+ if year is not None:
+ search_params["year"] = year
+ else:
+ endpoint = "/search/tv"
+ if year is not None:
+ search_params["first_air_date_year"] = year
+ data = tmdb_request(endpoint, search_params)
+ results = data.get("results", [])
+ if not isinstance(results, list):
+ return None
+ for result in results:
+ if isinstance(result, dict) and result.get("poster_path"):
+ return str(result["poster_path"])
+ return None
+
+
+def tmdb_fallback_poster_url(item: MediaItem) -> str | None:
+ if not tmdb_enabled():
+ return None
+ cache_key = tmdb_cache_key(item)
+ if cache_key in TMDB_POSTER_CACHE:
+ return TMDB_POSTER_CACHE[cache_key]
+ try:
+ poster_path = tmdb_search_poster_path(item)
+ except Exception:
+ TMDB_POSTER_CACHE[cache_key] = None
+ return None
+ url = tmdb_image_url(poster_path)
+ TMDB_POSTER_CACHE[cache_key] = url
+ return url
+
+
+def fetch_jellyfin_image(runtime: BotRuntime, item_id: str) -> tuple[bytes, str] | None:
+ if not item_id.strip():
+ return None
+ state = load_state(runtime.settings_path)
+ base_url = str(state.get("jellyfin_url", "")).strip().rstrip("/")
+ api_key = str(state.get("jellyfin_api_key", "")).strip()
+ if not base_url or not api_key:
+ return None
+
+ url = f"{base_url}/Items/{urllib.parse.quote(item_id, safe='')}/Images/Primary?maxHeight=720&maxWidth=480&quality=90"
+ request = urllib.request.Request(
+ url,
+ headers={
+ "User-Agent": "ArchiveStatusBot/1.0",
+ "X-Emby-Token": api_key,
+ "Accept": "image/*",
+ },
+ method="GET",
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=30) as response:
+ content_type = response.headers.get_content_type() or "image/jpeg"
+ return response.read(), content_type
+ except urllib.error.HTTPError as exc:
+ if exc.code == 404:
+ return None
+ detail = exc.read().decode("utf-8", errors="ignore")
+ raise RuntimeError(f"Jellyfin image fetch failed: HTTP {exc.code} {detail}") from exc
+ except (urllib.error.URLError, TimeoutError, socket.timeout) as exc:
+ raise RuntimeError(f"Jellyfin image fetch failed: {clean_error(exc)}") from exc
+
+
+def catalog_known_source_ids(runtime: BotRuntime) -> set[str]:
+ movies, shows = load_media_library(runtime)
+ return {
+ item.source_id.strip()
+ for item in [*movies, *shows]
+ if item.source_id and item.source_id.strip()
+ }
+
+
+def catalog_item_for_source_id(runtime: BotRuntime, item_id: str) -> MediaItem | None:
+ movies, shows = load_media_library(runtime)
+ for item in [*movies, *shows]:
+ if item.source_id and item.source_id.strip() == item_id:
+ return item
+ return None
+
+
+def media_library_changes(
+ previous_movies: list[MediaItem],
+ previous_shows: list[MediaItem],
+ current_movies: list[MediaItem],
+ current_shows: list[MediaItem],
+) -> dict[str, Any]:
+ previous = {media_item_tracking_key(item): item for item in [*previous_movies, *previous_shows]}
+ current = {media_item_tracking_key(item): item for item in [*current_movies, *current_shows]}
+ added_keys = sorted(set(current) - set(previous), key=lambda key: (current[key].media_type, current[key].title.casefold()))
+ removed_keys = sorted(set(previous) - set(current), key=lambda key: (previous[key].media_type, previous[key].title.casefold()))
+ added = [media_change_entry(current[key]) for key in added_keys]
+ removed = [media_change_entry(previous[key]) for key in removed_keys]
+ return {
+ "added": added,
+ "removed": removed,
+ "addedCount": len(added),
+ "removedCount": len(removed),
+ }
+
+
+def update_jellyfin_sync_state(
+ runtime: BotRuntime,
+ *,
+ fingerprint: str | None = None,
+ error: str | None = None,
+ published: bool = False,
+ changes: dict[str, Any] | None = None,
+) -> None:
+ state = load_state(runtime.settings_path)
+ now = datetime.now(timezone.utc).isoformat()
+ state["jellyfin_last_sync_at"] = now
+ state["jellyfin_last_sync_error"] = error
+ if fingerprint is not None:
+ state["jellyfin_last_fingerprint"] = fingerprint
+ if published:
+ state["jellyfin_last_published_at"] = now
+ if fingerprint is not None:
+ state["jellyfin_last_published_fingerprint"] = fingerprint
+ if changes is not None:
+ state["jellyfin_last_changes"] = changes
+ save_state(runtime.settings_path, state)
+
+
+def sync_jellyfin_library(runtime: BotRuntime, force_publish: bool = False) -> dict[str, Any]:
+ previous_movies, previous_shows = load_media_library(runtime)
+ movies, shows = fetch_jellyfin_library(runtime)
+ changes = media_library_changes(previous_movies, previous_shows, movies, shows)
+ fingerprint = media_library_fingerprint(movies, shows)
+ settings_state = load_state(runtime.settings_path)
+ changed = fingerprint != str(settings_state.get("jellyfin_last_fingerprint", ""))
+ publish_changed = fingerprint != str(settings_state.get("jellyfin_last_published_fingerprint", ""))
+ save_media_library(runtime, movies, shows)
+
+ published = False
+ result: dict[str, Any] = {}
+ if (publish_changed or force_publish) and not runtime.dry_run:
+ result = publish_media_items(
+ runtime=runtime,
+ channel_id=channel_settings(runtime)["mediaChannelId"],
+ movies_all=movies,
+ shows_all=shows,
+ )
+ published = True
+
+ update_jellyfin_sync_state(runtime, fingerprint=fingerprint, published=published, changes=changes)
+ return {
+ "changed": changed,
+ "published": published,
+ "changes": changes,
+ "movieCount": len(movies),
+ "showCount": len(shows),
+ "libraryNames": jellyfin_settings(runtime).get("libraryNames", []),
+ "library": media_library_to_jsonable(movies, shows),
+ "publishResult": result,
+ "jellyfin": jellyfin_settings(runtime),
+ }
+
+
+def maybe_run_jellyfin_sync(runtime: BotRuntime) -> dict[str, Any] | None:
+ settings = jellyfin_settings(runtime)
+ if not settings["configured"] or not settings["autoSync"]:
+ return None
+
+ interval = int(os.getenv("JELLYFIN_SYNC_INTERVAL_SECONDS", str(DEFAULT_JELLYFIN_SYNC_INTERVAL_SECONDS)))
+ state = load_state(runtime.settings_path)
+ last_sync = str(state.get("jellyfin_last_sync_at", "")).strip()
+ if last_sync:
+ try:
+ last = datetime.fromisoformat(last_sync)
+ if (datetime.now(timezone.utc) - last).total_seconds() < interval:
+ return None
+ except ValueError:
+ pass
+
+ try:
+ return sync_jellyfin_library(runtime)
+ except Exception as exc:
+ update_jellyfin_sync_state(runtime, error=str(exc)[:240])
+ raise
+
+
+def media_item_heading(item: MediaItem) -> str:
+ if item.year:
+ return f"{item.title} ({item.year})"
+ return item.title
+
+
+def media_item_genres(item: MediaItem) -> list[str]:
+ if not item.genres:
+ return []
+ return [genre.strip() for genre in item.genres.split(",") if genre.strip()]
+
+
+def media_item_runtime_minutes(item: MediaItem) -> int | None:
+ if not item.runtime or item.media_type != "movie":
+ return None
+ cleaned = item.runtime.lower().replace(" ", "")
+ match = re.fullmatch(r"(?:(\d+)h)?(?:(\d+)m)?", cleaned)
+ if match and (match.group(1) or match.group(2)):
+ hours = int(match.group(1) or 0)
+ minutes = int(match.group(2) or 0)
+ return (hours * 60) + minutes
+ if cleaned.isdigit():
+ return int(cleaned)
+ return None
+
+
+def media_item_year_number(item: MediaItem) -> int | None:
+ if not item.year:
+ return None
+ try:
+ return int(item.year)
+ except ValueError:
+ return None
+
+
+def media_item_search_blob(item: MediaItem) -> str:
+ fields = [
+ item.title,
+ item.year or "",
+ item.genres or "",
+ item.rating or "",
+ item.runtime or "",
+ item.summary or "",
+ ]
+ return " ".join(fields).casefold()
+
+
+def recommendation_presets() -> list[dict[str, str]]:
+ return [
+ {"id": "balanced", "label": "Balanced"},
+ {"id": "quick", "label": "Quick Watch"},
+ {"id": "deep", "label": "Deep Dive"},
+ {"id": "recent", "label": "Newer Picks"},
+ {"id": "comfort", "label": "Comfort Picks"},
+ ]
+
+
+def recommendation_reason(item: MediaItem, mode: str, genre: str) -> str:
+ reasons: list[str] = []
+ if genre and genre.lower() != "all":
+ reasons.append(f"matches {genre}")
+ if mode == "quick" and item.media_type == "movie" and item.runtime:
+ reasons.append(f"short runtime: {item.runtime}")
+ elif mode == "deep":
+ if item.media_type == "movie" and item.runtime:
+ reasons.append(f"long runtime: {item.runtime}")
+ elif item.media_type == "show" and item.seasons:
+ reasons.append(f"{item.seasons} season{'s' if item.seasons != 1 else ''}")
+ elif mode == "recent" and item.year:
+ reasons.append(f"newer release: {item.year}")
+ elif mode == "comfort":
+ if item.genres:
+ reasons.append(item.genres)
+ if item.media_type == "show" and item.episodes:
+ reasons.append(f"{item.episodes} episodes")
+ else:
+ if item.genres:
+ reasons.append(item.genres)
+ if item.media_type == "movie" and item.runtime:
+ reasons.append(item.runtime)
+ if item.media_type == "show" and item.episodes:
+ reasons.append(f"{item.episodes} episodes")
+ return " · ".join(reasons[:2]) or "from your library"
+
+
+def recommendation_score(item: MediaItem, mode: str, genre: str, search: str) -> float:
+ score = 0.0
+ genres = {value.casefold() for value in media_item_genres(item)}
+ runtime_minutes = media_item_runtime_minutes(item)
+ year = media_item_year_number(item)
+ blob = media_item_search_blob(item)
+
+ if genre and genre.casefold() != "all":
+ score += 40 if genre.casefold() in genres else -20
+
+ if search:
+ score += 25 if search.casefold() in blob else -30
+
+ if mode == "quick":
+ if item.media_type == "movie":
+ if runtime_minutes is not None:
+ score += max(0, 180 - runtime_minutes) / 2
+ else:
+ score += 12
+ if item.episodes:
+ score += max(0, 48 - item.episodes) / 3
+ elif mode == "deep":
+ if item.media_type == "movie":
+ if runtime_minutes is not None:
+ score += min(runtime_minutes, 240) / 2
+ else:
+ if item.seasons:
+ score += min(item.seasons * 10, 50)
+ if item.episodes:
+ score += min(item.episodes / 3, 30)
+ elif mode == "recent":
+ if year is not None:
+ score += max(0, year - 1990)
+ elif mode == "comfort":
+ if item.media_type == "show":
+ score += 24
+ if item.episodes:
+ score += min(item.episodes / 4, 25)
+ if genres:
+ score += 6
+ else:
+ if genres:
+ score += 8
+ if item.summary:
+ score += 4
+ if item.media_type == "movie" and runtime_minutes is not None:
+ score += max(0, 150 - abs(115 - runtime_minutes)) / 10
+ if item.media_type == "show" and item.episodes:
+ score += min(item.episodes / 6, 16)
+
+ score += (sum(ord(character) for character in item.title.casefold()) % 17) / 10
+ return score
+
+
+def available_media_genres(movies: list[MediaItem], shows: list[MediaItem]) -> list[str]:
+ genres: set[str] = set()
+ for item in [*movies, *shows]:
+ genres.update(media_item_genres(item))
+ return sorted(genres, key=str.casefold)
+
+
+def media_item_card_payload(item: MediaItem, reason: str = "") -> dict[str, Any]:
+ return {
+ "title": item.title,
+ "mediaType": item.media_type,
+ "posterUrl": catalog_poster_path(item),
+ "year": item.year or "",
+ "genres": item.genres or "",
+ "rating": item.rating or "",
+ "runtime": item.runtime or "",
+ "summary": item.summary or "",
+ "seasons": item.seasons or 0,
+ "episodes": item.episodes or 0,
+ "reason": reason,
+ }
+
+
+def recommend_media_items(
+ movies: list[MediaItem],
+ shows: list[MediaItem],
+ *,
+ media_type: str = "all",
+ genre: str = "all",
+ mode: str = "balanced",
+ search: str = "",
+ limit: int = 6,
+) -> dict[str, Any]:
+ pools = {
+ "movie": movies,
+ "show": shows,
+ "all": [*movies, *shows],
+ }
+ selected_pool = pools.get(media_type, pools["all"])
+ if not selected_pool:
+ return {"items": [], "summary": "No media available for recommendations."}
+
+ ranked = sorted(
+ selected_pool,
+ key=lambda item: (
+ recommendation_score(item, mode, genre, search),
+ media_item_year_number(item) or 0,
+ item.title.casefold(),
+ ),
+ reverse=True,
+ )
+ items = [
+ media_item_card_payload(item, recommendation_reason(item, mode, genre))
+ for item in ranked[: max(1, min(limit, 12))]
+ ]
+ media_label = {"all": "movies and shows", "movie": "movies", "show": "shows"}.get(media_type, "media")
+ if search:
+ summary = f"{mode.title()} picks in {media_label} for “{search}”."
+ elif genre and genre.casefold() != "all":
+ summary = f"{mode.title()} picks in {media_label} for {genre}."
+ else:
+ summary = f"{mode.title()} picks from your {media_label}."
+ return {"items": items, "summary": summary}
+
+
+def media_item_value(item: MediaItem) -> str:
+ details: list[str] = []
+ if item.genres:
+ details.append(item.genres)
+ if item.rating:
+ details.append(item.rating)
+ if item.runtime and item.media_type == "movie":
+ details.append(item.runtime)
+ if item.media_type == "show":
+ counts = []
+ if item.seasons:
+ counts.append(f"{item.seasons} season{'s' if item.seasons != 1 else ''}")
+ if item.episodes:
+ counts.append(f"{item.episodes} episode{'s' if item.episodes != 1 else ''}")
+ if counts:
+ details.append(" · ".join(counts))
+
+ lines = []
+ if details:
+ lines.append(" • ".join(details))
+ if item.summary:
+ lines.append(item.summary)
+ return "\n".join(lines)[:1024] or "No details provided."
+
+
+def media_category_embeds(
+ title: str,
+ items: list[MediaItem],
+ color: int,
+ total_count: int,
+ source_name: str,
+) -> list[dict[str, Any]]:
+ embeds: list[dict[str, Any]] = []
+ omitted = max(total_count - len(items), 0)
+ page_count = max((len(items) + MEDIA_ITEMS_PER_EMBED - 1) // MEDIA_ITEMS_PER_EMBED, 1)
+
+ for page_index in range(page_count):
+ start = page_index * MEDIA_ITEMS_PER_EMBED
+ chunk = items[start : start + MEDIA_ITEMS_PER_EMBED]
+ description = f"{total_count} total from `{source_name}`"
+ if omitted:
+ description += f" · showing first {len(items)}"
+ if page_count > 1:
+ description += f" · page {page_index + 1}/{page_count}"
+
+ fields = [
+ {
+ "name": media_item_heading(item)[:256],
+ "value": media_item_value(item),
+ "inline": False,
+ }
+ for item in chunk
+ ]
+ embeds.append(
+ {
+ "title": title,
+ "description": description,
+ "color": color,
+ "fields": fields,
+ }
+ )
+
+ return embeds
+
+
+def render_media_catalog_payloads(
+ movies: list[MediaItem],
+ shows: list[MediaItem],
+ movie_total: int,
+ show_total: int,
+ movie_source: str,
+ show_source: str,
+) -> list[dict[str, Any]]:
+ now = datetime.now(timezone.utc)
+ embeds: list[dict[str, Any]] = [
+ {
+ "title": "The Mithral Archive Media Catalog",
+ "description": "Current movies and shows available in the archive.",
+ "color": 0x5865F2,
+ "fields": [
+ {"name": "Movies", "value": str(movie_total), "inline": True},
+ {"name": "Shows", "value": str(show_total), "inline": True},
+ {"name": "Updated", "value": now.strftime("%Y-%m-%d %H:%M UTC"), "inline": True},
+ ],
+ "timestamp": now.isoformat(),
+ }
+ ]
+ if movies:
+ embeds.extend(media_category_embeds("Movies", movies, 0xF59E0B, movie_total, movie_source))
+ if shows:
+ embeds.extend(media_category_embeds("Shows", shows, 0x10B981, show_total, show_source))
+
+ payloads = []
+ for index in range(0, len(embeds), MAX_DISCORD_EMBEDS):
+ payloads.append(
+ {
+ "content": "**The Mithral Archive Media Catalog**" if index == 0 else "",
+ "embeds": embeds[index : index + MAX_DISCORD_EMBEDS],
+ }
+ )
+ return payloads
+
+
+def media_markdown_line(item: MediaItem) -> str:
+ title = media_item_heading(item)
+ details: list[str] = []
+ if item.genres:
+ details.append(item.genres)
+ if item.rating:
+ details.append(item.rating)
+ if item.runtime and item.media_type == "movie":
+ details.append(item.runtime)
+ if item.media_type == "show":
+ if item.seasons:
+ details.append(f"{item.seasons} season{'s' if item.seasons != 1 else ''}")
+ if item.episodes:
+ details.append(f"{item.episodes} episode{'s' if item.episodes != 1 else ''}")
+
+ suffix = f" - {'; '.join(details)}" if details else ""
+ return f"- **{title}**{suffix}"
+
+
+def render_media_catalog_markdown(movies: list[MediaItem], shows: list[MediaItem]) -> str:
+ now = datetime.now(timezone.utc)
+ lines = [
+ "# The Mithral Archive Media Catalog",
+ "",
+ f"Updated: {now.strftime('%Y-%m-%d %H:%M UTC')}",
+ f"Movies: {len(movies)}",
+ f"Shows: {len(shows)}",
+ "",
+ ]
+
+ if movies:
+ lines.extend(["## Movies", ""])
+ for item in movies:
+ lines.append(media_markdown_line(item))
+ if item.summary:
+ lines.append(f" - {item.summary}")
+ lines.append("")
+
+ if shows:
+ lines.extend(["## Shows", ""])
+ for item in shows:
+ lines.append(media_markdown_line(item))
+ if item.summary:
+ lines.append(f" - {item.summary}")
+ lines.append("")
+
+ return "\n".join(lines).strip() + "\n"
+
+
+def render_catalog_html(runtime: BotRuntime) -> bytes:
+ movies, shows = load_media_library(runtime)
+ genres = available_media_genres(movies, shows)
+ default_recommendations = {
+ "balanced": recommend_media_items(movies, shows, mode="balanced", limit=6),
+ "quick": recommend_media_items(movies, shows, mode="quick", limit=6),
+ "deep": recommend_media_items(movies, shows, mode="deep", limit=6),
+ }
+ updated_at = load_state(runtime.media_library_path).get("updated_at")
+ updated = "Never"
+ if updated_at:
+ try:
+ updated = datetime.fromisoformat(str(updated_at)).strftime("%Y-%m-%d %H:%M UTC")
+ except ValueError:
+ updated = str(updated_at)
+
+ def item_block(item: MediaItem) -> str:
+ meta = []
+ if item.year:
+ meta.append(item.year)
+ if item.genres:
+ meta.append(item.genres)
+ if item.rating:
+ meta.append(item.rating)
+ if item.runtime:
+ meta.append(item.runtime)
+ if item.media_type == "show":
+ if item.seasons:
+ meta.append(f"{item.seasons} season{'s' if item.seasons != 1 else ''}")
+ if item.episodes:
+ meta.append(f"{item.episodes} episode{'s' if item.episodes != 1 else ''}")
+ summary = f"
@@ -1232,6 +1822,47 @@
publishMedia().catch((error) => setMediaMessage(error.message, true));
});
+ document.querySelector("#recommendRun").addEventListener("click", () => {
+ loadRecommendations().catch((error) => setRecommendMessage(error.message, true));
+ });
+
+ document.querySelector("#watchRefresh").addEventListener("click", () => {
+ Promise.all([loadWatchWorkerHealth(), loadWatchSessions()])
+ .catch((error) => setWatchMessage(error.message, true));
+ });
+
+ document.querySelector("#watchCreate").addEventListener("click", () => {
+ createWatchSession().catch((error) => setWatchMessage(error.message, true));
+ });
+
+ document.querySelector("#watchSearchRun").addEventListener("click", () => {
+ searchWatchMedia().catch((error) => setWatchSearchMessage(error.message, true));
+ });
+
+ document.querySelector("#watchWorkerStart").addEventListener("click", () => {
+ watchWorkerStart().catch((error) => setWatchWorkerMessage(error.message, true));
+ });
+
+ document.querySelector("#watchPlayNext").addEventListener("click", () => {
+ watchWorkerPlayNext().catch((error) => setWatchSessionMessage(error.message, true));
+ });
+
+ document.querySelector("#watchPause").addEventListener("click", () => {
+ watchWorkerControl("pause").catch((error) => setWatchSessionMessage(error.message, true));
+ });
+
+ document.querySelector("#watchResume").addEventListener("click", () => {
+ watchWorkerControl("resume").catch((error) => setWatchSessionMessage(error.message, true));
+ });
+
+ document.querySelector("#watchStop").addEventListener("click", () => {
+ watchWorkerControl("stop").catch((error) => setWatchSessionMessage(error.message, true));
+ });
+
+ document.querySelector("#watchWorkerRefresh").addEventListener("click", () => {
+ watchWorkerRefresh().catch((error) => setWatchSessionMessage(error.message, true));
+ });
+
document.querySelector("#logout").addEventListener("click", () => {
logout();
});
@@ -1242,6 +1873,9 @@
if (item.dataset.view === "media") {
Promise.all([loadMediaStatus(), loadJellyfinStatus()])
.catch((error) => setMediaMessage(error.message, true));
+ } else if (item.dataset.view === "watch") {
+ Promise.all([loadWatchWorkerHealth(), loadWatchSessions()])
+ .catch((error) => setWatchMessage(error.message, true));
}
});
});
@@ -1288,6 +1922,18 @@
renderMediaLibrary();
});
+ watchSessionsEl.addEventListener("click", (event) => {
+ const button = event.target.closest("[data-watch-session-id]");
+ if (!button) return;
+ loadWatchSessionDetail(Number(button.dataset.watchSessionId)).catch((error) => setWatchMessage(error.message, true));
+ });
+
+ watchResultsEl.addEventListener("click", (event) => {
+ const button = event.target.closest("[data-watch-add]");
+ if (!button) return;
+ addWatchQueue(button.dataset.watchAdd).catch((error) => setWatchSearchMessage(error.message, true));
+ });
+
let draggedRow = null;
servicesEl.addEventListener("dragstart", (e) => {
diff --git a/orb_stream_worker/Dockerfile b/orb_stream_worker/Dockerfile
new file mode 100644
index 0000000..619dc6c
--- /dev/null
+++ b/orb_stream_worker/Dockerfile
@@ -0,0 +1,13 @@
+FROM node:20-alpine
+
+WORKDIR /app
+
+RUN apk add --no-cache ffmpeg python3 make g++
+
+COPY package.json /app/package.json
+RUN npm install --omit=dev
+COPY server.js /app/server.js
+
+EXPOSE 8790
+
+CMD ["node", "/app/server.js"]
diff --git a/orb_stream_worker/package-lock.json b/orb_stream_worker/package-lock.json
new file mode 100644
index 0000000..cad93d7
--- /dev/null
+++ b/orb_stream_worker/package-lock.json
@@ -0,0 +1,4240 @@
+{
+ "name": "orb-stream-worker",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "orb-stream-worker",
+ "version": "0.1.0",
+ "dependencies": {
+ "@dank074/discord-video-stream": "6.0.0",
+ "discord.js-selfbot-v13": "^3.7.1",
+ "dotenv": "^17.3.1",
+ "node-gyp": "^11.2.0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@dank074/discord-video-stream": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@dank074/discord-video-stream/-/discord-video-stream-6.0.0.tgz",
+ "integrity": "sha512-OhEqOI+UPsg0qn2slnv6psU6tMpmHsVq4pisENSfudblHnw83jEueclqmFxd0c4uXKVcYHf1ICu6P+PSu6PK0A==",
+ "license": "ISC",
+ "dependencies": {
+ "@lng2004/node-datachannel": "0.32.0-20260202",
+ "@snazzah/davey": "^0.1.8",
+ "debug-level": "^4.1.1",
+ "fluent-ffmpeg": "^2.1.3",
+ "node-av": "^5.2.2",
+ "p-debounce": "^5.1.0",
+ "sharp": "^0.34.5",
+ "zeromq": "^6.5.0"
+ },
+ "engines": {
+ "node": ">=22.4.0"
+ },
+ "peerDependencies": {
+ "discord.js-selfbot-v13": "^3.6.0"
+ }
+ },
+ "node_modules/@discordjs/builders": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz",
+ "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@discordjs/formatters": "^0.6.2",
+ "@discordjs/util": "^1.2.0",
+ "@sapphire/shapeshift": "^4.0.0",
+ "discord-api-types": "^0.38.40",
+ "fast-deep-equal": "^3.1.3",
+ "ts-mixer": "^6.0.4",
+ "tslib": "^2.6.3"
+ },
+ "engines": {
+ "node": ">=16.11.0"
+ },
+ "funding": {
+ "url": "https://github.com/discordjs/discord.js?sponsor"
+ }
+ },
+ "node_modules/@discordjs/collection": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
+ "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/discordjs/discord.js?sponsor"
+ }
+ },
+ "node_modules/@discordjs/formatters": {
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz",
+ "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "discord-api-types": "^0.38.33"
+ },
+ "engines": {
+ "node": ">=16.11.0"
+ },
+ "funding": {
+ "url": "https://github.com/discordjs/discord.js?sponsor"
+ }
+ },
+ "node_modules/@discordjs/util": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz",
+ "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "discord-api-types": "^0.38.33"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/discordjs/discord.js?sponsor"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
+ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
+ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@fidm/asn1": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@fidm/asn1/-/asn1-1.0.4.tgz",
+ "integrity": "sha512-esd1jyNvRb2HVaQGq2Gg8Z0kbQPXzV9Tq5Z14KNIov6KfFD6PTaRIO8UpcsYiTNzOqJpmyzWgVTrUwFV3UF4TQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@fidm/x509": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@fidm/x509/-/x509-1.2.1.tgz",
+ "integrity": "sha512-nwc2iesjyc9hkuzcrMCBXQRn653XuAUKorfWM8PZyJawiy1QzLj4vahwzaI25+pfpwOLvMzbJ0uKpWLDNmo16w==",
+ "license": "MIT",
+ "dependencies": {
+ "@fidm/asn1": "^1.0.4",
+ "tweetnacl": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.7.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@isaacs/cliui": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+ "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^5.1.2",
+ "string-width-cjs": "npm:string-width@^4.2.0",
+ "strip-ansi": "^7.0.1",
+ "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+ "wrap-ansi": "^8.1.0",
+ "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@isaacs/fs-minipass": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
+ "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^7.0.4"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@leichtgewicht/ip-codec": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
+ "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
+ "license": "MIT"
+ },
+ "node_modules/@lng2004/node-datachannel": {
+ "version": "0.32.0-20260202",
+ "resolved": "https://registry.npmjs.org/@lng2004/node-datachannel/-/node-datachannel-0.32.0-20260202.tgz",
+ "integrity": "sha512-YLpIA5yYC4NRSaw3suitZcQUW/vdI3DHadsCZjPEgUOi2vuoPzPwq+L8Qtt4REuJs/Pw3BcypdVBQfeUVtw9sA==",
+ "hasInstallScript": true,
+ "license": "MPL 2.0",
+ "dependencies": {
+ "prebuild-install": "^7.1.3"
+ },
+ "engines": {
+ "node": ">=18.20.0"
+ }
+ },
+ "node_modules/@minhducsun2002/leb128": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@minhducsun2002/leb128/-/leb128-1.0.0.tgz",
+ "integrity": "sha512-eFrYUPDVHeuwWHluTG1kwNQUEUcFjVKYwPkU8z9DR1JH3AW7JtJsG9cRVGmwz809kKtGfwGJj58juCZxEvnI/g==",
+ "license": "MIT"
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
+ "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@noble/curves": {
+ "version": "1.9.7",
+ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
+ "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "1.8.0"
+ },
+ "engines": {
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/hashes": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
+ "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
+ "license": "MIT",
+ "engines": {
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@npmcli/agent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz",
+ "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==",
+ "license": "ISC",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "http-proxy-agent": "^7.0.0",
+ "https-proxy-agent": "^7.0.1",
+ "lru-cache": "^10.0.1",
+ "socks-proxy-agent": "^8.0.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || >=20.5.0"
+ }
+ },
+ "node_modules/@npmcli/fs": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz",
+ "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==",
+ "license": "ISC",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": "^18.17.0 || >=20.5.0"
+ }
+ },
+ "node_modules/@otplib/core": {
+ "version": "12.0.1",
+ "resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
+ "integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==",
+ "license": "MIT"
+ },
+ "node_modules/@otplib/plugin-crypto": {
+ "version": "12.0.1",
+ "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz",
+ "integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==",
+ "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
+ "license": "MIT",
+ "dependencies": {
+ "@otplib/core": "^12.0.1"
+ }
+ },
+ "node_modules/@otplib/plugin-thirty-two": {
+ "version": "12.0.1",
+ "resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz",
+ "integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==",
+ "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
+ "license": "MIT",
+ "dependencies": {
+ "@otplib/core": "^12.0.1",
+ "thirty-two": "^1.0.2"
+ }
+ },
+ "node_modules/@otplib/preset-default": {
+ "version": "12.0.1",
+ "resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz",
+ "integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==",
+ "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
+ "license": "MIT",
+ "dependencies": {
+ "@otplib/core": "^12.0.1",
+ "@otplib/plugin-crypto": "^12.0.1",
+ "@otplib/plugin-thirty-two": "^12.0.1"
+ }
+ },
+ "node_modules/@otplib/preset-v11": {
+ "version": "12.0.1",
+ "resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz",
+ "integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==",
+ "license": "MIT",
+ "dependencies": {
+ "@otplib/core": "^12.0.1",
+ "@otplib/plugin-crypto": "^12.0.1",
+ "@otplib/plugin-thirty-two": "^12.0.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-cms": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.7.0.tgz",
+ "integrity": "sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.7.0",
+ "@peculiar/asn1-x509": "^2.7.0",
+ "@peculiar/asn1-x509-attr": "^2.7.0",
+ "asn1js": "^3.0.6",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-csr": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.7.0.tgz",
+ "integrity": "sha512-VVsAyGqErT9D1SY4aEqozThXMVI+ssVRiv2DDeYuvpBKLIgZ3hYs3Ay3u/VSoKq6ESFi9cf6rf3IOOzfwh7oMA==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.7.0",
+ "@peculiar/asn1-x509": "^2.7.0",
+ "asn1js": "^3.0.6",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-ecc": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.7.0.tgz",
+ "integrity": "sha512-n7KEs/Q/wrB415cxy4fHOBhegp4NdJ15fkJPwcB/3/8iNBQC2L/N7SChJPKDJPZGYH0jD4Tg4/0vnHmwghnbKw==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.7.0",
+ "@peculiar/asn1-x509": "^2.7.0",
+ "asn1js": "^3.0.6",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-pfx": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.7.0.tgz",
+ "integrity": "sha512-V/nrlQVmhg7lYAsM7E13UDL5erAwFv6kCIVFqNaMIHSVi7dngcT839JkRTkQBqznMG98l2XjxYk74ZztAohZzA==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-cms": "^2.7.0",
+ "@peculiar/asn1-pkcs8": "^2.7.0",
+ "@peculiar/asn1-rsa": "^2.7.0",
+ "@peculiar/asn1-schema": "^2.7.0",
+ "asn1js": "^3.0.6",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-pkcs8": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.7.0.tgz",
+ "integrity": "sha512-9GTl1nE8Mx1kTZ+7QyYatDyKsm34QcWRBFkY1iPvWC3X4Dona5s/tlLiQsx5WzVdZqiMBZNYT0buyw4/vbhnjw==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.7.0",
+ "@peculiar/asn1-x509": "^2.7.0",
+ "asn1js": "^3.0.6",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-pkcs9": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.7.0.tgz",
+ "integrity": "sha512-Bh7m+OuIaSEllPQcSd9OSp93F4ROWH7sbITWV8MI+8dwsjE5111/87VxiWVvYFKyww3vp39geLv9ENqhwWHcew==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-cms": "^2.7.0",
+ "@peculiar/asn1-pfx": "^2.7.0",
+ "@peculiar/asn1-pkcs8": "^2.7.0",
+ "@peculiar/asn1-schema": "^2.7.0",
+ "@peculiar/asn1-x509": "^2.7.0",
+ "@peculiar/asn1-x509-attr": "^2.7.0",
+ "asn1js": "^3.0.6",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-rsa": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.7.0.tgz",
+ "integrity": "sha512-/qvENQrXyTZURjMqSeofHul0JJt2sNSzSwk36pl2olkHbaioMQgrASDZAlHXl0xUlnVbHj0uGgOrBMTb5x2aJQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.7.0",
+ "@peculiar/asn1-x509": "^2.7.0",
+ "asn1js": "^3.0.6",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-schema": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz",
+ "integrity": "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/utils": "^2.0.2",
+ "asn1js": "^3.0.6",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-x509": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.7.0.tgz",
+ "integrity": "sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.7.0",
+ "@peculiar/utils": "^2.0.2",
+ "asn1js": "^3.0.6",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-x509-attr": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.7.0.tgz",
+ "integrity": "sha512-NS8e7SOgXipkzUPLF/sce7ukpMpWjhxYsH0n6Y+bHYo4TTxOb95Zv7hqwSuL212mj5YxovjdOKQOgH1As3E94w==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.7.0",
+ "@peculiar/asn1-x509": "^2.7.0",
+ "asn1js": "^3.0.6",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/utils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
+ "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/x509": {
+ "version": "1.14.3",
+ "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
+ "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-cms": "^2.6.0",
+ "@peculiar/asn1-csr": "^2.6.0",
+ "@peculiar/asn1-ecc": "^2.6.0",
+ "@peculiar/asn1-pkcs9": "^2.6.0",
+ "@peculiar/asn1-rsa": "^2.6.0",
+ "@peculiar/asn1-schema": "^2.6.0",
+ "@peculiar/asn1-x509": "^2.6.0",
+ "pvtsutils": "^1.3.6",
+ "reflect-metadata": "^0.2.2",
+ "tslib": "^2.8.1",
+ "tsyringe": "^4.10.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@pkgjs/parseargs": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@sapphire/async-queue": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
+ "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=v14.0.0",
+ "npm": ">=7.0.0"
+ }
+ },
+ "node_modules/@sapphire/shapeshift": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz",
+ "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "lodash": "^4.17.21"
+ },
+ "engines": {
+ "node": ">=v16"
+ }
+ },
+ "node_modules/@seydx/node-av-darwin-arm64": {
+ "version": "5.2.4",
+ "resolved": "https://registry.npmjs.org/@seydx/node-av-darwin-arm64/-/node-av-darwin-arm64-5.2.4.tgz",
+ "integrity": "sha512-uQh6jWXDeXtjuaGOXrBx2rszPDcQbRa4W1YFrmlQG+ffOo+IJyczGjhTWfgzebRksB9tMAe9ViztU39THWZAkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ }
+ },
+ "node_modules/@seydx/node-av-darwin-x64": {
+ "version": "5.2.4",
+ "resolved": "https://registry.npmjs.org/@seydx/node-av-darwin-x64/-/node-av-darwin-x64-5.2.4.tgz",
+ "integrity": "sha512-S9ptQF7JhvGSbvZH9jVeDAPXyRklA7rySKiFzi4UeLTMJslgFTSWFgY2nJAtHrP4wRdkAPnAVjExqd8U7jRZgg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ }
+ },
+ "node_modules/@seydx/node-av-linux-arm64": {
+ "version": "5.2.4",
+ "resolved": "https://registry.npmjs.org/@seydx/node-av-linux-arm64/-/node-av-linux-arm64-5.2.4.tgz",
+ "integrity": "sha512-XlQAWIcqFxVItr98VShaxzx57Cfl0y+RPCPciLOHXuEmfeKjON4o/xdbChHg02hHFrVAeSCfaHWQmHNgB9m6eQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ }
+ },
+ "node_modules/@seydx/node-av-linux-x64": {
+ "version": "5.2.4",
+ "resolved": "https://registry.npmjs.org/@seydx/node-av-linux-x64/-/node-av-linux-x64-5.2.4.tgz",
+ "integrity": "sha512-+2LdgZ7irDik++0mO6Bo1odYa2NZvze6xiy4B8jTbcf3F8k46qQEKkPjBwtV0qQu5fn3M+fUc/wk5coHdfxjxA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ }
+ },
+ "node_modules/@seydx/node-av-win32-arm64-mingw": {
+ "version": "5.2.4",
+ "resolved": "https://registry.npmjs.org/@seydx/node-av-win32-arm64-mingw/-/node-av-win32-arm64-mingw-5.2.4.tgz",
+ "integrity": "sha512-uVNgRQ44uSdtMcR9O6R+nrBou5BWmroTFnGPI3PX8pHR8oe1Nim5zXtWK0qGTTqOiTPNsK5XnSshM3c5/n0/Fg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ }
+ },
+ "node_modules/@seydx/node-av-win32-arm64-msvc": {
+ "version": "5.2.4",
+ "resolved": "https://registry.npmjs.org/@seydx/node-av-win32-arm64-msvc/-/node-av-win32-arm64-msvc-5.2.4.tgz",
+ "integrity": "sha512-Gi+Op8j028WHbdl2jR7MnyHqR2J1TfkEogLPludb3jx54Yi+MO1zvouICFUmtSbxd9JC7CAeoU8++CZAMqcEvA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ }
+ },
+ "node_modules/@seydx/node-av-win32-x64-mingw": {
+ "version": "5.2.4",
+ "resolved": "https://registry.npmjs.org/@seydx/node-av-win32-x64-mingw/-/node-av-win32-x64-mingw-5.2.4.tgz",
+ "integrity": "sha512-gClAhvV/aJa681tuTlG6dTqqMpnTM5k6QVhnWolBgkbpZXMi0qABzoUBTYpuwxnOEd6j2mBBAVwqx6kut55Ukw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ }
+ },
+ "node_modules/@seydx/node-av-win32-x64-msvc": {
+ "version": "5.2.4",
+ "resolved": "https://registry.npmjs.org/@seydx/node-av-win32-x64-msvc/-/node-av-win32-x64-msvc-5.2.4.tgz",
+ "integrity": "sha512-01s5ij6nudrtZ7ojiAOAgzuUrImvuQYrAE5o6uesoxYqOha6j7yRu5e4qCXRQCDKrh2FBzyO1cGDc7Ht8Ly8wQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ }
+ },
+ "node_modules/@shinyoshiaki/binary-data": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/@shinyoshiaki/binary-data/-/binary-data-0.6.1.tgz",
+ "integrity": "sha512-7HDb/fQAop2bCmvDIzU5+69i+UJaFgIVp99h1VzK1mpg1JwSODOkjbqD7ilTYnqlnadF8C4XjpwpepxDsGY6+w==",
+ "license": "MIT",
+ "dependencies": {
+ "generate-function": "^2.3.1",
+ "is-plain-object": "^2.0.3"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@shinyoshiaki/jspack": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/@shinyoshiaki/jspack/-/jspack-0.0.6.tgz",
+ "integrity": "sha512-SdsNhLjQh4onBlyPrn4ia1Pdx5bXT88G/LIEpOYAjx2u4xeY/m/HB5yHqlkJB1uQR3Zw4R3hBWLj46STRAN0rg=="
+ },
+ "node_modules/@snazzah/davey": {
+ "version": "0.1.11",
+ "resolved": "https://registry.npmjs.org/@snazzah/davey/-/davey-0.1.11.tgz",
+ "integrity": "sha512-oBN+msHzPnm1M5DDx3wVD7iBwpNXFUtkh2MrAbUJu0OhKjliLChi28hq++mu1+qdMpAVQO5JKAvQQxYVbyneiw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/Snazzah"
+ },
+ "optionalDependencies": {
+ "@snazzah/davey-android-arm-eabi": "0.1.11",
+ "@snazzah/davey-android-arm64": "0.1.11",
+ "@snazzah/davey-darwin-arm64": "0.1.11",
+ "@snazzah/davey-darwin-x64": "0.1.11",
+ "@snazzah/davey-freebsd-x64": "0.1.11",
+ "@snazzah/davey-linux-arm-gnueabihf": "0.1.11",
+ "@snazzah/davey-linux-arm64-gnu": "0.1.11",
+ "@snazzah/davey-linux-arm64-musl": "0.1.11",
+ "@snazzah/davey-linux-x64-gnu": "0.1.11",
+ "@snazzah/davey-linux-x64-musl": "0.1.11",
+ "@snazzah/davey-wasm32-wasi": "0.1.11",
+ "@snazzah/davey-win32-arm64-msvc": "0.1.11",
+ "@snazzah/davey-win32-ia32-msvc": "0.1.11",
+ "@snazzah/davey-win32-x64-msvc": "0.1.11"
+ }
+ },
+ "node_modules/@snazzah/davey-android-arm-eabi": {
+ "version": "0.1.11",
+ "resolved": "https://registry.npmjs.org/@snazzah/davey-android-arm-eabi/-/davey-android-arm-eabi-0.1.11.tgz",
+ "integrity": "sha512-T1RYbNYKN6tLOcGIDKJd8OI6FBSEemwL7DOYdTMmhqfhhMr3YVN8WOhfoxGg63OcnpTN2e2c5tdY2bAx25RmQQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@snazzah/davey-android-arm64": {
+ "version": "0.1.11",
+ "resolved": "https://registry.npmjs.org/@snazzah/davey-android-arm64/-/davey-android-arm64-0.1.11.tgz",
+ "integrity": "sha512-ksJn/x2VU8h6w9eku1HT96ugSRZ7lKVkKNKbFleaFN+U99DJaPM+gMu2YvnFU4V54HR06ZBnRihnVG6VLXQpDw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@snazzah/davey-darwin-arm64": {
+ "version": "0.1.11",
+ "resolved": "https://registry.npmjs.org/@snazzah/davey-darwin-arm64/-/davey-darwin-arm64-0.1.11.tgz",
+ "integrity": "sha512-E1d7PbaaVMO3Lj9EiAPqOVbuV0xg5+PsHzHH097DDXiD1+zUDXvJaTnUWsnm5z50pJniHpi4GtaYmk+ieB/guA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@snazzah/davey-darwin-x64": {
+ "version": "0.1.11",
+ "resolved": "https://registry.npmjs.org/@snazzah/davey-darwin-x64/-/davey-darwin-x64-0.1.11.tgz",
+ "integrity": "sha512-Tl4TI/LTmgJZepgbgVMYDi8RqlAkPtPg1OEBPl7a9Tn3AwR36Vs6lyIT1cs/lGy/ds/+B+mKI4rPObN1cyILTw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@snazzah/davey-freebsd-x64": {
+ "version": "0.1.11",
+ "resolved": "https://registry.npmjs.org/@snazzah/davey-freebsd-x64/-/davey-freebsd-x64-0.1.11.tgz",
+ "integrity": "sha512-T8Iw9FXkuI1T+YBAFzh9v/TXf9IOTOSqnd/BFpTRTrlW72PR2lhIidzSmg027VxO7r5pX47iFwiOkb9I/NU/EA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@snazzah/davey-linux-arm-gnueabihf": {
+ "version": "0.1.11",
+ "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-arm-gnueabihf/-/davey-linux-arm-gnueabihf-0.1.11.tgz",
+ "integrity": "sha512-1Txj+8pqA8uq/OGtaUaBFWAPnNMQzFgIywj0iA7EI4xZl+mab48/pv+YZ1pNb/suC6ynsW44oB9efiXSdcUAgA==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@snazzah/davey-linux-arm64-gnu": {
+ "version": "0.1.11",
+ "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-arm64-gnu/-/davey-linux-arm64-gnu-0.1.11.tgz",
+ "integrity": "sha512-ERzF5nM/IYW1BcN3wLXpEwBCGLFf0kGJUVhaV6yfiInz0tkU8UmvrrgpaMaACfMjIhfWdq5CcX+aTkXo/saNcg==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@snazzah/davey-linux-arm64-musl": {
+ "version": "0.1.11",
+ "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-arm64-musl/-/davey-linux-arm64-musl-0.1.11.tgz",
+ "integrity": "sha512-e6pX6Hiabtz99q+H/YHNkm9JVlpqN8HGh0qPib8G2+UY4/SSH8WvqWipk3v581dMy2oyCHt7MOoY1aU1P1N/xA==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@snazzah/davey-linux-x64-gnu": {
+ "version": "0.1.11",
+ "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-x64-gnu/-/davey-linux-x64-gnu-0.1.11.tgz",
+ "integrity": "sha512-TW5bSoqChOJMbvsDb4wAATYrxmAXuNnse7wFNVSAJUaZKSeRfZbu3UAiPWSNn7GwLwSfU6hg322KZUn8IWCuvg==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@snazzah/davey-linux-x64-musl": {
+ "version": "0.1.11",
+ "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-x64-musl/-/davey-linux-x64-musl-0.1.11.tgz",
+ "integrity": "sha512-5j6Pmc+Wzv5lSxVP6quA7teYRJXibkZqQyYGfTDnTsUOO5dPpcojpqlXlkhyvsA1OAQTj4uxbOCciN3cVWwzug==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@snazzah/davey-wasm32-wasi": {
+ "version": "0.1.11",
+ "resolved": "https://registry.npmjs.org/@snazzah/davey-wasm32-wasi/-/davey-wasm32-wasi-0.1.11.tgz",
+ "integrity": "sha512-rKOwZ/0J8lp+4VEyOdMDBRP9KR+PksZpa9V1Qn0veMzy4FqTVKthkxwGqewheFe0SFg9fdvt798l/PBFrfDeZw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@napi-rs/wasm-runtime": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@snazzah/davey-win32-arm64-msvc": {
+ "version": "0.1.11",
+ "resolved": "https://registry.npmjs.org/@snazzah/davey-win32-arm64-msvc/-/davey-win32-arm64-msvc-0.1.11.tgz",
+ "integrity": "sha512-5fptJU4tX901m3mj0SHiBljMrPT4ZEsynbBhR7bK1yn9TY1jjyhN8EFi7QF5IWtUEni+0mia2BCMHZ5ZkmFZqQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@snazzah/davey-win32-ia32-msvc": {
+ "version": "0.1.11",
+ "resolved": "https://registry.npmjs.org/@snazzah/davey-win32-ia32-msvc/-/davey-win32-ia32-msvc-0.1.11.tgz",
+ "integrity": "sha512-ualexn8SeLsiMHhWfzVrzRcjHgcBapg++FPaVgJJxoh2S/jCRiklXOu3luqIZdJdNKvhe2V9SwO/cImPeIIBKw==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@snazzah/davey-win32-x64-msvc": {
+ "version": "0.1.11",
+ "resolved": "https://registry.npmjs.org/@snazzah/davey-win32-x64-msvc/-/davey-win32-x64-msvc-0.1.11.tgz",
+ "integrity": "sha512-muNhc8UKXtknzsH/w4AIkbPR2I8BuvApn0pDXar0IEvY8PCjqU/M8MPbOOEYwQVvQRMwVTgExtxzrkBPSXB4nA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.2",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
+ "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/abbrev": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz",
+ "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==",
+ "license": "ISC",
+ "engines": {
+ "node": "^18.17.0 || >=20.5.0"
+ }
+ },
+ "node_modules/aes-js": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.1.2.tgz",
+ "integrity": "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==",
+ "license": "MIT"
+ },
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/asn1js": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
+ "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "pvtsutils": "^1.3.6",
+ "pvutils": "^1.1.5",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/async": {
+ "version": "0.2.10",
+ "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz",
+ "integrity": "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ=="
+ },
+ "node_modules/asyncc": {
+ "version": "2.0.9",
+ "resolved": "https://registry.npmjs.org/asyncc/-/asyncc-2.0.9.tgz",
+ "integrity": "sha512-nTQfwHtnL+MSqPaUJhV22GWP3jThj0GnS4Nw1uJyBus6EQ40hQFnbBGUvWxC5P3m+1neSqH1p8asMXg/ypmsQw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/atomic-sleep": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
+ "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "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==",
+ "license": "MIT"
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/bl": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ }
+ },
+ "node_modules/bluebird": {
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
+ "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
+ "license": "MIT"
+ },
+ "node_modules/brace-expansion": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
+ "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/buffer": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
+ "node_modules/buffer-crc32": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz",
+ "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/cacache": {
+ "version": "19.0.1",
+ "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz",
+ "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==",
+ "license": "ISC",
+ "dependencies": {
+ "@npmcli/fs": "^4.0.0",
+ "fs-minipass": "^3.0.0",
+ "glob": "^10.2.2",
+ "lru-cache": "^10.0.1",
+ "minipass": "^7.0.3",
+ "minipass-collect": "^2.0.1",
+ "minipass-flush": "^1.0.5",
+ "minipass-pipeline": "^1.2.4",
+ "p-map": "^7.0.2",
+ "ssri": "^12.0.0",
+ "tar": "^7.4.3",
+ "unique-filename": "^4.0.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || >=20.5.0"
+ }
+ },
+ "node_modules/camelcase": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/chownr": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
+ "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+ "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.0",
+ "wrap-ansi": "^6.2.0"
+ }
+ },
+ "node_modules/cliui/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/cliui/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/wrap-ansi": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+ "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cmake-ts": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/cmake-ts/-/cmake-ts-1.0.2.tgz",
+ "integrity": "sha512-5l++JHE7MxFuyV/OwJf3ek7ZZN1aGPFPM5oUz6AnK5inQAPe4TFXRMz5sA2qg2FRgByPWdqO+gSfIPo8GzoKNQ==",
+ "license": "MIT",
+ "bin": {
+ "cmake-ts": "build/main.js"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT"
+ },
+ "node_modules/commander": {
+ "version": "14.0.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
+ "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+ "license": "MIT"
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/cross-spawn/node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/date-fns": {
+ "version": "2.30.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
+ "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.21.0"
+ },
+ "engines": {
+ "node": ">=0.11"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/date-fns"
+ }
+ },
+ "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"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/debug-level": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/debug-level/-/debug-level-4.1.1.tgz",
+ "integrity": "sha512-r/T+zzVbsy4FL91zdUrxc1I788DRXwU4mg65Yk8F5ACHNu9ucTXiWrb4JaKHbNtwuOUKKpE8oEnDg0fcBFmmBw==",
+ "license": "MIT",
+ "dependencies": {
+ "asyncc": "^2.0.7",
+ "chalk": "^4.1.2",
+ "fast-safe-stringify": "^2.1.1",
+ "flatstr": "^1.0.12",
+ "map-lru": "^2.1.0",
+ "ms": "^2.1.3",
+ "sonic-boom": "^4.2.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "debug": "^4.3.1"
+ }
+ },
+ "node_modules/decamelize": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+ "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "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==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dijkstrajs": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
+ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
+ "license": "MIT"
+ },
+ "node_modules/discord-api-types": {
+ "version": "0.38.48",
+ "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.48.tgz",
+ "integrity": "sha512-WFUE/2o0lBlLeCQonQ+Pu2RqHAqbytBJ2RlXR91gzk05InSS6k9ShzzLYoymrA4c2oRgRKGE7/VqQJNNdGWSxQ==",
+ "license": "MIT",
+ "workspaces": [
+ "scripts/actions/documentation"
+ ]
+ },
+ "node_modules/discord.js-selfbot-v13": {
+ "version": "3.7.1",
+ "resolved": "https://registry.npmjs.org/discord.js-selfbot-v13/-/discord.js-selfbot-v13-3.7.1.tgz",
+ "integrity": "sha512-cq5AW/CVvNIUVTSBdZmhsob7v+wjxnkFjuNULcxBXvxutVBnSZqZupsT/9CDtdnT71iKUn9N8GGL6GPg9aZlGA==",
+ "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
+ "license": "GNU General Public License v3.0",
+ "dependencies": {
+ "@discordjs/builders": "^1.6.3",
+ "@discordjs/collection": "^2.1.1",
+ "@sapphire/async-queue": "^1.5.5",
+ "@sapphire/shapeshift": "^4.0.0",
+ "discord-api-types": "^0.38.15",
+ "fetch-cookie": "^3.1.0",
+ "find-process": "^2.0.0",
+ "otplib": "^12.0.1",
+ "prism-media": "^1.3.5",
+ "qrcode": "^1.5.4",
+ "tough-cookie": "^5.1.2",
+ "tree-kill": "^1.2.2",
+ "undici": "^7.11.0",
+ "werift-rtp": "^0.8.4",
+ "ws": "^8.16.0"
+ },
+ "engines": {
+ "node": ">=20.18"
+ }
+ },
+ "node_modules/dns-packet": {
+ "version": "5.6.1",
+ "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz",
+ "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==",
+ "license": "MIT",
+ "dependencies": {
+ "@leichtgewicht/ip-codec": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "17.4.2",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
+ "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/duplexer2": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
+ "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "readable-stream": "^2.0.2"
+ }
+ },
+ "node_modules/duplexer2/node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "license": "MIT",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/duplexer2/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
+ "node_modules/duplexer2/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/eastasianwidth": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
+ "license": "MIT"
+ },
+ "node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+ "license": "MIT"
+ },
+ "node_modules/encoding": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
+ "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "iconv-lite": "^0.6.2"
+ }
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/env-paths": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
+ "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/err-code": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz",
+ "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==",
+ "license": "MIT"
+ },
+ "node_modules/expand-template": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+ "license": "(MIT OR WTFPL)",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/exponential-backoff": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
+ "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==",
+ "license": "Apache-2.0"
+ },
+ "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"
+ },
+ "node_modules/fast-safe-stringify": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
+ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
+ "license": "MIT"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fetch-cookie": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-3.2.0.tgz",
+ "integrity": "sha512-n61pQIxP25C6DRhcJxn7BDzgHP/+S56Urowb5WFxtcRMpU6drqXD90xjyAsVQYsNSNNVbaCcYY1DuHsdkZLuiA==",
+ "license": "Unlicense",
+ "dependencies": {
+ "set-cookie-parser": "^2.4.8",
+ "tough-cookie": "^6.0.0"
+ }
+ },
+ "node_modules/fetch-cookie/node_modules/tldts": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.0.tgz",
+ "integrity": "sha512-yHBe+zVfzNZ3QfTPW/Z6KK1G2t340gFjMHqI/4KKSt/abzYydzuCnpqdaF5gCCABby+9Yfbj59oR5F2Fd5CBzg==",
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.4.0"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/fetch-cookie/node_modules/tldts-core": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.0.tgz",
+ "integrity": "sha512-/mb9kRld+x1sIMXxWNOAp5m6C+D4GrAORWlJkOJ5dElvxdN1eutz/o7qHLp9gFvDF4Y3/L2xeScoxz6AbEo8rQ==",
+ "license": "MIT"
+ },
+ "node_modules/fetch-cookie/node_modules/tough-cookie": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
+ "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/find-process": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/find-process/-/find-process-2.1.1.tgz",
+ "integrity": "sha512-SrQDx3QhlmHM90iqn9rdjCQcw/T+WlpOkHFsjoRgB+zTpDfltNA1VSNYeYELwhUTJy12UFxqjWhmhOrJc+o4sA==",
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "~4.1.2",
+ "commander": "^14.0.3",
+ "loglevel": "^1.9.2"
+ },
+ "bin": {
+ "find-process": "dist/cjs/bin/find-process.js"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/flatstr": {
+ "version": "1.0.12",
+ "resolved": "https://registry.npmjs.org/flatstr/-/flatstr-1.0.12.tgz",
+ "integrity": "sha512-4zPxDyhCyiN2wIAtSLI6gc82/EjqZc1onI4Mz/l0pWrAlsSfYH/2ZIcU+e3oA2wDwbzIWNKwa23F8rh6+DRWkw==",
+ "license": "MIT"
+ },
+ "node_modules/fluent-ffmpeg": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz",
+ "integrity": "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==",
+ "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
+ "license": "MIT",
+ "dependencies": {
+ "async": "^0.2.9",
+ "which": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/foreground-child": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
+ "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
+ "license": "ISC",
+ "dependencies": {
+ "cross-spawn": "^7.0.6",
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/fs-constants": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+ "license": "MIT"
+ },
+ "node_modules/fs-extra": {
+ "version": "11.3.5",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz",
+ "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==",
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=14.14"
+ }
+ },
+ "node_modules/fs-minipass": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz",
+ "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==",
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^7.0.3"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/generate-function": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
+ "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-property": "^1.0.2"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/github-from-package": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
+ "license": "MIT"
+ },
+ "node_modules/glob": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "license": "ISC",
+ "dependencies": {
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^3.1.2",
+ "minimatch": "^9.0.4",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^1.11.1"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "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==",
+ "license": "ISC"
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/http-cache-semantics": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
+ "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "license": "ISC"
+ },
+ "node_modules/int64-buffer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/int64-buffer/-/int64-buffer-1.1.0.tgz",
+ "integrity": "sha512-94smTCQOvigN4d/2R/YDjz8YVG0Sufvv2aAh8P5m42gwhCsDAJqnbNOrxJsrADuAFAA69Q/ptGzxvNcNuIJcvw==",
+ "license": "MIT"
+ },
+ "node_modules/ip": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz",
+ "integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==",
+ "license": "MIT"
+ },
+ "node_modules/ip-address": {
+ "version": "10.2.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
+ "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-plain-object": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
+ "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
+ "license": "MIT",
+ "dependencies": {
+ "isobject": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-property": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
+ "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
+ "license": "MIT"
+ },
+ "node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "license": "ISC"
+ },
+ "node_modules/isobject": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
+ "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/jackspeak": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
+ "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/cliui": "^8.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ },
+ "optionalDependencies": {
+ "@pkgjs/parseargs": "^0.11.0"
+ }
+ },
+ "node_modules/jsonfile": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
+ "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
+ "license": "MIT",
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+ "license": "MIT"
+ },
+ "node_modules/loglevel": {
+ "version": "1.9.2",
+ "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
+ "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6.0"
+ },
+ "funding": {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/loglevel"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "license": "ISC"
+ },
+ "node_modules/make-fetch-happen": {
+ "version": "14.0.3",
+ "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz",
+ "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==",
+ "license": "ISC",
+ "dependencies": {
+ "@npmcli/agent": "^3.0.0",
+ "cacache": "^19.0.1",
+ "http-cache-semantics": "^4.1.1",
+ "minipass": "^7.0.2",
+ "minipass-fetch": "^4.0.0",
+ "minipass-flush": "^1.0.5",
+ "minipass-pipeline": "^1.2.4",
+ "negotiator": "^1.0.0",
+ "proc-log": "^5.0.0",
+ "promise-retry": "^2.0.1",
+ "ssri": "^12.0.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || >=20.5.0"
+ }
+ },
+ "node_modules/map-lru": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/map-lru/-/map-lru-2.1.0.tgz",
+ "integrity": "sha512-Fa9E3knqtjzLtgtz49B2LjEFUH1asuG4iX7nA4rrlRq6Ey75xvi3hqvHnhs6IAj06o8ldasoFLEQtTATGD/GgA==",
+ "license": "Unlicense",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/minipass": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
+ "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/minipass-collect": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz",
+ "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==",
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^7.0.3"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/minipass-fetch": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz",
+ "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "minipass": "^7.0.3",
+ "minipass-sized": "^1.0.3",
+ "minizlib": "^3.0.1"
+ },
+ "engines": {
+ "node": "^18.17.0 || >=20.5.0"
+ },
+ "optionalDependencies": {
+ "encoding": "^0.1.13"
+ }
+ },
+ "node_modules/minipass-flush": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz",
+ "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/minipass-flush/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minipass-flush/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "license": "ISC"
+ },
+ "node_modules/minipass-pipeline": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz",
+ "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==",
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minipass-pipeline/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minipass-pipeline/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "license": "ISC"
+ },
+ "node_modules/minipass-sized": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz",
+ "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==",
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minipass-sized/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minipass-sized/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "license": "ISC"
+ },
+ "node_modules/minizlib": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
+ "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
+ "license": "MIT",
+ "dependencies": {
+ "minipass": "^7.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/mkdirp-classic": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+ "license": "MIT"
+ },
+ "node_modules/mp4box": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/mp4box/-/mp4box-0.5.4.tgz",
+ "integrity": "sha512-GcCH0fySxBurJtvr0dfhz0IxHZjc1RP+F+I8xw+LIwkU1a+7HJx8NCDiww1I5u4Hz6g4eR1JlGADEGJ9r4lSfA==",
+ "license": "BSD-3-Clause"
+ },
+ "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"
+ },
+ "node_modules/multicast-dns": {
+ "version": "7.2.5",
+ "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz",
+ "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==",
+ "license": "MIT",
+ "dependencies": {
+ "dns-packet": "^5.2.2",
+ "thunky": "^1.0.2"
+ },
+ "bin": {
+ "multicast-dns": "cli.js"
+ }
+ },
+ "node_modules/napi-build-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/node-abi": {
+ "version": "3.92.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz",
+ "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==",
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/node-addon-api": {
+ "version": "8.8.0",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.8.0.tgz",
+ "integrity": "sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==",
+ "license": "MIT",
+ "engines": {
+ "node": "^18 || ^20 || >= 21"
+ }
+ },
+ "node_modules/node-av": {
+ "version": "5.2.4",
+ "resolved": "https://registry.npmjs.org/node-av/-/node-av-5.2.4.tgz",
+ "integrity": "sha512-L5r+6k+YGvH5MZX8o55JHQbbeRZ+iFieAb1bhKtpa8hrqBEGuSwdkXRwpE4vd414JqVJsq/EQq6s+3qKeviQTg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "unzipper": "^0.12.3",
+ "werift": "^0.23.0"
+ },
+ "optionalDependencies": {
+ "@seydx/node-av-darwin-arm64": "^5.2.4",
+ "@seydx/node-av-darwin-x64": "^5.2.4",
+ "@seydx/node-av-linux-arm64": "^5.2.4",
+ "@seydx/node-av-linux-x64": "^5.2.4",
+ "@seydx/node-av-win32-arm64-mingw": "^5.2.4",
+ "@seydx/node-av-win32-arm64-msvc": "^5.2.4",
+ "@seydx/node-av-win32-x64-mingw": "^5.2.4",
+ "@seydx/node-av-win32-x64-msvc": "^5.2.4"
+ }
+ },
+ "node_modules/node-gyp": {
+ "version": "11.5.0",
+ "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.5.0.tgz",
+ "integrity": "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==",
+ "license": "MIT",
+ "dependencies": {
+ "env-paths": "^2.2.0",
+ "exponential-backoff": "^3.1.1",
+ "graceful-fs": "^4.2.6",
+ "make-fetch-happen": "^14.0.3",
+ "nopt": "^8.0.0",
+ "proc-log": "^5.0.0",
+ "semver": "^7.3.5",
+ "tar": "^7.4.3",
+ "tinyglobby": "^0.2.12",
+ "which": "^5.0.0"
+ },
+ "bin": {
+ "node-gyp": "bin/node-gyp.js"
+ },
+ "engines": {
+ "node": "^18.17.0 || >=20.5.0"
+ }
+ },
+ "node_modules/node-gyp/node_modules/isexe": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
+ "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/node-gyp/node_modules/which": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz",
+ "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^3.1.1"
+ },
+ "bin": {
+ "node-which": "bin/which.js"
+ },
+ "engines": {
+ "node": "^18.17.0 || >=20.5.0"
+ }
+ },
+ "node_modules/node-int64": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
+ "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
+ "license": "MIT"
+ },
+ "node_modules/nopt": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz",
+ "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==",
+ "license": "ISC",
+ "dependencies": {
+ "abbrev": "^3.0.0"
+ },
+ "bin": {
+ "nopt": "bin/nopt.js"
+ },
+ "engines": {
+ "node": "^18.17.0 || >=20.5.0"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/otplib": {
+ "version": "12.0.1",
+ "resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz",
+ "integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==",
+ "license": "MIT",
+ "dependencies": {
+ "@otplib/core": "^12.0.1",
+ "@otplib/preset-default": "^12.0.1",
+ "@otplib/preset-v11": "^12.0.1"
+ }
+ },
+ "node_modules/p-cancelable": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
+ "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/p-debounce": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/p-debounce/-/p-debounce-5.1.0.tgz",
+ "integrity": "sha512-3DNQmB7HPRMSuZ9P8JQFzEr7156s1S5Lqpi3mSrsZe5AHvl4ONfGrdB5azWqeOpNejQiemgQBkyU+IDKR5nwyg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "license": "MIT",
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/p-map": {
+ "version": "7.0.4",
+ "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz",
+ "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-try": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/package-json-from-dist": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
+ "license": "BlueOak-1.0.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==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "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==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-scurry": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
+ "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^10.2.0",
+ "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pngjs": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+ "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/prebuild-install": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
+ "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "expand-template": "^2.0.3",
+ "github-from-package": "0.0.0",
+ "minimist": "^1.2.3",
+ "mkdirp-classic": "^0.5.3",
+ "napi-build-utils": "^2.0.0",
+ "node-abi": "^3.3.0",
+ "pump": "^3.0.0",
+ "rc": "^1.2.7",
+ "simple-get": "^4.0.0",
+ "tar-fs": "^2.0.0",
+ "tunnel-agent": "^0.6.0"
+ },
+ "bin": {
+ "prebuild-install": "bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/prism-media": {
+ "version": "1.3.5",
+ "resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz",
+ "integrity": "sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "@discordjs/opus": ">=0.8.0 <1.0.0",
+ "ffmpeg-static": "^5.0.2 || ^4.2.7 || ^3.0.0 || ^2.4.0",
+ "node-opus": "^0.3.3",
+ "opusscript": "^0.0.8"
+ },
+ "peerDependenciesMeta": {
+ "@discordjs/opus": {
+ "optional": true
+ },
+ "ffmpeg-static": {
+ "optional": true
+ },
+ "node-opus": {
+ "optional": true
+ },
+ "opusscript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/proc-log": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz",
+ "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==",
+ "license": "ISC",
+ "engines": {
+ "node": "^18.17.0 || >=20.5.0"
+ }
+ },
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "license": "MIT"
+ },
+ "node_modules/promise-retry": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz",
+ "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==",
+ "license": "MIT",
+ "dependencies": {
+ "err-code": "^2.0.2",
+ "retry": "^0.12.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/pump": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/pvtsutils": {
+ "version": "1.3.6",
+ "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
+ "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/pvutils": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
+ "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/qrcode": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
+ "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
+ "license": "MIT",
+ "dependencies": {
+ "dijkstrajs": "^1.0.1",
+ "pngjs": "^5.0.0",
+ "yargs": "^15.3.1"
+ },
+ "bin": {
+ "qrcode": "bin/qrcode"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/reflect-metadata": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
+ "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/require-main-filename": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+ "license": "ISC"
+ },
+ "node_modules/retry": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
+ "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/rx.mini": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/rx.mini/-/rx.mini-1.4.0.tgz",
+ "integrity": "sha512-8w5cSc1mwNja7fl465DXOkVvIOkpvh2GW4jo31nAIvX4WTXCsRnKJGUfiDBzWtYRInEcHAUYIZfzusjIrea8gA=="
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/semver": {
+ "version": "7.8.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
+ "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+ "license": "ISC"
+ },
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "license": "MIT"
+ },
+ "node_modules/sharp": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
+ "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==",
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "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==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/simple-concat": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/simple-get": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decompress-response": "^6.0.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
+ "node_modules/smart-buffer": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
+ "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/socks": {
+ "version": "2.8.9",
+ "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
+ "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
+ "license": "MIT",
+ "dependencies": {
+ "ip-address": "^10.1.1",
+ "smart-buffer": "^4.2.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/socks-proxy-agent": {
+ "version": "8.0.5",
+ "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
+ "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "^4.3.4",
+ "socks": "^2.8.3"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/sonic-boom": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
+ "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
+ "license": "MIT",
+ "dependencies": {
+ "atomic-sleep": "^1.0.0"
+ }
+ },
+ "node_modules/ssri": {
+ "version": "12.0.0",
+ "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz",
+ "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==",
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^7.0.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || >=20.5.0"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+ "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+ "license": "MIT",
+ "dependencies": {
+ "eastasianwidth": "^0.2.0",
+ "emoji-regex": "^9.2.2",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/string-width-cjs": {
+ "name": "string-width",
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/string-width-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.2.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/strip-ansi-cjs": {
+ "name": "strip-ansi",
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/tar": {
+ "version": "7.5.15",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz",
+ "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/fs-minipass": "^4.0.0",
+ "chownr": "^3.0.0",
+ "minipass": "^7.1.2",
+ "minizlib": "^3.1.0",
+ "yallist": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tar-fs": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
+ "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "chownr": "^1.1.1",
+ "mkdirp-classic": "^0.5.2",
+ "pump": "^3.0.0",
+ "tar-stream": "^2.1.4"
+ }
+ },
+ "node_modules/tar-fs/node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "license": "ISC"
+ },
+ "node_modules/tar-stream": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bl": "^4.0.3",
+ "end-of-stream": "^1.4.1",
+ "fs-constants": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/thirty-two": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz",
+ "integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==",
+ "engines": {
+ "node": ">=0.2.6"
+ }
+ },
+ "node_modules/thunky": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
+ "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
+ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^6.1.86"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
+ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
+ "license": "MIT"
+ },
+ "node_modules/tough-cookie": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
+ "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^6.1.32"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tree-kill": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
+ "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
+ "license": "MIT",
+ "bin": {
+ "tree-kill": "cli.js"
+ }
+ },
+ "node_modules/ts-mixer": {
+ "version": "6.0.4",
+ "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
+ "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==",
+ "license": "MIT"
+ },
+ "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"
+ },
+ "node_modules/tsyringe": {
+ "version": "4.10.0",
+ "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
+ "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^1.9.3"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/tsyringe/node_modules/tslib": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
+ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
+ "license": "0BSD"
+ },
+ "node_modules/tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/tweetnacl": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
+ "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
+ "license": "Unlicense"
+ },
+ "node_modules/undici": {
+ "version": "7.26.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz",
+ "integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/unique-filename": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz",
+ "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==",
+ "license": "ISC",
+ "dependencies": {
+ "unique-slug": "^5.0.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || >=20.5.0"
+ }
+ },
+ "node_modules/unique-slug": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-5.0.0.tgz",
+ "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==",
+ "license": "ISC",
+ "dependencies": {
+ "imurmurhash": "^0.1.4"
+ },
+ "engines": {
+ "node": "^18.17.0 || >=20.5.0"
+ }
+ },
+ "node_modules/universalify": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
+ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/unzipper": {
+ "version": "0.12.3",
+ "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz",
+ "integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==",
+ "license": "MIT",
+ "dependencies": {
+ "bluebird": "~3.7.2",
+ "duplexer2": "~0.1.4",
+ "fs-extra": "^11.2.0",
+ "graceful-fs": "^4.2.2",
+ "node-int64": "^0.4.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "node_modules/werift": {
+ "version": "0.23.0",
+ "resolved": "https://registry.npmjs.org/werift/-/werift-0.23.0.tgz",
+ "integrity": "sha512-/WcIN5DHFG9Ri4anGOmIkp8gxBGFMWSIB/m4sfZ5CWlLfD3iMhiaAUuTBuc+KV3SY9NDmvmLtiN2uaM7k3lVzw==",
+ "license": "MIT",
+ "dependencies": {
+ "@fidm/x509": "^1.2.1",
+ "@minhducsun2002/leb128": "^1.0.0",
+ "@noble/curves": "^1.8.1",
+ "@peculiar/x509": "^1.12.3",
+ "@shinyoshiaki/binary-data": "^0.6.1",
+ "@shinyoshiaki/jspack": "^0.0.6",
+ "aes-js": "^3.1.2",
+ "buffer": "^6.0.3",
+ "debug": "4.4.0",
+ "fast-deep-equal": "^3.1.3",
+ "int64-buffer": "1.1.0",
+ "ip": "^2.0.1",
+ "mp4box": "^0.5.3",
+ "multicast-dns": "^7.2.5",
+ "tweetnacl": "^1.0.3",
+ "werift-common": "*",
+ "werift-dtls": "*",
+ "werift-ice": "*",
+ "werift-rtp": "*",
+ "werift-sctp": "*"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/werift-common": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/werift-common/-/werift-common-0.0.3.tgz",
+ "integrity": "sha512-ma3E4BqKTyZVLhrdfTVs2T1tg9seeUtKMRn5e64LwgrogWa62+3LAUoLBUSl1yPWhgSkXId7GmcHuWDen9IJeQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@shinyoshiaki/jspack": "^0.0.6",
+ "debug": "^4.4.0"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/werift-dtls": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/werift-dtls/-/werift-dtls-0.5.7.tgz",
+ "integrity": "sha512-z2fjbP7fFUFmu/Ky4bCKXzdgPTtmSY1DYi0TUf3GG2zJT4jMQ3TQmGY8y7BSSNGetvL4h3pRZ5un0EcSOWpPog==",
+ "license": "MIT",
+ "dependencies": {
+ "@fidm/x509": "^1.2.1",
+ "@noble/curves": "^1.3.0",
+ "@peculiar/x509": "^1.9.2",
+ "@shinyoshiaki/binary-data": "^0.6.1",
+ "date-fns": "^2.29.3",
+ "lodash": "^4.17.21",
+ "rx.mini": "^1.2.2",
+ "tweetnacl": "^1.0.3"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/werift-ice": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/werift-ice/-/werift-ice-0.2.2.tgz",
+ "integrity": "sha512-td52pHp+JmFnUn5jfDr/SSNO0dMCbknhuPdN1tFp9cfRj5jaktN63qnAdUuZC20QCC3ETWdsOthcm+RalHpFCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@shinyoshiaki/jspack": "^0.0.6",
+ "buffer-crc32": "^1.0.0",
+ "debug": "^4.3.4",
+ "int64-buffer": "^1.0.1",
+ "ip": "^2.0.1",
+ "lodash": "^4.17.21",
+ "multicast-dns": "^7.2.5",
+ "p-cancelable": "^2.1.1",
+ "rx.mini": "^1.2.2"
+ }
+ },
+ "node_modules/werift-rtp": {
+ "version": "0.8.8",
+ "resolved": "https://registry.npmjs.org/werift-rtp/-/werift-rtp-0.8.8.tgz",
+ "integrity": "sha512-GiYMSdvCyScQaw5bnEsraSoHUVZpjfokJAiLV4R1FsiB06t6XiebPYPpkqB9nYNNKiA8Z/cYWsym7wISq1sYSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@minhducsun2002/leb128": "^1.0.0",
+ "@shinyoshiaki/jspack": "^0.0.6",
+ "aes-js": "^3.1.2",
+ "buffer": "^6.0.3",
+ "mp4box": "^0.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/werift-rtp/node_modules/buffer": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
+ "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.2.1"
+ }
+ },
+ "node_modules/werift-sctp": {
+ "version": "0.0.11",
+ "resolved": "https://registry.npmjs.org/werift-sctp/-/werift-sctp-0.0.11.tgz",
+ "integrity": "sha512-7109yuI5U7NTEHjqjn0A8VeynytkgVaxM6lRr1Ziv0D8bPcaB8A7U/P88M7WaCpWDoELHoXiRUjQycMWStIgjQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@shinyoshiaki/jspack": "^0.0.6"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/werift/node_modules/buffer": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
+ "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.2.1"
+ }
+ },
+ "node_modules/werift/node_modules/debug": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
+ "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/which": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
+ "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "which": "bin/which"
+ }
+ },
+ "node_modules/which-module": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
+ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
+ "license": "ISC"
+ },
+ "node_modules/wrap-ansi": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+ "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.1.0",
+ "string-width": "^5.0.1",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs": {
+ "name": "wrap-ansi",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
+ "node_modules/ws": {
+ "version": "8.21.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/y18n": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+ "license": "ISC"
+ },
+ "node_modules/yallist": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
+ "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "15.4.1",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+ "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^6.0.0",
+ "decamelize": "^1.2.0",
+ "find-up": "^4.1.0",
+ "get-caller-file": "^2.0.1",
+ "require-directory": "^2.1.1",
+ "require-main-filename": "^2.0.0",
+ "set-blocking": "^2.0.0",
+ "string-width": "^4.2.0",
+ "which-module": "^2.0.0",
+ "y18n": "^4.0.0",
+ "yargs-parser": "^18.1.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "18.1.3",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+ "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+ "license": "ISC",
+ "dependencies": {
+ "camelcase": "^5.0.0",
+ "decamelize": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/yargs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/yargs/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/zeromq": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/zeromq/-/zeromq-6.5.0.tgz",
+ "integrity": "sha512-vWOrt19lvcXTxu5tiHXfEGQuldSlU+qZn2TT+4EbRQzaciWGwNZ99QQTolQOmcwVgZLodv+1QfC6UZs2PX/6pQ==",
+ "hasInstallScript": true,
+ "license": "MIT AND MPL-2.0",
+ "dependencies": {
+ "cmake-ts": "1.0.2",
+ "node-addon-api": "^8.3.1"
+ },
+ "engines": {
+ "node": ">= 12"
+ }
+ }
+ }
+}
diff --git a/orb_stream_worker/package.json b/orb_stream_worker/package.json
new file mode 100644
index 0000000..3c99f3d
--- /dev/null
+++ b/orb_stream_worker/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "orb-stream-worker",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Stream worker for Jellyfin-backed Discord VC watch parties",
+ "main": "server.js",
+ "scripts": {
+ "start": "node server.js"
+ },
+ "dependencies": {
+ "@dank074/discord-video-stream": "6.0.0",
+ "discord.js-selfbot-v13": "^3.7.1",
+ "dotenv": "^17.3.1",
+ "node-gyp": "^11.2.0"
+ }
+}
diff --git a/orb_stream_worker/server.js b/orb_stream_worker/server.js
new file mode 100644
index 0000000..1012c9b
--- /dev/null
+++ b/orb_stream_worker/server.js
@@ -0,0 +1,525 @@
+const http = require("http");
+const { URL } = require("url");
+
+try {
+ require("dotenv").config();
+} catch (_) {
+ // Optional in local dev when dependencies are not installed yet.
+}
+
+const PORT = Number(process.env.PORT || "8790");
+const TOKEN = String(process.env.WATCHPARTY_WORKER_TOKEN || "").trim();
+const DISCORD_USER_TOKEN = String(
+ process.env.WATCHPARTY_DISCORD_USER_TOKEN ||
+ process.env.DISCORD_USER_TOKEN ||
+ process.env.TOKEN ||
+ ""
+).trim();
+
+const STREAM_WIDTH = Number(process.env.WATCHPARTY_STREAM_WIDTH || "1280");
+const STREAM_HEIGHT = Number(process.env.WATCHPARTY_STREAM_HEIGHT || "720");
+const STREAM_FPS = Number(process.env.WATCHPARTY_STREAM_FPS || "30");
+const STREAM_BITRATE_KBPS = Number(process.env.WATCHPARTY_STREAM_BITRATE_KBPS || "2000");
+const STREAM_MAX_BITRATE_KBPS = Number(process.env.WATCHPARTY_STREAM_MAX_BITRATE_KBPS || "2500");
+const STREAM_CODEC = String(process.env.WATCHPARTY_STREAM_CODEC || "H264").trim().toUpperCase();
+const STREAM_PRESET = String(process.env.WATCHPARTY_STREAM_PRESET || "ultrafast").trim().toLowerCase();
+const STREAM_HARDWARE_ACCELERATION = ["1", "true", "yes", "on"].includes(
+ String(process.env.WATCHPARTY_STREAM_HARDWARE_ACCELERATION || "").trim().toLowerCase()
+);
+
+if (!TOKEN) {
+ console.error("Missing WATCHPARTY_WORKER_TOKEN");
+ process.exit(1);
+}
+
+function loadStreamingStack() {
+ try {
+ const { Client } = require("discord.js-selfbot-v13");
+ const streamLib = require("@dank074/discord-video-stream");
+ return {
+ available: true,
+ Client,
+ Streamer: streamLib.Streamer,
+ Utils: streamLib.Utils,
+ prepareStream: streamLib.prepareStream,
+ playStream: streamLib.playStream,
+ error: "",
+ };
+ } catch (error) {
+ return {
+ available: false,
+ error: error && error.message ? String(error.message) : "Streaming dependencies are unavailable",
+ };
+ }
+}
+
+const streamingStack = loadStreamingStack();
+const sessions = new Map();
+
+const runtime = {
+ client: null,
+ streamer: null,
+ ready: false,
+ readyPromise: null,
+ activeSessionId: null,
+};
+
+function json(response, statusCode, payload) {
+ const body = Buffer.from(JSON.stringify(payload, null, 2));
+ response.writeHead(statusCode, {
+ "Content-Type": "application/json; charset=utf-8",
+ "Cache-Control": "no-store",
+ "Content-Length": String(body.length),
+ });
+ response.end(body);
+}
+
+function unauthorized(response) {
+ json(response, 401, { error: "Unauthorized" });
+}
+
+function notFound(response) {
+ json(response, 404, { error: "Not found" });
+}
+
+function badRequest(response, error) {
+ json(response, 400, { error: error instanceof Error ? error.message : String(error) });
+}
+
+function requireAuth(request, response) {
+ const auth = String(request.headers.authorization || "");
+ return auth === `Bearer ${TOKEN}` ? true : (unauthorized(response), false);
+}
+
+function readJson(request) {
+ return new Promise((resolve, reject) => {
+ const chunks = [];
+ request.on("data", (chunk) => chunks.push(chunk));
+ request.on("end", () => {
+ const body = Buffer.concat(chunks).toString("utf-8");
+ if (!body) {
+ resolve({});
+ return;
+ }
+ try {
+ const parsed = JSON.parse(body);
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ reject(new Error("JSON body must be an object"));
+ return;
+ }
+ resolve(parsed);
+ } catch (error) {
+ reject(new Error(`Invalid JSON: ${error.message}`));
+ }
+ });
+ request.on("error", reject);
+ });
+}
+
+function nowSeconds() {
+ return Math.floor(Date.now() / 1000);
+}
+
+function ensureSession(sessionId) {
+ const session = sessions.get(String(sessionId));
+ if (!session) {
+ throw new Error(`Unknown worker session: ${sessionId}`);
+ }
+ return session;
+}
+
+function streamOptions() {
+ const options = {
+ width: STREAM_WIDTH,
+ height: STREAM_HEIGHT,
+ frameRate: STREAM_FPS,
+ bitrateVideo: STREAM_BITRATE_KBPS,
+ bitrateVideoMax: STREAM_MAX_BITRATE_KBPS,
+ hardwareAcceleratedDecoding: STREAM_HARDWARE_ACCELERATION,
+ minimizeLatency: false,
+ h26xPreset: STREAM_PRESET,
+ };
+ if (streamingStack.available && streamingStack.Utils && typeof streamingStack.Utils.normalizeVideoCodec === "function") {
+ options.videoCodec = streamingStack.Utils.normalizeVideoCodec(STREAM_CODEC);
+ } else {
+ options.videoCodec = STREAM_CODEC;
+ }
+ return options;
+}
+
+function streamUrlAtOffset(url, startSeconds) {
+ const parsed = new URL(url);
+ parsed.searchParams.set("StartTimeTicks", String(Math.max(0, Math.floor(startSeconds)) * 10000000));
+ return parsed.toString();
+}
+
+function sessionPlaybackPosition(session) {
+ if (session.playbackState !== "playing") {
+ return session.positionSeconds;
+ }
+ const startedAtSeconds = Number(session.startedAtSeconds || 0);
+ if (!startedAtSeconds) {
+ return session.positionSeconds;
+ }
+ const elapsed = Math.max(0, nowSeconds() - startedAtSeconds);
+ const total = Math.max(0, Number(session.positionSeconds || 0) + elapsed);
+ if (session.durationSeconds > 0) {
+ return Math.min(total, session.durationSeconds);
+ }
+ return total;
+}
+
+function sessionPayload(session) {
+ return {
+ workerStatus: session.workerStatus,
+ playbackState: session.playbackState,
+ currentTitle: session.currentTitle,
+ positionSeconds: sessionPlaybackPosition(session),
+ durationSeconds: session.durationSeconds,
+ lastError: session.lastError,
+ workerSessionId: session.workerSessionId,
+ guildId: session.guildId,
+ voiceChannelId: session.voiceChannelId,
+ textChannelId: session.textChannelId,
+ queueEntryId: session.queueEntryId,
+ jellyfinSourceId: session.jellyfinSourceId,
+ mediaType: session.mediaType,
+ playback: session.playback,
+ streamingAvailable: streamingStack.available,
+ discordReady: runtime.ready,
+ };
+}
+
+async function ensureStreamingRuntime() {
+ if (!streamingStack.available) {
+ throw new Error(`Streaming runtime unavailable: ${streamingStack.error}`);
+ }
+ if (!DISCORD_USER_TOKEN) {
+ throw new Error("WATCHPARTY_DISCORD_USER_TOKEN is required for Discord VC streaming");
+ }
+ if (runtime.ready) {
+ return runtime;
+ }
+ if (runtime.readyPromise) {
+ await runtime.readyPromise;
+ return runtime;
+ }
+
+ runtime.readyPromise = (async () => {
+ const client = new streamingStack.Client();
+ client.on("error", (error) => {
+ console.error("[worker] discord client error:", error);
+ });
+ runtime.client = client;
+ runtime.streamer = new streamingStack.Streamer(client);
+ await client.login(DISCORD_USER_TOKEN);
+ if (client.readyAt) {
+ runtime.ready = true;
+ return;
+ }
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => reject(new Error("Discord client did not become ready in time")), 30000);
+ client.once("ready", () => {
+ clearTimeout(timeout);
+ runtime.ready = true;
+ resolve();
+ });
+ client.once("error", (error) => {
+ clearTimeout(timeout);
+ reject(error);
+ });
+ });
+ })();
+
+ try {
+ await runtime.readyPromise;
+ } catch (error) {
+ runtime.readyPromise = null;
+ runtime.ready = false;
+ runtime.client = null;
+ runtime.streamer = null;
+ throw error;
+ }
+ return runtime;
+}
+
+async function ensureVoiceConnection(session) {
+ await ensureStreamingRuntime();
+ if (!runtime.streamer || !runtime.client) {
+ throw new Error("Worker runtime is not ready");
+ }
+
+ if (runtime.activeSessionId && runtime.activeSessionId !== session.workerSessionId) {
+ await stopSessionPlayback(ensureSession(runtime.activeSessionId), false);
+ }
+
+ await runtime.streamer.joinVoice(session.guildId, session.voiceChannelId);
+ runtime.activeSessionId = session.workerSessionId;
+ session.workerStatus = "connected";
+ session.lastError = "";
+
+ if (runtime.client.user && typeof runtime.client.user.setActivity === "function") {
+ runtime.client.user.setActivity(`Watch Party: ${session.currentTitle || session.title || "idle"}`).catch(() => {});
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+}
+
+function canPauseCommand(command) {
+ return Boolean(command && command.ffmpegProc && typeof command.ffmpegProc.kill === "function");
+}
+
+async function stopSessionPlayback(session, leaveVoice = true) {
+ session.manualStop = true;
+ if (session.abortController) {
+ try {
+ session.abortController.abort();
+ } catch (_) {}
+ }
+ if (runtime.streamer) {
+ try {
+ runtime.streamer.stopStream();
+ } catch (_) {}
+ if (leaveVoice) {
+ try {
+ runtime.streamer.leaveVoice();
+ } catch (_) {}
+ }
+ }
+ session.workerStatus = leaveVoice ? "connected" : session.workerStatus;
+ session.playbackState = leaveVoice ? "stopped" : "idle";
+ session.abortController = null;
+ session.ffmpegCommand = null;
+ session.startedAtSeconds = 0;
+ session.positionSeconds = 0;
+ if (leaveVoice && runtime.activeSessionId === session.workerSessionId) {
+ runtime.activeSessionId = null;
+ }
+}
+
+async function startPlayback(session, playback, startSeconds = 0) {
+ if (!playback || typeof playback !== "object") {
+ throw new Error("playback payload is required");
+ }
+ const input = String(playback.streamUrl || playback.downloadUrl || "").trim();
+ if (!input) {
+ throw new Error("Playback payload is missing a Jellyfin media URL");
+ }
+
+ await ensureVoiceConnection(session);
+ if (!runtime.streamer || !streamingStack.prepareStream || !streamingStack.playStream) {
+ throw new Error("Streaming runtime is not available");
+ }
+
+ if (session.abortController) {
+ await stopSessionPlayback(session, false);
+ }
+
+ const abortController = new AbortController();
+ session.abortController = abortController;
+ session.playback = playback;
+ session.currentTitle = String(playback.title || session.currentTitle || "").trim();
+ session.durationSeconds = Number(playback.durationSeconds || 0) || 0;
+ session.positionSeconds = Math.max(0, Number(startSeconds || 0));
+ session.startedAtSeconds = nowSeconds();
+ session.playbackState = "playing";
+ session.workerStatus = "streaming";
+ session.manualStop = false;
+ session.lastError = "";
+
+ const options = streamOptions();
+ const prepared = streamingStack.prepareStream(
+ session.positionSeconds > 0 ? streamUrlAtOffset(input, session.positionSeconds) : input,
+ options,
+ abortController.signal,
+ );
+ const command = prepared && prepared.command ? prepared.command : null;
+ const output = prepared && prepared.output ? prepared.output : null;
+ if (!command || !output) {
+ throw new Error("Failed to prepare FFmpeg stream");
+ }
+ session.ffmpegCommand = command;
+
+ command.on("error", (error) => {
+ if (session.manualStop || abortController.signal.aborted) {
+ return;
+ }
+ session.lastError = error && error.message ? String(error.message) : "FFmpeg stream failed";
+ session.playbackState = "error";
+ session.workerStatus = "error";
+ });
+
+ streamingStack.playStream(output, runtime.streamer, undefined, abortController.signal)
+ .then(() => {
+ if (session.manualStop || abortController.signal.aborted) {
+ return;
+ }
+ session.positionSeconds = session.durationSeconds > 0 ? session.durationSeconds : sessionPlaybackPosition(session);
+ session.startedAtSeconds = 0;
+ session.playbackState = "idle";
+ session.workerStatus = "connected";
+ session.ffmpegCommand = null;
+ session.abortController = null;
+ })
+ .catch((error) => {
+ if (session.manualStop || abortController.signal.aborted) {
+ return;
+ }
+ session.lastError = error && error.message ? String(error.message) : "Streaming failed";
+ session.startedAtSeconds = 0;
+ session.playbackState = "error";
+ session.workerStatus = "error";
+ session.ffmpegCommand = null;
+ session.abortController = null;
+ });
+}
+
+function createSessionRecord(body) {
+ const sessionId = String(body.sessionId || "").trim();
+ if (!sessionId) {
+ throw new Error("sessionId is required");
+ }
+ return {
+ workerSessionId: sessionId,
+ guildId: String(body.guildId || "").trim(),
+ voiceChannelId: String(body.voiceChannelId || "").trim(),
+ textChannelId: String(body.textChannelId || "").trim(),
+ title: String(body.title || "").trim(),
+ workerStatus: "idle",
+ playbackState: "idle",
+ currentTitle: "",
+ positionSeconds: 0,
+ durationSeconds: 0,
+ lastError: "",
+ queueEntryId: null,
+ jellyfinSourceId: "",
+ mediaType: "",
+ playback: null,
+ startedAtSeconds: 0,
+ manualStop: false,
+ abortController: null,
+ ffmpegCommand: null,
+ };
+}
+
+const server = http.createServer(async (request, response) => {
+ try {
+ const url = new URL(request.url, `http://127.0.0.1:${PORT}`);
+
+ if (request.method === "GET" && url.pathname === "/health") {
+ if (!requireAuth(request, response)) return;
+ json(response, 200, {
+ ok: true,
+ service: "orb-stream-worker",
+ streamingImplemented: streamingStack.available,
+ streamingError: streamingStack.available ? "" : streamingStack.error,
+ discordConfigured: Boolean(DISCORD_USER_TOKEN),
+ discordReady: runtime.ready,
+ sessions: sessions.size,
+ activeSessionId: runtime.activeSessionId,
+ });
+ return;
+ }
+
+ if (request.method === "POST" && url.pathname === "/sessions") {
+ if (!requireAuth(request, response)) return;
+ const body = await readJson(request);
+ const session = createSessionRecord(body);
+ sessions.set(session.workerSessionId, session);
+ try {
+ await ensureStreamingRuntime();
+ await ensureVoiceConnection(session);
+ } catch (error) {
+ session.workerStatus = "error";
+ session.lastError = error instanceof Error ? error.message : String(error);
+ }
+ json(response, 200, sessionPayload(session));
+ return;
+ }
+
+ const playMatch = request.method === "POST" && url.pathname.match(/^\/sessions\/([^/]+)\/play$/);
+ if (playMatch) {
+ if (!requireAuth(request, response)) return;
+ const session = ensureSession(decodeURIComponent(playMatch[1]));
+ const body = await readJson(request);
+ session.queueEntryId = Number(body.queueEntryId || 0) || null;
+ session.jellyfinSourceId = String(body.jellyfinSourceId || "").trim();
+ session.currentTitle = String(body.title || "").trim();
+ session.mediaType = String(body.mediaType || "").trim();
+ try {
+ await startPlayback(session, body.playback);
+ } catch (error) {
+ session.lastError = error instanceof Error ? error.message : String(error);
+ session.workerStatus = "error";
+ session.playbackState = "error";
+ }
+ json(response, 200, sessionPayload(session));
+ return;
+ }
+
+ const controlMatch = request.method === "POST" && url.pathname.match(/^\/sessions\/([^/]+)\/control$/);
+ if (controlMatch) {
+ if (!requireAuth(request, response)) return;
+ const session = ensureSession(decodeURIComponent(controlMatch[1]));
+ const body = await readJson(request);
+ const action = String(body.action || "").trim();
+
+ if (action === "pause") {
+ if (!canPauseCommand(session.ffmpegCommand)) {
+ throw new Error("Pause is not available because FFmpeg is not active");
+ }
+ session.positionSeconds = sessionPlaybackPosition(session);
+ session.startedAtSeconds = 0;
+ session.ffmpegCommand.ffmpegProc.kill("SIGSTOP");
+ session.playbackState = "paused";
+ session.workerStatus = "connected";
+ } else if (action === "resume") {
+ if (!canPauseCommand(session.ffmpegCommand)) {
+ throw new Error("Resume is not available because FFmpeg is not active");
+ }
+ session.startedAtSeconds = nowSeconds();
+ session.ffmpegCommand.ffmpegProc.kill("SIGCONT");
+ session.playbackState = "playing";
+ session.workerStatus = "streaming";
+ } else if (action === "stop" || action === "skip") {
+ await stopSessionPlayback(session, true);
+ } else if (action === "seek") {
+ if (!session.playback) {
+ throw new Error("Seek is not available because nothing has been loaded");
+ }
+ const requestedPosition = Math.max(0, Number((body.data && body.data.positionSeconds) || 0) || 0);
+ await stopSessionPlayback(session, false);
+ await startPlayback(session, session.playback, requestedPosition);
+ } else {
+ throw new Error(`Unsupported control action: ${action}`);
+ }
+
+ json(response, 200, sessionPayload(session));
+ return;
+ }
+
+ const sessionMatch = request.method === "GET" && url.pathname.match(/^\/sessions\/([^/]+)$/);
+ if (sessionMatch) {
+ if (!requireAuth(request, response)) return;
+ const session = ensureSession(decodeURIComponent(sessionMatch[1]));
+ json(response, 200, sessionPayload(session));
+ return;
+ }
+
+ notFound(response);
+ } catch (error) {
+ if (error instanceof Error && /Unknown worker session/.test(error.message)) {
+ notFound(response);
+ return;
+ }
+ if (error instanceof Error && /required|invalid|Unsupported|not implemented|not available|missing/i.test(error.message)) {
+ badRequest(response, error);
+ return;
+ }
+ json(response, 500, { error: error && error.message ? error.message : "Internal error" });
+ }
+});
+
+server.listen(PORT, "0.0.0.0", () => {
+ console.log(`orb-stream-worker listening on ${PORT}`);
+});
diff --git a/status_bot.py b/status_bot.py
index b0eb176..b973ee9 100644
--- a/status_bot.py
+++ b/status_bot.py
@@ -1,2236 +1,7 @@
#!/usr/bin/env python3
"""Live Discord status message for The Mithral Archive."""
-from __future__ import annotations
-
-import base64
-import asyncio
-import hashlib
-import hmac
-import html
-import json
-import os
-import re
-import secrets
-import signal
-import socket
-import ssl
-import sys
-import threading
-import time
-import urllib.error
-import urllib.parse
-import urllib.request
-from dataclasses import dataclass
-from datetime import datetime, timezone
-from http.cookies import SimpleCookie
-from http import HTTPStatus
-from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
-from pathlib import Path
-from typing import Any
-
-
-DISCORD_API = "https://discord.com/api/v10"
-DEFAULT_INTERVAL_SECONDS = 60
-DEFAULT_TIMEOUT_SECONDS = 10
-MAX_DISCORD_EMBEDS = 10
-MAX_REQUEST_BYTES = 8_000_000
-SESSION_COOKIE = "archive_bot_session"
-PBKDF2_ITERATIONS = 390_000
-MEDIA_ITEMS_PER_EMBED = 10
-DEFAULT_JELLYFIN_SYNC_INTERVAL_SECONDS = 900
-
-
-@dataclass(frozen=True)
-class Service:
- name: str
- group: str
- url: str
- display_url: str
- method: str
- timeout: float
- expected_statuses: set[int]
- expected_min: int
- expected_max: int
- keyword: str | None
-
-
-@dataclass(frozen=True)
-class CheckResult:
- service: Service
- ok: bool
- status: int | None
- latency_ms: int | None
- error: str | None
-
-
-@dataclass(frozen=True)
-class MediaItem:
- title: str
- media_type: str
- year: str | None = None
- genres: str | None = None
- rating: str | None = None
- runtime: str | None = None
- summary: str | None = None
- seasons: int | None = None
- episodes: int | None = None
-
-
-@dataclass(frozen=True)
-class DashboardAuthConfig:
- username: str
- password_hash: str
- session_ttl_seconds: int
- cookie_secure: bool
-
-
-@dataclass
-class DashboardSession:
- username: str
- csrf_token: str
- expires_at: float
-
-
-class DashboardAuth:
- def __init__(self, config: DashboardAuthConfig) -> None:
- self.config = config
- self.lock = threading.Lock()
- self.sessions: dict[str, DashboardSession] = {}
- self.failed_logins: dict[str, list[float]] = {}
-
- def login_allowed(self, key: str) -> bool:
- now = time.time()
- window_start = now - 900
- with self.lock:
- attempts = [attempt for attempt in self.failed_logins.get(key, []) if attempt >= window_start]
- self.failed_logins[key] = attempts
- return len(attempts) < 10
-
- def record_failed_login(self, key: str) -> None:
- now = time.time()
- with self.lock:
- self.failed_logins.setdefault(key, []).append(now)
-
- def clear_failed_login(self, key: str) -> None:
- with self.lock:
- self.failed_logins.pop(key, None)
-
- def login(self, username: str, password: str) -> tuple[str, DashboardSession] | None:
- if not hmac.compare_digest(username, self.config.username):
- return None
- if not verify_password_hash(self.config.password_hash, password):
- return None
-
- session_id = secrets.token_urlsafe(32)
- session = DashboardSession(
- username=username,
- csrf_token=secrets.token_urlsafe(32),
- expires_at=time.time() + self.config.session_ttl_seconds,
- )
- with self.lock:
- self.sessions[session_id] = session
- return session_id, session
-
- def session_from_cookie(self, cookie_header: str | None) -> tuple[str, DashboardSession] | None:
- if not cookie_header:
- return None
- cookie = SimpleCookie()
- cookie.load(cookie_header)
- morsel = cookie.get(SESSION_COOKIE)
- if morsel is None:
- return None
-
- session_id = morsel.value
- now = time.time()
- with self.lock:
- session = self.sessions.get(session_id)
- if session is None:
- return None
- if session.expires_at <= now:
- self.sessions.pop(session_id, None)
- return None
- session.expires_at = now + self.config.session_ttl_seconds
- return session_id, session
-
- def logout(self, session_id: str) -> None:
- with self.lock:
- self.sessions.pop(session_id, None)
-
-
-class BotRuntime:
- def __init__(
- self,
- token: str,
- channel_id: str,
- config_path: Path,
- state_path: Path,
- media_state_path: Path,
- media_library_path: Path,
- settings_path: Path,
- dry_run: bool = False,
- ) -> None:
- self.token = token
- self.default_channel_id = channel_id
- self.config_path = config_path
- self.state_path = state_path
- self.media_state_path = media_state_path
- self.media_library_path = media_library_path
- self.settings_path = settings_path
- self.dry_run = dry_run
- self.lock = threading.Lock()
- self.last_results: list[CheckResult] = []
- self.last_error: str | None = None
- self.last_message_id: str | None = None
- self.last_checked_at: datetime | None = None
-
-
-class DiscordGatewayManager:
- def __init__(self, token: str) -> None:
- self.token = token
- self.thread: threading.Thread | None = None
- self.loop: asyncio.AbstractEventLoop | None = None
- self.client: Any = None
- self.ready = threading.Event()
- self._disconnecting = threading.Event()
-
- def start(self) -> None:
- if self.thread is not None:
- return
- self.thread = threading.Thread(target=self._run, name="discord-gateway", daemon=True)
- self.thread.start()
-
- def stop(self) -> None:
- self._disconnecting.set()
- if self.loop is not None and self.client is not None:
- asyncio.run_coroutine_threadsafe(self.client.close(), self.loop)
- if self.thread is not None:
- self.thread.join(timeout=15)
-
- def _run(self) -> None:
- try:
- import discord
- except ImportError:
- print("discord.py is not installed. Install requirements.txt to keep the bot online.", file=sys.stderr, flush=True)
- self.ready.set()
- return
-
- class GatewayClient(discord.Client):
- def __init__(self, manager: DiscordGatewayManager) -> None:
- intents = discord.Intents.default()
- intents.guilds = True
- super().__init__(intents=intents)
- self.manager = manager
-
- async def on_ready(self) -> None:
- await self.change_presence(status=discord.Status.online)
- user = self.user
- name = user.name if user is not None else "unknown"
- bot_id = user.id if user is not None else "unknown"
- print(f"Discord gateway connected as {name} ({bot_id})", flush=True)
- self.manager.ready.set()
-
- async def on_disconnect(self) -> None:
- self.manager.ready.clear()
-
- self.loop = asyncio.new_event_loop()
- asyncio.set_event_loop(self.loop)
- self.client = GatewayClient(self)
-
- try:
- self.loop.run_until_complete(self.client.start(self.token))
- except Exception as exc:
- if not self._disconnecting.is_set():
- print(f"Discord gateway stopped: {exc}", file=sys.stderr, flush=True)
- finally:
- self.ready.set()
- try:
- pending = asyncio.all_tasks(self.loop)
- for task in pending:
- task.cancel()
- if pending:
- self.loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
- finally:
- self.loop.close()
-
-
-def env(name: str, default: str | None = None) -> str:
- value = os.getenv(name, default)
- if value is None or not value.strip():
- raise SystemExit(f"Missing required environment variable: {name}")
- return value.strip()
-
-
-def normalize_discord_token(token: str) -> str:
- cleaned = token.strip().strip("\"'")
- if cleaned.lower().startswith("bot "):
- cleaned = cleaned[4:].strip()
- return cleaned
-
-
-def load_dotenv(path: Path = Path(".env")) -> None:
- if not path.exists():
- return
-
- for line in path.read_text(encoding="utf-8").splitlines():
- stripped = line.strip()
- if not stripped or stripped.startswith("#") or "=" not in stripped:
- continue
-
- key, value = stripped.split("=", 1)
- key = key.strip()
- value = value.strip().strip("\"'")
- if key and key not in os.environ:
- os.environ[key] = value
-
-
-def load_json(path: Path) -> dict[str, Any]:
- try:
- with path.open("r", encoding="utf-8") as handle:
- data = json.load(handle)
- except FileNotFoundError as exc:
- raise ValueError(f"Config file not found: {path}") from exc
- except PermissionError as exc:
- raise ValueError(f"Config file is not readable: {path}") from exc
- except json.JSONDecodeError as exc:
- raise ValueError(f"Invalid JSON in {path}: {exc}") from exc
-
- if not isinstance(data, dict):
- raise ValueError(f"Config must be a JSON object: {path}")
- return data
-
-
-def parse_expected_statuses(raw: Any) -> tuple[set[int], int, int]:
- if raw is None:
- return set(), 200, 399
-
- if isinstance(raw, str):
- raw = [part.strip() for part in raw.split(",") if part.strip()]
-
- if not isinstance(raw, list):
- raise ValueError("expectedStatuses must be a list or comma-separated string")
-
- exact: set[int] = set()
- min_status = 999
- max_status = 0
-
- for item in raw:
- if isinstance(item, int):
- exact.add(item)
- continue
- if not isinstance(item, str):
- raise ValueError("expectedStatuses entries must be integers or ranges")
- if "-" in item:
- left, right = item.split("-", 1)
- try:
- min_status = min(min_status, int(left))
- max_status = max(max_status, int(right))
- except ValueError as exc:
- raise ValueError(f"Invalid expected status range: {item}") from exc
- continue
- try:
- exact.add(int(item))
- except ValueError as exc:
- raise ValueError(f"Invalid expected status value: {item}") from exc
-
- if min_status == 999 and max_status == 0:
- min_status, max_status = 0, -1
- return exact, min_status, max_status
-
-
-def services_from_data(data: dict[str, Any]) -> list[Service]:
- raw_services = data.get("services")
- if not isinstance(raw_services, list) or not raw_services:
- raise ValueError("Config must include a non-empty services array")
-
- services: list[Service] = []
- for index, item in enumerate(raw_services, start=1):
- if not isinstance(item, dict):
- raise ValueError(f"Service #{index} must be an object")
-
- name = str(item.get("name", "")).strip()
- url = str(item.get("url", "")).strip()
- if not name or not url:
- raise ValueError(f"Service #{index} must include name and url")
-
- parsed = urllib.parse.urlparse(url)
- if parsed.scheme not in {"http", "https"} or not parsed.netloc:
- raise ValueError(f"Service {name} has an invalid http(s) URL")
-
- exact, minimum, maximum = parse_expected_statuses(item.get("expectedStatuses"))
- services.append(
- Service(
- name=name,
- group=str(item.get("group", "Main Services")).strip() or "Main Services",
- url=url,
- display_url=str(item.get("displayUrl", url)).strip() or url,
- method=str(item.get("method", "GET")).strip().upper(),
- timeout=float(item.get("timeoutSeconds", DEFAULT_TIMEOUT_SECONDS)),
- expected_statuses=exact,
- expected_min=minimum,
- expected_max=maximum,
- keyword=(str(item["keyword"]).strip() if item.get("keyword") else None),
- )
- )
-
- return services
-
-
-def load_services(path: Path) -> list[Service]:
- return services_from_data(load_json(path))
-
-
-def save_services_config(path: Path, data: dict[str, Any]) -> None:
- services_from_data(data)
- path.parent.mkdir(parents=True, exist_ok=True)
- temporary = path.with_suffix(f"{path.suffix}.tmp")
- with temporary.open("w", encoding="utf-8") as handle:
- json.dump(data, handle, indent=2)
- handle.write("\n")
- try:
- temporary.replace(path)
- except OSError:
- with path.open("w", encoding="utf-8") as handle:
- json.dump(data, handle, indent=2)
- handle.write("\n")
- temporary.unlink(missing_ok=True)
-
-
-def services_to_jsonable(services: list[Service]) -> list[dict[str, Any]]:
- output: list[dict[str, Any]] = []
- for service in services:
- expected: list[int | str] = sorted(service.expected_statuses)
- if service.expected_min <= service.expected_max:
- expected.append(f"{service.expected_min}-{service.expected_max}")
- item: dict[str, Any] = {
- "name": service.name,
- "group": service.group,
- "url": service.url,
- "displayUrl": service.display_url,
- "method": service.method,
- "timeoutSeconds": service.timeout,
- "expectedStatuses": expected or ["200-399"],
- }
- if service.keyword:
- item["keyword"] = service.keyword
- output.append(item)
- return output
-
-
-def status_expected(service: Service, status: int) -> bool:
- if status in service.expected_statuses:
- return True
- return service.expected_min <= status <= service.expected_max
-
-
-def check_service(service: Service) -> CheckResult:
- started = time.monotonic()
- headers = {"User-Agent": env("HTTP_USER_AGENT", "ArchiveStatusBot/1.0")}
- request = urllib.request.Request(service.url, headers=headers, method=service.method)
-
- try:
- context = ssl.create_default_context()
- with urllib.request.urlopen(request, timeout=service.timeout, context=context) as response:
- body = response.read(1_000_000) if service.keyword else b""
- status = int(response.status)
- except urllib.error.HTTPError as exc:
- status = int(exc.code)
- latency_ms = int((time.monotonic() - started) * 1000)
- ok = status_expected(service, status)
- return CheckResult(service, ok, status, latency_ms, None if ok else f"HTTP {status}")
- except (urllib.error.URLError, TimeoutError, socket.timeout, ssl.SSLError) as exc:
- latency_ms = int((time.monotonic() - started) * 1000)
- return CheckResult(service, False, None, latency_ms, clean_error(exc))
-
- latency_ms = int((time.monotonic() - started) * 1000)
- ok = status_expected(service, status)
-
- if ok and service.keyword:
- try:
- text = body.decode("utf-8", errors="ignore")
- except UnicodeDecodeError:
- text = ""
- if service.keyword not in text:
- ok = False
- return CheckResult(service, False, status, latency_ms, "keyword missing")
-
- return CheckResult(service, ok, status, latency_ms, None if ok else f"HTTP {status}")
-
-
-def clean_error(exc: BaseException) -> str:
- reason = getattr(exc, "reason", None)
- if reason:
- return str(reason)[:120]
- return str(exc)[:120] or exc.__class__.__name__
-
-
-def discord_request(
- method: str,
- token: str,
- path: str,
- payload: dict[str, Any] | None = None,
-) -> dict[str, Any]:
- body = None
- headers = {
- "Authorization": f"Bot {token}",
- "User-Agent": "ArchiveStatusBot/1.0",
- }
-
- if payload is not None:
- body = json.dumps(payload).encode("utf-8")
- headers["Content-Type"] = "application/json"
-
- request = urllib.request.Request(
- f"{DISCORD_API}{path}",
- data=body,
- headers=headers,
- method=method,
- )
-
- try:
- with urllib.request.urlopen(request, timeout=20) as response:
- data = response.read()
- if not data:
- return {}
- return json.loads(data.decode("utf-8"))
- except urllib.error.HTTPError as exc:
- detail = exc.read().decode("utf-8", errors="ignore")
- raise RuntimeError(f"Discord API {method} {path} failed: {exc.code} {detail}") from exc
-
-
-def discord_multipart_request(
- method: str,
- token: str,
- path: str,
- payload: dict[str, Any],
- files: list[tuple[str, str, str, bytes]],
-) -> dict[str, Any]:
- boundary = f"----ArchiveBot{secrets.token_hex(16)}"
- body = bytearray()
-
- def add_part(name: str, content: bytes, content_type: str, filename: str | None = None) -> None:
- body.extend(f"--{boundary}\r\n".encode("ascii"))
- disposition = f'Content-Disposition: form-data; name="{name}"'
- if filename is not None:
- disposition += f'; filename="{filename}"'
- body.extend(f"{disposition}\r\n".encode("utf-8"))
- body.extend(f"Content-Type: {content_type}\r\n\r\n".encode("ascii"))
- body.extend(content)
- body.extend(b"\r\n")
-
- add_part("payload_json", json.dumps(payload).encode("utf-8"), "application/json")
- for field_name, filename, content_type, content in files:
- add_part(field_name, content, content_type, filename)
- body.extend(f"--{boundary}--\r\n".encode("ascii"))
-
- request = urllib.request.Request(
- f"{DISCORD_API}{path}",
- data=bytes(body),
- headers={
- "Authorization": f"Bot {token}",
- "User-Agent": "ArchiveStatusBot/1.0",
- "Content-Type": f"multipart/form-data; boundary={boundary}",
- },
- method=method,
- )
-
- try:
- with urllib.request.urlopen(request, timeout=30) as response:
- data = response.read()
- if not data:
- return {}
- return json.loads(data.decode("utf-8"))
- except urllib.error.HTTPError as exc:
- detail = exc.read().decode("utf-8", errors="ignore")
- raise RuntimeError(f"Discord API {method} {path} failed: {exc.code} {detail}") from exc
-
-
-def discord_delete_message(token: str, channel_id: str, message_id: str) -> None:
- try:
- discord_request("DELETE", token, f"/channels/{channel_id}/messages/{message_id}")
- except RuntimeError as exc:
- print(f"Could not delete old media catalog message {message_id}: {exc}", file=sys.stderr)
-
-
-def discord_bot_identity(token: str) -> dict[str, Any]:
- return discord_request("GET", token, "/users/@me")
-
-
-def load_state(path: Path) -> dict[str, Any]:
- if not path.exists():
- return {}
- try:
- with path.open("r", encoding="utf-8") as handle:
- data = json.load(handle)
- except json.JSONDecodeError:
- return {}
- return data if isinstance(data, dict) else {}
-
-
-def save_state(path: Path, state: dict[str, Any]) -> None:
- path.parent.mkdir(parents=True, exist_ok=True)
- temporary = path.with_suffix(f"{path.suffix}.tmp")
- with temporary.open("w", encoding="utf-8") as handle:
- json.dump(state, handle, indent=2, sort_keys=True)
- handle.write("\n")
- temporary.replace(path)
-
-
-def validate_channel_id(value: str, label: str) -> str:
- channel_id = value.strip()
- if not channel_id:
- raise ValueError(f"{label} channel ID is required")
- if not channel_id.isdigit():
- raise ValueError(f"{label} channel ID must be a Discord numeric channel ID")
- return channel_id
-
-
-def channel_settings(runtime: BotRuntime) -> dict[str, str]:
- data = load_state(runtime.settings_path)
- status_channel = str(data.get("status_channel_id", "")).strip() or runtime.default_channel_id
- media_channel = str(data.get("media_channel_id", "")).strip() or status_channel
- catalog_url = str(data.get("catalog_url", "")).strip() or os.getenv("PUBLIC_CATALOG_URL", "").strip()
- return {
- "statusChannelId": status_channel,
- "mediaChannelId": media_channel,
- "catalogUrl": catalog_url,
- }
-
-
-def validate_catalog_url(value: str) -> str:
- catalog_url = value.strip()
- if not catalog_url:
- return ""
- parsed = urllib.parse.urlparse(catalog_url)
- if parsed.scheme not in {"http", "https"} or not parsed.netloc:
- raise ValueError("Catalog URL must be a valid http(s) URL")
- return catalog_url
-
-
-def save_channel_settings(runtime: BotRuntime, status_channel_id: str, media_channel_id: str, catalog_url: str = "") -> dict[str, str]:
- status_channel = validate_channel_id(status_channel_id, "Status")
- media_channel = validate_channel_id(media_channel_id or status_channel, "Media")
- state = load_state(runtime.settings_path)
- state.update(
- {
- "status_channel_id": status_channel,
- "media_channel_id": media_channel,
- "catalog_url": validate_catalog_url(catalog_url),
- "updated_at": datetime.now(timezone.utc).isoformat(),
- }
- )
- save_state(runtime.settings_path, state)
- return channel_settings(runtime)
-
-
-def save_media_channel_setting(runtime: BotRuntime, media_channel_id: str) -> dict[str, str]:
- media_channel = validate_channel_id(media_channel_id, "Media")
- state = load_state(runtime.settings_path)
- state["media_channel_id"] = media_channel
- state["updated_at"] = datetime.now(timezone.utc).isoformat()
- save_state(runtime.settings_path, state)
- return channel_settings(runtime)
-
-
-def jellyfin_settings(runtime: BotRuntime) -> dict[str, Any]:
- data = load_state(runtime.settings_path)
- api_key = str(data.get("jellyfin_api_key", "")).strip()
- library_names = data.get("jellyfin_library_names", [])
- if not isinstance(library_names, list):
- library_names = []
- return {
- "url": str(data.get("jellyfin_url", "")).strip(),
- "configured": bool(api_key),
- "libraryNames": [str(name) for name in library_names if str(name).strip()],
- "autoSync": bool(data.get("jellyfin_auto_sync", False)),
- "lastSyncAt": data.get("jellyfin_last_sync_at"),
- "lastSyncError": data.get("jellyfin_last_sync_error"),
- "lastPublishedAt": data.get("jellyfin_last_published_at"),
- "lastFingerprint": data.get("jellyfin_last_fingerprint"),
- "lastPublishedFingerprint": data.get("jellyfin_last_published_fingerprint"),
- "lastChanges": data.get("jellyfin_last_changes"),
- }
-
-
-def save_jellyfin_settings(runtime: BotRuntime, data: dict[str, Any]) -> dict[str, Any]:
- state = load_state(runtime.settings_path)
- url = str(data.get("url", "")).strip().rstrip("/")
- if url:
- parsed = urllib.parse.urlparse(url)
- if parsed.scheme not in {"http", "https"} or not parsed.netloc:
- raise ValueError("Jellyfin URL must be a valid http(s) URL")
-
- api_key = str(data.get("apiKey", "")).strip()
- state["jellyfin_url"] = url
- if api_key:
- state["jellyfin_api_key"] = api_key
- elif data.get("clearApiKey"):
- state["jellyfin_api_key"] = ""
- raw_library_names = data.get("libraryNames", [])
- if isinstance(raw_library_names, str):
- library_names = [name.strip() for name in raw_library_names.split(",") if name.strip()]
- elif isinstance(raw_library_names, list):
- library_names = [str(name).strip() for name in raw_library_names if str(name).strip()]
- else:
- raise ValueError("Jellyfin libraries must be a list or comma-separated string")
- state["jellyfin_library_names"] = library_names
- state["jellyfin_auto_sync"] = bool(data.get("autoSync", False))
- state["updated_at"] = datetime.now(timezone.utc).isoformat()
- save_state(runtime.settings_path, state)
- return jellyfin_settings(runtime)
-
-
-def save_catalog_url_setting(runtime: BotRuntime, catalog_url: str) -> dict[str, str]:
- state = load_state(runtime.settings_path)
- state["catalog_url"] = validate_catalog_url(catalog_url)
- state["updated_at"] = datetime.now(timezone.utc).isoformat()
- save_state(runtime.settings_path, state)
- return channel_settings(runtime)
-
-
-def parse_int_text(value: str) -> int | None:
- digits = "".join(character for character in value if character.isdigit())
- if not digits:
- return None
- try:
- return int(digits)
- except ValueError:
- return None
-
-
-def parse_year_text(value: str) -> str | None:
- match = re.search(r"(18|19|20|21)\d{2}", value)
- return match.group(0) if match else None
-
-
-def clean_media_text(value: str, limit: int = 220) -> str | None:
- cleaned = " ".join(str(value).replace("\r", " ").replace("\n", " ").split())
- if not cleaned:
- return None
- if len(cleaned) <= limit:
- return cleaned
- return cleaned[: limit - 1].rstrip() + "…"
-
-
-def format_runtime(value: str) -> str | None:
- cleaned = clean_media_text(value, 48)
- if not cleaned:
- return None
- if not cleaned.isdigit():
- return cleaned
-
- minutes = int(cleaned)
- if minutes <= 0:
- return None
- hours, remainder = divmod(minutes, 60)
- if hours and remainder:
- return f"{hours}h {remainder}m"
- if hours:
- return f"{hours}h"
- return f"{remainder}m"
-
-
-def media_item_to_jsonable(item: MediaItem) -> dict[str, Any]:
- data: dict[str, Any] = {
- "title": item.title,
- "mediaType": item.media_type,
- "year": item.year or "",
- "genres": item.genres or "",
- "rating": item.rating or "",
- "runtime": item.runtime or "",
- "summary": item.summary or "",
- }
- if item.media_type == "show":
- data["seasons"] = item.seasons if item.seasons is not None else ""
- data["episodes"] = item.episodes if item.episodes is not None else ""
- return data
-
-
-def media_item_from_data(data: Any, media_type: str) -> MediaItem:
- if not isinstance(data, dict):
- raise ValueError(f"{media_type.title()} entries must be objects")
-
- title = clean_media_text(str(data.get("title", "")), 120)
- if not title:
- raise ValueError(f"{media_type.title()} entries must include a title")
-
- year = parse_year_text(str(data.get("year", "")))
- seasons = parse_int_text(str(data.get("seasons", ""))) if media_type == "show" else None
- episodes = parse_int_text(str(data.get("episodes", ""))) if media_type == "show" else None
-
- return MediaItem(
- title=title,
- media_type=media_type,
- year=year,
- genres=clean_media_text(str(data.get("genres", "")), 120),
- rating=clean_media_text(str(data.get("rating", "")), 32),
- runtime=format_runtime(str(data.get("runtime", ""))) if media_type == "movie" else None,
- summary=clean_media_text(str(data.get("summary", ""))),
- seasons=seasons,
- episodes=episodes,
- )
-
-
-def media_items_from_data(raw_items: Any, media_type: str) -> list[MediaItem]:
- if raw_items is None:
- return []
- if not isinstance(raw_items, list):
- raise ValueError(f"{media_type.title()} library must be a list")
-
- deduped: dict[tuple[str, str], MediaItem] = {}
- for raw_item in raw_items:
- item = media_item_from_data(raw_item, media_type)
- deduped[(item.title.casefold(), item.year or "")] = item
-
- return sorted(deduped.values(), key=lambda item: (item.title.casefold(), item.year or ""))
-
-
-def media_library_to_jsonable(movies: list[MediaItem], shows: list[MediaItem]) -> dict[str, Any]:
- return {
- "movies": [media_item_to_jsonable(item) for item in movies],
- "shows": [media_item_to_jsonable(item) for item in shows],
- }
-
-
-def load_media_library(runtime: BotRuntime) -> tuple[list[MediaItem], list[MediaItem]]:
- data = load_state(runtime.media_library_path)
- return (
- media_items_from_data(data.get("movies", []), "movie"),
- media_items_from_data(data.get("shows", []), "show"),
- )
-
-
-def save_media_library(runtime: BotRuntime, movies: list[MediaItem], shows: list[MediaItem]) -> dict[str, Any]:
- payload = media_library_to_jsonable(movies, shows)
- payload["updated_at"] = datetime.now(timezone.utc).isoformat()
- save_state(runtime.media_library_path, payload)
- return payload
-
-
-def reset_media_library(runtime: BotRuntime) -> dict[str, Any]:
- save_media_library(runtime, [], [])
- state = load_state(runtime.settings_path)
- for key in (
- "jellyfin_last_sync_at",
- "jellyfin_last_sync_error",
- "jellyfin_last_published_at",
- "jellyfin_last_fingerprint",
- "jellyfin_last_published_fingerprint",
- "jellyfin_last_changes",
- ):
- state.pop(key, None)
- state["updated_at"] = datetime.now(timezone.utc).isoformat()
- save_state(runtime.settings_path, state)
- return {
- "library": media_library_to_jsonable([], []),
- "movieCount": 0,
- "showCount": 0,
- "changes": {"added": [], "removed": [], "addedCount": 0, "removedCount": 0},
- "jellyfin": jellyfin_settings(runtime),
- }
-
-
-def jellyfin_runtime(runtime_ticks: Any) -> str | None:
- try:
- ticks = int(runtime_ticks or 0)
- except (TypeError, ValueError):
- return None
- if ticks <= 0:
- return None
- minutes = round(ticks / 10_000_000 / 60)
- if minutes <= 0:
- return None
- hours, remainder = divmod(minutes, 60)
- if hours and remainder:
- return f"{hours}h {remainder}m"
- if hours:
- return f"{hours}h"
- return f"{remainder}m"
-
-
-def jellyfin_item_year(item: dict[str, Any]) -> str | None:
- production_year = item.get("ProductionYear")
- if production_year:
- return parse_year_text(str(production_year))
- return parse_year_text(str(item.get("PremiereDate", "")))
-
-
-def jellyfin_item_summary(item: dict[str, Any]) -> str | None:
- return clean_media_text(str(item.get("Overview", "") or item.get("ShortOverview", "")))
-
-
-def jellyfin_movie_from_item(item: dict[str, Any]) -> MediaItem:
- return MediaItem(
- title=clean_media_text(str(item.get("Name", "")), 120) or "Untitled Movie",
- media_type="movie",
- year=jellyfin_item_year(item),
- genres=clean_media_text(", ".join(str(genre) for genre in item.get("Genres", []) if genre), 120),
- rating=clean_media_text(str(item.get("OfficialRating", "")), 32),
- runtime=jellyfin_runtime(item.get("RunTimeTicks")),
- summary=jellyfin_item_summary(item),
- )
-
-
-def jellyfin_show_from_item(item: dict[str, Any]) -> MediaItem:
- return MediaItem(
- title=clean_media_text(str(item.get("Name", "")), 120) or "Untitled Show",
- media_type="show",
- year=jellyfin_item_year(item),
- genres=clean_media_text(", ".join(str(genre) for genre in item.get("Genres", []) if genre), 120),
- rating=clean_media_text(str(item.get("OfficialRating", "")), 32),
- summary=jellyfin_item_summary(item),
- seasons=parse_int_text(str(item.get("ChildCount", ""))),
- episodes=parse_int_text(str(item.get("RecursiveItemCount", ""))),
- )
-
-
-def jellyfin_request(settings: dict[str, Any], path: str, params: dict[str, Any]) -> dict[str, Any]:
- base_url = str(settings.get("url", "")).strip().rstrip("/")
- api_key = str(settings.get("apiKey", "")).strip()
- if not base_url or not api_key:
- raise ValueError("Jellyfin URL and API key are required")
-
- query = urllib.parse.urlencode({key: value for key, value in params.items() if value is not None})
- url = f"{base_url}{path}"
- if query:
- url = f"{url}?{query}"
- request = urllib.request.Request(
- url,
- headers={
- "Accept": "application/json",
- "User-Agent": "ArchiveStatusBot/1.0",
- "X-Emby-Token": api_key,
- },
- method="GET",
- )
- try:
- with urllib.request.urlopen(request, timeout=30) as response:
- return json.loads(response.read().decode("utf-8"))
- except urllib.error.HTTPError as exc:
- detail = exc.read().decode("utf-8", errors="ignore")
- raise RuntimeError(f"Jellyfin API failed: HTTP {exc.code} {detail}") from exc
- except (urllib.error.URLError, TimeoutError, socket.timeout) as exc:
- raise RuntimeError(f"Jellyfin API failed: {clean_error(exc)}") from exc
-
-
-def fetch_jellyfin_library_ids(settings: dict[str, Any], library_names: list[str]) -> list[str]:
- if not library_names:
- return []
-
- requested = {name.casefold() for name in library_names}
- data = jellyfin_request(settings, "/Library/MediaFolders", {})
- items = data.get("Items", [])
- if not isinstance(items, list):
- raise RuntimeError("Jellyfin returned an invalid library folder payload")
-
- matched: list[str] = []
- available: list[str] = []
- for item in items:
- if not isinstance(item, dict):
- continue
- name = str(item.get("Name", "")).strip()
- folder_id = str(item.get("Id", "")).strip()
- if name:
- available.append(name)
- if name.casefold() in requested and folder_id:
- matched.append(folder_id)
-
- missing = sorted(set(library_names) - {name for name in available if name.casefold() in requested})
- if missing:
- raise ValueError(f"Jellyfin library not found: {', '.join(missing)}")
- return matched
-
-
-def fetch_jellyfin_items(settings: dict[str, Any], item_type: str) -> list[dict[str, Any]]:
- parent_ids = settings.get("ParentIds")
- if isinstance(parent_ids, list) and parent_ids:
- all_items: list[dict[str, Any]] = []
- for parent_id in parent_ids:
- scoped_settings = dict(settings)
- scoped_settings["ParentId"] = str(parent_id)
- scoped_settings.pop("ParentIds", None)
- all_items.extend(fetch_jellyfin_items(scoped_settings, item_type))
- return all_items
-
- parent_id_value = str(settings.get("ParentId", "")).strip() or None
- items: list[dict[str, Any]] = []
- start_index = 0
- limit = 200
- while True:
- data = jellyfin_request(
- settings,
- "/Items",
- {
- "Recursive": "true",
- "IncludeItemTypes": item_type,
- "Fields": "Genres,Overview,OfficialRating,RecursiveItemCount,ChildCount,PremiereDate,RunTimeTicks,ProviderIds",
- "SortBy": "SortName",
- "SortOrder": "Ascending",
- "EnableImages": "false",
- "ParentId": parent_id_value,
- "StartIndex": start_index,
- "Limit": limit,
- },
- )
- page = data.get("Items", [])
- if not isinstance(page, list):
- raise RuntimeError("Jellyfin returned an invalid Items payload")
- items.extend(item for item in page if isinstance(item, dict))
- total = int(data.get("TotalRecordCount", len(items)) or len(items))
- if not page or len(items) >= total:
- return items
- start_index += limit
-
-
-def jellyfin_item_dedupe_key(item: dict[str, Any], item_type: str) -> tuple[str, str]:
- provider_ids = item.get("ProviderIds")
- if isinstance(provider_ids, dict):
- for provider in ("Tmdb", "Imdb", "Tvdb"):
- value = str(provider_ids.get(provider, "")).strip().casefold()
- if value:
- return item_type.casefold(), f"{provider.casefold()}:{value}"
-
- title = clean_media_text(str(item.get("Name", "")), 120) or ""
- year = jellyfin_item_year(item) or ""
- return item_type.casefold(), f"title:{title.casefold()}:{year}"
-
-
-def dedupe_jellyfin_items(items: list[dict[str, Any]], item_type: str) -> list[dict[str, Any]]:
- provider_deduped: dict[tuple[str, str], dict[str, Any]] = {}
- for item in items:
- key = jellyfin_item_dedupe_key(item, item_type)
- current = provider_deduped.get(key)
- if current is None:
- provider_deduped[key] = item
- continue
- current_overview = str(current.get("Overview", "") or current.get("ShortOverview", ""))
- next_overview = str(item.get("Overview", "") or item.get("ShortOverview", ""))
- if len(next_overview) > len(current_overview):
- provider_deduped[key] = item
-
- title_deduped: dict[tuple[str, str], dict[str, Any]] = {}
- for item in provider_deduped.values():
- title = clean_media_text(str(item.get("Name", "")), 120) or ""
- key = (title.casefold(), jellyfin_item_year(item) or "")
- current = title_deduped.get(key)
- if current is None:
- title_deduped[key] = item
- continue
- current_has_provider = bool(current.get("ProviderIds"))
- next_has_provider = bool(item.get("ProviderIds"))
- if next_has_provider and not current_has_provider:
- title_deduped[key] = item
- continue
- current_overview = str(current.get("Overview", "") or current.get("ShortOverview", ""))
- next_overview = str(item.get("Overview", "") or item.get("ShortOverview", ""))
- if len(next_overview) > len(current_overview):
- title_deduped[key] = item
-
- return sorted(title_deduped.values(), key=lambda item: (str(item.get("SortName", item.get("Name", ""))).casefold(), str(item.get("ProductionYear", ""))))
-
-
-def fetch_jellyfin_library(runtime: BotRuntime) -> tuple[list[MediaItem], list[MediaItem]]:
- state = load_state(runtime.settings_path)
- settings = {
- "url": str(state.get("jellyfin_url", "")).strip(),
- "apiKey": str(state.get("jellyfin_api_key", "")).strip(),
- }
- library_names = jellyfin_settings(runtime).get("libraryNames", [])
- if isinstance(library_names, list) and library_names:
- settings["ParentIds"] = fetch_jellyfin_library_ids(settings, [str(name) for name in library_names])
- movie_items = dedupe_jellyfin_items(fetch_jellyfin_items(settings, "Movie"), "Movie")
- show_items = dedupe_jellyfin_items(fetch_jellyfin_items(settings, "Series"), "Series")
- movies = [jellyfin_movie_from_item(item) for item in movie_items]
- shows = [jellyfin_show_from_item(item) for item in show_items]
- return (
- sorted(movies, key=lambda item: (item.title.casefold(), item.year or "")),
- sorted(shows, key=lambda item: (item.title.casefold(), item.year or "")),
- )
-
-
-def media_library_fingerprint(movies: list[MediaItem], shows: list[MediaItem]) -> str:
- payload = media_library_to_jsonable(movies, shows)
- body = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
- return hashlib.sha256(body).hexdigest()
-
-
-def media_item_tracking_key(item: MediaItem) -> str:
- return f"{item.media_type}:{item.title.casefold()}:{item.year or ''}"
-
-
-def media_change_entry(item: MediaItem) -> dict[str, Any]:
- return {
- "title": item.title,
- "year": item.year or "",
- "mediaType": item.media_type,
- }
-
-
-def media_library_changes(
- previous_movies: list[MediaItem],
- previous_shows: list[MediaItem],
- current_movies: list[MediaItem],
- current_shows: list[MediaItem],
-) -> dict[str, Any]:
- previous = {media_item_tracking_key(item): item for item in [*previous_movies, *previous_shows]}
- current = {media_item_tracking_key(item): item for item in [*current_movies, *current_shows]}
- added_keys = sorted(set(current) - set(previous), key=lambda key: (current[key].media_type, current[key].title.casefold()))
- removed_keys = sorted(set(previous) - set(current), key=lambda key: (previous[key].media_type, previous[key].title.casefold()))
- added = [media_change_entry(current[key]) for key in added_keys]
- removed = [media_change_entry(previous[key]) for key in removed_keys]
- return {
- "added": added,
- "removed": removed,
- "addedCount": len(added),
- "removedCount": len(removed),
- }
-
-
-def update_jellyfin_sync_state(
- runtime: BotRuntime,
- *,
- fingerprint: str | None = None,
- error: str | None = None,
- published: bool = False,
- changes: dict[str, Any] | None = None,
-) -> None:
- state = load_state(runtime.settings_path)
- now = datetime.now(timezone.utc).isoformat()
- state["jellyfin_last_sync_at"] = now
- state["jellyfin_last_sync_error"] = error
- if fingerprint is not None:
- state["jellyfin_last_fingerprint"] = fingerprint
- if published:
- state["jellyfin_last_published_at"] = now
- if fingerprint is not None:
- state["jellyfin_last_published_fingerprint"] = fingerprint
- if changes is not None:
- state["jellyfin_last_changes"] = changes
- save_state(runtime.settings_path, state)
-
-
-def sync_jellyfin_library(runtime: BotRuntime, force_publish: bool = False) -> dict[str, Any]:
- previous_movies, previous_shows = load_media_library(runtime)
- movies, shows = fetch_jellyfin_library(runtime)
- changes = media_library_changes(previous_movies, previous_shows, movies, shows)
- fingerprint = media_library_fingerprint(movies, shows)
- settings_state = load_state(runtime.settings_path)
- changed = fingerprint != str(settings_state.get("jellyfin_last_fingerprint", ""))
- publish_changed = fingerprint != str(settings_state.get("jellyfin_last_published_fingerprint", ""))
- save_media_library(runtime, movies, shows)
-
- published = False
- result: dict[str, Any] = {}
- if (publish_changed or force_publish) and not runtime.dry_run:
- result = publish_media_items(
- runtime=runtime,
- channel_id=channel_settings(runtime)["mediaChannelId"],
- movies_all=movies,
- shows_all=shows,
- )
- published = True
-
- update_jellyfin_sync_state(runtime, fingerprint=fingerprint, published=published, changes=changes)
- return {
- "changed": changed,
- "published": published,
- "changes": changes,
- "movieCount": len(movies),
- "showCount": len(shows),
- "libraryNames": jellyfin_settings(runtime).get("libraryNames", []),
- "library": media_library_to_jsonable(movies, shows),
- "publishResult": result,
- "jellyfin": jellyfin_settings(runtime),
- }
-
-
-def maybe_run_jellyfin_sync(runtime: BotRuntime) -> dict[str, Any] | None:
- settings = jellyfin_settings(runtime)
- if not settings["configured"] or not settings["autoSync"]:
- return None
-
- interval = int(os.getenv("JELLYFIN_SYNC_INTERVAL_SECONDS", str(DEFAULT_JELLYFIN_SYNC_INTERVAL_SECONDS)))
- state = load_state(runtime.settings_path)
- last_sync = str(state.get("jellyfin_last_sync_at", "")).strip()
- if last_sync:
- try:
- last = datetime.fromisoformat(last_sync)
- if (datetime.now(timezone.utc) - last).total_seconds() < interval:
- return None
- except ValueError:
- pass
-
- try:
- return sync_jellyfin_library(runtime)
- except Exception as exc:
- update_jellyfin_sync_state(runtime, error=str(exc)[:240])
- raise
-
-
-def media_item_heading(item: MediaItem) -> str:
- if item.year:
- return f"{item.title} ({item.year})"
- return item.title
-
-
-def media_item_value(item: MediaItem) -> str:
- details: list[str] = []
- if item.genres:
- details.append(item.genres)
- if item.rating:
- details.append(item.rating)
- if item.runtime and item.media_type == "movie":
- details.append(item.runtime)
- if item.media_type == "show":
- counts = []
- if item.seasons:
- counts.append(f"{item.seasons} season{'s' if item.seasons != 1 else ''}")
- if item.episodes:
- counts.append(f"{item.episodes} episode{'s' if item.episodes != 1 else ''}")
- if counts:
- details.append(" · ".join(counts))
-
- lines = []
- if details:
- lines.append(" • ".join(details))
- if item.summary:
- lines.append(item.summary)
- return "\n".join(lines)[:1024] or "No details provided."
-
-
-def media_category_embeds(
- title: str,
- items: list[MediaItem],
- color: int,
- total_count: int,
- source_name: str,
-) -> list[dict[str, Any]]:
- embeds: list[dict[str, Any]] = []
- omitted = max(total_count - len(items), 0)
- page_count = max((len(items) + MEDIA_ITEMS_PER_EMBED - 1) // MEDIA_ITEMS_PER_EMBED, 1)
-
- for page_index in range(page_count):
- start = page_index * MEDIA_ITEMS_PER_EMBED
- chunk = items[start : start + MEDIA_ITEMS_PER_EMBED]
- description = f"{total_count} total from `{source_name}`"
- if omitted:
- description += f" · showing first {len(items)}"
- if page_count > 1:
- description += f" · page {page_index + 1}/{page_count}"
-
- fields = [
- {
- "name": media_item_heading(item)[:256],
- "value": media_item_value(item),
- "inline": False,
- }
- for item in chunk
- ]
- embeds.append(
- {
- "title": title,
- "description": description,
- "color": color,
- "fields": fields,
- }
- )
-
- return embeds
-
-
-def render_media_catalog_payloads(
- movies: list[MediaItem],
- shows: list[MediaItem],
- movie_total: int,
- show_total: int,
- movie_source: str,
- show_source: str,
-) -> list[dict[str, Any]]:
- now = datetime.now(timezone.utc)
- embeds: list[dict[str, Any]] = [
- {
- "title": "The Mithral Archive Media Catalog",
- "description": "Current movies and shows available in the archive.",
- "color": 0x5865F2,
- "fields": [
- {"name": "Movies", "value": str(movie_total), "inline": True},
- {"name": "Shows", "value": str(show_total), "inline": True},
- {"name": "Updated", "value": now.strftime("%Y-%m-%d %H:%M UTC"), "inline": True},
- ],
- "timestamp": now.isoformat(),
- }
- ]
- if movies:
- embeds.extend(media_category_embeds("Movies", movies, 0xF59E0B, movie_total, movie_source))
- if shows:
- embeds.extend(media_category_embeds("Shows", shows, 0x10B981, show_total, show_source))
-
- payloads = []
- for index in range(0, len(embeds), MAX_DISCORD_EMBEDS):
- payloads.append(
- {
- "content": "**The Mithral Archive Media Catalog**" if index == 0 else "",
- "embeds": embeds[index : index + MAX_DISCORD_EMBEDS],
- }
- )
- return payloads
-
-
-def media_markdown_line(item: MediaItem) -> str:
- title = media_item_heading(item)
- details: list[str] = []
- if item.genres:
- details.append(item.genres)
- if item.rating:
- details.append(item.rating)
- if item.runtime and item.media_type == "movie":
- details.append(item.runtime)
- if item.media_type == "show":
- if item.seasons:
- details.append(f"{item.seasons} season{'s' if item.seasons != 1 else ''}")
- if item.episodes:
- details.append(f"{item.episodes} episode{'s' if item.episodes != 1 else ''}")
-
- suffix = f" - {'; '.join(details)}" if details else ""
- return f"- **{title}**{suffix}"
-
-
-def render_media_catalog_markdown(movies: list[MediaItem], shows: list[MediaItem]) -> str:
- now = datetime.now(timezone.utc)
- lines = [
- "# The Mithral Archive Media Catalog",
- "",
- f"Updated: {now.strftime('%Y-%m-%d %H:%M UTC')}",
- f"Movies: {len(movies)}",
- f"Shows: {len(shows)}",
- "",
- ]
-
- if movies:
- lines.extend(["## Movies", ""])
- for item in movies:
- lines.append(media_markdown_line(item))
- if item.summary:
- lines.append(f" - {item.summary}")
- lines.append("")
-
- if shows:
- lines.extend(["## Shows", ""])
- for item in shows:
- lines.append(media_markdown_line(item))
- if item.summary:
- lines.append(f" - {item.summary}")
- lines.append("")
-
- return "\n".join(lines).strip() + "\n"
-
-
-def render_catalog_html(runtime: BotRuntime) -> bytes:
- movies, shows = load_media_library(runtime)
- updated_at = load_state(runtime.media_library_path).get("updated_at")
- updated = "Never"
- if updated_at:
- try:
- updated = datetime.fromisoformat(str(updated_at)).strftime("%Y-%m-%d %H:%M UTC")
- except ValueError:
- updated = str(updated_at)
-
- def item_block(item: MediaItem) -> str:
- meta = []
- if item.year:
- meta.append(item.year)
- if item.genres:
- meta.append(item.genres)
- if item.rating:
- meta.append(item.rating)
- if item.runtime:
- meta.append(item.runtime)
- if item.media_type == "show":
- if item.seasons:
- meta.append(f"{item.seasons} season{'s' if item.seasons != 1 else ''}")
- if item.episodes:
- meta.append(f"{item.episodes} episode{'s' if item.episodes != 1 else ''}")
- summary = f"
{html.escape(item.summary)}
" if item.summary else ""
- return (
- f'
'
- f"{html.escape(item.title)}
"
- f'{html.escape(" · ".join(meta))}
'
- f"{summary}"
- )
-
- items = "\n".join(item_block(item) for item in [*movies, *shows])
- body = f"""
-
-
-
-
-
The Mithral Archive Media Catalog
-
-
-
-
-
{items}
-
-
-
-"""
- return body.encode("utf-8")
-
-
-def publish_media_markdown_message(
- token: str,
- channel_id: str,
- movies: list[MediaItem],
- shows: list[MediaItem],
- catalog_url: str = "",
-) -> str:
- markdown = render_media_catalog_markdown(movies, shows)
- payload = {
- "content": (
- "**The Mithral Archive Media Catalog**\n"
- f"{len(movies)} movies · {len(shows)} shows\n"
- "Attached as a compact Markdown list."
- ),
- "allowed_mentions": {"parse": []},
- }
- if catalog_url:
- payload["components"] = [
- {
- "type": 1,
- "components": [
- {
- "type": 2,
- "style": 5,
- "label": "Open Catalog",
- "url": catalog_url,
- }
- ],
- }
- ]
- message = discord_multipart_request(
- "POST",
- token,
- f"/channels/{channel_id}/messages",
- payload,
- [("files[0]", "media-catalog.md", "text/markdown; charset=utf-8", markdown.encode("utf-8"))],
- )
- message_id = str(message.get("id", "")).strip()
- if not message_id:
- raise RuntimeError("Discord did not return a message id for the media catalog")
- return message_id
-
-
-def publish_media_items(
- runtime: BotRuntime,
- channel_id: str,
- movies_all: list[MediaItem],
- shows_all: list[MediaItem],
- movie_source: str = "Dashboard library",
- show_source: str = "Dashboard library",
-) -> dict[str, Any]:
- if runtime.dry_run:
- raise RuntimeError("Discord dry run is enabled; media catalog was parsed but not sent")
-
- settings = channel_settings(runtime)
- channel = validate_channel_id(channel_id.strip() or settings["mediaChannelId"], "Media")
-
- if not movies_all and not shows_all:
- raise ValueError("Add at least one movie or show before publishing")
-
- state = load_state(runtime.media_state_path)
- old_channel = str(state.get("channel_id", "")).strip()
- existing_ids = [
- str(message_id)
- for message_id in state.get("message_ids", [])
- if str(message_id).strip()
- ]
- delete_channel = old_channel or channel
- for old_id in existing_ids:
- discord_delete_message(runtime.token, delete_channel, old_id)
-
- message_id = publish_media_markdown_message(
- runtime.token,
- channel,
- movies_all,
- shows_all,
- catalog_url=settings.get("catalogUrl", ""),
- )
-
- save_state(
- runtime.media_state_path,
- {
- "channel_id": channel,
- "message_ids": [message_id],
- "movie_count": len(movies_all),
- "show_count": len(shows_all),
- "format": "markdown",
- "published_at": datetime.now(timezone.utc).isoformat(),
- },
- )
- save_media_channel_setting(runtime, channel)
-
- return {
- "channelId": channel,
- "messageIds": [message_id],
- "movieCount": len(movies_all),
- "showCount": len(shows_all),
- "displayedMovieCount": len(movies_all),
- "displayedShowCount": len(shows_all),
- "format": "markdown",
- }
-
-
-def media_catalog_status(runtime: BotRuntime) -> dict[str, Any]:
- state = load_state(runtime.media_state_path)
- settings = channel_settings(runtime)
- movies, shows = load_media_library(runtime)
- return {
- "channelId": str(state.get("channel_id", "")).strip() or settings["mediaChannelId"],
- "channels": settings,
- "messageIds": state.get("message_ids", []) if isinstance(state.get("message_ids"), list) else [],
- "movieCount": state.get("movie_count"),
- "showCount": state.get("show_count"),
- "format": state.get("format", "markdown"),
- "publishedAt": state.get("published_at"),
- "changes": jellyfin_settings(runtime).get("lastChanges"),
- "library": media_library_to_jsonable(movies, shows),
- }
-
-
-def render_embeds(results: list[CheckResult]) -> list[dict[str, Any]]:
- checked_at = datetime.now(timezone.utc)
- online = sum(1 for result in results if result.ok)
- total = len(results)
- degraded = 0 < online < total
-
- if total == 0:
- color = 0x6B7280
- summary = "No services configured."
- elif online == total:
- color = 0x10B981
- summary = f"🟢 Operational · {online}/{total} online"
- elif degraded:
- color = 0xF59E0B
- offline = total - online
- attention = "1 service needs attention" if offline == 1 else f"{offline} services need attention"
- summary = f"🟡 Degraded · {online}/{total} online · {attention}"
- else:
- color = 0xEF4444
- summary = f"🔴 Outage · {online}/{total} online"
-
- groups: dict[str, list[CheckResult]] = {}
- for result in results:
- groups.setdefault(result.service.group, []).append(result)
-
- fields = []
- for group_name, group_results in groups.items():
- service_lines = []
- state_lines = []
- for result in group_results:
- icon = "🟢" if result.ok else "🔴"
- label = "Online" if result.ok else "Issue"
- service_lines.append(f"**{result.service.name}**")
- state_lines.append(f"{icon} {label}")
-
- fields.append({
- "name": group_name,
- "value": "\n".join(service_lines)[:1024] or "None",
- "inline": True,
- })
- fields.append({
- "name": "Status",
- "value": "\n".join(state_lines)[:1024] or "None",
- "inline": True,
- })
- fields.append({
- "name": "\u200b",
- "value": "\u200b",
- "inline": False,
- })
-
- if fields and fields[-1]["name"] == "\u200b":
- fields.pop()
-
- interval = os.getenv("CHECK_INTERVAL_SECONDS", str(DEFAULT_INTERVAL_SECONDS)).strip()
- return [
- {
- "title": "The Mithral Archive",
- "description": summary,
- "color": color,
- "fields": fields[:25],
- "footer": {"text": f"Refreshes every {interval}s • Last checked {checked_at.strftime('%Y-%m-%d %H:%M:%S')} UTC"},
- "timestamp": checked_at.isoformat(),
- }
- ]
-
-
-def upsert_status_message(
- token: str,
- channel_id: str,
- state_path: Path,
- results: list[CheckResult],
-) -> str:
- state = load_state(state_path)
- message_id = str(state.get("message_id", "")).strip()
- payload = {"content": "", "embeds": render_embeds(results)}
-
- if message_id:
- try:
- discord_request("PATCH", token, f"/channels/{channel_id}/messages/{message_id}", payload)
- return message_id
- except RuntimeError as exc:
- print(f"Could not edit existing status message, creating a new one: {exc}", file=sys.stderr)
-
- message = discord_request("POST", token, f"/channels/{channel_id}/messages", payload)
- new_id = str(message.get("id", "")).strip()
- if not new_id:
- raise RuntimeError("Discord did not return a message id")
-
- save_state(state_path, {"message_id": new_id})
- return new_id
-
-
-def fake_preview_results(services: list[Service]) -> list[CheckResult]:
- results: list[CheckResult] = []
- for index, service in enumerate(services):
- results.append(
- CheckResult(
- service=service,
- ok=index != len(services) - 1,
- status=200 if index != len(services) - 1 else 502,
- latency_ms=42 + (index * 31),
- error=None if index != len(services) - 1 else "HTTP 502",
- )
- )
- return results
-
-
-def print_preview(services: list[Service]) -> None:
- payload = {"content": "", "embeds": render_embeds(fake_preview_results(services))}
- print(json.dumps(payload, indent=2))
-
-
-def result_to_jsonable(result: CheckResult) -> dict[str, Any]:
- return {
- "name": result.service.name,
- "group": result.service.group,
- "url": result.service.url,
- "displayUrl": result.service.display_url,
- "ok": result.ok,
- "status": result.status,
- "latencyMs": result.latency_ms,
- "error": result.error,
- }
-
-
-def run_check_cycle(runtime: BotRuntime) -> tuple[str, list[CheckResult]]:
- services = load_services(runtime.config_path)
- results = [check_service(service) for service in services]
- if runtime.dry_run:
- message_id = "dry-run"
- else:
- channel_id = validate_channel_id(channel_settings(runtime)["statusChannelId"], "Status")
- message_id = upsert_status_message(runtime.token, channel_id, runtime.state_path, results)
-
- with runtime.lock:
- runtime.last_results = results
- runtime.last_message_id = message_id
- runtime.last_checked_at = datetime.now(timezone.utc)
- runtime.last_error = None
-
- return message_id, results
-
-
-def runtime_status(runtime: BotRuntime) -> dict[str, Any]:
- services = load_services(runtime.config_path)
- settings = channel_settings(runtime)
- with runtime.lock:
- results = list(runtime.last_results)
- return {
- "services": services_to_jsonable(services),
- "results": [result_to_jsonable(result) for result in results],
- "lastError": runtime.last_error,
- "lastMessageId": runtime.last_message_id,
- "lastCheckedAt": runtime.last_checked_at.isoformat() if runtime.last_checked_at else None,
- "channelId": settings["statusChannelId"],
- "channels": settings,
- }
-
-
-def bool_env(name: str, default: bool = False) -> bool:
- raw = os.getenv(name)
- if raw is None:
- return default
- return raw.strip().lower() in {"1", "true", "yes", "on"}
-
-
-def password_hash(password: str) -> str:
- salt = secrets.token_bytes(16)
- digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, PBKDF2_ITERATIONS)
- salt_text = base64.urlsafe_b64encode(salt).decode("ascii").rstrip("=")
- digest_text = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
- return f"pbkdf2_sha256${PBKDF2_ITERATIONS}${salt_text}${digest_text}"
-
-
-def decode_urlsafe_base64(value: str) -> bytes:
- padding = "=" * (-len(value) % 4)
- return base64.urlsafe_b64decode(value + padding)
-
-
-def verify_password_hash(encoded: str, password: str) -> bool:
- try:
- algorithm, iterations, salt_text, digest_text = encoded.split("$", 3)
- if algorithm != "pbkdf2_sha256":
- return False
- salt = decode_urlsafe_base64(salt_text)
- expected = decode_urlsafe_base64(digest_text)
- actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, int(iterations))
- except (ValueError, TypeError):
- return False
- return hmac.compare_digest(actual, expected)
-
-
-def dashboard_auth_from_env() -> DashboardAuth | None:
- if bool_env("DASHBOARD_AUTH_DISABLED", False):
- return None
-
- username = os.getenv("DASHBOARD_USERNAME", "").strip()
- encoded_hash = os.getenv("DASHBOARD_PASSWORD_HASH", "").strip()
- if not username or not encoded_hash:
- return None
-
- ttl = int(os.getenv("DASHBOARD_SESSION_TTL_SECONDS", "28800"))
- secure = bool_env("DASHBOARD_COOKIE_SECURE", False)
- return DashboardAuth(
- DashboardAuthConfig(
- username=username,
- password_hash=encoded_hash,
- session_ttl_seconds=ttl,
- cookie_secure=secure,
- )
- )
-
-
-def print_password_hash() -> None:
- import getpass
-
- first = getpass.getpass("Dashboard password: ")
- second = getpass.getpass("Confirm password: ")
- if first != second:
- raise SystemExit("Passwords did not match")
- if len(first) < 12:
- raise SystemExit("Use at least 12 characters")
- print(password_hash(first))
-
-
-def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> type[BaseHTTPRequestHandler]:
- dashboard_path = Path(__file__).with_name("dashboard.html")
-
- class DashboardHandler(BaseHTTPRequestHandler):
- server_version = "ArchiveStatusDashboard/1.0"
-
- def log_message(self, format: str, *args: Any) -> None:
- print(f"[dashboard] {self.address_string()} - {format % args}", flush=True)
-
- def do_GET(self) -> None:
- path = urllib.parse.urlparse(self.path).path
- if path in {"/", "/dashboard"}:
- self.send_dashboard()
- return
- if path == "/catalog":
- self.send_catalog()
- return
- if path == "/favicon.ico":
- self.send_response(HTTPStatus.NO_CONTENT)
- self.end_headers()
- return
- if path == "/api/session":
- session = self.require_auth()
- if session is None:
- return
- _session_id, data = session
- self.send_json(
- HTTPStatus.OK,
- {
- "username": data.username,
- "csrfToken": data.csrf_token,
- },
- )
- return
- if path == "/api/status":
- if self.require_auth() is None:
- return
- self.send_json(HTTPStatus.OK, runtime_status(runtime))
- return
- if path == "/api/media":
- if self.require_auth() is None:
- return
- self.send_json(HTTPStatus.OK, media_catalog_status(runtime))
- return
- if path == "/api/settings":
- if self.require_auth() is None:
- return
- self.send_json(HTTPStatus.OK, {"channels": channel_settings(runtime)})
- return
- if path == "/api/jellyfin":
- if self.require_auth() is None:
- return
- self.send_json(HTTPStatus.OK, {"jellyfin": jellyfin_settings(runtime)})
- return
- self.send_error(HTTPStatus.NOT_FOUND)
-
- def do_POST(self) -> None:
- path = urllib.parse.urlparse(self.path).path
- if path == "/api/login":
- self.handle_login()
- return
- session = self.require_auth(require_csrf=True)
- if session is None:
- return
- if path == "/api/logout":
- self.handle_logout(session[0])
- return
- if path == "/api/check":
- self.handle_check()
- return
- if path == "/api/services":
- self.handle_services()
- return
- if path == "/api/media":
- self.handle_media_catalog()
- return
- if path == "/api/media/library":
- self.handle_media_library()
- return
- if path == "/api/media/reset":
- self.handle_media_reset()
- return
- if path == "/api/settings":
- self.handle_settings()
- return
- if path == "/api/jellyfin/settings":
- self.handle_jellyfin_settings()
- return
- if path == "/api/jellyfin/sync":
- self.handle_jellyfin_sync()
- return
- self.send_error(HTTPStatus.NOT_FOUND)
-
- def require_auth(self, require_csrf: bool = False) -> tuple[str, DashboardSession] | None:
- if auth is None:
- return "disabled", DashboardSession("local", "disabled", time.time() + 3600)
-
- session = auth.session_from_cookie(self.headers.get("Cookie"))
- if session is None:
- self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Login required"})
- return None
-
- if require_csrf:
- csrf = self.headers.get("X-CSRF-Token", "")
- if not hmac.compare_digest(csrf, session[1].csrf_token):
- self.send_json(HTTPStatus.FORBIDDEN, {"error": "CSRF token mismatch"})
- return None
-
- return session
-
- def cookie_attributes(self, max_age: int) -> str:
- attrs = [
- "Path=/",
- "HttpOnly",
- "SameSite=Strict",
- f"Max-Age={max_age}",
- ]
- if auth is not None and auth.config.cookie_secure:
- attrs.append("Secure")
- return "; ".join(attrs)
-
- def set_session_cookie(self, session_id: str) -> None:
- ttl = auth.config.session_ttl_seconds if auth is not None else 3600
- self.send_header(
- "Set-Cookie",
- f"{SESSION_COOKIE}={session_id}; {self.cookie_attributes(ttl)}",
- )
-
- def clear_session_cookie(self) -> None:
- self.send_header(
- "Set-Cookie",
- f"{SESSION_COOKIE}=; {self.cookie_attributes(0)}",
- )
-
- def send_dashboard(self) -> None:
- try:
- body = dashboard_path.read_bytes()
- except FileNotFoundError:
- self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR, "dashboard.html missing")
- return
-
- self.send_response(HTTPStatus.OK)
- self.send_header("Content-Type", "text/html; charset=utf-8")
- self.send_header("Content-Length", str(len(body)))
- self.end_headers()
- self.wfile.write(body)
-
- def send_catalog(self) -> None:
- body = render_catalog_html(runtime)
- self.send_response(HTTPStatus.OK)
- self.send_header("Content-Type", "text/html; charset=utf-8")
- self.send_header("Cache-Control", "no-store")
- self.send_header("Content-Length", str(len(body)))
- self.end_headers()
- self.wfile.write(body)
-
- def read_json(self) -> dict[str, Any]:
- raw_length = self.headers.get("Content-Length", "0")
- try:
- length = int(raw_length)
- except ValueError as exc:
- raise ValueError("Invalid Content-Length") from exc
- if length > MAX_REQUEST_BYTES:
- raise ValueError("Request body is too large")
-
- body = self.rfile.read(length)
- try:
- data = json.loads(body.decode("utf-8"))
- except json.JSONDecodeError as exc:
- raise ValueError(f"Invalid JSON: {exc}") from exc
- if not isinstance(data, dict):
- raise ValueError("JSON body must be an object")
- return data
-
- def send_json(self, status: HTTPStatus, payload: dict[str, Any]) -> None:
- body = json.dumps(payload, indent=2).encode("utf-8")
- self.send_response(status)
- self.send_header("Content-Type", "application/json; charset=utf-8")
- self.send_header("Cache-Control", "no-store")
- self.send_header("Content-Length", str(len(body)))
- self.end_headers()
- self.wfile.write(body)
-
- def handle_login(self) -> None:
- if auth is None:
- self.send_json(HTTPStatus.OK, {"username": "local", "csrfToken": "disabled"})
- return
-
- try:
- data = self.read_json()
- except ValueError as exc:
- self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
- return
-
- username = str(data.get("username", ""))
- password = str(data.get("password", ""))
- throttle_key = f"{self.client_address[0]}:{username}"
- if not auth.login_allowed(throttle_key):
- self.send_json(HTTPStatus.TOO_MANY_REQUESTS, {"error": "Too many login attempts. Try again later."})
- return
-
- login = auth.login(username, password)
- if login is None:
- auth.record_failed_login(throttle_key)
- self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Invalid username or password"})
- return
-
- auth.clear_failed_login(throttle_key)
- session_id, session = login
- payload = {
- "username": session.username,
- "csrfToken": session.csrf_token,
- }
- body = json.dumps(payload, indent=2).encode("utf-8")
- self.send_response(HTTPStatus.OK)
- self.set_session_cookie(session_id)
- self.send_header("Content-Type", "application/json; charset=utf-8")
- self.send_header("Cache-Control", "no-store")
- self.send_header("Content-Length", str(len(body)))
- self.end_headers()
- self.wfile.write(body)
-
- def handle_logout(self, session_id: str) -> None:
- if auth is not None:
- auth.logout(session_id)
- body = b'{\n "ok": true\n}'
- self.send_response(HTTPStatus.OK)
- self.clear_session_cookie()
- self.send_header("Content-Type", "application/json; charset=utf-8")
- self.send_header("Cache-Control", "no-store")
- self.send_header("Content-Length", str(len(body)))
- self.end_headers()
- self.wfile.write(body)
-
- def handle_check(self) -> None:
- try:
- message_id, results = run_check_cycle(runtime)
- except Exception as exc:
- with runtime.lock:
- runtime.last_error = str(exc)
- self.send_json(HTTPStatus.BAD_GATEWAY, {"error": str(exc)})
- return
-
- self.send_json(
- HTTPStatus.OK,
- {
- "messageId": message_id,
- "results": [result_to_jsonable(result) for result in results],
- },
- )
-
- def handle_services(self) -> None:
- try:
- data = self.read_json()
- services_from_data(data)
- save_services_config(runtime.config_path, data)
- message_id, results = run_check_cycle(runtime)
- except Exception as exc:
- with runtime.lock:
- runtime.last_error = str(exc)
- self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
- return
-
- self.send_json(
- HTTPStatus.OK,
- {
- "messageId": message_id,
- "services": data.get("services", []),
- "results": [result_to_jsonable(result) for result in results],
- },
- )
-
- def handle_media_catalog(self) -> None:
- try:
- data = self.read_json()
- if "movies" in data or "shows" in data:
- if "catalogUrl" in data:
- save_catalog_url_setting(runtime, str(data.get("catalogUrl", "")))
- movies = media_items_from_data(data.get("movies", []), "movie")
- shows = media_items_from_data(data.get("shows", []), "show")
- save_media_library(runtime, movies, shows)
- result = publish_media_items(
- runtime=runtime,
- channel_id=str(data.get("channelId", "")),
- movies_all=movies,
- shows_all=shows,
- )
- self.send_json(HTTPStatus.OK, result)
- return
-
- raise ValueError("Publish requires the saved Jellyfin media library")
- except Exception as exc:
- self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
- return
-
- self.send_json(HTTPStatus.OK, result)
-
- def handle_media_library(self) -> None:
- try:
- data = self.read_json()
- movies = media_items_from_data(data.get("movies", []), "movie")
- shows = media_items_from_data(data.get("shows", []), "show")
- saved = save_media_library(runtime, movies, shows)
- except Exception as exc:
- self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
- return
-
- self.send_json(
- HTTPStatus.OK,
- {
- "library": {
- "movies": saved.get("movies", []),
- "shows": saved.get("shows", []),
- },
- "movieCount": len(movies),
- "showCount": len(shows),
- },
- )
-
- def handle_media_reset(self) -> None:
- try:
- result = reset_media_library(runtime)
- except Exception as exc:
- self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
- return
-
- self.send_json(HTTPStatus.OK, result)
-
- def handle_settings(self) -> None:
- try:
- data = self.read_json()
- channels = data.get("channels", data)
- if not isinstance(channels, dict):
- raise ValueError("Settings payload must include a channels object")
- result = save_channel_settings(
- runtime,
- str(channels.get("statusChannelId", "")),
- str(channels.get("mediaChannelId", "")),
- str(channels.get("catalogUrl", "")),
- )
- except Exception as exc:
- self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
- return
-
- self.send_json(HTTPStatus.OK, {"channels": result})
-
- def handle_jellyfin_settings(self) -> None:
- try:
- data = self.read_json()
- result = save_jellyfin_settings(runtime, data)
- except Exception as exc:
- self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
- return
-
- self.send_json(HTTPStatus.OK, {"jellyfin": result})
-
- def handle_jellyfin_sync(self) -> None:
- try:
- data = self.read_json()
- if data:
- save_jellyfin_settings(runtime, data)
- result = sync_jellyfin_library(runtime, force_publish=bool(data.get("forcePublish", False)))
- except Exception as exc:
- update_jellyfin_sync_state(runtime, error=str(exc)[:240])
- self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc), "jellyfin": jellyfin_settings(runtime)})
- return
-
- self.send_json(HTTPStatus.OK, result)
-
- return DashboardHandler
-
-
-def maybe_start_dashboard(runtime: BotRuntime) -> ThreadingHTTPServer | None:
- if not bool_env("DASHBOARD_ENABLED", False):
- return None
-
- host = os.getenv("DASHBOARD_HOST", "127.0.0.1").strip() or "127.0.0.1"
- port = int(os.getenv("DASHBOARD_PORT", "8787"))
- auth = dashboard_auth_from_env()
- server = ThreadingHTTPServer((host, port), make_dashboard_handler(runtime, auth))
- thread = threading.Thread(target=server.serve_forever, name="dashboard", daemon=True)
- thread.start()
- auth_note = "without auth" if auth is None else "with password sessions"
- print(f"Dashboard running at http://{host}:{port} ({auth_note})", flush=True)
- return server
-
-
-def main() -> int:
- load_dotenv()
-
- if "--hash-password" in sys.argv:
- print_password_hash()
- return 0
-
- if "--preview" in sys.argv:
- config_path = Path(os.getenv("ARCHIVE_STATUS_CONFIG", "services.json"))
- print_preview(load_services(config_path))
- return 0
-
- token = env("DISCORD_BOT_TOKEN")
- token = normalize_discord_token(token)
- channel_id = os.getenv("DISCORD_CHANNEL_ID", "").strip()
- config_path = Path(env("ARCHIVE_STATUS_CONFIG", "services.json"))
- state_path = Path(env("ARCHIVE_STATUS_STATE", "state/status-message.json"))
- media_state_path = Path(env("MEDIA_CATALOG_STATE", "state/media-catalog.json"))
- media_library_path = Path(env("MEDIA_LIBRARY_STATE", "state/media-library.json"))
- settings_path = Path(env("BOT_SETTINGS_STATE", "state/bot-settings.json"))
- interval = int(env("CHECK_INTERVAL_SECONDS", str(DEFAULT_INTERVAL_SECONDS)))
- runtime = BotRuntime(token, channel_id, config_path, state_path, media_state_path, media_library_path, settings_path, dry_run=bool_env("DISCORD_DRY_RUN", False))
- gateway = None
- if bool_env("DISCORD_GATEWAY_ENABLED", True) and not runtime.dry_run:
- gateway = DiscordGatewayManager(token)
- gateway.start()
- dashboard = maybe_start_dashboard(runtime)
-
- if runtime.dry_run:
- print("Discord dry run is enabled; no Discord messages will be sent or edited.", flush=True)
- else:
- try:
- identity = discord_bot_identity(token)
- username = identity.get("username", "unknown")
- bot_id = identity.get("id", "unknown")
- print(f"Discord token authenticated as {username} ({bot_id})", flush=True)
- except Exception as exc:
- print(f"Could not verify Discord bot identity: {exc}", file=sys.stderr, flush=True)
-
- stopped = False
-
- def stop(_signum: int, _frame: Any) -> None:
- nonlocal stopped
- stopped = True
-
- signal.signal(signal.SIGINT, stop)
- signal.signal(signal.SIGTERM, stop)
-
- while not stopped:
- try:
- message_id, results = run_check_cycle(runtime)
- online = sum(1 for result in results if result.ok)
- print(f"Updated Discord status message {message_id}: {online}/{len(results)} online", flush=True)
- sync_result = maybe_run_jellyfin_sync(runtime)
- if sync_result is not None:
- action = "published" if sync_result["published"] else "checked"
- print(
- f"Jellyfin sync {action}: {sync_result['movieCount']} movies, {sync_result['showCount']} shows",
- flush=True,
- )
- except Exception as exc:
- with runtime.lock:
- runtime.last_error = str(exc)
- print(f"Status update failed: {exc}", file=sys.stderr, flush=True)
-
- for _ in range(interval):
- if stopped:
- break
- time.sleep(1)
-
- if dashboard is not None:
- dashboard.shutdown()
- if gateway is not None:
- gateway.stop()
-
- return 0
+from archive_bot.main import main
if __name__ == "__main__":