diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000000000000000000000000000000000000..823e540c19150f4751cc64ebf2eb1bfa75760470 --- /dev/null +++ b/.env.sample @@ -0,0 +1,553 @@ +# ============================================================================== +# ESSENTIAL ADDON SETUP +# ============================================================================== +# These are the most important settings you'll need to configure. + +# --- Addon Identification --- +# Descriptive name for your addon instance. +ADDON_NAME="AIOStreams" +# Unique identifier for your addon. +ADDON_ID="aiostreams.viren070.com" + +# --- Network Configuration --- +# The port on which the addon will listen. +# Default: 3000 +PORT=3000 + +# The base URL of your addon. Highly recommended for proper functioning. +# Used for generating installation URLs and identifying self-scraping. +# Example: https://aiostreams.yourdomain.com +BASE_URL= + +# --- Security --- +# CRITICAL: Secret key for encrypting addon configuration. +# MUST be a 64-character hex string. +# Generate one using: +# Linux/macOS: openssl rand -hex 32 +# Windows (PowerShell): -join ((0..31) | ForEach-Object { '{0:x2}' -f (Get-Random -Minimum 0 -Maximum 255) }) +# Or: [System.Guid]::NewGuid().ToString("N") + [System.Guid]::NewGuid().ToString("N") (ensure it's 64 chars) +SECRET_KEY= + +# API key to protect your addon installation and usage. +# Leave empty to disable password protection. +# Can be any string. +ADDON_PASSWORD= + +# --- Database --- +# REQUIRED: The database URI for storing addon configuration. +# Supports SQLite (simplest) or PostgreSQL. +# +# SQLite example (stores data in a file): +# DATABASE_URI=sqlite://./data/db.sqlite +# (You can change './data/db.sqlite' to your preferred path) +# +# PostgreSQL example: +# DATABASE_URI=postgres://username:password@host:port/database_name +# (e.g., postgresql://postgres:password@localhost:5432/aiostreams) +DATABASE_URI=sqlite://./data/db.sqlite + + +# ============================================================================== +# DEBRID & OTHER SERVICE API KEYS +# ============================================================================== + +# Provide a default TMDB access token to be used for the Title Matching filter if a user does not provide any. +TMDB_ACCESS_TOKEN= + +# Configure API keys for debrid services and others you plan to use. +# 'DEFAULT_' values are pre-filled in the user's config page. +# 'FORCED_' values override user settings and hide the option. + +# --- Real-Debrid --- +DEFAULT_REALDEBRID_API_KEY= +FORCED_REALDEBRID_API_KEY= + +# --- AllDebrid --- +DEFAULT_ALLDEBRID_API_KEY= +FORCED_ALLDEBRID_API_KEY= + +# --- Premiumize --- +DEFAULT_PREMIUMIZE_API_KEY= +FORCED_PREMIUMIZE_API_KEY= + +# --- Debrid-Link --- +DEFAULT_DEBRIDLINK_API_KEY= +FORCED_DEBRIDLINK_API_KEY= + +# --- Torbox --- +DEFAULT_TORBOX_API_KEY= +FORCED_TORBOX_API_KEY= + +# --- OffCloud --- +DEFAULT_OFFCLOUD_API_KEY= +FORCED_OFFCLOUD_API_KEY= +DEFAULT_OFFCLOUD_EMAIL= +FORCED_OFFCLOUD_EMAIL= +DEFAULT_OFFCLOUD_PASSWORD= +FORCED_OFFCLOUD_PASSWORD= + +# --- Put.io --- +DEFAULT_PUTIO_CLIENT_ID= +FORCED_PUTIO_CLIENT_ID= +DEFAULT_PUTIO_CLIENT_SECRET= +FORCED_PUTIO_CLIENT_SECRET= + +# --- EasyNews --- +DEFAULT_EASYNEWS_USERNAME= +FORCED_EASYNEWS_USERNAME= +DEFAULT_EASYNEWS_PASSWORD= +FORCED_EASYNEWS_PASSWORD= + +# --- EasyDebrid --- +DEFAULT_EASYDEBRID_API_KEY= +FORCED_EASYDEBRID_API_KEY= + +# --- PikPak --- +DEFAULT_PIKPAK_EMAIL= +FORCED_PIKPAK_EMAIL= +DEFAULT_PIKPAK_PASSWORD= +FORCED_PIKPAK_PASSWORD= + +# --- Seedr --- +DEFAULT_SEEDR_ENCODED_TOKEN= +FORCED_SEEDR_ENCODED_TOKEN= + + +# ============================================================================== +# CUSTOMIZATION & ACCESS CONTROL +# ============================================================================== + +# --- Custom HTML --- +# Display custom HTML at the top of the addon's configuration page. +# Example: CUSTOM_HTML="
Welcome to my AIOStreams!
" +CUSTOM_HTML= + +# --- Trusted Users --- +# Comma-separated list of trusted UUIDs. +# Trusted users can access features like regex filters if REGEX_FILTER_ACCESS is 'trusted'. +# Example: TRUSTED_UUIDS=ae32f456-1234-5678-9012-345678901234,another-uuid-here +# TRUSTED_UUIDS= + +# --- Regex Filter Access --- +# Controls who can use regex filters. +# 'none': No one can use regex filters. +# 'trusted': Only users listed in TRUSTED_UUIDS. +# 'all': All users (only recommended if ADDON_PASSWORD is set). +# Default: trusted +REGEX_FILTER_ACCESS=trusted + +# --- Aliased Configurations (Vanity URLs) --- +# Create shorter, memorable installation URLs. +# Format: aliasName1:uuid1:encryptedPassword1,aliasName2:uuid2:encryptedPassword2 +# Users can then access the addon via /stremio/u/aliasName/manifest.json +# ALIASED_CONFIGURATIONS= + +# ============================================================================== +# CACHE CONFIGURATION +# ============================================================================== + +# --- Default maximum cache size ---- +# The maximum number of items that can be held in a given cache instance, if not overriden by a specific cache instance +DEFAULT_MAX_CACHE_SIZE=100000 + +# --- Proxy IP TTL (StremThru/MediaFlow Proxy) +# The Time-To-Live (in seconds) of items in the Public IP cache. +# Set to -1 to disable caching +PROXY_IP_CACHE_TTL=900 + +# --- Addon Resource Caching --- +# Control the Caching of resources fetched from other addons +# Set to -1 to disable caching. +MANIFEST_CACHE_TTL=300 +SUBTITLE_CACHE_TTL=300 +STREAM_CACHE_TTL=-1 +CATALOG_CACHE_TTL=300 +META_CACHE_TTL=300 +ADDON_CATALOG_CACHE_TTL=300 + + +# --- RPDB API Key Validation Caching --- +# Control how long a valid API key check is cached for +# Default: 7 days +RPDB_API_KEY_VALIDITY_CACHE_TTL=604800 + +# ============================================================================== +# FEATURE CONTROL +# ============================================================================== +# Enable or disable specific addon features. + +# --- Self-Scraping --- +# Prevent this AIOStreams instance from being added as an addon to itself. +# Default: true +DISABLE_SELF_SCRAPING=true + +# --- Disabled Hosts --- +# Prevent certain hostnames from being added as addons. +# Format: host1:reason1,host2:reason2 +# Example: DISABLED_HOSTS=torrentio.strem.fun:Blocked by Torrentio +# DISABLED_HOSTS= + +# --- Disabled Addons (Marketplace) --- +# Disable specific addons from appearing in the marketplace. +# See https://github.com/Viren070/AIOStreams/blob/main/packages/core/src/utils/marketplace.ts for IDs. +# Format: addonID1:reason1,addonID2:reason2 +# Example: DISABLED_ADDONS=torrentio:Blocked by Torrentio +# DISABLED_ADDONS= + +# --- Disabled Services (Configuration Page) --- +# Hide certain services (e.g., debrid services) from the configuration page. +# Format: service1:reason1,service2:reason2 +# Example: DISABLED_SERVICES=realdebrid:Not available on this instance +# DISABLED_SERVICES= + + +# ============================================================================== +# LOGGING +# ============================================================================== + +# --- Log Level --- +# Set the verbosity of logs. Options: "error", "warn", "info", "http", "verbose","debug", "silly" +# Default: info +LOG_LEVEL=http + +# --- Log Format --- +# Output logs in "json" or "text" format. +# Default: text +LOG_FORMAT=text + +# --- Log Sensitive Information --- +# Whether to include potentially sensitive info (like API keys) in logs. +# Useful for debugging, but disable for production if concerned. +# Default: true +LOG_SENSITIVE_INFO=true + +# --- Log Timezone --- +# Adjust the timezone used for logging +# e.g. Europe/Paris, America/New_York +LOG_TIMEZONE=Etc/UTC + + +# ============================================================================== +# PROXY FOR OUTGOING ADDON REQUESTS (Torrentio, etc.) +# ============================================================================== +# Configure a proxy for requests made *by* this AIOStreams instance *to* other addons (e.g., Torrentio). +# Useful if your server's IP is blocked by an upstream service. + +# --- Addon Proxy URL --- +# The proxy URL to use for all requests to upstream addons. +# Example: ADDON_PROXY=http://warp:1080 (using https://github.com/cmj2002/warp-docker) +# ADDON_PROXY= + +# --- Addon Proxy Configuration --- +# Optionally, specify which domains to proxy. +# Comma-separated list of rules: domain_pattern:boolean. Later rules have higher priority. +# Wildcards (*) can be used. +# Example: ADDON_PROXY_CONFIG="*:false,*.strem.fun:true" (only proxy *.strem.fun domains) +# ADDON_PROXY_CONFIG= + + +# ============================================================================== +# DEFAULT/FORCED STREAM PROXY (MediaFlow, StremThru) +# ============================================================================== +# Configure how AIOStreams handles stream proxies like MediaFlow or StremThru for playback. +# 'DEFAULT_' values are pre-filled. 'FORCE_' values override user settings. + +# --- Stream Proxy Enabled --- +# DEFAULT_PROXY_ENABLED=true # Default state for enabling a stream proxy. +# FORCE_PROXY_ENABLED=false # Force stream proxy on/off for all users. + +# --- Stream Proxy ID --- +# 'mediaflow' or 'stremthru' +DEFAULT_PROXY_ID=mediaflow +# FORCE_PROXY_ID= + +# --- Stream Proxy URL --- +# URL of your MediaFlow or StremThru instance. +# DEFAULT_PROXY_URL= +# FORCE_PROXY_URL= + +# --- Stream Proxy Credentials --- +# Format: username:password +# DEFAULT_PROXY_CREDENTIALS= +# FORCE_PROXY_CREDENTIALS= + +# --- Stream Proxy Public IP --- +# Public IP for the proxy, if needed. +# DEFAULT_PROXY_PUBLIC_IP= +# FORCE_PROXY_PUBLIC_IP= + +# --- Proxied Services --- +# Comma-separated list of services whose streams should be proxied (e.g., realdebrid,alldebrid). +# DEFAULT_PROXY_PROXIED_SERVICES= +# FORCE_PROXY_PROXIED_SERVICES= + +# --- Disable Proxied Addons Feature --- +# If true, it disables the 'Proxied Addons' option. +FORCE_PROXY_DISABLE_PROXIED_ADDONS=false + +# --- Encrypt Streaming URLs --- +# Encrypt MediaFlow/StremThru URLs for better compatibility with external players. +ENCRYPT_MEDIAFLOW_URLS=true +ENCRYPT_STREMTHRU_URLS=true + + +# --- Forced Public proxy URL adjustments ---- +# If you'd like to force some adjustments to be made to the streaming urls generated by either proxy, you can do that here. +# This is useful when you want to use a local url for requests but have AIOStreams force the urls to use a specific host, port, and protocol. +# FORCE_PUBLIC_PROXY_HOST= +# FORCE_PUBLIC_PROXY_PORT= +# FORCE_PUBLIC_PROXY_PROTOCOL= + + +# ============================================================================== +# ADVANCED CONFIGURATION & LIMITS +# ============================================================================== + +# --- General Default Timeout --- +# Default timeout in milliseconds for all requests if not overridden by a specific timeout. +# Default: 15000 (15 seconds) +DEFAULT_TIMEOUT=15000 + +# --- Configuration Limits --- +# Maximum number of addons allowed per AIOStreams configuration. +MAX_ADDONS=15 +# Maximum number of groups allowed per AIOStreams configuration +MAX_GROUPS=20 +# Maximum number of keyword filters per AIOStreams configuration. +MAX_KEYWORD_FILTERS=30 +# Maximum number of condition filters per AIOStreams configuration +MAX_CONDITION_FILTERS=30 +# Maximum timeout (ms) an addon can be set to via override. +MAX_TIMEOUT=50000 +# Minimum timeout (ms) an addon can be set to via override. +MIN_TIMEOUT=1000 + + +# ============================================================================== +# RATE LIMIT CONFIGURATION +# ============================================================================== +# Configure rate limits to prevent abuse. Typically, defaults are fine. + +# --- Disable Rate Limits --- +# Set to true to disable all rate limits (NOT RECOMMENDED). +# Default: false +DISABLE_RATE_LIMITS=false + +# Window and Max requests refer to the maximum number of requests a user can make within a specific timeframe + +# --- Static File Serving --- +STATIC_RATE_LIMIT_WINDOW=5 +STATIC_RATE_LIMIT_MAX_REQUESTS=75 + +# --- User API --- +USER_API_RATE_LIMIT_WINDOW=5 +USER_API_RATE_LIMIT_MAX_REQUESTS=5 + +# --- Stream API --- +STREAM_API_RATE_LIMIT_WINDOW=5 +STREAM_API_RATE_LIMIT_MAX_REQUESTS=10 + +# --- Format API --- +FORMAT_API_RATE_LIMIT_WINDOW=5 +FORMAT_API_RATE_LIMIT_MAX_REQUESTS=30 + +# --- Catalog API --- +CATALOG_API_RATE_LIMIT_WINDOW=5 +CATALOG_API_RATE_LIMIT_MAX_REQUESTS=5 + +# --- Stremio Stream --- +STREMIO_STREAM_RATE_LIMIT_WINDOW=15 +STREMIO_STREAM_RATE_LIMIT_MAX_REQUESTS=10 + +# --- Stremio Catalog --- +STREMIO_CATALOG_RATE_LIMIT_WINDOW=5 +STREMIO_CATALOG_RATE_LIMIT_MAX_REQUESTS=30 + +# --- Stremio Manifest --- +STREMIO_MANIFEST_RATE_LIMIT_WINDOW=5 +STREMIO_MANIFEST_RATE_LIMIT_MAX_REQUESTS=5 + +# --- Stremio Subtitle --- +STREMIO_SUBTITLE_RATE_LIMIT_WINDOW=5 +STREMIO_SUBTITLE_RATE_LIMIT_MAX_REQUESTS=10 + +# --- Stremio Meta --- +STREMIO_META_RATE_LIMIT_WINDOW=5 +STREMIO_META_RATE_LIMIT_MAX_REQUESTS=15 + + +# ============================================================================== +# INACTIVE USER PRUNING +# ============================================================================== +# Automatically prune (delete) inactive user configurations. + +# --- Prune Interval --- +# How often to check for inactive users, in seconds. +# Default: 86400 (1 day) +PRUNE_INTERVAL=86400 + +# --- Prune Max Inactivity Days --- +# Maximum days of inactivity before a user's configuration is pruned. +# Set to -1 to disable +# Default: -1 +PRUNE_MAX_DAYS=-1 + + +# ============================================================================== +# EXTERNAL ADDON SERVICE URLs & TIMEOUTS +# ============================================================================== +# URLs and default timeouts for various external Stremio addons that AIOStreams can integrate with. +# Change these if you use self-hosted versions or if defaults become outdated. + +# ----------- COMET ------------ +COMET_URL=https://comet.elfhosted.com/ +# DEFAULT_COMET_TIMEOUT= +# Advanced: Override Comet hostname/port/protocol if COMET_URL is internal but needs to be public-facing. +# Only uncomment and set if needed. Usually, leave these commented. +# FORCE_COMET_HOSTNAME= +# FORCE_COMET_PORT= +# FORCE_COMET_PROTOCOL= # e.g., https + +# ----------- MEDIAFUSION ------------ +MEDIAFUSION_URL=https://mediafusion.elfhosted.com/ +# DEFAULT_MEDIAFUSION_TIMEOUT= +MEDIAFUSION_CONFIG_TIMEOUT=5000 # Timeout (ms) for /encrypt-user-data endpoint. +# API Password for self-hosted MediaFusion (for auto-configuration). +# MEDIAFUSION_API_PASSWORD= + +# ----------- JACKETTIO ------------- +JACKETTIO_URL=https://jackettio.elfhosted.com/ +# DEFAULT_JACKETTIO_TIMEOUT= +# Default indexers for auto-configuration with Jackettio. +DEFAULT_JACKETTIO_INDEXERS='["bitsearch", "eztv", "thepiratebay", "therarbg", "yts"]' +# Default StremThru URL used by Jackettio. +DEFAULT_JACKETTIO_STREMTHRU_URL=https://stremthru.13377001.xyz +# Self-hosted StremThru for Jackettio: +# DEFAULT_JACKETTIO_STREMTHRU_URL=http://stremthru:8080 +# Advanced: Override Jackettio hostname/port/protocol (similar to Comet). +# FORCE_JACKETTIO_HOSTNAME= +# FORCE_JACKETTIO_PORT= +# FORCE_JACKETTIO_PROTOCOL= + +# --------- STREMTHRU-STORE --------- +STREMTHRU_STORE_URL=https://stremthru.elfhosted.com/stremio/store/ +# DEFAULT_STREMTHRU_STORE_TIMEOUT= +# Advanced: Override StremThru Store hostname/port/protocol (similar to Comet). +# FORCE_STREMTHRU_STORE_HOST= +# FORCE_STREMTHRU_STORE_PORT= +# FORCE_STREMTHRU_STORE_PROTOCOL= +# --------- STREMTHRU-TORZ ----- +STREMTHRU_TORZ_URL=https://stremthru.elfhosted.com/stremio/torz/ +# DEFAULT_STREMTHRU_TORZ_TIMEOUT= +# Advanced: Override StremThru Torz hostname/port/protocol (similar to Comet). +# FORCE_STREMTHRU_TORZ_HOST= +# FORCE_STREMTHRU_TORZ_PORT= +# FORCE_STREMTHRU_TORZ_PROTOCOL= + +# --------- EASYNEWS+ ADDON --------- +EASYNEWS_PLUS_URL=https://b89262c192b0-stremio-easynews-addon.baby-beamup.club/ +# DEFAULT_EASYNEWS_PLUS_TIMEOUT= + +# -------- EASYNEWS++ ADDON --------- +EASYNEWS_PLUS_PLUS_URL=https://easynews-cloudflare-worker.jqrw92fchz.workers.dev/ +# DEFAULT_EASYNEWS_PLUS_PLUS_TIMEOUT= + +# --------- STREAMFUSION --------- +STREAMFUSION_URL=https://stream-fusion.stremiofr.com/ +# DEFAULT_STREAMFUSION_TIMEOUT= + +# --------- MARVEL UNIVERSE --------- +MARVEL_UNIVERSE_URL=https://addon-marvel.onrender.com/ +# DEFAULT_MARVEL_UNIVERSE_TIMEOUT= + +# --------- DC UNIVERSE --------- +DC_UNIVERSE_URL=https://addon-dc-cq85.onrender.com/ +# DEFAULT_DC_UNIVERSE_TIMEOUT= + +# --------- STAR WARS UNIVERSE --------- +STAR_WARS_UNIVERSE_URL=https://addon-star-wars-u9e3.onrender.com/ +# DEFAULT_STAR_WARS_UNIVERSE_TIMEOUT= + +# --------- ANIME KITSU --------- +ANIME_KITSU_URL=https://anime-kitsu.strem.fun/ +# DEFAULT_ANIME_KITSU_TIMEOUT= + +# --------- NUVIOSTREAMS --------- +NUVIOSTREAMS_URL=https://nuviostreams.hayd.uk/ +# DEFAULT_NUVIOSTREAMS_TIMEOUT= + +# --------- TMDB COLLECTIONS --------- +TMDB_COLLECTIONS_URL=https://61ab9c85a149-tmdb-collections.baby-beamup.club/ +# DEFAULT_TMDB_COLLECTIONS_TIMEOUT= + +# ----------- TORRENTIO ------------- +TORRENTIO_URL=https://torrentio.strem.fun/ +# DEFAULT_TORRENTIO_TIMEOUT= + +# -------- ORION STREMIO ADDON -------- +ORION_STREMIO_ADDON_URL=https://5a0d1888fa64-orion.baby-beamup.club/ +# DEFAULT_ORION_STREMIO_ADDON_TIMEOUT= + +# ------------ PEERFLIX -------------- +PEERFLIX_URL=https://peerflix-addon.onrender.com/ +# DEFAULT_PEERFLIX_TIMEOUT= + +# -------- TORBOX STREMIO ADDON -------- +TORBOX_STREMIO_URL=https://stremio.torbox.app/ +# DEFAULT_TORBOX_STREMIO_TIMEOUT= + +# -------- EASYNEWS ADDON (Standalone) -------- +EASYNEWS_URL=https://ea627ddf0ee7-easynews.baby-beamup.club/ +# DEFAULT_EASYNEWS_TIMEOUT= + +# ------------ DEBRIDIO ----------- +DEBRIDIO_URL=https://addon.debridio.com/ +# DEFAULT_DEBRIDIO_TIMEOUT= + +# ------------ DEBRIDIO TVDB ------------ +DEBRIDIO_TVDB_URL=https://tvdb-addon.debridio.com/ +# DEFAULT_DEBRIDIO_TVDB_TIMEOUT= + +# ------------ DEBRIDIO TMDB ------------ +DEBRIDIO_TMDB_URL=https://tmdb-addon.debridio.com/ +# DEFAULT_DEBRIDIO_TMDB_TIMEOUT= + +# ------------ DEBRIDIO TV ------------ +DEBRIDIO_TV_URL=https://tv-addon.debridio.com/ +# DEFAULT_DEBRIDIO_TV_TIMEOUT= + +# ------------ DEBRIDIO WATCHTOWER ------------ +DEBRIDIO_WATCHTOWER_URL=https://wt-addon.debridio.com/ +# DEFAULT_DEBRIDIO_WATCHTOWER_TIMEOUT= + +# ------------ OPENSUBTITLES V3 ------------ +OPENSUBTITLES_URL=https://opensubtitles-v3.strem.io/ +# DEFAULT_OPENSUBTITLES_TIMEOUT= + +# ------------ TORRENT CATALOGS ------------ +TORRENT_CATALOGS_URL=https://torrent-catalogs.strem.fun/ +# DEFAULT_TORRENT_CATALOGS_TIMEOUT= + +# ------------ RPDB CATALOGS ------------ +RPDB_CATALOGS_URL=https://1fe84bc728af-rpdb.baby-beamup.club/ +# DEFAULT_RPDB_CATALOGS_TIMEOUT= + +# ------------- DMM Cast ---------------- +# DEFAULT_DMM_CAST_TIMEOUT= + +# ----------- STREAMING CATALOGS --------- +STREAMING_CATALOGS_URL=https://7a82163c306e-stremio-netflix-catalog-addon.baby-beamup.club +# DEFAULT_STREAMING_CATALOGS_TIMEOUT= + +# ----------- ANIME CATALOGS ----------- +ANIME_CATALOGS_URL=https://1fe84bc728af-stremio-anime-catalogs.baby-beamup.club +# DEFAULT_ANIME_CATALOGS_TIMEOUT= + +# ----------- DOCTOR WHO UNIVERSE ----------- +DOCTOR_WHO_UNIVERSE_URL=https://new-who.onrender.com +# DEFAULT_DOCTOR_WHO_UNIVERSE_TIMEOUT= + +# ----------- WEBSTREAMR ----------- +WEBSTREAMR_URL=https://webstreamr.hayd.uk +# DEFAULT_WEBSTREAMR_TIMEOUT= +# ============================================================================== \ No newline at end of file diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..27bb7026cd46c967383c299c4b45e183115fdabe 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +*.png filter=lfs diff=lfs merge=lfs -text +*.ico filter=lfs diff=lfs merge=lfs -text diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000000000000000000000000000000000..4b39ce33266e11b2600231137e19fc66029cdc14 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,4 @@ +# These are supported funding model platforms + +github: Viren070 +ko_fi: Viren070 diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000000000000000000000000000000000000..c8e4887c2cce1b80864feb08747c9fe3c092db91 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,107 @@ +name: Bug report +description: Report a bug you encountered +title: 'bug: ' +labels: + - bug +body: + - type: dropdown + id: 'deployment' + attributes: + label: Deployment Method + description: How are you hosting the addon? + options: + - Public ElfHosted Instance + - Private ElfHosted Instance + - Cloudflare Workers + - VPS + - Other, specify at description + validations: + required: true + - type: input + id: 'addonVersion' + attributes: + label: Addon Version + description: What version of the addon are you using, or what was the commit that your deployment was built off of? + placeholder: v1.13.8 + validations: + required: true + - type: dropdown + id: 'bugArea' + attributes: + label: Bug Area + description: Select what area of the addon this issue affects + options: + - Deploying + - Configuring + - Installing + - Obtaining streams. + - Playback + validations: + required: true + - type: input + id: 'deviceInfo' + attributes: + label: Device/Browser/OS/Stremio Version + description: Details about the device this issue occurs on. Leave blank if not applicable (e.g. error within addon) + placeholder: Windows on Stremio 5 Beta + - type: textarea + id: 'bugDescription' + attributes: + label: Bug Description / Steps to Reproduce + description: Precisely describe the bug you encountered and the steps to reproduce it. Avoid vague descriptions. Include relevant details - such as if you were using MediaFlow. + validations: + required: true + - type: textarea + id: 'expectedBehaviour' + attributes: + label: Expected Behavior + description: Describe what you expected to happen. + validations: + required: true + - type: textarea + id: 'configExport' + attributes: + label: Configuration Export + description: Provide an export of your configuration with credentials excluded (make sure to double check it has nothing sensitive in it like custom addon URLs or overriden URLs etc. + validations: + required: true + - type: textarea + id: 'screenshots' + attributes: + label: Screenshots + description: If applicable, add screenshots of the bug and your configuration. + - type: checkboxes + id: 'debuggingChecklist' + attributes: + label: Debugging Checklist + description: Confirm you have included at least some of the following debugging information. If you haven't, please do so before submitting the issue. + options: + - label: >- + If applicable, I have included server logs + required: false + - label: >- + If applicable, I have included MediaFlow logs + required: false + - type: checkboxes + id: 'issueChecklist' + attributes: + label: Issue Checklist + description: Confirm that you have understood and followed these requirements + options: + - label: >- + I have written a short but informative title that clearly describes the issue. + required: true + - label: >- + I have given clear and descriptive steps to reproduce the issue. + required: true + - label: >- + I have checked open and closed issues and confirmed that this is not a duplicate of another issue. + required: true + - label: >- + I have filled out all of the requested information adequately. + required: true + - label: >- + I am using the [latest version](https://github.com/Viren070/AIOStreams/releases/latest). + required: true + + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000000000000000000000000000000000..7a4900b67cea37a0a1815e614cc169f330aa408b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,6 @@ +blank_issues_enabled: false +contact_links: + - name: AIOStreams Community Support + url: https://github.com/Viren070/AIOStreams/discussions/categories/help + about: If you need help, create a new post here with your question. + diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000000000000000000000000000000000000..d318b60d9a5a87b1d106615232944ed5a06ac11c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,39 @@ +name: Feature Request +description: Suggest an idea for the project +title: 'feature request: ' +labels: + - feature request +body: + - type: checkboxes + id: '1' + attributes: + label: Checklist + description: >- + Please check the following before submitting a feature request. If you + are unable to check all the boxes, please provide more information in the + description. + options: + - label: >- + I checked that this feature has not been requested before + required: true + - label: >- + I checked that this feature is not in the "Not planned" list + required: true + - label: >- + This feature will benefit the majority of users + - type: textarea + id: '2' + attributes: + label: Problem Description / Use Case + description: >- + Provide a detailed description of the problem you are facing or the use case you have in mind. + validations: + required: true + - type: textarea + id: '3' + attributes: + label: Proposed Solution + description: >- + Provide a detailed description of the solution you'd like to see. If you have any ideas on how to implement the feature, please include them here. + validations: + required: true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000000000000000000000000000000000000..fe50d5ac0a664c5dab3efe598f15dae0fd4fac61 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,27 @@ +name: Build Addon + +on: + pull_request: + branches: + - main + paths: + - 'packages/**' + - 'package-lock.json' + - 'package.json' + - 'tsconfig.json' + - 'tsconfig.base.json' + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.x' + + - name: Install Dependencies + run: npm ci + - name: Build Addon + run: npm run build diff --git a/.github/workflows/deploy-docker.yml b/.github/workflows/deploy-docker.yml new file mode 100644 index 0000000000000000000000000000000000000000..cdab66918deb83cad28cfb98c949892009967463 --- /dev/null +++ b/.github/workflows/deploy-docker.yml @@ -0,0 +1,197 @@ +name: Build and Deploy Docker Images + +on: + workflow_dispatch: + inputs: + ref: + description: Git Ref + required: true + type: string + +jobs: + build: + name: build + runs-on: ubuntu-latest + permissions: + packages: write + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{inputs.ref}} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Calculate Image Tags + env: + INPUT_REF: ${{inputs.ref}} + run: | + declare TAGS="" + case "${INPUT_REF}" in + v[0-9]*.[0-9]*.[0-9]*) + TAGS="${INPUT_REF}" + if [[ "$(git rev-parse origin/main)" = "$(git rev-parse "${INPUT_REF}")" ]]; then + TAGS="${TAGS} latest" + fi + CHANNEL="stable" + ;; + [0-9]*.[0-9]*.[0-9]*-nightly) + TAGS="${INPUT_REF} nightly" + CHANNEL="nightly" + ;; + *) + echo "Invalid Input Ref: ${INPUT_REF}" + exit 1 + esac + + if [[ -z "${TAGS}" ]]; then + echo "Empty Tags!" + exit 1 + fi + + { + echo 'DOCKER_IMAGE_TAGS<> "${GITHUB_ENV}" + + echo "TAGS=${TAGS}" >> "${GITHUB_ENV}" + + echo "CHANNEL=${CHANNEL}" >> "${GITHUB_ENV}" + + cat "${GITHUB_ENV}" + + - name: Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Generate metadata + run: | + node scripts/generateMetadata.js --channel=${{env.CHANNEL}} + + - name: Build & Push + uses: docker/build-push-action@v6 + with: + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64,linux/arm64 + push: true + context: . + file: ./Dockerfile + tags: ${{env.DOCKER_IMAGE_TAGS}} + + - name: Send Discord Notification + env: + CHANNEL: ${{env.CHANNEL}} + TAGS: ${{env.TAGS}} + run: | + # Determine role based on channel + if [ "$CHANNEL" = "stable" ]; then + ROLE_ID="<@&1384627130272452638>" + COLOR=5763719 # Green + TITLE="🎉 Stable Release" + else + ROLE_ID="<@&1384627462155272242>" + COLOR=15844367 # Orange + TITLE="🌙 Nightly Build" + fi + + # Format tags for display (each tag on a new line with bullet points) + FORMATTED_TAGS="" + for tag in $TAGS; do + if [ -z "$FORMATTED_TAGS" ]; then + FORMATTED_TAGS="• \`$tag\`" + else + FORMATTED_TAGS="$FORMATTED_TAGS\n• \`$tag\`" + fi + done + + # Get current timestamp in ISO 8601 format UTC + TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + # Create JSON payload file + cat << EOF > discord_payload.json + { + "content": "$ROLE_ID", + "embeds": [ + { + "title": "$TITLE - Docker Images Published", + "description": "New Docker images have been built and pushed to registries.", + "color": $COLOR, + "fields": [ + { + "name": "📦 Channel", + "value": "• \`$CHANNEL\`", + "inline": true + }, + { + "name": "🏷️ Tags Published", + "value": "$FORMATTED_TAGS", + "inline": false + }, + { + "name": "📍 View Images", + "value": "[Docker Hub](https://hub.docker.com/r/viren070/aiostreams) · [GHCR](https://github.com/Viren070/AIOStreams/pkgs/container/aiostreams)", + "inline": false + }, + { + "name": "🔗 View Build", + "value": "[GitHub Actions](https://github.com/Viren070/aiostreams/actions/runs/${{ github.run_id }})", + "inline": false + } + ], + "footer": { + "text": "AIOStreams CI", + "icon_url": "https://github.com/Viren070.png" + }, + "timestamp": "$TIMESTAMP" + } + ] + } + EOF + + echo "Sending to Discord:" + cat discord_payload.json + + # Send payload to Discord and capture response and status code + http_response=$(curl -s -w "\n%{http_code}" -H "Content-Type: application/json" \ + -X POST \ + -d @discord_payload.json \ + "${{ secrets.DISCORD_WEBHOOK_URL }}") + + http_body=$(echo "$http_response" | sed '$d') + http_code=$(echo "$http_response" | tail -n1) + + echo "HTTP Status Code: $http_code" + echo "Discord Response Body: $http_body" + + if [ "$http_code" != "204" ]; then + echo "Error sending to Discord webhook." + exit 1 + fi + + echo "Message sent successfully!" diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000000000000000000000000000000000000..308c562280f2d5d2048bef8229bc0a2a5c827dd7 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,64 @@ +name: Nightly Builds + +on: + push: + branches: [main] + paths: + - 'packages/**' + - 'package-lock.json' + - 'package.json' + - 'tsconfig.json' + - 'tsconfig.base.json' + - 'Dockerfile' + +jobs: + release: + name: release + if: ${{ github.ref == 'refs/heads/main' }} + runs-on: ubuntu-latest + permissions: + actions: write + contents: write + pull-requests: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Generate timestamp tag + id: gen_tag + run: | + TIMESTAMP=$(date '+%Y.%m.%d.%H%M-nightly') + echo "tag_name=$TIMESTAMP" >> $GITHUB_OUTPUT + + - name: Create git tag + env: + TAG_NAME: ${{ steps.gen_tag.outputs.tag_name }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag $TAG_NAME + git push origin $TAG_NAME + + - name: Get commit message + id: commit_msg + run: | + COMMIT_MSG=$(git log -1 --pretty=%B) + echo "commit_msg<> $GITHUB_OUTPUT + echo "$COMMIT_MSG" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Create GitHub prerelease + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "${{ steps.gen_tag.outputs.tag_name }}" \ + --repo "${GITHUB_REPOSITORY}" \ + --title "${{ steps.gen_tag.outputs.tag_name }}" \ + --notes "${{ steps.commit_msg.outputs.commit_msg }}" \ + --prerelease + + - name: Trigger Docker Image Publish + env: + GH_TOKEN: ${{ github.token }} + run: | + gh workflow run --repo ${GITHUB_REPOSITORY} deploy-docker.yml -f ref=${{ steps.gen_tag.outputs.tag_name }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000000000000000000000000000000000..eac1f66a330724342a949613cb09eb1bf6961989 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,31 @@ +name: Release + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + release: + name: release + if: ${{ github.ref == 'refs/heads/main' }} + runs-on: ubuntu-latest + permissions: + actions: write + contents: write + pull-requests: write + steps: + - name: Release + id: release + uses: google-github-actions/release-please-action@v4 + with: + token: ${{ secrets.RELEASE_PLEASE_TOKEN }} + + - name: Trigger Docker Image Publish + if: ${{ steps.release.outputs['release_created'] }} + env: + GH_TOKEN: ${{ github.token }} + TAG_NAME: ${{ steps.release.outputs.tag_name }} + run: | + gh workflow run --repo ${GITHUB_REPOSITORY} deploy-docker.yml -f ref=${TAG_NAME} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..4150dcb29bc515d9de048fade7a78d9f54d366d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +*.tsbuildinfo +out/ +.next/ +next-env.d.ts +.wrangler/ +.env +metadata.json +data/ \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000000000000000000000000000000000000..8ccc93db670d0a0fdbfff7b22c9a7abed2f4cd58 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "endOfLine": "lf" +} diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..bd1ba1cb30c2240afdeec57f64ca46f044c3ff4b --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "2.4.2" +} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000000000000000000000000000000000..e5f9ed3aa480947853783c343b73f6843bef7e09 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "debug addon", + "type": "node", + "request": "launch", + "program": "src/server.ts", + "localRoot": "${workspaceFolder}/packages/addon", + "runtimeExecutable": "tsx", + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "skipFiles": ["/**", "${workspaceFolder}/node_modules/**"] + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000000000000000000000000000000000..1b6457c5cacb4b180f03a70d7893fa88140139b9 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000000000000000000000000000000000..0628cd1be25efaea6a070ec4fcc41e15df580bab --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,665 @@ +# Changelog + +## [2.4.2](https://github.com/Viren070/AIOStreams/compare/v2.4.1...v2.4.2) (2025-06-27) + + +### Bug Fixes + +* **debridio:** add Italy option ([7774310](https://github.com/Viren070/AIOStreams/commit/77743105de53e5de76ef4f4224883d57cc559bee)) + +## [2.4.1](https://github.com/Viren070/AIOStreams/compare/v2.4.0...v2.4.1) (2025-06-27) + + +### Bug Fixes + +* add 'Clip' as valid type for Trailer ([025f622](https://github.com/Viren070/AIOStreams/commit/025f622002c1409ed6d0e997ac4ee3d857bf10ba)) +* adjust defaults ([78d4d60](https://github.com/Viren070/AIOStreams/commit/78d4d604ca732d4f96ba9927724c7935e9a956d8)) + +## [2.4.0](https://github.com/Viren070/AIOStreams/compare/v2.3.2...v2.4.0) (2025-06-27) + + +### Features + +* add always precache option ([d4ff4a2](https://github.com/Viren070/AIOStreams/commit/d4ff4a2c0c913e7c6e3754ecb9fd72b45b1f864d)) +* add slice function to stream expression ([321b325](https://github.com/Viren070/AIOStreams/commit/321b32584014d20d8e78f66b4cef313d0cd22f0c)) +* add USA TV and Argentina TV ([e29800a](https://github.com/Viren070/AIOStreams/commit/e29800a0ab159940cafa11f0d69d4bc3f46c918c)) +* allow disabling user agent ([305ebd8](https://github.com/Viren070/AIOStreams/commit/305ebd84c8040866fc45fe1879921e3a7bb93997)) + + +### Bug Fixes + +* apply filters and precomputation to streams after each group fetch ([78144d0](https://github.com/Viren070/AIOStreams/commit/78144d02135072681237eae8bd5b11bf8fc3f991)) +* fix filtering ([32b1c3c](https://github.com/Viren070/AIOStreams/commit/32b1c3c3b384fad4109520c5730e8076cb2c6ebc)) +* include headers in logs ([4b9f268](https://github.com/Viren070/AIOStreams/commit/4b9f268b8f399a30f47d2140ecd9afd2856f284a)) +* pass specified services in DebridioPreset ([e264db6](https://github.com/Viren070/AIOStreams/commit/e264db6fc57ce58da476deadef5b3684228eba73)) +* set excludeUncached to false during pre-caching ([62aed42](https://github.com/Viren070/AIOStreams/commit/62aed42b07adf24c42cd5ac6c3a43d323e210890)) +* skip failed addons on manifest fetch ([cada0de](https://github.com/Viren070/AIOStreams/commit/cada0de63ac8602adabd2af2b04015f87697668e)) +* **streamfusion:** remove service requirement, enable torrent providers, lower limits ([3d856a2](https://github.com/Viren070/AIOStreams/commit/3d856a252dd77d27c81d4539ad848af95f1ca0dd)) + +## [2.3.2](https://github.com/Viren070/AIOStreams/compare/v2.3.1...v2.3.2) (2025-06-24) + + +### Bug Fixes + +* only show warning when no idPrefixes are given ([832deae](https://github.com/Viren070/AIOStreams/commit/832deaed64ea977d493d3815a58f7528aa7b03e1)) +* remove folderSize from downloadable streams ([baf4c46](https://github.com/Viren070/AIOStreams/commit/baf4c461682fae5dd30e809897498f5d5a62482b)) +* remove length requirement for string properties in ManifestSchema ([b009511](https://github.com/Viren070/AIOStreams/commit/b00951144925f174a30b0e2858f963c6cbee3837)) + +## [2.3.1](https://github.com/Viren070/AIOStreams/compare/v2.3.0...v2.3.1) (2025-06-24) + + +### Bug Fixes + +* set idPrefixes to undefined for new resources too ([97894be](https://github.com/Viren070/AIOStreams/commit/97894be562ef28a4ddd3887093481f60d4e6b3f1)) + +## [2.3.0](https://github.com/Viren070/AIOStreams/compare/v2.2.1...v2.3.0) (2025-06-24) + + +### Features + +* add `EXPOSE_USER_COUNT` set to false by default ([3e9820b](https://github.com/Viren070/AIOStreams/commit/3e9820bc3de6bca391259026523a07d63e8c90e7)) +* add more fields to bingeGroup ([f53c8ca](https://github.com/Viren070/AIOStreams/commit/f53c8cab1f3465ebf639e035824b7b3c2e069203)) +* add tmdb addon ([96bf1de](https://github.com/Viren070/AIOStreams/commit/96bf1de8bd5a44b10bc3ada6dd8e1cd5c11b1d2e)) +* add torrentsdb ([aebef33](https://github.com/Viren070/AIOStreams/commit/aebef33432d9d21c5c90577da73fc21803432b83)) +* improve parsing for debridio tv ([320dbb2](https://github.com/Viren070/AIOStreams/commit/320dbb29020cc499c4d806f904feb0f4d45730d3)) + + +### Bug Fixes + +* add discovery+ option to streaming catalogs ([3b47339](https://github.com/Viren070/AIOStreams/commit/3b473393e201c32afbe5301a1d5ff9026b1f5718)) +* make sorting in deduplicator consistent ([b15efd5](https://github.com/Viren070/AIOStreams/commit/b15efd5529d1246e538b25797930bcbab874b73b)) +* only extract folder size if difference is large enough ([3d7808b](https://github.com/Viren070/AIOStreams/commit/3d7808b92cde840dac242ad8f52fd671a02199fb)) +* set idPrefixes to undefined when an addon for that resource doesn't provide it ([f3ff7c5](https://github.com/Viren070/AIOStreams/commit/f3ff7c53d2ad4d6c809c1f27d5be3177969f4841)) + +## [2.2.1](https://github.com/Viren070/AIOStreams/compare/v2.2.0...v2.2.1) (2025-06-22) + + +### Bug Fixes + +* add catalog and meta resources to mediafusion preset ([ee492e2](https://github.com/Viren070/AIOStreams/commit/ee492e2b218bbad813426368ec7f30ecedc79e59)) +* add min and max constraints validation for options in config ([675eaf0](https://github.com/Viren070/AIOStreams/commit/675eaf0b6340ed52b2d0267442a24448048b04cb)) +* allow null values in options array for manifest extras ([99d66e8](https://github.com/Viren070/AIOStreams/commit/99d66e835c421af0ad6c86500dc38f93b8d85ca3)) +* correct property name from 'seeders' to 'seeder' in includedReasons ([912fa49](https://github.com/Viren070/AIOStreams/commit/912fa4910e097c2ec1424ac360482d56b51e6022)) +* **frontend:** add sensible steps and remove min max constraint in NumberInput for TemplateOption ([1119721](https://github.com/Viren070/AIOStreams/commit/1119721054d77cf7729ed27cf7b4593237bc3675)) + +## [2.2.0](https://github.com/Viren070/AIOStreams/compare/v2.1.0...v2.2.0) (2025-06-22) + + +### Features + +* add 'not' function to BaseConditionParser for filtering streams ([44d2c4c](https://github.com/Viren070/AIOStreams/commit/44d2c4c8708dae8c07370b16d6ca5e7750369ddb)) +* add logging for include details/reasons during filtering ([9de901d](https://github.com/Viren070/AIOStreams/commit/9de901d22b6e3c8ae515f41fccb63447283492b8)) +* add merge function in BaseConditionParser ([f223368](https://github.com/Viren070/AIOStreams/commit/f22336800ae952b6f3e703006075b4360da94524)) +* add regexMatchedInRange function to BaseConditionParser ([cc2f5f7](https://github.com/Viren070/AIOStreams/commit/cc2f5f7608f8dbd3f9d52031e0e6da377d9031b0)) +* add support for required and preferred filter conditions ([d9281bd](https://github.com/Viren070/AIOStreams/commit/d9281bd978f186a50f04ead98c6fcca41bb32bfb)) +* adjust wording and naming of expression/condition parser ([a06aea9](https://github.com/Viren070/AIOStreams/commit/a06aea923cdad1540d2edb858ce1de1412d5dd11)) +* apply filter conditions last ([41d507a](https://github.com/Viren070/AIOStreams/commit/41d507a679af598fbfb4e9391688f1dc70613a5c)) +* enable addition and subtraction in base Parser ([c4e65f8](https://github.com/Viren070/AIOStreams/commit/c4e65f83b3a0157ec87b775d8967017bfd425ee8)) +* handle missing debridio api key for clear errors ([ad4a51c](https://github.com/Viren070/AIOStreams/commit/ad4a51caa11c4447cff59ce6f07cf2a870d8f297)) +* improve condition parser functions to support multiple parameters ([110146c](https://github.com/Viren070/AIOStreams/commit/110146c088ceebc1472eb8f5442d966faabb0278)) +* loop through optionMetas to ensure new options are validated too and ignore individual errors from presets when necessary ([2ffc82c](https://github.com/Viren070/AIOStreams/commit/2ffc82c864878e0d212bbfa6582044555ba6fc78)) +* support multiple regex names in regexMatched function ([455f430](https://github.com/Viren070/AIOStreams/commit/455f4307fdee14098c9b3766322dd47682e7d270)) +* use title modifier for title in light gdrive formatter ([e542989](https://github.com/Viren070/AIOStreams/commit/e542989038ad253a098cce41e9480a2927c7514a)) + + +### Bug Fixes + +* actually use the streams after applying filter conditions ([4bc0259](https://github.com/Viren070/AIOStreams/commit/4bc0259dad92d636f338aae4b0b4af0cb0666d2a)) +* allow empty regex names in ParsedStreamSchema and AIOStream ([cf39cdf](https://github.com/Viren070/AIOStreams/commit/cf39cdfee14d41bb67e9a8bff2d720e7d33cffc5)) +* **debridio:** update preset to support new version ([#213](https://github.com/Viren070/AIOStreams/issues/213)) ([23e8078](https://github.com/Viren070/AIOStreams/commit/23e8078b3f5aaa7554857e3fefd0a49ba4d2f6b7)) +* ensure comparison checks for deduplications are carried out when needed ([c7bb0c8](https://github.com/Viren070/AIOStreams/commit/c7bb0c8bee69b82688f08e005985b3a8e6436048)) +* extract streamExpressionMatched from AIOStream parser ([7e65738](https://github.com/Viren070/AIOStreams/commit/7e657380d7f0c2cfd01b3367e9bd876465a710d8)) +* fallback to parent get filename method when filename not found in description for mediafusion ([cfb5977](https://github.com/Viren070/AIOStreams/commit/cfb59771fc9bc23b3b982bfbfce75436ca4f37fa)) +* fallback to using parsed properties from folder when undefined in file and correctly merge array properties ([8eb9b7a](https://github.com/Viren070/AIOStreams/commit/8eb9b7a92efbbf76991d532b828ab48070f13b6d)) +* filter out uuid in filtered export ([bd21b36](https://github.com/Viren070/AIOStreams/commit/bd21b364d27cbcc72a464c14eb372ffbd8e33a51)) +* **formatters:** make title modifier return consistent cases with each word titled ([3e6b45a](https://github.com/Viren070/AIOStreams/commit/3e6b45a554bdc15c473e64bc22cee5d0b8c7de7f)) +* handle invalid addon password error separately for catalog API to be more clear ([a2275cc](https://github.com/Viren070/AIOStreams/commit/a2275cce1bcdf37ba7dd65016a544b222e8ee3a4)) +* ignore port in host check ([e73be92](https://github.com/Viren070/AIOStreams/commit/e73be9298d82dbb2a9b492cb636c5bd5d82fd1e0)) +* normalize case sensitivity in condition parser filters for resolutions, qualities, encodes, types, visualTags, audioTags, audioChannels, and languages ([87d2ffb](https://github.com/Viren070/AIOStreams/commit/87d2ffba7f8b8fc3e7ce70372e4f4932aa86bbc5)) +* only form keyword patterns when length of array is greater than 0 ([9136694](https://github.com/Viren070/AIOStreams/commit/91366943bb813fd99cd6c4775952c8bc5af9d54f)) +* rename 'not' function to 'negate' to avoid conflicts ([8477584](https://github.com/Viren070/AIOStreams/commit/8477584f9e65aae796c7aa432ffdbc36212f3260)) +* update credentials field to allow empty strings ([c006321](https://github.com/Viren070/AIOStreams/commit/c00632146d4980ec5a640b54eeb3bbd63f999189)) + +## [2.1.0](https://github.com/Viren070/AIOStreams/compare/v2.0.1...v2.1.0) (2025-06-20) + + +### Features + +* allow disabling pruning and disable it by default ([85c0ec1](https://github.com/Viren070/AIOStreams/commit/85c0ec1b5436af1115f97149f87b41aba41fe3ff)) +* allow specifying providers in torrentio ([8e5f4b5](https://github.com/Viren070/AIOStreams/commit/8e5f4b520cbcf472598a955039dc33bdda676bd5)) +* enable conditional operators in parser, allowing ternary statements in filter conditions ([eb6edfc](https://github.com/Viren070/AIOStreams/commit/eb6edfc3f1cb1c6a79400d2311cbe8811f1d284c)) +* extract folder size for stremthru torz ([e775562](https://github.com/Viren070/AIOStreams/commit/e775562e3c736fb4d652a161a7e29f3fcd28be1f)) +* improve cache stats logging ([d47eee0](https://github.com/Viren070/AIOStreams/commit/d47eee002112f6330d1b74920199bface0105eed)) +* improve save install page ([a115e59](https://github.com/Viren070/AIOStreams/commit/a115e5906f568b630425276cf321a931b37aadf1)) +* only add foldername if different and parse info from both folder and filename ([6eed23f](https://github.com/Viren070/AIOStreams/commit/6eed23f445d017ae6d18e9874978a8874350d006)) + + +### Bug Fixes + +* add enableCollectionFromMovie option to TMDB Collections ([71d9fe0](https://github.com/Viren070/AIOStreams/commit/71d9fe093cad1566172206d0a87662358bd446a6)), closes [#194](https://github.com/Viren070/AIOStreams/issues/194) +* add stream as supported resource for TMDB Collections ([d2ef215](https://github.com/Viren070/AIOStreams/commit/d2ef2154fda902900751c47527ff52390506bd54)) +* add validation to pruneUsers method to ensure negative maxDays input is not used ([6b597b3](https://github.com/Viren070/AIOStreams/commit/6b597b31306fbe42d4104a71f9f330db32d9cda5)) +* adjust idPrefixes handling to improve compatibility in most cases ([7fa8ba7](https://github.com/Viren070/AIOStreams/commit/7fa8ba71fbb682d077fb5c8ccfbadfb0050bea80)) +* change all debrid service name to AllDebrid ([a89cdca](https://github.com/Viren070/AIOStreams/commit/a89cdca583e50c3bf66432bbb721797954323ba6)), closes [#208](https://github.com/Viren070/AIOStreams/issues/208) +* convert live types to http for webstreamr ([64977ca](https://github.com/Viren070/AIOStreams/commit/64977caeffe2cb6b95714916c14bfa006502c386)) +* don't pass encoded_user_data header if URL is overriden ([ed2c0f5](https://github.com/Viren070/AIOStreams/commit/ed2c0f5800592c6bf140dc1f9ea8bdb9057d1d55)) +* exit auto prune when max days is less than 0 ([ee1ddc0](https://github.com/Viren070/AIOStreams/commit/ee1ddc07389d01b382f19fa46e434ca93f41d3e8)) +* explicitly check for unknown in version and default to 0.0.0 for manifest response ([8664e00](https://github.com/Viren070/AIOStreams/commit/8664e004e2553ffb675131488a4c4eab70ede7b3)), closes [#198](https://github.com/Viren070/AIOStreams/issues/198) +* extract size for nuviostreams ([ebbd7ec](https://github.com/Viren070/AIOStreams/commit/ebbd7ec3b24d11abc2806e9edbd2aeaee45faa09)) +* fix error handling in config modal ([5182a07](https://github.com/Viren070/AIOStreams/commit/5182a07ac49d1aa79f515d72c71c7494a27866dd)) +* **frontend:** filter out proxy credentials and url in export when exclude credentials is true ([3c31939](https://github.com/Viren070/AIOStreams/commit/3c319391b86e6efa530aab5b8cd04ad9341867d1)) +* handle empty addon name in stream results and update description for addon name field ([5612140](https://github.com/Viren070/AIOStreams/commit/5612140ffee8b8e8804d36efdfd22e6f110b32ef)) +* handle pikpak credentials for mediafusion ([eee444f](https://github.com/Viren070/AIOStreams/commit/eee444f376136ed04257187c4bb1ddc05f05a3f5)) +* include addon name in error messages for invalid manifest URLs ([abf99c1](https://github.com/Viren070/AIOStreams/commit/abf99c1768f3cf86d6f58ec256705ae235f9d8f9)) +* make types optional in ManifestSchema ([5281756](https://github.com/Viren070/AIOStreams/commit/5281756c78e362d3c48cc4469c07c17df9350d9c)) +* make types required and provide array based on resources object array ([01cf37f](https://github.com/Viren070/AIOStreams/commit/01cf37f8340a9fd130ecb19c93dc7a9863eab012)) +* manually override type to http for watchtower and nuviostreams ([1fb00a4](https://github.com/Viren070/AIOStreams/commit/1fb00a4317605ee9a5d0da73a4b363bf08b9bf6f)) +* map defaultProviders to their values in TorrentioPreset configuration ([9b04403](https://github.com/Viren070/AIOStreams/commit/9b044037d38b46270e23172914d1e35f72f51e1f)) +* normalize version check ([#206](https://github.com/Viren070/AIOStreams/issues/206)) ([05cc116](https://github.com/Viren070/AIOStreams/commit/05cc116fafc9ba6d0f40b7e10938e2505085ea10)) +* only add to idPrefixes if not null ([6fb5f7b](https://github.com/Viren070/AIOStreams/commit/6fb5f7b841872b0261023766c2472c7f5201be95)) +* overlapping snippets modal ([#202](https://github.com/Viren070/AIOStreams/issues/202)) ([195da69](https://github.com/Viren070/AIOStreams/commit/195da69f19ca8e15acd000420c1187fd4116de1f)) +* prevent title from being parsed for info ([f8b2e2d](https://github.com/Viren070/AIOStreams/commit/f8b2e2d66ce07ae4342db974ed6f169c0474d1d2)) +* remove idPrefixes from top level manifest ([908b4ff](https://github.com/Viren070/AIOStreams/commit/908b4ffa399439ab3f9428357b30a6ae7bc0f29d)) +* remove outdated decoding of credentials causing issues with some credentials ([609931e](https://github.com/Viren070/AIOStreams/commit/609931e5318c8b6d782cc04cf6a6691269bba287)) +* remove timestamp from cache stats ([509e3bd](https://github.com/Viren070/AIOStreams/commit/509e3bd2098f10d041a2a776d9b4099567fe4370)) +* remove unused method handler for unsupported HTTP methods ([7405d27](https://github.com/Viren070/AIOStreams/commit/7405d272ab79321d8b1e97ee4bcd1a2b2f8c12a5)) +* rename web_dl to webdl in stremthru store ([3fb57c5](https://github.com/Viren070/AIOStreams/commit/3fb57c5d04e23585e71a5e9f0643735f675671c7)) +* simplify and fix configuration generation for services and providers in TorrentioPreset ([cfafeec](https://github.com/Viren070/AIOStreams/commit/cfafeecda3591c342d5f2aeb756fde4adc536024)) +* try explicitly setting idPrefixes to an empty array ([c16060f](https://github.com/Viren070/AIOStreams/commit/c16060f7a5ffa5b5142fe5a0753046748f682f0a)) +* try removing types ([10c4e2d](https://github.com/Viren070/AIOStreams/commit/10c4e2d51f7a0ba05d6214da1c848b66ec9237ca)) +* try setting idPrefixes to null ([a5f32df](https://github.com/Viren070/AIOStreams/commit/a5f32df451c7ba73438322c217ffa431e9a84125)) +* update descriptions for filtering options in menu component to clarify behavior ([67bb204](https://github.com/Viren070/AIOStreams/commit/67bb204362951ef3998c690ba1c0055c1a4cc12b)) +* use password type where necessary ([0a12d33](https://github.com/Viren070/AIOStreams/commit/0a12d335c34b8181c9ac849bed623ea77b43a84c)) + +## [2.0.1](https://github.com/Viren070/AIOStreams/compare/v2.0.0...v2.0.1) (2025-06-19) + + +### Bug Fixes + +* add audio channel to skipReasons ([ef1763c](https://github.com/Viren070/AIOStreams/commit/ef1763cbe60fe5c279138a152e1a8d677f30f0ce)) +* correctly handle overriding URL for mediafusion ([9bf3838](https://github.com/Viren070/AIOStreams/commit/9bf3838732542c5cac1ef189cd5afefc13fe0204)) +* ensure instances is defined ([7e00e32](https://github.com/Viren070/AIOStreams/commit/7e00e32bbe93a5610d4f94bc3d78a78e48d32c6b)) + +## [2.0.0](https://github.com/Viren070/AIOStreams/compare/v1.22.0...v2.0.0) (2025-06-18) + +### 🚀 The Big Upgrades in v2 🚀 + +- **Beyond Just Streams:** AIOStreams v2 now supports more than just stream addons! You can integrate **any supported Stremio addon type**, including **Catalog addons, Subtitle addons, and even Addon Catalog addons** into your single AIOStreams setup. Now it truly can do _everything_! +- **100% Addon Compatibility:** That's right! AIOStreams v2 is designed to work with **100% of existing Stremio addons** that adhere to the Stremio addon SDK. +- **Sleek New UI**: The entire interface has been redesigned for a more modern, intuitive, and frankly, beautiful configuration experience. + +_This new configuration page was only possible thanks to [Seanime](https://seanime.rahim.app), a beautiful application for anime_ + +--- + +### ✨ Feature Deep Dive - Get Ready for Control! ✨ + +This rewrite has paved the way for a TON of new features and enhancements. Here’s a rundown: + +**🛠️ Configuration Heaven & Built-in Marketplace:** + +- The configuration page now features a **built-in marketplace for addons**. This makes it super easy to discover and add new addons, displaying their supported resources (streams, catalogs, subtitles, etc.), Debrid services they integrate with, and stream types (torrent, http, usenet, live etc.). +- You can now **quickly enable or disable individual addons** within your AIOStreams setup without fully removing them. This is particularly useful because tools like StremThru Sidekick wouldn't be able to detect or manage the individual addons _inside_ your AIOStreams bundle, but with AIOStreams' own UI, you have that fine-grained control. +- Remember, the marketplace is just there for convenience. You can still add any addon you want using the 'Custom' addon at the top of the marketplace and use an addons manifest URL to add it to AIOStreams. + +**📚 Supercharged Catalog Management:** + +- **Total Catalog Control:** Reorder your catalogs exactly how you want them, **regardless of which addon they originate from!** Mix and match to your heart's content. +- **Granular Management:** Enable/disable specific catalogs, apply **shuffling** to individual catalogs - and control how long a shuffle lasts, **rename catalogs** for a personalized touch, and you can even **disable certain catalogs from appearing on your Stremio home page**, having them only show up in the Discover section for a cleaner look! +- **Universal RPDB Posters:** Ever wanted those sleek posters with ratings on _any_ catalog? Now you can! Apply **RPDB posters (with ratings) to any addon that uses a supported ID type (like IMDB or TMDB ID), even if the original addon doesn't support RPDB itself.** Yes, this means you could add RPDB posters to Cinemeta if you wanted! +- **Why not just use other tools like StremThru Sidekick or the Addon Manager for catalogs?** + - **Broader Compatibility:** Both StremThru Sidekick and Addon Manager are primarily limited to managing addons _for Stremio itself_. AIOStreams’ catalog features can be utilized by _any application_ that supports Stremio addons, not just Stremio. + - **True Internal Reordering:** Neither of those tools supports reordering catalogs _within an addon itself_. Since AIOStreams presents all its combined catalogs as coming from _one addon_, those tools wouldn't be able to reorder the catalogs _inside_ your AIOStreams setup. AIOStreams gives you that crucial internal control. + - **Safety:** AIOStreams does **not** make use of the Stremio API for its core functionality. This means it operates independently and **cannot break your Stremio account** or interfere with its settings. + +**🌐 Expanded Addon Ecosystem:** + +- The built-in marketplace comes packed with **many more addons than before**. +- Some notable new stream addons include: **StremThru Torz, Nuvio Streams, Debridio Watchtower, StreamFusion**, and even built-in support for **wrapping AIOStreams within AIOStreams** (AIOception!). + +**💎 Revolutionary Grouping Feature:** + +- This is a big one! I've implemented a **new grouping feature** that allows you to group your addons and apply highly customizable conditions. +- Streams from addons in Group 1 are always fetched. Then, you can set conditions for subsequent groups. For example, for Group 2, you could set a condition like `count(previousStreams) < 5`. This means addons in Group 2 will only be queried if the total number of streams found by Group 1 is less than 5. This means you can tell AIOStreams, for instance, to only tap into your backup/slower addon group if your main, preferred addons don't find enough streams first – super efficient! +- This allows for incredibly optimized and tailored stream fetching. (For more advanced setups and details, I highly recommend checking out the **[Wiki](https://github.com/Viren070/AIOStreams/wiki/Groups)**). + +**🔎 Next-Level Filtering System:** + +- The filtering system has been completely revamped. Previously, you could mainly exclude or prefer. Now, for _every_ filter criteria, you can set **four different filter types**: + - **Include:** If matched, this item won't be excluded by other exclude/required filters for _any other exclude/required filter_. + - **Required:** Exclude the stream if this criteria is _not_ detected. + - **Exclude:** Exclude the stream if this criteria _is_ detected. + - **Preferred:** This is used for ranking when you use that filter as a sort criteria. +- **New Filters Added:** + - **Conditions Filter:** This incredibly flexible filter uses the same powerful condition parser as the "Groups" feature. You can now enter **multiple filter conditions**, and any stream that matches _any_ of the conditions you define will be filtered out. This allows for an almost infinite number of ways to combine and exclude streams with surgical precision! For example, a condition like `addon(type(streams, 'debrid'), 'TorBox')` would exclude all Debrid-type streams _only_ from the "TorBox" addon, leaving its Usenet streams untouched. + - **Matching:** This powerful filter helps ensure you get the right content. It includes: + - **Title Matching:** Filter out results that don't match the requested title. You can choose between an "exact match" mode or a "contains" mode for flexibility. **You can optionally also match the year too.** + - **Season/Episode Matching:** Specifically for series, this mode filters out results with incorrect season or episode numbers, ensuring accuracy. This can be granularly applied to only specific addons or request types. + - **Audio Channels:** This was previously part of the Audio Tag filter but is now its own dedicated filter for more precise control (e.g., filter for 5.1, 7.1). + - **Seeders:** Define include/required/exclude ranges for seeders. Finally, you can set a **minimum seeder count** and automatically exclude results below that threshold! +- **Adjusted & Enhanced Filters:** + - **Cache:** Get fine-grained control over cached/uncached content. You can now exclude uncached/cached content from specific Debrid services or addons, and even for specific stream types. For example, you could filter out all uncached _torrents_ but still allow uncached _Usenet_ results. + - **Clean Results (now "Deduplicator"):** This is now far more customizable! You can modify what attributes are used to identify duplicates (e.g., infohash, filename) and how duplicates are removed for each stream type. For instance, for cached results, you might want one result from each of your Debrid services, while for uncached results, you might only want the single best result from your highest priority service. + - **Size:** You can now set **individual file size ranges for each resolution** (e.g., 1-2GB for 720p, 3-5GB for 1080p, etc.). + +**📺 Smarter Sorting & Display:** + +- Define **different sorting priorities for cached vs. uncached media**, and also **different sorting for movies vs. series.** +- **New "Light GDrive" Formatter:** For those who prefer a cleaner look but still need key information from the filename, this formatter only shows the title, year, and season/episode info (e.g., "Movie Title (2023) S01E01"), making sure you don't potentially choose an incorrect result while still keeping the text to a minimal level. + - And of course, you can always join our Discord server to discover custom display formats shared by the community and easily use them with AIOStreams' custom formatter feature! + +**✨ Quality of Life Enhancements:** + +- **Import/Export Configurations:** You can now easily **export your entire AIOStreams configuration into a file.** This file can then be imported into any AIOStreams instance at any time – perfect for backups or migrating to a new setup. + - **Shareable Templates:** There's an "Exclude Credentials" option when exporting, making it easy to share template configurations with others! + - **⚠️ Important Warning:** While the "Exclude Credentials" feature removes sensitive information you enter _directly_ into AIOStreams (like API keys), it **does not** modify or exclude URLs you provide for "Custom" addons or when you override an addon's default URL. These URLs can potentially contain sensitive tokens or identifiers, so please review them carefully before sharing a configuration file. +- **External Downloads:** For added convenience, AIOStreams v2 now adds an "External Download" link below each stream result. Clicking this will open the direct download link for that stream in your browser, making it easy to grab a copy of the content if needed. +- **Hide Errors:** Optionally hide error messages, and you can even specify this for particular resources (e.g., hide errors only for stream fetching, but show them for catalog fetching). +- **Precache Next Episode:** When you're watching a series, AIOStreams can automatically request results for the _next_ episode in the background. If it finds that all available results are uncached, it can **ping the first uncached result for your preferred Debrid service to start downloading it.** The goal? By the time you finish your current episode, the next one might already be cached and ready to stream instantly! + +**A Note on Options:** AIOStreams v2 offers a vast array of configuration options, especially within the filtering system. While this provides incredible power and flexibility for advanced users, please remember that **most users won't need to dive deep into every setting.** The default configurations are designed to be sensible and provide a great experience out-of-the-box! For a detailed explanation of every option and how to fine-tune your setup, the **[AIOStreams v2 Configuration Guide](https://guides.viren070.me/stremio/addons/aiostreams)** has been fully updated and is your best resource. + +--- + +### 💾 Under The Hood: The New Database Foundation 💾 + +- **Database-Driven:** AIOStreams is now database-based! This means your configurations are stored securely. When you create a configuration, it's assigned a **unique UUID** that you'll use to access it in Stremio. +- **Password Protected:** You'll protect your configurations with a **password**. Without it, no one else can access your configuration. +- **Seamless Updates (Mostly!):** A huge benefit of being database-driven is that for most setting changes, there’s **no longer a need to reinstall the addon in Stremio!** Just update your configuration, and the changes apply automatically. + - **Note:** The only exception is if you make changes to your catalogs that affect their order or which addons provide them (e.g., reordering addons in the list, adding/removing catalog-providing addons). In this specific case, a reinstall of the AIOStreams addon in Stremio is needed for Stremio to pick up the new catalog structure. + +--- + +### ⚠️ Important Notes & Caveats for v2 ⚠️ + +- **Migration Requires Reconfiguration:** Due to the extensive changes and the new database system, existing AIOStreams users will need to **reconfigure their setups for v2.** Think of it as a fresh start with a much more powerful system! The **[v1 to v2 Migration Guide](https://github.com/Viren070/AIOStreams/wiki/Migrate-to-V2)** on the Wiki can help. For a deep dive into all the new settings, refer to the comprehensive **[AIOStreams v2 Configuration Guide](https://guides.viren070.me/stremio/addons/aiostreams)**. **If you use custom formatters, you should also check the migration guide for minor syntax adjustments.** +- **Torrentio support (on public instance)?** Torrentio, the most popular addon, was disabled for most of v1's history due to the way it works (multiple requests appear to come from one IP, which is problematic for public instances). Torrentio remains **disabled on the public instance**, and this will not change. Self-hosted instances will have Torrentio enabled by default. The developer of Torrentio has personally stated that he does not want ElfHosted's public instances scraping Torrentio. +- **Cloudflare Worker Support Dropped:** Maintaining compatibility with Cloudflare Workers alongside the new database requirements and feature set became infeasible. It was essentially like writing and maintaining two different versions of the addon. As such, direct Cloudflare Worker support has been dropped. +- **Free Hosting Challenges:** AIOStreams v2 now **requires a database** for storing configurations. Many free hosting services do not provide persistent database storage (or have very limited free tiers), which can lead to your configurations being wiped when the instance restarts. + - For example, **Hugging Face Spaces** requires a paid tier for persistent storage. + - **Koyeb's** free tier does not offer persistent file storage for the SQLite database, however, Koyeb _does_ provide a free PostgreSQL database instance which AIOStreams v2 can use, offering a viable free hosting path if configured correctly. + I recommend looking for hosting solutions that offer persistent storage or a compatible free database tier if you plan to self-host on a free platform. + +--- + +### 🔧 Self-Hosting AIOStreams & Self-Hosting Guides 🔧 + +For those of you who like to have full control over your setup, **AIOStreams v2 is, of course, _still_ self-hostable!** + +If you're migrating your instance from v1 to v2, read the [Migration](https://github.com/Viren070/AIOStreams/wiki/Migrate-to-V2) page on the Wiki to ensure nothing unexpected happens. + +A few months back, I started out knowing very little about self-hosting (I was using Hugging Face to host my personal AIOStreams instance back then) and I've since decided to dive into self-hosting. + +As a result, I've put together a **set of comprehensive self-hosting guides** that I'm excited to share with the community. My goal with these guides is to take you **from scratch to hosting all sorts of addons and applications**, including AIOStreams, without spending a dime or needing any hardware other than a laptop/computer. (Some of you may even be able to set this all up just using your phone/tablet) + +The guides cover: + +- Securing a **free Oracle Cloud VPS** (yes, free!). +- Installing **Docker** and getting comfortable with its basics. +- Utilizing my **highly flexible and detailed template compose project.** This Docker Compose setup is designed to be a launchpad for your self-hosting adventures and includes configurations for **countless apps, with AIOStreams v2 ready to go!** + +If you've ever been curious about self-hosting but didn't know where to start, I believe these guides can help you get up and running with a powerful, remote, and secure setup. + +- **https://guides.viren070.me/selfhosting** + +--- + +### 💬 Join the AIOStreams Community on Discord! 💬 + +AIOStreams v2 wouldn't be where it is today without the feedback, bug reports, and ideas from our community. A Massive **THANK YOU** to everyone on Discord who took part in testing, shared suggestions, and patiently helped polish every feature. Your involvement genuinely shaped this release! + +To celebrate the launch, I'm running a **1-year Real-Debrid giveaway (with 2 winners)** exclusively in the Discord server! Just join the server for your chance to win. + +Outside of the giveaway, you can also join our server for: + +- Questions about and support for AIOStreams +- Receive help with self hosting +- Discover setups shared by the community like formats, regexes, group filters, condition filters etc. (and possibly even share your own!) +- Staying updated on the latest AIOStreams developments + +Join our server using the link below: + +- **https://discord.viren070.me** + +--- + +### ❤️ Support AIOStreams Development ❤️ + +AIOStreams is a passion project that I develop solo in my free time. Countless hours have gone into this v2 rewrite, and I'm committed to making it the best it can be. + +If you find AIOStreams useful and want to support its continued development, please consider donating. Any amount is hugely appreciated and helps me dedicate more time to new features, bug fixes, and support. + +- **[Sponsor me on GitHub](https://github.com/sponsors/Viren070)** +- **[Buy me a coffee on Ko-fi](https://ko-fi.com/viren070)** + +--- + +### 🚀 Get Started with AIOStreams v2! 🚀 + +I'm incredibly excited for you all to try out AIOStreams v2! I believe it's a massive step forward. Please give it a go, explore the new features, and share your feedback. + +Here’s how you can jump in: + +**1. Try the Public Instance (Easiest Way!)** + +- **ElfHosted (Official Public Instance):** Generously hosted and maintained. + - **Link:** **https://aiostreams.elfhosted.com/** + +**2. Self-Host AIOStreams v2** + +- **For New Self-Hosters:** If you know what you're doing - follow the [Deployment Wiki](https://github.com/Viren070/AIOStreams/wiki/Deployment). Otherwise, check out my comprehensive **[Self-Hosting Guides](https://guides.viren070.me/selfhosting)** to get started from scratch. +- **Migrating from v1?** If you're currently self-hosting v1, ensure your setup supports persistent storage and then follow the **[v1 to v2 Migration Guide](https://github.com/Viren070/AIOStreams/wiki/Migrate-to-V2)**. + +**3. Managed Private Instance via ElfHosted (Support AIOStreams Development!)** + +- Want AIOStreams without the self-hosting hassle? ElfHosted offers private, managed instances. +- ✨ **Support My Work:** If you sign up using my referral link, **33% of your subscription fee directly supports AIOStreams development!** + - **Get your ElfHosted AIOStreams Instance:** **https://store.elfhosted.com/product/aiostreams/elf/viren070** + +This release marks a new chapter for AIOStreams, and I can't wait to see how you all use it to enhance your Stremio experience. + +Cheers, + +Viren. + +See the commit breakdown below: + +### Features + +- add 'onlyOnDiscover' catalog modifier ([4024c01](https://github.com/Viren070/AIOStreams/commit/4024c01b0a55cdd18023cf4d9328f38d3b5c29d0)) +- add alert and socials options to schema, implement SocialIcon component, and update TemplateOption to render new option types ([a0a3c82](https://github.com/Viren070/AIOStreams/commit/a0a3c8231ae77cd379eb39ba68ef437b15b0a4e5)) +- add alert option to DebridioTmdbPreset and TmdbCollectionsPreset for language selector clarification ([093f90a](https://github.com/Viren070/AIOStreams/commit/093f90a3eeafb540aaf28638557ad75a8f1e44d9)) +- add aliased configuration support ([5df60d7](https://github.com/Viren070/AIOStreams/commit/5df60d7085a0b5f938c8f135c93c29286aed566b)) +- add anime catalogs ([5968685](https://github.com/Viren070/AIOStreams/commit/59686852d3b7c2e3f0f8e204bcf8b765aadb29f7)) +- add anime specific sorting and add help box to sort menu ([77ee7b4](https://github.com/Viren070/AIOStreams/commit/77ee7b48c465d67e2e105d1c134d88cd96b27093)) +- add api key field and handle encrypted values correctly. ([6a5759d](https://github.com/Viren070/AIOStreams/commit/6a5759d60e27ec83101a3f1b02284ad8242faea9)) +- add asthetic startup logs ([fdbd282](https://github.com/Viren070/AIOStreams/commit/fdbd2821101bd8de0f9ffc4030a6b4938c43ec70)) +- add audio channel filter and fix unknown filtering not working in some cases ([df546d3](https://github.com/Viren070/AIOStreams/commit/df546d3a0c9ca39e772a64980a6aa582a4e9c81a)) +- add built-in torrentio format ([6fa1b2b](https://github.com/Viren070/AIOStreams/commit/6fa1b2b0c0cb45e9344163989009238d528d330b)) +- add configurable URL modifications for Stremthru Store and Torz ([3ce9dd0](https://github.com/Viren070/AIOStreams/commit/3ce9dd0ff5e5b7e9298bef87b3c5abe12c96afc9)) +- add delete icon to preferred list, only load valid values, fix password requirement check for new logins, fix spellings and add types ([d845c0c](https://github.com/Viren070/AIOStreams/commit/d845c0ce8bfb040c800355e97ea552758ad3c719)) +- add doctor who universe ([048c612](https://github.com/Viren070/AIOStreams/commit/048c612896723acffe908459c381dd1ee6f63784)) +- add donation modal button at top of about menu ([0170267](https://github.com/Viren070/AIOStreams/commit/01702671d59d7b924f4693e30b4f8fb1efaeaa15)) +- add external download streams option ([952a050](https://github.com/Viren070/AIOStreams/commit/952a05057cfbd9446f19ea4e7c71e26ae8acee89)), closes [#191](https://github.com/Viren070/AIOStreams/issues/191) +- add folder size, add smart detect deduplicator, parse folder size for mediafusion, improve size parsing ([52fb3bb](https://github.com/Viren070/AIOStreams/commit/52fb3bb41c9b59433e00695c61fd643724c1bff4)) +- add health check to dockerfile ([8c68051](https://github.com/Viren070/AIOStreams/commit/8c680511edb2c5936bebdab5931bd32a968bcc9e)) +- add infohash extractor in base stream parser ([4b1f45d](https://github.com/Viren070/AIOStreams/commit/4b1f45da3a8c3eff9b9a2d675332267cbedf6722)) +- add keepOpenOnSelect prop to Combobox for customizable popover behavior and set it to true by default ([f32a1a1](https://github.com/Viren070/AIOStreams/commit/f32a1a1002937023cb50a9b5d230950f9981aaba)) +- add link to wiki in groups and link to predefined formatter definitions ([7f4405e](https://github.com/Viren070/AIOStreams/commit/7f4405e3574cdd230cc2112125163408738d2685)) +- add more addons and fix stuff ([51f6bd6](https://github.com/Viren070/AIOStreams/commit/51f6bd606c1d4db184b7e9c497f8e63aaf3c03cc)) +- add nuviostreams and anime kitsu ([34ed384](https://github.com/Viren070/AIOStreams/commit/34ed3846da218065ad89f840e739ec541109158a)) +- add opensubtitles v3 ([b4f6927](https://github.com/Viren070/AIOStreams/commit/b4f69273a4de6572dafcd5b121910048da3cb3aa)) +- add P2P option and enhance service handling in StremthruTorzPreset ([6390995](https://github.com/Viren070/AIOStreams/commit/6390995eebbd96ab524c3980b103500ecc8300ad)) +- add predefined format definitions for torbox, gdrive, and light gdrive ([e3294eb](https://github.com/Viren070/AIOStreams/commit/e3294eb7e9403e457d622e848bbf81534e92c9e6)) +- add public ip option and load forced/default value to proxy menu ([3c2c59e](https://github.com/Viren070/AIOStreams/commit/3c2c59e676144dba70ba9c3675f3767eab4991ea)) +- add regex functions to condition parser ([731c1d0](https://github.com/Viren070/AIOStreams/commit/731c1d002cb2fa2bce79f7b20df27f4e6e726e2b)) +- add season/episode matching ([4cd6522](https://github.com/Viren070/AIOStreams/commit/4cd6522417bb15eb37d23a39b6556ff8aa41838e)) +- add seeders filters ([653b306](https://github.com/Viren070/AIOStreams/commit/653b30632154c31c1036b76bc84e013253539a47)) +- add sensible built-in limits and configurable limits, remove unused variables from Env ([37259d9](https://github.com/Viren070/AIOStreams/commit/37259d90f133e57571a896929aa9c023027fad6e)) +- add shuffle persistence setting and improve shuffling ([e6286bc](https://github.com/Viren070/AIOStreams/commit/e6286bcf9bdbf509722e68879803485cc7926c62)) +- add size filters, allowing resolution specific limit ([fcec2b9](https://github.com/Viren070/AIOStreams/commit/fcec2b9ed850a852c4254306421c91b82c8a6c54)) +- add social options to various presets ([ea02be9](https://github.com/Viren070/AIOStreams/commit/ea02be99a714e03687b603848f4157e1150aa817)) +- add source addon name to catalog and improve ui/ux ([878cd7c](https://github.com/Viren070/AIOStreams/commit/878cd7c71fd648072dc9ec2c8de53428eb79a93c)) +- add stream passthrough option, orion, jackettio, dmm cast, marvel, peerflix, ([0383671](https://github.com/Viren070/AIOStreams/commit/038367126eb4e9fa327101163a12b4ef6dc9b7e6)) +- add stream type exclusions for cached and uncached results ([18e034f](https://github.com/Viren070/AIOStreams/commit/18e034f7bfb092c053405244a6f972aff44cf1d1)) +- add StreamFusion ([8b34be3](https://github.com/Viren070/AIOStreams/commit/8b34be3845a86bddf0b95d9aab43607cf9223a92)) +- add streaming catalogs ([4ce36f1](https://github.com/Viren070/AIOStreams/commit/4ce36f1ba0a8b3149cb9823b7499d625e0e285dd)) +- add strict title matching ([c4991c6](https://github.com/Viren070/AIOStreams/commit/c4991c678db0333587e57a632e68f26a650ea24a)) +- add support for converting ISO 639_2 to languages and prevent languages being detected as indexer in Easynews++ ([938323f](https://github.com/Viren070/AIOStreams/commit/938323f1dd5a4a333275c506afa1c85a8c9af361)) +- add support for includes modifier for array ([90432ae](https://github.com/Viren070/AIOStreams/commit/90432ae9c8b93b7bc1ba4a7a677f7a576b946cd7)) +- add webstreamr, improve parsing of nuviostream results, validate tmdb access token, always check for languages ([dc50c6c](https://github.com/Viren070/AIOStreams/commit/dc50c6c70b94df7cc0124bbc8b2f96df01011b38)) +- adjust addons menu ([6d0a088](https://github.com/Viren070/AIOStreams/commit/6d0a088c395aacb7123a66c12d01df1547733f37)) +- adjust default user data ([dea5950](https://github.com/Viren070/AIOStreams/commit/dea595055a1cb5ce07f26b64faa209bbaa71dd7a)) +- adjust handling of meta requests by trying multiple supported addons until one succeeds ([9fab116](https://github.com/Viren070/AIOStreams/commit/9fab1162c004fa7c5f4b73b522527ec0ed142b8a)) +- adjustments and proxy menu ([0c5479c](https://github.com/Viren070/AIOStreams/commit/0c5479c12997dc755b34897a4ed1814c2140dacb)) +- allow editing catalog type ([d99a29f](https://github.com/Viren070/AIOStreams/commit/d99a29fd6e97b010d41047d61522ce49a7084ade)) +- allow passing flags through ([bec91a8](https://github.com/Viren070/AIOStreams/commit/bec91a8a5835b340003381d99ebd5b02596dca4b)) +- cache RPDB API Key validation ([63622e0](https://github.com/Viren070/AIOStreams/commit/63622e0a07c64b45a228a1f3f653449744ec96e4)) +- changes ([e8c61a9](https://github.com/Viren070/AIOStreams/commit/e8c61a986066e1bdd06f00c5e3a4ff215ae5f968)) +- changes ([13a20a7](https://github.com/Viren070/AIOStreams/commit/13a20a7b610da0f41b40ccaf454a31805b445e9e)) +- clean up env vars and add rate limit to catalog api ([20fc37c](https://github.com/Viren070/AIOStreams/commit/20fc37cc123bacf729c57ae0718d6e85d02d4bb9)) +- **conditions:** add support for multiple groupings, and add type constant ([2a525b2](https://github.com/Viren070/AIOStreams/commit/2a525b292ef98a8e5a6697f967474714d0ceec23)) +- enhance language detection in MediaFusionStreamParser to parse languages from stream descriptions ([50db0e2](https://github.com/Viren070/AIOStreams/commit/50db0e2714f5f040660f47efa3012b41ae8da55d)) +- enhance stream parsing to prefer folder titles when available ([4001fae](https://github.com/Viren070/AIOStreams/commit/4001faede127a5712c3112ea334726bd18717c7d)) +- enhance strict title matching with configuration options for request types and addons ([3378851](https://github.com/Viren070/AIOStreams/commit/3378851ff8048216529a9d1a6715d3b9d1439d39)) +- enhance title matching by adding year matching option and updating metadata handling ([62752ef](https://github.com/Viren070/AIOStreams/commit/62752ef98c75741e59e70a08ce811b1e032dc8a9)) +- expand cache system and add rate limiting to all routes, attempt to block recursive requests ([c9356db](https://github.com/Viren070/AIOStreams/commit/c9356db83ab311261c001702ea5a31193a4b0432)) +- filter out invalid items in wrapper repsponses, rather than failing whole request. add message parsing for torbox ([da7dc3a](https://github.com/Viren070/AIOStreams/commit/da7dc3a935d29ec66c9c7509313268c16c3e4f1a)) +- fix condition parsing for unknown values and separate cached into cached and uncached function for simplicity ([3d26421](https://github.com/Viren070/AIOStreams/commit/3d26421b6878cf21edd6c648f5b61f125bf6cb4d)) +- **frontend:** add customization options for addon name and logo in AboutMenu ([47cc8f6](https://github.com/Viren070/AIOStreams/commit/47cc8f6dd6287d214ba34b0413fee784adbc52a7)) +- **frontend:** add descriptions to addons and catalog cards ([98c5b71](https://github.com/Viren070/AIOStreams/commit/98c5b71f1e364dc2eb9d97448c2cf5d2bf42b12a)) +- **frontend:** add shuffle indicator to catalog item ([edd1e4f](https://github.com/Viren070/AIOStreams/commit/edd1e4f8093a9cbb24278f4470d05ff6732acd15)) +- **frontend:** add tooltip for full service name in service tags for addon card ([5b8ec4d](https://github.com/Viren070/AIOStreams/commit/5b8ec4d9e75822d3ec39e55d5ae503d5f7c5a51f)) +- **frontend:** add valid formatter snippets and add valid descriptions for proxy services ([12b3f42](https://github.com/Viren070/AIOStreams/commit/12b3f423c0fd1706b9014996978e737d246fcac1)) +- **frontend:** enhance nightly version display with clickable commit link ([84d53cb](https://github.com/Viren070/AIOStreams/commit/84d53cbdcf835d797312245dc9377da71b0b54d7)) +- **frontend:** hide menu control button text on smaller screens ([2361e5c](https://github.com/Viren070/AIOStreams/commit/2361e5c373253db928027c2da0ca0eaa54f35579)) +- **frontend:** improve addons menu, preserve existing catalog settings ([2c5c642](https://github.com/Viren070/AIOStreams/commit/2c5c642b022601e3a41ed74934bd29538eec9d71)) +- **frontend:** improve services page ([384bdc3](https://github.com/Viren070/AIOStreams/commit/384bdc3a52d67bc85b33f2338b0076d7bd165fc1)) +- **frontend:** make catalog card title consistent with other cards ([5197331](https://github.com/Viren070/AIOStreams/commit/5197331a79093065f8de326f76bfb2add9c0050a)) +- **frontend:** services page, parse markdown, toast when duplicate addon ([3bc2538](https://github.com/Viren070/AIOStreams/commit/3bc25387f521792d5a2455a600d459176767497e)) +- **frontend:** update addon item layout for improved readability ([589e639](https://github.com/Viren070/AIOStreams/commit/589e639870fe9618dcee6e7e221750b1d8a9e17c)) +- **frontend:** use NumberInput component ([77edb07](https://github.com/Viren070/AIOStreams/commit/77edb07831ac6c4daf628e044fd369534fb58fcc)) +- **frontend:** use queue and default regex matched to undefined ([2c97ec0](https://github.com/Viren070/AIOStreams/commit/2c97ec04cde252ffdeafac25ecbe5c02148b4385)) +- identify casted streams from DMM cast as library streams and include full message ([6fd5f5b](https://github.com/Viren070/AIOStreams/commit/6fd5f5b9c03e46667255c9949b3c98b176724ebd)) +- implement advanced stream filtering with excluded conditions ([302b4cb](https://github.com/Viren070/AIOStreams/commit/302b4cb5c99fe00f21b5b775ef2187f4088717a9)), closes [#57](https://github.com/Viren070/AIOStreams/issues/57) +- implement cache statistics logging and configurable interval ([8594ca0](https://github.com/Viren070/AIOStreams/commit/8594ca0374be534cb89dbbee427805202cc08ce6)) +- implement config validation and addon error handling ([f7b14cd](https://github.com/Viren070/AIOStreams/commit/f7b14cd1dbe54d714fe41881ff9993107746b895)) +- implement detailed statistics tracking and reporting for stream deduplication process ([89eac41](https://github.com/Viren070/AIOStreams/commit/89eac415a422189d80a3c3c66cde26762bd7f437)) +- implement disjoint set union (DSU) for stream deduplication, ensuring multiple detection methods are handled correctly ([b0cc718](https://github.com/Viren070/AIOStreams/commit/b0cc718a094f22b4c0cec870e5b06e2ec9e1e7e9)) +- implement import functionality via modal for JSON files and URLs in TextInputs component ([32b5a5b](https://github.com/Viren070/AIOStreams/commit/32b5a5b7bdfc9b2b27e15eddf060555e6b9c0596)) +- implement MAX_ADDONS and fix error returning ([ae74926](https://github.com/Viren070/AIOStreams/commit/ae74926ce2e04710771a7166e946f87166985188)) +- implement pre-caching of the next episode ([980682c](https://github.com/Viren070/AIOStreams/commit/980682cd28e40f84caf1c8f1072fd79ec49ac62b)) +- implement timeout constraints in preset options using MAX_TIMEOUT and MIN_TIMEOUT ([e415a70](https://github.com/Viren070/AIOStreams/commit/e415a70485fdd33bf5d9b1379d3ede633ea60475)) +- implement user pruning functionality with configurable intervals and maximum inactivity days ([0bf6fcb](https://github.com/Viren070/AIOStreams/commit/0bf6fcbe9c484c4df6582d76d3bd8fd10567f34b)) +- improve config handling, define all skip reasons, add env vars to disable addons/hosts/services, ([a301002](https://github.com/Viren070/AIOStreams/commit/a301002ba49fce87e40a28a650e411e5078f769b)) +- improve formatting of zod errors when using unions ([9c2a970](https://github.com/Viren070/AIOStreams/commit/9c2a970c7d612c9432db70a011663f3f241072ca)) +- improve French language regex to include common indicators ([163352a](https://github.com/Viren070/AIOStreams/commit/163352a1909faf4e4b45b56222ba08afa023fd7e)) +- improve handling of unsupport meta id and type ([3779ea0](https://github.com/Viren070/AIOStreams/commit/3779ea09d392ffb3f14b7efcba989ec7cc44bf89)) +- improve preset/parser system and add mediafusion, comet, stremthru torz, torbox, debridio, en, en+, en+ ([b70a763](https://github.com/Viren070/AIOStreams/commit/b70a763e8b6dc9cfbaf865c8526dd078e1965cb8)) +- include preset id in formatter ([6053855](https://github.com/Viren070/AIOStreams/commit/6053855f9a3dc5b32bcd8296161ef8ac6df18df8)) +- make `BASE_URL` required and disable self scraping by default ([d572c04](https://github.com/Viren070/AIOStreams/commit/d572c047e9da4d3cf5be645fd2125b3781b80898)) +- make caching more configurable and add to sample .env ([1e65fd9](https://github.com/Viren070/AIOStreams/commit/1e65fd9e7dddfe3a0bb9bcf07d77d03fbadf846a)) +- match years for series too, but don't filter out episode results without a year ([8394f09](https://github.com/Viren070/AIOStreams/commit/8394f0969da665b31074c8e6b9fc15bf9e731b2a)) +- move 'custom' preset to the beginning ([0b85ff3](https://github.com/Viren070/AIOStreams/commit/0b85ff35e7eba5f62579e117621b212122fd8eca)) +- **parser:** add support for additional video quality resolutions (144p, 180p, 240p, 360p, 576p) in regex parser ([59d86ff](https://github.com/Viren070/AIOStreams/commit/59d86ffcbfe4d576c49903cdeb8adf197b811963)) +- prefer results with higher seeders when deduping ([aed775c](https://github.com/Viren070/AIOStreams/commit/aed775c6d5a2b983dc04adbd15b7409a8b11a3a0)) +- proxy fixes and log adjustments ([091394b](https://github.com/Viren070/AIOStreams/commit/091394b837565f59815bb968dea13fdc356b6160)) +- remove duplicated info from download streams ([4901745](https://github.com/Viren070/AIOStreams/commit/49017450b9958eabc5a04a098401f2a2561a8e26)) +- remove useMultipleInstances and debridDownloader options for simplicity and force multiple instances. ([8c0622e](https://github.com/Viren070/AIOStreams/commit/8c0622ea984082dc8c8f678c12d8c962967a70c1)) +- rename API Key to Addon Password and update related help text in save-install component ([b63813c](https://github.com/Viren070/AIOStreams/commit/b63813c29db53b5a3fbf83c6c042ee10fdda739d)) +- rename cache to cached in condition parser ([db68a5c](https://github.com/Viren070/AIOStreams/commit/db68a5c0266a5aa05068c4bcbc0c0f0532cd6097)) +- replace custom HTML div with SettingsCard component for consistent styling ([8611523](https://github.com/Viren070/AIOStreams/commit/86115230bfd5958374294896adc59c83f28d3fee)) +- revert 89eac415a422189d80a3c3c66cde26762bd7f437 ([34b57c9](https://github.com/Viren070/AIOStreams/commit/34b57c9883901722736cb5d52e0911f6434ddfe3)) +- service cred env vars, better validation, handling of encrypted values ([61e21cd](https://github.com/Viren070/AIOStreams/commit/61e21cd803981899b4e445c5058fb546db79096d)) +- start ([3517218](https://github.com/Viren070/AIOStreams/commit/35172188081b688011031439ec26b11e428dd02d)) +- stuff ([0c9c86c](https://github.com/Viren070/AIOStreams/commit/0c9c86c218c5754e62ff94c0d26d398f32da92a1)) +- switch to different arrow icons and use built-in hideTextOnSmallScreen prop ([8d307a0](https://github.com/Viren070/AIOStreams/commit/8d307a0c2f755b16074e1a7262204e635853ddfd)) +- ui improvements ([7e031e5](https://github.com/Viren070/AIOStreams/commit/7e031e51b12cd1fa09e1ed70b90467e8a6bd956e)) +- ui improvements, check for anime type using kitsu id, loosen schema definitions ([9668a15](https://github.com/Viren070/AIOStreams/commit/9668a152fd116ed9fa9657e935b3b0ed711ce06d)) +- ui improvments ([39b1e84](https://github.com/Viren070/AIOStreams/commit/39b1e84d87ea4422ebbdab2495d242aeee231562)) +- update About component with new guide URLs and enhance Getting Started section ([5232e38](https://github.com/Viren070/AIOStreams/commit/5232e3847b4aeb812c44ad0e153b95189ceda607)) +- update static file serving rate limiting and refactor file path handling ([010b63c](https://github.com/Viren070/AIOStreams/commit/010b63c8725bfb3968c6678b2615675b393fb449)) +- update TMDB access token input to password type with placeholder ([2378869](https://github.com/Viren070/AIOStreams/commit/23788695e2cedad3a1491c78f17f7e900aa77aeb)) +- use `API_KEY` as fallback for `ADDON_PASSWORD` to maintain backwards compatability ([5424490](https://github.com/Viren070/AIOStreams/commit/5424490a284aa74e98071a36f3848706f81f5033)) +- use button for log in/out ([62911ad](https://github.com/Viren070/AIOStreams/commit/62911adfacde25c9f9e7b3551c277c4a7a6340db)) +- use shorter function names in condition parser ([3bd2751](https://github.com/Viren070/AIOStreams/commit/3bd27519fdfa8cbf9435a48b49f3aeb2992aae42)) +- use sliders for seeder ranges and fix some options not being multi-option ([915187a](https://github.com/Viren070/AIOStreams/commit/915187a6120dff969dcfe9d4bf9e473673f8ebf0)) +- validate regexes on config validation ([dd0f45c](https://github.com/Viren070/AIOStreams/commit/dd0f45c731938c37575fb376a981d3c0d2c7a45a)) + +### Bug Fixes + +- (mediafusion) increase max streams per resolution limit to 500 ([322b4f3](https://github.com/Viren070/AIOStreams/commit/322b4f375ebbd1047f3e457cf48d75ac9b610d15)) +- adapt queries for PostgreSQL and SQLite ([e2834d5](https://github.com/Viren070/AIOStreams/commit/e2834d571c709cc9ca3db541da6c1374fb201490)) +- adapt query for SQLite dialect in DB class ([a7bb898](https://github.com/Viren070/AIOStreams/commit/a7bb8983de03d5f1fb044636133c6f01aaeebf1f)) +- add back library marker to LightGDriveFormatter ([871f54e](https://github.com/Viren070/AIOStreams/commit/871f54e896a4315f197e6a15b779d4b2a957e8a4)) +- add back logo.png to v1 path for backwards compatability ([ce5a5b9](https://github.com/Viren070/AIOStreams/commit/ce5a5b99059cd2902d60c9e865503d995ed46df9)) +- add back y flag ([0e0a18b](https://github.com/Viren070/AIOStreams/commit/0e0a18b9c1f7e65f84af762aab785aa7a79e1222)) +- add block scope for array modifier handling in BaseFormatter ([02a2885](https://github.com/Viren070/AIOStreams/commit/02a2885d33dfbe355203d4f561408eb82355d939)) +- add description for stremthru torz ([6e7c142](https://github.com/Viren070/AIOStreams/commit/6e7c14224e5fe90d56dbda7f6ac91d5b87091444)) +- add extras to cache key for catalog shuffling ([1cdfc6e](https://github.com/Viren070/AIOStreams/commit/1cdfc6e0e3a44f983ac43f1c210257c63c0a78a9)) +- add France option to DebridioTvPreset language selection ([bd19d01](https://github.com/Viren070/AIOStreams/commit/bd19d01b5434070384ac69278fbc8e21a65bafe9)) +- add missing audio tags to constant ([fda5ffe](https://github.com/Viren070/AIOStreams/commit/fda5ffe2062f1e6953380c4904c174b81b3b07ef)) +- add missing braces in parseConnectionURI function for sqlite and postgres cases ([807b681](https://github.com/Viren070/AIOStreams/commit/807b6810ea2b29900408a96e15f934d49b4407d9)) +- add timeout to fetch requests in TMDBMetadata class to prevent hanging requests ([1a0d57a](https://github.com/Viren070/AIOStreams/commit/1a0d57af43efd68d41a623e2a81b23cb217011da)) +- add validation for encrypted data format in decryptString function ([843b535](https://github.com/Viren070/AIOStreams/commit/843b535d7ca47c362e254669d0a3f149abe9ffc2)) +- add verbose logging for resources and fix addon catalog support ([4daa644](https://github.com/Viren070/AIOStreams/commit/4daa6441eede8aa630108c21f8760fa7c19a3745)) +- adjust cache stat logging behaviour ([d921070](https://github.com/Viren070/AIOStreams/commit/d921070192a4e07e3702b521a7b3819f42da3529)) +- adjust default rate limit values ([aa98e7b](https://github.com/Viren070/AIOStreams/commit/aa98e7b491a1f7ab9360af8d69490c39bbfd8268)) +- adjust grid layout in AddonFilterPopover ([632fbf9](https://github.com/Viren070/AIOStreams/commit/632fbf9206dcf5d9532557ca69df42683b5f7ffd)) +- adjust grouping in season presence check logic ([d89e796](https://github.com/Viren070/AIOStreams/commit/d89e796cb07e534691401e307d28fc89f4176dad)) +- adjust option name to keep backwards compatability with older configs ([eb651b5](https://github.com/Viren070/AIOStreams/commit/eb651b517db2bf8b91e3c60488f5336049a6bb69)) +- adjust spacing in predefined formatters and add p2p marker to torbox format ([d8f5d1a](https://github.com/Viren070/AIOStreams/commit/d8f5d1a2d152d2930c0cb03c533748f81f742869)) +- allow empty strings for formatter definitions ([dba54f5](https://github.com/Viren070/AIOStreams/commit/dba54f5c426e8b0391d3f2b2979b473574968036)) +- allow null for released in MetaVideoSchema ([ca8d744](https://github.com/Viren070/AIOStreams/commit/ca8d74448ac2479c948a1cc8509cee8a76db0042)) +- allow null value for description in MetaPreview ([0f16575](https://github.com/Viren070/AIOStreams/commit/0f165752db011c5d525c59bb915edda43afea718)) +- allow null value in MetaVideoSchema ([73b4d0b](https://github.com/Viren070/AIOStreams/commit/73b4d0b99fc587f7f82515553d92bf7c69647157)) +- always apply seeder ranges, defaulting seeders to 0 ([0f5dd76](https://github.com/Viren070/AIOStreams/commit/0f5dd764d9577944c587a75423db5256942b583b)) +- apply negativity to all addon and encode sorting ([411ae7c](https://github.com/Viren070/AIOStreams/commit/411ae7cee234ec8fefe08bf3d844d4711dc37645)) +- assign unique IDs to each stream to allow consistent comparison ([673ecb2](https://github.com/Viren070/AIOStreams/commit/673ecb2133d3dc5435db7be23cf116b2a6ad34c3)) +- await precomputation of sort regexes ([56994ef](https://github.com/Viren070/AIOStreams/commit/56994ef9e83248d49e890af99181943c7715d9bb)) +- call await on all compileRegex calls ([8e87004](https://github.com/Viren070/AIOStreams/commit/8e87004a07a8b5612356f5d346b4b1140a866b64)) +- carry out regex check for new users too ([1555199](https://github.com/Viren070/AIOStreams/commit/155519951bd5422da9d9fc112e1eca89c4d1fb51)) +- change image class from object-cover to object-contain in AddonCard component ([734bd88](https://github.com/Viren070/AIOStreams/commit/734bd88d34ba84267934862117a846c8c246e96e)) +- check if title matching is enabled before attempting to fetch titles ([fd03112](https://github.com/Viren070/AIOStreams/commit/fd03112288bdf00504a6e614993a50170bd7fb43)) +- coerce runtime to string type in MetaSchema for improved validation ([cc6eea7](https://github.com/Viren070/AIOStreams/commit/cc6eea7e52cc7604806f04459439c7256e1b5aee)) +- coerce year field to string type in ParsedFileSchema for consistent data handling ([10bef68](https://github.com/Viren070/AIOStreams/commit/10bef68c3625b855a473406dbd9bc4e852fe3cb2)) +- **comet:** don't make service required for comet ([826edae](https://github.com/Viren070/AIOStreams/commit/826edae8030627bb94591a07c6343ee64e0108f9)) +- **constants:** add back Dual Audio, Dubbed, and Multi ([7c10930](https://github.com/Viren070/AIOStreams/commit/7c109304ffdf035532514284c021171e91c0fe93)) +- **core:** actually apply exclude uncached/cached filters ([413a29d](https://github.com/Viren070/AIOStreams/commit/413a29d2d85b50b62042c26f9bed665c7822d11d)) +- correct handling of year matching and improved normalisation ([bd53adc](https://github.com/Viren070/AIOStreams/commit/bd53adc8f7538243caf121c9b3583cd257dc9181)) +- correct library marker usage in LightGDriveFormatter ([2470ae9](https://github.com/Viren070/AIOStreams/commit/2470ae94ec2f52f869e3c2edf904500095502b27)) +- correct spelling of 'committed' in UserRepository class ([551335b](https://github.com/Viren070/AIOStreams/commit/551335bcbaef570a6c6b81d023c1985f6fd19cd2)) +- correctly handle negate flag ([a65ef19](https://github.com/Viren070/AIOStreams/commit/a65ef19f555d34103cd68e8c021707a61e54cdde)) +- correctly handle overriden URLs for mediafusion ([46e7e67](https://github.com/Viren070/AIOStreams/commit/46e7e6748e461ec77575efb5ebec4dc7ee50eba7)) +- correctly handle required filters and remove HDR+DV as a tag after filtering/sorting ([113c150](https://github.com/Viren070/AIOStreams/commit/113c150e143b65eeea5dc2e5e1d74df6c096b8be)) +- correctly handle undefined parsed file ([8b85a53](https://github.com/Viren070/AIOStreams/commit/8b85a5332d2b33fb6d79139fb6e771d6446b7957)) +- correctly handle usenet results during deduping ([153366b](https://github.com/Viren070/AIOStreams/commit/153366b41a6b8a08cff8a4cd29ab10dfc1c7d3ac)) +- correctly import/export FeatureControl ([654b1bc](https://github.com/Viren070/AIOStreams/commit/654b1bc0585d3403836159ac2efde495f4cd44d4)) +- **custom:** replace 'stremio://' with 'https://' in manifest URL ([0a4a761](https://github.com/Viren070/AIOStreams/commit/0a4a76187d78e924222512f1ca971292463270b7)) +- **custom:** update manifest URL option to use 'manifestUrl' ([6370ac7](https://github.com/Viren070/AIOStreams/commit/6370ac7d00a75bd626cad67fa448dcaaa9b0a6ba)) +- decode data before attempting validation ([bdf9a91](https://github.com/Viren070/AIOStreams/commit/bdf9a9198f06e550e0fb3681936e6bfacf483731)) +- decrypt values for catalog fetching ([6cf8436](https://github.com/Viren070/AIOStreams/commit/6cf843666f97dedc247e52cf6946842d66c50229)) +- default seeders to 0 for included seeder range ([b0aea2d](https://github.com/Viren070/AIOStreams/commit/b0aea2ddec56da2428f515615251712313138cec)) +- default seeders to 0 in condition parser too ([53123a3](https://github.com/Viren070/AIOStreams/commit/53123a314c45d39c9d482e5105f47de712fcc7fc)) +- default value to mediaflow if neither forced or proxy is defined and remove fallback from select value ([61781b7](https://github.com/Viren070/AIOStreams/commit/61781b7e0650713777c7475416e1fc8b837c13fa)) +- default version to 0.0.0 when not defined ([f031f1a](https://github.com/Viren070/AIOStreams/commit/f031f1a50eabad7d122021ce9b6556694c49af76)) +- don't fail on invalid external api keys when skip errors is true ([c2db243](https://github.com/Viren070/AIOStreams/commit/c2db243b5798032b75843faf7254969d63ff14b6)) +- don't make base_url required ([3d7b0da](https://github.com/Viren070/AIOStreams/commit/3d7b0da93fb1add0c6f1d4523411fc0e9512a2b9)) +- don't make name required in MetaPreview schema ([062247a](https://github.com/Viren070/AIOStreams/commit/062247a89a38d3fad1129a8965a92b6245d5e08e)) +- don't pass idPrefixes in manifest response ([35ceb87](https://github.com/Viren070/AIOStreams/commit/35ceb87ff325960fc035db735ac8009ab636e09d)) +- don't validate user data on retrieval within UserRepository ([17873bb](https://github.com/Viren070/AIOStreams/commit/17873bb476d280e6f533cd7cabf8bb8e3e91d518)) +- enable passthrough on all stremio response schemas ([377d215](https://github.com/Viren070/AIOStreams/commit/377d215c0f5801ff93ec1b0065d0c64ce1fd8217)) +- encrypt forced proxy URL and credentials before assignment ([e741de3](https://github.com/Viren070/AIOStreams/commit/e741de378775baecd00ee9a8838f3f9fc6ca2bb1)) +- enhance Japanese language regex to include 'jpn' as an abbreviation ([7a02f12](https://github.com/Viren070/AIOStreams/commit/7a02f12818f64971971bc49b3ec80de594c4a1fe)) +- ensure debridDownloader defaults to an empty string when no serviceIds are present in StreamFusionPreset ([886a8cb](https://github.com/Viren070/AIOStreams/commit/886a8cb98190fb0e6b4b3d2358103485c9cc6f47)) +- ensure early return on error handling in catalog route ([6cc20e1](https://github.com/Viren070/AIOStreams/commit/6cc20e124dfe751051f61a700eb4765e8083310e)) +- ensure tmdb access token, rpdb api key, and password options are filtered out when exclude credentials is on ([299a6d5](https://github.com/Viren070/AIOStreams/commit/299a6d578cef763528095cb80b2337c44d1994e0)) +- ensure transaction rollback only occurs if not committed in deleteUser method ([67b188e](https://github.com/Viren070/AIOStreams/commit/67b188e7d76b6d0a424f5b86360c2b8a20ddc3b9)) +- ensure uniqueness of preset instanceIds and disallow dots in instanceId ([3a9be38](https://github.com/Viren070/AIOStreams/commit/3a9be38c77bb7a1b4b991c46902241a6e265b327)) +- export formatZodError ([af90131](https://github.com/Viren070/AIOStreams/commit/af90131787616a091373e69bf6f8de67e06f1e78)) +- fallback to undefined when both default and forced value are undefined for proxy id ([efb57bf](https://github.com/Viren070/AIOStreams/commit/efb57bfc3e1a2819712e54c03aee78f967427837)) +- **formatters:** add message to light gdrive and remove unecessary spacing ([5cb1b0a](https://github.com/Viren070/AIOStreams/commit/5cb1b0a21ed6b29dccf1a56e59434c28da39d1be)) +- **frontend:** encode password when loading config ([e8971df](https://github.com/Viren070/AIOStreams/commit/e8971df66d8ed79dec7d93bbc790c3de13f54a01)) +- **frontend:** load existing overriden type in newType ([caeb282](https://github.com/Viren070/AIOStreams/commit/caeb282438edfa8c731b32775840cc5f71c3ec36)) +- **frontend:** pass seeder info through to formatter ([2ec06a6](https://github.com/Viren070/AIOStreams/commit/2ec06a6f9905c7e1f9c32cc0a5ef56e96872933b)) +- **frontend:** set default presetInstanceId to 'custom' to pass length check ([ec7a19a](https://github.com/Viren070/AIOStreams/commit/ec7a19a92d2ffc2b06046ab0176f02a4f5b2014e)) +- **frontend:** try and make dnd better on touchscreen devices ([6aa1130](https://github.com/Viren070/AIOStreams/commit/6aa11301a5dc06eb8674cfb6a834bf181a41eeee)) +- **frontend:** update filter options to use textValue to correctly show addon name when selected ([6a87480](https://github.com/Viren070/AIOStreams/commit/6a874806b893dbd6382082563f2c45c274e2650b)) +- give more descriptive errors when no service is provded ([c0b6fd3](https://github.com/Viren070/AIOStreams/commit/c0b6fd3e7dac933b7fd0f10d999a48850c70244e)) +- handle when drag ends outside drag context ([7a8655d](https://github.com/Viren070/AIOStreams/commit/7a8655dd4326821f2445b1055a819a87a2c3270b)) +- handle when item doesn't exist in preferred list ([d728bb6](https://github.com/Viren070/AIOStreams/commit/d728bb67bdd872b2d812e3fa0ce1e5352860dff4)) +- ignore language flags in Torrentio streams if Multi Subs is present ([6d08d7c](https://github.com/Viren070/AIOStreams/commit/6d08d7c0336366c185ad43a89657cbe94dc30278)) +- ignore recursion checks for certain requests ([d266026](https://github.com/Viren070/AIOStreams/commit/d26602631e030f59ef0f0098633b7f4909db87bc)) +- improve error handling in TMDBMetadata by including response status and status text ([2f37187](https://github.com/Viren070/AIOStreams/commit/2f371876c151a9b4b0b7db3a4cf1fa14868d4db6)) +- improve filename sanitization in StreamParser by using Emoji_Presentation to keep numbers and removing identifiers ([714fedb](https://github.com/Viren070/AIOStreams/commit/714fedb2c318a115836faa939c5f888c7785b34c)) +- include overrideType in catalog modification check ([db473f3](https://github.com/Viren070/AIOStreams/commit/db473f3a32788bb34ed9cede11a24be45979d040)) +- increase recursion threshold limit and window for improved request handling ([cc2acde](https://github.com/Viren070/AIOStreams/commit/cc2acdeb7ab7dcfdaadc767450065dc8df520f57)) +- log errors in more cases, correctly handle partial proxy configuration, correctly handle undefined value in tryDecrypt, only decrypt when defined ([56734f0](https://github.com/Viren070/AIOStreams/commit/56734f0956b38998ea802d23e312e0dda2379c88)) +- make adjustments to how internal addon IDs are determined and fix some things ([a6515de](https://github.com/Viren070/AIOStreams/commit/a6515de2718138cefdad5c4c53617a745ff044c5)) +- make behaviorHints optional in manifest schema ([313c6bc](https://github.com/Viren070/AIOStreams/commit/313c6bc14e119d62c65bd2cea61eca23af4f4463)) +- make keyword pattern case insensitive ([795adb3](https://github.com/Viren070/AIOStreams/commit/795adb3e2521a766c92889cc0701e1a8b0d68d96)) +- make object validation less strict for parsed streams ([e39e690](https://github.com/Viren070/AIOStreams/commit/e39e6900b452b565c6f4c6ed7de151eceb54d38d)) +- **mediaflow:** add api_password query param when getting public IP ([00e305f](https://github.com/Viren070/AIOStreams/commit/00e305f4f31d9c78741fb0d8d2585b8478d732ea)) +- **mediaflow:** include api_password in public IP endpoint URL only ([279ff00](https://github.com/Viren070/AIOStreams/commit/279ff003be87febed59ac6f8edb3f0d0d439659a)) +- **mediafusion:** correctly return encoded user data, and fix parsing ([c6a6350](https://github.com/Viren070/AIOStreams/commit/c6a63502b6049fd403816114547be42e5f44b305)) +- only add addons that support the type only when idPrefixes is undefined ([d7355cb](https://github.com/Viren070/AIOStreams/commit/d7355cb5983202d08c5d6f863cf5f2f742a6ad97)) +- only allow p2p on its own addon in StremThruTorzPreset ([510c086](https://github.com/Viren070/AIOStreams/commit/510c086ab0dfbedd089e06ec063837f9e465695f)) +- only carry out missing title check after checking addons and request types ([eff8d50](https://github.com/Viren070/AIOStreams/commit/eff8d50006d3814af7a4140b0ad9f599eea6bddc)) +- only exclude a file with excludedLanguages if all its languages are excluded ([2dfb718](https://github.com/Viren070/AIOStreams/commit/2dfb718fa1bca8ae188c5ff55b2f7b1bf7fbbb10)) +- only filter out resources using specified resources when length greater than 0 ([cd78ead](https://github.com/Viren070/AIOStreams/commit/cd78ead297b8641d4f45ca224d5455ec649ee429)) +- only use the movie/series specific cached/uncached sort criteria if defined ([049f65b](https://github.com/Viren070/AIOStreams/commit/049f65b18069a0b8c8b8ae7d34e5981cfa34244e)) +- override stream parser for torz to remove indexer ([f0a448b](https://github.com/Viren070/AIOStreams/commit/f0a448b489585e22af6bcfffbc3ff0a383e35085)) +- **parser:** match against stream.description and apply fallback logic to stream.title ([a1d2fc9](https://github.com/Viren070/AIOStreams/commit/a1d2fc9981c967254dcb91d1779310c2fd1f8fba)) +- **parser:** safely access parsedFile properties to handle potential undefined values ([e995f97](https://github.com/Viren070/AIOStreams/commit/e995f97e2f43063f7e69b179237279d5aaba51e8)) +- pass user provided TMDB access token to TMDBMetadata ([d2f4dc1](https://github.com/Viren070/AIOStreams/commit/d2f4dc1b8dbe17c17e80ac4698398af5a3757cc9)) +- potentially fix regex sorting ([9771c7b](https://github.com/Viren070/AIOStreams/commit/9771c7be7f8e19c25cebac4439c42a7ae6766459)) +- potentially fix sorting ([887d285](https://github.com/Viren070/AIOStreams/commit/887d2850f23e883734f2b56d4545e546c07a5694)) +- prefix addon instance ID to ensure uniquenes of stream id ([009d7d1](https://github.com/Viren070/AIOStreams/commit/009d7d1cf40a1e4041690d5c217b34003f7d51a2)) +- prevent fetching from aiostreams instance of the same user ([963a3f7](https://github.com/Viren070/AIOStreams/commit/963a3f7064abf0387d0ce49ffb7773659ea88577)) +- prevent mutating options object in OrionPreset ([f8b08b3](https://github.com/Viren070/AIOStreams/commit/f8b08b3093e49e50acd52aed439ed3e5c7a0674b)) +- prevent pushing errors for general type support to avoid blocking requests to other addons ([b390534](https://github.com/Viren070/AIOStreams/commit/b390534dae906235836c3fc4a43b3db27dee8324)) +- reduce timeout duration for resetting values in AddonModal to ensure new modals properly keep their initial values ([9213d78](https://github.com/Viren070/AIOStreams/commit/9213d781d176101f8e7826cc187e44188cf346c4)) +- refine year matching logic in title filtering for movies ([21f1d3e](https://github.com/Viren070/AIOStreams/commit/21f1d3e0210c84936d2c06b238ede488715d0165)) +- remove check of non-existent url option in OpenSubtitlesPreset ([dbd5dd6](https://github.com/Viren070/AIOStreams/commit/dbd5dd6bd73abf26ad4c408c17af653dae6ed949)) +- remove debug logging in getServiceCredentialDefault ([27932a5](https://github.com/Viren070/AIOStreams/commit/27932a54ff683faa01052e5cec1cf450ec5d8603)) +- remove emojis from filename ([b8bbb17](https://github.com/Viren070/AIOStreams/commit/b8bbb178a8c66eaad6fc5b1637492b1358f12645)) +- remove log pollution ([5b72292](https://github.com/Viren070/AIOStreams/commit/5b7229299e0f0dfd80a57ed4367a554574b8a9d8)) +- remove max connections limit from PostgreSQL pool configuration ([bff13dc](https://github.com/Viren070/AIOStreams/commit/bff13dc22c59bb358926867bceefceca1c36574d)) +- remove unecessary formatBytes function and display actual max size ([5c9406f](https://github.com/Viren070/AIOStreams/commit/5c9406f88e13e538e3683b82c8045899498ec185)) +- remove unnecessary UUID assignment in UserRepository class ([c8224bc](https://github.com/Viren070/AIOStreams/commit/c8224bc21e496686971e99176d48eb1c859d675e)) +- remove unused regex environment variables from status route ([2fd0522](https://github.com/Viren070/AIOStreams/commit/2fd05220a480bd70fca5d383d7477be6e7eb5fb2)) +- remove unused regex fields from StatusResponseSchema ([dfef789](https://github.com/Viren070/AIOStreams/commit/dfef7895b2ad0c2c0b879ad0ce7e1d4410431eeb)) +- replace crypto random UUID generation with a simple counter for unique ID assignment in StreamParser ([11b2204](https://github.com/Viren070/AIOStreams/commit/11b220443c67c22de475ab22d32ced033e083740)) +- replace hardcoded SUPPORTED_RESOURCES with supportedResources in NuvioStreamsPreset ([4eeeb59](https://github.com/Viren070/AIOStreams/commit/4eeeb59186668ad1b2d7975e21ea7b90b501bfa7)) +- replace incorrect hardcoded SUPPORTED_RESOURCES with supportedResources in DebridioPreset ([ed73f5d](https://github.com/Viren070/AIOStreams/commit/ed73f5de6c66ef408f513f54cafee8d2a22e6965)) +- restore TMDBMetadata import in main.ts and enable metadata export in index.ts ([2cd7d4d](https://github.com/Viren070/AIOStreams/commit/2cd7d4dfd1ada052dad8b21f79a2ffd24eafc178)) +- return original URL when no modifications are made in CometStreamParser ([cbfb4b7](https://github.com/Viren070/AIOStreams/commit/cbfb4b7838f5a91a401ce7f4d5b5c1a566b222ee)) +- return url when no modifications are needed in JackettioStreamParser ([4791f36](https://github.com/Viren070/AIOStreams/commit/4791f360da880758ab5d227d2ada8f27ad2f9c64)) +- **rpdbCatalogs:** correct spelling of 'movies' to 'movie' ([9e1960a](https://github.com/Viren070/AIOStreams/commit/9e1960a6ddd19e6ad705cab30539d6f2c2107321)) +- **rpdb:** improve id parsing logic and include type for tmdb ([18621ca](https://github.com/Viren070/AIOStreams/commit/18621ca646bb3765963849fd10e25866b253759d)) +- safely access catalogs options and default to false for streamfusion ([9c48fad](https://github.com/Viren070/AIOStreams/commit/9c48fad6a620e30730b9da9a8074daf016e24105)) +- save preferred values when adjusting from select menu ([2b329fe](https://github.com/Viren070/AIOStreams/commit/2b329fe6feabdcefcb4c4603a772ec8cf8791a0b)) +- set default sizeK value to 1024 in StreamParser and remove overridden method in TorrentioParser ([a09dcea](https://github.com/Viren070/AIOStreams/commit/a09dcead9bc6107b25dd8829c66d0b49d1dc49e8)) +- set public IP to undefined when empty ([32f90fb](https://github.com/Viren070/AIOStreams/commit/32f90fb0f3e5a067ba8f3486bfeb366387b28f01)) +- simplify and improve validation checks ([dde5af0](https://github.com/Viren070/AIOStreams/commit/dde5af02d9dab1634a2c7cd9e9346b4707011848)) +- simplify duration formatting in getTimeTakenSincePoint function ([f1afe5f](https://github.com/Viren070/AIOStreams/commit/f1afe5f5a26024b6fbc860abbba902da201996d7)) +- truncate addon name and update modal value states to handle changes in props ([14f56d1](https://github.com/Viren070/AIOStreams/commit/14f56d12479580033123bbbd312b5bc4ff67f4df)) +- update addon name formatting in AIOStreamsStreamParser to prefix aiostreams addon name ([eefa184](https://github.com/Viren070/AIOStreams/commit/eefa184b7c0e8e3a2f7779360da94254858f6e6f)) +- update AIOStream schema export and enhance AIOStreamsStreamParser with validation ([edc310f](https://github.com/Viren070/AIOStreams/commit/edc310fe5f213b4e03976aeb815fd51c81be7976)) +- update Bengali regex to not match ben the men ([90980c7](https://github.com/Viren070/AIOStreams/commit/90980c76363abdec3d1f53ad2b27eb4181bd8131)) +- update cached sorting to prefer all streams that are not explicitly marked as uncached ([b16f36d](https://github.com/Viren070/AIOStreams/commit/b16f36d4ea80d4a842281814239aaa23430c5c65)) +- update default apply mode for cached and uncached filters from 'and' to 'or' ([3fe5027](https://github.com/Viren070/AIOStreams/commit/3fe50274dcfdfaea68103f6477cbc30563327f65)) +- update default value for ADDON_PASSWORD and SECRET_KEY ([65a4c91](https://github.com/Viren070/AIOStreams/commit/65a4c9177cc8da04990c82fbde939fa4c5452637)) +- update Dockerfile to use default port fallback for healthcheck and expose ([0ffca95](https://github.com/Viren070/AIOStreams/commit/0ffca9560460a640b763c2a4cabdd3c4a420b6ca)) +- update duration state to use milliseconds and adjust input handling ([3d43673](https://github.com/Viren070/AIOStreams/commit/3d43673a66f695a1a7547d95a1ef36cd45d27864)) +- update error handling in OrionStreamParser to throw an error instead of returning an error stream for partial success ([bb30b4a](https://github.com/Viren070/AIOStreams/commit/bb30b4a19a66c6eb8c3b408e64eea33d927bd8ea)) +- update error message for missing addons to suggest reinstallation ([78a0d7f](https://github.com/Viren070/AIOStreams/commit/78a0d7f788aaa4ea10e2e69ccbd5d79c72bb17d1)) +- update formatter preview ([f3d84bc](https://github.com/Viren070/AIOStreams/commit/f3d84bc9778a345e837a698c68c2e28ea71752a4)) +- update GDriveFormatter to use 'inLibrary' instead of 'personal' ([f6ef47f](https://github.com/Viren070/AIOStreams/commit/f6ef47f3a8f7c781a084ffb3d5ba26615edf77fa)) +- update handling of default/forced values ([c60ef6f](https://github.com/Viren070/AIOStreams/commit/c60ef6fde9c0de6abc98f2cb2de2a7e981719f3e)) +- update help text to include selected proxy name rather than mediaflow only ([af24d67](https://github.com/Viren070/AIOStreams/commit/af24d674d1c265f9fe9a37f4528548b25790638e)) +- update MediaFlowProxy to conditionally include api_password in proxy URL for /proxy/ip endpoint ([d0faecc](https://github.com/Viren070/AIOStreams/commit/d0faecc563cd7d2c9ed52310ce658b13ee3fc076)) +- update MediaFusion logo URL ([3648f94](https://github.com/Viren070/AIOStreams/commit/3648f94d0acdebfde842818335f473fb4564d0e7)) +- update NameableRegex schema to allow empty name and remove useless regex check ([96d355f](https://github.com/Viren070/AIOStreams/commit/96d355ffdabeb4a308b0f99a9f9a198b8a7d8733)) +- update Peerflix logo URL ([ab1c216](https://github.com/Viren070/AIOStreams/commit/ab1c21695e596d8fb482f299d31bf44f51ba78fa)) +- update seeder condition in TorrentioFormatter to allow zero seeders ([c890671](https://github.com/Viren070/AIOStreams/commit/c890671a444f6d82e48d9fdce1308913779d7123)) +- update service links ([fea2675](https://github.com/Viren070/AIOStreams/commit/fea26752ac521415bf8f23ae022d4ecad7b7e731)) +- update size filter constraints to allow zero values ([4a8e9c3](https://github.com/Viren070/AIOStreams/commit/4a8e9c3f7d2d463c0e800e542ef63ad0dab813b7)) +- update social link from Buy Me a Coffee to Ko-fi in DcUniversePreset ([671567c](https://github.com/Viren070/AIOStreams/commit/671567cb433a4912e472d02cf975a1f8037ff223)) +- update table schema ([f3b4088](https://github.com/Viren070/AIOStreams/commit/f3b4088397a7a09bfc0199bcbf769262a0cb1f75)) +- update user data merging logic in configuration import ([5ebb539](https://github.com/Viren070/AIOStreams/commit/5ebb539a3e2e5d623a3682dfeeb626781bb2dde0)) +- update user data reset logic ([9bd9810](https://github.com/Viren070/AIOStreams/commit/9bd9810a7a11132c814024e5182229135e23b42f)) +- use correct input change handlers ([6f3013c](https://github.com/Viren070/AIOStreams/commit/6f3013cdc2883ef9214538bb9cafba475f692604)) +- use nullish coalescing for seeder info in formatter to allow values of 0 ([3e5d581](https://github.com/Viren070/AIOStreams/commit/3e5d581cb0861bfd09a26dbb4bfc318abb579d9a)) +- use structuredClone for config decryption to ensure immutability ([a67603d](https://github.com/Viren070/AIOStreams/commit/a67603d669439465756809b3e1ee9c2637a7bcc5)) +- wrap handling for join case in block ([85a7775](https://github.com/Viren070/AIOStreams/commit/85a777544593b9a76d7cb8930db8e0321e6511fa)) +- wrap switch cases in blocks ([16b208b](https://github.com/Viren070/AIOStreams/commit/16b208b05b2450771834954cd54a193af79fdc2d)) +- **wrapper:** allow empty arrays as valid input in wrapper class ([c64a4f4](https://github.com/Viren070/AIOStreams/commit/c64a4f43ceb1b1eb85658a919ce3759df81556a9)) +- **wrapper:** enhance error logging for manifest and resource parsing by using formatZodError ([ffc974e](https://github.com/Viren070/AIOStreams/commit/ffc974ede622e970fc5f7396d4f1d1658726228a)) + +## [1.22.0](https://github.com/Viren070/AIOStreams/compare/v1.21.1...v1.22.0) (2025-05-22) + +### Features + +- pass `baseUrl` in Easynews++ config and add optional `EASYNEWS_PLUS_PLUS_PUBLIC_URL`. ([b41e210](https://github.com/Viren070/AIOStreams/commit/b41e210c04777b349629dc98f28982bfb2e54886)) +- stremthru improvements ([#172](https://github.com/Viren070/AIOStreams/issues/172)) ([72b5ab6](https://github.com/Viren070/AIOStreams/commit/72b5ab648e511220d7ff8b4bf453db94bb952b30)) diff --git a/CONFIGURING.md b/CONFIGURING.md new file mode 100644 index 0000000000000000000000000000000000000000..b09ddf7937bbd1acd88f798aa256f06b4b250bd0 --- /dev/null +++ b/CONFIGURING.md @@ -0,0 +1,45 @@ +> [!NOTE] +> This table may be outdated, please look at [settings.ts](https://github.com/Viren070/AIOStreams/blob/main/packages/utils/src/settings.ts) for the full list of environment variables you can set. + +| Environment Variable | Default Value | Description | +|------------------------------------|------------------------------------------------------|---------------------------------------------------------------------------------------------| +| `ADDON_NAME` | `AIOStreams` | The name of the addon. | +| `ADDON_ID` | `aiostreams.viren070.com` | The unique identifier for the addon. | +| `PORT` | `3000` | The port on which the server runs. | +| `BRANDING` | `undefined` | Custom branding for the addon, displayed at the top of the configuration page. **This is a BUILD TIME environment variable.** | +| `SECRET_KEY` | Empty string (`''`) | The secret key used for encryption or sensitive operations. `openssl rand -hex 32` or `[System.Guid]::NewGuid().ToString("N").Substring(0, 32) + [System.Guid]::NewGuid().ToString("N").Substring(0, 32)` can be used to generate a new secret key for Linux/MacoS and Windows respectively. | +| `CUSTOM_CONFIGS` | Empty string (`''`) | Custom configurations in JSON format, using the alias as the key, and the encoded/encrypted string as the value. e.g. {"default": "eyJyZXNvbHV0....", "rd": "E-affedc...}.

In this case, using /default/manifest.json would use the configuration stored at the `default` key.

To easily generate the value for this environment variable, head to /custom-config-generator on your instance to find a tool that outputs the necessary value based on your configurations. | +| `DISABLE_CUSTOM_CONFIG_GENERATOR_ROUTE` | `false` | Whether to disable the /custom-config-generator route | +| `COMET_URL` | `https://comet.elfhosted.com/` | The URL for the Comet addon. You can replace this with your self-hosted instance of Comet. | +| `MEDIAFUSION_URL` | `https://mediafusion.elfhosted.com/` | The URL for the MediaFusion addon | +| `MEDIAFUSION_API_PASSWORD` | Empty string (`''`) | The API_PASSWORD variable you set for your self-hosted MediaFusion instance. This is required if you want AIOStreams to generate configurations for MediaFusion based on the services you entered. If you provide the override url option for MediaFusion, this environment variable isn't required | +| `TORRENTIO_URL` | `https://torrentio.strem.fun/` | The URL for the Torrentio addon. | +| `TORBOX_STREMIO_URL` | `https://stremio.torbox.app/` | The URL for the Torbox Stremio addon. | +| `EASYNEWS_URL` | `https://ea627ddf0ee7-easynews.baby-beamup.club/` | The URL for the Easynews addon. | +| `EASYNEWS_PLUS_URL` | `https://b89262c192b0-stremio-easynews-addon.baby-beamup.club/` | The URL for the Easynews Plus addon. This can be replaced with your self-hosted instance | +| `ADDON_PROXY` | Empty string (`''`) | You can run the requests AIOStreams makes to other addons through a proxy such as https://github.com/cmj2002/warp-docker | +| `DEFAULT_MEDIAFLOW_URL` | Empty string (`''`) | You can set a default MediaFlow URL. All configurations made at an instance with this enabled will use this MediaFlow URL if it is not overriden by a user-set URL at the configure page | +| `DEFAULT_MEDIAFLOW_API_PASSWORD` | Empty string (`''`) | The API password for the default MediaFlow URL. | +| `DEFAULT_MEDIAFLOW_PUBLIC_IP` | Empty string (`''`) | Public IP for the default MediaFlow instance. This IP is forwarded to other addons | +| `MAX_ADDONS` | `15` | Maximum number of addons allowed. | +| `CACHE_STREAM_RESULTS` | `true` | Whether to cache the responses from addons for a specific requests. Only useful when the exact same request with the same debrid service configuration is repeated within a small timeframe. This can end up overwriting the MediaFlow public IPs depending on how many users are using the instance | +| `CACHE_STREAM_RESULTS_TTL` | `600` | The time to live (TTL) for cached stream responses in seconds. Cache that becomes older than this time is discarded | +| `CACHE_MEDIAFLOW_IP_TTL` | `900` | The time to live (TTL) for cached public IPs for the same MediaFlow URL and password. | +| `MAX_CACHE_SIZE` | `1024` | Maximum number of items the memory cache can hold. The cache stores streams from addon responses (for 10 minutes) and MediaFlow Public IPs (for 5 minutes). | +| `MAX_KEYWORD_FILTERS` | `30` | The maximum number of individual filters that you are allowed to enter for all keyword filters | +| `MAX_MOVIE_SIZE` | `161061273600` (150 GiB) | The maximum movie size that the user can set with the slider at the configuration page | +| `MAX_EPISODE_SIZE` | `16106127360` (15 GiB) | The maximum episode size that the user can set with the slider at the configuration page | +| `MAX_TIMEOUT` | `50000` | Maximum timeout that can be entered by the user in the configuration options | +| `MIN_TIMEOUT` | `1000` | Minimum timeout that can be entered by the user in the configuration options | +| `MEDIAFLOW_IP_TIMEOUT` | `30000` | The timeout for public IP requests to MediaFlow. When AIOStreams fails to get the IP, it will not make the request to the addon. +| `DEFAULT_TIMEOUT` | `15000` | The value of this environment variable applies to all addon requests by default, unless overriden by an addon specific environment variable.

What this means is that this value essentially controls the time you wait for AIOStreams to response. As AIOStreams barely takes any time for its post-sorting and filtering. If all timeouts are set to 5000ms, the addon is forced to respond within 5 seconds, as all addon requests are carried out concurrently. | +| `DEFAULT_TORRENTIO_TIMEOUT` | `5000` | Default timeout for Torrentio requests (in milliseconds). | +| `DEFAULT_TORBOX_TIMEOUT` | `15000` | Default timeout for Torbox requests (in milliseconds). | +| `DEFAULT_COMET_TIMEOUT` | `15000` | Default timeout for Comet requests (in milliseconds). | +| `DEFAULT_MEDIAFUSION_TIMEOUT` | `15000` | Default timeout for MediaFusion requests (in milliseconds). | +| `DEFAULT_EASYNEWS_TIMEMOUT` | `15000` | Default timeout for Easynews requests (in milliseconds). | +| `DEFAULT_EASYNEWS_PLUS_TIMEMOUT` | `15000` | Default timeout for Easynews Plus requests (in milliseconds). | +| `SHOW_DIE` | `true` | Whether to display the die emoji in AIOStreams results | +| `LOG_SENSITIVE_INFO` | `false` | Whether to log sensitive information. | +| `DISABLE_TORRENTIO` | `false` | Whether to disable adding Torrentio as an addon, through override URLs, custom addons, or through the public ElfHosted instance of StremThru | +| `DISABLE_TORRENTIO_MESSAGE` | `''The Torrentio addon has been disabled, please remove it to use this addon.'`| The message shown when `DISABLE_TORRENTIO` is `true` and Torrentio is present in the configuration | diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..e43124238a430b3458b34f00044542befabfe131 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,58 @@ +FROM node:22-alpine AS builder + +WORKDIR /build + +# Copy LICENSE file. +COPY LICENSE ./ + +# Copy the relevant package.json and package-lock.json files. +COPY package*.json ./ +COPY packages/server/package*.json ./packages/server/ +COPY packages/core/package*.json ./packages/core/ +COPY packages/frontend/package*.json ./packages/frontend/ + +# Install dependencies. +RUN npm install + +# Copy source files. +COPY tsconfig.*json ./ + +COPY packages/server ./packages/server +COPY packages/core ./packages/core +COPY packages/frontend ./packages/frontend +COPY scripts ./scripts +COPY resources ./resources + + +# Build the project. +RUN npm run build + +# Remove development dependencies. +RUN npm --workspaces prune --omit=dev + +FROM node:22-alpine AS final + +WORKDIR /app + +# Copy the built files from the builder. +# The package.json files must be copied as well for NPM workspace symlinks between local packages to work. +COPY --from=builder /build/package*.json /build/LICENSE ./ + +COPY --from=builder /build/packages/core/package.*json ./packages/core/ +COPY --from=builder /build/packages/frontend/package.*json ./packages/frontend/ +COPY --from=builder /build/packages/server/package.*json ./packages/server/ + +COPY --from=builder /build/packages/core/dist ./packages/core/dist +COPY --from=builder /build/packages/frontend/out ./packages/frontend/out +COPY --from=builder /build/packages/server/dist ./packages/server/dist + +COPY --from=builder /build/resources ./resources + +COPY --from=builder /build/node_modules ./node_modules + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:${PORT:-3000}/api/v1/status || exit 1 + +EXPOSE ${PORT:-3000} + +ENTRYPOINT ["npm", "run", "start"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..467004ab4253747fd3c91cc49358a3d458f6c5ec --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Viren070 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 2a0bad22ad2daeec7c05cb04a0a2a19999038869..2dd5c06c21bf02a719f592334601601373cf0d92 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,158 @@ +

+ AIOStreams Logo +

+ +

AIOStreams

+ +

+ One addon to rule them all. +
+ AIOStreams consolidates multiple Stremio addons and debrid services into a single, highly customisable super-addon. +

+ +

+ + Build Status + + + Latest Release + + + GitHub Stars + + + Docker Pulls + + + Discord Server + +

+ +--- + +## ✨ What is AIOStreams? + +AIOStreams was created to give users ultimate control over their Stremio experience. Instead of juggling multiple addons with different configurations and limitations, AIOStreams acts as a central hub. It fetches results from all your favorite sources, then filters, sorts, and formats them according to *your* rules before presenting them in a single, clean list. + +Whether you're a casual user who wants a simple, unified stream list or a power user who wants to fine-tune every aspect of your results, AIOStreams has you covered. + + +

+ AIOStreams in action +

+ +## 🚀 Key Features + +### 🔌 All Your Addons, One Interface +- **Unified Results**: Aggregate streams from multiple addons into one consistently sorted and formatted list. +- **Simplified Addon Management**: AIOStreams features a built-in addon marketplace. Many addons require you to install them multiple times to support different debrid services. AIOStreams handles this automatically. Just enable an addon from the marketplace, and AIOStreams dynamically applies your debrid keys, so you only have to configure it once. +- **Automatic Updates**: Because addon manifests are generated dynamically, you get the latest updates and fixes without ever needing to reconfigure or reinstall. +- **Custom Addon Support**: Add *any* Stremio addon by providing its configured URL. If it works in Stremio, it works here. +- **Full Stremio Support**: AIOStreams doesn't just manage streams; it supports all Stremio resources, including catalogs, metadata, and even addon catalogs. + +

+ Addon Configuration +

+ +### 🔬 Advanced Filtering & Sorting Engine +Because all addons are routed through AIOStreams, you only have to **configure your filters and sorting rules once**. This powerful, centralized engine offers far more options and flexibility than any individual addon. + +- **Granular Filtering**: Define `include` (prevents filtering), `required`, or `excluded` rules for a huge range of properties: + - **Video/Audio**: Resolution, quality, encodes, visual tags (`HDR`, `DV`), audio tags (`Atmos`), and channels. + - **Source**: Stream type (`Debrid`, `Usenet`, `P2P`), language, seeder ranges, and cached/uncached status (can be applied to specific addons/services). +- **Preferred Lists**: Manually define and order a list of preferred properties to prioritize certain results, for example, always showing `HDR` streams first. +- **Keyword & Regex Filtering**: Filter by simple keywords or complex regex patterns matched against filenames, indexers and release groups for ultimate precision. +- **Accurate Title Matching**: Leverages the TMDB API to precisely match titles, years, and season/episode numbers, ensuring you always get the right content. This can be granularly applied to specific addons or content types. +- **Powerful Conditional Engine**: Create dynamic rules with a simple yet powerful expression language. + - *Example*: Only exclude 720p streams if more than five 1080p streams are available: `count(resolution(streams, '1080p')) > 5 ? resolution(streams, '720p') : false`. + - Check the wiki for a [full function reference](https://github.com/Viren070/AIOStreams/wiki/Stream-Expression-Language). +- **Customisable Deduplication**: Choose how duplicate streams are detected: by filename, infohash, and a unique "smart detect" hash generated from certain file attributes. +- **Sophisticated Sorting**: + - Build your perfect sort order using any combination of criteria. + - Define separate sorting logic for movies, series, anime, and even for cached vs. uncached results. + - The sorting system automatically uses the rankings from your "Preferred Lists". + +### 🗂️ Unified Catalog Management +Take control of your Stremio home page. AIOStreams lets you manage catalogs from all your addons in one place. +- **Rename**: Rename both the name and the type of the catalog to whatever you want. (e.g. Changing Cinemeta's `Popular - Movies` to `Popular - 📺`) +- **Reorder & Disable**: Arrange catalogs in your preferred order or hide the ones you don't use. +- **Shuffle Catalogs**: Discover new content by shuffling the results of any catalog. You can even persist the shuffle for a set period. +- **Enhanced Posters**: Automatically apply high-quality posters from [RPDB](https://rpdb.net/) to catalogs that provide a supported metadata source, even if the original addon doesn't support it. + +

+ + Filtering and Sorting Rules +

+ +### 🎨 Total Customization +- **Custom Stream Formatting**: Design exactly how stream information is displayed using a powerful templating system. +- **Live Preview**: See your custom format changes in real-time as you build them. +- **Predefined Formats**: Get started quickly with built-in formats, some created by me and others inspired by other popular addons like Torrentio and the TorBox Stremio Addon. +- **[Custom Formatter Wiki](https://github.com/Viren070/AIOStreams/wiki/Custom-Formatter)**: Dive deep into the documentation to create your perfect stream title. + + +

+ Custom Formatter UI +

+ +

+ + This format was created by one of our community members in the + Discord Server + +

+ + +### 🛡️ Proxy & Performance +- **Proxy Integration**: Seamlessly proxy streams through **[MediaFlow Proxy](https://github.com/mhdzumair/mediaflow-proxy)** or **[StremThru](https://github.com/MunifTanjim/stremthru)**. +- **Bypass IP Restrictions**: Essential for services that limit simultaneous connections from different IP addresses. +- **Improve Compatibility**: Fixes playback issues with certain players (like Infuse) and addons. + +And much much more... + +## 🚀 Getting Started + +Setting up AIOStreams is simple. + +1. **Choose a Hosting Method** + - **🔓 Public Instance**: Use the **[Community Instance (Hosted by ElfHosted)](https://aiostreams.elfhosted.com/configure)**. It's free, but rate-limited and has Torrentio disabled. + - **🛠️ Self-Host / Paid Hosting**: For full control and no rate limits, host it yourself (Docker) or use a paid service like **[ElfHosted](https://store.elfhosted.com/product/aiostreams/elf/viren070/)** (using this link supports the project!). + +2. **Configure Your Addon** + - Open the `/stremio/configure` page of your AIOStreams instance in a web browser. + - Enable the addons you use, add your debrid API keys, and set up your filtering, sorting, and formatting rules. + +3. **Install** + - Click the "Install" button. This will open your Stremio addon compatible app and add your newly configured AIOStreams addon. + +For detailed instructions, check out the Wiki: +- **[Deployment Guide](https://github.com/Viren070/AIOStreams/wiki/Deployment)** +- **[Configuration Guide](https://github.com/Viren070/AIOStreams/wiki/Configuration)** + --- -title: Monkastremio -emoji: 😻 -colorFrom: gray -colorTo: yellow -sdk: docker -pinned: false -license: mit -short_description: streams for monkaS + +## ❤️ Support the Project + +AIOStreams is a passion project developed and maintained for free. If you find it useful, please consider supporting its development. + +- ⭐ **Star the Repository** on [GitHub](https://github.com/Viren070/AIOStreams). +- ⭐ **Star the Addon** in the [Stremio Community Catalog](https://beta.stremio-addons.net/addons/aiostreams). +- 🤝 **Contribute**: Report issues, suggest features, or submit pull requests. +- ☕ **Donate**: + - **[Ko-fi](https://ko-fi.com/viren070)** + - **[GitHub Sponsors](https://github.com/sponsors/Viren070)** + --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +## ⚠️ Disclaimer + +AIOStreams is a tool for aggregating and managing data from other Stremio addons. It does not host, store, or distribute any content. The developer does not endorse or promote access to copyrighted content. Users are solely responsible for complying with all applicable laws and the terms of service for any addons or services they use with AIOStreams. + +## 🙏 Credits + +This project wouldn't be possible without the foundational work of many others in the community, especially those who develop the addons that AIOStreams integrates. Special thanks to the developers of all the integrated addons, the creators of [mhdzumair/mediaflow-proxy](https://github.com/mhdzumair/mediaflow-proxy) and [MunifTanjim/stremthru](https://github.com/MunifTanjim/stremthru), and the open-source projects that inspired parts of AIOStreams' design: + +* UI Components and issue templates adapted with permission from [5rahim/seanime](https://github.com/5rahim/seanime) (which any anime enthusiast should definitely check out!) +* [sleeyax/stremio-easynews-addon](https://github.com/sleeyax/stremio-easynews-addon) for the projects initial structure +* Custom formatter system inspired by and adapted from [diced/zipline](https://github.com/diced/zipline). +* Condition engine powered by [expr-eval](https://github.com/silentmatt/expr-eval) diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000000000000000000000000000000000000..112138f94b95c6ecb7e31e79130465426cd82706 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,17 @@ +services: + aiostreams: + image: ghcr.io/viren070/aiostreams:latest + container_name: aiostreams + restart: unless-stopped + ports: + - 3000:3000 + env_file: + - .env + volumes: + - ./data:/app/data + healthcheck: + test: wget -qO- http://localhost:3000/api/v1/status + interval: 1m + timeout: 10s + retries: 5 + start_period: 10s diff --git a/fetch-proxy.js b/fetch-proxy.js new file mode 100644 index 0000000000000000000000000000000000000000..44be797a9e280592717f6fbd03a480cdcec1d207 --- /dev/null +++ b/fetch-proxy.js @@ -0,0 +1,103 @@ +const express = require('express'); +const cors = require('cors'); +const fetch = require('node-fetch'); + +const app = express(); +app.use(cors()); +app.use(express.json()); + +// Handle GET requests to /fetch?url=... (for Cloudflare Worker) +app.get('/fetch', async (req, res) => { + try { + const { url, method = 'GET' } = req.query; + + if (!url) { + return res.status(400).json({ error: 'URL parameter is required' }); + } + + console.log(`GET /fetch - Proxying request to: ${url}`); + + const response = await fetch(url, { + method: method.toUpperCase(), + headers: { + 'User-Agent': 'AIOStreams-Proxy/1.0', + 'Accept': '*/*', + }, + }); + + const data = await response.text(); + + // Forward the status code and content + res.status(response.status); + + // Forward important headers + const contentType = response.headers.get('content-type'); + if (contentType) { + res.set('Content-Type', contentType); + } + + res.send(data); + } catch (error) { + console.error('GET Proxy error:', error); + res.status(500).json({ error: error.message }); + } +}); + +// Handle POST requests to / (existing functionality) +app.post('/', async (req, res) => { + try { + const { url, method = 'GET', headers = {}, body } = req.body; + + if (!url) { + return res.status(400).json({ error: 'URL is required in request body' }); + } + + console.log(`POST / - Proxying request to: ${url}`); + + const response = await fetch(url, { + method, + headers: { + 'User-Agent': 'AIOStreams-Proxy/1.0', + ...headers, + }, + body: body ? JSON.stringify(body) : undefined, + }); + + const data = await response.text(); + + res.status(response.status).send(data); + } catch (error) { + console.error('POST Proxy error:', error); + res.status(500).json({ error: error.message }); + } +}); + +// Health check endpoint +app.get('/health', (req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +// Root endpoint with usage info +app.get('/', (req, res) => { + res.json({ + message: 'AIOStreams Fetch Proxy', + endpoints: { + 'GET /fetch?url=': 'Proxy a GET request to the specified URL', + 'POST /': 'Proxy a request with full control (url, method, headers, body in JSON)', + 'GET /health': 'Health check' + }, + examples: { + get: '/fetch?url=https://example.com', + post: 'POST / with {"url": "https://example.com", "method": "GET"}' + } + }); +}); + +const PORT = process.env.PORT || 3128; +app.listen(PORT, () => { + console.log(`Fetch proxy running on port ${PORT}`); + console.log(`Endpoints:`); + console.log(` GET /fetch?url= - Simple proxy for GET requests`); + console.log(` POST / - Full proxy with method/headers/body control`); + console.log(` GET /health - Health check`); +}); \ No newline at end of file diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000000000000000000000000000000000000..9d30e68f3fe70ecc06648d56759c951e08931a04 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,234 @@ +# =================================================================== +# nginx.conf (or a file under /etc/nginx/conf.d/aiostreams.conf) +# =================================================================== +# +# This config listens on port 443 for aiostreams.bolabaden.org (HTTPS), +# and proxies all requests to one of three upstreams: +# - aiostreams-cf.bolabaden.org +# - aiostreams-koyeb.bolabaden.org +# - aiostreams.bolabaden.duckdns.org +# +# If any one of the three backends becomes unreachable (TCP error, 5xx, etc.), +# NGINX will mark it “down” for a short period and continue sending new requests +# to the remaining healthy backends. When it recovers, NGINX will automatically +# send traffic to it again. +# +# We use “ip_hash” so that each client IP tends to hit the same backend (session affinity). +# If that backend dies, NGINX will transparently route the next request to another healthy +# backend. No DNS‐level trickery is used here—NGINX does all proxying at L7. +# +# Replace the SSL certificate/key paths below with your actual .crt/.key files. + +################################################################################ +# 1) GLOBAL CONTEXT / EVENTS +################################################################################ + +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log notice; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + + +################################################################################ +# 2) HTTP CONTEXT +################################################################################ + +http { + # mime types, log format, etc. can be inherited from defaults. + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Tuning timeouts for proxy connections: + send_timeout 30s; + keepalive_timeout 65s; + client_max_body_size 10m; + + # ------------------------------------------------------------------------ + # 2.a) Define the upstream block with all three backends + # “ip_hash” ensures session‐affinity by client IP. If that server fails, + # NGINX (passively) marks it down (max_fails + fail_timeout) and sends + # to the next available IP. + # + # Note: We are proxying to each backend over HTTPS (port 443), + # so we append “:443 ssl” to each server line. NGINX by default will + # initiate an SSL hand‐shake to the upstream. + # ------------------------------------------------------------------------ + + upstream aiostreams_pool { + # Use ip_hash for basic session affinity (same client IP → same backend): + ip_hash; + + # Primary backends. max_fails=3 means that if 3 consecutive attempts + # to connect or pass data to a given backend fail within “fail_timeout” + # (30s), that backend is marked as unavailable for 30s. + server aiostreams-cf.bolabaden.org:443 max_fails=3 fail_timeout=30s; + server aiostreams-koyeb.bolabaden.org:443 max_fails=3 fail_timeout=30s; + server aiostreams.bolabaden.duckdns.org:443 max_fails=3 fail_timeout=30s; + } + + + # ------------------------------------------------------------------------ + # 2.b) Redirect all HTTP (port 80) → HTTPS (port 443). + # This ensures clients typing “http://…” get automatically sent + # to the secure endpoint. + # ------------------------------------------------------------------------ + server { + listen 80; + listen [::]:80; + server_name aiostreams.bolabaden.org; + + # Redirect every request to https://... preserving URI: + return 301 https://$host$request_uri; + } + + + # ------------------------------------------------------------------------ + # 2.c) Main HTTPS server block for aiostreams.bolabaden.org + # ------------------------------------------------------------------------ + server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name aiostreams.bolabaden.org; + + # -------------------------------------------------------------------- + # SSL Certificate / Key + # -------------------------------------------------------------------- + # + # You must replace these with the actual paths to your certificate + # and private key (e.g. from Let’s Encrypt or another CA). + # + ssl_certificate /etc/letsencrypt/live/aiostreams.bolabaden.org/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/aiostreams.bolabaden.org/privkey.pem; + + # (Optional but recommended) + # Tune SSL protocols/ciphers for security. Example: + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + # -------------------------------------------------------------------- + # Access and Error Logs (optional, but recommended for debugging) + # -------------------------------------------------------------------- + access_log /var/log/nginx/aiostreams.access.log combined; + error_log /var/log/nginx/aiostreams.error.log warn; + + # -------------------------------------------------------------------- + # Proxy Buffering / Timeouts (adjust to suit your app’s needs) + # -------------------------------------------------------------------- + proxy_buffer_size 16k; + proxy_buffers 4 32k; + proxy_busy_buffers_size 64k; + proxy_connect_timeout 5s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + proxy_buffering on; + proxy_buffering off; # ← turn OFF if your app does streaming / WebSockets + + # -------------------------------------------------------------------- + # Main Location: proxy everything (/) to the upstream pool + # -------------------------------------------------------------------- + location / { + # Pass requests to our upstream group: + proxy_pass https://aiostreams_pool; + + # Preserve the original Host header so the backends see “aiostreams.bolabaden.org” + # (if your backends require the Host to match their TLS certificate, you can also + # use “proxy_ssl_name aiostreams-cf.bolabaden.org;” per-backend via “proxy_set_header Host …”) + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # If a backend fails with certain HTTP status codes or connection errors, + # retry on the next available upstream. Here we retry on: timeout, HTTP 500, 502, 503, 504. + proxy_next_upstream error timeout http_502 http_503 http_504; + + # Number of retries before giving up: + proxy_next_upstream_tries 3; + + # When proxying to an HTTPS upstream, ensure the upstream’s TLS name matches: + proxy_ssl_server_name on; + + # (Optional) If your backends use self-signed certs, disable verification: + # proxy_ssl_verify off; + # + # Otherwise (recommended), keep verification ON and trust the system’s ca‐bundle: + proxy_ssl_verify on; + proxy_ssl_trusted_certificate /etc/ssl/certs/ca-bundle.crt; + } + + # -------------------------------------------------------------------- + # (Optional) Handle ACME (Let’s Encrypt) HTTP-01 challenges if you + # want to auto-provision certificates. Skip if you manage certificates manually. + # -------------------------------------------------------------------- + # location /.well-known/acme-challenge/ { + # root /var/www/letsencrypt; + # allow all; + # } + + # -------------------------------------------------------------------- + # (Optional) Return 404 for anything else: + # -------------------------------------------------------------------- + error_page 404 /404.html; + location = /404.html { + internal; + root /usr/share/nginx/html; + } + } + + # ------------------------------------------------------------------------ + # 2.d) (Optional) Upstream health check—passive only (built-in): + # NGINX will automatically mark a backend “down” after max_fails, + # and retry only after fail_timeout. If you need active health checks, + # you’d install the nginx‐upstream‐healthcheck module (third-party). + # ------------------------------------------------------------------------ +} + + +################################################################################ +# 3) NOTES ON HOW THIS WORKS +################################################################################ + +# 1) ip_hash +# - Guarantees “session affinity” by hashing the client’s IP and sending it +# to the same backend each time (so long as that backend is up). +# - If the chosen backend becomes unavailable (because of max_fails/fail_timeout), +# NGINX transparently sends the next request to a healthy backend. +# - No DNS changes are ever made; clients always talk to https://aiostreams.bolabaden.org, +# and NGINX does the upstream routing. + +# 2) max_fails / fail_timeout +# - “max_fails=3 fail_timeout=30s” means: +# • If 3 consecutive attempts to connect (or receive a response) to that backend +# fail within 30s, that backend is marked “unhealthy” and is skipped for the +# next 30 seconds. +# • After 30s passes, NGINX will retry the backend on a new request. +# - This is a passive health check—based on traffic failures. + +# 3) proxy_next_upstream +# - If NGINX sees a connection error, a timeout, or one of the configured status codes +# (5xx, 502, 503, 504), it immediately retries the request on the next healthy backend. +# - Use “proxy_next_upstream_tries 3;” to limit retries. + +# 4) proxy_ssl_server_name +# - When proxying via HTTPS, NGINX by default will send “Host”=IP to the upstream, +# which may not match the certificate on the backend. “proxy_ssl_server_name on;” +# causes NGINX to send SNI = the hostname in the “proxy_pass” URL (e.g. aiostreams-cf.bolabaden.org). + +# 5) If you require faster health checks (active probes), you could compile or install +# the “nginx_upstream_check_module” (third party). But the above passive checks +# are sufficient for most “any one of three must be up” scenarios. + +# 6) SSL Certificates +# - This example assumes you store Let’s Encrypt certificates under /etc/letsencrypt/… +# - If you haven’t yet obtained a certificate for aiostreams.bolabaden.org, do: +# certbot certonly --webroot -w /var/www/html -d aiostreams.bolabaden.org +# then update the “ssl_certificate” paths above accordingly. + +################################################################################ +# END OF CONFIG +################################################################################ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..1d68a09585c3747d291d2613f013e6449f10f61b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,14686 @@ +{ + "name": "aiostreams", + "version": "2.4.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aiostreams", + "version": "2.4.2", + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "devDependencies": { + "cross-env": "^7.0.3", + "prettier": "^3.3.2", + "ts-node": "^10.9.2", + "tsx": "^4.16.2", + "typescript": "^5.5.3", + "vitest": "^2.1.5" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aiostreams/core": { + "resolved": "packages/core", + "link": true + }, + "node_modules/@aiostreams/frontend": { + "resolved": "packages/frontend", + "link": true + }, + "node_modules/@aiostreams/server": { + "resolved": "packages/server", + "link": true + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@csstools/css-calc": "^2.1.2", + "@csstools/css-color-parser": "^3.0.8", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true + }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.0.2", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.2", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.0.8", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@csstools/color-helpers": "^5.0.2", + "@csstools/css-calc": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.4", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.3" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.3", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.3", + "license": "MIT", + "dependencies": { + "colorspace": "1.1.x", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/modifiers": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz", + "integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz", + "integrity": "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", + "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.2.tgz", + "integrity": "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", + "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", + "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", + "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", + "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", + "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", + "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", + "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", + "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", + "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", + "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", + "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", + "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", + "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", + "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", + "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", + "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", + "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", + "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", + "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", + "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", + "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", + "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", + "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", + "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", + "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", + "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.2.tgz", + "integrity": "sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", + "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.27.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.27.0.tgz", + "integrity": "sha512-G5JD9Tu5HJEu4z2Uo4aHY2sLV64B7CDMXxFzqzjl3NKd6RVzSXNoE80jk7Y0lJkTTkjiIhBAqmlYwjuBY3tvpA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz", + "integrity": "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.14.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.0.tgz", + "integrity": "sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.0.tgz", + "integrity": "sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.0", + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.26.28", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", + "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", + "license": "MIT" + }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "license": "MIT", + "optional": true + }, + "node_modules/@headlessui/react": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.4.tgz", + "integrity": "sha512-lz+OGcAH1dK93rgSMzXmm1qKOJkBUqZf1L4M8TWLNplftQD3IkoEDdUFNfAn4ylsN6WOTVtWaLmvmaHOUk1dTA==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.26.16", + "@react-aria/focus": "^3.20.2", + "@react-aria/interactions": "^3.25.0", + "@tanstack/react-virtual": "^3.13.9", + "use-sync-external-store": "^1.5.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/@headlessui/tailwindcss": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@headlessui/tailwindcss/-/tailwindcss-0.2.2.tgz", + "integrity": "sha512-xNe42KjdyA4kfUKLLPGzME9zkH7Q3rOZ5huFihWNWOQFxnItxPB3/67yBI8/qBfY8nwBRx5GHn4VprsoluVMGw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "tailwindcss": "^3.0 || ^4.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.1.tgz", + "integrity": "sha512-pn44xgBtgpEbZsu+lWf2KNb6OAf70X68k+yk69Ic2Xz11zHR/w24/U49XT7AeRwJ0Px+mhALhU5LPci1Aymk7A==", + "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.1.0" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.1.tgz", + "integrity": "sha512-VfuYgG2r8BpYiOUN+BfYeFo69nP/MIwAtSJ7/Zpxc5QF3KS22z8Pvg3FkrSFJBPNQ7mmcUcYQFBmEQp7eu1F8Q==", + "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.1.0" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.1.0.tgz", + "integrity": "sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==", + "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.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.1.0.tgz", + "integrity": "sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ==", + "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.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.1.0.tgz", + "integrity": "sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==", + "cpu": [ + "arm" + ], + "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.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.1.0.tgz", + "integrity": "sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==", + "cpu": [ + "arm64" + ], + "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.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.1.0.tgz", + "integrity": "sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==", + "cpu": [ + "ppc64" + ], + "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.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.1.0.tgz", + "integrity": "sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==", + "cpu": [ + "s390x" + ], + "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.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.1.0.tgz", + "integrity": "sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==", + "cpu": [ + "x64" + ], + "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.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.1.0.tgz", + "integrity": "sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==", + "cpu": [ + "arm64" + ], + "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.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.1.0.tgz", + "integrity": "sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==", + "cpu": [ + "x64" + ], + "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.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.1.tgz", + "integrity": "sha512-anKiszvACti2sGy9CirTlNyk7BjjZPiML1jt2ZkTdcvpLU1YH6CXwRAZCA2UmRXnhiIftXQ7+Oh62Ji25W72jA==", + "cpu": [ + "arm" + ], + "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.1.0" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.1.tgz", + "integrity": "sha512-kX2c+vbvaXC6vly1RDf/IWNXxrlxLNpBVWkdpRq5Ka7OOKj6nr66etKy2IENf6FtOgklkg9ZdGpEu9kwdlcwOQ==", + "cpu": [ + "arm64" + ], + "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.1.0" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.1.tgz", + "integrity": "sha512-7s0KX2tI9mZI2buRipKIw2X1ufdTeaRgwmRabt5bi9chYfhur+/C1OXg3TKg/eag1W+6CCWLVmSauV1owmRPxA==", + "cpu": [ + "s390x" + ], + "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.1.0" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.1.tgz", + "integrity": "sha512-wExv7SH9nmoBW3Wr2gvQopX1k8q2g5V5Iag8Zk6AVENsjwd+3adjwxtp3Dcu2QhOXr8W9NusBU6XcQUohBZ5MA==", + "cpu": [ + "x64" + ], + "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.1.0" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.1.tgz", + "integrity": "sha512-DfvyxzHxw4WGdPiTF0SOHnm11Xv4aQexvqhRDAoD00MzHekAj9a/jADXeXYCDFH/DzYruwHbXU7uz+H+nWmSOQ==", + "cpu": [ + "arm64" + ], + "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.1.0" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.1.tgz", + "integrity": "sha512-pax/kTR407vNb9qaSIiWVnQplPcGU8LRIJpDT5o8PdAx5aAA7AS3X9PS8Isw1/WfqgQorPotjrZL3Pqh6C5EBg==", + "cpu": [ + "x64" + ], + "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.1.0" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.1.tgz", + "integrity": "sha512-YDybQnYrLQfEpzGOQe7OKcyLUCML4YOXl428gOOzBgN6Gw0rv8dpsJ7PqTHxBnXnwXr8S1mYFSLSa727tpz0xg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.4.0" + }, + "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.1", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.1.tgz", + "integrity": "sha512-WKf/NAZITnonBf3U1LfdjoMgNO5JYRSlhovhRhMxXVdvWYveM4kM3L8m35onYIdh75cOMCo1BexgVQcCDzyoWw==", + "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.1", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.1.tgz", + "integrity": "sha512-hw1iIAHpNE8q3uMIRCgGOeDoz9KtFNarFLQclLxr/LK1VBkj8nby18RjFvr6aP7USRYAjTZW6yisnBWMX571Tw==", + "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/@internationalized/number": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.2.tgz", + "integrity": "sha512-E5QTOlMg9wo5OrKdHD6edo1JJlIoOsylh0+mbf0evi1tHJwMZfJSaBpGtnJV9N7w3jeiioox9EG/EWRWPh82vg==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "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==", + "dev": true, + "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/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/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==", + "dev": true, + "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/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "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==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@isaacs/fs-minipass/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.10.tgz", + "integrity": "sha512-bCsCyeZEwVErsGmyPNSzwfwFn4OdxBj0mmv6hOFucB/k81Ojdu68RbZdxYsRQUPc9l6SU5F/cG+bXgWs3oUgsQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.9.0" + } + }, + "node_modules/@next/env": { + "version": "15.3.2", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.2.tgz", + "integrity": "sha512-xURk++7P7qR9JG1jJtLzPzf0qEvqCN0A/T3DXf8IPMKo9/6FfjxtEffRJIIew/bIL4T3C2jLLqBor8B/zVlx6g==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "15.3.2", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.3.2.tgz", + "integrity": "sha512-ijVRTXBgnHT33aWnDtmlG+LJD+5vhc9AKTJPquGG5NKXjpKNjc62woIhFtrAcWdBobt8kqjCoaJ0q6sDQoX7aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.3.2", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.2.tgz", + "integrity": "sha512-2DR6kY/OGcokbnCsjHpNeQblqCZ85/1j6njYSkzRdpLn5At7OkSdmk7WyAmB9G0k25+VgqVZ/u356OSoQZ3z0g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.3.2", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.2.tgz", + "integrity": "sha512-ro/fdqaZWL6k1S/5CLv1I0DaZfDVJkWNaUU3un8Lg6m0YENWlDulmIWzV96Iou2wEYyEsZq51mwV8+XQXqMp3w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.3.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.2.tgz", + "integrity": "sha512-covwwtZYhlbRWK2HlYX9835qXum4xYZ3E2Mra1mdQ+0ICGoMiw1+nVAn4d9Bo7R3JqSmK1grMq/va+0cdh7bJA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.3.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.2.tgz", + "integrity": "sha512-KQkMEillvlW5Qk5mtGA/3Yz0/tzpNlSw6/3/ttsV1lNtMuOHcGii3zVeXZyi4EJmmLDKYcTcByV2wVsOhDt/zg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.3.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.2.tgz", + "integrity": "sha512-uRBo6THWei0chz+Y5j37qzx+BtoDRFIkDzZjlpCItBRXyMPIg079eIkOCl3aqr2tkxL4HFyJ4GHDes7W8HuAUg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.3.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.2.tgz", + "integrity": "sha512-+uxFlPuCNx/T9PdMClOqeE8USKzj8tVz37KflT3Kdbx/LOlZBRI2yxuIcmx1mPNK8DwSOMNCr4ureSet7eyC0w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.3.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.2.tgz", + "integrity": "sha512-LLTKmaI5cfD8dVzh5Vt7+OMo+AIOClEdIU/TSKbXXT2iScUTSxOGoBhfuv+FU8R9MLmrkIL1e2fBMkEEjYAtPQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.3.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.2.tgz", + "integrity": "sha512-aW5B8wOPioJ4mBdMDXkt5f3j8pUr9W8AnlX0Df35uRWNT1Y6RIybxjnSUe+PhM+M1bwgyY8PHLmXZC6zT1o5tA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@npmcli/fs": { + "version": "1.1.1", + "license": "ISC", + "optional": true, + "dependencies": { + "@gar/promisify": "^1.0.1", + "semver": "^7.3.5" + } + }, + "node_modules/@npmcli/move-file": { + "version": "1.1.2", + "license": "MIT", + "optional": true, + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/move-file/node_modules/rimraf": { + "version": "3.0.2", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", + "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.11.tgz", + "integrity": "sha512-l3W5D54emV2ues7jjeG1xcyN7S3jnK3zE2zHqgn0CmMsy9lNJwmgcrmaxS+7ipw15FAivzKNzH3d5EcGoFKw0A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collapsible": "1.1.11", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.11.tgz", + "integrity": "sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz", + "integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz", + "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.15.tgz", + "integrity": "sha512-mIBnOjgwo9AH3FyKaSWoSu/dYj6VdhJ7frEPiGTeXCdUFHjl9h3mFh2wwhEtINOmYXWhdpf1rY2minFsmaNgVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz", + "integrity": "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.15.tgz", + "integrity": "sha512-tVlmA3Vb9n8SZSd+YSbuFR66l87Wiy4du+YE+0hzKQEANA+7cWKH1WgqcEX4pXqxUFQKrWQGHdvEfw00TjFiew==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.10", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.2.tgz", + "integrity": "sha512-F90uYnlBsLPU1UbSLciLsWQmk8+hdWa6SFw4GXaIdNWxFxI5ITKVdAG64f+Twaa9ic6xE7pqxPyUmodrGjT4pQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.14.tgz", + "integrity": "sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz", + "integrity": "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz", + "integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz", + "integrity": "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz", + "integrity": "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.5.tgz", + "integrity": "sha512-rkfe2pU2NBAYfGaxa3Mqosi7VZEWX5CxKaanRv0vZd4Zhl9fvQrg0VM93dv3xGLGfrHuoTRF3JXH8nb9g+B3fw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.5.tgz", + "integrity": "sha512-5ijLkak6ZMylXsaImpZ8u4Rlf5grRmoc0p0QeX9VJtlrM4f5m3nCTX8tWga/zOA8PZYIR/t0p2Mnvd7InrJ6yQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.12.tgz", + "integrity": "sha512-GTVAlRVrQrSw3cEARM0nAx73ixrWDPNZAruETn3oHCNP6SbZ/hNxdxp+u7VkIEv3/sFoLq1PfcHrl7Pnp0CDpw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.7.tgz", + "integrity": "sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@react-aria/focus": { + "version": "3.20.3", + "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.20.3.tgz", + "integrity": "sha512-rR5uZUMSY4xLHmpK/I8bP1V6vUNHFo33gTvrvNUsAKKqvMfa7R2nu5A6v97dr5g6tVH6xzpdkPsOJCWh90H2cw==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/interactions": "^3.25.1", + "@react-aria/utils": "^3.29.0", + "@react-types/shared": "^3.29.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/interactions": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.1.tgz", + "integrity": "sha512-ntLrlgqkmZupbbjekz3fE/n3eQH2vhncx8gUp0+N+GttKWevx7jos11JUBjnJwb1RSOPgRUFcrluOqBp0VgcfQ==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.8", + "@react-aria/utils": "^3.29.0", + "@react-stately/flags": "^3.1.1", + "@react-types/shared": "^3.29.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/ssr": { + "version": "3.9.8", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.8.tgz", + "integrity": "sha512-lQDE/c9uTfBSDOjaZUJS8xP2jCKVk4zjQeIlCH90xaLhHDgbpCdns3xvFpJJujfj3nI4Ll9K7A+ONUBDCASOuw==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/utils": { + "version": "3.29.0", + "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.29.0.tgz", + "integrity": "sha512-jSOrZimCuT1iKNVlhjIxDkAhgF7HSp3pqyT6qjg/ZoA0wfqCi/okmrMPiWSAKBnkgX93N8GYTLT3CIEO6WZe9Q==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.8", + "@react-stately/flags": "^3.1.1", + "@react-stately/utils": "^3.10.6", + "@react-types/shared": "^3.29.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-stately/flags": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.1.tgz", + "integrity": "sha512-XPR5gi5LfrPdhxZzdIlJDz/B5cBf63l4q6/AzNqVWFKgd0QqY5LvWJftXkklaIUpKSJkIKQb8dphuZXDtkWNqg==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@react-stately/utils": { + "version": "3.10.6", + "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.6.tgz", + "integrity": "sha512-O76ip4InfTTzAJrg8OaZxKU4vvjMDOpfA/PGNOytiXwBbkct2ZeZwaimJ8Bt9W1bj5VsZ81/o/tW4BacbdDOMA==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-types/shared": { + "version": "3.29.1", + "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.29.1.tgz", + "integrity": "sha512-KtM+cDf2CXoUX439rfEhbnEdAgFZX20UP2A35ypNIawR7/PFFPjQDWyA2EnClCcW/dLWJDEPX2U8+EJff8xqmQ==", + "license": "Apache-2.0", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.43.0.tgz", + "integrity": "sha512-Krjy9awJl6rKbruhQDgivNbD1WuLb8xAclM4IR4cN5pHGAs2oIMMQJEiC3IC/9TZJ+QZkmZhlMO/6MBGxPidpw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.43.0.tgz", + "integrity": "sha512-ss4YJwRt5I63454Rpj+mXCXicakdFmKnUNxr1dLK+5rv5FJgAxnN7s31a5VchRYxCFWdmnDWKd0wbAdTr0J5EA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.43.0.tgz", + "integrity": "sha512-eKoL8ykZ7zz8MjgBenEF2OoTNFAPFz1/lyJ5UmmFSz5jW+7XbH1+MAgCVHy72aG59rbuQLcJeiMrP8qP5d/N0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.43.0.tgz", + "integrity": "sha512-SYwXJgaBYW33Wi/q4ubN+ldWC4DzQY62S4Ll2dgfr/dbPoF50dlQwEaEHSKrQdSjC6oIe1WgzosoaNoHCdNuMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.43.0.tgz", + "integrity": "sha512-SV+U5sSo0yujrjzBF7/YidieK2iF6E7MdF6EbYxNz94lA+R0wKl3SiixGyG/9Klab6uNBIqsN7j4Y/Fya7wAjQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.43.0.tgz", + "integrity": "sha512-J7uCsiV13L/VOeHJBo5SjasKiGxJ0g+nQTrBkAsmQBIdil3KhPnSE9GnRon4ejX1XDdsmK/l30IYLiAaQEO0Cg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.43.0.tgz", + "integrity": "sha512-gTJ/JnnjCMc15uwB10TTATBEhK9meBIY+gXP4s0sHD1zHOaIh4Dmy1X9wup18IiY9tTNk5gJc4yx9ctj/fjrIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.43.0.tgz", + "integrity": "sha512-ZJ3gZynL1LDSIvRfz0qXtTNs56n5DI2Mq+WACWZ7yGHFUEirHBRt7fyIk0NsCKhmRhn7WAcjgSkSVVxKlPNFFw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.43.0.tgz", + "integrity": "sha512-8FnkipasmOOSSlfucGYEu58U8cxEdhziKjPD2FIa0ONVMxvl/hmONtX/7y4vGjdUhjcTHlKlDhw3H9t98fPvyA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.43.0.tgz", + "integrity": "sha512-KPPyAdlcIZ6S9C3S2cndXDkV0Bb1OSMsX0Eelr2Bay4EsF9yi9u9uzc9RniK3mcUGCLhWY9oLr6er80P5DE6XA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.43.0.tgz", + "integrity": "sha512-HPGDIH0/ZzAZjvtlXj6g+KDQ9ZMHfSP553za7o2Odegb/BEfwJcR0Sw0RLNpQ9nC6Gy8s+3mSS9xjZ0n3rhcYg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.43.0.tgz", + "integrity": "sha512-gEmwbOws4U4GLAJDhhtSPWPXUzDfMRedT3hFMyRAvM9Mrnj+dJIFIeL7otsv2WF3D7GrV0GIewW0y28dOYWkmw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.43.0.tgz", + "integrity": "sha512-XXKvo2e+wFtXZF/9xoWohHg+MuRnvO29TI5Hqe9xwN5uN8NKUYy7tXUG3EZAlfchufNCTHNGjEx7uN78KsBo0g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.43.0.tgz", + "integrity": "sha512-ruf3hPWhjw6uDFsOAzmbNIvlXFXlBQ4nk57Sec8E8rUxs/AI4HD6xmiiasOOx/3QxS2f5eQMKTAwk7KHwpzr/Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.43.0.tgz", + "integrity": "sha512-QmNIAqDiEMEvFV15rsSnjoSmO0+eJLoKRD9EAa9rrYNwO/XRCtOGM3A5A0X+wmG+XRrw9Fxdsw+LnyYiZWWcVw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.43.0.tgz", + "integrity": "sha512-jAHr/S0iiBtFyzjhOkAics/2SrXE092qyqEg96e90L3t9Op8OTzS6+IX0Fy5wCt2+KqeHAkti+eitV0wvblEoQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.43.0.tgz", + "integrity": "sha512-3yATWgdeXyuHtBhrLt98w+5fKurdqvs8B53LaoKD7P7H7FKOONLsBVMNl9ghPQZQuYcceV5CDyPfyfGpMWD9mQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.43.0.tgz", + "integrity": "sha512-wVzXp2qDSCOpcBCT5WRWLmpJRIzv23valvcTwMHEobkjippNf+C3ys/+wf07poPkeNix0paTNemB2XrHr2TnGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.43.0.tgz", + "integrity": "sha512-fYCTEyzf8d+7diCw8b+asvWDCLMjsCEA8alvtAutqJOJp/wL5hs1rWSqJ1vkjgW0L2NB4bsYJrpKkiIPRR9dvw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.43.0.tgz", + "integrity": "sha512-SnGhLiE5rlK0ofq8kzuDkM0g7FN1s5VYY+YSMTibP7CqShxCQvqtNxTARS4xX4PFJfHjG0ZQYX9iGzI3FQh5Aw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rushstack/eslint-patch": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.11.0.tgz", + "integrity": "sha512-zxnHvoMQVqewTJr/W4pKjF0bMGiKJv1WX7bSrkl46Hg0QjESbzBROWK0Wg4RphzSOS5Jiy7eFimmM3UgMrMZbQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/forms": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz", + "integrity": "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==", + "license": "MIT", + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.7.tgz", + "integrity": "sha512-9rsOpdY9idRI2NH6CL4wORFY0+Q6fnx9XP9Ju+iq/0wJwGD5IByIgFmwVbyy4ymuyprj8Qh4ErxMKTUL4uNh3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "enhanced-resolve": "^5.18.1", + "jiti": "^2.4.2", + "lightningcss": "1.30.1", + "magic-string": "^0.30.17", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.7" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.7.tgz", + "integrity": "sha512-5SF95Ctm9DFiUyjUPnDGkoKItPX/k+xifcQhcqX5RA85m50jw1pT/KzjdvlqxRja45Y52nR4MR9fD1JYd7f8NQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.4", + "tar": "^7.4.3" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.7", + "@tailwindcss/oxide-darwin-arm64": "4.1.7", + "@tailwindcss/oxide-darwin-x64": "4.1.7", + "@tailwindcss/oxide-freebsd-x64": "4.1.7", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.7", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.7", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.7", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.7", + "@tailwindcss/oxide-linux-x64-musl": "4.1.7", + "@tailwindcss/oxide-wasm32-wasi": "4.1.7", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.7", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.7" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.7.tgz", + "integrity": "sha512-IWA410JZ8fF7kACus6BrUwY2Z1t1hm0+ZWNEzykKmMNM09wQooOcN/VXr0p/WJdtHZ90PvJf2AIBS/Ceqx1emg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.7.tgz", + "integrity": "sha512-81jUw9To7fimGGkuJ2W5h3/oGonTOZKZ8C2ghm/TTxbwvfSiFSDPd6/A/KE2N7Jp4mv3Ps9OFqg2fEKgZFfsvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.7.tgz", + "integrity": "sha512-q77rWjEyGHV4PdDBtrzO0tgBBPlQWKY7wZK0cUok/HaGgbNKecegNxCGikuPJn5wFAlIywC3v+WMBt0PEBtwGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.7.tgz", + "integrity": "sha512-RfmdbbK6G6ptgF4qqbzoxmH+PKfP4KSVs7SRlTwcbRgBwezJkAO3Qta/7gDy10Q2DcUVkKxFLXUQO6J3CRvBGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.7.tgz", + "integrity": "sha512-OZqsGvpwOa13lVd1z6JVwQXadEobmesxQ4AxhrwRiPuE04quvZHWn/LnihMg7/XkN+dTioXp/VMu/p6A5eZP3g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.7.tgz", + "integrity": "sha512-voMvBTnJSfKecJxGkoeAyW/2XRToLZ227LxswLAwKY7YslG/Xkw9/tJNH+3IVh5bdYzYE7DfiaPbRkSHFxY1xA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.7.tgz", + "integrity": "sha512-PjGuNNmJeKHnP58M7XyjJyla8LPo+RmwHQpBI+W/OxqrwojyuCQ+GUtygu7jUqTEexejZHr/z3nBc/gTiXBj4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.7.tgz", + "integrity": "sha512-HMs+Va+ZR3gC3mLZE00gXxtBo3JoSQxtu9lobbZd+DmfkIxR54NO7Z+UQNPsa0P/ITn1TevtFxXTpsRU7qEvWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.7.tgz", + "integrity": "sha512-MHZ6jyNlutdHH8rd+YTdr3QbXrHXqwIhHw9e7yXEBcQdluGwhpQY2Eku8UZK6ReLaWtQ4gijIv5QoM5eE+qlsA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.7.tgz", + "integrity": "sha512-ANaSKt74ZRzE2TvJmUcbFQ8zS201cIPxUDm5qez5rLEwWkie2SkGtA4P+GPTj+u8N6JbPrC8MtY8RmJA35Oo+A==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@emnapi/wasi-threads": "^1.0.2", + "@napi-rs/wasm-runtime": "^0.2.9", + "@tybys/wasm-util": "^0.9.0", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.7.tgz", + "integrity": "sha512-HUiSiXQ9gLJBAPCMVRk2RT1ZrBjto7WvqsPBwUrNK2BcdSxMnk19h4pjZjI7zgPhDxlAbJSumTC4ljeA9y0tEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.7.tgz", + "integrity": "sha512-rYHGmvoHiLJ8hWucSfSOEmdCBIGZIq7SpkPRSqLsH2Ab2YUNgKeAPT1Fi2cx3+hnYOrAb0jp9cRyode3bBW4mQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide/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==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@tailwindcss/oxide/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@tailwindcss/oxide/node_modules/minizlib": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", + "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@tailwindcss/oxide/node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@tailwindcss/oxide/node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@tailwindcss/oxide/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==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.7.tgz", + "integrity": "sha512-88g3qmNZn7jDgrrcp3ZXEQfp9CVox7xjP1HN2TFKI03CltPVd/c61ydn5qJJL8FYunn0OqBaW5HNUga0kmPVvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.7", + "@tailwindcss/oxide": "4.1.7", + "postcss": "^8.4.41", + "tailwindcss": "4.1.7" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz", + "integrity": "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==", + "license": "MIT", + "dependencies": { + "lodash.castarray": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.9", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.9.tgz", + "integrity": "sha512-SPWC8kwG/dWBf7Py7cfheAPOxuvIv4fFQ54PdmYbg7CpXfsKxkucak43Q0qKsxVthhUJQ1A7CIMAIplq4BjVwA==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.9" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.9", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.9.tgz", + "integrity": "sha512-3jztt0jpaoJO5TARe2WIHC1UQC3VMLAFUW5mmMo0yrkwtDB2AQP0+sh10BVUpWrnvHjSLvzFizydtEGLCJKFoQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", + "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/bcrypt": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz", + "integrity": "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/bytes": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@types/bytes/-/bytes-3.1.5.tgz", + "integrity": "sha512-VgZkrJckypj85YxEsEavcMmmSOIzkUHqWmM4CCyia5dc54YwsXzJ5uT4fYxBQNEXx+oF1krlhgCbvfubXqZYsQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-rate-limit": { + "version": "5.1.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.17.10", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/@types/pg": { + "version": "8.15.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^4.0.1" + } + }, + "node_modules/@types/pg/node_modules/pg-types": { + "version": "4.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "pg-numeric": "1.0.2", + "postgres-array": "~3.0.1", + "postgres-bytea": "~3.0.0", + "postgres-date": "~2.1.0", + "postgres-interval": "^3.0.0", + "postgres-range": "^1.1.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/pg/node_modules/postgres-array": { + "version": "3.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/@types/pg/node_modules/postgres-bytea": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "obuf": "~1.1.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@types/pg/node_modules/postgres-date": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/@types/pg/node_modules/postgres-interval": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/@types/qs": { + "version": "6.9.18", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.1.4", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.4.tgz", + "integrity": "sha512-EB1yiiYdvySuIITtD5lhW4yPyJ31RkJkkDw794LaQYrxCSaQV/47y5o1FMC4zF9ZyjUjzJMZwbovEnT5yHTW6g==", + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.1.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.5.tgz", + "integrity": "sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/@types/send": { + "version": "0.17.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.1.tgz", + "integrity": "sha512-6u6Plg9nP/J1GRpe/vcjjabo6Uc5YQPAMxsgQyGC/I0RuukiG1wIe3+Vtg3IrSCVJDmqK3j8adrtzXSENRtFgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.32.1", + "@typescript-eslint/type-utils": "8.32.1", + "@typescript-eslint/utils": "8.32.1", + "@typescript-eslint/visitor-keys": "8.32.1", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.4.tgz", + "integrity": "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.32.1.tgz", + "integrity": "sha512-LKMrmwCPoLhM45Z00O1ulb6jwyVr2kr3XJp+G+tSEZcbauNnScewcQwtJqXDhXeYPDEjZ8C1SjXm015CirEmGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.32.1", + "@typescript-eslint/types": "8.32.1", + "@typescript-eslint/typescript-estree": "8.32.1", + "@typescript-eslint/visitor-keys": "8.32.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.32.1.tgz", + "integrity": "sha512-7IsIaIDeZn7kffk7qXC3o6Z4UblZJKV3UBpkvRNpr5NSyLji7tvTcvmnMNYuYLyh26mN8W723xpo3i4MlD33vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.32.1", + "@typescript-eslint/visitor-keys": "8.32.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.32.1.tgz", + "integrity": "sha512-mv9YpQGA8iIsl5KyUPi+FGLm7+bA4fgXaeRcFKRDRwDMu4iwrSHeDPipwueNXhdIIZltwCJv+NkxftECbIZWfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.32.1", + "@typescript-eslint/utils": "8.32.1", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.32.1.tgz", + "integrity": "sha512-YmybwXUJcgGqgAp6bEsgpPXEg6dcCyPyCSr0CAAueacR/CCBi25G3V8gGQ2kRzQRBNol7VQknxMs9HvVa9Rvfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.32.1.tgz", + "integrity": "sha512-Y3AP9EIfYwBb4kWGb+simvPaqQoT5oJuzzj9m0i6FCY6SPvlomY2Ei4UEMm7+FXtlNJbor80ximyslzaQF6xhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.32.1", + "@typescript-eslint/visitor-keys": "8.32.1", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.32.1.tgz", + "integrity": "sha512-DsSFNIgLSrc89gpq1LJB7Hm1YpuhK086DRDJSNrewcGvYloWW1vZLHBTIvarKZDcAORIy/uWNx8Gad+4oMpkSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.32.1", + "@typescript-eslint/types": "8.32.1", + "@typescript-eslint/typescript-estree": "8.32.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.32.1.tgz", + "integrity": "sha512-ar0tjQfObzhSaW3C3QNmTc5ofj0hDoNQ5XWrCy6zDyabdr0TWhCkClp+rywGNj/odAFBVzzJrK4tEq5M4Hmu4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.32.1", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.7.2.tgz", + "integrity": "sha512-vxtBno4xvowwNmO/ASL0Y45TpHqmNkAaDtz4Jqb+clmcVSSl8XCG/PNFFkGsXXXS6AMjP+ja/TtNCFFa1QwLRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.7.2.tgz", + "integrity": "sha512-qhVa8ozu92C23Hsmv0BF4+5Dyyd5STT1FolV4whNgbY6mj3kA0qsrGPe35zNR3wAN7eFict3s4Rc2dDTPBTuFQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.7.2.tgz", + "integrity": "sha512-zKKdm2uMXqLFX6Ac7K5ElnnG5VIXbDlFWzg4WJ8CGUedJryM5A3cTgHuGMw1+P5ziV8CRhnSEgOnurTI4vpHpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.7.2.tgz", + "integrity": "sha512-8N1z1TbPnHH+iDS/42GJ0bMPLiGK+cUqOhNbMKtWJ4oFGzqSJk/zoXFzcQkgtI63qMcUI7wW1tq2usZQSb2jxw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.7.2.tgz", + "integrity": "sha512-tjYzI9LcAXR9MYd9rO45m1s0B/6bJNuZ6jeOxo1pq1K6OBuRMMmfyvJYval3s9FPPGmrldYA3mi4gWDlWuTFGA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.7.2.tgz", + "integrity": "sha512-jon9M7DKRLGZ9VYSkFMflvNqu9hDtOCEnO2QAryFWgT6o6AXU8du56V7YqnaLKr6rAbZBWYsYpikF226v423QA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.7.2.tgz", + "integrity": "sha512-c8Cg4/h+kQ63pL43wBNaVMmOjXI/X62wQmru51qjfTvI7kmCy5uHTJvK/9LrF0G8Jdx8r34d019P1DVJmhXQpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.7.2.tgz", + "integrity": "sha512-A+lcwRFyrjeJmv3JJvhz5NbcCkLQL6Mk16kHTNm6/aGNc4FwPHPE4DR9DwuCvCnVHvF5IAd9U4VIs/VvVir5lg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.7.2.tgz", + "integrity": "sha512-hQQ4TJQrSQW8JlPm7tRpXN8OCNP9ez7PajJNjRD1ZTHQAy685OYqPrKjfaMw/8LiHCt8AZ74rfUVHP9vn0N69Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.7.2.tgz", + "integrity": "sha512-NoAGbiqrxtY8kVooZ24i70CjLDlUFI7nDj3I9y54U94p+3kPxwd2L692YsdLa+cqQ0VoqMWoehDFp21PKRUoIQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.7.2.tgz", + "integrity": "sha512-KaZByo8xuQZbUhhreBTW+yUnOIHUsv04P8lKjQ5otiGoSJ17ISGYArc+4vKdLEpGaLbemGzr4ZeUbYQQsLWFjA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.7.2.tgz", + "integrity": "sha512-dEidzJDubxxhUCBJ/SHSMJD/9q7JkyfBMT77Px1npl4xpg9t0POLvnWywSk66BgZS/b2Hy9Y1yFaoMTFJUe9yg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.7.2.tgz", + "integrity": "sha512-RvP+Ux3wDjmnZDT4XWFfNBRVG0fMsc+yVzNFUqOflnDfZ9OYujv6nkh+GOr+watwrW4wdp6ASfG/e7bkDradsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.7.2.tgz", + "integrity": "sha512-y797JBmO9IsvXVRCKDXOxjyAE4+CcZpla2GSoBQ33TVb3ILXuFnMrbR/QQZoauBYeOFuu4w3ifWLw52sdHGz6g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.9" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.7.2.tgz", + "integrity": "sha512-gtYTh4/VREVSLA+gHrfbWxaMO/00y+34htY7XpioBTy56YN2eBjkPrY1ML1Zys89X3RJDKVaogzwxlM1qU7egg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.7.2.tgz", + "integrity": "sha512-Ywv20XHvHTDRQs12jd3MY8X5C8KLjDbg/jyaal/QLKx3fAShhJyD4blEANInsjxW3P7isHx1Blt56iUDDJO3jg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.7.2.tgz", + "integrity": "sha512-friS8NEQfHaDbkThxopGk+LuE5v3iY0StruifjQEt7SLbA46OnfgMO15sOTkbpJkol6RB+1l1TYPXh0sCddpvA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@zag-js/anatomy": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@zag-js/anatomy/-/anatomy-1.13.1.tgz", + "integrity": "sha512-o0syzwTVPei8g3Puy7NVhviVcB2KLvhYHpT19r3OMt4WtK4i9Qxy5V/7H6RFAf4Z5+xOYXfllJk9qtu3L3cwSg==", + "license": "MIT" + }, + "node_modules/@zag-js/core": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@zag-js/core/-/core-1.13.1.tgz", + "integrity": "sha512-8sDA9c2K5JMMLN7F7d4HUmeKxF+bpc7bB5DGkagevLZ+iomDdpg2B0sDDV88guq5/lYNfMJ6krSfO80JMO3Gjg==", + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.13.1", + "@zag-js/utils": "1.13.1" + } + }, + "node_modules/@zag-js/dom-query": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.13.1.tgz", + "integrity": "sha512-BWQB77N7dbbUauYGmG2YmL5eNCot9v/kuCVzeR3WHueApum7f14ZvbFM8Lr9+tCL4BibPyAc8mZzAiB1reHWWQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.13.1" + } + }, + "node_modules/@zag-js/number-input": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@zag-js/number-input/-/number-input-1.13.1.tgz", + "integrity": "sha512-4HSSWUiUM876K16+eaHsIVeuu1TErAE3B4RrXX3fTHoqduM5EQTYtIXEuWzgvTscl9L8pmF9v9uSHpezHPoNPA==", + "license": "MIT", + "dependencies": { + "@internationalized/number": "3.6.2", + "@zag-js/anatomy": "1.13.1", + "@zag-js/core": "1.13.1", + "@zag-js/dom-query": "1.13.1", + "@zag-js/types": "1.13.1", + "@zag-js/utils": "1.13.1" + } + }, + "node_modules/@zag-js/react": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@zag-js/react/-/react-1.13.1.tgz", + "integrity": "sha512-UBzy9bnZBdDNaWppsn8W5vao9fLm8OMCo3Erf+3gf2Z/dHdD8bHd9ObNOxXLUYJhELBSDJvoJiSMNmX8N8RLVg==", + "license": "MIT", + "dependencies": { + "@zag-js/core": "1.13.1", + "@zag-js/store": "1.13.1", + "@zag-js/types": "1.13.1", + "@zag-js/utils": "1.13.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@zag-js/store": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@zag-js/store/-/store-1.13.1.tgz", + "integrity": "sha512-v5Y1S+7EB3pH5NgLKd1DN4w04SGGgYFUzRsBJD3jQuF/kuMqSYAEOfBM/cezAmphsAZRE2H7JmvhtZUSB7pzrw==", + "license": "MIT", + "dependencies": { + "proxy-compare": "3.0.1" + } + }, + "node_modules/@zag-js/types": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@zag-js/types/-/types-1.13.1.tgz", + "integrity": "sha512-i74ng4uyEFLKxw3JWxDtt2fFpJ0HkSuSHQQ+k+ZpHuErDbAA7r9BKtSqLne3Hw/gvjel4yU/uH88Ogdl1J55zg==", + "license": "MIT", + "dependencies": { + "csstype": "3.1.3" + } + }, + "node_modules/@zag-js/utils": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@zag-js/utils/-/utils-1.13.1.tgz", + "integrity": "sha512-2nnBUxlf8eUv9khFBR6gbMogy1+/CSlIVYJrirN/w+v5r516QUXgzjlM6ZNSYGiF5kPgbxOUdgsNgTnvjyNvmA==", + "license": "MIT" + }, + "node_modules/abbrev": { + "version": "1.1.1", + "license": "ISC", + "optional": true + }, + "node_modules/accepts": { + "version": "1.3.8", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.3", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "license": "MIT", + "optional": true, + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "license": "MIT", + "optional": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/aproba": { + "version": "2.0.0", + "license": "ISC", + "optional": true + }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/array-includes": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", + "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.4", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async": { + "version": "3.2.6", + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.10.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz", + "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "devOptional": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "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/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/bcrypt/node_modules/node-addon-api": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.3.1.tgz", + "integrity": "sha512-lytcDEdxKjGJPTLEfW4mYMigRezMlyJY8W4wxJK8zE533Jlb8L8dRuObJFWg2P+AuOIxoCgKF+2Oq4d4Zd0OUA==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.24.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz", + "integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001716", + "electron-to-chromium": "^1.5.149", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "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/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cacache": { + "version": "15.3.0", + "license": "ISC", + "optional": true, + "dependencies": { + "@npmcli/fs": "^1.0.0", + "@npmcli/move-file": "^1.0.1", + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "glob": "^7.1.4", + "infer-owner": "^1.0.4", + "lru-cache": "^6.0.0", + "minipass": "^3.1.1", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.2", + "mkdirp": "^1.0.3", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^8.0.1", + "tar": "^6.0.2", + "unique-filename": "^1.1.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cacache/node_modules/rimraf": { + "version": "3.0.2", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001718", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz", + "integrity": "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chai": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", + "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "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/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "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==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/color-support": { + "version": "1.1.3", + "license": "ISC", + "optional": true, + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/colorspace": { + "version": "1.1.4", + "license": "MIT", + "dependencies": { + "color": "^3.1.3", + "text-hex": "1.0.x" + } + }, + "node_modules/colorspace/node_modules/color": { + "version": "3.2.1", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.3", + "color-string": "^1.6.0" + } + }, + "node_modules/colorspace/node_modules/color-convert": { + "version": "1.9.3", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/colorspace/node_modules/color-name": { + "version": "1.1.3", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "devOptional": true, + "license": "MIT" + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "license": "ISC", + "optional": true + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-hrtime": { + "version": "5.0.0", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cookie": { + "version": "0.7.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "license": "MIT" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-env": { + "version": "7.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssstyle": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@asamuzakjp/css-color": "^3.1.1", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.0", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.5.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/decode-named-character-reference": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.1.0.tgz", + "integrity": "sha512-Wy+JTSbFThEOXQIR2L6mxJvEs+veIzpmqD7ynWxMXGpnk3smkHQOp6forLdHsKpAMW9iJpaBBIxz285t1n1C3w==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "license": "MIT", + "optional": true + }, + "node_modules/depd": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.0.4", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dotenv": { + "version": "16.4.7", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.157", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.157.tgz", + "integrity": "sha512-/0ybgsQd1muo8QlnuTpKwtl0oX5YMlUGbm8xyqgDU00motRkKFFbUJySAQBWcY79rVqNLWIWa87BGVGClwAB2w==", + "dev": true, + "license": "ISC" + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/enabled": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding": { + "version": "0.1.13", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", + "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/envalid": { + "version": "8.0.0", + "license": "MIT", + "dependencies": { + "tslib": "2.6.2" + }, + "engines": { + "node": ">=8.12" + } + }, + "node_modules/envalid/node_modules/tslib": { + "version": "2.6.2", + "license": "0BSD" + }, + "node_modules/err-code": { + "version": "2.0.3", + "license": "MIT", + "optional": true + }, + "node_modules/es-abstract": { + "version": "1.23.9", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz", + "integrity": "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.0", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-regex": "^1.2.1", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.0", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.3", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.3", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.18" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", + "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.6", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.4", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", + "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.5", + "@esbuild/android-arm": "0.25.5", + "@esbuild/android-arm64": "0.25.5", + "@esbuild/android-x64": "0.25.5", + "@esbuild/darwin-arm64": "0.25.5", + "@esbuild/darwin-x64": "0.25.5", + "@esbuild/freebsd-arm64": "0.25.5", + "@esbuild/freebsd-x64": "0.25.5", + "@esbuild/linux-arm": "0.25.5", + "@esbuild/linux-arm64": "0.25.5", + "@esbuild/linux-ia32": "0.25.5", + "@esbuild/linux-loong64": "0.25.5", + "@esbuild/linux-mips64el": "0.25.5", + "@esbuild/linux-ppc64": "0.25.5", + "@esbuild/linux-riscv64": "0.25.5", + "@esbuild/linux-s390x": "0.25.5", + "@esbuild/linux-x64": "0.25.5", + "@esbuild/netbsd-arm64": "0.25.5", + "@esbuild/netbsd-x64": "0.25.5", + "@esbuild/openbsd-arm64": "0.25.5", + "@esbuild/openbsd-x64": "0.25.5", + "@esbuild/sunos-x64": "0.25.5", + "@esbuild/win32-arm64": "0.25.5", + "@esbuild/win32-ia32": "0.25.5", + "@esbuild/win32-x64": "0.25.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.27.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.27.0.tgz", + "integrity": "sha512-ixRawFQuMB9DZ7fjU3iGGganFDp3+45bPOdaRurcFHSXO1e/sYwUX/FtQZpLZJR6SjMoJH8hR2pPEAfDyCoU2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.20.0", + "@eslint/config-helpers": "^0.2.1", + "@eslint/core": "^0.14.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.27.0", + "@eslint/plugin-kit": "^0.3.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.3.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "15.3.2", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.3.2.tgz", + "integrity": "sha512-FerU4DYccO4FgeYFFglz0SnaKRe1ejXQrDb8kWUkTAg036YWi+jUsgg4sIGNCDhAsDITsZaL4MzBWKB6f4G1Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "15.3.2", + "@rushstack/eslint-patch": "^1.10.3", + "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^5.0.0" + }, + "peerDependencies": { + "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz", + "integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", + "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.8", + "array.prototype.findlastindex": "^1.2.5", + "array.prototype.flat": "^1.3.2", + "array.prototype.flatmap": "^1.3.2", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.0", + "hasown": "^2.0.2", + "is-core-module": "^2.15.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.0", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.8", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", + "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", + "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.14.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/expect-type": { + "version": "1.1.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/expr-eval": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/expr-eval/-/expr-eval-2.0.2.tgz", + "integrity": "sha512-4EMSHGOPSwAfBiibw3ndnP0AvjDWLsMvGOvWEZ2F96IGk0bIVdjQisOHxReSkE13mHcfbuCiXw+G4y0zv6N8Eg==", + "license": "MIT" + }, + "node_modules/express": { + "version": "4.21.2", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.0", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": "^4.11 || 5 || ^5.0.0-beta.1" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fecha": { + "version": "4.2.3", + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/fn.name": { + "version": "1.1.0", + "license": "MIT" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "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/foreground-child/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==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.2", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/framer-motion": { + "version": "12.12.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.12.2.tgz", + "integrity": "sha512-qCszZCiGWkilL40E3VuhIJJC/CS3SIBl2IHyGK8FU30nOUhTmhBNWPrNFyozAWH/bXxwzi19vJHIGVdALF0LCg==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.12.1", + "motion-utils": "^12.12.1", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "license": "ISC", + "optional": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function-timeout": { + "version": "1.0.2", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "4.0.4", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.0.tgz", + "integrity": "sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "license": "MIT" + }, + "node_modules/glob": { + "version": "7.2.3", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "devOptional": true, + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "license": "ISC", + "optional": true + }, + "node_modules/hasown": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/http-errors": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "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/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "license": "ISC", + "optional": true + }, + "node_modules/inflight": { + "version": "1.0.6", + "license": "ISC", + "optional": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "license": "ISC" + }, + "node_modules/inline-style-parser": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", + "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==", + "license": "MIT" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ip-address": { + "version": "9.0.5", + "license": "MIT", + "optional": true, + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.2", + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-lambda": { + "version": "1.0.1", + "license": "MIT", + "optional": true + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "devOptional": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "dev": true, + "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/jiti": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", + "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsbn": { + "version": "1.1.0", + "license": "MIT", + "optional": true + }, + "node_modules/jsdom": { + "version": "25.0.1", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kuler": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", + "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.30.1", + "lightningcss-darwin-x64": "1.30.1", + "lightningcss-freebsd-x64": "1.30.1", + "lightningcss-linux-arm-gnueabihf": "1.30.1", + "lightningcss-linux-arm64-gnu": "1.30.1", + "lightningcss-linux-arm64-musl": "1.30.1", + "lightningcss-linux-x64-gnu": "1.30.1", + "lightningcss-linux-x64-musl": "1.30.1", + "lightningcss-win32-arm64-msvc": "1.30.1", + "lightningcss-win32-x64-msvc": "1.30.1" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", + "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", + "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", + "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", + "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", + "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", + "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", + "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", + "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", + "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", + "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.castarray": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", + "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "license": "MIT" + }, + "node_modules/logform": { + "version": "2.7.0", + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", + "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lucide-react": { + "version": "0.511.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.511.0.tgz", + "integrity": "sha512-VK5a2ydJ7xm8GvBeKLS9mu1pVK6ucef9780JVUjw6bAjJL/QXnd4Y0p7SPeOUMC27YhzNCZvm5d/QX0Tp3rc0w==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.17", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/make-fetch-happen": { + "version": "9.1.0", + "license": "ISC", + "optional": true, + "dependencies": { + "agentkeepalive": "^4.1.3", + "cacache": "^15.2.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^6.0.0", + "minipass": "^3.1.3", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^1.3.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.2", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^6.0.0", + "ssri": "^8.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/make-fetch-happen/node_modules/agent-base": { + "version": "6.0.2", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/make-fetch-happen/node_modules/http-proxy-agent": { + "version": "4.0.1", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/make-fetch-happen/node_modules/https-proxy-agent": { + "version": "5.0.1", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", + "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "license": "MIT", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "devOptional": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "3.3.6", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-fetch": { + "version": "1.4.1", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.1.0", + "minipass-sized": "^1.0.3", + "minizlib": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "optionalDependencies": { + "encoding": "^0.1.12" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "license": "MIT" + }, + "node_modules/moment": { + "version": "2.30.1", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/moment-timezone": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", + "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==", + "license": "MIT", + "dependencies": { + "moment": "^2.29.4" + }, + "engines": { + "node": "*" + } + }, + "node_modules/motion-dom": { + "version": "12.12.1", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.12.1.tgz", + "integrity": "sha512-GXq/uUbZBEiFFE+K1Z/sxdPdadMdfJ/jmBALDfIuHGi0NmtealLOfH9FqT+6aNPgVx8ilq0DtYmyQlo6Uj9LKQ==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.12.1" + } + }, + "node_modules/motion-utils": { + "version": "12.12.1", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.12.1.tgz", + "integrity": "sha512-f9qiqUHm7hWSLlNW8gS9pisnsN7CRFRD58vNjptKdsqFLpkVnX00TNeD6Q0d27V9KzT7ySFyK1TZ/DShfVOv6w==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.8", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/napi-postinstall": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.2.4.tgz", + "integrity": "sha512-ZEzHJwBhZ8qQSbknHqYcdtQVr8zUgGyM/q6h6qAyhtyVMNrSgDhrC4disf03dYW0e+czXyLnZINnCTEkWy0eJg==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/next": { + "version": "15.3.2", + "resolved": "https://registry.npmjs.org/next/-/next-15.3.2.tgz", + "integrity": "sha512-CA3BatMyHkxZ48sgOCLdVHjFU36N7TF1HhqAHLFOkV6buwZnvMI84Cug8xD56B9mCuKrqXnLn94417GrZ/jjCQ==", + "license": "MIT", + "dependencies": { + "@next/env": "15.3.2", + "@swc/counter": "0.1.3", + "@swc/helpers": "0.5.15", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.3.2", + "@next/swc-darwin-x64": "15.3.2", + "@next/swc-linux-arm64-gnu": "15.3.2", + "@next/swc-linux-arm64-musl": "15.3.2", + "@next/swc-linux-x64-gnu": "15.3.2", + "@next/swc-linux-x64-musl": "15.3.2", + "@next/swc-win32-arm64-msvc": "15.3.2", + "@next/swc-win32-x64-msvc": "15.3.2", + "sharp": "^0.34.1" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-abi": { + "version": "3.75.0", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "license": "MIT" + }, + "node_modules/node-gyp": { + "version": "8.4.1", + "license": "MIT", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^9.1.0", + "nopt": "^5.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": ">= 10.12.0" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/node-gyp/node_modules/rimraf": { + "version": "3.0.2", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "5.0.0", + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npmlog": { + "version": "6.0.2", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/nwsapi": { + "version": "2.2.19", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.3", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obuf": { + "version": "1.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/one-time": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "license": "MIT", + "optional": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse-torrent-title": { + "version": "1.3.0", + "resolved": "git+ssh://git@github.com/TheBeastLT/parse-torrent-title.git#1169487e316a4eed898dcfd900957f89a253604c", + "license": "MIT", + "dependencies": { + "moment": "^2.24.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/parse5": { + "version": "7.2.1", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "entities": "^4.5.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "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/path-scurry/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==", + "dev": true, + "license": "ISC" + }, + "node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", + "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/pg": { + "version": "8.16.0", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.9.0", + "pg-pool": "^3.10.0", + "pg-protocol": "^1.10.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 8.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.2.5" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.2.5", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.9.0", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-numeric": { + "version": "1.0.2", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-pool": { + "version": "3.10.0", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.10.0", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/pgpass/node_modules/split2": { + "version": "4.2.0", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-nesting": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-13.0.1.tgz", + "integrity": "sha512-VbqqHkOBOt4Uu3G8Dm8n6lU5+9cJFxiuty9+4rcoyRPO9zZS1JIs6td49VIoix3qYqELHlJIn46Oih9SAKo+yQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/selector-resolve-nested": "^3.0.0", + "@csstools/selector-specificity": "^5.0.0", + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-nesting/node_modules/@csstools/selector-resolve-nested": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-3.0.0.tgz", + "integrity": "sha512-ZoK24Yku6VJU1gS79a5PFmC8yn3wIapiKmPgun0hZgEI5AOqgH2kiPRsPz1qkGv4HL+wuDLH83yQyk6inMYrJQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.0.0" + } + }, + "node_modules/postcss-nesting/node_modules/@csstools/selector-specificity": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", + "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.0.0" + } + }, + "node_modules/postcss-nesting/node_modules/postcss-selector-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", + "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-range": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "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/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.4.2", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "license": "ISC", + "optional": true + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "license": "MIT", + "optional": true, + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-3.0.1.tgz", + "integrity": "sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==", + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.2", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "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/range-parser": { + "version": "1.2.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "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/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.0.tgz", + "integrity": "sha512-sGsQtcjMqdQyijAHytfGEELB8FufGbfXIsvUTe+NLx1GDRJCXtCFLBLUI1eyZCKXXvbEU2C6gai0PZKoIE9Vbg==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.43.0.tgz", + "integrity": "sha512-wdN2Kd3Twh8MAEOEJZsuxuLKCsBEo4PVNLK6tQWAn10VhsVewQLzcucMgLolRlhFybGxfclbPeEYBaP6RvUFGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.7" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.43.0", + "@rollup/rollup-android-arm64": "4.43.0", + "@rollup/rollup-darwin-arm64": "4.43.0", + "@rollup/rollup-darwin-x64": "4.43.0", + "@rollup/rollup-freebsd-arm64": "4.43.0", + "@rollup/rollup-freebsd-x64": "4.43.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.43.0", + "@rollup/rollup-linux-arm-musleabihf": "4.43.0", + "@rollup/rollup-linux-arm64-gnu": "4.43.0", + "@rollup/rollup-linux-arm64-musl": "4.43.0", + "@rollup/rollup-linux-loongarch64-gnu": "4.43.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.43.0", + "@rollup/rollup-linux-riscv64-gnu": "4.43.0", + "@rollup/rollup-linux-riscv64-musl": "4.43.0", + "@rollup/rollup-linux-s390x-gnu": "4.43.0", + "@rollup/rollup-linux-x64-gnu": "4.43.0", + "@rollup/rollup-linux-x64-musl": "4.43.0", + "@rollup/rollup-win32-arm64-msvc": "4.43.0", + "@rollup/rollup-win32-ia32-msvc": "4.43.0", + "@rollup/rollup-win32-x64-msvc": "4.43.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "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": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "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/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-static": { + "version": "1.16.2", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "license": "ISC", + "optional": true + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.1.tgz", + "integrity": "sha512-1j0w61+eVxu7DawFJtnfYcvSv6qPFvfTaqzTQ2BLknVhHTwGS8sc63ZBF4rzkWMBVKybo4S5OBtDdZahh2A1xg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.7.1" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.1", + "@img/sharp-darwin-x64": "0.34.1", + "@img/sharp-libvips-darwin-arm64": "1.1.0", + "@img/sharp-libvips-darwin-x64": "1.1.0", + "@img/sharp-libvips-linux-arm": "1.1.0", + "@img/sharp-libvips-linux-arm64": "1.1.0", + "@img/sharp-libvips-linux-ppc64": "1.1.0", + "@img/sharp-libvips-linux-s390x": "1.1.0", + "@img/sharp-libvips-linux-x64": "1.1.0", + "@img/sharp-libvips-linuxmusl-arm64": "1.1.0", + "@img/sharp-libvips-linuxmusl-x64": "1.1.0", + "@img/sharp-linux-arm": "0.34.1", + "@img/sharp-linux-arm64": "0.34.1", + "@img/sharp-linux-s390x": "0.34.1", + "@img/sharp-linux-x64": "0.34.1", + "@img/sharp-linuxmusl-arm64": "0.34.1", + "@img/sharp-linuxmusl-x64": "0.34.1", + "@img/sharp-wasm32": "0.34.1", + "@img/sharp-win32-ia32": "0.34.1", + "@img/sharp-win32-x64": "0.34.1" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "license": "ISC", + "optional": true + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "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", + "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/simple-swizzle": { + "version": "0.2.2", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.4", + "license": "MIT", + "optional": true, + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "6.2.1", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/socks-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/sonner": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.3.tgz", + "integrity": "sha512-njQ4Hht92m0sMqqHVDL32V2Oun9W1+PHO9NDv9FHfJjT3JT22IG4Jpo3FPQy+mouRKCXFWO+r67v6MrHX2zeIA==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/sqlite": { + "version": "5.1.1", + "license": "MIT" + }, + "node_modules/sqlite3": { + "version": "5.1.7", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.1", + "tar": "^6.1.11" + }, + "optionalDependencies": { + "node-gyp": "8.x" + }, + "peerDependencies": { + "node-gyp": "8.x" + }, + "peerDependenciesMeta": { + "node-gyp": { + "optional": true + } + } + }, + "node_modules/ssri": { + "version": "8.0.1", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.8.0", + "dev": true, + "license": "MIT" + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "devOptional": true, + "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": { + "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==", + "dev": true, + "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/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width/node_modules/emoji-regex": { + "version": "8.0.0", + "devOptional": true, + "license": "MIT" + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/style-to-js": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.16.tgz", + "integrity": "sha512-/Q6ld50hKYPH3d/r6nr117TZkHR0w0kGGIVfpG9N6D8NymRPM9RqCUv4pRpJ62E5DqOYx2AFpbZMyCPnjQCnOw==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.8" + } + }, + "node_modules/style-to-object": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.8.tgz", + "integrity": "sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.4" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "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/sucrase/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/super-regex": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "function-timeout": "^1.0.1", + "time-span": "^5.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.0.tgz", + "integrity": "sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwind-scrollbar-hide": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/tailwind-scrollbar-hide/-/tailwind-scrollbar-hide-1.1.7.tgz", + "integrity": "sha512-X324n9OtpTmOMqEgDUEA/RgLrNfBF/jwJdctaPZDzB3mppxJk7TLIDmOreEDm1Bq4R9LSPu4Epf8VSdovNU+iA==", + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.7.tgz", + "integrity": "sha512-kr1o/ErIdNhTz8uzAYL7TpaUuzKIE6QPQ4qmSdxnoX/lo+5wmUHQA6h3L5yIqEImSRnAAURDirLu/BgiXGPAhg==", + "license": "MIT" + }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-fs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", + "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", + "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", + "license": "ISC" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "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/tar/node_modules/minipass": { + "version": "5.0.0", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/text-hex": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/time-span": { + "version": "5.1.0", + "license": "MIT", + "dependencies": { + "convert-hrtime": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.1", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", + "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.84", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tldts-core": "^6.1.84" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.84", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "peer": true, + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.20.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz", + "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.7.2", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.10.0.tgz", + "integrity": "sha512-u5otvFBOBZvmdjWLVW+5DAc9Nkq8f24g0O9oY7qw2JVIF1VocIFoyz9JFkuVOS2j41AufeO0xnlweJ2RLT8nGw==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "6.19.8", + "dev": true, + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unique-filename": { + "version": "1.1.1", + "license": "ISC", + "optional": true, + "dependencies": { + "unique-slug": "^2.0.0" + } + }, + "node_modules/unique-slug": { + "version": "2.0.2", + "license": "ISC", + "optional": true, + "dependencies": { + "imurmurhash": "^0.1.4" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", + "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", + "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unrs-resolver": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.7.2.tgz", + "integrity": "sha512-BBKpaylOW8KbHsu378Zky/dGh4ckT/4NW/0SHRABdqRLcQJ2dAOjDo9g97p04sWflm0kqPqpUatxReNV/dqI5A==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.2.2" + }, + "funding": { + "url": "https://github.com/sponsors/JounQin" + }, + "optionalDependencies": { + "@unrs/resolver-binding-darwin-arm64": "1.7.2", + "@unrs/resolver-binding-darwin-x64": "1.7.2", + "@unrs/resolver-binding-freebsd-x64": "1.7.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.7.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.7.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.7.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.7.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.7.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.7.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.7.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.7.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.7.2", + "@unrs/resolver-binding-linux-x64-musl": "1.7.2", + "@unrs/resolver-binding-wasm32-wasi": "1.7.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.7.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.7.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.7.2" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", + "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "5.4.19", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", + "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "devOptional": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/winston": { + "version": "3.17.0", + "license": "MIT", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.2", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/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==", + "dev": true, + "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/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.18.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "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/xml-name-validator": { + "version": "5.0.0", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/xtend": { + "version": "4.0.2", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", + "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.24.4", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "packages/core": { + "name": "@aiostreams/core", + "version": "0.0.0", + "dependencies": { + "bcrypt": "^6.0.0", + "bytes": "^3.1.2", + "dotenv": "^16.4.7", + "envalid": "^8.0.0", + "expr-eval": "^2.0.2", + "moment-timezone": "^0.5.48", + "parse-torrent-title": "github:TheBeastLT/parse-torrent-title", + "pg": "^8.16.0", + "sqlite": "^5.1.1", + "sqlite3": "^5.1.7", + "super-regex": "^1.0.0", + "undici": "^7.2.3", + "winston": "^3.17.0", + "zod": "^3.24.4" + }, + "devDependencies": { + "@types/bcrypt": "^5.0.2", + "@types/bytes": "^3.1.5", + "@types/node": "^20.14.10", + "@types/pg": "^8.15.2" + } + }, + "packages/frontend": { + "name": "@aiostreams/frontend", + "version": "0.0.0", + "dependencies": { + "@aiostreams/core": "^0.0.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@headlessui/react": "^2.2.4", + "@headlessui/tailwindcss": "^0.2.2", + "@radix-ui/react-accordion": "^1.2.11", + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-password-toggle-field": "^0.1.2", + "@radix-ui/react-popover": "^1.1.14", + "@radix-ui/react-select": "^2.2.5", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slider": "^1.3.5", + "@radix-ui/react-switch": "^1.2.5", + "@radix-ui/react-tabs": "^1.1.12", + "@radix-ui/react-tooltip": "^1.2.7", + "@tailwindcss/forms": "^0.5.10", + "@tailwindcss/typography": "^0.5.16", + "@zag-js/number-input": "^1.13.1", + "@zag-js/react": "^1.13.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "framer-motion": "^12.12.2", + "lucide-react": "^0.511.0", + "next": "15.3.2", + "next-themes": "^0.4.6", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-icons": "^5.5.0", + "react-markdown": "^10.1.0", + "sonner": "^2.0.3", + "tailwind-merge": "^3.3.0", + "tailwind-scrollbar-hide": "~1.1.7", + "tailwindcss-animate": "~1.0.7" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "autoprefixer": "^10.4.21", + "eslint": "^9", + "eslint-config-next": "15.3.2", + "postcss": "^8.5.3", + "postcss-import": "^16.1.0", + "postcss-nesting": "^13.0.1", + "tailwindcss": "^3.4.17", + "typescript": "^5" + } + }, + "packages/frontend-temp": { + "version": "0.1.0", + "extraneous": true, + "dependencies": { + "next": "15.3.2", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "15.3.2", + "tailwindcss": "^4", + "typescript": "^5" + } + }, + "packages/frontend/node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "packages/frontend/node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "packages/frontend/node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "packages/frontend/node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "packages/frontend/node_modules/postcss-import": { + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-16.1.0.tgz", + "integrity": "sha512-7hsAZ4xGXl4MW+OKEWCnF6T5jqBw80/EE9aXg1r2yyn1RsVEU8EtKXbijEODa+rg7iih4bKf7vlvTGYR4CnPNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "packages/frontend/node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "packages/frontend/node_modules/tailwindcss/node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "packages/frontend/node_modules/tailwindcss/node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "packages/server": { + "name": "@aiostreams/server", + "version": "0.0.0", + "dependencies": { + "@aiostreams/core": "^0.0.0", + "express": "^4.21.2", + "express-rate-limit": "^7.5.0" + }, + "devDependencies": { + "@types/express": "^5.0.1", + "@types/express-rate-limit": "^5.1.3", + "@types/node": "^20.14.10" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000000000000000000000000000000000..4fede5ec99341e7e76333979ce985e6a74c1fa74 --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "aiostreams", + "version": "2.4.2", + "description": "AIOStreams consolidates multiple Stremio addons and debrid services into a single, easily configurable addon. It allows highly customisable filtering, sorting, and formatting of results and supports proxying all your streams through MediaFlow Proxy or StremThru for improved compatibility and IP restriction bypassing.", + "main": "dist/index.js", + "scripts": { + "test": "npm run test --workspaces", + "release": "commit-and-tag-version", + "format": "prettier --write .", + "metadata": "node scripts/generateMetadata.js", + "build": "npm -w packages/core run build && npm -w packages/server run build && npm -w packages/frontend run build", + "build:watch": "tsc --build --watch", + "start": "node packages/server/dist/server", + "start:addon": "npm run start", + "start:dev": "cross-env NODE_ENV=development tsx watch packages/server/src/server.ts", + "start:addon:dev": "npm run start:dev", + "start:frontend:dev": "npm -w packages/frontend run dev" + }, + "author": "Viren070", + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "devDependencies": { + "cross-env": "^7.0.3", + "prettier": "^3.3.2", + "tsx": "^4.16.2", + "typescript": "^5.5.3", + "vitest": "^2.1.5", + "ts-node": "^10.9.2" + }, + "engines": { + "node": ">=20.0.0" + } +} \ No newline at end of file diff --git a/packages/addon/package.json b/packages/addon/package.json new file mode 100644 index 0000000000000000000000000000000000000000..d1675917b195c351a1534a8a18eb4164a908f40c --- /dev/null +++ b/packages/addon/package.json @@ -0,0 +1,25 @@ +{ + "name": "@aiostreams/addon", + "version": "1.21.1", + "main": "dist/index.js", + "scripts": { + "test": "vitest run --passWithNoTests", + "test:watch": "vitest watch", + "build": "tsc", + "prepublish": "npm run build", + "start": "node dist/server.js", + "start:dev": "cross-env NODE_ENV=dev tsx watch src/server.ts" + }, + "description": "Combine all your streams into one addon and display them with consistent formatting, sorting, and filtering.", + "dependencies": { + "@aiostreams/formatters": "^1.0.0", + "@aiostreams/types": "^1.0.0", + "@aiostreams/utils": "^1.0.0", + "@aiostreams/wrappers": "^1.0.0", + "dotenv": "^16.4.7", + "express": "^4.21.2" + }, + "devDependencies": { + "@types/express": "^5.0.0" + } +} diff --git a/packages/addon/src/addon.ts b/packages/addon/src/addon.ts new file mode 100644 index 0000000000000000000000000000000000000000..caf6e3637c12035119d34a900072f62f1d660fcb --- /dev/null +++ b/packages/addon/src/addon.ts @@ -0,0 +1,1416 @@ +import { + BaseWrapper, + getCometStreams, + getDebridioStreams, + getDMMCastStreams, + getEasynewsPlusPlusStreams, + getEasynewsPlusStreams, + getEasynewsStreams, + getJackettioStreams, + getMediafusionStreams, + getOrionStreams, + getPeerflixStreams, + getStremioJackettStreams, + getStremThruStoreStreams, + getTorboxStreams, + getTorrentioStreams, +} from '@aiostreams/wrappers'; +import { + Stream, + ParsedStream, + StreamRequest, + Config, + ErrorStream, +} from '@aiostreams/types'; +import { + gdriveFormat, + torrentioFormat, + torboxFormat, + imposterFormat, + customFormat, +} from '@aiostreams/formatters'; +import { + addonDetails, + getMediaFlowConfig, + getMediaFlowPublicIp, + getTimeTakenSincePoint, + Settings, + createLogger, + generateMediaFlowStreams, + getStremThruConfig, + getStremThruPublicIp, + generateStremThruStreams, + safeRegexTest, + compileRegex, + formRegexFromKeywords, +} from '@aiostreams/utils'; +import { errorStream } from './responses'; +import { isMatch } from 'super-regex'; + +const logger = createLogger('addon'); + +export class AIOStreams { + private config: Config; + + constructor(config: any) { + this.config = config; + } + + private async retryGetIp( + getter: () => Promise, + label: string, + maxRetries: number = 3 + ): Promise { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + const result = await getter(); + if (result) { + return result; + } + logger.warn( + `Failed to get ${label}, retrying... (${attempt}/${maxRetries})` + ); + } + throw new Error(`Failed to get ${label} after ${maxRetries} attempts`); + } + + private async getRequestingIp() { + let userIp = this.config.requestingIp; + const PRIVATE_IP_REGEX = + /^(::1|::ffff:(10|127|192|172)\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})|10\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})|127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})|192\.168\.(\d{1,3})\.(\d{1,3})|172\.(1[6-9]|2[0-9]|3[0-1])\.(\d{1,3})\.(\d{1,3}))$/; + + if (userIp && PRIVATE_IP_REGEX.test(userIp)) { + userIp = undefined; + } + const mediaflowConfig = getMediaFlowConfig(this.config); + const stremThruConfig = getStremThruConfig(this.config); + if (mediaflowConfig.mediaFlowEnabled) { + userIp = await this.retryGetIp( + () => getMediaFlowPublicIp(mediaflowConfig), + 'MediaFlow public IP' + ); + } else if (stremThruConfig.stremThruEnabled) { + userIp = await this.retryGetIp( + () => getStremThruPublicIp(stremThruConfig), + 'StremThru public IP' + ); + } + return userIp; + } + + public async getStreams(streamRequest: StreamRequest): Promise { + const streams: Stream[] = []; + const startTime = new Date().getTime(); + + try { + this.config.requestingIp = await this.getRequestingIp(); + } catch (error) { + logger.error(error); + return [errorStream(`Failed to get Proxy IP`)]; + } + + const { parsedStreams, errorStreams } = + await this.getParsedStreams(streamRequest); + + const skipReasons = { + excludeLanguages: 0, + excludeResolutions: 0, + excludeQualities: 0, + excludeEncodes: 0, + excludeAudioTags: 0, + excludeVisualTags: 0, + excludeStreamTypes: 0, + excludeUncached: 0, + sizeFilters: 0, + duplicateStreams: 0, + streamLimiters: 0, + excludeRegex: 0, + requiredRegex: 0, + }; + + logger.info( + `Got ${parsedStreams.length} parsed streams and ${errorStreams.length} error streams in ${getTimeTakenSincePoint(startTime)}` + ); + + const excludeRegexPattern = this.config.apiKey + ? this.config.regexFilters?.excludePattern || + Settings.DEFAULT_REGEX_EXCLUDE_PATTERN + : undefined; + const excludeRegex = excludeRegexPattern + ? compileRegex(excludeRegexPattern, 'i') + : undefined; + + const excludeKeywordsRegex = this.config.excludeFilters + ? formRegexFromKeywords(this.config.excludeFilters) + : undefined; + + const requiredRegexPattern = this.config.apiKey + ? this.config.regexFilters?.includePattern || + Settings.DEFAULT_REGEX_INCLUDE_PATTERN + : undefined; + const requiredRegex = requiredRegexPattern + ? compileRegex(requiredRegexPattern, 'i') + : undefined; + + const requiredKeywordsRegex = this.config.strictIncludeFilters + ? formRegexFromKeywords(this.config.strictIncludeFilters) + : undefined; + + const sortRegexPatterns = this.config.apiKey + ? this.config.regexSortPatterns || Settings.DEFAULT_REGEX_SORT_PATTERNS + : undefined; + + const sortRegexes: { name?: string; regex: RegExp }[] | undefined = + sortRegexPatterns + ? sortRegexPatterns + .split(/\s+/) + .filter(Boolean) + .map((pattern) => { + const delimiter = '<::>'; + const delimiterIndex = pattern.indexOf(delimiter); + if (delimiterIndex !== -1) { + const name = pattern + .slice(0, delimiterIndex) + .replace(/_/g, ' '); + const regexPattern = pattern.slice( + delimiterIndex + delimiter.length + ); + + const regex = compileRegex(regexPattern, 'i'); + return { name, regex }; + } + return { regex: compileRegex(pattern, 'i') }; + }) + : undefined; + + excludeRegex || + excludeKeywordsRegex || + requiredRegex || + requiredKeywordsRegex || + sortRegexes + ? logger.debug( + `The following regex patterns are being used:\n` + + `Exclude Regex: ${excludeRegex}\n` + + `Exclude Keywords: ${excludeKeywordsRegex}\n` + + `Required Regex: ${requiredRegex}\n` + + `Required Keywords: ${requiredKeywordsRegex}\n` + + `Sort Regexes: ${sortRegexes?.map((regex) => `${regex.name || 'Unnamed'}: ${regex.regex}`).join(' --> ')}\n` + ) + : []; + + const filterStartTime = new Date().getTime(); + + let filteredResults = parsedStreams.filter((parsedStream) => { + const streamTypeFilter = this.config.streamTypes?.find( + (streamType) => streamType[parsedStream.type] === false + ); + if (this.config.streamTypes && streamTypeFilter) { + skipReasons.excludeStreamTypes++; + return false; + } + + const resolutionFilter = this.config.resolutions?.find( + (resolution) => resolution[parsedStream.resolution] === false + ); + if (resolutionFilter) { + skipReasons.excludeResolutions++; + return false; + } + + const qualityFilter = this.config.qualities?.find( + (quality) => quality[parsedStream.quality] === false + ); + if (this.config.qualities && qualityFilter) { + skipReasons.excludeQualities++; + return false; + } + + // Check for HDR and DV tags in the parsed stream + const hasHDR = parsedStream.visualTags.some((tag) => + tag.startsWith('HDR') + ); + const hasDV = parsedStream.visualTags.includes('DV'); + const hasHDRAndDV = hasHDR && hasDV; + const HDRAndDVEnabled = this.config.visualTags.some( + (visualTag) => visualTag['HDR+DV'] === true + ); + + const isTagDisabled = (tag: string) => + this.config.visualTags.some((visualTag) => visualTag[tag] === false); + + if (hasHDRAndDV) { + if (!HDRAndDVEnabled) { + skipReasons.excludeVisualTags++; + return false; + } + } else if (hasHDR) { + const specificHdrTags = parsedStream.visualTags.filter((tag) => + tag.startsWith('HDR') + ); + const disabledTags = specificHdrTags.filter( + (tag) => isTagDisabled(tag) === true + ); + if (disabledTags.length > 0) { + skipReasons.excludeVisualTags++; + return; + } + } else if (hasDV && isTagDisabled('DV')) { + skipReasons.excludeVisualTags++; + return false; + } + + // Check other visual tags for explicit disabling + for (const tag of parsedStream.visualTags) { + if (tag.startsWith('HDR') || tag === 'DV') continue; + if (isTagDisabled(tag)) { + skipReasons.excludeVisualTags++; + return false; + } + } + + // apply excludedLanguages filter + const excludedLanguages = this.config.excludedLanguages; + if (excludedLanguages && parsedStream.languages.length > 0) { + if ( + parsedStream.languages.every((lang) => + excludedLanguages.includes(lang) + ) + ) { + skipReasons.excludeLanguages++; + return false; + } + } else if ( + excludedLanguages && + excludedLanguages.includes('Unknown') && + parsedStream.languages.length === 0 + ) { + skipReasons.excludeLanguages++; + return false; + } + + const audioTagFilter = parsedStream.audioTags.find((tag) => + this.config.audioTags.some((audioTag) => audioTag[tag] === false) + ); + if (audioTagFilter) { + skipReasons.excludeAudioTags++; + return false; + } + + if ( + parsedStream.encode && + this.config.encodes.some( + (encode) => encode[parsedStream.encode] === false + ) + ) { + skipReasons.excludeEncodes++; + return false; + } + + if ( + this.config.onlyShowCachedStreams && + parsedStream.provider && + !parsedStream.provider.cached + ) { + skipReasons.excludeUncached++; + return false; + } + + if ( + this.config.minSize && + parsedStream.size && + parsedStream.size < this.config.minSize + ) { + skipReasons.sizeFilters++; + return false; + } + + if ( + this.config.maxSize && + parsedStream.size && + parsedStream.size > this.config.maxSize + ) { + skipReasons.sizeFilters++; + return false; + } + + if ( + streamRequest.type === 'movie' && + this.config.maxMovieSize && + parsedStream.size && + parsedStream.size > this.config.maxMovieSize + ) { + skipReasons.sizeFilters++; + return false; + } + + if ( + streamRequest.type === 'movie' && + this.config.minMovieSize && + parsedStream.size && + parsedStream.size < this.config.minMovieSize + ) { + skipReasons.sizeFilters++; + return false; + } + + if ( + streamRequest.type === 'series' && + this.config.maxEpisodeSize && + parsedStream.size && + parsedStream.size > this.config.maxEpisodeSize + ) { + skipReasons.sizeFilters++; + return false; + } + + if ( + streamRequest.type === 'series' && + this.config.minEpisodeSize && + parsedStream.size && + parsedStream.size < this.config.minEpisodeSize + ) { + skipReasons.sizeFilters++; + return false; + } + + // generate array of excludeTests. for each regex, only add to array if the filename or indexers are defined + let excludeTests: (boolean | null)[] = []; + let requiredTests: (boolean | null)[] = []; + + const addToTests = (field: string | undefined) => { + if (field) { + excludeTests.push( + excludeRegex ? safeRegexTest(excludeRegex, field) : null, + excludeKeywordsRegex + ? safeRegexTest(excludeKeywordsRegex, field) + : null + ); + requiredTests.push( + requiredRegex ? safeRegexTest(requiredRegex, field) : null, + requiredKeywordsRegex + ? safeRegexTest(requiredKeywordsRegex, field) + : null + ); + } + }; + + addToTests(parsedStream.filename); + addToTests(parsedStream.folderName); + addToTests(parsedStream.indexers); + + // filter out any null values as these are when the regex is not defined + excludeTests = excludeTests.filter((test) => test !== null); + requiredTests = requiredTests.filter((test) => test !== null); + + if (excludeTests.length > 0 && excludeTests.some((test) => test)) { + skipReasons.excludeRegex++; + return false; + } + + if (requiredTests.length > 0 && !requiredTests.some((test) => test)) { + skipReasons.requiredRegex++; + return false; + } + + return true; + }); + + logger.info( + `Initial filter to ${filteredResults.length} streams in ${getTimeTakenSincePoint(filterStartTime)}` + ); + + if (this.config.cleanResults) { + const cleanedStreams: ParsedStream[] = []; + const initialStreams = filteredResults; + const normaliseFilename = (filename?: string): string | undefined => + filename + ? filename + ?.replace( + /\.(mkv|mp4|avi|mov|wmv|flv|webm|m4v|mpg|mpeg|3gp|3g2|m2ts|ts|vob|ogv|ogm|divx|xvid|rm|rmvb|asf|mxf|mka|mks|mk3d|webm|f4v|f4p|f4a|f4b)$/i, + '' + ) + .replace(/[^\p{L}\p{N}+]/gu, '') + .replace(/\s+/g, '') + .toLowerCase() + : undefined; + + const groupStreamsByKey = ( + streams: ParsedStream[], + keyExtractor: (stream: ParsedStream) => string | undefined + ): Record => { + return streams.reduce( + (acc, stream) => { + const key = keyExtractor(stream); + if (!key) { + if (!cleanedStreams.includes(stream)) { + cleanedStreams.push(stream); + } + return acc; + } + acc[key] = acc[key] || []; + acc[key].push(stream); + return acc; + }, + {} as Record + ); + }; + + const cleanResultsStartTime = new Date().getTime(); + // Deduplication by normalised filename + const cleanResultsByFilenameStartTime = new Date().getTime(); + logger.info(`Received ${initialStreams.length} streams to clean`); + const streamsGroupedByFilename = groupStreamsByKey( + initialStreams, + (stream) => normaliseFilename(stream.filename) + ); + + logger.info( + `Found ${Object.keys(streamsGroupedByFilename).length} unique filenames with ${ + initialStreams.length - + Object.values(streamsGroupedByFilename).reduce( + (sum, group) => sum + group.length, + 0 + ) + } streams not grouped` + ); + + // Process grouped streams by filename + const cleanedStreamsByFilename = await this.processGroupedStreams( + streamsGroupedByFilename + ); + + logger.info( + `Deduplicated streams by filename to ${cleanedStreamsByFilename.length} streams in ${getTimeTakenSincePoint(cleanResultsByFilenameStartTime)}` + ); + + // Deduplication by hash + const cleanResultsByHashStartTime = new Date().getTime(); + + const streamsGroupedByHash = groupStreamsByKey( + cleanedStreamsByFilename, + (stream) => stream._infoHash + ); + logger.info( + `Found ${Object.keys(streamsGroupedByHash).length} unique hashes with ${cleanedStreamsByFilename.length - Object.values(streamsGroupedByHash).reduce((sum, group) => sum + group.length, 0)} streams not grouped` + ); + + // Process grouped streams by hash + const cleanedStreamsByHash = + await this.processGroupedStreams(streamsGroupedByHash); + + logger.info( + `Deduplicated streams by hash to ${cleanedStreamsByHash.length} streams in ${getTimeTakenSincePoint(cleanResultsByHashStartTime)}` + ); + + cleanedStreams.push(...cleanedStreamsByHash); + logger.info( + `Deduplicated streams to ${cleanedStreams.length} streams in ${getTimeTakenSincePoint(cleanResultsStartTime)}` + ); + skipReasons.duplicateStreams = + filteredResults.length - cleanedStreams.length; + filteredResults = cleanedStreams; + } + // pre compute highest indexes for regexSortPatterns + const startPrecomputeTime = new Date().getTime(); + filteredResults.forEach((stream: ParsedStream) => { + if (sortRegexes) { + for (let i = 0; i < sortRegexes.length; i++) { + if (!stream.filename && !stream.folderName) continue; + const regex = sortRegexes[i]; + if ( + (stream.filename && isMatch(regex.regex, stream.filename)) || + (stream.folderName && isMatch(regex.regex, stream.folderName)) + ) { + stream.regexMatched = { + name: regex.name, + pattern: regex.regex.source, + index: i, + }; + break; + } + } + } + }); + logger.info( + `Precomputed sortRegex indexes for ${filteredResults.length} streams in ${getTimeTakenSincePoint( + startPrecomputeTime + )}` + ); + // Apply sorting + const sortStartTime = new Date().getTime(); + // initially sort by filename to ensure consistent results + filteredResults.sort((a, b) => + a.filename && b.filename ? a.filename.localeCompare(b.filename) : 0 + ); + + // then apply our this.config sorting + filteredResults.sort((a, b) => { + for (const sortByField of this.config.sortBy) { + const field = Object.keys(sortByField).find( + (key) => typeof sortByField[key] === 'boolean' + ); + if (!field) continue; + const value = sortByField[field]; + + if (value) { + const fieldComparison = this.compareByField(a, b, field); + if (fieldComparison !== 0) return fieldComparison; + } + } + + return 0; + }); + + logger.info(`Sorted results in ${getTimeTakenSincePoint(sortStartTime)}`); + + // apply config.maxResultsPerResolution + if (this.config.maxResultsPerResolution) { + const startTime = new Date().getTime(); + const resolutionCounts = new Map(); + + const limitedResults = filteredResults.filter((result) => { + const resolution = result.resolution || 'Unknown'; + const currentCount = resolutionCounts.get(resolution) || 0; + + if (currentCount < this.config.maxResultsPerResolution!) { + resolutionCounts.set(resolution, currentCount + 1); + return true; + } + + return false; + }); + skipReasons.streamLimiters = + filteredResults.length - limitedResults.length; + filteredResults = limitedResults; + + logger.info( + `Limited results to ${limitedResults.length} streams after applying maxResultsPerResolution in ${new Date().getTime() - startTime}ms` + ); + } + + const totalSkipped = Object.values(skipReasons).reduce( + (acc, val) => acc + val, + 0 + ); + const reportLines = [ + '╔═══════════════════════╤════════════╗', + '║ Skip Reason │ Count ║', + '╟───────────────────────┼────────────╢', + ...Object.entries(skipReasons) + .filter(([reason, count]) => count > 0) + .map( + ([reason, count]) => + `║ ${reason.padEnd(21)} │ ${String(count).padStart(10)} ║` + ), + '╟───────────────────────┼────────────╢', + `║ Total Skipped │ ${String(totalSkipped).padStart(10)} ║`, + '╚═══════════════════════╧════════════╝', + ]; + + if (totalSkipped > 0) logger.info('\n' + reportLines.join('\n')); + + // Create stream objects + const streamsStartTime = new Date().getTime(); + const streamObjects = await this.createStreamObjects(filteredResults); + streams.push(...streamObjects.filter((s) => s !== null)); + + // Add error streams to the end + streams.push( + ...errorStreams.map((e) => errorStream(e.error, e.addon.name)) + ); + + logger.info( + `Created ${streams.length} stream objects in ${getTimeTakenSincePoint(streamsStartTime)}` + ); + logger.info( + `Total time taken to get streams: ${getTimeTakenSincePoint(startTime)}` + ); + return streams; + } + + private shouldProxyStream( + stream: ParsedStream, + mediaFlowConfig: ReturnType, + stremThruConfig: ReturnType + ): boolean { + if (!stream.url) return false; + + const streamProvider = stream.provider ? stream.provider.id : 'none'; + + // // now check if mediaFlowConfig.proxiedAddons or mediaFlowConfig.proxiedServices is not null + // logger.info(this.config.mediaFlowConfig?.proxiedAddons); + // logger.info(stream.addon.id); + if ( + mediaFlowConfig.mediaFlowEnabled && + (!mediaFlowConfig.proxiedAddons?.length || + mediaFlowConfig.proxiedAddons.includes(stream.addon.id)) && + (!mediaFlowConfig.proxiedServices?.length || + mediaFlowConfig.proxiedServices.includes(streamProvider)) + ) { + return true; + } + + if ( + stremThruConfig.stremThruEnabled && + (!stremThruConfig.proxiedAddons?.length || + stremThruConfig.proxiedAddons.includes(stream.addon.id)) && + (!stremThruConfig.proxiedServices?.length || + stremThruConfig.proxiedServices.includes(streamProvider)) + ) { + return true; + } + + return false; + } + + private getFormattedText(parsedStream: ParsedStream): { + name: string; + description: string; + } { + switch (this.config.formatter) { + case 'gdrive': { + return gdriveFormat(parsedStream, false); + } + case 'minimalistic-gdrive': { + return gdriveFormat(parsedStream, true); + } + case 'imposter': { + return imposterFormat(parsedStream); + } + case 'torrentio': { + return torrentioFormat(parsedStream); + } + case 'torbox': { + return torboxFormat(parsedStream); + } + default: { + if ( + this.config.formatter.startsWith('custom:') && + this.config.formatter.length > 7 + ) { + const jsonString = this.config.formatter.slice(7); + const formatter = JSON.parse(jsonString); + if (formatter.name && formatter.description) { + try { + return customFormat(parsedStream, formatter); + } catch (error: any) { + logger.error( + `Error in custom formatter: ${error.message || error}, falling back to default formatter` + ); + return gdriveFormat(parsedStream, false); + } + } + } + + return gdriveFormat(parsedStream, false); + } + } + } + + private async createStreamObjects( + parsedStreams: ParsedStream[] + ): Promise { + const mediaFlowConfig = getMediaFlowConfig(this.config); + const stremThruConfig = getStremThruConfig(this.config); + + // Identify streams that require proxying + const streamsToProxy = parsedStreams + .map((stream, index) => ({ stream, index })) + .filter( + ({ stream }) => + stream.url && + this.shouldProxyStream(stream, mediaFlowConfig, stremThruConfig) + ); + + const proxiedUrls = streamsToProxy.length + ? mediaFlowConfig.mediaFlowEnabled + ? await generateMediaFlowStreams( + mediaFlowConfig, + streamsToProxy.map(({ stream }) => ({ + url: stream.url!, + filename: stream.filename, + headers: stream.stream?.behaviorHints?.proxyHeaders, + })) + ) + : stremThruConfig.stremThruEnabled + ? await generateStremThruStreams( + stremThruConfig, + streamsToProxy.map(({ stream }) => ({ + url: stream.url!, + filename: stream.filename, + headers: stream.stream?.behaviorHints?.proxyHeaders, + })) + ) + : null + : null; + + const removeIndexes = new Set(); + + // Apply proxied URLs and mark as proxied + streamsToProxy.forEach(({ stream, index }, i) => { + const proxiedUrl = proxiedUrls?.[i]; + if (proxiedUrl) { + stream.url = proxiedUrl; + stream.proxied = true; + } else { + removeIndexes.add(index); + } + }); + + // Remove streams that failed to proxy + if (removeIndexes.size > 0) { + logger.error( + `Failed to proxy ${removeIndexes.size} streams, removing them from the final list` + ); + parsedStreams = parsedStreams.filter( + (_, index) => !removeIndexes.has(index) + ); + } + + // Build final Stream objects + const proxyBingeGroupPrefix = mediaFlowConfig.mediaFlowEnabled + ? 'mfp.' + : stremThruConfig.stremThruEnabled + ? 'st.' + : ''; + const streamObjects: Stream[] = await Promise.all( + parsedStreams.map((parsedStream) => { + const { name, description } = this.getFormattedText(parsedStream); + + const combinedTags = [ + parsedStream.resolution, + parsedStream.quality, + parsedStream.encode, + ...parsedStream.visualTags, + ...parsedStream.audioTags, + ...parsedStream.languages, + ]; + + return { + url: parsedStream.url, + externalUrl: parsedStream.externalUrl, + infoHash: parsedStream.torrent?.infoHash, + fileIdx: parsedStream.torrent?.fileIdx, + name, + description, + subtitles: parsedStream.stream?.subtitles, + sources: parsedStream.torrent?.sources, + behaviorHints: { + videoSize: parsedStream.size + ? Math.floor(parsedStream.size) + : undefined, + filename: parsedStream.filename, + bingeGroup: `${parsedStream.proxied ? proxyBingeGroupPrefix : ''}${Settings.ADDON_ID}|${parsedStream.addon.name}|${combinedTags.join('|')}`, + proxyHeaders: parsedStream.stream?.behaviorHints?.proxyHeaders, + notWebReady: parsedStream.stream?.behaviorHints?.notWebReady, + }, + }; + }) + ); + + return streamObjects; + } + + private compareLanguages(a: ParsedStream, b: ParsedStream) { + if (this.config.prioritiseLanguage) { + const aHasPrioritisedLanguage = a.languages.includes( + this.config.prioritiseLanguage + ); + const bHasPrioritisedLanguage = b.languages.includes( + this.config.prioritiseLanguage + ); + + if (aHasPrioritisedLanguage && !bHasPrioritisedLanguage) return -1; + if (!aHasPrioritisedLanguage && bHasPrioritisedLanguage) return 1; + } + return 0; + } + + private compareByField(a: ParsedStream, b: ParsedStream, field: string) { + if (field === 'resolution') { + return ( + this.config.resolutions.findIndex( + (resolution) => resolution[a.resolution] + ) - + this.config.resolutions.findIndex( + (resolution) => resolution[b.resolution] + ) + ); + } else if (field === 'regexSort') { + const regexSortPatterns = + this.config.regexSortPatterns || Settings.DEFAULT_REGEX_SORT_PATTERNS; + if (!regexSortPatterns) return 0; + try { + // Get direction once + const direction = this.config.sortBy.find( + (sort) => Object.keys(sort)[0] === 'regexSort' + )?.direction; + + // Early exit if no filename to test + if (!a.filename && !b.filename) return 0; + if (!a.filename) return direction === 'asc' ? -1 : 1; + if (!b.filename) return direction === 'asc' ? 1 : -1; + + const aHighestIndex = a.regexMatched?.index; + const bHighestIndex = b.regexMatched?.index; + + // If both have a regex match, sort by the highest index + if (aHighestIndex !== undefined && bHighestIndex !== undefined) { + return direction === 'asc' + ? bHighestIndex - aHighestIndex + : aHighestIndex - bHighestIndex; + } + // If one has a regex match and the other doesn't, sort by the one that does + if (aHighestIndex !== undefined) return direction === 'asc' ? 1 : -1; + if (bHighestIndex !== undefined) return direction === 'asc' ? -1 : 1; + + // If both have no regex match, they are equal + return 0; + } catch (e) { + return 0; + } + } else if (field === 'cached') { + let aCanbeCached = a.provider; + let bCanbeCached = b.provider; + let aCached = a.provider?.cached; + let bCached = b.provider?.cached; + + // prioritise non debrid/usenet p2p over uncached + if (aCanbeCached && !bCanbeCached && !aCached) return 1; + if (!aCanbeCached && bCanbeCached && !bCached) return -1; + if (aCanbeCached && bCanbeCached) { + if (aCached === bCached) return 0; + // prioritise a false value over undefined + if (aCached === false && bCached === undefined) return -1; + if (aCached === undefined && bCached === false) return 1; + return this.config.sortBy.find( + (sort) => Object.keys(sort)[0] === 'cached' + )?.direction === 'asc' + ? aCached + ? 1 + : -1 // uncached > cached + : aCached + ? -1 + : 1; // cached > uncached + } + } else if (field === 'personal') { + // depending on direction, sort by personal or not personal + const direction = this.config.sortBy.find( + (sort) => Object.keys(sort)[0] === 'personal' + )?.direction; + if (direction === 'asc') { + // prefer not personal over personal + return a.personal === b.personal ? 0 : a.personal ? 1 : -1; + } + if (direction === 'desc') { + // prefer personal over not personal + return a.personal === b.personal ? 0 : a.personal ? -1 : 1; + } + } else if (field === 'service') { + // sort files with providers by name + let aProvider = a.provider?.id; + let bProvider = b.provider?.id; + + if (aProvider && bProvider) { + const aIndex = this.config.services.findIndex( + (service) => service.id === aProvider + ); + const bIndex = this.config.services.findIndex( + (service) => service.id === bProvider + ); + return aIndex - bIndex; + } + } else if (field === 'size') { + return this.config.sortBy.find((sort) => Object.keys(sort)[0] === 'size') + ?.direction === 'asc' + ? (a.size || 0) - (b.size || 0) + : (b.size || 0) - (a.size || 0); + } else if (field === 'seeders') { + if ( + a.torrent?.seeders !== undefined && + b.torrent?.seeders !== undefined + ) { + return this.config.sortBy.find( + (sort) => Object.keys(sort)[0] === 'seeders' + )?.direction === 'asc' + ? a.torrent.seeders - b.torrent.seeders + : b.torrent.seeders - a.torrent.seeders; + } else if ( + a.torrent?.seeders !== undefined && + b.torrent?.seeders === undefined + ) { + return -1; + } else if ( + a.torrent?.seeders === undefined && + b.torrent?.seeders !== undefined + ) { + return 1; + } + } else if (field === 'streamType') { + return ( + (this.config.streamTypes?.findIndex( + (streamType) => streamType[a.type] + ) ?? -1) - + (this.config.streamTypes?.findIndex( + (streamType) => streamType[b.type] + ) ?? -1) + ); + } else if (field === 'quality') { + return ( + this.config.qualities.findIndex((quality) => quality[a.quality]) - + this.config.qualities.findIndex((quality) => quality[b.quality]) + ); + } else if (field === 'visualTag') { + // Find the highest priority visual tag in each file + const getIndexOfTag = (tag: string) => + this.config.visualTags.findIndex((t) => t[tag]); + + const getHighestPriorityTagIndex = (tags: string[]) => { + // Check if the file contains both any HDR tag and DV + const hasHDR = tags.some((tag) => tag.startsWith('HDR')); + const hasDV = tags.includes('DV'); + + if (hasHDR && hasDV) { + // Sort according to the position of the HDR+DV tag + const hdrDvIndex = this.config.visualTags.findIndex( + (t) => t['HDR+DV'] + ); + if (hdrDvIndex !== -1) { + return hdrDvIndex; + } + } + + // If the file contains multiple HDR tags, look at the HDR tag that has the highest priority + const hdrTagIndices = tags + .filter((tag) => tag.startsWith('HDR')) + .map((tag) => getIndexOfTag(tag)); + if (hdrTagIndices.length > 0) { + return Math.min(...hdrTagIndices); + } + + // Always consider the highest priority visual tag when a file has multiple visual tags + return tags.reduce( + (minIndex, tag) => Math.min(minIndex, getIndexOfTag(tag)), + this.config.visualTags.length + ); + }; + + const aVisualTagIndex = getHighestPriorityTagIndex(a.visualTags); + const bVisualTagIndex = getHighestPriorityTagIndex(b.visualTags); + + // Sort by the visual tag index + return aVisualTagIndex - bVisualTagIndex; + } else if (field === 'audioTag') { + // Find the highest priority audio tag in each file + const getIndexOfTag = (tag: string) => + this.config.audioTags.findIndex((t) => t[tag]); + const aAudioTagIndex = a.audioTags.reduce( + (minIndex, tag) => Math.min(minIndex, getIndexOfTag(tag)), + this.config.audioTags.length + ); + + const bAudioTagIndex = b.audioTags.reduce( + (minIndex, tag) => Math.min(minIndex, getIndexOfTag(tag)), + this.config.audioTags.length + ); + // Sort by the audio tag index + return aAudioTagIndex - bAudioTagIndex; + } else if (field === 'encode') { + return ( + this.config.encodes.findIndex((encode) => encode[a.encode]) - + this.config.encodes.findIndex((encode) => encode[b.encode]) + ); + } else if (field === 'addon') { + const aAddon = a.addon.id; + const bAddon = b.addon.id; + + const addonIds = this.config.addons.map((addon) => { + return `${addon.id}-${JSON.stringify(addon.options)}`; + }); + return addonIds.indexOf(aAddon) - addonIds.indexOf(bAddon); + } else if (field === 'language') { + if (this.config.prioritiseLanguage) { + return this.compareLanguages(a, b); + } + if (!this.config.prioritisedLanguages) { + return 0; + } + // else, we look at the array of prioritisedLanguages. + // any file with a language in the prioritisedLanguages array should be prioritised + // if both files contain a prioritisedLanguage, we compare the index of the highest priority language + + const aHasPrioritisedLanguage = + a.languages.some((lang) => + this.config.prioritisedLanguages?.includes(lang) + ) || + (a.languages.length === 0 && + this.config.prioritisedLanguages?.includes('Unknown')); + const bHasPrioritisedLanguage = + b.languages.some((lang) => + this.config.prioritisedLanguages?.includes(lang) + ) || + (b.languages.length === 0 && + this.config.prioritisedLanguages?.includes('Unknown')); + + if (aHasPrioritisedLanguage && !bHasPrioritisedLanguage) return -1; + if (!aHasPrioritisedLanguage && bHasPrioritisedLanguage) return 1; + + if (aHasPrioritisedLanguage && bHasPrioritisedLanguage) { + const getHighestPriorityLanguageIndex = (languages: string[]) => { + if (languages.length === 0) { + const unknownIndex = + this.config.prioritisedLanguages!.indexOf('Unknown'); + return unknownIndex !== -1 + ? unknownIndex + : this.config.prioritisedLanguages!.length; + } + return languages.reduce((minIndex, lang) => { + const index = + this.config.prioritisedLanguages?.indexOf(lang) ?? + this.config.prioritisedLanguages!.length; + return index !== -1 ? Math.min(minIndex, index) : minIndex; + }, this.config.prioritisedLanguages!.length); + }; + + const aHighestPriorityLanguageIndex = getHighestPriorityLanguageIndex( + a.languages + ); + const bHighestPriorityLanguageIndex = getHighestPriorityLanguageIndex( + b.languages + ); + + return aHighestPriorityLanguageIndex - bHighestPriorityLanguageIndex; + } + } + return 0; + } + + private async getParsedStreams( + streamRequest: StreamRequest + ): Promise<{ parsedStreams: ParsedStream[]; errorStreams: ErrorStream[] }> { + const parsedStreams: ParsedStream[] = []; + const errorStreams: ErrorStream[] = []; + const formatError = (error: string) => + typeof error === 'string' + ? error + .replace(/- |: /g, '\n') + .split('\n') + .map((line: string) => line.trim()) + .join('\n') + .trim() + : error; + + const addonPromises = this.config.addons.map(async (addon) => { + const addonName = + addon.options.name || + addon.options.overrideName || + addonDetails.find((addonDetail) => addonDetail.id === addon.id)?.name || + addon.id; + const addonId = `${addon.id}-${JSON.stringify(addon.options)}`; + try { + const startTime = new Date().getTime(); + const { addonStreams, addonErrors } = await this.getStreamsFromAddon( + addon, + addonId, + streamRequest + ); + parsedStreams.push(...addonStreams); + errorStreams.push( + ...[...new Set(addonErrors)].map((error) => ({ + error: formatError(error), + addon: { id: addonId, name: addonName }, + })) + ); + logger.info( + `Got ${addonStreams.length} streams ${addonErrors.length > 0 ? `and ${addonErrors.length} errors ` : ''}from addon ${addonName} in ${getTimeTakenSincePoint(startTime)}` + ); + } catch (error: any) { + logger.error(`Failed to get streams from ${addonName}: ${error}`); + errorStreams.push({ + error: formatError(error.message ?? error ?? 'Unknown error'), + addon: { + id: addonId, + name: addonName, + }, + }); + } + }); + + await Promise.all(addonPromises); + return { parsedStreams, errorStreams }; + } + + private async getStreamsFromAddon( + addon: Config['addons'][0], + addonId: string, + streamRequest: StreamRequest + ): Promise<{ addonStreams: ParsedStream[]; addonErrors: string[] }> { + switch (addon.id) { + case 'torbox': { + return await getTorboxStreams( + this.config, + addon.options, + streamRequest, + addonId + ); + } + case 'torrentio': { + return await getTorrentioStreams( + this.config, + addon.options, + streamRequest, + addonId + ); + } + case 'comet': { + return await getCometStreams( + this.config, + addon.options, + streamRequest, + addonId + ); + } + case 'mediafusion': { + return await getMediafusionStreams( + this.config, + addon.options, + streamRequest, + addonId + ); + } + case 'stremio-jackett': { + return await getStremioJackettStreams( + this.config, + addon.options, + streamRequest, + addonId + ); + } + case 'jackettio': { + return await getJackettioStreams( + this.config, + addon.options, + streamRequest, + addonId + ); + } + case 'orion-stremio-addon': { + return await getOrionStreams( + this.config, + addon.options, + streamRequest, + addonId + ); + } + case 'easynews': { + return await getEasynewsStreams( + this.config, + addon.options, + streamRequest, + addonId + ); + } + case 'easynews-plus': { + return await getEasynewsPlusStreams( + this.config, + addon.options, + streamRequest, + addonId + ); + } + case 'easynews-plus-plus': { + return await getEasynewsPlusPlusStreams( + this.config, + addon.options, + streamRequest, + addonId + ); + } + case 'debridio': { + return await getDebridioStreams( + this.config, + addon.options, + streamRequest, + addonId + ); + } + case 'peerflix': { + return await getPeerflixStreams( + this.config, + addon.options, + streamRequest, + addonId + ); + } + case 'stremthru-store': { + return await getStremThruStoreStreams( + this.config, + addon.options, + streamRequest, + addonId + ); + } + case 'dmm-cast': { + return await getDMMCastStreams( + this.config, + addon.options, + streamRequest, + addonId + ); + } + case 'gdrive': { + if (!addon.options.addonUrl) { + throw new Error('The addon URL was undefined for GDrive'); + } + const wrapper = new BaseWrapper( + addon.options.overrideName || 'GDrive', + addon.options.addonUrl, + addonId, + this.config, + addon.options.indexerTimeout + ? parseInt(addon.options.indexerTimeout) + : Settings.DEFAULT_GDRIVE_TIMEOUT + ); + return await wrapper.getParsedStreams(streamRequest); + } + default: { + if (!addon.options.url) { + throw new Error( + `The addon URL was undefined for ${addon.options.name}` + ); + } + const wrapper = new BaseWrapper( + addon.options.name || 'Custom', + addon.options.url.trim(), + addonId, + this.config, + addon.options.indexerTimeout + ? parseInt(addon.options.indexerTimeout) + : undefined + ); + return wrapper.getParsedStreams(streamRequest); + } + } + } + private async processGroupedStreams( + groupedStreams: Record + ) { + const uniqueStreams: ParsedStream[] = []; + Object.values(groupedStreams).forEach((groupedStreams) => { + if (groupedStreams.length === 1) { + uniqueStreams.push(groupedStreams[0]); + return; + } + + /*logger.info( + `==================\nDetermining unique streams for ${groupedStreams[0].filename} from ${groupedStreams.length} total duplicates` + ); + logger.info( + groupedStreams.map( + (stream) => + `Addon ID: ${stream.addon.id}, Provider ID: ${stream.provider?.id}, Provider Cached: ${stream.provider?.cached}, type: ${stream.torrent ? 'torrent' : 'usenet'}` + ) + ); + logger.info('==================');*/ + // Separate streams into categories + const cachedStreams = groupedStreams.filter( + (stream) => stream.provider?.cached || (!stream.provider && stream.url) + ); + const uncachedStreams = groupedStreams.filter( + (stream) => stream.provider && !stream.provider.cached + ); + const noProviderStreams = groupedStreams.filter( + (stream) => !stream.provider && stream.torrent?.infoHash + ); + + // Select uncached streams by addon priority (one per provider) + const selectedUncachedStreams = Object.values( + uncachedStreams.reduce( + (acc, stream) => { + acc[stream.provider!.id] = acc[stream.provider!.id] || []; + acc[stream.provider!.id].push(stream); + return acc; + }, + {} as Record + ) + ).map((providerGroup) => { + return providerGroup.sort((a, b) => { + const aIndex = this.config.addons.findIndex( + (addon) => + `${addon.id}-${JSON.stringify(addon.options)}` === a.addon.id + ); + const bIndex = this.config.addons.findIndex( + (addon) => + `${addon.id}-${JSON.stringify(addon.options)}` === b.addon.id + ); + return aIndex - bIndex; + })[0]; + }); + //selectedUncachedStreams.forEach(stream => logger.info(`Selected uncached stream for provider ${stream.provider!.id}: Addon ID: ${stream.addon.id}`)); + + // Select cached streams by provider and addon priority + const selectedCachedStream = cachedStreams.sort((a, b) => { + const aProviderIndex = this.config.services.findIndex( + (service) => service.id === a.provider?.id + ); + const bProviderIndex = this.config.services.findIndex( + (service) => service.id === b.provider?.id + ); + + if (aProviderIndex !== bProviderIndex) { + return aProviderIndex - bProviderIndex; + } + + const aAddonIndex = this.config.addons.findIndex( + (addon) => + `${addon.id}-${JSON.stringify(addon.options)}` === a.addon.id + ); + const bAddonIndex = this.config.addons.findIndex( + (addon) => + `${addon.id}-${JSON.stringify(addon.options)}` === b.addon.id + ); + + if (aAddonIndex !== bAddonIndex) { + return aAddonIndex - bAddonIndex; + } + + // now look at the type of stream. prefer usenet over torrents + if (a.torrent?.seeders && !b.torrent?.seeders) return 1; + if (!a.torrent?.seeders && b.torrent?.seeders) return -1; + return 0; + })[0]; + // Select one non-provider stream (highest addon priority) + const selectedNoProviderStream = noProviderStreams.sort((a, b) => { + const aIndex = this.config.addons.findIndex( + (addon) => + `${addon.id}-${JSON.stringify(addon.options)}` === a.addon.id + ); + const bIndex = this.config.addons.findIndex( + (addon) => + `${addon.id}-${JSON.stringify(addon.options)}` === b.addon.id + ); + + if (aIndex !== bIndex) { + return aIndex - bIndex; + } + + // now look at the type of stream. prefer usenet over torrents + if (a.torrent?.seeders && !b.torrent?.seeders) return 1; + if (!a.torrent?.seeders && b.torrent?.seeders) return -1; + return 0; + })[0]; + + // Combine selected streams for this group + if (selectedNoProviderStream) { + //logger.info(`Selected no provider stream: Addon ID: ${selectedNoProviderStream.addon.id}`); + uniqueStreams.push(selectedNoProviderStream); + } + if (selectedCachedStream) { + //logger.info(`Selected cached stream for provider ${selectedCachedStream.provider!.id} from Addon ID: ${selectedCachedStream.addon.id}`); + uniqueStreams.push(selectedCachedStream); + } + uniqueStreams.push(...selectedUncachedStreams); + }); + + return uniqueStreams; + } +} diff --git a/packages/addon/src/config.ts b/packages/addon/src/config.ts new file mode 100644 index 0000000000000000000000000000000000000000..619a1f21f3665bd76a4c7725bc7eefe7b93c6266 --- /dev/null +++ b/packages/addon/src/config.ts @@ -0,0 +1,537 @@ +import { AddonDetail, Config } from '@aiostreams/types'; +import { + addonDetails, + isValueEncrypted, + parseAndDecryptString, + serviceDetails, + Settings, + unminifyConfig, +} from '@aiostreams/utils'; + +export const allowedFormatters = [ + 'gdrive', + 'minimalistic-gdrive', + 'torrentio', + 'torbox', + 'imposter', + 'custom', +]; + +export const allowedLanguages = [ + 'Multi', + 'English', + 'Japanese', + 'Chinese', + 'Russian', + 'Arabic', + 'Portuguese', + 'Spanish', + 'French', + 'German', + 'Italian', + 'Korean', + 'Hindi', + 'Bengali', + 'Punjabi', + 'Marathi', + 'Gujarati', + 'Tamil', + 'Telugu', + 'Kannada', + 'Malayalam', + 'Thai', + 'Vietnamese', + 'Indonesian', + 'Turkish', + 'Hebrew', + 'Persian', + 'Ukrainian', + 'Greek', + 'Lithuanian', + 'Latvian', + 'Estonian', + 'Polish', + 'Czech', + 'Slovak', + 'Hungarian', + 'Romanian', + 'Bulgarian', + 'Serbian', + 'Croatian', + 'Slovenian', + 'Dutch', + 'Danish', + 'Finnish', + 'Swedish', + 'Norwegian', + 'Malay', + 'Latino', + 'Unknown', + 'Dual Audio', + 'Dubbed', +]; + +export function validateConfig( + config: Config, + environment: 'client' | 'server' = 'server' +): { + valid: boolean; + errorCode: string | null; + errorMessage: string | null; +} { + config = unminifyConfig(config); + const createResponse = ( + valid: boolean, + errorCode: string | null, + errorMessage: string | null + ) => { + return { valid, errorCode, errorMessage }; + }; + + if (config.addons.length < 1) { + return createResponse( + false, + 'noAddons', + 'At least one addon must be selected' + ); + } + + if (config.addons.length > Settings.MAX_ADDONS) { + return createResponse( + false, + 'tooManyAddons', + `You can only select a maximum of ${Settings.MAX_ADDONS} addons` + ); + } + // check for apiKey if Settings.API_KEY is set + if (environment === 'server' && Settings.API_KEY) { + const { apiKey } = config; + if (!apiKey) { + return createResponse( + false, + 'missingApiKey', + 'The AIOStreams API key is required' + ); + } + let decryptedApiKey = apiKey; + if (isValueEncrypted(apiKey)) { + const decryptionResult = parseAndDecryptString(apiKey); + if (decryptionResult === null) { + return createResponse( + false, + 'decryptionFailed', + 'Failed to decrypt the AIOStreams API key' + ); + } else if (decryptionResult === '') { + return createResponse( + false, + 'emptyDecryption', + 'Decrypted API key is empty' + ); + } + decryptedApiKey = decryptionResult; + } + if (decryptedApiKey !== Settings.API_KEY) { + return createResponse( + false, + 'invalidApiKey', + 'Invalid AIOStreams API key. Please use the one defined in your environment variables' + ); + } + } + const duplicateAddons = config.addons.filter( + (addon, index) => + config.addons.findIndex( + (a) => + a.id === addon.id && + JSON.stringify(a.options) === JSON.stringify(addon.options) + ) !== index + ); + + if (duplicateAddons.length > 0) { + return createResponse( + false, + 'duplicateAddons', + 'Duplicate addons found. Please remove any duplicates' + ); + } + + for (const addon of config.addons) { + if (Settings.DISABLE_TORRENTIO && addon.id === 'torrentio') { + return createResponse( + false, + 'torrentioDisabled', + Settings.DISABLE_TORRENTIO_MESSAGE + ); + } + + const details = addonDetails.find( + (detail: AddonDetail) => detail.id === addon.id + ); + if (!details) { + return createResponse( + false, + 'invalidAddon', + `Invalid addon: ${addon.id}` + ); + } + if (details.requiresService) { + const supportedServices = details.supportedServices; + const isAtLeastOneServiceEnabled = config.services.some( + (service) => supportedServices.includes(service.id) && service.enabled + ); + const isOverrideUrlSet = addon.options?.overrideUrl; + if (!isAtLeastOneServiceEnabled && !isOverrideUrlSet) { + return createResponse( + false, + 'missingService', + `${addon.options?.name || details.name} requires at least one of the following services to be enabled: ${supportedServices + .map( + (service) => + serviceDetails.find((detail) => detail.id === service)?.name || + service + ) + .join(', ')}` + ); + } + } + if (details.options) { + for (const option of details.options) { + if (option.required && !addon.options[option.id]) { + return createResponse( + false, + 'missingRequiredOption', + `Option ${option.label} is required for addon ${addon.id}` + ); + } + + if ( + option.id.toLowerCase().includes('url') && + addon.options[option.id] && + ((isValueEncrypted(addon.options[option.id]) && + environment === 'server') || + !isValueEncrypted(addon.options[option.id])) + ) { + const url = parseAndDecryptString(addon.options[option.id] ?? ''); + if (url === null) { + return createResponse( + false, + 'decryptionFailed', + `Failed to decrypt URL for ${option.label}` + ); + } else if (url === '') { + return createResponse( + false, + 'emptyDecryption', + `Decrypted URL for ${option.label} is empty` + ); + } + if ( + Settings.DISABLE_TORRENTIO && + url.match(/torrentio\.strem\.fun/) !== null + ) { + // if torrentio is disabled, don't allow the user to set URLs with torrentio.strem.fun + return createResponse( + false, + 'torrentioDisabled', + Settings.DISABLE_TORRENTIO_MESSAGE + ); + } else if ( + Settings.DISABLE_TORRENTIO && + url.match(/stremthru\.elfhosted\.com/) !== null + ) { + // if torrentio is disabled, we need to inspect the stremthru URL to see if it's using torrentio + try { + const parsedUrl = new URL(url); + // get the component before manifest.json + const pathComponents = parsedUrl.pathname.split('/'); + if (pathComponents.includes('manifest.json')) { + const index = pathComponents.indexOf('manifest.json'); + const componentBeforeManifest = pathComponents[index - 1]; + // base64 decode the component before manifest.json + const decodedComponent = atob(componentBeforeManifest); + const stremthruData = JSON.parse(decodedComponent); + if (stremthruData?.manifest_url?.match(/torrentio.strem.fun/)) { + return createResponse( + false, + 'torrentioDisabled', + Settings.DISABLE_TORRENTIO_MESSAGE + ); + } + } + } catch (_) { + // ignore + } + } else { + try { + new URL(url); + } catch (_) { + return createResponse( + false, + 'invalidUrl', + ` Invalid URL for ${option.label}` + ); + } + } + } + + if (option.type === 'number' && addon.options[option.id]) { + const input = addon.options[option.id]; + if (input !== undefined && !parseInt(input)) { + return createResponse( + false, + 'invalidNumber', + `${option.label} must be a number` + ); + } else if (input !== undefined) { + const value = parseInt(input); + const { min, max } = option.constraints || {}; + if ( + (min !== undefined && value < min) || + (max !== undefined && value > max) + ) { + return createResponse( + false, + 'invalidNumber', + `${option.label} must be between ${min} and ${max}` + ); + } + } + } + } + } + } + + if (!allowedFormatters.includes(config.formatter)) { + if (config.formatter.startsWith('custom') && config.formatter.length > 7) { + const jsonString = config.formatter.slice(7); + const data = JSON.parse(jsonString); + if (!data.name || !data.description) { + return createResponse( + false, + 'invalidCustomFormatter', + 'Invalid custom formatter: name and description are required' + ); + } + } else { + return createResponse( + false, + 'invalidFormatter', + `Invalid formatter: ${config.formatter}` + ); + } + } + for (const service of config.services) { + if (service.enabled) { + const serviceDetail = serviceDetails.find( + (detail) => detail.id === service.id + ); + if (!serviceDetail) { + return createResponse( + false, + 'invalidService', + `Invalid service: ${service.id}` + ); + } + for (const credential of serviceDetail.credentials) { + if (!service.credentials[credential.id]) { + return createResponse( + false, + 'missingCredential', + `${credential.label} is required for ${service.name}` + ); + } + } + } + } + + // need at least one visual tag, resolution, quality + + if ( + !config.visualTags.some((tag) => Object.values(tag)[0]) || + !config.resolutions.some((resolution) => Object.values(resolution)[0]) || + !config.qualities.some((quality) => Object.values(quality)[0]) + ) { + return createResponse( + false, + 'noFilters', + 'At least one visual tag, resolution, and quality must be selected' + ); + } + + for (const [min, max] of [ + [config.minMovieSize, config.maxMovieSize], + [config.minEpisodeSize, config.maxEpisodeSize], + [config.minSize, config.maxSize], + ]) { + if (min && max) { + if (min >= max) { + return createResponse( + false, + 'invalidSizeRange', + "Your minimum size limit can't be greater than or equal to your maximum size limit" + ); + } + } + } + + if (config.maxResultsPerResolution && config.maxResultsPerResolution < 1) { + return createResponse( + false, + 'invalidMaxResultsPerResolution', + 'Max results per resolution must be greater than 0' + ); + } + + if ( + config.mediaFlowConfig?.mediaFlowEnabled && + config.stremThruConfig?.stremThruEnabled + ) { + return createResponse( + false, + 'multipleProxyServices', + 'Multiple proxy services are not allowed' + ); + } + if (config.mediaFlowConfig?.mediaFlowEnabled) { + if (!config.mediaFlowConfig.proxyUrl) { + return createResponse( + false, + 'missingProxyUrl', + 'Proxy URL is required if MediaFlow is enabled' + ); + } + if (!config.mediaFlowConfig.apiPassword) { + return createResponse( + false, + 'missingApiPassword', + 'API Password is required if MediaFlow is enabled' + ); + } + } + + if (config.stremThruConfig?.stremThruEnabled) { + if (!config.stremThruConfig.url) { + return createResponse( + false, + 'missingUrl', + 'URL is required if Stremthru is enabled' + ); + } + if (!config.stremThruConfig.credential) { + return createResponse( + false, + 'missingCredential', + 'Credential is required if StremThru is enabled' + ); + } + } + + if ( + (config.excludeFilters?.length ?? 0) > Settings.MAX_KEYWORD_FILTERS || + (config.strictIncludeFilters?.length ?? 0) > Settings.MAX_KEYWORD_FILTERS + ) { + return createResponse( + false, + 'tooManyFilters', + `You can only have a maximum of ${Settings.MAX_KEYWORD_FILTERS} filters` + ); + } + + const filters = [ + ...(config.excludeFilters || []), + ...(config.strictIncludeFilters || []), + ]; + filters.forEach((filter) => { + if (filter.length > 20) { + return createResponse( + false, + 'invalidFilter', + 'One of your filters is too long' + ); + } + if (!filter) { + return createResponse( + false, + 'invalidFilter', + 'Filters must not be empty' + ); + } + }); + + if (config.regexFilters) { + if (!config.apiKey) { + return createResponse( + false, + 'missingApiKey', + 'Regex filtering requires an API key to be set' + ); + } + + if (config.regexFilters.excludePattern) { + try { + new RegExp(config.regexFilters.excludePattern); + } catch (e) { + return createResponse( + false, + 'invalidExcludeRegex', + 'Invalid exclude regex pattern' + ); + } + } + + if (config.regexFilters.includePattern) { + try { + new RegExp(config.regexFilters.includePattern); + } catch (e) { + return createResponse( + false, + 'invalidIncludeRegex', + 'Invalid include regex pattern' + ); + } + } + } + + if (config.regexSortPatterns) { + if (!config.apiKey) { + return createResponse( + false, + 'missingApiKey', + 'Regex sorting requires an API key to be set' + ); + } + + // Split the pattern by spaces and validate each one + const patterns = config.regexSortPatterns.split(/\s+/).filter(Boolean); + // Enforce an upper bound on the number of patterns + if (patterns.length > Settings.MAX_REGEX_SORT_PATTERNS) { + return createResponse( + false, + 'tooManyRegexSortPatterns', + `You can specify at most ${Settings.MAX_REGEX_SORT_PATTERNS} regex sort patterns` + ); + } + + for (const pattern of patterns) { + const delimiter = '<::>'; + const delimiterIndex = pattern.indexOf(delimiter); + let name: string = 'Unamed'; + let regexPattern = pattern; + if (delimiterIndex !== -1) { + name = pattern.slice(0, delimiterIndex).replace(/_/g, ' '); + regexPattern = pattern.slice(delimiterIndex + delimiter.length); + } + try { + new RegExp(regexPattern); + } catch (e) { + return createResponse( + false, + 'invalidRegexSortPattern', + `Invalid regex sort pattern: ${name ? `"${name}" ` : ''}${regexPattern}` + ); + } + } + } + return createResponse(true, null, null); +} diff --git a/packages/addon/src/index.ts b/packages/addon/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..21d564cba493322d5ed85f1f838020cf5e1edf8b --- /dev/null +++ b/packages/addon/src/index.ts @@ -0,0 +1,4 @@ +export * from './addon'; +export * from './config'; +export * from './manifest'; +export * from './responses'; diff --git a/packages/addon/src/manifest.ts b/packages/addon/src/manifest.ts new file mode 100644 index 0000000000000000000000000000000000000000..4c828b60d0d7098ed7adf206ed1dbef8cd680dab --- /dev/null +++ b/packages/addon/src/manifest.ts @@ -0,0 +1,37 @@ +import { Config } from '@aiostreams/types'; +import { version, description } from '../../../package.json'; +import { getTextHash, Settings } from '@aiostreams/utils'; + +const manifest = (config?: Config, configPresent?: boolean) => { + let addonId = Settings.ADDON_ID; + if (config && Settings.DETERMINISTIC_ADDON_ID) { + addonId = + addonId += `.${getTextHash(JSON.stringify(config)).substring(0, 12)}`; + } + return { + name: config?.overrideName || Settings.ADDON_NAME, + id: addonId, + version: version, + description: description, + catalogs: [], + resources: ['stream'], + background: + 'https://raw.githubusercontent.com/Viren070/AIOStreams/refs/heads/main/packages/frontend/public/assets/background.png', + logo: 'https://raw.githubusercontent.com/Viren070/AIOStreams/refs/heads/main/packages/frontend/public/assets/logo.png', + types: ['movie', 'series'], + behaviorHints: { + configurable: true, + configurationRequired: config || configPresent ? false : true, + }, + stremioAddonsConfig: + Settings.STREMIO_ADDONS_CONFIG_ISSUER && + Settings.STREMIO_ADDONS_CONFIG_SIGNATURE + ? { + issuer: Settings.STREMIO_ADDONS_CONFIG_ISSUER, + signature: Settings.STREMIO_ADDONS_CONFIG_SIGNATURE, + } + : undefined, + }; +}; + +export default manifest; diff --git a/packages/addon/src/responses.ts b/packages/addon/src/responses.ts new file mode 100644 index 0000000000000000000000000000000000000000..dc604ae0769f41206e7b6657e526c52c01e4b13a --- /dev/null +++ b/packages/addon/src/responses.ts @@ -0,0 +1,29 @@ +import { Settings } from '@aiostreams/utils'; + +export const errorResponse = ( + errorMessage: string, + origin?: string, + path?: string, + externalUrl?: string +) => { + return { + streams: [errorStream(errorMessage, 'Error', origin, path, externalUrl)], + }; +}; + +export const errorStream = ( + errorMessage: string, + errorTitle?: string, + origin?: string, + path?: string, + externalUrl?: string +) => { + return { + externalUrl: + (origin && path ? origin + path : undefined) || + externalUrl || + 'https://github.com/Viren070/AIOStreams', + name: `[❌] ${Settings.ADDON_NAME}\n${errorTitle || 'Error'}`, + description: errorMessage, + }; +}; diff --git a/packages/addon/src/server.ts b/packages/addon/src/server.ts new file mode 100644 index 0000000000000000000000000000000000000000..e67cfa85c80ab37d855883e489e0bc9a553eb6d3 --- /dev/null +++ b/packages/addon/src/server.ts @@ -0,0 +1,645 @@ +import express, { Request, Response } from 'express'; + +import path from 'path'; +import { AIOStreams } from './addon'; +import { Config, StreamRequest } from '@aiostreams/types'; +import { validateConfig } from './config'; +import manifest from './manifest'; +import { errorResponse } from './responses'; +import { + Settings, + addonDetails, + parseAndDecryptString, + Cache, + unminifyConfig, + minifyConfig, + crushJson, + compressData, + encryptData, + decompressData, + decryptData, + uncrushJson, + loadSecretKey, + createLogger, + getTimeTakenSincePoint, + isValueEncrypted, + maskSensitiveInfo, +} from '@aiostreams/utils'; + +const logger = createLogger('server'); + +const app = express(); +//logger.info(`Starting server and loading settings...`); +logger.info('Starting server and loading settings...', { func: 'init' }); +Object.entries(Settings).forEach(([key, value]) => { + switch (key) { + case 'SECRET_KEY': + if (value) { + logger.info(`${key} = ${value.replace(/./g, '*').slice(0, 64)}`); + } + break; + + case 'BRANDING': + case 'CUSTOM_CONFIGS': + // Skip CUSTOM_CONFIGS processing here, handled later + break; + + default: + logger.info(`${key} = ${value}`); + } +}); + +// attempt to load the secret key +try { + if (Settings.SECRET_KEY) loadSecretKey(true); +} catch (error: any) { + // determine command to run based on system OS + const command = + process.platform === 'win32' + ? '[System.Guid]::NewGuid().ToString("N").Substring(0, 32) + [System.Guid]::NewGuid().ToString("N").Substring(0, 32)' + : 'openssl rand -hex 32'; + logger.error( + `The secret key is invalid. You will not be able to generate configurations. You can generate a new secret key by running the following command\n${command}` + ); +} + +// Built-in middleware for parsing JSON +app.use(express.json()); +// Built-in middleware for parsing URL-encoded data +app.use(express.urlencoded({ extended: true })); + +// unhandled errors +app.use((err: any, req: Request, res: Response, next: any) => { + logger.error(`${err.message}`); + res.status(500).send('Internal server error'); +}); + +app.use((req, res, next) => { + res.append('Access-Control-Allow-Origin', '*'); + res.append('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE'); + const start = Date.now(); + res.on('finish', () => { + logger.info( + `${req.method} ${req.path + .replace(/\/ey[JI][\w\=]+/g, '/*******') + .replace( + /\/(E2?|B)?-[\w-\%]+/g, + '/*******' + )} - ${getIp(req) ? maskSensitiveInfo(getIp(req)!) : 'Unknown IP'} - ${res.statusCode} - ${getTimeTakenSincePoint(start)}` + ); + }); + next(); +}); + +app.get('/', (req, res) => { + res.redirect('/configure'); +}); + +app.get( + ['/_next/*', '/assets/*', '/icon.ico', '/configure.txt'], + (req, res) => { + res.sendFile(path.join(__dirname, '../../frontend/out', req.path)); + } +); + +if (!Settings.DISABLE_CUSTOM_CONFIG_GENERATOR_ROUTE) { + app.get('/custom-config-generator', (req, res) => { + res.sendFile( + path.join(__dirname, '../../frontend/out/custom-config-generator.html') + ); + }); +} + +app.get('/configure', (req, res) => { + res.sendFile(path.join(__dirname, '../../frontend/out/configure.html')); +}); + +app.get('/:config/configure', (req, res) => { + const config = req.params.config; + if (config.startsWith('eyJ') || config.startsWith('eyI')) { + return res.sendFile( + path.join(__dirname, '../../frontend/out/configure.html') + ); + } + try { + let configJson = extractJsonConfig(config); + let configString = config; + if (Settings.CUSTOM_CONFIGS) { + const customConfig = extractCustomConfig(config); + if (customConfig) { + configJson = customConfig; + configString = decodeURIComponent(Settings.CUSTOM_CONFIGS[config]); + } + } + if (isValueEncrypted(configString)) { + logger.info(`Encrypted config detected, encrypting credentials`); + configJson = encryptInfoInConfig(configJson); + } + const base64Config = Buffer.from(JSON.stringify(configJson)).toString( + 'base64' + ); + res.redirect(`/${encodeURIComponent(base64Config)}/configure`); + } catch (error: any) { + logger.error(`Failed to extract config: ${error.message}`); + res.status(400).send('Invalid config'); + } +}); + +app.get('/manifest.json', (req, res) => { + res.status(200).json(manifest()); +}); + +app.get('/:config/manifest.json', (req, res) => { + const config = decodeURIComponent(req.params.config); + let configJson: Config; + try { + configJson = extractJsonConfig(config); + logger.info(`Extracted config for manifest request`); + configJson = decryptEncryptedInfoFromConfig(configJson); + if (Settings.LOG_SENSITIVE_INFO) { + logger.info(`Final config: ${JSON.stringify(configJson)}`); + } + logger.info(`Successfully removed or decrypted sensitive info`); + const { valid, errorMessage } = validateConfig(configJson); + if (!valid) { + logger.error( + `Received invalid config for manifest request: ${errorMessage}` + ); + res.status(400).json({ error: 'Invalid config', message: errorMessage }); + return; + } + } catch (error: any) { + logger.error(`Failed to extract config: ${error.message}`); + res.status(400).json({ error: 'Invalid config' }); + return; + } + res.status(200).json(manifest(configJson)); +}); + +// Route for /stream +app.get('/stream/:type/:id', (req: Request, res: Response) => { + res + .status(200) + .json( + errorResponse( + 'You must configure this addon to use it', + rootUrl(req), + '/configure' + ) + ); +}); + +app.get('/:config/stream/:type/:id.json', (req, res: Response): void => { + const { config, type, id } = req.params; + let configJson: Config; + try { + configJson = extractJsonConfig(config); + logger.info(`Extracted config for stream request`); + configJson = decryptEncryptedInfoFromConfig(configJson); + if (Settings.LOG_SENSITIVE_INFO) { + logger.info(`Final config: ${JSON.stringify(configJson)}`); + } + logger.info(`Successfully removed or decrypted sensitive info`); + } catch (error: any) { + logger.error(`Failed to extract config: ${error.message}`); + res.json( + errorResponse( + `${error.message}, please check the logs or click this stream to create an issue on GitHub`, + rootUrl(req), + undefined, + 'https://github.com/Viren070/AIOStreams/issues/new?template=bug_report.yml' + ) + ); + return; + } + + logger.info(`Requesting streams for ${type} ${id}`); + + if (type !== 'movie' && type !== 'series') { + logger.error(`Invalid type for stream request`); + res.json( + errorResponse( + 'Invalid type for stream request, must be movie or series', + rootUrl(req), + '/' + ) + ); + return; + } + let streamRequest: StreamRequest = { id, type }; + + try { + const { valid, errorCode, errorMessage } = validateConfig(configJson); + if (!valid) { + logger.error(`Received invalid config: ${errorCode} - ${errorMessage}`); + res.json( + errorResponse(errorMessage ?? 'Unknown', rootUrl(req), '/configure') + ); + return; + } + configJson.requestingIp = getIp(req); + const aioStreams = new AIOStreams(configJson); + aioStreams + .getStreams(streamRequest) + .then((streams) => { + res.json({ streams: streams }); + }) + .catch((error: any) => { + logger.error(`Internal addon error: ${error.message}`); + res.json( + errorResponse( + 'An unexpected error occurred, please check the logs or create an issue on GitHub', + rootUrl(req), + undefined, + 'https://github.com/Viren070/AIOStreams/issues/new?template=bug_report.yml' + ) + ); + }); + } catch (error: any) { + logger.error(`Internal addon error: ${error.message}`); + res.json( + errorResponse( + 'An unexpected error occurred, please check the logs or create an issue on GitHub', + rootUrl(req), + undefined, + 'https://github.com/Viren070/AIOStreams/issues/new?template=bug_report.yml' + ) + ); + } +}); + +app.post('/encrypt-user-data', (req, res) => { + const { data } = req.body; + let finalString: string = ''; + if (!data) { + logger.error('/encrypt-user-data: No data provided'); + res.json({ success: false, message: 'No data provided' }); + return; + } + // First, validate the config + try { + const config = JSON.parse(data); + const { valid, errorCode, errorMessage } = validateConfig(config); + if (!valid) { + logger.error( + `generateConfig: Invalid config: ${errorCode} - ${errorMessage}` + ); + res.json({ success: false, message: errorMessage, error: errorMessage }); + return; + } + } catch (error: any) { + logger.error(`/encrypt-user-data: Invalid JSON: ${error.message}`); + res.json({ success: false, message: 'Malformed configuration' }); + return; + } + + try { + const minified = minifyConfig(JSON.parse(data)); + const crushed = crushJson(JSON.stringify(minified)); + const compressed = compressData(crushed); + if (!Settings.SECRET_KEY) { + // use base64 encoding if no secret key is set + finalString = `B-${encodeURIComponent(compressed.toString('base64'))}`; + } else { + const { iv, data } = encryptData(compressed); + finalString = `E2-${encodeURIComponent(iv)}-${encodeURIComponent(data)}`; + } + + logger.info( + `|INF| server > /encrypt-user-data: Encrypted user data, compression report:` + ); + logger.info(`+--------------------------------------------+`); + logger.info(`| Original: ${data.length} bytes`); + logger.info(`| URL Encoded: ${encodeURIComponent(data).length} bytes`); + logger.info(`| Minified: ${JSON.stringify(minified).length} bytes`); + logger.info(`| Crushed: ${crushed.length} bytes`); + logger.info(`| Compressed: ${compressed.length} bytes`); + logger.info(`| Final String: ${finalString.length} bytes`); + logger.info( + `| Ratio: ${((finalString.length / data.length) * 100).toFixed(2)}%` + ); + logger.info( + `| Reduction: ${data.length - finalString.length} bytes (${(((data.length - finalString.length) / data.length) * 100).toFixed(2)}%)` + ); + logger.info(`+--------------------------------------------+`); + + res.json({ success: true, data: finalString }); + } catch (error: any) { + logger.error(`/encrypt-user-data: ${error.message}`); + logger.error(error); + res.json({ success: false, message: error.message }); + } +}); + +app.get('/get-addon-config', (req, res) => { + res.status(200).json({ + success: true, + maxMovieSize: Settings.MAX_MOVIE_SIZE, + maxEpisodeSize: Settings.MAX_EPISODE_SIZE, + torrentioDisabled: Settings.DISABLE_TORRENTIO, + apiKeyRequired: !!Settings.API_KEY, + }); +}); + +app.get('/health', (req, res) => { + res.status(200).json({ status: 'ok' }); +}); + +// define 404 +app.use((req, res) => { + res.status(404).sendFile(path.join(__dirname, '../../frontend/out/404.html')); +}); + +app.listen(Settings.PORT, () => { + logger.info(`Listening on port ${Settings.PORT}`); +}); + +function getIp(req: Request): string | undefined { + return ( + req.get('X-Client-IP') || + req.get('X-Forwarded-For')?.split(',')[0].trim() || + req.get('X-Real-IP') || + req.get('CF-Connecting-IP') || + req.get('True-Client-IP') || + req.get('X-Forwarded')?.split(',')[0].trim() || + req.get('Forwarded-For')?.split(',')[0].trim() || + req.ip + ); +} +function extractJsonConfig(config: string): Config { + if ( + config.startsWith('eyJ') || + config.startsWith('eyI') || + config.startsWith('B-') || + isValueEncrypted(config) + ) { + return extractEncryptedOrEncodedConfig(config, 'Config'); + } + if (Settings.CUSTOM_CONFIGS) { + const customConfig = extractCustomConfig(config); + if (customConfig) return customConfig; + } + throw new Error('Config was in an unexpected format'); +} + +function extractCustomConfig(config: string): Config | undefined { + const customConfig = Settings.CUSTOM_CONFIGS[config]; + if (!customConfig) return undefined; + logger.info( + `Found custom config for alias ${config}, attempting to extract config` + ); + return extractEncryptedOrEncodedConfig( + decodeURIComponent(customConfig), + `CustomConfig ${config}` + ); +} + +function extractEncryptedOrEncodedConfig( + config: string, + label: string +): Config { + let decodedConfig: Config; + try { + if (config.startsWith('E-')) { + // compressed and encrypted (hex) + logger.info(`Extracting encrypted (v1) config`); + const parts = config.split('-'); + if (parts.length !== 3) { + throw new Error('Invalid encrypted config format'); + } + const iv = Buffer.from(decodeURIComponent(parts[1]), 'hex'); + const data = Buffer.from(decodeURIComponent(parts[2]), 'hex'); + decodedConfig = JSON.parse(decompressData(decryptData(data, iv))); + } else if (config.startsWith('E2-')) { + // minified, crushed, compressed and encrypted (base64) + logger.info(`Extracting encrypted (v2) config`); + const parts = config.split('-'); + if (parts.length !== 3) { + throw new Error('Invalid encrypted config format'); + } + const iv = Buffer.from(decodeURIComponent(parts[1]), 'base64'); + const data = Buffer.from(decodeURIComponent(parts[2]), 'base64'); + const compressedCrushedJson = decryptData(data, iv); + const crushedJson = decompressData(compressedCrushedJson); + const minifiedConfig = uncrushJson(crushedJson); + decodedConfig = unminifyConfig(JSON.parse(minifiedConfig)); + } else if (config.startsWith('B-')) { + // minifed, crushed, compressed, base64 encoded + logger.info(`Extracting base64 encoded and compressed config`); + decodedConfig = unminifyConfig( + JSON.parse( + uncrushJson(decompressData(Buffer.from(config.slice(2), 'base64'))) + ) + ); + } else { + // plain base64 encoded + logger.info(`Extracting plain base64 encoded config`); + decodedConfig = JSON.parse( + Buffer.from(config, 'base64').toString('utf-8') + ); + } + return decodedConfig; + } catch (error: any) { + logger.error(`Failed to parse ${label}: ${error.message}`, { + func: 'extractJsonConfig', + }); + logger.error(error, { func: 'extractJsonConfig' }); + throw new Error(`Failed to parse ${label}`); + } +} + +function decryptEncryptedInfoFromConfig(config: Config): Config { + if (config.services) { + config.services.forEach( + (service) => + service.credentials && + processObjectValues( + service.credentials, + `service ${service.id}`, + true, + (key, value) => isValueEncrypted(value) + ) + ); + } + + if (config.mediaFlowConfig) { + decryptMediaFlowConfig(config.mediaFlowConfig); + } + if (config.stremThruConfig) { + decryptStremThruConfig(config.stremThruConfig); + } + + if (config.apiKey) { + config.apiKey = decryptValue(config.apiKey, 'aioStreams apiKey'); + } + + if (config.addons) { + config.addons.forEach((addon) => { + if (addon.options) { + processObjectValues( + addon.options, + `addon ${addon.id}`, + true, + (key, value) => + isValueEncrypted(value) && + // Decrypt only if the option is secret + ( + addonDetails.find((addonDetail) => addonDetail.id === addon.id) + ?.options ?? [] + ).some((option) => option.id === key && option.secret) + ); + } + }); + } + return config; +} + +function decryptMediaFlowConfig(mediaFlowConfig: { + apiPassword: string; + proxyUrl: string; + publicIp: string; +}): void { + const { apiPassword, proxyUrl, publicIp } = mediaFlowConfig; + mediaFlowConfig.apiPassword = decryptValue( + apiPassword, + 'MediaFlow apiPassword' + ); + mediaFlowConfig.proxyUrl = decryptValue(proxyUrl, 'MediaFlow proxyUrl'); + mediaFlowConfig.publicIp = decryptValue(publicIp, 'MediaFlow publicIp'); +} + +function decryptStremThruConfig( + stremThruConfig: Config['stremThruConfig'] +): void { + if (!stremThruConfig) return; + const { url, credential, publicIp } = stremThruConfig; + stremThruConfig.url = decryptValue(url, 'StremThru url'); + stremThruConfig.credential = decryptValue(credential, 'StremThru credential'); + stremThruConfig.publicIp = decryptValue(publicIp, 'StremThru publicIp'); +} + +function encryptInfoInConfig(config: Config): Config { + if (config.services) { + config.services.forEach( + (service) => + service.credentials && + processObjectValues( + service.credentials, + `service ${service.id}`, + false, + () => true + ) + ); + } + + if (config.mediaFlowConfig) { + encryptMediaFlowConfig(config.mediaFlowConfig); + } + + if (config.stremThruConfig) { + encryptStremThruConfig(config.stremThruConfig); + } + + if (config.apiKey) { + // we can either remove the api key for better security or encrypt it for usability + // removing it means the user has to enter it every time upon reconfiguration. + config.apiKey = encryptValue(config.apiKey, 'aioStreams apiKey'); + } + + if (config.addons) { + config.addons.forEach((addon) => { + if (addon.options) { + processObjectValues( + addon.options, + `addon ${addon.id}`, + false, + (key) => { + const addonDetail = addonDetails.find( + (addonDetail) => addonDetail.id === addon.id + ); + if (!addonDetail) return false; + const optionDetail = addonDetail.options?.find( + (option) => option.id === key + ); + // Encrypt only if the option is secret + return optionDetail?.secret ?? false; + } + ); + } + }); + } + return config; +} + +function encryptMediaFlowConfig(mediaFlowConfig: { + apiPassword: string; + proxyUrl: string; + publicIp: string; +}): void { + const { apiPassword, proxyUrl, publicIp } = mediaFlowConfig; + mediaFlowConfig.apiPassword = encryptValue( + apiPassword, + 'MediaFlow apiPassword' + ); + mediaFlowConfig.proxyUrl = encryptValue(proxyUrl, 'MediaFlow proxyUrl'); + mediaFlowConfig.publicIp = encryptValue(publicIp, 'MediaFlow publicIp'); +} + +function encryptStremThruConfig( + stremThruConfig: Config['stremThruConfig'] +): void { + if (!stremThruConfig) return; + const { url, credential, publicIp } = stremThruConfig; + stremThruConfig.url = encryptValue(url, 'StremThru url'); + stremThruConfig.credential = encryptValue(credential, 'StremThru credential'); + stremThruConfig.publicIp = encryptValue(publicIp, 'StremThru publicIp'); +} + +function processObjectValues( + obj: Record, + labelPrefix: string, + decrypt: boolean, + condition: (key: string, value: any) => boolean +): void { + Object.keys(obj).forEach((key) => { + const value = obj[key]; + if (condition(key, value)) { + logger.debug(`Processing ${labelPrefix} ${key}`); + obj[key] = decrypt + ? decryptValue(value, `${labelPrefix} ${key}`) + : encryptValue(value, `${labelPrefix} ${key}`); + } + }); +} + +function encryptValue(value: any, label: string): any { + if (value && !isValueEncrypted(value)) { + try { + const { iv, data } = encryptData(compressData(value)); + return `E2-${iv}-${data}`; + } catch (error: any) { + logger.error(`Failed to encrypt ${label}`, { func: 'encryptValue' }); + logger.error(error, { func: 'encryptValue' }); + return ''; + } + } + return value; +} + +function decryptValue(value: any, label: string): any { + try { + if (!isValueEncrypted(value)) return value; + const decrypted = parseAndDecryptString(value); + if (decrypted === null) throw new Error('Decryption failed'); + return decrypted; + } catch (error: any) { + logger.error(`Failed to decrypt ${label}: ${error.message}`, { + func: 'decryptValue', + }); + logger.error(error, { func: 'decryptValue' }); + throw new Error('Failed to decrypt config'); + } +} + +const rootUrl = (req: Request) => + `${req.protocol}://${req.hostname}${req.hostname === 'localhost' ? `:${Settings.PORT}` : ''}`; diff --git a/packages/addon/tsconfig.json b/packages/addon/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..ea109a6bbd6841f3e10c3e687e9f25c14aa2f5d9 --- /dev/null +++ b/packages/addon/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "resolveJsonModule": true + }, + "references": [ + { + "path": "../wrappers" + }, + { + "path": "../formatters" + }, + { + "path": "../types" + }, + { + "path": "../utils" + } + ] +} diff --git a/packages/cloudflare-loadbalancer/COMPARISON.md b/packages/cloudflare-loadbalancer/COMPARISON.md new file mode 100644 index 0000000000000000000000000000000000000000..5069e6abf6cd663510fd98e9a91eb2680dd89367 --- /dev/null +++ b/packages/cloudflare-loadbalancer/COMPARISON.md @@ -0,0 +1,67 @@ +# AIOStreams Load Balancing: Cloudflare Worker vs. NGINX + +This document compares the two load balancing approaches used for AIOStreams: + +1. **NGINX Load Balancer** (configured in `nginx.conf`) +2. **Cloudflare Worker Load Balancer** (in `packages/cloudflare-loadbalancer`) + +## Deployment Models + +### NGINX Approach + +- **Self-hosted**: Requires a dedicated server running NGINX. +- **Single Point of Failure**: The NGINX server itself becomes a potential point of failure. +- **Traditional HTTP Proxy**: Uses L7 (HTTP) load balancing. +- **SSL Termination**: Handles HTTPS connections directly with certificates stored on the server. + +### Cloudflare Worker Approach + +- **Serverless**: No dedicated infrastructure required. +- **Globally Distributed**: Runs on Cloudflare's edge network in 300+ locations worldwide. +- **High Availability**: No single point of failure in the load balancer itself. +- **Zero Maintenance**: No server patching, scaling, or management required. + +## Feature Comparison + +| Feature | NGINX | Cloudflare Worker | +| --------------------- | ------------------------ | -------------------------- | +| Load Balancing | ✅ (ip_hash) | ✅ (client IP hash) | +| Health Checks | ✅ (passive only) | ✅ (passive only) | +| Failover | ✅ | ✅ | +| WebSocket Support | ✅ | ✅ | +| Session Affinity | ✅ (ip_hash) | ✅ (cookies) | +| HTTP→HTTPS Redirect | ✅ | ✅ | +| Global Distribution | ❌ | ✅ | +| SSL Management | Manual | Automatic (via Cloudflare) | +| DDoS Protection | Limited | ✅ (via Cloudflare) | +| Deployment Complexity | Higher | Lower | +| Operational Costs | Server + bandwidth costs | Cloudflare Workers pricing | + +## When to Use Each Approach + +### Use the NGINX Approach When: + +- You need complete control over the load balancing infrastructure. +- You want to avoid any third-party dependencies. +- You already have servers running in a datacenter with NGINX expertise. +- You need advanced customization of HTTP headers, rewriting rules, etc. + +### Use the Cloudflare Worker Approach When: + +- You want global low-latency access without managing infrastructure. +- You prefer a serverless, maintenance-free deployment. +- You need built-in DDoS protection and security features. +- You want to minimize operational complexity and management. + +## Hybrid Approach + +You can also use both approaches together: + +1. **Primary Traffic**: Route through the Cloudflare Worker for global distribution and DDoS protection. +2. **Fallback**: If Cloudflare has issues, DNS can be updated to point directly to your NGINX load balancer. + +This gives you the benefits of Cloudflare's global network while maintaining the ability to operate independently if needed. + +## Conclusion + +Both approaches effectively solve the load balancing needs of AIOStreams, but with different operational models. The Cloudflare Worker provides a modern, serverless approach with global distribution, while the NGINX configuration offers traditional self-hosted load balancing with maximum control. \ No newline at end of file diff --git a/packages/cloudflare-loadbalancer/README.md b/packages/cloudflare-loadbalancer/README.md new file mode 100644 index 0000000000000000000000000000000000000000..8a6099f0dcfa68770f691b1928783f9e45106cf3 --- /dev/null +++ b/packages/cloudflare-loadbalancer/README.md @@ -0,0 +1,70 @@ +# AIOStreams Cloudflare Load Balancer + +A Cloudflare Worker that load balances traffic across multiple AIOStreams backends, providing high availability and redundancy. + +## Features + +- **Load Balancing**: Routes traffic among three backend services: + - `aiostreams-cf.example.com` + - `aiostreams-koyeb.example.com` + - `aiostreams.example.duckdns.org` + +- **Sticky Sessions**: Maintains session affinity using cookies, ensuring users stay on the same backend throughout their session. + +- **Health Checking**: Automatically detects backend failures and routes traffic away from unhealthy instances. + +- **Automatic Failover**: If a backend is unresponsive or returns 5xx errors, requests are retried with another backend. + +- **Consistent Hashing**: Uses client IP for consistent backend selection when no sticky session exists. + +- **WebSocket Support**: Properly handles WebSocket connections, maintaining the upgrade flow. + +- **HTTP-to-HTTPS Redirection**: Automatically redirects HTTP requests to HTTPS. + +- **Header Preservation**: Maintains all request headers and adds proper proxy headers for backends. + +## Configuration + +The worker is configured via environment variables in `wrangler.toml`: + +| Variable | Description | +| --------------------- | ------------------------------------------------------------------ | +| `PRIMARY_DOMAIN` | Domain name this worker is handling (e.g., aiostreams.example.com) | +| `BACKEND_CF` | Hostname for Cloudflare backend | +| `BACKEND_KOYEB` | Hostname for Koyeb backend | +| `BACKEND_DUCK` | Hostname for DuckDNS backend | +| `STICKY_SESSIONS` | Enable/disable sticky sessions (true/false) | +| `SESSION_COOKIE_NAME` | Cookie name for session stickiness | +| `SESSION_COOKIE_TTL` | Session cookie time-to-live in seconds (default: 86400) | +| `BACKEND_DOWN_TIME` | How long to mark a backend as down after a failure (ms) | +| `MAX_RETRIES` | Maximum number of retry attempts | + +## Deployment + +Deploy the worker to Cloudflare: + +```bash +cd packages/cloudflare-loadbalancer +npm install +npm run deploy +``` + +## How It Works + +1. When a request arrives at the worker (configured for the domain in `PRIMARY_DOMAIN`), the worker chooses a backend based on: + - Existing session cookie (if sticky sessions enabled) + - Client IP hash (for consistent backend selection) + - Backend health status + +2. The worker forwards the request to the selected backend, preserving all headers, query parameters, and request body. + +3. If the backend fails or returns a 5xx error, the worker retries with another backend. + +4. For sticky sessions, the worker sets a cookie to ensure subsequent requests from the same client go to the same backend. + +## Fault Tolerance + +- The worker maintains a temporary in-memory health status for each backend. +- Failed backends are marked as "down" for a configurable period. +- If all backends are down, the worker will reset all health statuses and try again. +- A maximum retry count prevents excessive attempts when all backends are failing. diff --git a/packages/cloudflare-loadbalancer/deploy.sh b/packages/cloudflare-loadbalancer/deploy.sh new file mode 100644 index 0000000000000000000000000000000000000000..57444dfa0f476c95648a2f43a096006e0fa1aa5e --- /dev/null +++ b/packages/cloudflare-loadbalancer/deploy.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +# AIOStreams Cloudflare Load Balancer Deployment Script + +echo "=== AIOStreams Cloudflare Load Balancer Deployment ===" +echo "Installing dependencies..." +npm install + +echo "Building and deploying worker..." +npx wrangler deploy + +echo "=== Deployment Complete ===" +echo "Your load balancer is now deployed to Cloudflare." +echo "Make sure to set up appropriate DNS records pointing aiostreams.example.com to your worker." +echo "" +echo "Test your deployment with:" +echo "curl -I https://aiostreams.example.com" \ No newline at end of file diff --git a/packages/cloudflare-loadbalancer/package.json b/packages/cloudflare-loadbalancer/package.json new file mode 100644 index 0000000000000000000000000000000000000000..8d7f42a1cd63816a77901f9c6fbe040452050787 --- /dev/null +++ b/packages/cloudflare-loadbalancer/package.json @@ -0,0 +1,19 @@ +{ + "name": "@aiostreams/cloudflare-loadbalancer", + "version": "1.0.0", + "description": "Cloudflare Worker that routes traffic between multiple AIOStreams backends", + "scripts": { + "deploy": "wrangler deploy", + "dev": "wrangler dev", + "start": "wrangler dev", + "test": "vitest", + "cf-typegen": "wrangler types" + }, + "dependencies": {}, + "devDependencies": { + "@cloudflare/workers-types": "^4.20241224.0", + "esbuild": "^0.25.5", + "typescript": "^5.5.2", + "wrangler": "^4.18.0" + } +} \ No newline at end of file diff --git a/packages/cloudflare-loadbalancer/src/index.ts b/packages/cloudflare-loadbalancer/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..8e1daed45fb8b3b896ee5c4638dbb58712269d51 --- /dev/null +++ b/packages/cloudflare-loadbalancer/src/index.ts @@ -0,0 +1,260 @@ +export interface Env { + // Environment variables + BACKEND_CF: string; + BACKEND_KOYEB: string; + BACKEND_DUCK: string; + STICKY_SESSIONS: boolean; + SESSION_COOKIE_NAME: string; + BACKEND_DOWN_TIME: string; + MAX_RETRIES: string; + // Optional duration for sticky session cookie in seconds (default: 1 day) + SESSION_COOKIE_TTL?: string; + // Primary domain that the worker is handling + PRIMARY_DOMAIN: string; +} + +// Store for tracking backend health status +interface HealthStatus { + isDown: boolean; + lastFailure: number; +} + +// Health status for each backend +const backendHealth = new Map(); + +// Helper function to choose a backend +function chooseBackend(request: Request, env: Env): string { + const backendOptions = [ + env.BACKEND_CF, + env.BACKEND_KOYEB, + env.BACKEND_DUCK + ]; + + // Filter out any backends that are marked as down + const availableBackends = backendOptions.filter(backend => { + const health = backendHealth.get(backend); + if (!health) return true; + + if (health.isDown) { + // Check if the backend has been down long enough to retry + const downTime = parseInt(env.BACKEND_DOWN_TIME) || 30000; + if (Date.now() - health.lastFailure > downTime) { + // Reset the backend status + backendHealth.set(backend, { isDown: false, lastFailure: 0 }); + return true; + } + return false; + } + + return true; + }); + + // If no backends are available, reset all backends and try again + if (availableBackends.length === 0) { + backendOptions.forEach(backend => { + backendHealth.set(backend, { isDown: false, lastFailure: 0 }); + }); + return backendOptions[0]; + } + + // Check for sticky session cookie if enabled + if (env.STICKY_SESSIONS) { + const cookies = request.headers.get('Cookie') || ''; + const cookieRegex = new RegExp(`${env.SESSION_COOKIE_NAME}=([^;]+)`); + const match = cookies.match(cookieRegex); + + if (match && match[1]) { + const preferredBackend = match[1]; + // Check if the preferred backend is available + if (availableBackends.includes(preferredBackend)) { + return preferredBackend; + } + } + } + + // Use client IP for consistent hashing if no cookie or preferred backend is down + const clientIP = request.headers.get('CF-Connecting-IP') || + request.headers.get('X-Real-IP') || + request.headers.get('X-Forwarded-For')?.split(',')[0].trim() || + 'unknown'; + + // Simple hash function for the client IP + const hashCode = (str: string) => { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32bit integer + } + return Math.abs(hash); + }; + + const index = hashCode(clientIP) % availableBackends.length; + return availableBackends[index]; +} + +// Mark a backend as down +function markBackendDown(backend: string, env: Env): void { + backendHealth.set(backend, { + isDown: true, + lastFailure: Date.now() + }); + + console.error(`Backend ${backend} marked as down at ${new Date().toISOString()}`); +} + +// Clone request with new URL +function createBackendRequest(request: Request, backend: string): Request { + const url = new URL(request.url); + const backendUrl = new URL(`https://${backend}`); + + // Preserve path and query parameters + backendUrl.pathname = url.pathname; + backendUrl.search = url.search; + + // Get original headers and create a new headers object + const headers = new Headers(request.headers); + + // Set the host header to the backend hostname + headers.set('Host', backend); + + // Add proxy headers + headers.set('X-Forwarded-Host', url.hostname); + headers.set('X-Forwarded-Proto', url.protocol.replace(':', '')); + + // Check if we need to handle WebSockets + const upgradeHeader = request.headers.get('Upgrade'); + const isWebSocket = upgradeHeader !== null && upgradeHeader.toLowerCase() === 'websocket'; + + // Clone the request with the new URL + const newRequest = new Request(backendUrl.toString(), { + method: request.method, + headers: headers, + body: request.body, + redirect: 'manual', // Don't follow redirects automatically + // If this is a WebSocket request, we need to preserve the upgrade header + duplex: isWebSocket ? 'half' : undefined + }); + + return newRequest; +} + +// Determine if we're in a development environment +function isDevelopment(): boolean { + try { + // Check if we're in a browser-like environment with location object + // @ts-ignore - Cloudflare Workers have location in dev/preview but not in TypeScript defs + return typeof globalThis.location === 'object' && + // @ts-ignore + (globalThis.location.hostname === 'localhost' || + // @ts-ignore + globalThis.location.hostname.includes('workers.dev') || + // @ts-ignore + globalThis.location.hostname.includes('preview')); + } catch (e) { + return false; + } +} + +export default { + async fetch(request: Request, env: Env, ctx: any): Promise { + const url = new URL(request.url); + const isDevEnvironment = isDevelopment(); + + // In production, only handle requests for the PRIMARY_DOMAIN + // In development, handle all requests (to make testing easier) + if (!isDevEnvironment && url.hostname !== env.PRIMARY_DOMAIN) { + console.log(`Request for ${url.hostname} rejected (expected ${env.PRIMARY_DOMAIN})`); + return new Response(`This worker is configured to handle requests for ${env.PRIMARY_DOMAIN} only`, { + status: 404, + headers: { 'Content-Type': 'text/plain' } + }); + } + + // Redirect HTTP to HTTPS in production + if (!isDevEnvironment && url.protocol === 'http:') { + url.protocol = 'https:'; + return Response.redirect(url.toString(), 301); + } + + // Check for WebSocket upgrade + const upgradeHeader = request.headers.get('Upgrade'); + const isWebSocket = upgradeHeader !== null && upgradeHeader.toLowerCase() === 'websocket'; + + // Try each backend until success or we run out of retries + let backend = chooseBackend(request, env); + let attempts = 0; + const maxRetries = parseInt(env.MAX_RETRIES) || 3; + + // For WebSockets, we only try once per backend to avoid connection issues + const effectiveMaxRetries = isWebSocket ? Math.min(maxRetries, 1) : maxRetries; + + console.log(`Routing request to ${backend} (attempt 1/${effectiveMaxRetries})`); + + while (attempts < effectiveMaxRetries) { + attempts++; + + try { + // Create a new request for the backend + const backendRequest = createBackendRequest(request, backend); + + // Forward the request to the backend + const response = await fetch(backendRequest); + + // If the response is a server error (5xx), mark the backend as down and try another + if (response.status >= 500 && response.status < 600) { + console.error(`Backend ${backend} returned ${response.status}`); + markBackendDown(backend, env); + + // Choose a different backend for the next attempt + if (attempts < effectiveMaxRetries) { + backend = chooseBackend(request, env); + console.log(`Retrying with ${backend} (attempt ${attempts + 1}/${effectiveMaxRetries})`); + continue; + } + } + + // Clone the response so we can modify headers + const clonedResponse = new Response(response.body, response); + + // If sticky sessions are enabled, set a cookie with the backend + if (env.STICKY_SESSIONS) { + // Calculate cookie expiration + const ttl = parseInt(env.SESSION_COOKIE_TTL || '86400'); // Default to 1 day + const expires = new Date(); + expires.setSeconds(expires.getSeconds() + ttl); + + clonedResponse.headers.append('Set-Cookie', + `${env.SESSION_COOKIE_NAME}=${backend}; Path=/; HttpOnly; SameSite=Lax; Expires=${expires.toUTCString()}`); + } + + // For WebSocket upgrade responses, make sure we preserve the Connection and Upgrade headers + if (isWebSocket && response.status === 101) { + clonedResponse.headers.set('Connection', 'Upgrade'); + clonedResponse.headers.set('Upgrade', 'websocket'); + } + + console.log(`Successfully routed to ${backend}, status: ${response.status}`); + return clonedResponse; + } catch (error) { + console.error(`Error forwarding to ${backend}:`, error); + markBackendDown(backend, env); + + // Choose a different backend for the next attempt + if (attempts < effectiveMaxRetries) { + backend = chooseBackend(request, env); + console.log(`Retrying with ${backend} (attempt ${attempts + 1}/${effectiveMaxRetries})`); + } + } + } + + // If we've exhausted all retries, return a 502 Bad Gateway + return new Response('All backends are currently unavailable', { + status: 502, + headers: { + 'Content-Type': 'text/plain', + 'Retry-After': '30' + } + }); + } +}; \ No newline at end of file diff --git a/packages/cloudflare-loadbalancer/src/test.ts b/packages/cloudflare-loadbalancer/src/test.ts new file mode 100644 index 0000000000000000000000000000000000000000..04be58a4fa02df4f2d2f03201cb78a70bb6e61e5 --- /dev/null +++ b/packages/cloudflare-loadbalancer/src/test.ts @@ -0,0 +1,149 @@ +/** + * This is a simple test script that can be run locally using wrangler dev. + * It simulates the behavior of the load balancer by mocking backend responses. + */ + +import { Env } from './index'; + +// Mock fetch responses for each backend +const mockResponses = new Map(); + +// Helper to register a mock response for a backend +function mockBackendResponse(backend: string, status: number, body: string): void { + mockResponses.set(backend, new Response(body, { status })); +} + +// Overrides the global fetch for testing +// @ts-ignore +globalThis.fetch = async (request: Request): Promise => { + const url = new URL(request.url); + const hostname = url.hostname; + + if (mockResponses.has(hostname)) { + return mockResponses.get(hostname)!; + } + + return new Response(`Unmocked backend: ${hostname}`, { status: 404 }); +}; + +// Mock the location object for development detection +// @ts-ignore +globalThis.location = { + hostname: 'localhost:8787', + protocol: 'http:' +}; + +// Test scenarios +async function runTests() { + // Set up mock environment + const env: Env = { + PRIMARY_DOMAIN: 'aiostreams.example.com', + BACKEND_CF: 'aiostreams-cf.example.com', + BACKEND_KOYEB: 'aiostreams-koyeb.example.com', + BACKEND_DUCK: 'aiostreams.example.duckdns.org', + STICKY_SESSIONS: true, + SESSION_COOKIE_NAME: 'aiostreams_backend', + SESSION_COOKIE_TTL: '86400', + BACKEND_DOWN_TIME: '30000', + MAX_RETRIES: '3' + }; + + console.log('=== AIOStreams Load Balancer Tests ==='); + + // Test 1: All backends healthy + console.log('\nTest 1: All backends healthy'); + mockBackendResponse(env.BACKEND_CF, 200, 'Response from CF'); + mockBackendResponse(env.BACKEND_KOYEB, 200, 'Response from Koyeb'); + mockBackendResponse(env.BACKEND_DUCK, 200, 'Response from DuckDNS'); + + // Simulate a request + const request1 = new Request(`https://${env.PRIMARY_DOMAIN}/test`); + // @ts-ignore - Import the actual handler from index.ts + const response1 = await require('./index').default.fetch(request1, env, {}); + + console.log(`Status: ${response1.status}`); + console.log(`Body: ${await response1.text()}`); + console.log(`Cookie: ${response1.headers.get('Set-Cookie')}`); + + // Test 2: One backend down + console.log('\nTest 2: One backend down (Cloudflare)'); + mockBackendResponse(env.BACKEND_CF, 503, 'Service Unavailable'); + + const request2 = new Request(`https://${env.PRIMARY_DOMAIN}/test`); + // @ts-ignore - Import the actual handler from index.ts + const response2 = await require('./index').default.fetch(request2, env, {}); + + console.log(`Status: ${response2.status}`); + console.log(`Body: ${await response2.text()}`); + + // Test 3: Sticky session + console.log('\nTest 3: Sticky session (should use Koyeb)'); + const request3 = new Request(`https://${env.PRIMARY_DOMAIN}/test`, { + headers: { + 'Cookie': `${env.SESSION_COOKIE_NAME}=${env.BACKEND_KOYEB}` + } + }); + + // @ts-ignore - Import the actual handler from index.ts + const response3 = await require('./index').default.fetch(request3, env, {}); + + console.log(`Status: ${response3.status}`); + console.log(`Body: ${await response3.text()}`); + + // Test 4: All backends down + console.log('\nTest 4: All backends down'); + mockBackendResponse(env.BACKEND_CF, 503, 'Service Unavailable'); + mockBackendResponse(env.BACKEND_KOYEB, 502, 'Bad Gateway'); + mockBackendResponse(env.BACKEND_DUCK, 500, 'Internal Server Error'); + + const request4 = new Request(`https://${env.PRIMARY_DOMAIN}/test`); + // @ts-ignore - Import the actual handler from index.ts + const response4 = await require('./index').default.fetch(request4, env, {}); + + console.log(`Status: ${response4.status}`); + console.log(`Body: ${await response4.text()}`); + + // Test 5: HTTP to HTTPS redirect + console.log('\nTest 5: HTTP to HTTPS redirect'); + const request5 = new Request(`http://${env.PRIMARY_DOMAIN}/test`); + // @ts-ignore - Import the actual handler from index.ts + const response5 = await require('./index').default.fetch(request5, env, {}); + + console.log(`Status: ${response5.status}`); + console.log(`Location: ${response5.headers.get('Location')}`); + + // Test 6: WebSocket handling + console.log('\nTest 6: WebSocket handling'); + mockBackendResponse(env.BACKEND_CF, 101, ''); // WebSocket upgrade response + + const webSocketHeaders = new Headers(); + webSocketHeaders.set('Upgrade', 'websocket'); + webSocketHeaders.set('Connection', 'Upgrade'); + + const request6 = new Request(`https://${env.PRIMARY_DOMAIN}/ws`, { + headers: webSocketHeaders + }); + + // @ts-ignore - Import the actual handler from index.ts + const response6 = await require('./index').default.fetch(request6, env, {}); + + console.log(`Status: ${response6.status}`); + console.log(`Upgrade header: ${response6.headers.get('Upgrade')}`); + console.log(`Connection header: ${response6.headers.get('Connection')}`); + + // Test 7: Wrong hostname (should be ignored in dev environment) + console.log('\nTest 7: Wrong hostname (should be handled in dev)'); + const request7 = new Request('https://wrong-hostname.example.com/test'); + // @ts-ignore - Import the actual handler from index.ts + const response7 = await require('./index').default.fetch(request7, env, {}); + + console.log(`Status: ${response7.status}`); + console.log(`Body: ${await response7.text().then((body: string) => body.substring(0, 50) + '...')}`); + + console.log('\n=== Tests Complete ==='); +} + +// Run the tests +runTests().catch(error => { + console.error('Test error:', error); +}); \ No newline at end of file diff --git a/packages/cloudflare-loadbalancer/tsconfig.json b/packages/cloudflare-loadbalancer/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..a326c5fcede1188e5810306f6df7c05da7dc7f87 --- /dev/null +++ b/packages/cloudflare-loadbalancer/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "es2022", + "lib": [ + "es2022" + ], + "module": "es2022", + "moduleResolution": "node", + "esModuleInterop": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "alwaysStrict": true, + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "outDir": "dist" + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/packages/cloudflare-loadbalancer/wrangler.toml b/packages/cloudflare-loadbalancer/wrangler.toml new file mode 100644 index 0000000000000000000000000000000000000000..683ce00df785d9145c24b42d2c80b1e279974901 --- /dev/null +++ b/packages/cloudflare-loadbalancer/wrangler.toml @@ -0,0 +1,28 @@ +#:schema node_modules/wrangler/config-schema.json +name = "aiostreams-loadbalancer" +main = "src/index.ts" +compatibility_date = "2024-12-24" +compatibility_flags = ["nodejs_compat"] + +# Workers Logs +[observability] +enabled = true + +# Variable bindings +[vars] +# Primary domain the worker is handling +PRIMARY_DOMAIN = "aiostreams.example.com" +# Upstream backends +BACKEND_CF = "aiostreams-cf.example.com" +BACKEND_KOYEB = "aiostreams-koyeb.example.com" +BACKEND_DUCK = "aiostreams.example.duckdns.org" +# Sticky session configuration +STICKY_SESSIONS = true +# Session cookie name (used if sticky sessions are enabled) +SESSION_COOKIE_NAME = "aiostreams_backend" +# Session cookie TTL in seconds (default: 86400 = 1 day) +SESSION_COOKIE_TTL = "86400" +# How long to mark a backend as down after a failure (in milliseconds) +BACKEND_DOWN_TIME = "30000" +# Maximum number of retries when a backend fails +MAX_RETRIES = "3" \ No newline at end of file diff --git a/packages/cloudflare-worker/package.json b/packages/cloudflare-worker/package.json new file mode 100644 index 0000000000000000000000000000000000000000..61541adbb35f1b5d06fa5a3161ba60058eb191b9 --- /dev/null +++ b/packages/cloudflare-worker/package.json @@ -0,0 +1,21 @@ +{ + "name": "@aiostreams/cloudflare-worker", + "version": "1.21.1", + "scripts": { + "deploy": "wrangler deploy", + "dev": "wrangler dev", + "start": "wrangler dev", + "test": "vitest", + "cf-typegen": "wrangler types" + }, + "dependencies": { + "@aiostreams/addon": "^1.0.0", + "@aiostreams/types": "^1.0.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20241224.0", + "esbuild": "^0.25.5", + "typescript": "^5.5.2", + "wrangler": "^4.18.0" + } +} diff --git a/packages/cloudflare-worker/src/index.ts b/packages/cloudflare-worker/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..6507a5a6e8f28ba2cf4c3060129b80a355cffb49 --- /dev/null +++ b/packages/cloudflare-worker/src/index.ts @@ -0,0 +1,215 @@ +import { AIOStreams, errorResponse, validateConfig } from '@aiostreams/addon'; +import manifest from '@aiostreams/addon/src/manifest'; +import { Config, StreamRequest } from '@aiostreams/types'; +import { unminifyConfig } from '@aiostreams/utils'; + +const HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET,HEAD,POST,OPTIONS', +}; + +const PROXY_URL = 'https://warp-proxy.bolabaden.org'; + +// Proxy utility function +async function fetchWithProxy(input: RequestInfo | URL, init?: RequestInit): Promise { + try { + // Convert input to string URL + const targetUrl = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + + // First, try to use the proxy + const proxyUrl = `${PROXY_URL}/fetch?url=${encodeURIComponent(targetUrl)}`; + const proxyResponse = await fetch(proxyUrl, { + ...init, + // Add API key and other headers + headers: { + ...init?.headers, + 'User-Agent': 'AIOStreams-CloudflareWorker/1.0', + 'X-API-Key': 'sk_IQys9kpENSiYY8lFuCslok3PauKBRSzeGprmvPfiMWAM9neeXoSqCZW7pMlWKbqPrwtF33kh1F73vf7D4PBpVfZJ1reHEL8d6ny6J03Ho', + }, + }); + + // If proxy responds successfully, return the response + if (proxyResponse.ok) { + return proxyResponse; + } + + // If proxy fails, fall back to direct request + console.warn(`Proxy failed with status ${proxyResponse.status}, falling back to direct request`); + return await fetch(input, init); + } catch (error) { + // If proxy is completely unreachable, fall back to direct request + console.warn('Proxy unreachable, falling back to direct request:', error); + return await fetch(input, init); + } +} + +function createJsonResponse(data: any): Response { + return new Response(JSON.stringify(data, null, 4), { + headers: HEADERS, + }); +} + +function createResponse(message: string, status: number): Response { + return new Response(message, { + status, + headers: HEADERS, + }); +} + +export default { + async fetch(request, env, ctx): Promise { + try { + const url = new URL(decodeURIComponent(request.url)); + const components = url.pathname.split('/').splice(1); + + // handle static asset requests + if (components.includes('_next') || components.includes('assets')) { + return env.ASSETS.fetch(request); + } + + if (url.pathname === '/icon.ico') { + return env.ASSETS.fetch(request); + } + + // redirect to /configure if root path is requested + if (url.pathname === '/') { + return Response.redirect(url.origin + '/configure', 301); + } + + // handle /encrypt-user-data POST requests + if (components.includes('encrypt-user-data')) { + const data = (await request.json()) as { data: string }; + if (!data) { + return createResponse('Invalid Request', 400); + } + const dataToEncode = data.data; + try { + console.log( + `Received /encrypt-user-data request with Data: ${dataToEncode}` + ); + const encodedData = Buffer.from(dataToEncode).toString('base64'); + return createJsonResponse({ data: encodedData, success: true }); + } catch (error: any) { + console.error(error); + return createJsonResponse({ error: error.message, success: false }); + } + } + // handle /configure and /:config/configure requests + if (components.includes('configure')) { + if (components.length === 1) { + return env.ASSETS.fetch(request); + } else { + // display configure page with config still in url + return env.ASSETS.fetch( + new Request(url.origin + '/configure', request) + ); + } + } + + // handle /manifest.json and /:config/manifest.json requests + if (components.includes('manifest.json')) { + if (components.length === 1) { + return createJsonResponse(manifest()); + } else { + return createJsonResponse(manifest(undefined, true)); + } + } + + if (components.includes('stream')) { + // when /stream is requested without config + let config = decodeURIComponent(components[0]); + console.log(`components: ${components}`); + if (components.length < 4) { + return createJsonResponse( + errorResponse( + 'You must configure this addon first', + url.origin, + '/configure' + ) + ); + } + console.log(`Received /stream request with Config: ${config}`); + const decodedPath = decodeURIComponent(url.pathname); + + const streamMatch = /stream\/(movie|series)\/([^/]+)\.json/.exec( + decodedPath + ); + if (!streamMatch) { + let path = decodedPath.replace(`/${config}`, ''); + console.error(`Invalid request: ${path}`); + return createResponse('Invalid request', 400); + } + + const [type, id] = streamMatch.slice(1); + console.log(`Received /stream request with Type: ${type}, ID: ${id}`); + + let decodedConfig: Config; + + if (config.startsWith('E-') || config.startsWith('E2-')) { + return createResponse('Encrypted Config Not Supported', 400); + } + try { + decodedConfig = unminifyConfig( + JSON.parse(Buffer.from(config, 'base64').toString('utf-8')) + ); + } catch (error: any) { + console.error(error); + return createJsonResponse( + errorResponse( + 'Unable to parse config, please reconfigure or create an issue on GitHub', + url.origin, + '/configure' + ) + ); + } + const { valid, errorMessage, errorCode } = + validateConfig(decodedConfig); + if (!valid) { + console.error(`Invalid config: ${errorMessage}`); + return createJsonResponse( + errorResponse(errorMessage ?? 'Unknown', url.origin, '/configure') + ); + } + + if (type !== 'movie' && type !== 'series') { + return createResponse('Invalid Request', 400); + } + + let streamRequest: StreamRequest = { id, type }; + + decodedConfig.requestingIp = + request.headers.get('X-Forwarded-For') || + request.headers.get('X-Real-IP') || + request.headers.get('CF-Connecting-IP') || + request.headers.get('X-Client-IP') || + undefined; + + // Temporarily replace global fetch with proxy-enabled fetch for AIOStreams + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchWithProxy; + + try { + const aioStreams = new AIOStreams(decodedConfig); + const streams = await aioStreams.getStreams(streamRequest); + return createJsonResponse({ streams }); + } finally { + // Restore original fetch + globalThis.fetch = originalFetch; + } + } + + const notFound = await env.ASSETS.fetch( + new Request(url.origin + '/404', request) + ); + return new Response(notFound.body, { ...notFound, status: 404 }); + } catch (e) { + console.error(e); + return new Response('Internal Server Error', { + status: 500, + headers: { + 'Content-Type': 'text/plain', + }, + }); + } + }, +} satisfies ExportedHandler; diff --git a/packages/cloudflare-worker/tsconfig.json b/packages/cloudflare-worker/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..e006935defea2f40c1cbb16d3ad626726ada1c9a --- /dev/null +++ b/packages/cloudflare-worker/tsconfig.json @@ -0,0 +1,48 @@ +{ + "compilerOptions": { + "target": "es2021", + "lib": ["es2021"], + /* Specify what JSX code is generated. */ + "jsx": "react-jsx", + + /* Specify what module code is generated. */ + "module": "es2022", + /* Specify how TypeScript looks up a file from a given module specifier. */ + "moduleResolution": "Bundler", + /* Specify type package names to be included without being referenced in a source file. */ + "types": ["@cloudflare/workers-types"], + /* Enable importing .json files */ + "resolveJsonModule": true, + + /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ + "allowJs": true, + /* Enable error reporting in type-checked JavaScript files. */ + "checkJs": false, + + /* Disable emitting files from a compilation. */ + "noEmit": true, + + /* Ensure that each file can be safely transpiled without relying on other imports. */ + "isolatedModules": true, + /* Allow 'import x from y' when a module doesn't have a default export. */ + "allowSyntheticDefaultImports": true, + /* Ensure that casing is correct in imports. */ + "forceConsistentCasingInFileNames": true, + + /* Enable all strict type-checking options. */ + "strict": true, + + /* Skip type checking all .d.ts files. */ + "skipLibCheck": true + }, + "references": [ + { + "path": "../addon" + }, + { + "path": "../types" + } + ], + "exclude": ["test"], + "include": ["worker-configuration.d.ts", "src/**/*.ts"] +} diff --git a/packages/cloudflare-worker/worker-configuration.d.ts b/packages/cloudflare-worker/worker-configuration.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..43fbb88a81359232c17a2d2e958870dbd282aa3a --- /dev/null +++ b/packages/cloudflare-worker/worker-configuration.d.ts @@ -0,0 +1,5 @@ +// Generated by Wrangler by running `wrangler types` + +interface Env { + ASSETS: Fetcher; +} diff --git a/packages/cloudflare-worker/wrangler.toml b/packages/cloudflare-worker/wrangler.toml new file mode 100644 index 0000000000000000000000000000000000000000..4faf778d1d0319c62ff688d732fc667f54df7d08 --- /dev/null +++ b/packages/cloudflare-worker/wrangler.toml @@ -0,0 +1,138 @@ +#:schema node_modules/wrangler/config-schema.json +name = "aiostreams" +main = "src/index.ts" +compatibility_date = "2024-12-24" +compatibility_flags = ["nodejs_compat"] +assets = { directory = "../frontend/out", binding = "ASSETS", experimental_serve_directly = false} + +# Workers Logs +# Docs: https://developers.cloudflare.com/workers/observability/logs/workers-logs/ +# Configuration: https://developers.cloudflare.com/workers/observability/logs/workers-logs/#enable-workers-logs +[observability] +enabled = true + +# Automatically place your workloads in an optimal location to minimize latency. +# If you are running back-end logic in a Worker, running it closer to your back-end infrastructure +# rather than the end user may result in better performance. +# Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement +# [placement] +# mode = "smart" + +# Variable bindings. These are arbitrary, plaintext strings (similar to environment variables) +# Docs: +# - https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables +# Note: Use secrets to store sensitive data. +# - https://developers.cloudflare.com/workers/configuration/secrets/ +[vars] +ADDON_ID="aiostreams.cfworker.bolabaden" +ADDON_NAME="BadenAIO (CloudFlare)" +DETERMINISTIC_ADDON_ID=false +SECRET_KEY="1070c705d193441da9fce510d5977e824686d5d0a0ab44bc8d8cb006ff64ee82" +API_KEY="sk_4dc059c0399c43fd94c09baaf0b94da119fc526775914bf2b3a3fb6e073e26d9" +LOG_LEVEL="debug" +LOG_FORMAT="text" +LOG_SENSITIVE_INFO=true +MAX_ADDONS=50 +MAX_KEYWORD_FILTERS=30 +DEFAULT_STREMTHRU_URL="https://stremthru.bolabaden.org/" +DEFAULT_STREMTHRU_CREDENTIAL="bolabaden.duckdns@gmail.com:Hilogirl80!" +ENCRYPT_STREMTHRU_URLS=true +COMET_URL="http://comet.elfhosted.com/" +MEDIAFUSION_URL="http://mediafusion.elfhosted.com/" +JACKETTIO_URL="https://jackettio.elfhosted.com/" +DEFAULT_JACKETTIO_INDEXERS='["1337x", "animetosho", "anirena", "limetorrents", "nyaasi", "solidtorrents", "thepiratebay", "torlock", "yts"]' +DEFAULT_JACKETTIO_STREMTHRU_URL="https://stremthru.bolabaden.org" +STREMIO_JACKETT_URL="https://stremio-jackett.elfhosted.com/" +DEFAULT_STREMIO_JACKETT_TMDB_API_KEY="cec876f852b9c15d2c1b436b1117dff7" +STREMIO_JACKETT_CACHE_ENABLED=true +STREMTHRU_STORE_URL="https://stremthru.bolabaden.org/stremio/store/" + +# Add other non-sensitive environment variables here + +# Bind the Workers AI model catalog. Run machine learning models, powered by serverless GPUs, on Cloudflare’s global network +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#workers-ai +# [ai] +# binding = "AI" + +# Bind an Analytics Engine dataset. Use Analytics Engine to write analytics within your Pages Function. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#analytics-engine-datasets +# [[analytics_engine_datasets]] +# binding = "MY_DATASET" + +# Bind a headless browser instance running on Cloudflare's global network. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#browser-rendering +# [browser] +# binding = "MY_BROWSER" + +# Bind a D1 database. D1 is Cloudflare’s native serverless SQL database. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#d1-databases +# [[d1_databases]] +# binding = "MY_DB" +# database_name = "my-database" +# database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + +# Bind a dispatch namespace. Use Workers for Platforms to deploy serverless functions programmatically on behalf of your customers. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#dispatch-namespace-bindings-workers-for-platforms +# [[dispatch_namespaces]] +# binding = "MY_DISPATCHER" +# namespace = "my-namespace" + +# Bind a Durable Object. Durable objects are a scale-to-zero compute primitive based on the actor model. +# Durable Objects can live for as long as needed. Use these when you need a long-running "server", such as in realtime apps. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#durable-objects +# [[durable_objects.bindings]] +# name = "MY_DURABLE_OBJECT" +# class_name = "MyDurableObject" + +# Durable Object migrations. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#migrations +# [[migrations]] +# tag = "v1" +# new_classes = ["MyDurableObject"] + +# Bind a Hyperdrive configuration. Use to accelerate access to your existing databases from Cloudflare Workers. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#hyperdrive +# [[hyperdrive]] +# binding = "MY_HYPERDRIVE" +# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# Bind a KV Namespace. Use KV as persistent storage for small key-value pairs. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#kv-namespaces +# [[kv_namespaces]] +# binding = "MY_KV_NAMESPACE" +# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# Bind an mTLS certificate. Use to present a client certificate when communicating with another service. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#mtls-certificates +# [[mtls_certificates]] +# binding = "MY_CERTIFICATE" +# certificate_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + +# Bind a Queue producer. Use this binding to schedule an arbitrary task that may be processed later by a Queue consumer. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#queues +# [[queues.producers]] +# binding = "MY_QUEUE" +# queue = "my-queue" + +# Bind a Queue consumer. Queue Consumers can retrieve tasks scheduled by Producers to act on them. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#queues +# [[queues.consumers]] +# queue = "my-queue" + +# Bind an R2 Bucket. Use R2 to store arbitrarily large blobs of data, such as files. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#r2-buckets +# [[r2_buckets]] +# binding = "MY_BUCKET" +# bucket_name = "my-bucket" + +# Bind another Worker service. Use this binding to call another Worker without network overhead. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings +# [[services]] +# binding = "MY_SERVICE" +# service = "my-service" + +# Bind a Vectorize index. Use to store and query vector embeddings for semantic search, classification and other vector search use-cases. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#vectorize-indexes +# [[vectorize]] +# binding = "MY_INDEX" +# index_name = "my-index" diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000000000000000000000000000000000000..7b2b4da917cda6cd27270aca2b0d99310af3165a --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,33 @@ +{ + "name": "@aiostreams/core", + "version": "0.0.0", + "main": "dist/index.js", + "scripts": { + "test": "vitest run --passWithNoTests", + "test:watch": "vitest watch", + "build": "tsc" + }, + "description": "Combine all your streams into one addon and display them with consistent formatting, sorting, and filtering.", + "dependencies": { + "bcrypt": "^6.0.0", + "bytes": "^3.1.2", + "dotenv": "^16.4.7", + "envalid": "^8.0.0", + "expr-eval": "^2.0.2", + "moment-timezone": "^0.5.48", + "parse-torrent-title": "github:TheBeastLT/parse-torrent-title", + "pg": "^8.16.0", + "sqlite": "^5.1.1", + "sqlite3": "^5.1.7", + "super-regex": "^1.0.0", + "undici": "^7.2.3", + "winston": "^3.17.0", + "zod": "^3.24.4" + }, + "devDependencies": { + "@types/bcrypt": "^5.0.2", + "@types/bytes": "^3.1.5", + "@types/node": "^20.14.10", + "@types/pg": "^8.15.2" + } +} diff --git a/packages/core/src/db/db.ts b/packages/core/src/db/db.ts new file mode 100644 index 0000000000000000000000000000000000000000..09ff4a3a0f51964308e3c540c09b68ba9ee7673b --- /dev/null +++ b/packages/core/src/db/db.ts @@ -0,0 +1,230 @@ +import { TABLES } from './schemas'; +import { createLogger } from '../utils'; +import { parseConnectionURI, adaptQuery, ConnectionURI } from './utils'; + +const logger = createLogger('database'); + +import { Pool, Client, QueryResult } from 'pg'; +import sqlite3 from 'sqlite3'; +import { open, Database } from 'sqlite'; +import { URL } from 'url'; +import path from 'path'; +import fs from 'fs'; + +type QueryResultRow = Record; + +interface UnifiedQueryResult { + rows: T[]; + rowCount: number; + command?: string; +} + +type DBDialect = 'postgres' | 'sqlite'; + +type DSNModifier = (url: URL, query: URLSearchParams) => void; + +type Transaction = { + commit: () => Promise; + rollback: () => Promise; + execute: (query: string, params?: any[]) => Promise>; +}; + +export class DB { + private static instance: DB; + private db!: Pool | Database; + private static initialised: boolean = false; + private static dialect: DBDialect; + private uri!: ConnectionURI; + private dsnModifiers: DSNModifier[] = []; + + private constructor() {} + + static getInstance(): DB { + if (!this.instance) { + this.instance = new DB(); + } + return this.instance; + } + isInitialised(): boolean { + return DB.initialised; + } + + getDialect(): DBDialect { + return DB.dialect; + } + + async initialise( + uri: string, + dsnModifiers: DSNModifier[] = [] + ): Promise { + if (DB.initialised) { + return; + } + try { + this.uri = parseConnectionURI(uri); + this.dsnModifiers = dsnModifiers; + await this.open(); + await this.ping(); + + // create tables + for (const [name, schema] of Object.entries(TABLES)) { + const createTableQuery = `CREATE TABLE IF NOT EXISTS ${name} (${schema})`; + await this.execute(createTableQuery); + } + + if (this.uri.dialect === 'sqlite') { + await this.execute('PRAGMA busy_timeout = 5000'); + await this.execute('PRAGMA foreign_keys = ON'); + await this.execute('PRAGMA synchronous = OFF'); + await this.execute('PRAGMA journal_mode = WAL'); + await this.execute('PRAGMA locking_mode = IMMEDIATE'); + } + + DB.initialised = true; + DB.dialect = this.uri.dialect; + } catch (error) { + logger.error('Failed to initialize database:', error); + throw error; + } + } + + async open(): Promise { + if (this.uri.dialect === 'postgres') { + const pool = new Pool({ + connectionString: this.uri.url.toString(), + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000, + }); + this.db = pool; + this.uri.dialect = 'postgres'; + } else if (this.uri.dialect === 'sqlite') { + // make parent directory if it does not exist + const parentDir = path.dirname(this.uri.filename); + if (!parentDir) { + throw new Error('Invalid SQLite path'); + } + if (!fs.existsSync(parentDir)) { + fs.mkdirSync(parentDir, { recursive: true }); + } + logger.debug(`Opening SQLite database: ${this.uri.filename}`); + + this.db = await open({ + filename: this.uri.filename, + driver: sqlite3.Database, + }); + this.uri.dialect = 'sqlite'; + } + } + + async close(): Promise { + if (this.uri.dialect === 'postgres') { + await (this.db as Pool).end(); + } else if (this.uri.dialect === 'sqlite') { + await (this.db as Database).close(); + } + } + + async ping(): Promise { + if (this.uri.dialect === 'postgres') { + await (this.db as Pool).query('SELECT 1'); + } else if (this.uri.dialect === 'sqlite') { + await (this.db as Database).get('SELECT 1'); + } + } + + async execute(query: string, params?: any[]): Promise { + if (this.uri.dialect === 'postgres') { + return (this.db as Pool).query( + adaptQuery(query, this.uri.dialect), + params + ); + } else if (this.uri.dialect === 'sqlite') { + return (this.db as Database).run( + adaptQuery(query, this.uri.dialect), + params + ); + } + throw new Error('Unsupported dialect'); + } + + async query(query: string, params?: any[]): Promise { + const adaptedQuery = adaptQuery(query, this.uri.dialect); + if (this.uri.dialect === 'postgres') { + const result = await (this.db as Pool).query(adaptedQuery, params); + return result.rows; + } else if (this.uri.dialect === 'sqlite') { + return (this.db as Database).all(adaptedQuery, params); + } + return []; + } + + async begin(): Promise { + if (this.uri.dialect === 'postgres') { + const client = await (this.db as Pool).connect(); + await client.query('BEGIN'); + + let finalised = false; + + const finalise = () => { + if (!finalised) { + finalised = true; + client.release(); + } + }; + + return { + commit: async () => { + try { + await client.query('COMMIT'); + } finally { + finalise(); + } + }, + rollback: async () => { + try { + await client.query('ROLLBACK'); + } finally { + finalise(); + } + }, + execute: async ( + query: string, + params?: any[] + ): Promise => { + const result = await client.query( + adaptQuery(query, 'postgres'), + params + ); + return { + rows: result.rows, + rowCount: result.rowCount || 0, + command: result.command, + }; + }, + }; + } else if (this.uri.dialect === 'sqlite') { + const db = this.db as Database; + await db.run('BEGIN'); + return { + commit: async () => { + await db.run('COMMIT'); + }, + rollback: async () => { + await db.run('ROLLBACK'); + }, + execute: async ( + query: string, + params?: any[] + ): Promise => { + const result = await db.all(adaptQuery(query, 'sqlite'), params); + return { + rows: result, + rowCount: result.length || 0, + command: 'SELECT', + }; + }, + }; + } + throw new Error('Unsupported transaction dialect'); + } +} diff --git a/packages/core/src/db/index.ts b/packages/core/src/db/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..efc8bdba50154515b482fc287e0e1db3eb10c6f3 --- /dev/null +++ b/packages/core/src/db/index.ts @@ -0,0 +1,4 @@ +export * from './db'; +export * from './users'; +export * from './schemas'; +export * from './queue'; diff --git a/packages/core/src/db/queue.ts b/packages/core/src/db/queue.ts new file mode 100644 index 0000000000000000000000000000000000000000..f7b9cbfa70224b866ce1761e614c981a5c89672b --- /dev/null +++ b/packages/core/src/db/queue.ts @@ -0,0 +1,58 @@ +import { createLogger } from '../utils'; +import { DB } from './db'; +const logger = createLogger('db'); +const db = DB.getInstance(); + +// Queue for SQLite transactions + +export class TransactionQueue { + private queue: Array<() => Promise> = []; + private processing = false; + private static instance: TransactionQueue; + + private constructor() {} + + static getInstance(): TransactionQueue { + if (!this.instance) { + this.instance = new TransactionQueue(); + } + return this.instance; + } + + async enqueue(operation: () => Promise): Promise { + // If using PostgreSQL, execute directly without queuing + if (db['uri']?.dialect === 'postgres') { + return operation(); + } + + return new Promise((resolve, reject) => { + this.queue.push(async () => { + try { + const result = await operation(); + resolve(result); + } catch (error) { + reject(error); + } + }); + this.processQueue(); + }); + } + + private async processQueue() { + if (this.processing || this.queue.length === 0) return; + this.processing = true; + + while (this.queue.length > 0) { + const operation = this.queue.shift(); + if (operation) { + try { + await operation(); + } catch (error) { + logger.error('Error processing queued operation:', error); + } + } + } + + this.processing = false; + } +} diff --git a/packages/core/src/db/schemas.ts b/packages/core/src/db/schemas.ts new file mode 100644 index 0000000000000000000000000000000000000000..e86974d8628a14e88c6ac0b5cc01a49ae2520e4d --- /dev/null +++ b/packages/core/src/db/schemas.ts @@ -0,0 +1,863 @@ +import { z } from 'zod'; +import * as constants from '../utils/constants'; + +const ServiceIds = z.enum(constants.SERVICES); + +const Resolutions = z.enum(constants.RESOLUTIONS); + +const Qualities = z.enum(constants.QUALITIES); + +const VisualTags = z.enum(constants.VISUAL_TAGS); + +const AudioTags = z.enum(constants.AUDIO_TAGS); + +const AudioChannels = z.enum(constants.AUDIO_CHANNELS); + +const Encodes = z.enum(constants.ENCODES); + +// const SortCriteria = z.enum(constants.SORT_CRITERIA); + +// const SortDirections = z.enum(constants.SORT_DIRECTIONS); + +const SortCriterion = z.object({ + key: z.enum(constants.SORT_CRITERIA), + direction: z.enum(constants.SORT_DIRECTIONS), +}); + +export type SortCriterion = z.infer; + +const StreamTypes = z.enum(constants.STREAM_TYPES); +const Languages = z.enum(constants.LANGUAGES); + +const Formatter = z.object({ + id: z.enum(constants.FORMATTERS), + definition: z + .object({ + name: z.string().max(5000), + description: z.string().max(5000), + }) + .optional(), +}); + +const StreamProxyConfig = z.object({ + enabled: z.boolean().optional(), + id: z.enum(constants.PROXY_SERVICES).optional(), + url: z.string().optional(), + credentials: z.string().optional(), + publicIp: z.string().ip().optional(), + proxiedAddons: z.array(z.string().min(1)).optional(), + proxiedServices: z.array(z.string().min(1)).optional(), +}); + +export type StreamProxyConfig = z.infer; + +const ResultLimitOptions = z.object({ + global: z.number().min(1).optional(), + service: z.number().min(1).optional(), + addon: z.number().min(1).optional(), + resolution: z.number().min(1).optional(), + quality: z.number().min(1).optional(), + streamType: z.number().min(1).optional(), + indexer: z.number().min(1).optional(), + releaseGroup: z.number().min(1).optional(), +}); + +// const SizeFilter = z.object({ +// min: z.number().min(1).optional(), +// max: z.number().min(1).optional(), +// }); +const SizeFilter = z.object({ + movies: z + .tuple([z.number().min(0), z.number().min(0)]) + // .object({ + // min: z.number().min(1).optional(), + // max: z.number().min(1).optional(), + // }) + .optional(), + series: z + .tuple([z.number().min(0), z.number().min(0)]) + // .object({ + // min: z.number().min(1).optional(), + // max: z.number().min(1).optional(), + // }) + .optional(), +}); + +const SizeFilterOptions = z.object({ + global: SizeFilter, + resolution: z.record(Resolutions, SizeFilter).optional(), +}); + +const ServiceSchema = z.object({ + id: ServiceIds, + enabled: z.boolean().optional(), + credentials: z.record(z.string().min(1), z.string().min(1)), +}); + +export type Service = z.infer; + +const ServiceList = z.array(ServiceSchema); + +const ResourceSchema = z.enum(constants.RESOURCES); + +export type Resource = z.infer; + +const ResourceList = z.array(ResourceSchema); + +const AddonSchema = z.object({ + instanceId: z.string().min(1).optional(), // uniquely identifies the addon in a given list of addons + presetType: z.string().min(1), // reference to the type of the preset that created this addon + presetInstanceId: z.string().min(1), // reference to the instance id of the preset that created this addon + manifestUrl: z.string().url(), + enabled: z.boolean(), + resources: ResourceList.optional(), + name: z.string(), + identifier: z.string().optional(), // true identifier for generating IDs + displayIdentifier: z.string().optional(), // identifier for display purposes + timeout: z.number().min(1), + library: z.boolean().optional(), + streamPassthrough: z.boolean().optional(), + headers: z.record(z.string().min(1), z.string().min(1)).optional(), + ip: z.string().ip().optional(), +}); + +// preset objects are transformed into addons by a preset transformer. +const PresetSchema = z.object({ + type: z.string().min(1), // the preset type e.g. 'torrentio' + instanceId: z.string().min(1), // uniquely identifies the preset in a given list of presets + enabled: z.boolean(), + options: z.record(z.string().min(1), z.any()), +}); + +export type PresetObject = z.infer; + +const PresetList = z.array(PresetSchema); + +export type Addon = z.infer; +export type Preset = z.infer; + +const DeduplicatorKey = z.enum(constants.DEDUPLICATOR_KEYS); + +// deduplicator options. +// can choose what keys to use for identifying duplicates. +// can choose how duplicates are removed specifically. +// we can either +// - keep only 1 result from the highest priority service from the highest priority addon (single_result) +// - keep 1 result for each enabled service from the higest priority addon (per_service) +// - keep 1 result from the highest priority service from each enabled addon (per_addon) +const DeduplicatorMode = z.enum([ + 'single_result', + 'per_service', + 'per_addon', + 'disabled', +]); + +const DeduplicatorOptions = z.object({ + enabled: z.boolean().optional(), + keys: z.array(DeduplicatorKey).optional(), + cached: DeduplicatorMode.optional(), + uncached: DeduplicatorMode.optional(), + p2p: DeduplicatorMode.optional(), + http: DeduplicatorMode.optional(), + live: DeduplicatorMode.optional(), + youtube: DeduplicatorMode.optional(), + external: DeduplicatorMode.optional(), +}); + +const OptionDefinition = z.object({ + id: z.string().min(1), + name: z.string().min(1), + description: z.string().min(1), + emptyIsUndefined: z.boolean().optional(), + type: z.enum([ + 'string', + 'password', + 'number', + 'boolean', + 'select', + 'multi-select', + 'url', + 'alert', + 'socials', + ]), + required: z.boolean().optional(), + default: z.any().optional(), + // sensitive: z.boolean().optional(), + forced: z.any().optional(), + options: z + .array( + z.object({ + value: z.any(), + label: z.string().min(1), + }) + ) + .optional(), + intent: z + .enum([ + 'alert', + 'info', + 'success', + 'warning', + 'info-basic', + 'success-basic', + 'warning-basic', + 'alert-basic', + ]) + .optional(), + socials: z + .array( + z.object({ + id: z.enum([ + 'website', + 'github', + 'discord', + 'ko-fi', + 'patreon', + 'buymeacoffee', + 'github-sponsors', + ]), + url: z.string().url(), + }) + ) + .optional(), + constraints: z + .object({ + min: z.number().min(1).optional(), // for string inputs, consider this the minimum length. + max: z.number().min(1).optional(), // and for number inputs, consider this the minimum and maximum value. + }) + .optional(), +}); + +export type Option = z.infer; + +const NameableRegex = z.object({ + name: z.string().min(0), + pattern: z.string().min(1), +}); + +const Group = z.object({ + addons: z.array(z.string().min(1)).min(1), + condition: z.string().min(1).max(200), +}); + +export type Group = z.infer; + +// Resolution, Quality, Encode, Visual Tag, Audio Tag, Stream Type, Keyword, Regex, Cached, Uncached, Size + +const CatalogModification = z.object({ + id: z.string().min(1), // an id that maps to an actual catalog ID + type: z.string().min(1), // the type of catalog modification + name: z.string().min(1).optional(), // override the name of the catalog + shuffle: z.boolean().optional(), // shuffle the catalog + persistShuffleFor: z.number().min(0).max(24).optional(), // persist the shuffle for a given amount of time (in hours) + onlyOnDiscover: z.boolean().optional(), // only show the catalog on the discover page + enabled: z.boolean().optional(), // enable or disable the catalog + rpdb: z.boolean().optional(), // use rpdb for posters if supported + overrideType: z.string().min(1).optional(), // override the type of the catalog + hideable: z.boolean().optional(), // hide the catalog from the home page + addonName: z.string().min(1).optional(), // the name of the addon that provides the catalog +}); + +export const UserDataSchema = z.object({ + uuid: z.string().uuid().optional(), + encryptedPassword: z.string().min(1).optional(), + trusted: z.boolean().optional(), + addonPassword: z.string().min(1).optional(), + ip: z.string().ip().optional(), + addonName: z.string().min(1).max(300).optional(), + addonLogo: z.string().url().optional(), + addonBackground: z.string().url().optional(), + addonDescription: z.string().min(1).optional(), + excludedResolutions: z.array(Resolutions).optional(), + includedResolutions: z.array(Resolutions).optional(), + requiredResolutions: z.array(Resolutions).optional(), + preferredResolutions: z.array(Resolutions).optional(), + excludedQualities: z.array(Qualities).optional(), + includedQualities: z.array(Qualities).optional(), + requiredQualities: z.array(Qualities).optional(), + preferredQualities: z.array(Qualities).optional(), + excludedLanguages: z.array(Languages).optional(), + includedLanguages: z.array(Languages).optional(), + requiredLanguages: z.array(Languages).optional(), + preferredLanguages: z.array(Languages).optional(), + excludedVisualTags: z.array(VisualTags).optional(), + includedVisualTags: z.array(VisualTags).optional(), + requiredVisualTags: z.array(VisualTags).optional(), + preferredVisualTags: z.array(VisualTags).optional(), + excludedAudioTags: z.array(AudioTags).optional(), + includedAudioTags: z.array(AudioTags).optional(), + requiredAudioTags: z.array(AudioTags).optional(), + preferredAudioTags: z.array(AudioTags).optional(), + excludedAudioChannels: z.array(AudioChannels).optional(), + includedAudioChannels: z.array(AudioChannels).optional(), + requiredAudioChannels: z.array(AudioChannels).optional(), + preferredAudioChannels: z.array(AudioChannels).optional(), + excludedStreamTypes: z.array(StreamTypes).optional(), + includedStreamTypes: z.array(StreamTypes).optional(), + requiredStreamTypes: z.array(StreamTypes).optional(), + preferredStreamTypes: z.array(StreamTypes).optional(), + excludedEncodes: z.array(Encodes).optional(), + includedEncodes: z.array(Encodes).optional(), + requiredEncodes: z.array(Encodes).optional(), + preferredEncodes: z.array(Encodes).optional(), + excludedRegexPatterns: z.array(z.string().min(1)).optional(), + includedRegexPatterns: z.array(z.string().min(1)).optional(), + requiredRegexPatterns: z.array(z.string().min(1)).optional(), + preferredRegexPatterns: z.array(NameableRegex).optional(), + requiredKeywords: z.array(z.string().min(1)).optional(), + includedKeywords: z.array(z.string().min(1)).optional(), + excludedKeywords: z.array(z.string().min(1)).optional(), + preferredKeywords: z.array(z.string().min(1)).optional(), + randomiseResults: z.boolean().optional(), + enhanceResults: z.boolean().optional(), + enhancePosters: z.boolean().optional(), + excludeSeederRange: z + .tuple([z.number().min(0), z.number().min(0)]) + .optional(), + includeSeederRange: z + .tuple([z.number().min(0), z.number().min(0)]) + .optional(), + requiredSeederRange: z + .tuple([z.number().min(0), z.number().min(0)]) + .optional(), + seederRangeTypes: z.array(z.enum(['p2p', 'cached', 'uncached'])).optional(), + excludeCached: z.boolean().optional(), + excludeCachedFromAddons: z.array(z.string().min(1)).optional(), + excludeCachedFromServices: z.array(z.string().min(1)).optional(), + excludeCachedFromStreamTypes: z.array(StreamTypes).optional(), + excludeCachedMode: z.enum(['or', 'and']).optional(), + excludeUncached: z.boolean().optional(), + excludeUncachedFromAddons: z.array(z.string().min(1)).optional(), + excludeUncachedFromServices: z.array(z.string().min(1)).optional(), + excludeUncachedFromStreamTypes: z.array(StreamTypes).optional(), + excludeUncachedMode: z.enum(['or', 'and']).optional(), + excludedStreamExpressions: z.array(z.string().min(1).max(1000)).optional(), + requiredStreamExpressions: z.array(z.string().min(1).max(1000)).optional(), + preferredStreamExpressions: z.array(z.string().min(1).max(1000)).optional(), + groups: z + .array( + z.object({ + addons: z.array(z.string().min(1)), + condition: z.string().min(1).max(200), + }) + ) + .optional(), + sortCriteria: z.object({ + // global must be defined. + global: z.array(SortCriterion), + // results must be from either a movie or series search, so we can safely apply different sort criteria. + movies: z.array(SortCriterion).optional(), + series: z.array(SortCriterion).optional(), + anime: z.array(SortCriterion).optional(), + // cached and uncached results are a sort criteria themselves, so this can only be applied when cache is high enough in the global + // sort criteria, and we would have to split the results into two (cached and uncached) lists, and then apply both sort criteria below + // and then merge the results. + cached: z.array(SortCriterion).optional(), + uncached: z.array(SortCriterion).optional(), + cachedMovies: z.array(SortCriterion).optional(), + uncachedMovies: z.array(SortCriterion).optional(), + cachedSeries: z.array(SortCriterion).optional(), + uncachedSeries: z.array(SortCriterion).optional(), + cachedAnime: z.array(SortCriterion).optional(), + uncachedAnime: z.array(SortCriterion).optional(), + }), + rpdbApiKey: z.string().optional(), + formatter: Formatter, + proxy: StreamProxyConfig.optional(), + resultLimits: ResultLimitOptions.optional(), + size: SizeFilterOptions.optional(), + hideErrors: z.boolean().optional(), + hideErrorsForResources: z.array(ResourceSchema).optional(), + tmdbAccessToken: z.string().optional(), + titleMatching: z + .object({ + mode: z.enum(['exact', 'contains']).optional(), + matchYear: z.boolean().optional(), + enabled: z.boolean().optional(), + requestTypes: z.array(z.string()).optional(), + addons: z.array(z.string()).optional(), + }) + .optional(), + seasonEpisodeMatching: z + .object({ + enabled: z.boolean().optional(), + requestTypes: z.array(z.string()).optional(), + addons: z.array(z.string()).optional(), + }) + .optional(), + deduplicator: DeduplicatorOptions.optional(), + precacheNextEpisode: z.boolean().optional(), + alwaysPrecache: z.boolean().optional(), + services: ServiceList.optional(), + presets: PresetList, + catalogModifications: z.array(CatalogModification).optional(), + externalDownloads: z.boolean().optional(), +}); + +export type UserData = z.infer; + +export const TABLES = { + USERS: ` + uuid TEXT PRIMARY KEY, + password_hash TEXT NOT NULL, + config TEXT NOT NULL, + config_salt TEXT NOT NULL, + created_at TIMESTAMP DEFAULT (CURRENT_TIMESTAMP), + updated_at TIMESTAMP DEFAULT (CURRENT_TIMESTAMP), + accessed_at TIMESTAMP DEFAULT (CURRENT_TIMESTAMP) + `, +}; + +const strictManifestResourceSchema = z.object({ + name: z.enum(constants.RESOURCES), + types: z.array(z.string()), + idPrefixes: z.array(z.string().min(1)).or(z.null()).optional(), +}); + +export type StrictManifestResource = z.infer< + typeof strictManifestResourceSchema +>; + +const ManifestResourceSchema = z.union([ + z.string(), + strictManifestResourceSchema, +]); + +const ManifestExtraSchema = z.object({ + name: z.string().min(1), + isRequired: z.boolean().optional(), + options: z.array(z.string().or(z.null())).or(z.null()).optional(), + optionsLimit: z.number().min(1).optional(), +}); +const ManifestCatalogSchema = z.object({ + type: z.string(), + id: z.string().min(1), + name: z.string().min(1), + extra: z.array(ManifestExtraSchema).optional(), +}); + +const AddonCatalogDefinitionSchema = z.object({ + type: z.string(), + id: z.string().min(1), + name: z.string().min(1), +}); + +export const ManifestSchema = z + .object({ + id: z.string().min(1), + name: z.string(), + description: z.string(), + version: z.string(), + types: z.array(z.string()), + idPrefixes: z.array(z.string().min(1)).or(z.null()).optional(), + resources: z.array(ManifestResourceSchema), + catalogs: z.array(ManifestCatalogSchema), + addonCatalogs: z.array(AddonCatalogDefinitionSchema).optional(), + background: z.string().or(z.null()).optional(), + logo: z.string().or(z.null()).optional(), + contactEmail: z.string().or(z.null()).optional(), + behaviorHints: z + .object({ + adult: z.boolean().optional(), + p2p: z.boolean().optional(), + configurable: z.boolean().optional(), + configurationRequired: z.boolean().optional(), + }) + .optional(), + // not part of the manifest scheme, but needed for stremio-addons.net + stremioAddonsConfig: z + .object({ + issuer: z.string().min(1), + signature: z.string().min(1), + }) + .optional(), + }) + .passthrough(); + +export type Manifest = z.infer; + +export const SubtitleSchema = z + .object({ + id: z.string().min(1), + url: z.string().url(), + lang: z.string().min(1), + }) + .passthrough(); + +export const SubtitleResponseSchema = z.object({ + subtitles: z.array(SubtitleSchema), +}); +export type SubtitleResponse = z.infer; +export type Subtitle = z.infer; + +export const StreamSchema = z + .object({ + url: z.string().url().or(z.null()).optional(), + ytId: z.string().min(1).or(z.null()).optional(), + infoHash: z.string().min(1).or(z.null()).optional(), + fileIdx: z.number().or(z.null()).optional(), + externalUrl: z.string().min(1).or(z.null()).optional(), + name: z.string().min(1).or(z.null()).optional(), + title: z.string().min(1).or(z.null()).optional(), + description: z.string().min(1).or(z.null()).optional(), + subtitles: z.array(SubtitleSchema).or(z.null()).optional(), + sources: z.array(z.string().min(1)).or(z.null()).optional(), + behaviorHints: z + .object({ + countryWhitelist: z.array(z.string().length(3)).or(z.null()).optional(), + notWebReady: z.boolean().or(z.null()).optional(), + bingeGroup: z.string().min(1).or(z.null()).optional(), + proxyHeaders: z + .object({ + request: z.record(z.string().min(1), z.string().min(1)).optional(), + response: z.record(z.string().min(1), z.string().min(1)).optional(), + }) + .optional(), + videoHash: z.string().min(1).or(z.null()).optional(), + videoSize: z.number().or(z.null()).optional(), + filename: z.string().min(1).or(z.null()).optional(), + }) + .optional(), + }) + .passthrough(); + +export const StreamResponseSchema = z.object({ + streams: z.array(StreamSchema), +}); + +export type StreamResponse = z.infer; + +export type Stream = z.infer; + +const TrailerSchema = z + .object({ + source: z.string().min(1), + type: z.enum(['Trailer', 'Clip']), + }) + .passthrough(); + +const MetaLinkSchema = z + .object({ + name: z.string().min(1), + category: z.string().min(1), + url: z.string().url().or(z.string().startsWith('stremio:///')), + }) + .passthrough(); + +const MetaVideoSchema = z + .object({ + id: z.string().min(1), + title: z.string().or(z.null()).optional(), + name: z.string().or(z.null()).optional(), + released: z.string().datetime().or(z.null()).optional(), + thumbnail: z.string().url().or(z.null()).optional(), + streams: z.array(StreamSchema).or(z.null()).optional(), + available: z.boolean().or(z.null()).optional(), + episode: z.number().or(z.null()).optional(), + season: z.number().or(z.null()).optional(), + trailers: z.array(TrailerSchema).or(z.null()).optional(), + overview: z.string().or(z.null()).optional(), + }) + .passthrough(); + +export const MetaPreviewSchema = z + .object({ + id: z.string().min(1), + type: z.string().min(1), + name: z.string().or(z.null()).optional(), + poster: z.string().or(z.null()).optional(), + posterShape: z + .enum(['square', 'poster', 'landscape', 'regular']) + .optional(), + // discover sidebar + //@deprecated use links instead + genres: z.array(z.string()).or(z.null()).optional(), + imdbRating: z.string().or(z.null()).or(z.number()).optional(), + releaseInfo: z.string().or(z.number()).or(z.null()).optional(), + //@deprecated + director: z.array(z.string()).or(z.null()).optional(), + //@deprecated + cast: z.array(z.string()).or(z.null()).optional(), + // background: z.string().min(1).optional(), + // logo: z.string().min(1).optional(), + description: z.string().or(z.null()).optional(), + trailers: z.array(TrailerSchema).or(z.null()).optional(), + links: z.array(MetaLinkSchema).or(z.null()).optional(), + // released: z.string().datetime().optional(), + }) + .passthrough(); + +export const MetaSchema = MetaPreviewSchema.extend({ + poster: z.string().or(z.null()).optional(), + background: z.string().or(z.null()).optional(), + logo: z.string().or(z.null()).optional(), + videos: z.array(MetaVideoSchema).or(z.null()).optional(), + runtime: z.coerce.string().or(z.null()).optional(), + language: z.string().or(z.null()).optional(), + country: z.string().or(z.null()).optional(), + awards: z.string().or(z.null()).optional(), + website: z.string().url().or(z.null()).optional(), + behaviorHints: z + .object({ + defaultVideoId: z.string().or(z.null()).optional(), + }) + .optional(), +}).passthrough(); + +export const MetaResponseSchema = z.object({ + meta: MetaSchema, +}); +export const CatalogResponseSchema = z.object({ + metas: z.array(MetaPreviewSchema), +}); +export type MetaResponse = z.infer; +export type CatalogResponse = z.infer; +export type Meta = z.infer; +export type MetaPreview = z.infer; + +export const AddonCatalogSchema = z + .object({ + transportName: z.literal('http'), + transportUrl: z.string().url(), + manifest: ManifestSchema, + }) + .passthrough(); +export const AddonCatalogResponseSchema = z.object({ + addons: z.array(AddonCatalogSchema), +}); +export type AddonCatalogResponse = z.infer; +export type AddonCatalog = z.infer; + +const ParsedFileSchema = z.object({ + releaseGroup: z.string().optional(), + resolution: z.string().optional(), + quality: z.string().optional(), + encode: z.string().optional(), + audioChannels: z.array(z.string()), + visualTags: z.array(z.string()), + audioTags: z.array(z.string()), + languages: z.array(z.string()), + title: z.string().optional(), + year: z.coerce.string().optional(), + season: z.number().optional(), + seasons: z.array(z.number()).optional(), + episode: z.number().optional(), + seasonEpisode: z.array(z.string()).optional(), +}); + +export type ParsedFile = z.infer; + +export const ParsedStreamSchema = z.object({ + id: z.string().min(1), + proxied: z.boolean().optional(), + addon: AddonSchema, + parsedFile: ParsedFileSchema.optional(), + message: z.string().max(1000).optional(), + regexMatched: z + .object({ + name: z.string().optional(), + pattern: z.string().min(1).optional(), + index: z.number(), + }) + .optional(), + keywordMatched: z.boolean().optional(), + streamExpressionMatched: z.number().optional(), + size: z.number().optional(), + folderSize: z.number().optional(), + type: StreamTypes, + indexer: z.string().optional(), + age: z.string().optional(), + torrent: z + .object({ + infoHash: z.string().min(1).optional(), + fileIdx: z.number().optional(), + seeders: z.number().optional(), + sources: z.array(z.string().min(1)).optional(), // array of tracker urls and DHT nodes + }) + .optional(), + countryWhitelist: z.array(z.string().length(3)).optional(), + notWebReady: z.boolean().optional(), + bingeGroup: z.string().min(1).optional(), + requestHeaders: z.record(z.string().min(1), z.string().min(1)).optional(), + responseHeaders: z.record(z.string().min(1), z.string().min(1)).optional(), + videoHash: z.string().min(1).optional(), + subtitles: z.array(SubtitleSchema).optional(), + filename: z.string().optional(), + folderName: z.string().optional(), + service: z + .object({ + id: z.enum(constants.SERVICES), + cached: z.boolean(), + }) + .optional(), + duration: z.number().optional(), + library: z.boolean().optional(), + url: z.string().url().optional(), + ytId: z.string().min(1).optional(), + externalUrl: z.string().min(1).optional(), + error: z + .object({ + title: z.string().min(1), + description: z.string().min(1), + }) + .optional(), + originalName: z.string().optional(), + originalDescription: z.string().optional(), +}); + +export const ParsedStreams = z.array(ParsedStreamSchema); + +export type ParsedStream = z.infer; +export type ParsedStreams = z.infer; + +export const AIOStream = StreamSchema.extend({ + streamData: z.object({ + error: z + .object({ + title: z.string().min(1), + description: z.string().min(1), + }) + .optional(), + proxied: z.boolean().optional(), + addon: z.string().optional(), + filename: z.string().optional(), + folderName: z.string().optional(), + service: z + .object({ + id: z.enum(constants.SERVICES), + cached: z.boolean(), + }) + .optional(), + parsedFile: ParsedFileSchema.optional(), + message: z.string().max(1000).optional(), + regexMatched: z + .object({ + name: z.string().optional(), + pattern: z.string().min(1).optional(), + index: z.number(), + }) + .optional(), + keywordMatched: z.boolean().optional(), + streamExpressionMatched: z.number().optional(), + size: z.number().optional(), + folderSize: z.number().optional(), + type: StreamTypes.optional(), + indexer: z.string().optional(), + age: z.string().optional(), + torrent: z + .object({ + infoHash: z.string().min(1).optional(), + fileIdx: z.number().optional(), + seeders: z.number().optional(), + sources: z.array(z.string().min(1)).optional(), // array of tracker urls and DHT nodes + }) + .optional(), + duration: z.number().optional(), + library: z.boolean().optional(), + }), +}); + +export type AIOStream = z.infer; + +const AIOStreamResponseSchema = z.object({ + streams: z.array(AIOStream), +}); +export type AIOStreamResponse = z.infer; + +const PresetMetadataSchema = z.object({ + ID: z.string(), + NAME: z.string(), + DISABLED: z + .object({ + reason: z.string(), + disabled: z.boolean(), + }) + .optional(), + LOGO: z.string().optional(), + DESCRIPTION: z.string(), + URL: z.string(), + TIMEOUT: z.number(), + USER_AGENT: z.string(), + SUPPORTED_SERVICES: z.array(z.string()), + OPTIONS: z.array(OptionDefinition), + SUPPORTED_STREAM_TYPES: z.array(StreamTypes), + SUPPORTED_RESOURCES: z.array(ResourceSchema), +}); + +const PresetMinimalMetadataSchema = z.object({ + ID: z.string(), + NAME: z.string(), + LOGO: z.string().optional(), + DESCRIPTION: z.string(), + URL: z.string(), + DISABLED: z + .object({ + reason: z.string(), + disabled: z.boolean(), + }) + .optional(), + SUPPORTED_RESOURCES: z.array(ResourceSchema), + SUPPORTED_STREAM_TYPES: z.array(StreamTypes), + SUPPORTED_SERVICES: z.array(z.string()), + OPTIONS: z.array(OptionDefinition), +}); + +const StatusResponseSchema = z.object({ + version: z.string(), + tag: z.string(), + commit: z.string(), + buildTime: z.string(), + commitTime: z.string(), + users: z.number().or(z.null()), + settings: z.object({ + baseUrl: z.string().url().optional(), + addonName: z.string(), + customHtml: z.string().optional(), + protected: z.boolean(), + regexFilterAccess: z.enum(['none', 'trusted', 'all']), + tmdbApiAvailable: z.boolean(), + forced: z.object({ + proxy: z.object({ + enabled: z.boolean().or(z.null()), + id: z.string().or(z.null()), + url: z.string().or(z.null()), + publicIp: z.string().or(z.null()), + credentials: z.string().or(z.null()), + disableProxiedAddons: z.boolean(), + proxiedServices: z.array(z.string()).or(z.null()), + }), + }), + defaults: z.object({ + proxy: z.object({ + enabled: z.boolean().or(z.null()), + id: z.string().or(z.null()), + url: z.string().or(z.null()), + publicIp: z.string().or(z.null()), + credentials: z.string().or(z.null()), + proxiedServices: z.array(z.string()).or(z.null()), + }), + timeout: z.number().or(z.null()), + }), + presets: z.array(PresetMinimalMetadataSchema), + services: z.record( + z.enum(constants.SERVICES), + z.object({ + id: z.enum(constants.SERVICES), + name: z.string(), + shortName: z.string(), + knownNames: z.array(z.string()), + signUpText: z.string(), + credentials: z.array(OptionDefinition), + }) + ), + }), +}); + +export type StatusResponse = z.infer; +export type PresetMetadata = z.infer; +export type PresetMinimalMetadata = z.infer; + +export const RPDBIsValidResponse = z.object({ + valid: z.boolean(), +}); +export type RPDBIsValidResponse = z.infer; diff --git a/packages/core/src/db/users.ts b/packages/core/src/db/users.ts new file mode 100644 index 0000000000000000000000000000000000000000..b41ebe187e15eafb7422d2a23a3937b6c6213bf5 --- /dev/null +++ b/packages/core/src/db/users.ts @@ -0,0 +1,387 @@ +// import { UserDataSchema, UserData, DB } from '../db'; +import { UserDataSchema, UserData } from './schemas'; +import { TransactionQueue } from './queue'; +import { DB } from './db'; +import { + decryptString, + deriveKey, + encryptString, + generateUUID, + getTextHash, + maskSensitiveInfo, + createLogger, + constants, + Env, + verifyHash, + validateConfig, + formatZodError, +} from '../utils'; + +const APIError = constants.APIError; +const logger = createLogger('users'); +const db = DB.getInstance(); +const txQueue = TransactionQueue.getInstance(); + +export class UserRepository { + static async createUser( + config: UserData, + password: string + ): Promise<{ uuid: string; encryptedPassword: string }> { + return txQueue.enqueue(async () => { + if (password.length < 6) { + return Promise.reject( + new APIError(constants.ErrorCode.USER_NEW_PASSWORD_TOO_SHORT) + ); + } + let validatedConfig: UserData; + if (Env.ADDON_PASSWORD && config.addonPassword !== Env.ADDON_PASSWORD) { + return Promise.reject( + new APIError(constants.ErrorCode.USER_INVALID_PASSWORD) + ); + } + config.trusted = false; + try { + // don't skip errors, but don't decrypt credentials + // as we need to store the encrypted version + validatedConfig = await validateConfig(config, false, false); + } catch (error: any) { + logger.error(`Invalid config for new user: ${error.message}`); + return Promise.reject( + new APIError( + constants.ErrorCode.USER_INVALID_CONFIG, + undefined, + error.message + ) + ); + } + + const uuid = await this.generateUUID(); + + const { encryptedConfig, salt: configSalt } = await this.encryptConfig( + validatedConfig, + password + ); + const hashedPassword = await getTextHash(password); + + const { success, data } = encryptString(password); + if (success === false) { + return Promise.reject(constants.ErrorCode.USER_ERROR); + } + + const encryptedPassword = data; + let tx; + let committed = false; + try { + tx = await db.begin(); + await tx.execute( + 'INSERT INTO users (uuid, password_hash, config, config_salt) VALUES (?, ?, ?, ?)', + [uuid, hashedPassword, encryptedConfig, configSalt] + ); + await tx.commit(); + committed = true; + logger.info(`Created a new user with UUID: ${uuid}`); + return { uuid, encryptedPassword }; + } catch (error) { + logger.error( + `Failed to create user: ${error instanceof Error ? error.message : String(error)}` + ); + if (error instanceof APIError) { + throw error; + } + throw new APIError(constants.ErrorCode.INTERNAL_SERVER_ERROR); + } finally { + if (tx && !committed) { + await tx.rollback(); + } + } + }); + } + + static async checkUserExists(uuid: string): Promise { + try { + const result = await db.query('SELECT uuid FROM users WHERE uuid = ?', [ + uuid, + ]); + return result.length > 0; + } catch (error) { + logger.error(`Error checking user existence: ${error}`); + return Promise.reject(constants.ErrorCode.USER_ERROR); + } + } + + // with stremio auth, we are given the encrypted password + // with api use, we are given the password + // GET /user should also return + + static async getUser( + uuid: string, + password: string + ): Promise { + try { + const result = await db.query( + 'SELECT config, config_salt, password_hash FROM users WHERE uuid = ?', + [uuid] + ); + + if (!result.length || !result[0].config) { + return Promise.reject(new APIError(constants.ErrorCode.USER_NOT_FOUND)); + } + + await db.execute( + 'UPDATE users SET accessed_at = CURRENT_TIMESTAMP WHERE uuid = ?', + [uuid] + ); + + const isValid = await this.verifyUserPassword( + password, + result[0].password_hash + ); + if (!isValid) { + return Promise.reject( + new APIError(constants.ErrorCode.USER_INVALID_PASSWORD) + ); + } + + const decryptedConfig = await this.decryptConfig( + result[0].config, + password, + result[0].config_salt + ); + + // try { + // // skip errors, and dont decrypt credentials either, as this would make + // // encryption pointless + // validatedConfig = await validateConfig(decryptedConfig, true, false); + // } catch (error: any) { + // return Promise.reject( + // new APIError( + // constants.ErrorCode.USER_INVALID_CONFIG, + // undefined, + // error.message + // ) + // ); + // } + // const { + // success, + // data: validatedConfig, + // error, + // } = UserDataSchema.safeParse(decryptedConfig); + // if (!success) { + // return Promise.reject( + // new APIError( + // constants.ErrorCode.USER_INVALID_CONFIG, + // undefined, + // formatZodError(error) + // ) + // ); + // } + decryptedConfig.trusted = + Env.TRUSTED_UUIDS?.split(',').some((u) => new RegExp(u).test(uuid)) ?? + false; + logger.info(`Retrieved configuration for user ${uuid}`); + return decryptedConfig; + } catch (error) { + logger.error( + `Error retrieving user ${uuid}: ${error instanceof Error ? error.message : String(error)}` + ); + return Promise.reject( + new APIError(constants.ErrorCode.INTERNAL_SERVER_ERROR) + ); + } + } + + static async updateUser( + uuid: string, + password: string, + config: UserData + ): Promise { + return txQueue.enqueue(async () => { + let tx; + let committed = false; + try { + tx = await db.begin(); + const currentUser = await tx.execute( + 'SELECT config_salt, password_hash FROM users WHERE uuid = ?', + [uuid] + ); + + if (!currentUser.rows.length) { + throw new APIError(constants.ErrorCode.USER_NOT_FOUND); + } + + if (Env.ADDON_PASSWORD && config.addonPassword !== Env.ADDON_PASSWORD) { + throw new APIError( + constants.ErrorCode.USER_INVALID_PASSWORD, + undefined, + 'Invalid password' + ); + } + let validatedConfig: UserData; + try { + validatedConfig = await validateConfig(config, false, false); + } catch (error: any) { + throw new APIError( + constants.ErrorCode.USER_INVALID_CONFIG, + undefined, + error.message + ); + } + const storedHash = currentUser.rows[0].password_hash; + const isValid = await this.verifyUserPassword(password, storedHash); + if (!isValid) { + throw new APIError(constants.ErrorCode.USER_INVALID_PASSWORD); + } + const { encryptedConfig } = await this.encryptConfig( + validatedConfig, + password, + currentUser.rows[0].config_salt + ); + await tx.execute( + 'UPDATE users SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE uuid = ?', + [encryptedConfig, uuid] + ); + await tx.commit(); + committed = true; + logger.info(`Updated user ${uuid} with an updated configuration`); + } catch (error) { + logger.error( + `Failed to update user ${uuid}: ${error instanceof Error ? error.message : String(error)}` + ); + if (error instanceof APIError) { + throw error; + } + throw new APIError(constants.ErrorCode.INTERNAL_SERVER_ERROR); + } finally { + if (tx && !committed) { + await tx.rollback(); + } + } + }); + } + + static async getUserCount(): Promise { + try { + const result = await db.query('SELECT * FROM users'); + return result.length; + } catch (error) { + logger.error(`Error getting user count: ${error}`); + return Promise.reject(new APIError(constants.ErrorCode.USER_ERROR)); + } + } + + static async deleteUser(uuid: string): Promise { + return txQueue.enqueue(async () => { + let tx; + let committed = false; + try { + tx = await db.begin(); + const result = await tx.execute('DELETE FROM users WHERE uuid = ?', [ + uuid, + ]); + + if (result.rowCount === 0) { + throw new APIError(constants.ErrorCode.USER_NOT_FOUND); + } + + await tx.commit(); + committed = true; + logger.info(`Deleted user ${uuid}`); + } catch (error) { + logger.error( + `Failed to delete user ${uuid}: ${error instanceof Error ? error.message : String(error)}` + ); + if (error instanceof APIError) { + throw error; + } + throw new APIError(constants.ErrorCode.INTERNAL_SERVER_ERROR); + } finally { + if (tx && !committed) { + await tx.rollback(); + } + } + }); + } + + static async pruneUsers(maxDays: number = 30): Promise { + if (maxDays < 0) { + return 0; + } + try { + const query = + db.getDialect() === 'postgres' + ? `DELETE FROM users WHERE accessed_at < NOW() - INTERVAL '${maxDays} days'` + : `DELETE FROM users WHERE accessed_at < datetime('now', '-' || ${maxDays} || ' days')`; + + const result = await db.execute(query); + const deletedCount = result.changes || result.rowCount || 0; + logger.info(`Pruned ${deletedCount} users older than ${maxDays} days`); + return deletedCount; + } catch (error) { + logger.error('Failed to prune users:', error); + return Promise.reject(new APIError(constants.ErrorCode.USER_ERROR)); + } + } + + private static async verifyUserPassword( + password: string, + storedHash: string + ): Promise { + return verifyHash(password, storedHash); + } + + private static async encryptConfig( + config: UserData, + password: string, + salt?: string + ): Promise<{ + encryptedConfig: string; + salt: string; + }> { + const { key, salt: saltUsed } = await deriveKey( + `${password}:${Env.SECRET_KEY}`, + salt + ); + const configString = JSON.stringify(config); + const { success, data, error } = encryptString(configString, key); + + if (!success) { + return Promise.reject(new APIError(constants.ErrorCode.USER_ERROR)); + } + + return { encryptedConfig: data, salt: saltUsed }; + } + + private static async decryptConfig( + encryptedConfig: string, + password: string, + salt: string + ): Promise { + const { key } = await deriveKey(`${password}:${Env.SECRET_KEY}`, salt); + const { + success, + data: decryptedString, + error, + } = decryptString(encryptedConfig, key); + + if (!success || !decryptedString) { + return Promise.reject(new APIError(constants.ErrorCode.USER_ERROR)); + } + + return JSON.parse(decryptedString); + } + + private static async generateUUID(count: number = 1): Promise { + if (count > 10) { + return Promise.reject(new APIError(constants.ErrorCode.USER_ERROR)); + } + + const uuid = generateUUID(); + const existingUser = await this.checkUserExists(uuid); + + if (existingUser) { + return this.generateUUID(count + 1); + } + + return uuid; + } +} diff --git a/packages/core/src/db/utils.ts b/packages/core/src/db/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..a239c630fb53b225439c63af3af18ad59cbaa82c --- /dev/null +++ b/packages/core/src/db/utils.ts @@ -0,0 +1,77 @@ +import { URL } from 'url'; +import { createLogger } from '../utils/logger'; +import path from 'path'; + +type BaseConnectionURI = { + url: URL; + driverName: string; +}; + +type PostgresConnectionURI = BaseConnectionURI & { + dialect: 'postgres'; +}; + +type SQLiteConnectionURI = BaseConnectionURI & { + filename: string; + dialect: 'sqlite'; +}; + +export type ConnectionURI = PostgresConnectionURI | SQLiteConnectionURI; + +type DBDialect = 'postgres' | 'sqlite'; + +type DSNModifier = (url: URL, query: URLSearchParams) => void; +const logger = createLogger('database'); + +function parseConnectionURI(uri: string): ConnectionURI { + const url = new URL(uri); + let driverName: string; + let dialect: DBDialect; + + switch (url.protocol) { + case 'sqlite:': { + driverName = 'sqlite3'; + dialect = 'sqlite'; + let filename = url.pathname; + if (url.hostname && url.hostname !== '.') { + throw new Error("Invalid path, must start with '/' or './'"); + } + if (!url.pathname) { + throw new Error('Invalid path, must be absolute'); + } + if (url.hostname === '.') { + // resolve relative path using process.cwd() + filename = path.join(process.cwd(), url.pathname.replace(/^\//, '')); + } + return { + url, + driverName, + filename: filename, + dialect, + }; + } + case 'postgres:': { + driverName = 'pg'; + dialect = 'postgres'; + return { + url, + + driverName, + dialect, + }; + } + default: + throw new Error('Unsupported scheme: ' + url.protocol); + } +} + +function adaptQuery(query: string, dialect: DBDialect): string { + if (dialect === 'sqlite') { + return query; + } + + let position = 1; + return query.replace(/\?/g, () => `$${position++}`); +} + +export { parseConnectionURI, adaptQuery }; diff --git a/packages/core/src/formatters/Intl.d.ts b/packages/core/src/formatters/Intl.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..817f2a330e2cc1714e8808aa13187ed64382554c --- /dev/null +++ b/packages/core/src/formatters/Intl.d.ts @@ -0,0 +1,12 @@ +// vs code... +declare namespace Intl { + type Key = + | 'calendar' + | 'collation' + | 'currency' + | 'numberingSystem' + | 'timeZone' + | 'unit'; + + function supportedValuesOf(input: Key): string[]; +} diff --git a/packages/core/src/formatters/base.ts b/packages/core/src/formatters/base.ts new file mode 100644 index 0000000000000000000000000000000000000000..97aadc33b863e6d7ec707949890fc6793a94504a --- /dev/null +++ b/packages/core/src/formatters/base.ts @@ -0,0 +1,600 @@ +import { ParsedStream } from '../db'; +// import { constants, Env, createLogger } from '../utils'; +import * as constants from '../utils/constants'; +import { createLogger } from '../utils/logger'; +import { formatBytes, formatDuration, languageToEmoji } from './utils'; +import { Env } from '../utils/env'; + +const logger = createLogger('formatter'); + +/** + * + * The custom formatter code in this file was adapted from https://github.com/diced/zipline/blob/trunk/src/lib/parser/index.ts + * + * The original code is licensed under the MIT License. + * + * MIT License + * + * Copyright (c) 2023 dicedtomato + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +export interface FormatterConfig { + name: string; + description: string; +} + +export interface ParseValue { + config?: { + addonName: string | null; + }; + stream?: { + filename: string | null; + folderName: string | null; + size: number | null; + folderSize: number | null; + library: boolean | null; + quality: string | null; + resolution: string | null; + languages: string[] | null; + languageEmojis: string[] | null; + wedontknowwhatakilometeris: string[] | null; + visualTags: string[] | null; + audioTags: string[] | null; + releaseGroup: string | null; + regexMatched: string | null; + encode: string | null; + audioChannels: string[] | null; + indexer: string | null; + year: string | null; + title: string | null; + season: number | null; + seasons: number[] | null; + episode: number | null; + seasonEpisode: string[] | null; + seeders: number | null; + age: string | null; + duration: number | null; + infoHash: string | null; + type: string | null; + message: string | null; + proxied: boolean | null; + }; + service?: { + id: string | null; + shortName: string | null; + name: string | null; + cached: boolean | null; + }; + addon?: { + name: string; + presetId: string; + manifestUrl: string; + }; + debug?: { + json: string | null; + jsonf: string | null; + }; +} + +export abstract class BaseFormatter { + protected config: FormatterConfig; + protected addonName: string; + + constructor(config: FormatterConfig, addonName?: string) { + this.config = config; + this.addonName = addonName || Env.ADDON_NAME; + } + + public format(stream: ParsedStream): { name: string; description: string } { + const parseValue = this.convertStreamToParseValue(stream); + return { + name: this.parseString(this.config.name, parseValue) || '', + description: this.parseString(this.config.description, parseValue) || '', + }; + } + + protected convertStreamToParseValue(stream: ParsedStream): ParseValue { + return { + config: { + addonName: this.addonName, + }, + stream: { + filename: stream.filename || null, + folderName: stream.folderName || null, + size: stream.size || null, + folderSize: stream.folderSize || null, + library: stream.library !== undefined ? stream.library : null, + quality: stream.parsedFile?.quality || null, + resolution: stream.parsedFile?.resolution || null, + languages: stream.parsedFile?.languages || null, + languageEmojis: stream.parsedFile?.languages + ? stream.parsedFile.languages + .map((lang) => languageToEmoji(lang) || lang) + .filter((value, index, self) => self.indexOf(value) === index) + : null, + wedontknowwhatakilometeris: stream.parsedFile?.languages + ? stream.parsedFile.languages + .map((lang) => languageToEmoji(lang) || lang) + .map((emoji) => emoji.replace('🇬🇧', '🇺🇸🦅')) + .filter((value, index, self) => self.indexOf(value) === index) + : null, + visualTags: stream.parsedFile?.visualTags || null, + audioTags: stream.parsedFile?.audioTags || null, + releaseGroup: stream.parsedFile?.releaseGroup || null, + regexMatched: stream.regexMatched?.name || null, + encode: stream.parsedFile?.encode || null, + audioChannels: stream.parsedFile?.audioChannels || null, + indexer: stream.indexer || null, + seeders: stream.torrent?.seeders ?? null, + year: stream.parsedFile?.year || null, + type: stream.type || null, + title: stream.parsedFile?.title || null, + season: stream.parsedFile?.season || null, + seasons: stream.parsedFile?.seasons || null, + episode: stream.parsedFile?.episode || null, + seasonEpisode: stream.parsedFile?.seasonEpisode || null, + duration: stream.duration || null, + infoHash: stream.torrent?.infoHash || null, + age: stream.age || null, + message: stream.message || null, + proxied: stream.proxied !== undefined ? stream.proxied : null, + }, + addon: { + name: stream.addon.name, + presetId: stream.addon.presetType, + manifestUrl: stream.addon.manifestUrl, + }, + service: { + id: stream.service?.id || null, + shortName: stream.service?.id + ? Object.values(constants.SERVICE_DETAILS).find( + (service) => service.id === stream.service?.id + )?.shortName || null + : null, + name: stream.service?.id + ? Object.values(constants.SERVICE_DETAILS).find( + (service) => service.id === stream.service?.id + )?.name || null + : null, + cached: + stream.service?.cached !== undefined ? stream.service?.cached : null, + }, + }; + } + + protected parseString(str: string, value: ParseValue): string | null { + if (!str) return null; + + const replacer = (key: string, value: unknown) => { + return value; + }; + + const data = { + stream: value.stream, + service: value.service, + addon: value.addon, + config: value.config, + }; + + value.debug = { + json: JSON.stringify(data, replacer), + jsonf: JSON.stringify(data, replacer, 2), + }; + + const re = + /\{(?stream|service|addon|config|debug)\.(?\w+)(::(?(\w+(\([^)]*\))?|<|<=|=|>=|>|\^|\$|~|\/)+))?((::(?\S+?))|(?\[(?".*?")\|\|(?".*?")\]))?\}/gi; + let matches: RegExpExecArray | null; + + while ((matches = re.exec(str))) { + if (!matches.groups) continue; + + const index = matches.index as number; + + const getV = value[matches.groups.type as keyof ParseValue]; + + if (!getV) { + str = this.replaceCharsFromString( + str, + '{unknown_type}', + index, + re.lastIndex + ); + re.lastIndex = index; + continue; + } + + const v = + getV[ + matches.groups.prop as + | keyof ParseValue['stream'] + | keyof ParseValue['service'] + | keyof ParseValue['addon'] + ]; + + if (v === undefined) { + str = this.replaceCharsFromString( + str, + '{unknown_value}', + index, + re.lastIndex + ); + re.lastIndex = index; + continue; + } + + if (matches.groups.mod) { + str = this.replaceCharsFromString( + str, + this.modifier( + matches.groups.mod, + v, + matches.groups.mod_tzlocale ?? undefined, + matches.groups.mod_check_true ?? undefined, + matches.groups.mod_check_false ?? undefined, + value + ), + index, + re.lastIndex + ); + re.lastIndex = index; + continue; + } + + str = this.replaceCharsFromString(str, v, index, re.lastIndex); + re.lastIndex = index; + } + + return str + .replace(/\\n/g, '\n') + .split('\n') + .filter( + (line) => line.trim() !== '' && !line.includes('{tools.removeLine}') + ) + .join('\n') + .replace(/\{tools.newLine\}/g, '\n'); + } + + protected modifier( + mod: string, + value: unknown, + tzlocale?: string, + check_true?: string, + check_false?: string, + _value?: ParseValue + ): string { + mod = mod.toLowerCase(); + check_true = check_true?.slice(1, -1); + check_false = check_false?.slice(1, -1); + + if (Array.isArray(value)) { + switch (true) { + case mod === 'join': + return value.join(', '); + case mod.startsWith('join(') && mod.endsWith(')'): { + // Extract the separator from join(separator) + // e.g. join(' - ') + const separator = mod + .substring(5, mod.length - 1) + .replace(/^['"]|['"]$/g, ''); + return value.join(separator); + } + case mod == 'length': + return value.length.toString(); + case mod == 'first': + return value.length > 0 ? String(value[0]) : ''; + case mod == 'last': + return value.length > 0 ? String(value[value.length - 1]) : ''; + case mod == 'random': + return value.length > 0 + ? String(value[Math.floor(Math.random() * value.length)]) + : ''; + case mod == 'sort': + return [...value].sort().join(', '); + case mod == 'reverse': + return [...value].reverse().join(', '); + case mod.startsWith('~'): { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_array_modifier(${mod})}`; + + const check = mod.replace('~', '').replace('_', ' '); + + if (_value) { + return value.some((item) => item.toLowerCase().includes(check)) + ? this.parseString(check_true, _value) || check_true + : this.parseString(check_false, _value) || check_false; + } + + return value.some((item) => item.toLowerCase().includes(check)) + ? check_true + : check_false; + } + case mod == 'exists': { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_array_modifier(${mod})}`; + + if (_value) { + return value.length > 0 + ? this.parseString(check_true, _value) || check_true + : this.parseString(check_false, _value) || check_false; + } + + return value.length > 0 ? check_true : check_false; + } + default: + return `{unknown_array_modifier(${mod})}`; + } + } else if (typeof value === 'string') { + switch (true) { + case mod == 'upper': + return value.toUpperCase(); + case mod == 'lower': + return value.toLowerCase(); + case mod == 'title': { + return value + .split(' ') + .map((word) => word.toLowerCase()) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } + case mod == 'length': + return value.length.toString(); + case mod == 'reverse': + return value.split('').reverse().join(''); + case mod == 'base64': + return btoa(value); + case mod == 'string': + return value; + case mod == 'exists': { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_str_modifier(${mod})}`; + + if (_value) { + return value != 'null' && value + ? this.parseString(check_true, _value) || check_true + : this.parseString(check_false, _value) || check_false; + } + + return value != 'null' && value ? check_true : check_false; + } + case mod.startsWith('='): { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_str_modifier(${mod})}`; + + const check = mod.replace('=', ''); + + if (!check) return `{unknown_str_modifier(${mod})}`; + + if (_value) { + return value.toLowerCase() == check + ? this.parseString(check_true, _value) || check_true + : this.parseString(check_false, _value) || check_false; + } + + return value.toLowerCase() == check ? check_true : check_false; + } + case mod.startsWith('$'): { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_str_modifier(${mod})}`; + + const check = mod.replace('$', ''); + + if (!check) return `{unknown_str_modifier(${mod})}`; + + if (_value) { + return value.toLowerCase().startsWith(check) + ? this.parseString(check_true, _value) || check_true + : this.parseString(check_false, _value) || check_false; + } + + return value.toLowerCase().startsWith(check) + ? check_true + : check_false; + } + case mod.startsWith('^'): { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_str_modifier(${mod})}`; + + const check = mod.replace('^', ''); + + if (!check) return `{unknown_str_modifier(${mod})}`; + + if (_value) { + return value.toLowerCase().endsWith(check) + ? this.parseString(check_true, _value) || check_true + : this.parseString(check_false, _value) || check_false; + } + + return value.toLowerCase().endsWith(check) ? check_true : check_false; + } + case mod.startsWith('~'): { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_str_modifier(${mod})}`; + + const check = mod.replace('~', ''); + + if (!check) return `{unknown_str_modifier(${mod})}`; + + if (_value) { + return value.toLowerCase().includes(check) + ? this.parseString(check_true, _value) || check_true + : this.parseString(check_false, _value) || check_false; + } + + return value.toLowerCase().includes(check) ? check_true : check_false; + } + default: + return `{unknown_str_modifier(${mod})}`; + } + } else if (typeof value === 'number') { + switch (true) { + case mod == 'comma': + return value.toLocaleString(); + case mod == 'hex': + return value.toString(16); + case mod == 'octal': + return value.toString(8); + case mod == 'binary': + return value.toString(2); + case mod == 'bytes10' || mod == 'bytes': + return formatBytes(value, 1000); + case mod == 'bytes2': + return formatBytes(value, 1024); + case mod == 'string': + return value.toString(); + case mod == 'time': + return formatDuration(value); + case mod.startsWith('>='): { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_int_modifier(${mod})}`; + + const check = Number(mod.replace('>=', '')); + + if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`; + + if (_value) { + return value >= check + ? this.parseString(check_true, _value) || check_true + : this.parseString(check_false, _value) || check_false; + } + + return value >= check ? check_true : check_false; + } + case mod.startsWith('>'): { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_int_modifier(${mod})}`; + + const check = Number(mod.replace('>', '')); + + if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`; + + if (_value) { + return value > check + ? this.parseString(check_true, _value) || check_true + : this.parseString(check_false, _value) || check_false; + } + + return value > check ? check_true : check_false; + } + case mod.startsWith('='): { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_int_modifier(${mod})}`; + + const check = Number(mod.replace('=', '')); + + if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`; + + if (_value) { + return value == check + ? this.parseString(check_true, _value) || check_true + : this.parseString(check_false, _value) || check_false; + } + + return value == check ? check_true : check_false; + } + case mod.startsWith('<='): { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_int_modifier(${mod})}`; + + const check = Number(mod.replace('<=', '')); + + if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`; + + if (_value) { + return value <= check + ? this.parseString(check_true, _value) || check_true + : this.parseString(check_false, _value) || check_false; + } + + return value <= check ? check_true : check_false; + } + case mod.startsWith('<'): { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_int_modifier(${mod})}`; + + const check = Number(mod.replace('<', '')); + + if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`; + + if (_value) { + return value < check + ? this.parseString(check_true, _value) || check_true + : this.parseString(check_false, _value) || check_false; + } + + return value < check ? check_true : check_false; + } + default: + return `{unknown_int_modifier(${mod})}`; + } + } else if (typeof value === 'boolean') { + switch (true) { + case mod == 'istrue': { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_bool_modifier(${mod})}`; + + if (_value) { + return value + ? this.parseString(check_true, _value) || check_true + : this.parseString(check_false, _value) || check_false; + } + + return value ? check_true : check_false; + } + case mod == 'isfalse': { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_bool_modifier(${mod})}`; + + if (_value) { + return !value + ? this.parseString(check_true, _value) || check_true + : this.parseString(check_false, _value) || check_false; + } + + return !value ? check_true : check_false; + } + default: + return `{unknown_bool_modifier(${mod})}`; + } + } + + if ( + typeof check_false == 'string' && + (['>', '>=', '=', '<=', '<', '~', '$', '^'].some((modif) => + mod.startsWith(modif) + ) || + ['istrue', 'exists', 'isfalse'].includes(mod)) + ) { + if (_value) return this.parseString(check_false, _value) || check_false; + return check_false; + } + + return `{unknown_modifier(${mod})}`; + } + + protected replaceCharsFromString( + str: string, + replace: string, + start: number, + end: number + ): string { + return str.slice(0, start) + replace + str.slice(end); + } +} diff --git a/packages/core/src/formatters/custom.ts b/packages/core/src/formatters/custom.ts new file mode 100644 index 0000000000000000000000000000000000000000..be11456f410901783dc3909945236674134c5426 --- /dev/null +++ b/packages/core/src/formatters/custom.ts @@ -0,0 +1,38 @@ +import { BaseFormatter, FormatterConfig } from './base'; + +export class CustomFormatter extends BaseFormatter { + constructor( + nameTemplate: string, + descriptionTemplate: string, + addonName?: string + ) { + super( + { + name: nameTemplate, + description: descriptionTemplate, + }, + addonName + ); + } + + public static fromConfig( + config: FormatterConfig, + addonName: string | undefined + ): CustomFormatter { + return new CustomFormatter(config.name, config.description, addonName); + } + + public updateTemplate( + nameTemplate: string, + descriptionTemplate: string + ): void { + this.config = { + name: nameTemplate, + description: descriptionTemplate, + }; + } + + public getTemplate(): FormatterConfig { + return this.config; + } +} diff --git a/packages/core/src/formatters/index.ts b/packages/core/src/formatters/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..56b981c1a954def60c034d7ceaeb81b6e736c75b --- /dev/null +++ b/packages/core/src/formatters/index.ts @@ -0,0 +1,41 @@ +export * from './base'; +export * from './predefined'; +export * from './custom'; +export * from './utils'; + +import { BaseFormatter, FormatterConfig } from './base'; +import { + TorrentioFormatter, + TorboxFormatter, + GDriveFormatter, + LightGDriveFormatter, + MinimalisticGdriveFormatter, +} from './predefined'; +import { CustomFormatter } from './custom'; +import { FormatterType } from '../utils/constants'; + +export function createFormatter( + type: FormatterType, + config?: FormatterConfig, + addonName?: string +): BaseFormatter { + switch (type) { + case 'torrentio': + return new TorrentioFormatter(addonName); + case 'torbox': + return new TorboxFormatter(addonName); + case 'gdrive': + return new GDriveFormatter(addonName); + case 'lightgdrive': + return new LightGDriveFormatter(addonName); + case 'minimalisticgdrive': + return new MinimalisticGdriveFormatter(addonName); + case 'custom': + if (!config) { + throw new Error('Config is required for custom formatter'); + } + return CustomFormatter.fromConfig(config, addonName); + default: + throw new Error(`Unknown formatter type: ${type}`); + } +} diff --git a/packages/core/src/formatters/predefined.ts b/packages/core/src/formatters/predefined.ts new file mode 100644 index 0000000000000000000000000000000000000000..f4389f5c763ce0d06c7d8ec912f08d5e5770f913 --- /dev/null +++ b/packages/core/src/formatters/predefined.ts @@ -0,0 +1,97 @@ +import { BaseFormatter, FormatterConfig } from './base'; + +export class TorrentioFormatter extends BaseFormatter { + constructor(addonName?: string) { + super( + { + name: ` +{stream.proxied::istrue["🕵️‍♂️ "||""]}{stream.type::=p2p["[P2P] "||""]}{service.id::exists["[{service.shortName}"||""]}{service.cached::istrue["+] "||""]}{service.cached::isfalse[" download] "||""]}{addon.name} {stream.resolution::exists["{stream.resolution}"||"Unknown"]} +{stream.visualTags::exists["{stream.visualTags::join(' | ')}"||""]} +`, + description: ` +{stream.message::exists["ℹ️{stream.message}"||""]} +{stream.folderName::exists["{stream.folderName}"||""]} +{stream.filename::exists["{stream.filename}"||""]} +{stream.size::>0["💾{stream.size::bytes2} "||""]}{stream.folderSize::>0["/ 💾{stream.folderSize::bytes2}"||""]}{stream.seeders::>=0["👤{stream.seeders} "||""]}{stream.age::exists["📅{stream.age} "||""]}{stream.indexer::exists["⚙️{stream.indexer}"||""]} +{stream.languageEmojis::exists["{stream.languageEmojis::join( / ')}"||""]} +`, + }, + addonName + ); + } +} + +export class TorboxFormatter extends BaseFormatter { + constructor(addonName?: string) { + super( + { + name: ` +{stream.proxied::istrue["🕵️‍♂️ "||""]}{stream.type::=p2p["[P2P] "||""]}{addon.name}{stream.library::istrue[" (Your Media) "||""]}{service.cached::istrue[" (Instant "||""]}{service.cached::isfalse[" ("||""]}{service.id::exists["{service.shortName})"||""]}{stream.resolution::exists[" ({stream.resolution})"||""]} + `, + description: ` +Quality: {stream.quality::exists["{stream.quality}"||"Unknown"]} +Name: {stream.filename::exists["{stream.filename}"||"Unknown"]} +Size: {stream.size::>0["{stream.size::bytes} "||""]}{stream.folderSize::>0["/ {stream.folderSize::bytes} "||""]}{stream.indexer::exists["| Source: {stream.indexer} "||""]}{stream.duration::>0["| Duration: {stream.duration::time} "||""]} +Language: {stream.languages::exists["{stream.languages::join(', ')}"||""]} +Type: {stream.type::title}{stream.seeders::>=0[" | Seeders: {stream.seeders}"||""]}{stream.age::exists[" | Age: {stream.age}"||""]} +{stream.message::exists["Message: {stream.message}"||""]} + `, + }, + addonName + ); + } +} + +export class GDriveFormatter extends BaseFormatter { + constructor(addonName?: string) { + super( + { + name: ` +{stream.proxied::istrue["🕵️ "||""]}{stream.type::=p2p["[P2P] "||""]}{service.shortName::exists["[{service.shortName}"||""]}{service.cached::istrue["⚡] "||""]}{service.cached::isfalse["⏳] "||""]}{addon.name}{stream.library::istrue[" (Your Media)"||""]} {stream.resolution::exists["{stream.resolution}"||""]}{stream.regexMatched::exists[" ({stream.regexMatched})"||""]} + `, + description: ` +{stream.quality::exists["🎥 {stream.quality} "||""]}{stream.encode::exists["🎞️ {stream.encode} "||""]}{stream.releaseGroup::exists["🏷️ {stream.releaseGroup}"||""]} +{stream.visualTags::exists["📺 {stream.visualTags::join(' | ')} "||""]}{stream.audioTags::exists["🎧 {stream.audioTags::join(' | ')} "||""]}{stream.audioChannels::exists["🔊 {stream.audioChannels::join(' | ')}"||""]} +{stream.size::>0["📦 {stream.size::bytes} "||""]}{stream.folderSize::>0["/ 📦 {stream.folderSize::bytes}"||""]}{stream.duration::>0["⏱️ {stream.duration::time} "||""]}{stream.seeders::>0["👥 {stream.seeders} "||""]}{stream.age::exists["📅 {stream.age} "||""]}{stream.indexer::exists["🔍 {stream.indexer}"||""]} +{stream.languages::exists["🌎 {stream.languages::join(' | ')}"||""]} +{stream.filename::exists["📁"||""]} {stream.folderName::exists["{stream.folderName}/"||""]}{stream.filename::exists["{stream.filename}"||""]} +{stream.message::exists["ℹ️ {stream.message}"||""]} + `, + }, + addonName + ); + } +} + +export class LightGDriveFormatter extends BaseFormatter { + constructor(addonName?: string) { + super( + { + name: ` +{stream.proxied::istrue["🕵️ "||""]}{stream.type::=p2p["[P2P] "||""]}{service.shortName::exists["[{service.shortName}"||""]}{stream.library::istrue["☁️"||""]}{service.cached::istrue["⚡] "||""]}{service.cached::isfalse["⏳] "||""]}{addon.name}{stream.resolution::exists[" {stream.resolution}"||""]}{stream.regexMatched::exists[" ({stream.regexMatched})"||""]} +`, + description: ` +{stream.message::exists["ℹ️ {stream.message}"||""]} +{stream.title::exists["📁 {stream.title::title}"||""]}{stream.year::exists[" ({stream.year})"||""]}{stream.season::>=0[" S"||""]}{stream.season::<=9["0"||""]}{stream.season::>0["{stream.season}"||""]}{stream.episode::>=0[" • E"||""]}{stream.episode::<=9["0"||""]}{stream.episode::>0["{stream.episode}"||""]} +{stream.quality::exists["🎥 {stream.quality} "||""]}{stream.encode::exists["🎞️ {stream.encode} "||""]}{stream.releaseGroup::exists["🏷️ {stream.releaseGroup}"||""]} +{stream.visualTags::exists["📺 {stream.visualTags::join(' • ')} "||""]}{stream.audioTags::exists["🎧 {stream.audioTags::join(' • ')} "||""]}{stream.audioChannels::exists["🔊 {stream.audioChannels::join(' • ')}"||""]} +{stream.size::>0["📦 {stream.size::bytes} "||""]}{stream.folderSize::>0["/ 📦 {stream.folderSize::bytes}"||""]}{stream.duration::>0["⏱️ {stream.duration::time} "||""]}{stream.age::exists["📅 {stream.age} "||""]}{stream.indexer::exists["🔍 {stream.indexer}"||""]} +{stream.languageEmojis::exists["🌐 {stream.languageEmojis::join(' / ')}"||""]} +`, + }, + addonName + ); + } +} + +export class MinimalisticGdriveFormatter extends BaseFormatter { + constructor(addonName?: string) { + super( + { + name: '{stream.title} {stream.quality}', + description: '{stream.size::bytes} {stream.seeders} seeders', + }, + addonName + ); + } +} diff --git a/packages/core/src/formatters/utils.ts b/packages/core/src/formatters/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..0085c09b9dbd6b0c2c0579dcb14f5e1a21187430 --- /dev/null +++ b/packages/core/src/formatters/utils.ts @@ -0,0 +1,148 @@ +export function formatBytes(bytes: number, k: 1024 | 1000): string { + if (bytes === 0) return '0 B'; + const sizes = + k === 1024 + ? ['B', 'KiB', 'MiB', 'GiB', 'TiB'] + : ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +export function formatDuration(durationInMs: number): string { + const seconds = Math.floor(durationInMs / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + const formattedSeconds = seconds % 60; + const formattedMinutes = minutes % 60; + + if (hours > 0) { + return `${hours}h:${formattedMinutes}m:${formattedSeconds}s`; + } else if (formattedSeconds > 0) { + return `${formattedMinutes}m:${formattedSeconds}s`; + } else { + return `${formattedMinutes}m`; + } +} + +export function languageToEmoji(language: string): string | undefined { + return languageEmojiMap[language.toLowerCase()]; +} + +export function emojiToLanguage(emoji: string): string | undefined { + return Object.entries(languageEmojiMap).find( + ([_, value]) => value === emoji + )?.[0]; +} + +export function codeToLanguage(code: string): string | undefined { + return codeLanguageMap[code]; +} +/** + * A mapping of language names to their corresponding emoji flags. + * + * This mapping was adapted from the g0ldy/comet project. + * https://github.com/g0ldyy/comet/blob/de5413425ac30a9d88bc7176862a7ff02027eb7f/comet/utils/general.py#L19C1-L19C18 + */ +const languageEmojiMap: Record = { + multi: '🌎', + english: '🇬🇧', + japanese: '🇯🇵', + chinese: '🇨🇳', + russian: '🇷🇺', + arabic: '🇸🇦', + portuguese: '🇵🇹', + spanish: '🇪🇸', + french: '🇫🇷', + german: '🇩🇪', + italian: '🇮🇹', + korean: '🇰🇷', + hindi: '🇮🇳', + bengali: '🇧🇩', + punjabi: '🇵🇰', + marathi: '🇮🇳', + gujarati: '🇮🇳', + tamil: '🇮🇳', + telugu: '🇮🇳', + kannada: '🇮🇳', + malayalam: '🇮🇳', + thai: '🇹🇭', + vietnamese: '🇻🇳', + indonesian: '🇮🇩', + turkish: '🇹🇷', + hebrew: '🇮🇱', + persian: '🇮🇷', + ukrainian: '🇺🇦', + greek: '🇬🇷', + lithuanian: '🇱🇹', + latvian: '🇱🇻', + estonian: '🇪🇪', + polish: '🇵🇱', + czech: '🇨🇿', + slovak: '🇸🇰', + hungarian: '🇭🇺', + romanian: '🇷🇴', + bulgarian: '🇧🇬', + serbian: '🇷🇸', + croatian: '🇭🇷', + slovenian: '🇸🇮', + dutch: '🇳🇱', + danish: '🇩🇰', + finnish: '🇫🇮', + swedish: '🇸🇪', + norwegian: '🇳🇴', + malay: '🇲🇾', + latino: '💃🏻', + Latino: '🇲🇽', +}; + +const codeLanguageMap: Record = { + EN: 'english', + JA: 'japanese', + ZH: 'chinese', + RU: 'russian', + AR: 'arabic', + PT: 'portuguese', + ES: 'spanish', + FR: 'french', + DE: 'german', + IT: 'italian', + KO: 'korean', + HI: 'hindi', + BN: 'bengali', + PA: 'punjabi', + MR: 'marathi', + GU: 'gujarati', + TA: 'tamil', + TE: 'telugu', + KN: 'kannada', + ML: 'malayalam', + TH: 'thai', + VI: 'vietnamese', + ID: 'indonesian', + TR: 'turkish', + HE: 'hebrew', + FA: 'persian', + UK: 'ukrainian', + EL: 'greek', + LT: 'lithuanian', + LV: 'latvian', + ET: 'estonian', + PL: 'polish', + CS: 'czech', + SK: 'slovak', + HU: 'hungarian', + RO: 'romanian', + BG: 'bulgarian', + SR: 'serbian', + HR: 'croatian', + SL: 'slovenian', + NL: 'dutch', + DA: 'danish', + FI: 'finnish', + SV: 'swedish', + NO: 'norwegian', + MS: 'malay', + LA: 'latino', + MX: 'Latino', +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..0aaec49ccbc121508b2054e8381d60aff57dc9b8 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,7 @@ +export * from './utils'; +export * from './db'; +export * from './main'; +export * from './parser'; +export * from './formatters'; +export * from './transformers'; +export { PresetManager } from './presets'; diff --git a/packages/core/src/main.ts b/packages/core/src/main.ts new file mode 100644 index 0000000000000000000000000000000000000000..6d3fa051ce219c614a98155272436884f6c6716b --- /dev/null +++ b/packages/core/src/main.ts @@ -0,0 +1,1089 @@ +import { + Addon, + Manifest, + Resource, + StrictManifestResource, + UserData, +} from './db'; +import { + constants, + createLogger, + Env, + getSimpleTextHash, + getTimeTakenSincePoint, + maskSensitiveInfo, + Cache, +} from './utils'; +import { Wrapper } from './wrapper'; +import { PresetManager } from './presets'; +import { + AddonCatalog, + Meta, + MetaPreview, + ParsedStream, + Subtitle, +} from './db/schemas'; +import { createProxy } from './proxy'; +import { RPDB } from './utils/rpdb'; +import { FeatureControl } from './utils/feature'; +import Proxifier from './streams/proxifier'; +import StreamLimiter from './streams/limiter'; +import { + StreamFetcher as Fetcher, + StreamFilterer as Filterer, + StreamSorter as Sorter, + StreamDeduplicator as Deduplicator, + StreamPrecomputer as Precomputer, + StreamUtils, +} from './streams'; +import { getAddonName } from './utils/general'; +const logger = createLogger('core'); + +const shuffleCache = Cache.getInstance('shuffle'); + +export interface AIOStreamsError { + title?: string; + description?: string; +} + +export interface AIOStreamsResponse { + success: boolean; + data: T; + errors: AIOStreamsError[]; +} + +export class AIOStreams { + private userData: UserData; + private manifests: Record; + private supportedResources: Record; + private finalResources: StrictManifestResource[] = []; + private finalCatalogs: Manifest['catalogs'] = []; + private finalAddonCatalogs: Manifest['addonCatalogs'] = []; + private isInitialised: boolean = false; + private addons: Addon[] = []; + private skipFailedAddons: boolean = true; + private proxifier: Proxifier; + private limiter: StreamLimiter; + private streamUtils: StreamUtils; + private fetcher: Fetcher; + private filterer: Filterer; + private deduplicator: Deduplicator; + private sorter: Sorter; + private precomputer: Precomputer; + + private addonInitialisationErrors: { + addon: Addon; + error: string; + }[] = []; + + constructor(userData: UserData, skipFailedAddons: boolean = true) { + this.addonInitialisationErrors = []; + this.userData = userData; + this.manifests = {}; + this.supportedResources = {}; + this.skipFailedAddons = skipFailedAddons; + this.proxifier = new Proxifier(userData); + this.limiter = new StreamLimiter(userData); + this.streamUtils = new StreamUtils(); + this.fetcher = new Fetcher(userData); + this.filterer = new Filterer(userData); + this.deduplicator = new Deduplicator(userData); + this.sorter = new Sorter(userData); + this.precomputer = new Precomputer(userData); + } + + private setUserData(userData: UserData) { + this.userData = userData; + } + + public async initialise(): Promise { + if (this.isInitialised) return this; + await this.applyPresets(); + await this.assignPublicIps(); + await this.fetchManifests(); + await this.fetchResources(); + this.isInitialised = true; + return this; + } + + private checkInitialised() { + if (!this.isInitialised) { + throw new Error( + 'AIOStreams is not initialised. Call initialise() first.' + ); + } + } + + public async getStreams( + id: string, + type: string, + preCaching: boolean = false + ): Promise> { + logger.info(`Handling stream request`, { type, id }); + + // get a list of all addons that support the stream resource with the given type and id. + const supportedAddons = []; + for (const [instanceId, addonResources] of Object.entries( + this.supportedResources + )) { + const resource = addonResources.find( + (r) => + r.name === 'stream' && + r.types.includes(type) && + (r.idPrefixes + ? r.idPrefixes?.some((prefix) => id.startsWith(prefix)) + : true) // if no id prefixes are defined, assume it supports all IDs + ); + if (resource) { + const addon = this.getAddon(instanceId); + if (addon) { + supportedAddons.push(addon); + } + } + } + + logger.info( + `Found ${supportedAddons.length} addons that support the stream resource`, + { + supportedAddons: supportedAddons.map((a) => a.name), + } + ); + + // step 2 + // get all parsed stream objects and errors from all addons that have the stream resource. + // and that support the type and match the id prefix + + const { streams, errors } = await this.fetcher.fetch( + supportedAddons, + type, + id + ); + + // append initialisation errors to the errors array + errors.push( + ...this.addonInitialisationErrors.map((e) => ({ + title: `[❌] ${getAddonName(e.addon)}`, + description: e.error, + })) + ); + + // step 4 + // deduplicate streams based on the depuplicatoroptions + + const deduplicatedStreams = await this.deduplicator.deduplicate(streams); + + let sortedStreams = await this.sorter.sort( + deduplicatedStreams, + id.startsWith('kitsu') ? 'anime' : type + ); + sortedStreams = sortedStreams + // remove HDR+DV from visual tags after filtering/sorting + .map((stream) => { + if (stream.parsedFile?.visualTags?.includes('HDR+DV')) { + stream.parsedFile.visualTags = stream.parsedFile.visualTags.filter( + (tag) => tag !== 'HDR+DV' + ); + } + return stream; + }); + + // step 6 + // limit the number of streams based on the limit criteria. + + const limitedStreams = await this.limiter.limit(sortedStreams); + + // step 7 + // apply stream expressions last + const postFilteredStreams = + await this.filterer.applyStreamExpressionFilters(limitedStreams); + // step 8 + // proxify streaming links if a proxy is provided + + const proxifiedStreams = await this.proxifier.proxify(postFilteredStreams); + + let finalStreams = this.applyModifications(proxifiedStreams); + + // step 8 + // if this.userData.precacheNextEpisode is true, start a new thread to request the next episode, check if + // all provider streams are uncached, and only if so, then send a request to the first uncached stream in the list. + if (this.userData.precacheNextEpisode && !preCaching) { + setImmediate(() => { + this.precacheNextEpisode(type, id).catch((error) => { + logger.error('Error during precaching:', { + error: error instanceof Error ? error.message : String(error), + type, + id, + }); + }); + }); + } + + if (this.userData.externalDownloads) { + logger.info(`Adding external downloads to streams`); + let count = 0; + // for each stream object, insert a new stream object, replacing the url with undefined, and appending its value to externalUrl instead + // and place it right after the original stream object + const streamsWithExternalDownloads: ParsedStream[] = []; + for (const stream of proxifiedStreams) { + streamsWithExternalDownloads.push(stream); + if (stream.url) { + const downloadableStream: ParsedStream = + this.streamUtils.createDownloadableStream(stream); + streamsWithExternalDownloads.push(downloadableStream); + count++; + } + } + logger.info(`Added ${count} external downloads to streams`); + finalStreams = streamsWithExternalDownloads; + } + // step 9 + // return the final list of streams, followed by the error streams. + logger.info( + `Returning ${finalStreams.length} streams and ${errors.length} errors` + ); + return { + success: true, + data: finalStreams, + errors: errors, + }; + } + + public async getCatalog( + type: string, + id: string, + extras?: string + ): Promise> { + // step 1 + // get the addon index from the id + logger.info(`Handling catalog request`, { type, id, extras }); + const start = Date.now(); + const addonInstanceId = id.split('.', 2)[0]; + const addon = this.getAddon(addonInstanceId); + if (!addon) { + logger.error(`Addon ${addonInstanceId} not found`); + return { + success: false, + data: [], + errors: [ + { + title: `Addon ${addonInstanceId} not found. Try reinstalling the addon.`, + description: 'Addon not found', + }, + ], + }; + } + + // step 2 + // get the actual catalog id from the id + const actualCatalogId = id.split('.').slice(1).join('.'); + let modification; + if (this.userData.catalogModifications) { + modification = this.userData.catalogModifications.find( + (mod) => + mod.id === id && (mod.type === type || mod.overrideType === type) + ); + } + if (modification?.overrideType) { + // reset the type from the request (which is the overriden type) to the actual type + type = modification.type; + } + // step 3 + // get the catalog from the addon + let catalog; + try { + catalog = await new Wrapper(addon).getCatalog( + type, + actualCatalogId, + extras + ); + } catch (error) { + return { + success: false, + data: [], + errors: [ + { + title: `[❌] ${addon.name}`, + description: error instanceof Error ? error.message : String(error), + }, + ], + }; + } + + logger.info( + `Received catalog ${actualCatalogId} of type ${type} from ${addon.name} in ${getTimeTakenSincePoint(start)}` + ); + + // apply catalog modifications + + if (modification) { + if (modification.shuffle && !(extras && extras.includes('search'))) { + // shuffle the catalog array if it is not a search + const cacheKey = `shuffle-${type}-${actualCatalogId}-${extras}-${this.userData.uuid}`; + const cachedShuffle = shuffleCache.get(cacheKey, false); + if (cachedShuffle) { + catalog = cachedShuffle; + } else { + for (let i = catalog.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [catalog[i], catalog[j]] = [catalog[j], catalog[i]]; + } + if (modification.persistShuffleFor) { + shuffleCache.set( + cacheKey, + catalog, + modification.persistShuffleFor * 3600 + ); + } + } + } + if (modification.rpdb && this.userData.rpdbApiKey) { + const rpdb = new RPDB(this.userData.rpdbApiKey); + catalog = catalog.map((item) => { + const posterUrl = rpdb.getPosterUrl( + type, + (item as any).imdb_id || item.id + ); + if (posterUrl) { + item.poster = posterUrl; + } + return item; + }); + } + } + + if (this.userData.enhancePosters) { + catalog = catalog.map((item) => { + if (Math.random() < 0.2) { + item.poster = Buffer.from( + constants.DEFAULT_POSTERS[ + Math.floor(Math.random() * constants.DEFAULT_POSTERS.length) + ], + 'base64' + ).toString('utf-8'); + } + return item; + }); + } + + // step 4 + return { + success: true, + data: catalog, + errors: [], + }; + } + + public async getMeta( + type: string, + id: string + ): Promise> { + logger.info(`Handling meta request`, { type, id }); + + // Build prioritized list of candidate addons (naturally ordered by priority) + const candidates: Array<{ + instanceId: string; + addon: any; + reason: string; + }> = []; + + // Step 1: Find addons with matching idPrefix (added first = higher priority) + for (const [instanceId, resources] of Object.entries( + this.supportedResources + )) { + const resource = resources.find( + (r) => + r.name === 'meta' && + r.types.includes(type) && + r.idPrefixes?.some((prefix) => id.startsWith(prefix)) + ); + + if (resource) { + const addon = this.getAddon(instanceId); + if (addon) { + candidates.push({ + instanceId, + addon, + reason: 'matching id prefix', + }); + } + } + } + + // Step 2: Find addons that support meta for this type (added second = lower priority) + for (const [instanceId, resources] of Object.entries( + this.supportedResources + )) { + // Skip if already added with higher priority + if (candidates.some((c) => c.instanceId === instanceId)) { + continue; + } + + // look for addons that support the type, but don't have an id prefix + const resource = resources.find( + (r) => + r.name === 'meta' && r.types.includes(type) && !r.idPrefixes?.length + ); + + if (resource) { + const addon = this.getAddon(instanceId); + if (addon) { + candidates.push({ + instanceId, + addon, + reason: 'general type support', + }); + } + } + } + + if (candidates.length === 0) { + logger.warn(`No supported addon was found for the requested meta`, { + type, + id, + }); + return { + success: false, + data: null, + errors: [], + }; + } + + // Try each candidate in order, collecting errors + const errors: Array<{ title: string; description: string }> = []; + + for (const candidate of candidates) { + logger.info(`Trying addon for meta resource`, { + addonName: candidate.addon.name, + addonInstanceId: candidate.instanceId, + reason: candidate.reason, + }); + + try { + const meta = await new Wrapper(candidate.addon).getMeta(type, id); + logger.info(`Successfully got meta from addon`, { + addonName: candidate.addon.name, + addonInstanceId: candidate.instanceId, + }); + + return { + success: true, + data: meta, + errors: [], // Clear errors on success + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logger.warn(`Failed to get meta from addon ${candidate.addon.name}`, { + error: errorMessage, + reason: candidate.reason, + }); + + // don't push errors if the reason for trying was general type support + // this is to ensure that we don't block stremio from making requests to other addons + // which may potentially be the intended addon + if (candidate.reason === 'general type support') { + continue; + } + + errors.push({ + title: `[❌] ${candidate.addon.name}`, + description: errorMessage, + }); + } + } + + // If we reach here, all addons failed + logger.error( + `All ${candidates.length} candidate addons failed for meta request`, + { + type, + id, + candidateCount: candidates.length, + } + ); + + return { + success: false, + data: null, + errors, + }; + } + // subtitle resource + public async getSubtitles( + type: string, + id: string, + extras?: string + ): Promise> { + logger.info(`getSubtitles: ${id}`); + + // Find all addons that support subtitles for this type and id prefix + const supportedAddons = []; + for (const [instanceId, addonResources] of Object.entries( + this.supportedResources + )) { + const resource = addonResources.find( + (r) => + r.name === 'subtitles' && + r.types.includes(type) && + (r.idPrefixes + ? r.idPrefixes.some((prefix) => id.startsWith(prefix)) + : true) + ); + if (resource) { + const addon = this.getAddon(instanceId); + if (addon) { + supportedAddons.push(addon); + } + } + } + + // Request subtitles from all supported addons in parallel + let errors: AIOStreamsError[] = this.addonInitialisationErrors.map( + (error) => ({ + title: `[❌] ${getAddonName(error.addon)}`, + description: error.error, + }) + ); + let allSubtitles: Subtitle[] = []; + + await Promise.all( + supportedAddons.map(async (addon) => { + try { + const subtitles = await new Wrapper(addon).getSubtitles( + type, + id, + extras + ); + if (subtitles) { + allSubtitles.push(...subtitles); + } + } catch (error) { + errors.push({ + title: `[❌] ${getAddonName(addon)}`, + description: error instanceof Error ? error.message : String(error), + }); + } + }) + ); + + return { + success: true, + data: allSubtitles, + errors: errors, + }; + } + + // addon_catalog resource + public async getAddonCatalog( + type: string, + id: string + ): Promise> { + logger.info(`getAddonCatalog: ${id}`); + // step 1 + // get the addon instance id from the id + const addonInstanceId = id.split('.', 2)[0]; + const addon = this.getAddon(addonInstanceId); + if (!addon) { + return { + success: false, + data: [], + errors: [ + { + title: `Addon ${addonInstanceId} not found`, + description: 'Addon not found', + }, + ], + }; + } + + // step 2 + // get the actual addon catalog id from the id + const actualAddonCatalogId = id.split('.').slice(1).join('.'); + + // step 3 + // get the addon catalog from the addon + let addonCatalogs: AddonCatalog[] = []; + try { + addonCatalogs = await new Wrapper(addon).getAddonCatalog( + type, + actualAddonCatalogId + ); + } catch (error) { + return { + success: false, + data: [], + errors: [ + { + title: `[❌] ${getAddonName(addon)}`, + description: error instanceof Error ? error.message : String(error), + }, + ], + }; + } + // step 4 + return { + success: true, + data: addonCatalogs, + errors: [], + }; + } + // converts all addons to + private async applyPresets() { + if (!this.userData.presets) { + return; + } + + for (const preset of this.userData.presets.filter((p) => p.enabled)) { + const addons = await PresetManager.fromId(preset.type).generateAddons( + this.userData, + preset.options + ); + this.addons.push( + ...addons.map((a) => ({ + ...a, + presetInstanceId: preset.instanceId, + // if no identifier is present, we can assume that the preset can only generate one addon at a time and so no + // unique identifier is needed as the preset instance id is enough to identify the addon + instanceId: `${preset.instanceId}${getSimpleTextHash(`${a.identifier ?? ''}`).slice(0, 4)}`, + })) + ); + } + + if (this.addons.length > Env.MAX_ADDONS) { + throw new Error( + `Your current configuration requires ${this.addons.length} addons, but the maximum allowed is ${Env.MAX_ADDONS}. Please reduce the number of addons, or increase it in the environment variables.` + ); + } + } + + private async fetchManifests() { + this.manifests = Object.fromEntries( + await Promise.all( + this.addons.map(async (addon) => { + try { + this.validateAddon(addon); + return [addon.instanceId, await new Wrapper(addon).getManifest()]; + } catch (error: any) { + if (this.skipFailedAddons) { + this.addonInitialisationErrors.push({ + addon: addon, + error: error.message, + }); + logger.error(`${error.message}, skipping`); + return [addon.instanceId, null]; + } + throw error; + } + }) + ) + ); + } + + private async fetchResources() { + for (const [instanceId, manifest] of Object.entries(this.manifests)) { + if (!manifest) continue; + + // Convert string resources to StrictManifestResource objects + const addonResources = manifest.resources.map((resource) => { + if (typeof resource === 'string') { + return { + name: resource as Resource, + types: manifest.types, + idPrefixes: manifest.idPrefixes, + }; + } + return resource; + }); + + const addon = this.getAddon(instanceId); + + if (!addon) { + logger.error(`Addon with instanceId ${instanceId} not found`); + continue; + } + + logger.verbose( + `Determined that ${getAddonName(addon)} (Instance ID: ${instanceId}) has support for the following resources: ${JSON.stringify( + addonResources + )}` + ); + + // Filter and merge resources + for (const resource of addonResources) { + if ( + addon.resources && + addon.resources.length > 0 && + !addon.resources.includes(resource.name) + ) { + continue; + } + + const existing = this.finalResources.find( + (r) => r.name === resource.name + ); + // NOTE: we cannot push idPrefixes in the scenario that the user adds multiple addons that provide meta for example, + // and one of them has defined idPrefixes, while the other hasn't + // in this case, stremio assumes we only support that resource for the specified id prefix and then + // will not send a request to AIOStreams for other id prefixes even though our other addon that didn't specify + // an id prefix technically says it supports all ids + + // leaving idPrefixes as null/undefined causes various odd issues with stremio even though it says it is optional. + // therefore, we set it as normal, but if there comes an addon that doesn't support any id prefixes, we set it to undefined + // this fixes issues in most cases as most addons do provide idPrefixes + if (existing) { + existing.types = [...new Set([...existing.types, ...resource.types])]; + if ( + existing.idPrefixes && + existing.idPrefixes.length > 0 && + resource.idPrefixes && + resource.idPrefixes.length > 0 + ) { + existing.idPrefixes = [ + ...new Set([...existing.idPrefixes, ...resource.idPrefixes]), + ]; + } else { + logger.warn( + `Addon ${getAddonName(addon)} does not provide idPrefixes for type ${resource.name}, setting idPrefixes to undefined` + ); + // if an addon for this type does not provide idPrefixes, we set it to undefined + // to ensure it works with at least some platforms on stremio rather than none. + existing.idPrefixes = undefined; + } + } else { + if (!resource.idPrefixes?.length) { + logger.warn( + `Addon ${getAddonName(addon)} does not provide idPrefixes for type ${resource.name}, setting idPrefixes to undefined` + ); + } + this.finalResources.push({ + ...resource, + // explicitly set to null + idPrefixes: resource.idPrefixes?.length + ? resource.idPrefixes + : undefined, + // idPrefixes: resource.idPrefixes + // ? [...resource.idPrefixes] + // : undefined, + }); + } + } + + // Add catalogs with prefixed IDs (ensure to check that if addon.resources is defined and does not have catalog + // then we do not add the catalogs) + + if ( + !addon.resources?.length || + (addon.resources && addon.resources.includes('catalog')) + ) { + this.finalCatalogs.push( + ...manifest.catalogs.map((catalog) => ({ + ...catalog, + id: `${addon.instanceId}.${catalog.id}`, + })) + ); + } + + // add all addon catalogs, prefixing id with index + if (manifest.addonCatalogs) { + this.finalAddonCatalogs!.push( + ...(manifest.addonCatalogs || []).map((catalog) => ({ + ...catalog, + id: `${addon.instanceId}.${catalog.id}`, + })) + ); + } + + this.supportedResources[instanceId] = addonResources; + } + + logger.verbose( + `Parsed all catalogs and determined the following catalogs: ${JSON.stringify( + this.finalCatalogs.map((c) => ({ + id: c.id, + name: c.name, + type: c.type, + })) + )}` + ); + + logger.verbose( + `Parsed all addon catalogs and determined the following catalogs: ${JSON.stringify( + this.finalAddonCatalogs?.map((c) => ({ + id: c.id, + name: c.name, + type: c.type, + })) + )}` + ); + + logger.verbose( + `Parsed all resources and determined the following resources: ${JSON.stringify( + this.finalResources.map((r) => ({ + name: r.name, + types: r.types, + idPrefixes: r.idPrefixes, + })) + )}` + ); + + if (this.userData.catalogModifications) { + this.finalCatalogs = this.finalCatalogs + // Sort catalogs based on catalogModifications order, with non-modified catalogs at the end + .sort((a, b) => { + const aModIndex = this.userData.catalogModifications!.findIndex( + (mod) => mod.id === a.id && mod.type === a.type + ); + const bModIndex = this.userData.catalogModifications!.findIndex( + (mod) => mod.id === b.id && mod.type === b.type + ); + + // If neither catalog is in modifications, maintain original order + if (aModIndex === -1 && bModIndex === -1) { + return ( + this.finalCatalogs.indexOf(a) - this.finalCatalogs.indexOf(b) + ); + } + + // If only one catalog is in modifications, it should come first + if (aModIndex === -1) return 1; + if (bModIndex === -1) return -1; + + // If both are in modifications, sort by their order in modifications + return aModIndex - bModIndex; + }) + // filter out any catalogs that are disabled + .filter((catalog) => { + const modification = this.userData.catalogModifications!.find( + (mod) => mod.id === catalog.id && mod.type === catalog.type + ); + return modification?.enabled !== false; // only if explicity disabled i.e. enabled is true or undefined + }) + // rename any catalogs if necessary and apply the onlyOnDiscover modification + .map((catalog) => { + const modification = this.userData.catalogModifications!.find( + (mod) => mod.id === catalog.id && mod.type === catalog.type + ); + if (modification?.name) { + catalog.name = modification.name; + } + if (modification?.onlyOnDiscover) { + // look in the extra list for a extra with name 'genre', and set 'isRequired' to true + const genreExtra = catalog.extra?.find((e) => e.name === 'genre'); + if (genreExtra) { + genreExtra.isRequired = true; + } + } + if (modification?.overrideType !== undefined) { + catalog.type = modification.overrideType; + } + return catalog; + }); + } + } + + public getResources(): StrictManifestResource[] { + this.checkInitialised(); + return this.finalResources; + } + + public getCatalogs(): Manifest['catalogs'] { + this.checkInitialised(); + return this.finalCatalogs; + } + + public getAddonCatalogs(): Manifest['addonCatalogs'] { + this.checkInitialised(); + return this.finalAddonCatalogs; + } + + public getAddon(instanceId: string): Addon | undefined { + return this.addons.find((a) => a.instanceId === instanceId); + } + + private async getProxyIp() { + let userIp = this.userData.ip; + const PRIVATE_IP_REGEX = + /^(::1|::ffff:(10|127|192|172)\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})|10\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})|127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})|192\.168\.(\d{1,3})\.(\d{1,3})|172\.(1[6-9]|2[0-9]|3[0-1])\.(\d{1,3})\.(\d{1,3}))$/; + + if (userIp && PRIVATE_IP_REGEX.test(userIp)) { + userIp = undefined; + } + if (!this.userData.proxy) { + return userIp; + } + + const proxy = createProxy(this.userData.proxy); + if (proxy.getConfig().enabled) { + userIp = await this.retryGetIp( + () => proxy.getPublicIp(), + 'Proxy public IP' + ); + } + return userIp; + } + + private async retryGetIp( + getter: () => Promise, + label: string, + maxRetries: number = 3 + ): Promise { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const result = await getter(); + if (result) { + return result; + } + } catch (error) { + logger.warn( + `Failed to get ${label}, retrying... (${attempt}/${maxRetries})` + ); + } + } + throw new Error(`Failed to get ${label} after ${maxRetries} attempts`); + } + // stream utility functions + private async assignPublicIps() { + let userIp = this.userData.ip; + let proxyIp = undefined; + if (this.userData.proxy && this.userData.proxy.enabled) { + proxyIp = await this.getProxyIp(); + } + for (const addon of this.addons) { + const proxy = + this.userData.proxy && + (!this.userData.proxy?.proxiedAddons?.length || + this.userData.proxy.proxiedAddons.includes( + addon.presetInstanceId || '' + )); + logger.debug( + `Using ${proxy ? 'proxy' : 'user'} ip for ${getAddonName(addon)}: ${ + proxy + ? maskSensitiveInfo(proxyIp ?? 'none') + : maskSensitiveInfo(userIp ?? 'none') + }` + ); + if (proxy) { + addon.ip = proxyIp; + } else { + addon.ip = userIp; + } + } + } + + private validateAddon(addon: Addon) { + const manifestUrl = new URL(addon.manifestUrl); + const baseUrl = Env.BASE_URL ? new URL(Env.BASE_URL) : undefined; + if (this.userData.uuid && addon.manifestUrl.includes(this.userData.uuid)) { + logger.warn( + `${this.userData.uuid} detected to be trying to cause infinite self scraping` + ); + throw new Error( + `${getAddonName(addon)} appears to be trying to scrape the current user's AIOStreams instance.` + ); + } else if ( + ((baseUrl && manifestUrl.host === baseUrl.host) || + (manifestUrl.host.startsWith('localhost') && + manifestUrl.port === Env.PORT.toString())) && + Env.DISABLE_SELF_SCRAPING === true + ) { + throw new Error( + `Scraping the same AIOStreams instance is disabled. Please use a different AIOStreams instance, or enable it through the environment variables.` + ); + } + if ( + addon.presetInstanceId && + FeatureControl.disabledAddons.has(addon.presetInstanceId) + ) { + throw new Error( + `Addon ${getAddonName(addon)} is disabled: ${FeatureControl.disabledAddons.get( + addon.presetType + )}` + ); + } else if ( + FeatureControl.disabledHosts.has(manifestUrl.host.split(':')[0]) + ) { + throw new Error( + `Addon ${getAddonName(addon)} is disabled: ${FeatureControl.disabledHosts.get( + manifestUrl.host.split(':')[0] + )}` + ); + } + } + + private applyModifications(streams: ParsedStream[]): ParsedStream[] { + if (this.userData.randomiseResults) { + streams.sort(() => Math.random() - 0.5); + } + if (this.userData.enhanceResults) { + streams.forEach((stream) => { + if (Math.random() < 0.4) { + stream.filename = undefined; + stream.parsedFile = undefined; + stream.type = 'youtube'; + stream.ytId = Buffer.from(constants.DEFAULT_YT_ID, 'base64').toString( + 'utf-8' + ); + stream.message = + 'This stream has been artificially enhanced using the best AI on the market.'; + } + }); + } + return streams; + } + + private async precacheNextEpisode(type: string, id: string) { + const seasonEpisodeRegex = /:(\d+):(\d+)$/; + const match = id.match(seasonEpisodeRegex); + if (!match) { + return; + } + const season = match[1]; + const episode = match[2]; + const titleId = id.replace(seasonEpisodeRegex, ''); + const nextEpisodeId = `${titleId}:${season}:${Number(episode) + 1}`; + logger.info(`Pre-caching next episode of ${titleId}`, { + season, + episode, + nextEpisode: Number(episode) + 1, + nextEpisodeId, + }); + // modify userData to remove the excludeUncached filter + const userData = structuredClone(this.userData); + userData.excludeUncached = false; + this.setUserData(userData); + const nextStreamsResponse = await this.getStreams( + nextEpisodeId, + type, + true + ); + if (nextStreamsResponse.success) { + const nextStreams = nextStreamsResponse.data; + const serviceStreams = nextStreams.filter((stream) => stream.service); + if ( + serviceStreams.every((stream) => stream.service?.cached === false) || // only if all streams are uncached + this.userData.alwaysPrecache // or if alwaysPrecache is true + ) { + const firstUncachedStream = serviceStreams.find( + (stream) => stream.service?.cached === false + ); + if (firstUncachedStream && firstUncachedStream.url) { + try { + const wrapper = new Wrapper(firstUncachedStream.addon); + logger.debug( + `The following stream was selected for precaching:\n${firstUncachedStream.originalDescription}` + ); + const response = await wrapper.makeRequest(firstUncachedStream.url); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + logger.debug(`Response: ${response.status} ${response.statusText}`); + } catch (error) { + logger.error(`Error pinging url of first uncached stream`, { + error: error instanceof Error ? error.message : String(error), + }); + } + } + } + } + } +} diff --git a/packages/core/src/parser/file.ts b/packages/core/src/parser/file.ts new file mode 100644 index 0000000000000000000000000000000000000000..2bae3a8cf94e90b7aa3fec663d249772b5da4682 --- /dev/null +++ b/packages/core/src/parser/file.ts @@ -0,0 +1,89 @@ +import { PARSE_REGEX } from './regex'; +import * as PTT from 'parse-torrent-title'; +import { ParsedFile } from '../db'; + +function matchPattern( + filename: string, + patterns: Record +): string | undefined { + return Object.entries(patterns).find(([_, pattern]) => + pattern.test(filename) + )?.[0]; +} + +function matchMultiplePatterns( + filename: string, + patterns: Record +): string[] { + return Object.entries(patterns) + .filter(([_, pattern]) => pattern.test(filename)) + .map(([tag]) => tag); +} + +class FileParser { + static parse(filename: string): ParsedFile { + const parsed = PTT.parse(filename); + // prevent the title from being parsed for info + if (parsed.title && parsed.title.length > 4) { + filename = filename.replace(parsed.title, '').trim(); + filename = filename.replace(/\s+/g, '.').replace(/^\.+|\.+$/g, ''); + } + const resolution = matchPattern(filename, PARSE_REGEX.resolutions); + const quality = matchPattern(filename, PARSE_REGEX.qualities); + const encode = matchPattern(filename, PARSE_REGEX.encodes); + const audioChannels = matchMultiplePatterns( + filename, + PARSE_REGEX.audioChannels + ); + const visualTags = matchMultiplePatterns(filename, PARSE_REGEX.visualTags); + const audioTags = matchMultiplePatterns(filename, PARSE_REGEX.audioTags); + const languages = matchMultiplePatterns(filename, PARSE_REGEX.languages); + + const getPaddedNumber = (number: number, length: number) => + number.toString().padStart(length, '0'); + + const releaseGroup = parsed.group; + const title = parsed.title; + const year = parsed.year ? parsed.year.toString() : undefined; + const season = parsed.season; + const seasons = parsed.seasons; + const episode = parsed.episode; + const formattedSeasonString = seasons?.length + ? seasons.length === 1 + ? `S${getPaddedNumber(seasons[0], 2)}` + : `S${getPaddedNumber(seasons[0], 2)}-${getPaddedNumber( + seasons[seasons.length - 1], + 2 + )}` + : season + ? `S${getPaddedNumber(season, 2)}` + : undefined; + const formattedEpisodeString = episode + ? `E${getPaddedNumber(episode, 2)}` + : undefined; + + const seasonEpisode = [ + formattedSeasonString, + formattedEpisodeString, + ].filter((v) => v !== undefined); + + return { + resolution, + quality, + languages, + encode, + audioChannels, + audioTags, + visualTags, + releaseGroup, + title, + year, + season, + seasons, + episode, + seasonEpisode, + }; + } +} + +export default FileParser; diff --git a/packages/core/src/parser/index.ts b/packages/core/src/parser/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..0a07f9569fb9ec26ada33c57e0789adc52c05482 --- /dev/null +++ b/packages/core/src/parser/index.ts @@ -0,0 +1,2 @@ +export { default as FileParser } from './file'; +export { default as StreamParser } from './streams'; diff --git a/packages/core/src/parser/regex.ts b/packages/core/src/parser/regex.ts new file mode 100644 index 0000000000000000000000000000000000000000..ccdee0a29a35a05b6afe6425ac14892e3ffaa450 --- /dev/null +++ b/packages/core/src/parser/regex.ts @@ -0,0 +1,186 @@ +import { AUDIO_TAGS, QUALITIES, RESOLUTIONS } from '../utils/constants'; +import { VISUAL_TAGS } from '../utils/constants'; +import { ENCODES } from '../utils/constants'; +import { LANGUAGES } from '../utils/constants'; +import { AUDIO_CHANNELS } from '../utils/constants'; +const createRegex = (pattern: string): RegExp => + new RegExp(`(? + createRegex(`${pattern}(?![ .\\-_]?sub(title)?s?)`); + +type PARSE_REGEX = { + resolutions: Omit, 'Unknown'> & { + Unknown?: RegExp; + }; + qualities: Omit, 'Unknown'> & { + Unknown?: RegExp; + }; + visualTags: Omit< + Record<(typeof VISUAL_TAGS)[number], RegExp>, + 'Unknown' | 'HDR+DV' + > & { + Unknown?: RegExp; + 'HDR+DV'?: RegExp; + }; + audioTags: Omit, 'Unknown'> & { + Unknown?: RegExp; + }; + audioChannels: Omit< + Record<(typeof AUDIO_CHANNELS)[number], RegExp>, + 'Unknown' + > & { + Unknown?: RegExp; + }; + languages: Omit, 'Unknown'> & { + Unknown?: RegExp; + }; + encodes: Omit, 'Unknown'> & { + Unknown?: RegExp; + }; + releaseGroup: RegExp; +}; + +export const PARSE_REGEX: PARSE_REGEX = { + resolutions: { + '2160p': createRegex( + '(bd|hd|m)?(4k|2160(p|i)?)|u(ltra)?[ .\\-_]?hd|3840\s?x\s?(\d{4})' + ), + '1440p': createRegex( + '(bd|hd|m)?(1440(p|i)?)|2k|w?q(uad)?[ .\\-_]?hd|2560\s?x(\d{4})' + ), + '1080p': createRegex( + '(bd|hd|m)?(1080(p|i)?)|f(ull)?[ .\\-_]?hd|1920\s?x\s?(\d{3,4})' + ), + '720p': createRegex('(bd|hd|m)?((720|800)(p|i)?)|hd|1280\s?x\s?(\d{3,4})'), + '576p': createRegex('(bd|hd|m)?((576|534)(p|i)?)'), + '480p': createRegex('(bd|hd|m)?(480(p|i)?)|sd'), + '360p': createRegex('(bd|hd|m)?(360(p|i)?)'), + '240p': createRegex('(bd|hd|m)?((240|266)(p|i)?)'), + '144p': createRegex('(bd|hd|m)?(144(p|i)?)'), + }, + qualities: { + 'BluRay REMUX': + /((?<=remux.*)[ .\-_](blu[ .\-_]?ray))|((blu[ .\-_]?ray)[ .\-_](?=.*remux))|((? stream.regexMatched); + } + return streams.filter((stream) => + regexNames.some((regexName) => stream.regexMatched?.name === regexName) + ); + }; + + // gets all streams that have a regex matched with an index in the range of min and max + this.parser.functions.regexMatchedInRange = function ( + streams: ParsedStream[], + min: number, + max: number + ) { + return streams.filter((stream) => { + if (!stream.regexMatched) { + return false; + } else if ( + stream.regexMatched.index < min || + stream.regexMatched.index > max + ) { + return false; + } + return true; + }); + }; + + this.parser.functions.indexer = function ( + streams: ParsedStream[], + ...indexers: string[] + ) { + if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { + throw new Error('Your streams input must be an array of streams'); + } else if ( + indexers.length === 0 || + indexers.some((i) => typeof i !== 'string') + ) { + throw new Error('You must provide one or more indexer strings'); + } + return streams.filter((stream) => + indexers.includes(stream.indexer || 'Unknown') + ); + }; + + this.parser.functions.resolution = function ( + streams: ParsedStream[], + ...resolutions: string[] + ) { + if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { + throw new Error('Your streams input must be an array of streams'); + } else if ( + resolutions.length === 0 || + resolutions.some((r) => typeof r !== 'string') + ) { + throw new Error('You must provide one or more resolution strings'); + } + + return streams.filter((stream) => + resolutions + .map((r) => r.toLowerCase()) + .includes(stream.parsedFile?.resolution?.toLowerCase() || 'unknown') + ); + }; + + this.parser.functions.quality = function ( + streams: ParsedStream[], + ...qualities: string[] + ) { + if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { + throw new Error('Your streams input must be an array of streams'); + } else if ( + qualities.length === 0 || + qualities.some((q) => typeof q !== 'string') + ) { + throw new Error('You must provide one or more quality strings'); + } + return streams.filter((stream) => + qualities + .map((q) => q.toLowerCase()) + .includes(stream.parsedFile?.quality?.toLowerCase() || 'unknown') + ); + }; + + this.parser.functions.encode = function ( + streams: ParsedStream[], + ...encodes: string[] + ) { + if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { + throw new Error('Your streams input must be an array of streams'); + } else if ( + encodes.length === 0 || + encodes.some((e) => typeof e !== 'string') + ) { + throw new Error('You must provide one or more encode strings'); + } + return streams.filter((stream) => + encodes + .map((encode) => encode.toLowerCase()) + .includes(stream.parsedFile?.encode?.toLowerCase() || 'unknown') + ); + }; + + this.parser.functions.type = function ( + streams: ParsedStream[], + ...types: string[] + ) { + if (!Array.isArray(streams)) { + throw new Error('Your streams input must be an array of streams'); + } else if ( + types.length === 0 || + types.some((t) => typeof t !== 'string') + ) { + throw new Error('You must provide one or more type string parameters'); + } + return streams.filter((stream) => + types.map((t) => t.toLowerCase()).includes(stream.type.toLowerCase()) + ); + }; + + this.parser.functions.visualTag = function ( + streams: ParsedStream[], + ...visualTags: string[] + ) { + if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { + throw new Error('Your streams input must be an array of streams'); + } else if ( + visualTags.length === 0 || + visualTags.some((v) => typeof v !== 'string') + ) { + throw new Error( + 'You must provide one or more visual tag string parameters' + ); + } + return streams.filter((stream) => + stream.parsedFile?.visualTags.some((v) => + visualTags.map((vt) => vt.toLowerCase()).includes(v.toLowerCase()) + ) + ); + }; + + this.parser.functions.audioTag = function ( + streams: ParsedStream[], + ...audioTags: string[] + ) { + if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { + throw new Error('Your streams input must be an array of streams'); + } else if ( + audioTags.length === 0 || + audioTags.some((a) => typeof a !== 'string') + ) { + throw new Error( + 'You must provide one or more audio tag string parameters' + ); + } + return streams.filter((stream) => + audioTags + .map((a) => a.toLowerCase()) + .some((a) => + stream.parsedFile?.audioTags + .map((at) => at.toLowerCase()) + .includes(a) + ) + ); + }; + + this.parser.functions.audioChannels = function ( + streams: ParsedStream[], + ...audioChannels: string[] + ) { + if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { + throw new Error('Your streams input must be an array of streams'); + } else if ( + audioChannels.length === 0 || + audioChannels.some((a) => typeof a !== 'string') + ) { + throw new Error( + 'You must provide one or more audio channel string parameters' + ); + } + return streams.filter((stream) => + audioChannels + .map((a) => a.toLowerCase()) + .some((a) => + stream.parsedFile?.audioChannels + ?.map((ac) => ac.toLowerCase()) + .includes(a) + ) + ); + }; + + this.parser.functions.language = function ( + streams: ParsedStream[], + ...languages: string[] + ) { + if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { + throw new Error('Your streams input must be an array of streams'); + } else if ( + languages.length === 0 || + languages.some((l) => typeof l !== 'string') + ) { + throw new Error( + 'You must provide one or more language string parameters' + ); + } + return streams.filter((stream) => + languages + .map((l) => l.toLowerCase()) + .some((l) => + stream.parsedFile?.languages + ?.map((lang) => lang.toLowerCase()) + .includes(l) + ) + ); + }; + + this.parser.functions.seeders = function ( + streams: ParsedStream[], + minSeeders?: number, + maxSeeders?: number + ) { + if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { + throw new Error('Your streams input must be an array of streams'); + } else if ( + typeof minSeeders !== 'number' && + typeof maxSeeders !== 'number' + ) { + throw new Error('Min and max seeders must be a number'); + } + // select streams with seeders that lie within the range. + return streams.filter((stream) => { + if (minSeeders && (stream.torrent?.seeders ?? 0) < minSeeders) { + return false; + } + if (maxSeeders && (stream.torrent?.seeders ?? 0) > maxSeeders) { + return false; + } + return true; + }); + }; + + this.parser.functions.size = function ( + streams: ParsedStream[], + minSize?: string | number, + maxSize?: string | number + ) { + if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { + throw new Error('Your streams input must be an array of streams'); + } else if ( + typeof minSize !== 'number' && + typeof maxSize !== 'number' && + typeof minSize !== 'string' && + typeof maxSize !== 'string' + ) { + throw new Error('Min and max size must be a number'); + } + // use the bytes library to ensure we get a number + const minSizeInBytes = + typeof minSize === 'string' ? bytes.parse(minSize) : minSize; + const maxSizeInBytes = + typeof maxSize === 'string' ? bytes.parse(maxSize) : maxSize; + return streams.filter((stream) => { + if ( + minSize && + stream.size && + minSizeInBytes && + stream.size < minSizeInBytes + ) { + return false; + } + if ( + maxSize && + stream.size && + maxSizeInBytes && + stream.size > maxSizeInBytes + ) { + return false; + } + return true; + }); + }; + + this.parser.functions.service = function ( + streams: ParsedStream[], + ...services: string[] + ) { + if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { + throw new Error('Your streams input must be an array of streams'); + } else if ( + services.length === 0 || + services.some((s) => typeof s !== 'string') + ) { + throw new Error( + 'You must provide one or more service string parameters' + ); + } else if ( + services.length === 0 || + services.some((s) => typeof s !== 'string') + ) { + throw new Error( + 'You must provide one or more service string parameters' + ); + } else if ( + !services.every((s) => + [ + 'realdebrid', + 'debridlink', + 'alldebrid', + 'torbox', + 'pikpak', + 'seedr', + 'offcloud', + 'premiumize', + 'easynews', + 'easydebrid', + ].includes(s) + ) + ) { + throw new Error( + 'Service must be a string and one of: realdebrid, debridlink, alldebrid, torbox, pikpak, seedr, offcloud, premiumize, easynews, easydebrid' + ); + } + return streams.filter((stream) => + services.some((s) => stream.service?.id === s) + ); + }; + + this.parser.functions.cached = function (streams: ParsedStream[]) { + if (!Array.isArray(streams)) { + throw new Error( + "Please use one of 'totalStreams' or 'previousStreams' as the first argument" + ); + } + return streams.filter((stream) => stream.service?.cached === true); + }; + + this.parser.functions.uncached = function (streams: ParsedStream[]) { + if (!Array.isArray(streams)) { + throw new Error( + "Please use one of 'totalStreams' or 'previousStreams' as the first argument" + ); + } + return streams.filter((stream) => stream.service?.cached === false); + }; + + this.parser.functions.releaseGroup = function ( + streams: ParsedStream[], + ...releaseGroups: string[] + ) { + if (!Array.isArray(streams)) { + throw new Error( + "Please use one of 'totalStreams' or 'previousStreams' as the first argument" + ); + } else if ( + releaseGroups.length === 0 || + releaseGroups.some((r) => typeof r !== 'string') + ) { + throw new Error( + 'You must provide one or more release group string parameters' + ); + } + return streams.filter((stream) => + releaseGroups.some((r) => stream.parsedFile?.releaseGroup === r) + ); + }; + + this.parser.functions.addon = function ( + streams: ParsedStream[], + ...addons: string[] + ) { + if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { + throw new Error('Your streams input must be an array of streams'); + } else if ( + addons.length === 0 || + addons.some((a) => typeof a !== 'string') + ) { + throw new Error('You must provide one or more addon string parameters'); + } + return streams.filter((stream) => addons.includes(stream.addon.name)); + }; + + this.parser.functions.library = function (streams: ParsedStream[]) { + if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { + throw new Error('Your streams input must be an array of streams'); + } + return streams.filter((stream) => stream.library); + }; + + this.parser.functions.count = function (streams: ParsedStream[]) { + if (!Array.isArray(streams)) { + throw new Error( + "Please use one of 'totalStreams' or 'previousStreams' as the first argument" + ); + } + return streams.length; + }; + + this.parser.functions.negate = function ( + streams: ParsedStream[], + originalStreams: ParsedStream[] + ) { + if (!Array.isArray(originalStreams) || !Array.isArray(streams)) { + throw new Error( + "Both arguments of the 'negate' function must be arrays of streams" + ); + } + const streamIds = new Set(streams.map((stream) => stream.id)); + return originalStreams.filter((stream) => !streamIds.has(stream.id)); + }; + + this.parser.functions.merge = function ( + ...streamArrays: ParsedStream[][] + ): ParsedStream[] { + const seen = new Set(); + const merged: ParsedStream[] = []; + + for (const array of streamArrays) { + for (const stream of array) { + if (!seen.has(stream.id)) { + seen.add(stream.id); + merged.push(stream); + } + } + } + + return merged; + }; + + this.parser.functions.slice = function ( + streams: ParsedStream[], + start: number, + end: number + ) { + if (!Array.isArray(streams)) { + throw new Error('Your streams input must be an array of streams'); + } + return streams.slice(start, end); + }; + } + + protected async evaluateCondition(condition: string): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('Condition parsing timed out')); + }, 1); + + try { + const result = this.parser.evaluate(condition); + clearTimeout(timeout); + resolve(result); + } catch (error) { + clearTimeout(timeout); + reject(error); + } + }); + } + + protected createTestStream( + overrides: Partial = {} + ): ParsedStream { + const defaultStream: ParsedStream = { + id: '1', + type: 'http', + addon: { + instanceId: 'test-instance', + presetType: 'test-preset', + presetInstanceId: 'test-preset-instance', + manifestUrl: 'https://example.com/manifest.json', + enabled: true, + name: 'Test Addon', + timeout: 30000, + }, + service: { + id: 'realdebrid', + cached: true, + }, + indexer: 'Test Indexer', + parsedFile: { + title: 'Test Title', + year: '2024', + season: 1, + episode: 1, + seasons: [1], + resolution: '1080p', + quality: 'BluRay', + encode: 'x264', + releaseGroup: 'TEST', + seasonEpisode: ['S01', 'E01'], + visualTags: ['HDR'], + audioTags: ['AAC'], + audioChannels: ['2.0'], + languages: ['English'], + }, + size: 1073741824, // 1GB in bytes + folderSize: 2147483648, // 2GB in bytes + library: false, + url: 'https://example.com/stream.mkv', + filename: 'test.mkv', + folderName: 'Test Folder', + duration: 7200, // 2 hours in seconds + age: '1 day', + message: 'Test message', + torrent: { + infoHash: 'test-hash', + fileIdx: 0, + seeders: 100, + sources: ['https://tracker.example.com'], + }, + countryWhitelist: ['USA'], + notWebReady: false, + bingeGroup: 'test-group', + requestHeaders: { 'User-Agent': 'Test' }, + responseHeaders: { 'Content-Type': 'video/mp4' }, + videoHash: 'test-video-hash', + subtitles: [], + proxied: false, + regexMatched: { + name: 'test-regex', + pattern: 'test', + index: 0, + }, + keywordMatched: false, + ytId: undefined, + externalUrl: undefined, + error: undefined, + originalName: 'Original Test Name', + originalDescription: 'Original Test Description', + }; + + return { ...defaultStream, ...overrides }; + } +} + +export class GroupConditionEvaluator extends StreamExpressionEngine { + private previousStreams: ParsedStream[]; + private totalStreams: ParsedStream[]; + private previousGroupTimeTaken: number; + private totalTimeTaken: number; + + constructor( + previousStreams: ParsedStream[], + totalStreams: ParsedStream[], + previousGroupTimeTaken: number, + totalTimeTaken: number, + queryType: string + ) { + super(); + + this.previousStreams = previousStreams; + this.totalStreams = totalStreams; + this.previousGroupTimeTaken = previousGroupTimeTaken; + this.totalTimeTaken = totalTimeTaken; + + // Set up constants for this specific parser + this.parser.consts.previousStreams = this.previousStreams; + this.parser.consts.totalStreams = this.totalStreams; + this.parser.consts.queryType = queryType; + this.parser.consts.previousGroupTimeTaken = this.previousGroupTimeTaken; + this.parser.consts.totalTimeTaken = this.totalTimeTaken; + } + + async evaluate(condition: string) { + return await this.evaluateCondition(condition); + } + + static async testEvaluate(condition: string) { + const parser = new GroupConditionEvaluator([], [], 0, 0, 'movie'); + return await parser.evaluate(condition); + } +} + +export class StreamSelector extends StreamExpressionEngine { + constructor() { + super(); + } + + async select( + streams: ParsedStream[], + condition: string + ): Promise { + // Set the streams constant for this filter operation + this.parser.consts.streams = streams; + let selectedStreams: ParsedStream[] = []; + + selectedStreams = await this.evaluateCondition(condition); + + // if the result is a boolean value, convert it to the appropriate type + // true = all streams, false = no streams + if (typeof selectedStreams === 'boolean') { + selectedStreams = selectedStreams ? streams : []; + } + + // attempt to parse the result + try { + selectedStreams = ParsedStreams.parse(selectedStreams); + } catch (error) { + throw new Error( + `Filter condition failed: ${error instanceof Error ? error.message : String(error)}` + ); + } + return selectedStreams; + } + + static async testSelect(condition: string): Promise { + const parser = new StreamSelector(); + const streams = [ + parser.createTestStream({ type: 'debrid' }), + parser.createTestStream({ type: 'debrid' }), + parser.createTestStream({ type: 'debrid' }), + parser.createTestStream({ type: 'usenet' }), + parser.createTestStream({ type: 'p2p' }), + parser.createTestStream({ type: 'p2p' }), + ]; + return await parser.select(streams, condition); + } +} diff --git a/packages/core/src/parser/streams.ts b/packages/core/src/parser/streams.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd5fd11e49c094df488768d24cb9a5992df309c9 --- /dev/null +++ b/packages/core/src/parser/streams.ts @@ -0,0 +1,551 @@ +import { Stream, ParsedStream, Addon, ParsedFile } from '../db'; +import { constants, createLogger, FULL_LANGUAGE_MAPPING } from '../utils'; +import FileParser from './file'; +const logger = createLogger('parser'); +class StreamParser { + private count = 0; + get errorRegexes(): { pattern: RegExp; message: string }[] | undefined { + return [ + { + pattern: /invalid\s+\w+\s+(account|apikey|token)/i, + message: 'Invalid account or apikey or token', + }, + ]; + } + protected get filenameRegex(): RegExp | undefined { + return undefined; + } + protected get folderNameRegex(): RegExp | undefined { + return undefined; + } + + protected get sizeRegex(): RegExp | undefined { + return /(\d+(\.\d+)?)\s?(KB|MB|GB|TB)/i; + } + protected get sizeK(): 1024 | 1000 { + return 1024; + } + + protected get seedersRegex(): RegExp | undefined { + return /[👥👤]\s*(\d+)/u; + } + + protected get indexerEmojis(): string[] { + return ['🌐', '⚙️', '🔗', '🔎', '🔍', '☁️']; + } + + protected get indexerRegex(): RegExp | undefined { + return this.getRegexForTextAfterEmojis(this.indexerEmojis); + } + + protected get ageRegex(): RegExp | undefined { + return undefined; + } + + protected getRegexForTextAfterEmojis(emojis: string[]): RegExp { + return new RegExp( + `(?:${emojis.join('|')})\\s*([^\\p{Emoji_Presentation}\\n]*?)(?=\\p{Emoji_Presentation}|$|\\n)`, + 'u' + ); + } + + constructor(protected readonly addon: Addon) {} + + parse(stream: Stream): ParsedStream { + let parsedStream: ParsedStream = { + id: this.getRandomId(), + addon: this.addon, + type: 'http', + url: this.applyUrlModifications(stream.url ?? undefined), + externalUrl: stream.externalUrl ?? undefined, + ytId: stream.ytId ?? undefined, + requestHeaders: stream.behaviorHints?.proxyHeaders?.request, + responseHeaders: stream.behaviorHints?.proxyHeaders?.response, + notWebReady: stream.behaviorHints?.notWebReady ?? undefined, + videoHash: stream.behaviorHints?.videoHash ?? undefined, + originalName: stream.name ?? undefined, + originalDescription: (stream.description || stream.title) ?? undefined, + }; + + stream.description = stream.description || stream.title; + + this.raiseErrorIfNecessary(stream, parsedStream); + + parsedStream.error = this.getError(stream, parsedStream); + if (parsedStream.error) { + parsedStream.type = constants.ERROR_STREAM_TYPE; + return parsedStream; + } + + const normaliseText = (text: string) => { + return text + .replace( + /(mkv|mp4|avi|mov|wmv|flv|webm|m4v|mpg|mpeg|3gp|3g2|m2ts|ts|vob|ogv|ogm|divx|xvid|rm|rmvb|asf|mxf|mka|mks|mk3d|webm|f4v|f4p|f4a|f4b)$/i, + '' + ) + .replace(/[^\p{L}\p{N}+]/gu, '') + + .toLowerCase() + .trim(); + }; + + parsedStream.filename = this.getFilename(stream, parsedStream); + parsedStream.folderName = this.getFolder(stream, parsedStream); + if ( + parsedStream.folderName && + parsedStream.filename && + normaliseText(parsedStream.folderName) === + normaliseText(parsedStream.filename) + ) { + parsedStream.folderName = undefined; + } + parsedStream.size = this.getSize(stream, parsedStream); + parsedStream.folderSize = this.getFolderSize(stream, parsedStream); + parsedStream.indexer = this.getIndexer(stream, parsedStream); + parsedStream.service = this.getService(stream, parsedStream); + parsedStream.duration = this.getDuration(stream, parsedStream); + parsedStream.type = this.getStreamType( + stream, + parsedStream.service, + parsedStream + ); + parsedStream.library = this.getInLibrary(stream, parsedStream); + parsedStream.age = this.getAge(stream, parsedStream); + parsedStream.message = this.getMessage(stream, parsedStream); + + parsedStream.parsedFile = this.getParsedFile(stream, parsedStream); + + parsedStream.torrent = { + infoHash: + parsedStream.type === 'p2p' + ? (stream.infoHash ?? undefined) + : this.getInfoHash(stream, parsedStream), + seeders: this.getSeeders(stream, parsedStream), + sources: stream.sources ?? undefined, + fileIdx: stream.fileIdx ?? undefined, + }; + + return parsedStream; + } + + protected getRandomId(): string { + return `${this.addon.instanceId}-${this.count++}`; + } + + protected applyUrlModifications(url: string | undefined): string | undefined { + return url; + } + + protected raiseErrorIfNecessary( + stream: Stream, + currentParsedStream: ParsedStream + ) { + if (!this.errorRegexes) { + return; + } + for (const errorRegex of this.errorRegexes) { + if (errorRegex.pattern.test(stream.description || stream.title || '')) { + throw new Error(errorRegex.message); + } + } + } + + protected getError( + stream: Stream, + currentParsedStream: ParsedStream + ): ParsedStream['error'] | undefined { + return undefined; + } + + protected getFilename( + stream: Stream, + currentParsedStream: ParsedStream + ): string | undefined { + let filename = stream.behaviorHints?.filename; + + if (filename) { + return filename; + } + + const description = stream.description || stream.title; + if (!description) { + return undefined; + } + + if (this.filenameRegex) { + const match = description.match(this.filenameRegex); + if (match) { + return match[1]; + } + } + + // attempt to find a filename by finding the most suitable line that has more info + const potentialFilenames = description + .split('\n') + .filter((line) => line.trim() !== '') + .splice(0, 5); + + for (const line of potentialFilenames) { + const parsed = FileParser.parse(line); + if (parsed.year || (parsed.season && parsed.episode) || parsed.episode) { + filename = line; + break; + } + } + + if (!filename) { + filename = description.split('\n')[0]; + } + return filename + ?.trim() + ?.replace(/^\p{Emoji_Presentation}+/gu, '') + ?.replace(/^[^:]+:\s*/g, ''); + } + + protected getFolder( + stream: Stream, + currentParsedStream: ParsedStream + ): string | undefined { + if (this.folderNameRegex) { + const match = stream.description?.match(this.folderNameRegex); + if (match) { + return match[1]; + } + } + return undefined; + } + + protected getSize( + stream: Stream, + currentParsedStream: ParsedStream + ): number | undefined { + let description = stream.description || stream.title; + if (currentParsedStream.filename && description) { + description = description.replace(currentParsedStream.filename, ''); + } + if (currentParsedStream.folderName && description) { + description = description.replace(currentParsedStream.folderName, ''); + } + let size = + stream.behaviorHints?.videoSize || + (stream as any).size || + (stream as any).sizeBytes || + (stream as any).sizebytes || + (description && this.calculateBytesFromSizeString(description)) || + (stream.name && this.calculateBytesFromSizeString(stream.name)); + + if (typeof size === 'string') { + size = parseInt(size); + } else if (typeof size === 'number') { + size = Math.round(size); + } + + return size; + } + + protected getFolderSize( + stream: Stream, + currentParsedStream: ParsedStream + ): number | undefined { + return undefined; + } + + protected getSeeders( + stream: Stream, + currentParsedStream: ParsedStream + ): number | undefined { + const regex = this.seedersRegex; + if (!regex) { + return undefined; + } + const match = stream.description?.match(regex); + if (match) { + return parseInt(match[1]); + } + + return undefined; + } + + protected getAge( + stream: Stream, + currentParsedStream: ParsedStream + ): string | undefined { + const regex = this.ageRegex; + if (!regex) { + return undefined; + } + const match = stream.description?.match(regex); + if (match) { + return match[1]; + } + + return undefined; + } + + protected getIndexer( + stream: Stream, + currentParsedStream: ParsedStream + ): string | undefined { + const regex = this.indexerRegex; + if (!regex) { + return undefined; + } + const match = stream.description?.match(regex); + if (match) { + return match[1]; + } + + return undefined; + } + + protected getMessage( + stream: Stream, + currentParsedStream: ParsedStream + ): string | undefined { + return undefined; + } + + protected getService( + stream: Stream, + currentParsedStream: ParsedStream + ): ParsedStream['service'] | undefined { + return this.parseServiceData(stream.name || ''); + } + + protected getInfoHash( + stream: Stream, + currentParsedStream: ParsedStream + ): string | undefined { + return stream.url + ? stream.url.match(/(?<=[-/[(;:&])[a-fA-F0-9]{40}(?=[-\]\)/:;&])/)?.[0] + : undefined; + } + + protected getDuration( + stream: Stream, + currentParsedStream: ParsedStream + ): number | undefined { + // Regular expression to match different formats of time durations + const regex = + /(? { + const possibleLanguages = FULL_LANGUAGE_MAPPING.filter( + (language) => language.flag === flag + ); + + const language = + possibleLanguages.find((l) => l.flag_priority) || + possibleLanguages[0]; + const languageName = ( + language?.internal_english_name || language?.english_name + ) + ?.split('(')?.[0] + ?.trim(); + + if (languageName && constants.LANGUAGES.includes(languageName as any)) { + return languageName; + } + return undefined; + }) + .filter((language) => language !== undefined); + return languages; + } + + protected convertISO6392ToLanguage(code: string): string | undefined { + const lang = FULL_LANGUAGE_MAPPING.find( + (language) => language.iso_639_2 === code + ); + return lang?.english_name?.split('(')?.[0]?.trim(); + } + + protected getInLibrary( + stream: Stream, + currentParsedStream: ParsedStream + ): boolean { + return this.addon.library ?? false; + } + + protected calculateBytesFromSizeString( + size: string, + sizeRegex?: RegExp + ): number | undefined { + const k = this.sizeK; + const sizePattern = sizeRegex || this.sizeRegex; + if (!sizePattern) { + return undefined; + } + const match = size.match(sizePattern); + if (!match) return 0; + const value = parseFloat(match[1]); + const unit = match[3]; + + switch (unit.toUpperCase()) { + case 'TB': + return value * k * k * k * k; + case 'GB': + return value * k * k * k; + case 'MB': + return value * k * k; + case 'KB': + return value * k; + default: + return 0; + } + } + + protected parseServiceData( + string: string + ): ParsedStream['service'] | undefined { + const cleanString = string.replace(/web-?dl/i, ''); + const services = constants.SERVICE_DETAILS; + const cachedSymbols = ['+', '⚡', '🚀', 'cached']; + const uncachedSymbols = ['⏳', 'download', 'UNCACHED']; + let streamService: ParsedStream['service'] | undefined; + Object.values(services).forEach((service) => { + // for each service, generate a regexp which creates a regex with all known names separated by | + const regex = new RegExp( + `(^|(? string.includes(symbol))) { + cached = false; + } + // check if any of the cachedSymbols are in the string + else if (cachedSymbols.some((symbol) => string.includes(symbol))) { + cached = true; + } + + streamService = { + id: service.id, + cached: cached, + }; + } + }); + return streamService; + } +} + +export default StreamParser; diff --git a/packages/core/src/presets/aiostreams.ts b/packages/core/src/presets/aiostreams.ts new file mode 100644 index 0000000000000000000000000000000000000000..e436fd2effa1b204c755509cfe8f8be43ea763d8 --- /dev/null +++ b/packages/core/src/presets/aiostreams.ts @@ -0,0 +1,159 @@ +import { + Addon, + Option, + UserData, + ParsedStream, + Stream, + AIOStream, +} from '../db'; +import { Preset, baseOptions } from './preset'; +import { Env, formatZodError, RESOURCES } from '../utils'; +import { StreamParser } from '../parser'; +import { createLogger } from '../utils'; + +const logger = createLogger('parser'); + +class AIOStreamsStreamParser extends StreamParser { + override parse(stream: Stream): ParsedStream { + const aioStream = stream as AIOStream; + const parsed = AIOStream.safeParse(aioStream); + if (!parsed.success) { + logger.error( + `Stream from AIOStream was not detected as a valid stream: ${formatZodError(parsed.error)}` + ); + throw new Error('Invalid stream'); + } + const addonName = this.addon?.name?.trim(); + return { + id: this.getRandomId(), + addon: { + ...this.addon, + name: addonName + ? `${addonName} | ${aioStream.streamData?.addon ?? ''}` + : (aioStream.streamData?.addon ?? ''), + }, + error: aioStream.streamData?.error, + type: aioStream.streamData?.type ?? 'http', + url: aioStream.url ?? undefined, + externalUrl: aioStream.externalUrl ?? undefined, + ytId: aioStream.ytId ?? undefined, + requestHeaders: aioStream.behaviorHints?.proxyHeaders?.request, + responseHeaders: aioStream.behaviorHints?.proxyHeaders?.response, + notWebReady: aioStream.behaviorHints?.notWebReady ?? undefined, + videoHash: aioStream.behaviorHints?.videoHash ?? undefined, + filename: aioStream.streamData?.filename, + folderName: aioStream.streamData?.folderName, + size: aioStream.streamData?.size, + folderSize: aioStream.streamData?.folderSize, + indexer: aioStream.streamData?.indexer, + service: aioStream.streamData?.service, + duration: aioStream.streamData?.duration, + library: aioStream.streamData?.library ?? false, + age: aioStream.streamData?.age, + message: aioStream.streamData?.message, + torrent: aioStream.streamData?.torrent, + parsedFile: aioStream.streamData?.parsedFile, + keywordMatched: aioStream.streamData?.keywordMatched, + streamExpressionMatched: aioStream.streamData?.streamExpressionMatched, + regexMatched: aioStream.streamData?.regexMatched, + originalName: aioStream.name ?? undefined, + originalDescription: (aioStream.description || stream.title) ?? undefined, + }; + } +} + +export class AIOStreamsPreset extends Preset { + static override getParser(): typeof StreamParser { + return AIOStreamsStreamParser; + } + + static override get METADATA() { + const options: Option[] = [ + { + id: 'name', + name: 'Name', + description: + "What to call this addon. Leave empty if you don't want to include the name of this addon in the stream results.", + type: 'string', + required: true, + default: 'AIOStreams', + }, + { + id: 'manifestUrl', + name: 'Manifest URL', + description: 'Provide the Manifest URL for this AIOStreams addon.', + type: 'url', + required: true, + }, + { + id: 'timeout', + name: 'Timeout', + description: 'The timeout for this addon', + type: 'number', + default: Env.DEFAULT_TIMEOUT, + constraints: { + min: Env.MIN_TIMEOUT, + max: Env.MAX_TIMEOUT, + }, + }, + { + id: 'resources', + name: 'Resources', + description: + 'Optionally override the resources that are fetched from this addon ', + type: 'multi-select', + required: false, + default: undefined, + options: RESOURCES.map((resource) => ({ + label: resource, + value: resource, + })), + }, + ]; + + return { + ID: 'aiostreams', + NAME: 'AIOStreams', + LOGO: 'https://raw.githubusercontent.com/Viren070/AIOStreams/refs/heads/main/packages/frontend/public/assets/logo.png', + URL: '', + TIMEOUT: Env.DEFAULT_TIMEOUT, + USER_AGENT: Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: [], + DESCRIPTION: 'Wrap AIOStreams within AIOStreams!', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [], + SUPPORTED_RESOURCES: [], + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + if (!options.manifestUrl.endsWith('/manifest.json')) { + throw new Error( + `${options.name} has an invalid Manifest URL. It must be a valid link to a manifest.json` + ); + } + return [this.generateAddon(userData, options)]; + } + + private static generateAddon( + userData: UserData, + options: Record + ): Addon { + return { + name: options.name || this.METADATA.NAME, + manifestUrl: options.manifestUrl.replace('stremio://', 'https://'), + enabled: true, + library: false, + resources: options.resources || undefined, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } +} diff --git a/packages/core/src/presets/animeCatalogs.ts b/packages/core/src/presets/animeCatalogs.ts new file mode 100644 index 0000000000000000000000000000000000000000..82fbdc9235b5ef7c33fc985f221d7776877af26a --- /dev/null +++ b/packages/core/src/presets/animeCatalogs.ts @@ -0,0 +1,291 @@ +import { Addon, Option, UserData } from '../db'; +import { Preset, baseOptions } from './preset'; +import { constants, Env } from '../utils'; + +export class AnimeCatalogsPreset extends Preset { + private static malCatalogs = [ + { + label: 'Top All Time', + value: 'myanimelist_top-all-time', + }, + { + label: 'Top Airing', + value: 'myanimelist_top-airing', + }, + { + label: 'Top Series', + value: 'myanimelist_top-series', + }, + { + label: 'Top Movies', + value: 'myanimelist_top-movies', + }, + { + label: 'Popular', + value: 'myanimelist_popular', + }, + { + label: 'Most Favorited', + value: 'myanimelist_most-favorited', + }, + ]; + + private static anidbCatalogs = [ + { + label: 'Popular', + value: 'anidb_popular', + }, + { + label: 'Latest Started', + value: 'anidb_latest-started', + }, + { + label: 'Latest Ended', + value: 'anidb_latest-ended', + }, + { + label: 'Best of 10s', + value: 'anidb_best-of-10s', + }, + { + label: 'Best of 00s', + value: 'anidb_best-of-00s', + }, + { + label: 'Best of 90s', + value: 'anidb_best-of-90s', + }, + { + label: 'Best of 80s', + value: 'anidb_best-of-80s', + }, + ]; + + private static anilistCatalogs = [ + { + label: 'Trending Now', + value: 'anilist_trending-now', + }, + { + label: 'Popular This Season', + value: 'anilist_popular-this-season', + }, + { + label: 'Upcoming Next Season', + value: 'anilist_upcoming-next-season', + }, + { + label: 'All Time Popular', + value: 'anilist_all-time-popular', + }, + { + label: 'Top Anime', + value: 'anilist_top-anime', + }, + ]; + + private static kitsuCatalogs = [ + { + label: 'Top Airing', + value: 'kitsu_top-airing', + }, + { + label: 'Most Popular', + value: 'kitsu_most-popular', + }, + { + label: 'Highest Rated', + value: 'kitsu_highest-rated', + }, + { + label: 'Newest', + value: 'kitsu_newest', + }, + ]; + + private static anisearchCatalogs = [ + { + label: 'Top All Time', + value: 'anisearch_top-all-time', + }, + { + label: 'Trending', + value: 'anisearch_trending', + }, + { + label: 'Popular', + value: 'anisearch_popular', + }, + ]; + + private static livechartCatalogs = [ + { + label: 'Popular', + value: 'livechart_popular', + }, + { + label: 'Top Rated', + value: 'livechart_top-rated', + }, + ]; + + private static notifymoeCatalogs = [ + { + label: 'Airing Now', + value: 'notifymoe_airing-now', + }, + ]; + + static override get METADATA() { + const supportedResources = [constants.CATALOG_RESOURCE]; + + const options: Option[] = [ + ...baseOptions( + 'Anime Catalogs', + supportedResources, + Env.DEFAULT_ANIME_CATALOGS_TIMEOUT + ).filter((option) => option.id !== 'url'), + { + id: 'dubbed', + name: 'Show Dubbed content only', + description: 'Only show items that have a dubbed version available', + type: 'boolean', + default: false, + }, + { + id: 'cinemeta', + name: 'Use Cinemeta Ordering', + description: + 'Order results using Cinemeta metadata rather than Kitsu (one entry for all seasons rather than one entry per season)', + type: 'boolean', + default: false, + }, + { + id: 'search', + name: 'Enable Search', + description: 'Enable searching for anime using Kitsu', + type: 'boolean', + default: true, + }, + { + id: 'mal_catalogs', + name: 'MyAnimeList Catalogs', + description: 'MyAnimeList catalog sections to include', + type: 'multi-select', + options: this.malCatalogs, + default: [], + }, + { + id: 'anidb_catalogs', + name: 'AniDB Catalogs', + description: 'AniDB catalog sections to include', + type: 'multi-select', + options: this.anidbCatalogs, + default: [], + }, + { + id: 'anilist_catalogs', + name: 'AniList Catalogs', + description: 'AniList catalog sections to include', + type: 'multi-select', + options: this.anilistCatalogs, + default: [], + }, + { + id: 'kitsu_catalogs', + name: 'Kitsu Catalogs', + description: 'Kitsu catalog sections to include', + type: 'multi-select', + options: this.kitsuCatalogs, + default: [], + }, + { + id: 'anisearch_catalogs', + name: 'AniSearch Catalogs', + description: 'AniSearch catalog sections to include', + type: 'multi-select', + options: this.anisearchCatalogs, + default: [], + }, + { + id: 'livechart_catalogs', + name: 'LiveChart Catalogs', + description: 'LiveChart catalog sections to include', + type: 'multi-select', + options: this.livechartCatalogs, + default: [], + }, + { + id: 'notifymoe_catalogs', + name: 'Notify.moe Catalogs', + description: 'Notify.moe catalog sections to include', + type: 'multi-select', + options: this.notifymoeCatalogs, + default: [], + }, + ]; + + return { + ID: 'anime-catalogs', + NAME: 'Anime Catalogs', + LOGO: `${Env.ANIME_CATALOGS_URL}/addon-logo.png`, + URL: Env.ANIME_CATALOGS_URL, + TIMEOUT: Env.DEFAULT_ANIME_CATALOGS_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: + Env.DEFAULT_ANIME_CATALOGS_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: [], + DESCRIPTION: + 'Catalogs for your favourite anime from: MyAnimeList, AniDB, AniList, Kitsu, aniSearch, LiveChart.me, Notify.Moe', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [], + SUPPORTED_RESOURCES: supportedResources, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + return [this.generateAddon(userData, options)]; + } + + private static generateAddon( + userData: UserData, + options: Record + ): Addon { + // generate a joint list of all catalogs + const enabledCatalogs = [ + ...(options.mal_catalogs || []), + ...(options.anidb_catalogs || []), + ...(options.anilist_catalogs || []), + ...(options.kitsu_catalogs || []), + ...(options.anisearch_catalogs || []), + ...(options.livechart_catalogs || []), + ...(options.notifymoe_catalogs || []), + ]; + // {"dubbed":"on","cinemeta":"on","search":"on","myanimelist_top-all-time":"on","myanimelist_top-airing":"on","myanimelist_top-series":"on","myanimelist_top-movies":"on","myanimelist_popular":"on","myanimelist_most-favorited":"on","anidb_popular":"on","anidb_latest-started":"on","anidb_latest-ended":"on","anidb_best-of-10s":"on","anidb_best-of-00s":"on","anidb_best-of-90s":"on","anidb_best-of-80s":"on","anilist_trending-now":"on","anilist_popular-this-season":"on","anilist_upcoming-next-season":"on","anilist_all-time-popular":"on","anilist_top-anime":"on","kitsu_top-airing":"on","kitsu_most-popular":"on","kitsu_highest-rated":"on","kitsu_newest":"on","anisearch_top-all-time":"on","anisearch_trending":"on","anisearch_popular":"on","livechart_popular":"on","livechart_top-rated":"on","notifymoe_airing-now":"on"} + const config = this.urlEncodeJSON({ + dubbed: options.dubbed ? 'on' : undefined, + cinemeta: options.cinemeta ? 'on' : undefined, + search: options.search ? 'on' : undefined, + ...enabledCatalogs.reduce((acc, catalog) => { + acc[catalog] = 'on'; + return acc; + }, {}), + }); + return { + name: options.name || this.METADATA.NAME, + identifier: '', + manifestUrl: `${Env.ANIME_CATALOGS_URL}/${config}/manifest.json`, + enabled: true, + library: false, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } +} diff --git a/packages/core/src/presets/animeKitsu.ts b/packages/core/src/presets/animeKitsu.ts new file mode 100644 index 0000000000000000000000000000000000000000..6ddffef92cff311a6b3cda285f73142abb67949f --- /dev/null +++ b/packages/core/src/presets/animeKitsu.ts @@ -0,0 +1,75 @@ +import { Addon, Option, UserData } from '../db'; +import { Preset, baseOptions } from './preset'; +import { constants, Env } from '../utils'; + +export class AnimeKitsuPreset extends Preset { + static override get METADATA() { + const supportedResources = [ + constants.CATALOG_RESOURCE, + constants.META_RESOURCE, + ]; + + const options: Option[] = [ + ...baseOptions( + 'Anime Kitsu', + supportedResources, + Env.DEFAULT_ANIME_KITSU_TIMEOUT + ), + { + id: 'socials', + name: '', + description: '', + type: 'socials', + socials: [ + { + id: 'github', + url: 'https://github.com/TheBeastLT/stremio-kitsu-anime', + }, + ], + }, + ]; + + return { + ID: 'anime-kitsu', + NAME: 'Anime Kitsu', + LOGO: 'https://i.imgur.com/7N6XGoO.png', + URL: Env.ANIME_KITSU_URL, + TIMEOUT: Env.DEFAULT_ANIME_KITSU_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: Env.DEFAULT_ANIME_KITSU_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: [], + DESCRIPTION: 'Anime catalog using Kitsu', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [], + SUPPORTED_RESOURCES: supportedResources, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + return [this.generateAddon(userData, options)]; + } + + private static generateAddon( + userData: UserData, + options: Record + ): Addon { + const baseUrl = options.url + ? new URL(options.url).origin + : Env.ANIME_KITSU_URL; + return { + name: options.name || this.METADATA.NAME, + manifestUrl: `${baseUrl}/manifest.json`, + enabled: true, + library: false, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } +} diff --git a/packages/core/src/presets/argentinaTv.ts b/packages/core/src/presets/argentinaTv.ts new file mode 100644 index 0000000000000000000000000000000000000000..4dcaff26badf4f07bc9a43b782241417756684bb --- /dev/null +++ b/packages/core/src/presets/argentinaTv.ts @@ -0,0 +1,111 @@ +import { + Addon, + Option, + ParsedFile, + ParsedStream, + Stream, + UserData, +} from '../db'; +import { Preset, baseOptions } from './preset'; +import { constants, Env, LIVE_STREAM_TYPE } from '../utils'; +import { FileParser, StreamParser } from '../parser'; + +class ArgentinaTvStreamParser extends StreamParser { + protected override getParsedFile( + stream: Stream, + parsedStream: ParsedStream + ): ParsedFile | undefined { + const parsed = stream.name ? FileParser.parse(stream.name) : undefined; + if (!parsed) { + return undefined; + } + return { + ...parsed, + title: undefined, + }; + } + protected override getFilename( + stream: Stream, + currentParsedStream: ParsedStream + ): string | undefined { + return undefined; + } + + protected override getMessage( + stream: Stream, + currentParsedStream: ParsedStream + ): string | undefined { + return `${stream.name} - ${stream.description}`; + } +} + +export class ArgentinaTVPreset extends Preset { + static override getParser(): typeof StreamParser { + return ArgentinaTvStreamParser; + } + + static override get METADATA() { + const supportedResources = [ + constants.CATALOG_RESOURCE, + constants.META_RESOURCE, + constants.STREAM_RESOURCE, + ]; + + const options: Option[] = [ + ...baseOptions( + 'Argentina TV', + supportedResources, + Env.DEFAULT_ARGENTINA_TV_TIMEOUT + ), + ]; + + return { + ID: 'argentina-tv', + NAME: 'Argentina TV', + LOGO: `${Env.ARGENTINA_TV_URL}/public/logo.png`, + URL: Env.ARGENTINA_TV_URL, + TIMEOUT: Env.DEFAULT_ARGENTINA_TV_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: Env.DEFAULT_ARGENTINA_TV_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: [], + DESCRIPTION: + 'Provides access to channels across various categories for Argentina', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [LIVE_STREAM_TYPE], + SUPPORTED_RESOURCES: supportedResources, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + return [this.generateAddon(userData, options)]; + } + + private static generateAddon( + userData: UserData, + options: Record + ): Addon { + const baseUrl = options.url + ? new URL(options.url).origin + : this.METADATA.URL; + + const url = options.url?.endsWith('/manifest.json') + ? options.url + : `${baseUrl}/manifest.json`; + + return { + name: options.name || this.METADATA.NAME, + manifestUrl: url, + enabled: true, + library: false, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } +} diff --git a/packages/core/src/presets/comet.ts b/packages/core/src/presets/comet.ts new file mode 100644 index 0000000000000000000000000000000000000000..ffd48d7f9760499b7edd05ef211a667c1d562a8c --- /dev/null +++ b/packages/core/src/presets/comet.ts @@ -0,0 +1,209 @@ +import { Addon, Option, UserData, Resource } from '../db'; +import { baseOptions, Preset } from './preset'; +import { Env } from '../utils'; +import { constants, ServiceId } from '../utils'; +import { StreamParser } from '../parser'; + +class CometStreamParser extends StreamParser { + override applyUrlModifications(url: string | undefined): string | undefined { + if (!url) { + return url; + } + if ( + Env.FORCE_COMET_HOSTNAME !== undefined || + Env.FORCE_COMET_PORT !== undefined || + Env.FORCE_COMET_PROTOCOL !== undefined + ) { + // modify the URL according to settings, needed when using a local URL for requests but a public stream URL is needed. + const urlObj = new URL(url); + + if (Env.FORCE_COMET_PROTOCOL !== undefined) { + urlObj.protocol = Env.FORCE_COMET_PROTOCOL; + } + if (Env.FORCE_COMET_PORT !== undefined) { + urlObj.port = Env.FORCE_COMET_PORT.toString(); + } + if (Env.FORCE_COMET_HOSTNAME !== undefined) { + urlObj.hostname = Env.FORCE_COMET_HOSTNAME; + } + return urlObj.toString(); + } + return url; + } +} + +export class CometPreset extends Preset { + static override getParser(): typeof StreamParser { + return CometStreamParser; + } + + static override get METADATA() { + const supportedServices: ServiceId[] = [ + constants.REALDEBRID_SERVICE, + constants.PREMIUMIZE_SERVICE, + constants.ALLEDEBRID_SERVICE, + constants.TORBOX_SERVICE, + constants.EASYDEBRID_SERVICE, + constants.DEBRIDLINK_SERVICE, + constants.OFFCLOUD_SERVICE, + constants.PIKPAK_SERVICE, + ]; + + const supportedResources = [constants.STREAM_RESOURCE]; + + const options: Option[] = [ + ...baseOptions('Comet', supportedResources, Env.DEFAULT_COMET_TIMEOUT), + { + id: 'includeP2P', + name: 'Include P2P', + description: 'Include P2P results, even if a debrid service is enabled', + type: 'boolean', + default: false, + }, + { + id: 'removeTrash', + name: 'Remove Trash', + description: + 'Remove all trash from results (Adult Content, CAM, Clean Audio, PDTV, R5, Screener, Size, Telecine and Telesync)', + type: 'boolean', + default: true, + }, + { + id: 'services', + name: 'Services', + description: + 'Optionally override the services that are used. If not specified, then the services that are enabled and supported will be used.', + type: 'multi-select', + required: false, + options: supportedServices.map((service) => ({ + value: service, + label: constants.SERVICE_DETAILS[service].name, + })), + default: undefined, + emptyIsUndefined: true, + }, + { + id: 'socials', + name: '', + description: '', + type: 'socials', + socials: [ + { + id: 'github', + url: 'https://github.com/g0ldyy/comet', + }, + { + id: 'ko-fi', + url: 'https://ko-fi.com/g0ldyy', + }, + ], + }, + ]; + + return { + ID: 'comet', + NAME: 'Comet', + LOGO: 'https://i.imgur.com/jmVoVMu.jpeg', + URL: Env.COMET_URL, + TIMEOUT: Env.DEFAULT_COMET_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: Env.DEFAULT_COMET_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: supportedServices, + DESCRIPTION: "Stremio's fastest Torrent/Debrid addon", + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [ + constants.P2P_STREAM_TYPE, + constants.DEBRID_STREAM_TYPE, + ], + SUPPORTED_RESOURCES: supportedResources, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + // url can either be something like https://torrentio.com/ or it can be a custom manifest url. + // if it is a custom manifest url, return a single addon with the custom manifest url. + if (options?.url?.endsWith('/manifest.json')) { + return [this.generateAddon(userData, options, undefined)]; + } + + const usableServices = this.getUsableServices(userData, options.services); + // if no services are usable, use p2p + if (!usableServices || usableServices.length === 0) { + return [this.generateAddon(userData, options, undefined)]; + } + + let addons = usableServices.map((service) => + this.generateAddon(userData, options, service.id) + ); + + if (options.includeP2P) { + addons.push(this.generateAddon(userData, options, undefined)); + } + + return addons; + } + + private static generateAddon( + userData: UserData, + options: Record, + serviceId?: ServiceId + ): Addon { + return { + name: options.name || this.METADATA.NAME, + identifier: serviceId + ? `${constants.SERVICE_DETAILS[serviceId].shortName}` + : options.url?.endsWith('/manifest.json') + ? undefined + : 'p2p', + manifestUrl: this.generateManifestUrl(userData, options, serviceId), + enabled: true, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } + + private static generateManifestUrl( + userData: UserData, + options: Record, + serviceId: ServiceId | undefined + ) { + let url = options.url || this.METADATA.URL; + if (url.endsWith('/manifest.json')) { + return url; + } + url = url.replace(/\/$/, ''); + const configString = this.base64EncodeJSON({ + maxResultsPerResolution: 0, + maxSize: 0, + cachedOnly: false, + removeTrash: options.removeTrash ?? true, + resultFormat: ['all'], + debridService: serviceId || 'torrent', + debridApiKey: serviceId + ? this.getServiceCredential(serviceId, userData, { + [constants.OFFCLOUD_SERVICE]: (credentials: any) => + `${credentials.email}:${credentials.password}`, + [constants.PIKPAK_SERVICE]: (credentials: any) => + `${credentials.email}:${credentials.password}`, + }) + : '', + debridStreamProxyPassword: '', + languages: { required: [], exclude: [], preferred: [] }, + resolutions: {}, + options: { + remove_ranks_under: -10000000000, + allow_english_in_languages: false, + remove_unknown_languages: false, + }, + }); + + return `${url}${configString ? '/' + configString : ''}/manifest.json`; + } +} diff --git a/packages/core/src/presets/custom.ts b/packages/core/src/presets/custom.ts new file mode 100644 index 0000000000000000000000000000000000000000..266013133d375bfa0f1cb267f159c504a95db452 --- /dev/null +++ b/packages/core/src/presets/custom.ts @@ -0,0 +1,111 @@ +import { Addon, Option, UserData } from '../db'; +import { Preset, baseOptions } from './preset'; +import { Env, RESOURCES } from '../utils'; + +export class CustomPreset extends Preset { + static override get METADATA() { + const options: Option[] = [ + { + id: 'name', + name: 'Name', + description: 'What to call this addon', + type: 'string', + required: true, + default: 'Custom Addon', + }, + { + id: 'manifestUrl', + name: 'Manifest URL', + description: 'Provide the Manifest URL for this custom addon.', + type: 'url', + required: true, + }, + { + id: 'libraryAddon', + name: 'Library Addon', + description: + 'Whether to mark this addon as a library addon. This will result in all streams from this addon being marked as library streams.', + type: 'boolean', + required: false, + default: false, + }, + { + id: 'streamPassthrough', + name: 'Stream Passthrough', + description: + 'Whether to pass through the stream formatting. This means your formatting will not be applied and original stream formatting is retained.', + type: 'boolean', + }, + { + id: 'timeout', + name: 'Timeout', + description: 'The timeout for this addon', + type: 'number', + default: Env.DEFAULT_TIMEOUT, + constraints: { + min: Env.MIN_TIMEOUT, + max: Env.MAX_TIMEOUT, + }, + }, + { + id: 'resources', + name: 'Resources', + description: + 'Optionally override the resources that are fetched from this addon ', + type: 'multi-select', + required: false, + default: undefined, + options: RESOURCES.map((resource) => ({ + label: resource, + value: resource, + })), + }, + ]; + + return { + ID: 'custom', + NAME: 'Custom', + LOGO: '', + URL: '', + TIMEOUT: Env.DEFAULT_TIMEOUT, + USER_AGENT: Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: [], + DESCRIPTION: 'Add your own addon by providing its Manifest URL.', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [], + SUPPORTED_RESOURCES: [], + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + if (!options.manifestUrl.endsWith('/manifest.json')) { + throw new Error( + `${options.name} has an invalid Manifest URL. It must be a valid link to a manifest.json` + ); + } + return [this.generateAddon(userData, options)]; + } + + private static generateAddon( + userData: UserData, + options: Record + ): Addon { + return { + name: options.name || this.METADATA.NAME, + manifestUrl: options.manifestUrl, + enabled: true, + library: options.libraryAddon ?? false, + resources: options.resources || undefined, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + streamPassthrough: options.streamPassthrough ?? false, + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } +} diff --git a/packages/core/src/presets/dcUniverse.ts b/packages/core/src/presets/dcUniverse.ts new file mode 100644 index 0000000000000000000000000000000000000000..d02b06d10c9d155933048e8bfbe11d94c9f8428a --- /dev/null +++ b/packages/core/src/presets/dcUniverse.ts @@ -0,0 +1,135 @@ +import { Addon, Option, UserData } from '../db'; +import { Preset, baseOptions } from './preset'; +import { constants, Env } from '../utils'; + +export class DcUniversePreset extends Preset { + // dc-batman-animations%2C + // dc-superman-animations%2C + // dc-batman%2C + // dc-superman + private static catalogs = [ + { + label: 'DC Chronological Order', + value: 'dc-chronological', + }, + { + label: 'DC Release Order', + value: 'dc-release', + }, + { + label: 'Movies', + value: 'dc-movies', + }, + { + label: 'DCEU Movies', + value: 'dceu_movies', + }, + { + label: 'Series', + value: 'dc-series', + }, + { + label: 'DC Modern Series', + value: 'dc_modern_series', + }, + { + label: 'Animations', + value: 'dc-animations', + }, + { + label: 'Batman Animations', + value: 'dc-batman-animations', + }, + { + label: 'Superman Animations', + value: 'dc-superman-animations', + }, + { + label: 'Batman Collection', + value: 'dc-batman', + }, + { + label: 'Superman Collection', + value: 'dc-superman', + }, + ]; + static override get METADATA() { + const supportedResources = [ + constants.CATALOG_RESOURCE, + constants.META_RESOURCE, + ]; + + const options: Option[] = [ + ...baseOptions( + 'DC Universe', + supportedResources, + Env.DEFAULT_DC_UNIVERSE_TIMEOUT + ).filter((option) => option.id !== 'url'), + // series movies animations xmen release-order marvel-mcu + { + id: 'catalogs', + name: 'Catalogs', + description: 'The catalogs to display', + type: 'multi-select', + required: true, + options: this.catalogs, + default: this.catalogs.map((catalog) => catalog.value), + }, + { + id: 'socials', + name: '', + description: '', + type: 'socials', + socials: [ + { id: 'github', url: 'https://github.com/tapframe/addon-dc' }, + { id: 'ko-fi', url: 'https://ko-fi.com/tapframe' }, + ], + }, + ]; + + return { + ID: 'dc-universe', + NAME: 'DC Universe', + LOGO: 'https://raw.githubusercontent.com/tapframe/addon-dc/refs/heads/main/assets/icon.png', + URL: Env.DC_UNIVERSE_URL, + TIMEOUT: Env.DEFAULT_DC_UNIVERSE_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: Env.DEFAULT_DC_UNIVERSE_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: [], + DESCRIPTION: + 'Explore the DC Universe by release date, movies, series, and animations!', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [], + SUPPORTED_RESOURCES: supportedResources, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + return [this.generateAddon(userData, options)]; + } + + private static generateAddon( + userData: UserData, + options: Record + ): Addon { + const config = + options.catalogs.length !== this.catalogs.length + ? options.catalogs.join('%2C') + : ''; + return { + name: options.name || this.METADATA.NAME, + manifestUrl: `${Env.DC_UNIVERSE_URL}/${config ? 'catalog/' + config + '/' : ''}manifest.json`, + enabled: true, + library: false, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } +} diff --git a/packages/core/src/presets/debridio.ts b/packages/core/src/presets/debridio.ts new file mode 100644 index 0000000000000000000000000000000000000000..59cf81d5779e7f5c9e1c80873a55eec1ae3a1aea --- /dev/null +++ b/packages/core/src/presets/debridio.ts @@ -0,0 +1,158 @@ +import { Addon, Option, UserData, Resource, Stream } from '../db'; +import { Preset, baseOptions } from './preset'; +import { Env, SERVICE_DETAILS } from '../utils'; +import { constants, ServiceId } from '../utils'; +import { StreamParser } from '../parser'; + +export const debridioSocialOption: Option = { + id: 'socials', + name: '', + description: '', + type: 'socials', + socials: [{ id: 'website', url: 'https://debridio.com' }], +}; + +export class DebridioPreset extends Preset { + static override get METADATA() { + const supportedServices: ServiceId[] = [ + constants.REALDEBRID_SERVICE, + constants.ALLEDEBRID_SERVICE, + constants.DEBRIDLINK_SERVICE, + constants.PREMIUMIZE_SERVICE, + constants.TORBOX_SERVICE, + constants.EASYDEBRID_SERVICE, + ]; + const supportedResources = [constants.STREAM_RESOURCE]; + + const options: Option[] = [ + ...baseOptions( + 'Debridio', + supportedResources, + Env.DEFAULT_DEBRIDIO_TIMEOUT + ), + { + id: 'debridioApiKey', + name: 'Debridio API Key', + description: + 'Your Debridio API Key, located at your [account settings](https://debridio.com/account)', + type: 'password', + required: true, + }, + { + id: 'services', + name: 'Services', + description: + 'Optionally override the services that are used. If not specified, then the services that are enabled and supported will be used.', + type: 'multi-select', + required: false, + options: supportedServices.map((service) => ({ + value: service, + label: constants.SERVICE_DETAILS[service].name, + })), + default: undefined, + emptyIsUndefined: true, + }, + debridioSocialOption, + ]; + + return { + ID: 'debridio', + NAME: 'Debridio', + LOGO: 'https://res.cloudinary.com/adobotec/image/upload/w_120,h_120/v1735925306/debridio/logo.png.png', + URL: Env.DEBRIDIO_URL, + TIMEOUT: Env.DEFAULT_DEBRIDIO_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: Env.DEFAULT_DEBRIDIO_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: supportedServices, + DESCRIPTION: 'Torrent streaming using Debrid providers.', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [constants.DEBRID_STREAM_TYPE], + SUPPORTED_RESOURCES: supportedResources, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + if (options?.url?.endsWith('/manifest.json')) { + return [this.generateAddon(userData, options)]; + } + + if (!options.debridioApiKey) { + throw new Error( + `${this.METADATA.NAME} requires a Debridio API Key, please provide one in the configuration` + ); + } + + const usableServices = this.getUsableServices(userData, options.services); + + // if no services are usable, return a single addon with no services + if (!usableServices || usableServices.length === 0) { + throw new Error( + `${this.METADATA.NAME} requires at least one of the following services to be enabled: ${this.METADATA.SUPPORTED_SERVICES.join( + ', ' + )}` + ); + } + + return usableServices.map((service) => + this.generateAddon(userData, options, service.id) + ); + } + + private static generateAddon( + userData: UserData, + options: Record, + service?: ServiceId + ): Addon { + return { + name: options.name || this.METADATA.NAME, + displayIdentifier: service + ? `${constants.SERVICE_DETAILS[service].shortName}` + : 'custom', + identifier: service + ? `${constants.SERVICE_DETAILS[service].shortName}` + : 'custom', + manifestUrl: this.generateManifestUrl(userData, options, service), + enabled: true, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } + + private static generateManifestUrl( + userData: UserData, + options: Record, + service?: ServiceId + ) { + const url = options?.url || this.METADATA.URL; + if (url.endsWith('/manifest.json')) { + return url; + } + if (!service) { + throw new Error( + `${this.METADATA.NAME} requires at least one of the following services to be enabled: ${this.METADATA.SUPPORTED_SERVICES.join( + ', ' + )}` + ); + } + + const configString = this.base64EncodeJSON({ + api_key: options.debridioApiKey, + provider: service, + providerKey: this.getServiceCredential(service, userData), + disableUncached: false, + maxSize: '', + maxReturnPerQuality: '', + resolutions: ['4k', '1440p', '1080p', '720p', '480p', '360p', 'unknown'], + excludedQualities: [], + }); + + return `${url}${configString ? '/' + configString : ''}/manifest.json`; + } +} diff --git a/packages/core/src/presets/debridioTmdb.ts b/packages/core/src/presets/debridioTmdb.ts new file mode 100644 index 0000000000000000000000000000000000000000..592962ff67a3daf4642867d116ba6bb8b38df7c8 --- /dev/null +++ b/packages/core/src/presets/debridioTmdb.ts @@ -0,0 +1,152 @@ +import { Addon, Option, UserData } from '../db'; +import { Preset, baseOptions } from './preset'; +import { constants, Env, FULL_LANGUAGE_MAPPING } from '../utils'; +import { debridioSocialOption } from './debridio'; + +export class DebridioTmdbPreset extends Preset { + static override get METADATA() { + const supportedResources = [ + constants.CATALOG_RESOURCE, + constants.META_RESOURCE, + ]; + + const options: Option[] = [ + ...baseOptions( + 'Debridio TMDB', + supportedResources, + Env.DEFAULT_DEBRIDIO_TMDB_TIMEOUT + ), + { + id: 'debridioApiKey', + name: 'Debridio API Key', + description: + 'Your Debridio API Key, located at your [account settings](https://debridio.com/account)', + type: 'password', + required: true, + }, + { + id: 'language', + name: 'Language', + description: 'The language of the catalogs', + type: 'select', + default: 'en-US', + options: FULL_LANGUAGE_MAPPING.sort((a, b) => + a.english_name.localeCompare(b.english_name) + ).map((language) => ({ + label: language.english_name, + value: `${language.iso_639_1}-${language.iso_3166_1}`, + })), + required: false, + }, + { + id: 'alert', + name: '', + description: + 'The language selector above will not work for some languages due to the option values not being consistent. In which case, you can override the URL with a preconfigured Manifest URL.', + type: 'alert', + }, + debridioSocialOption, + ]; + + return { + ID: 'debridio-tmdb', + NAME: 'Debridio TMDB', + LOGO: 'https://res.cloudinary.com/adobotec/image/upload/w_120,h_120/v1735925306/debridio/logo.png.png', + URL: Env.DEBRIDIO_TMDB_URL, + TIMEOUT: Env.DEFAULT_DEBRIDIO_TMDB_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: + Env.DEFAULT_DEBRIDIO_TMDB_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: [], + DESCRIPTION: 'Catalogs for the Debridio TMDB', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [], + SUPPORTED_RESOURCES: supportedResources, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + if (!options.debridioApiKey && !options.url) { + throw new Error( + 'To access the Debridio addons, you must provide your Debridio API Key' + ); + } + return [this.generateAddon(userData, options)]; + } + + private static generateAddon( + userData: UserData, + options: Record + ): Addon { + let url = this.METADATA.URL; + if (options.url?.endsWith('/manifest.json')) { + url = options.url; + } else { + let baseUrl = this.METADATA.URL; + if (options.url) { + baseUrl = new URL(options.url).origin; + } + // remove trailing slash + baseUrl = baseUrl.replace(/\/$/, ''); + if (!options.debridioApiKey) { + throw new Error( + 'To access the Debridio addons, you must provide your Debridio API Key' + ); + } + const config = this.base64EncodeJSON({ + api_key: options.debridioApiKey, + language: options.language || 'en-US', + rpdb_api: '', + catalogs: [ + { + id: 'debridio_tmdb.movie_trending', + home: true, + enabled: true, + name: 'Trending', + }, + { + id: 'debridio_tmdb.movie_popular', + home: true, + enabled: true, + name: 'Popular', + }, + { + id: 'debridio_tmdb.tv_trending', + home: true, + enabled: true, + name: 'Trending', + }, + { + id: 'debridio_tmdb.tv_popular', + home: true, + enabled: true, + name: 'Popular', + }, + { + id: 'debridio_tmdb.search_collections', + home: false, + enabled: true, + name: 'Search', + }, + ], + }); + url = `${baseUrl}/${config}/manifest.json`; + } + + return { + name: options.name || this.METADATA.NAME, + manifestUrl: url, + enabled: true, + library: false, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } +} diff --git a/packages/core/src/presets/debridioTv.ts b/packages/core/src/presets/debridioTv.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f6f1516c65d724f3284b7c320f40093093c0862 --- /dev/null +++ b/packages/core/src/presets/debridioTv.ts @@ -0,0 +1,179 @@ +import { + Addon, + Option, + ParsedFile, + ParsedStream, + Stream, + UserData, +} from '../db'; +import { Preset, baseOptions } from './preset'; +import { constants, createLogger, Env } from '../utils'; +import { debridioSocialOption } from './debridio'; +import { FileParser, StreamParser } from '../parser'; +const logger = createLogger('DebridioTvPreset'); +class DebridioTvStreamParser extends StreamParser { + protected override getParsedFile( + stream: Stream, + parsedStream: ParsedStream + ): ParsedFile | undefined { + const parsed = stream.name ? FileParser.parse(stream.name) : undefined; + if (!parsed) { + return undefined; + } + logger.debug(`resolution: ${parsed}`); + + return { + ...parsed, + title: undefined, + }; + } + protected override getFilename( + stream: Stream, + currentParsedStream: ParsedStream + ): string | undefined { + logger.debug('returning undefined for filename'); + return undefined; + } + + protected override getMessage( + stream: Stream, + currentParsedStream: ParsedStream + ): string | undefined { + return `${stream.name} - ${stream.description}`; + } +} +export class DebridioTvPreset extends Preset { + static override getParser(): typeof StreamParser { + return DebridioTvStreamParser; + } + + static override get METADATA() { + const supportedResources = [ + constants.CATALOG_RESOURCE, + constants.META_RESOURCE, + constants.STREAM_RESOURCE, + ]; + + const channels = [ + { + label: 'USA', + value: 'usa', + }, + { + label: 'Mexico', + value: 'mx', + }, + { + label: 'UK', + value: 'uk', + }, + { + label: 'France', + value: 'fr', + }, + { + label: 'Chile', + value: 'cl', + }, + { + label: 'Italy', + value: 'it', + }, + { + label: 'Estonia', + value: 'ee', + }, + ]; + const options: Option[] = [ + ...baseOptions( + 'Debridio TV', + supportedResources, + Env.DEFAULT_DEBRIDIO_TV_TIMEOUT + ), + { + id: 'debridioApiKey', + name: 'Debridio API Key', + description: + 'Your Debridio API Key, located at your [account settings](https://debridio.com/account)', + type: 'password', + required: true, + }, + { + id: 'channels', + name: 'Channels', + description: 'The channels to display', + type: 'multi-select', + required: true, + options: channels, + default: channels.map((channel) => channel.value), + }, + debridioSocialOption, + ]; + + return { + ID: 'debridio-tv', + NAME: 'Debridio TV', + LOGO: 'https://res.cloudinary.com/adobotec/image/upload/w_120,h_120/v1735925306/debridio/logo.png.png', + URL: Env.DEBRIDIO_TV_URL, + TIMEOUT: Env.DEFAULT_DEBRIDIO_TV_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: Env.DEFAULT_DEBRIDIO_TV_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: [], + DESCRIPTION: 'Live streaming of a wide variety of channels.', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [constants.LIVE_STREAM_TYPE], + SUPPORTED_RESOURCES: supportedResources, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + if (!options.url && !options.debridioApiKey) { + throw new Error( + 'To access the Debridio addons, you must provide your Debridio API Key' + ); + } + return [this.generateAddon(userData, options)]; + } + + private static generateAddon( + userData: UserData, + options: Record + ): Addon { + let url = this.METADATA.URL; + if (options.url?.endsWith('/manifest.json')) { + url = options.url; + } else { + let baseUrl = this.METADATA.URL; + if (options.url) { + baseUrl = new URL(options.url).origin; + } + // remove trailing slash + baseUrl = baseUrl.replace(/\/$/, ''); + if (!options.debridioApiKey) { + throw new Error( + 'To access the Debridio addons, you must provide your Debridio API Key' + ); + } + const config = this.base64EncodeJSON({ + api_key: options.debridioApiKey, + channels: options.channels, + }); + url = `${baseUrl}/${config}/manifest.json`; + } + return { + name: options.name || this.METADATA.NAME, + manifestUrl: url, + enabled: true, + library: false, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } +} diff --git a/packages/core/src/presets/debridioTvdb.ts b/packages/core/src/presets/debridioTvdb.ts new file mode 100644 index 0000000000000000000000000000000000000000..bd35f3e6eb05a27e5834ecbc8889439a9c40b322 --- /dev/null +++ b/packages/core/src/presets/debridioTvdb.ts @@ -0,0 +1,92 @@ +import { Addon, Option, UserData } from '../db'; +import { Preset, baseOptions } from './preset'; +import { constants, Env } from '../utils'; +import { debridioSocialOption } from './debridio'; + +export class DebridioTvdbPreset extends Preset { + static override get METADATA() { + const supportedResources = [ + constants.CATALOG_RESOURCE, + constants.META_RESOURCE, + ]; + + const options: Option[] = [ + ...baseOptions( + 'Debridio TVDB', + supportedResources, + Env.DEFAULT_DEBRIDIO_TVDB_TIMEOUT + ), + { + id: 'debridioApiKey', + name: 'Debridio API Key', + description: + 'Your Debridio API Key, located at your [account settings](https://debridio.com/account)', + type: 'password', + required: true, + }, + debridioSocialOption, + ]; + + return { + ID: 'debridio-tvdb', + NAME: 'Debridio TVDB', + LOGO: 'https://res.cloudinary.com/adobotec/image/upload/w_120,h_120/v1735925306/debridio/logo.png.png', + URL: Env.DEBRIDIO_TVDB_URL, + TIMEOUT: Env.DEFAULT_DEBRIDIO_TVDB_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: + Env.DEFAULT_DEBRIDIO_TVDB_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: [], + DESCRIPTION: 'Catalogs for the Debridio TVDB', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [], + SUPPORTED_RESOURCES: supportedResources, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + if (!options.url && !options.debridioApiKey) { + throw new Error( + 'To access the Debridio addons, you must provide your Debridio API Key' + ); + } + return [this.generateAddon(userData, options)]; + } + + private static generateAddon( + userData: UserData, + options: Record + ): Addon { + let url = this.METADATA.URL; + if (options.url?.endsWith('/manifest.json')) { + url = options.url; + } else { + let baseUrl = this.METADATA.URL; + if (options.url) { + baseUrl = new URL(options.url).origin; + } + // remove trailing slash + baseUrl = baseUrl.replace(/\/$/, ''); + const config = this.base64EncodeJSON({ + api_key: options.debridioApiKey, + language: 'eng', + }); + url = `${baseUrl}/${config}/manifest.json`; + } + return { + name: options.name || this.METADATA.NAME, + manifestUrl: url, + enabled: true, + library: false, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } +} diff --git a/packages/core/src/presets/debridioWatchtower.ts b/packages/core/src/presets/debridioWatchtower.ts new file mode 100644 index 0000000000000000000000000000000000000000..4b570b432f15816d2b10596667fafbba15b36bdf --- /dev/null +++ b/packages/core/src/presets/debridioWatchtower.ts @@ -0,0 +1,146 @@ +import { Addon, Option, ParsedStream, Stream, UserData } from '../db'; +import { Preset, baseOptions } from './preset'; +import { constants, Env } from '../utils'; +import { FileParser, StreamParser } from '../parser'; +import { debridioSocialOption } from './debridio'; + +class DebridioWatchtowerStreamParser extends StreamParser { + parse(stream: Stream): ParsedStream { + let parsedStream: ParsedStream = { + id: this.getRandomId(), + addon: this.addon, + type: 'http', + url: this.applyUrlModifications(stream.url ?? undefined), + externalUrl: stream.externalUrl ?? undefined, + ytId: stream.ytId ?? undefined, + requestHeaders: stream.behaviorHints?.proxyHeaders?.request, + responseHeaders: stream.behaviorHints?.proxyHeaders?.response, + notWebReady: stream.behaviorHints?.notWebReady ?? undefined, + videoHash: stream.behaviorHints?.videoHash ?? undefined, + originalName: stream.name ?? undefined, + originalDescription: (stream.description || stream.title) ?? undefined, + }; + + stream.description = stream.description || stream.title; + + parsedStream.filename = this.getFilename(stream, parsedStream); + + parsedStream.type = 'http'; + + if (parsedStream.filename) { + parsedStream.parsedFile = FileParser.parse(parsedStream.filename); + parsedStream.parsedFile = { + resolution: parsedStream.parsedFile.resolution, + languages: [], + audioChannels: [], + visualTags: [], + audioTags: [], + }; + parsedStream.parsedFile.languages = Array.from( + new Set([ + ...parsedStream.parsedFile.languages, + ...this.getLanguages(stream, parsedStream), + ]) + ); + } + parsedStream.filename = stream.behaviorHints?.filename ?? undefined; + parsedStream.folderName = undefined; + + parsedStream.message = stream.description?.replace(/\d+p?/g, ''); + + return parsedStream; + } +} + +export class DebridioWatchtowerPreset extends Preset { + static override getParser(): typeof StreamParser { + return DebridioWatchtowerStreamParser; + } + + static override get METADATA() { + const supportedResources = [constants.STREAM_RESOURCE]; + + const options: Option[] = [ + ...baseOptions( + 'Debridio Watchtower', + supportedResources, + Env.DEFAULT_DEBRIDIO_WATCHTOWER_TIMEOUT + ), + { + id: 'debridioApiKey', + name: 'Debridio API Key', + description: + 'Your Debridio API Key, located at your [account settings](https://debridio.com/account)', + type: 'password', + required: true, + }, + debridioSocialOption, + ]; + + return { + ID: 'debridio-watchtower', + NAME: 'Debridio Watchtower', + LOGO: 'https://res.cloudinary.com/adobotec/image/upload/w_120,h_120/v1735925306/debridio/logo.png.png', + URL: Env.DEBRIDIO_WATCHTOWER_URL, + TIMEOUT: Env.DEFAULT_DEBRIDIO_WATCHTOWER_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: + Env.DEFAULT_DEBRIDIO_WATCHTOWER_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: [], + DESCRIPTION: 'Watchtower is a http stream provider.', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [constants.HTTP_STREAM_TYPE], + SUPPORTED_RESOURCES: supportedResources, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + if (!options.url && !options.debridioApiKey) { + throw new Error( + 'To access the Debridio addons, you must provide your Debridio API Key' + ); + } + return [this.generateAddon(userData, options)]; + } + + private static generateAddon( + userData: UserData, + options: Record + ): Addon { + let url = this.METADATA.URL; + if (options.url?.endsWith('/manifest.json')) { + url = options.url; + } else { + let baseUrl = this.METADATA.URL; + if (options.url) { + baseUrl = new URL(options.url).origin; + } + // remove trailing slash + baseUrl = baseUrl.replace(/\/$/, ''); + if (!options.debridioApiKey) { + throw new Error( + 'To access the Debridio addons, you must provide your Debridio API Key' + ); + } + const config = this.base64EncodeJSON({ + api_key: options.debridioApiKey, + }); + url = `${baseUrl}/${config}/manifest.json`; + } + return { + name: options.name || this.METADATA.NAME, + manifestUrl: url, + enabled: true, + library: false, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } +} diff --git a/packages/core/src/presets/dmmCast.ts b/packages/core/src/presets/dmmCast.ts new file mode 100644 index 0000000000000000000000000000000000000000..afc6903efc4d557ed929ef6552578eb5d39427ea --- /dev/null +++ b/packages/core/src/presets/dmmCast.ts @@ -0,0 +1,151 @@ +import { + Addon, + Option, + UserData, + ParsedStream, + Stream, + AIOStream, +} from '../db'; +import { Preset, baseOptions } from './preset'; +import { constants, Env, RESOURCES } from '../utils'; +import { StreamParser } from '../parser'; + +class DMMCastStreamParser extends StreamParser { + protected override getFilename( + stream: Stream, + currentParsedStream: ParsedStream + ): string | undefined { + let filename = stream.description + ? stream.description + .split('\n') + .map((line) => line.replace(/-$/, '')) + .filter((line) => !line.includes('📦')) + .join('') + : stream.behaviorHints?.filename?.trim(); + return filename; + } + + protected override getMessage( + stream: Stream, + currentParsedStream: ParsedStream + ): string | undefined { + if (!stream.description?.includes('📦')) { + currentParsedStream.filename = undefined; + return `${stream.name} - ${stream.description}`; + } + return undefined; + } + + protected override getInLibrary( + stream: Stream, + currentParsedStream: ParsedStream + ): boolean { + if (stream.name?.includes('Yours')) { + return true; + } + return false; + } +} + +export class DMMCastPreset extends Preset { + static override getParser(): typeof StreamParser { + return DMMCastStreamParser; + } + + static override get METADATA() { + const supportedResources = [constants.STREAM_RESOURCE]; + const options: Option[] = [ + { + id: 'name', + name: 'Name', + description: 'What to call this addon', + type: 'string', + required: true, + default: 'DMM Cast', + }, + { + id: 'installationUrl', + name: 'Installation URL', + description: + 'Provide the Unique Installation URL for your DMM Cast addon, available [here](https://debridmediamanager.com/stremio)', + type: 'password', + required: true, + }, + { + id: 'timeout', + name: 'Timeout', + description: 'The timeout for this addon', + type: 'number', + default: Env.DEFAULT_DMM_CAST_TIMEOUT || Env.DEFAULT_TIMEOUT, + constraints: { + min: Env.MIN_TIMEOUT, + max: Env.MAX_TIMEOUT, + }, + }, + { + id: 'resources', + name: 'Resources', + description: + 'Optionally override the resources that are fetched from this addon ', + type: 'multi-select', + required: false, + default: undefined, + options: RESOURCES.map((resource) => ({ + label: resource, + value: resource, + })), + }, + { + id: 'socials', + name: '', + description: '', + type: 'socials', + socials: [{ id: 'website', url: 'https://debridmediamanager.com' }], + }, + ]; + + return { + ID: 'dmm-cast', + NAME: 'DMM Cast', + LOGO: 'https://static.debridmediamanager.com/dmmcast.png', + URL: '', + TIMEOUT: Env.DEFAULT_DMM_CAST_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: Env.DEFAULT_DMM_CAST_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: [], + DESCRIPTION: + 'Access streams casted from [DMM](https://debridmediamanager.com) by you or other users', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [], + SUPPORTED_RESOURCES: supportedResources, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + if (!options.installationUrl.endsWith('/manifest.json')) { + throw new Error('Invalid installation URL'); + } + return [this.generateAddon(userData, options)]; + } + + private static generateAddon( + userData: UserData, + options: Record + ): Addon { + return { + name: options.name || this.METADATA.NAME, + manifestUrl: options.installationUrl, + enabled: true, + library: false, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } +} diff --git a/packages/core/src/presets/doctorWhoUniverse.ts b/packages/core/src/presets/doctorWhoUniverse.ts new file mode 100644 index 0000000000000000000000000000000000000000..c3df8edc7e7430885032ad7ea042c738bf642ec4 --- /dev/null +++ b/packages/core/src/presets/doctorWhoUniverse.ts @@ -0,0 +1,91 @@ +// stremio://new-who.onrender.com/manifest.json + +import { Addon, Option, ParsedStream, Stream, UserData } from '../db'; +import { Preset, baseOptions } from './preset'; +import { constants, Env } from '../utils'; +import { StreamParser } from '../parser'; + +class DoctorWhoUniverseStreamParser extends StreamParser { + protected override getMessage( + stream: Stream, + currentParsedStream: ParsedStream + ): string | undefined { + return stream.name ?? undefined; + } +} + +export class DoctorWhoUniversePreset extends Preset { + static override getParser(): typeof StreamParser { + return DoctorWhoUniverseStreamParser; + } + + static override get METADATA() { + const supportedResources = [ + constants.CATALOG_RESOURCE, + constants.META_RESOURCE, + constants.STREAM_RESOURCE, + ]; + + const options: Option[] = [ + ...baseOptions( + 'Doctor Who Universe', + supportedResources, + Env.DEFAULT_DOCTOR_WHO_UNIVERSE_TIMEOUT + ), + { + id: 'socials', + name: '', + description: '', + type: 'socials', + socials: [ + { id: 'github', url: 'https://github.com/nubblyn/whoniverse' }, + ], + }, + ]; + + return { + ID: 'doctor-who-universe', + NAME: 'Doctor Who Universe', + LOGO: 'https://i.imgur.com/zQ9Btju.png', + URL: Env.DOCTOR_WHO_UNIVERSE_URL, + TIMEOUT: Env.DEFAULT_DOCTOR_WHO_UNIVERSE_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: + Env.DEFAULT_DOCTOR_WHO_UNIVERSE_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: [], + DESCRIPTION: + 'The complete Doctor Who universe, including Classic and New Who episodes, specials, minisodes, prequels, and spinoffs in original UK broadcast order.', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [], + SUPPORTED_RESOURCES: supportedResources, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + return [this.generateAddon(userData, options)]; + } + + private static generateAddon( + userData: UserData, + options: Record + ): Addon { + const baseUrl = options.url + ? new URL(options.url).origin + : Env.DOCTOR_WHO_UNIVERSE_URL; + return { + name: options.name || this.METADATA.NAME, + manifestUrl: `${baseUrl}/manifest.json`, + enabled: true, + library: false, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } +} diff --git a/packages/core/src/presets/easynews.ts b/packages/core/src/presets/easynews.ts new file mode 100644 index 0000000000000000000000000000000000000000..70de9168b7e7b6ce2d6b46de2dc727548f28cb6a --- /dev/null +++ b/packages/core/src/presets/easynews.ts @@ -0,0 +1,114 @@ +import { baseOptions, Preset } from './preset'; +import { constants, Env } from '../utils'; +import { + PresetMetadata, + Option, + Addon, + UserData, + ParsedStream, + Stream, +} from '../db'; +import { StreamParser } from '../parser'; + +export class EasynewsParser extends StreamParser { + protected override getStreamType( + stream: Stream, + service: ParsedStream['service'], + currentParsedStream: ParsedStream + ): ParsedStream['type'] { + return constants.USENET_STREAM_TYPE; + } +} + +export class EasynewsPreset extends Preset { + static override getParser(): typeof StreamParser { + return EasynewsParser; + } + + static override get METADATA(): PresetMetadata { + const supportedServices = [constants.EASYNEWS_SERVICE]; + const supportedResources = [constants.STREAM_RESOURCE]; + + const options: Option[] = [ + ...baseOptions( + 'Easynews', + supportedResources, + Env.DEFAULT_EASYNEWS_TIMEOUT + ), + ]; + + return { + ID: 'easynews', + NAME: 'Easynews', + DESCRIPTION: + 'The original Easynews addon, to access streams from Easynews', + LOGO: `https://pbs.twimg.com/profile_images/479627852757733376/8v9zH7Yo_400x400.jpeg`, + URL: Env.EASYNEWS_URL, + TIMEOUT: Env.DEFAULT_EASYNEWS_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: Env.DEFAULT_EASYNEWS_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: supportedServices, + SUPPORTED_RESOURCES: supportedResources, + SUPPORTED_STREAM_TYPES: [constants.USENET_STREAM_TYPE], + OPTIONS: options, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + return [this.generateAddon(userData, options)]; + } + + private static generateAddon( + userData: UserData, + options: Record + ): Addon { + return { + name: options.name || this.METADATA.NAME, + manifestUrl: this.generateManifestUrl(userData, options), + enabled: true, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } + + protected static generateConfig( + easynewsCredentials: { + username: string; + password: string; + }, + options: Record + ) { + return this.urlEncodeJSON({ + username: easynewsCredentials.username, + password: easynewsCredentials.password, + }); + } + + private static generateManifestUrl( + userData: UserData, + options: Record + ) { + let url = options.url || this.METADATA.URL; + if (url.endsWith('/manifest.json')) { + return url; + } + url = url.replace(/\/$/, ''); + const easynewsCredentials = this.getServiceCredential( + constants.EASYNEWS_SERVICE, + userData + ); + if (!easynewsCredentials) { + throw new Error( + `${this.METADATA.NAME} requires the Easynews service to be enabled.` + ); + } + return `${url}/${this.generateConfig(easynewsCredentials, options)}/manifest.json`; + } +} diff --git a/packages/core/src/presets/easynewsPlus.ts b/packages/core/src/presets/easynewsPlus.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f1c404a79ce8209190fae4b9e3374346bb743dc --- /dev/null +++ b/packages/core/src/presets/easynewsPlus.ts @@ -0,0 +1,75 @@ +import { PresetMetadata } from '../db'; +import { EasynewsPreset } from './easynews'; +import { constants, Env } from '../utils'; +import { baseOptions } from './preset'; + +export class EasynewsPlusPreset extends EasynewsPreset { + static override get METADATA(): PresetMetadata { + return { + ...super.METADATA, + ID: 'easynewsPlus', + NAME: 'Easynews+', + DESCRIPTION: + 'Easynews+ provides content from Easynews & includes a search catalog', + URL: Env.EASYNEWS_PLUS_URL, + TIMEOUT: Env.DEFAULT_EASYNEWS_PLUS_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: + Env.DEFAULT_EASYNEWS_PLUS_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_RESOURCES: [ + ...super.METADATA.SUPPORTED_RESOURCES, + constants.CATALOG_RESOURCE, + constants.META_RESOURCE, + ], + OPTIONS: [ + ...baseOptions( + 'Easynews+', + [ + ...super.METADATA.SUPPORTED_RESOURCES, + constants.CATALOG_RESOURCE, + constants.META_RESOURCE, + ], + Env.DEFAULT_EASYNEWS_PLUS_TIMEOUT || Env.DEFAULT_TIMEOUT + ), + { + id: 'socials', + name: '', + description: '', + type: 'socials', + socials: [ + { + id: 'github', + url: 'https://github.com/sleeyax/stremio-easynews-addon', + }, + { + id: 'patreon', + url: 'https://patreon.com/sleeyax', + }, + { + id: 'buymeacoffee', + url: 'https://buymeacoffee.com/sleeyax', + }, + ], + }, + ], + }; + } + + protected static override generateConfig( + easynewsCredentials: { + username: string; + password: string; + }, + options: Record + ): string { + return this.urlEncodeJSON({ + username: easynewsCredentials.username, + password: easynewsCredentials.password, + sort1: 'Size', + sort1Direction: 'Descending', + sort2: 'Relevance', + sort2Direction: 'Descending', + sort3: 'Date & Time', + sort3Direction: 'Descending', + }); + } +} diff --git a/packages/core/src/presets/easynewsPlusPlus.ts b/packages/core/src/presets/easynewsPlusPlus.ts new file mode 100644 index 0000000000000000000000000000000000000000..41a80a811aeffc52fa4884a9d1688709cc5461c3 --- /dev/null +++ b/packages/core/src/presets/easynewsPlusPlus.ts @@ -0,0 +1,103 @@ +import { ParsedStream, PresetMetadata, Stream } from '../db'; +import { EasynewsPreset, EasynewsParser } from './easynews'; +import { constants, Env } from '../utils'; +import { baseOptions } from './preset'; +import { StreamParser } from '../parser'; + +class EasynewsPlusPlusParser extends EasynewsParser { + protected override get ageRegex(): RegExp { + return /📅\s*(\d+[a-zA-Z])/; + } + + protected get indexerRegex(): RegExp | undefined { + return undefined; + } + + protected override getLanguages( + stream: Stream, + currentParsedStream: ParsedStream + ): string[] { + const regex = this.getRegexForTextAfterEmojis(['🌐']); + const langs = stream.description?.match(regex)?.[1]; + return ( + langs + ?.split(',') + ?.map((lang) => this.convertISO6392ToLanguage(lang.trim())) + .filter((lang) => lang !== undefined) || [] + ); + } +} + +export class EasynewsPlusPlusPreset extends EasynewsPreset { + static override getParser(): typeof StreamParser { + return EasynewsPlusPlusParser; + } + + static override get METADATA(): PresetMetadata { + return { + ...super.METADATA, + ID: 'easynewsPlusPlus', + NAME: 'Easynews++', + DESCRIPTION: 'Easynews++ provides content from Easynews', + URL: Env.EASYNEWS_PLUS_PLUS_URL, + TIMEOUT: Env.DEFAULT_EASYNEWS_PLUS_PLUS_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: + Env.DEFAULT_EASYNEWS_PLUS_PLUS_USER_AGENT || Env.DEFAULT_USER_AGENT, + OPTIONS: [ + ...baseOptions( + 'Easynews++', + super.METADATA.SUPPORTED_RESOURCES, + Env.DEFAULT_EASYNEWS_PLUS_PLUS_TIMEOUT || Env.DEFAULT_TIMEOUT + ), + { + id: 'strictTitleMatching', + name: 'Strict Title Matching', + description: + "Whether to filter out results that don't match the title exactly", + type: 'boolean', + required: true, + default: false, + }, + { + id: 'socials', + name: '', + description: '', + type: 'socials', + socials: [ + { + id: 'github', + url: 'https://github.com/panteLx/easynews-plus-plus', + }, + { + id: 'buymeacoffee', + url: 'https://buymeacoffee.com/pantel', + }, + ], + }, + ], + }; + } + + protected static override generateConfig( + easynewsCredentials: { + username: string; + password: string; + }, + options: Record + ): string { + return this.urlEncodeJSON({ + uiLanguage: 'eng', + username: easynewsCredentials.username, + password: easynewsCredentials.password, + strictTitleMatching: options.strictTitleMatching ? 'on' : 'off', + baseUrl: options.url + ? new URL(options.url).origin + : Env.EASYNEWS_PLUS_PLUS_URL, + preferredLanguage: '', + sortingPreference: 'quality_first', + showQualities: '4k,1080p,720p,480p', + maxResultsPerQuality: '', + maxFileSize: '', + }); + } +} diff --git a/packages/core/src/presets/index.ts b/packages/core/src/presets/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e41c246d5f6d83b78c3e426c9933f15f360113f3 --- /dev/null +++ b/packages/core/src/presets/index.ts @@ -0,0 +1,2 @@ +export * from './preset'; +export * from './presetManager'; diff --git a/packages/core/src/presets/jackettio.ts b/packages/core/src/presets/jackettio.ts new file mode 100644 index 0000000000000000000000000000000000000000..a7126eb45c823b7c75816f8096661b436e466f36 --- /dev/null +++ b/packages/core/src/presets/jackettio.ts @@ -0,0 +1,196 @@ +import { Addon, Option, UserData, Resource } from '../db'; +import { baseOptions, Preset } from './preset'; +import { Env } from '../utils'; +import { constants, ServiceId } from '../utils'; +import { StreamParser } from '../parser'; + +class JackettioStreamParser extends StreamParser { + override applyUrlModifications(url: string | undefined): string | undefined { + if (!url) { + return url; + } + if ( + Env.FORCE_JACKETTIO_HOSTNAME !== undefined || + Env.FORCE_JACKETTIO_PORT !== undefined || + Env.FORCE_JACKETTIO_PROTOCOL !== undefined + ) { + // modify the URL according to settings, needed when using a local URL for requests but a public stream URL is needed. + const urlObj = new URL(url); + + if (Env.FORCE_JACKETTIO_PROTOCOL !== undefined) { + urlObj.protocol = Env.FORCE_JACKETTIO_PROTOCOL; + } + if (Env.FORCE_JACKETTIO_PORT !== undefined) { + urlObj.port = Env.FORCE_JACKETTIO_PORT.toString(); + } + if (Env.FORCE_JACKETTIO_HOSTNAME !== undefined) { + urlObj.hostname = Env.FORCE_JACKETTIO_HOSTNAME; + } + return urlObj.toString(); + } + return url; + } +} + +export class JackettioPreset extends Preset { + static override getParser(): typeof StreamParser { + return JackettioStreamParser; + } + + static override get METADATA() { + const supportedServices: ServiceId[] = [ + constants.REALDEBRID_SERVICE, + constants.PREMIUMIZE_SERVICE, + constants.ALLEDEBRID_SERVICE, + constants.TORBOX_SERVICE, + constants.EASYDEBRID_SERVICE, + constants.DEBRIDLINK_SERVICE, + constants.OFFCLOUD_SERVICE, + constants.PIKPAK_SERVICE, + ]; + + const supportedResources = [constants.STREAM_RESOURCE]; + + const options: Option[] = [ + ...baseOptions( + 'Jackettio', + supportedResources, + Env.DEFAULT_JACKETTIO_TIMEOUT + ), + { + id: 'services', + name: 'Services', + description: + 'Optionally override the services that are used. If not specified, then the services that are enabled and supported will be used.', + type: 'multi-select', + required: false, + options: supportedServices.map((service) => ({ + value: service, + label: constants.SERVICE_DETAILS[service].name, + })), + default: undefined, + emptyIsUndefined: true, + }, + { + id: 'socials', + name: '', + description: '', + type: 'socials', + socials: [ + { id: 'github', url: 'https://github.com/Telkaoss/jackettio' }, + ], + }, + ]; + + return { + ID: 'jackettio', + NAME: 'Jackettio', + LOGO: 'https://raw.githubusercontent.com/Jackett/Jackett/bbea5febd623f6e536e11aa1fa8d6674d8d4043f/src/Jackett.Common/Content/jacket_medium.png', + URL: Env.JACKETTIO_URL, + TIMEOUT: Env.DEFAULT_JACKETTIO_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: Env.DEFAULT_JACKETTIO_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: supportedServices, + DESCRIPTION: + 'Stremio addon that resolves streams using Jackett and Debrid', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [constants.DEBRID_STREAM_TYPE], + SUPPORTED_RESOURCES: supportedResources, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + if (options?.url?.endsWith('/manifest.json')) { + return [this.generateAddon(userData, options, undefined)]; + } + + const usableServices = this.getUsableServices(userData, options.services); + if (!usableServices || usableServices.length === 0) { + throw new Error( + `${this.METADATA.NAME} requires at least one usable service from the list of supported services: ${this.METADATA.SUPPORTED_SERVICES.map((service) => constants.SERVICE_DETAILS[service].name).join(', ')}` + ); + } + + let addons = usableServices.map((service) => + this.generateAddon(userData, options, service.id) + ); + + return addons; + } + + private static generateAddon( + userData: UserData, + options: Record, + serviceId?: ServiceId + ): Addon { + return { + name: options.name || this.METADATA.NAME, + identifier: serviceId + ? `${constants.SERVICE_DETAILS[serviceId].shortName}` + : undefined, // when no service is provided - its either going to fail or be a custom addon, which is only 1 addon in either case + displayIdentifier: serviceId + ? `${constants.SERVICE_DETAILS[serviceId].shortName}` + : undefined, + manifestUrl: this.generateManifestUrl(userData, options, serviceId), + enabled: true, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } + + private static generateManifestUrl( + userData: UserData, + options: Record, + serviceId: ServiceId | undefined + ) { + let url = options.url || this.METADATA.URL; + if (url.endsWith('/manifest.json')) { + return url; + } + if (!serviceId) { + throw new Error( + `${this.METADATA.NAME} requires at least one usable service from the list of supported services: ${this.METADATA.SUPPORTED_SERVICES.map((service) => constants.SERVICE_DETAILS[service].name).join(', ')}` + ); + } + url = url.replace(/\/$/, ''); + const configString = this.base64EncodeJSON({ + maxTorrents: 30, + priotizePackTorrents: 2, + excludeKeywords: [], + debridId: serviceId, + debridApiKey: this.getServiceCredential(serviceId, userData, { + [constants.OFFCLOUD_SERVICE]: (credentials: any) => + `${credentials.email}:${credentials.password}`, + [constants.PIKPAK_SERVICE]: (credentials: any) => + `${credentials.email}:${credentials.password}`, + }), + hideUncached: false, + sortCached: [ + ['quality', true], + ['size', true], + ], + sortUncached: [['seeders', true]], + forceCacheNextEpisode: false, + priotizeLanguages: [], + indexerTimeoutSec: 60, + metaLanguage: '', + enableMediaFlow: false, + mediaflowProxyUrl: '', + mediaflowApiPassword: '', + mediaflowPublicIp: '', + useStremThru: true, + stremthruUrl: Env.DEFAULT_JACKETTIO_STREMTHRU_URL, + qualities: [0, 360, 480, 720, 1080, 2160], + indexers: Env.DEFAULT_JACKETTIO_INDEXERS, + }); + + return `${url}${configString ? '/' + configString : ''}/manifest.json`; + } +} diff --git a/packages/core/src/presets/marvel.ts b/packages/core/src/presets/marvel.ts new file mode 100644 index 0000000000000000000000000000000000000000..d368d9abd4ae99c0d64c6d66ce11a8b940b97d32 --- /dev/null +++ b/packages/core/src/presets/marvel.ts @@ -0,0 +1,111 @@ +import { Addon, Option, UserData } from '../db'; +import { Preset, baseOptions } from './preset'; +import { constants, Env } from '../utils'; + +export class MarvelPreset extends Preset { + private static catalogs = [ + { + label: 'MCU Chronological Order', + value: 'marvel-mcu', + }, + { + label: 'MCU Release Order', + value: 'release-order', + }, + { + label: 'X-Men Chronological Order', + value: 'xmen', + }, + { + label: 'Marvel Movies', + value: 'movies', + }, + { + label: 'Marvel TV Shows', + value: 'series', + }, + { + label: 'Marvel Animated Series', + value: 'animations', + }, + ]; + static override get METADATA() { + const supportedResources = [ + constants.CATALOG_RESOURCE, + constants.META_RESOURCE, + ]; + + const options: Option[] = [ + ...baseOptions( + 'Marvel Universe', + supportedResources, + Env.DEFAULT_MARVEL_CATALOG_TIMEOUT + ).filter((option) => option.id !== 'url'), + // series movies animations xmen release-order marvel-mcu + { + id: 'catalogs', + name: 'Catalogs', + description: 'The catalogs to display', + type: 'multi-select', + required: true, + options: this.catalogs, + default: this.catalogs.map((catalog) => catalog.value), + }, + { + id: 'socials', + name: '', + description: '', + type: 'socials', + socials: [ + { id: 'github', url: 'https://github.com/joaogonp/addon-marvel' }, + { id: 'buymeacoffee', url: 'https://buymeacoffee.com/joaogonp' }, + ], + }, + ]; + + return { + ID: 'marvel-universe', + NAME: 'Marvel Universe', + LOGO: 'https://upload.wikimedia.org/wikipedia/commons/b/b9/Marvel_Logo.svg', + URL: Env.MARVEL_UNIVERSE_URL, + TIMEOUT: Env.DEFAULT_MARVEL_CATALOG_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: + Env.DEFAULT_MARVEL_CATALOG_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: [], + DESCRIPTION: 'Catalogs for the Marvel Universe', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [], + SUPPORTED_RESOURCES: supportedResources, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + return [this.generateAddon(userData, options)]; + } + + private static generateAddon( + userData: UserData, + options: Record + ): Addon { + const config = + options.catalogs.length !== this.catalogs.length + ? options.catalogs.join('%2C') + : ''; + return { + name: options.name || this.METADATA.NAME, + manifestUrl: `${Env.MARVEL_UNIVERSE_URL}/${config ? 'catalog/' + config + '/' : ''}manifest.json`, + enabled: true, + library: false, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } +} diff --git a/packages/core/src/presets/mediafusion.ts b/packages/core/src/presets/mediafusion.ts new file mode 100644 index 0000000000000000000000000000000000000000..0ce4b02cbaf90b3353aae9cb6427271d0f18dd0f --- /dev/null +++ b/packages/core/src/presets/mediafusion.ts @@ -0,0 +1,466 @@ +import { Addon, Option, UserData, Resource, Stream, ParsedStream } from '../db'; +import { baseOptions, Preset } from './preset'; +import { createLogger, Env } from '../utils'; +import { constants, ServiceId } from '../utils'; +import { StreamParser } from '../parser'; + +const logger = createLogger('core'); + +class MediaFusionStreamParser extends StreamParser { + protected override raiseErrorIfNecessary( + stream: Stream, + currentParsedStream: ParsedStream + ): void { + if (stream.description?.includes('Content Warning')) { + throw new Error(stream.description); + } + } + + protected override get indexerEmojis(): string[] { + return ['🔗']; + } + + protected override getFolder( + stream: Stream, + currentParsedStream: ParsedStream + ): string | undefined { + const regex = this.getRegexForTextAfterEmojis(['📂']); + const file = stream.description?.match(regex)?.[1]; + if (file && file.includes('┈➤')) { + return file.split('┈➤')[0].trim(); + } + return undefined; + } + + protected override getFolderSize( + stream: Stream, + currentParsedStream: ParsedStream + ): number | undefined { + const regex = /💾\s?.*\s?\/\s?💾\s?([^💾\n]+)/; + const match = stream.description?.match(regex); + if (match) { + const folderSize = match[1].trim(); + return this.calculateBytesFromSizeString(folderSize); + } + return undefined; + } + + protected override getFilename( + stream: Stream, + currentParsedStream: ParsedStream + ): string | undefined { + const regex = this.getRegexForTextAfterEmojis(['📂']); + const file = stream.description?.match(regex)?.[1]; + if (file && file.includes('┈➤')) { + return file.split('┈➤')[1].trim(); + } + if (file) { + return file.trim(); + } + return super.getFilename(stream, currentParsedStream); + } + + protected override getIndexer( + stream: Stream, + currentParsedStream: ParsedStream + ): string | undefined { + const indexer = super.getIndexer(stream, currentParsedStream); + if (indexer?.includes('Contribution')) { + const contributor = stream.description?.match( + this.getRegexForTextAfterEmojis(['🧑‍💻']) + )?.[1]; + return contributor ? `Contributor|${contributor}` : undefined; + } + return indexer; + } + + protected override getLanguages( + stream: Stream, + currentParsedStream: ParsedStream + ): string[] { + const languages = super.getLanguages(stream, currentParsedStream); + const regex = this.getRegexForTextAfterEmojis(['🌐']); + const languagesString = stream.description?.match(regex)?.[1]; + if (languagesString) { + return languages.concat( + languagesString + .split('+') + .map((language) => language.trim()) + .filter((language) => constants.LANGUAGES.includes(language as any)) + ); + } + return languages; + } +} + +export class MediaFusionPreset extends Preset { + static override getParser(): typeof StreamParser { + return MediaFusionStreamParser; + } + + static override get METADATA() { + const supportedServices: ServiceId[] = [ + constants.REALDEBRID_SERVICE, + constants.PREMIUMIZE_SERVICE, + constants.ALLEDEBRID_SERVICE, + constants.TORBOX_SERVICE, + constants.DEBRIDLINK_SERVICE, + constants.EASYDEBRID_SERVICE, + constants.OFFCLOUD_SERVICE, + constants.PIKPAK_SERVICE, + constants.SEEDR_SERVICE, + ]; + + const supportedResources = [ + constants.STREAM_RESOURCE, + constants.CATALOG_RESOURCE, + constants.META_RESOURCE, + ]; + + const options: Option[] = [ + ...baseOptions( + 'MediaFusion', + supportedResources, + Env.DEFAULT_MEDIAFUSION_TIMEOUT + ), + { + id: 'useCachedResultsOnly', + name: 'Use Cached Searches Only', + description: + "Only show results that are already cached in MediaFusion's database from previous searches. This disables live searching, making requests faster but potentially showing fewer results.", + type: 'boolean', + forced: Env.MEDIAFUSION_FORCED_USE_CACHED_RESULTS_ONLY, + default: Env.MEDIAFUSION_DEFAULT_USE_CACHED_RESULTS_ONLY, + }, + { + id: 'enableWatchlistCatalogs', + name: 'Enable Watchlist Catalogs', + description: 'Enable watchlist catalogs for the selected services.', + type: 'boolean', + default: false, + }, + { + id: 'downloadViaBrowser', + name: 'Download via Browser', + description: + 'Show download streams to allow downloading the stream from your service, rather than streaming.', + type: 'boolean', + default: false, + }, + { + id: 'certificationLevelsFilter', + name: 'Certification Levels Filter', + description: + 'Choose to not display streams for titles of a certain certification level. Leave blank to show all results.', + type: 'multi-select', + required: false, + options: [ + { + value: 'Unknown', + label: 'Unknown', + }, + { + value: 'All Ages', + label: 'All Ages', + }, + { + value: 'Children', + label: 'Children', + }, + { + value: 'Parental Guidance', + label: 'Parental Guidance', + }, + { + value: 'Teen', + label: 'Teen', + }, + { + value: 'Adults', + label: 'Adults', + }, + { + value: 'Adults+', + label: 'Adults+', + }, + ], + }, + { + id: 'nudityFilter', + name: 'Nudity Filter', + description: + 'Choose to not display streams that a certain level of nudity. Leave blank to show all results.', + type: 'multi-select', + required: false, + options: [ + { + value: 'Unknown', + label: 'Unknown', + }, + { + value: 'None', + label: 'None', + }, + { + value: 'Mild', + label: 'Mild', + }, + { + value: 'Moderate', + label: 'Moderate', + }, + { + value: 'Severe', + label: 'Severe', + }, + ], + }, + + { + id: 'services', + name: 'Services', + description: + 'Optionally override the services that are used. If not specified, then the services that are enabled and supported will be used.', + type: 'multi-select', + required: false, + options: supportedServices.map((service) => ({ + value: service, + label: constants.SERVICE_DETAILS[service].name, + })), + default: undefined, + emptyIsUndefined: true, + }, + { + id: 'socials', + name: '', + description: '', + type: 'socials', + socials: [ + { id: 'github', url: 'https://github.com/mhdzumair/MediaFusion' }, + ], + }, + ]; + + return { + ID: 'mediafusion', + NAME: 'MediaFusion', + LOGO: `https://raw.githubusercontent.com/mhdzumair/MediaFusion/refs/heads/main/resources/images/mediafusion_logo.png`, + URL: Env.MEDIAFUSION_URL, + TIMEOUT: Env.DEFAULT_MEDIAFUSION_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: Env.DEFAULT_MEDIAFUSION_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: supportedServices, + DESCRIPTION: + 'Universal Stremio Add-on for Movies, Series, Live TV & Sports Events', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [ + constants.P2P_STREAM_TYPE, + constants.DEBRID_STREAM_TYPE, + ], + SUPPORTED_RESOURCES: supportedResources, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + if (options?.url?.endsWith('/manifest.json')) { + return [this.generateAddon(userData, options, undefined)]; + } + + const usableServices = this.getUsableServices(userData, options.services); + + if (!usableServices || usableServices.length === 0) { + return [this.generateAddon(userData, options, undefined)]; + } + + let addons = usableServices.map((service) => + this.generateAddon(userData, options, service.id) + ); + + if (options.includeP2P) { + addons.push(this.generateAddon(userData, options, undefined)); + } + + return addons; + } + + private static generateAddon( + userData: UserData, + options: Record, + serviceId?: ServiceId + ): Addon { + const url = this.generateManifestUrl(options); + return { + name: options.name || this.METADATA.NAME, + identifier: serviceId + ? `${constants.SERVICE_DETAILS[serviceId].shortName}` + : options.url?.endsWith('/manifest.json') + ? undefined + : 'p2p', + displayIdentifier: serviceId + ? `${constants.SERVICE_DETAILS[serviceId].shortName}` + : options.url?.endsWith('/manifest.json') + ? undefined + : 'P2P', + manifestUrl: url, + enabled: true, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: options.url?.endsWith('/manifest.json') + ? { + 'User-Agent': this.METADATA.USER_AGENT, + } + : { + 'User-Agent': this.METADATA.USER_AGENT, + encoded_user_data: this.generateEncodedUserData( + userData, + options, + serviceId + ), + }, + }; + } + + private static generateManifestUrl(options: Record) { + const url = options.url || this.METADATA.URL; + if (url.endsWith('/manifest.json')) { + return url; + } + return `${url}/manifest.json`; + } + + private static generateEncodedUserData( + userData: UserData, + options: Record, + serviceId: ServiceId | undefined + ) { + let pikpakCredentials = undefined; + if (serviceId === constants.PIKPAK_SERVICE) { + pikpakCredentials = this.getServiceCredential(serviceId, userData); + } + const encodedUserData = this.base64EncodeJSON( + { + streaming_provider: !serviceId + ? null + : { + token: + serviceId != constants.PIKPAK_SERVICE + ? this.getServiceCredential(serviceId, userData) + : undefined, + email: pikpakCredentials?.email, + password: pikpakCredentials?.password, + service: serviceId, + enable_watchlist_catalogs: + options.enableWatchlistCatalogs || false, + download_via_browser: options.downloadViaBrowser || false, + only_show_cached_streams: false, + }, + selected_catalogs: [], + selected_resolutions: [ + '4k', + '2160p', + '1440p', + '1080p', + '720p', + '576p', + '480p', + '360p', + '240p', + null, + ], + enable_catalogs: true, + enable_imdb_metadata: false, + max_size: 'inf', + max_streams_per_resolution: '500', + torrent_sorting_priority: [ + { key: 'language', direction: 'desc' }, + { key: 'cached', direction: 'desc' }, + { key: 'resolution', direction: 'desc' }, + { key: 'quality', direction: 'desc' }, + { key: 'size', direction: 'desc' }, + { key: 'seeders', direction: 'desc' }, + { key: 'created_at', direction: 'desc' }, + ], + show_full_torrent_name: true, + show_language_country_flag: false, + nudity_filter: options.nudityFilter?.length + ? options.nudityFilter + : ['Disable'], + certification_filter: options.certificationLevelsFilter?.length + ? options.certificationLevelsFilter + : ['Disable'], + language_sorting: [ + 'English', + 'Tamil', + 'Hindi', + 'Malayalam', + 'Kannada', + 'Telugu', + 'Chinese', + 'Russian', + 'Arabic', + 'Japanese', + 'Korean', + 'Taiwanese', + 'Latino', + 'French', + 'Spanish', + 'Portuguese', + 'Italian', + 'German', + 'Ukrainian', + 'Polish', + 'Czech', + 'Thai', + 'Indonesian', + 'Vietnamese', + 'Dutch', + 'Bengali', + 'Turkish', + 'Greek', + 'Swedish', + 'Romanian', + 'Hungarian', + 'Finnish', + 'Norwegian', + 'Danish', + 'Hebrew', + 'Lithuanian', + 'Punjabi', + 'Marathi', + 'Gujarati', + 'Bhojpuri', + 'Nepali', + 'Urdu', + 'Tagalog', + 'Filipino', + 'Malay', + 'Mongolian', + 'Armenian', + 'Georgian', + null, + ], + quality_filter: [ + 'BluRay/UHD', + 'WEB/HD', + 'DVD/TV/SAT', + 'CAM/Screener', + 'Unknown', + ], + api_password: Env.MEDIAFUSION_API_PASSWORD, + mediaflow_config: null, + rpdb_config: null, + live_search_streams: !options.useCachedResultsOnly, + contribution_streams: false, + mdblist_config: null, + }, + false, + true + ); + + return encodedUserData; + } +} diff --git a/packages/core/src/presets/nuviostreams.ts b/packages/core/src/presets/nuviostreams.ts new file mode 100644 index 0000000000000000000000000000000000000000..33e0e4376d71bea5648fa09540b012b4b35f0ea3 --- /dev/null +++ b/packages/core/src/presets/nuviostreams.ts @@ -0,0 +1,262 @@ +import { Addon, Option, UserData, Resource, Stream, ParsedStream } from '../db'; +import { Preset, baseOptions } from './preset'; +import { Env, SERVICE_DETAILS } from '../utils'; +import { constants, ServiceId } from '../utils'; +import { FileParser, StreamParser } from '../parser'; + +class NuvioStreamsStreamParser extends StreamParser { + parse(stream: Stream): ParsedStream { + let parsedStream: ParsedStream = { + id: this.getRandomId(), + addon: this.addon, + type: 'http', + url: this.applyUrlModifications(stream.url ?? undefined), + externalUrl: stream.externalUrl ?? undefined, + ytId: stream.ytId ?? undefined, + requestHeaders: stream.behaviorHints?.proxyHeaders?.request, + responseHeaders: stream.behaviorHints?.proxyHeaders?.response, + notWebReady: stream.behaviorHints?.notWebReady ?? undefined, + videoHash: stream.behaviorHints?.videoHash ?? undefined, + originalName: stream.name ?? undefined, + originalDescription: (stream.description || stream.title) ?? undefined, + }; + + stream.description = stream.description || stream.title; + + parsedStream.type = 'http'; + + parsedStream.parsedFile = FileParser.parse( + `${stream.name}\n${stream.description}` + ); + parsedStream.filename = stream.description?.split('\n')[0]; + parsedStream.folderName = undefined; + + parsedStream.size = this.getSize(stream, parsedStream); + + parsedStream.message = stream.name + ?.replace(/\d+p?/gi, '') + ?.trim() + ?.replace(/-$/, '') + ?.trim(); + + if (stream.description?.split('\n')?.[-1]?.includes('⚠️')) { + parsedStream.message += `\n${stream.description?.split('\n')?.[-1]}`; + } + + return parsedStream; + } +} + +export class NuvioStreamsPreset extends Preset { + static override getParser(): typeof StreamParser { + return NuvioStreamsStreamParser; + } + + static override get METADATA() { + const supportedResources = [constants.STREAM_RESOURCE]; + const regions = [ + { + value: 'USA7', + label: 'USA East', + }, + { + value: 'USA6', + label: 'USA West', + }, + { + value: 'USA5', + label: 'USA Middle', + }, + { + value: 'UK3', + label: 'United Kingdom', + }, + { + value: 'CA1', + label: 'Canada', + }, + { + value: 'FR1', + label: 'France', + }, + { + value: 'DE2', + label: 'Germany', + }, + { + value: 'HK1', + label: 'Hong Kong', + }, + { + value: 'IN1', + label: 'India', + }, + { + value: 'AU1', + label: 'Australia', + }, + { + value: 'SZ', + label: 'China', + }, + ]; + const providers = [ + { + value: 'showbox', + label: 'Showbox', + }, + { + value: 'xprime', + label: 'XPrime', + }, + { + value: 'hollymoviehd', + label: 'HollyMovieHD', + }, + { + value: 'cuevana', + label: 'Cuevana', + }, + { + value: 'soapertv', + label: 'Soapertv', + }, + { + value: 'vidzee', + label: 'Vidzee', + }, + { + value: 'hianime', + label: 'HiAnime', + }, + { + value: 'vidsrc', + label: 'Vidsrc', + }, + ]; + + const options: Option[] = [ + ...baseOptions( + 'Nuvio Streams', + supportedResources, + Env.DEFAULT_NUVIOSTREAMS_TIMEOUT + ), + { + id: 'scraperApiKey', + name: 'Scraper API Key', + description: + 'Optionally provide a [ScraperAPI](https://www.scraperapi.com/) API Key from', + type: 'password', + required: false, + default: '', + }, + { + id: 'showBoxCookie', + name: 'ShowBox Cookie', + description: + 'The cookie for the ShowBox provider. Highly recommended to get streams greater than 9GB. Log in at [Febbox](https://www.febbox.com/) > DevTools > Storage > Cookied > Copy the value of the `ui` cookie. ', + type: 'password', + required: false, + default: '', + }, + { + id: 'showBoxRegion', + name: 'ShowBox Region', + description: 'The region to use for the ShowBox provider', + type: 'select', + required: false, + options: regions, + default: regions[0].value, + }, + { + id: 'providers', + name: 'Providers', + description: 'The providers to use', + type: 'multi-select', + required: true, + options: providers, + default: providers.map((provider) => provider.value), + }, + { + id: 'socials', + name: '', + description: '', + type: 'socials', + socials: [ + { id: 'github', url: 'https://github.com/tapframe/NuvioStreaming' }, + { id: 'ko-fi', url: 'https://ko-fi.com/tapframe' }, + ], + }, + ]; + + return { + ID: 'nuvio-streams', + NAME: 'Nuvio Streams', + LOGO: 'https://raw.githubusercontent.com/tapframe/NuvioStreaming/main/assets/titlelogo.png', + URL: Env.NUVIOSTREAMS_URL, + TIMEOUT: Env.DEFAULT_NUVIOSTREAMS_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: Env.DEFAULT_NUVIOSTREAMS_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: [], + DESCRIPTION: 'Free high quality streaming using multiple providers. ', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [constants.HTTP_STREAM_TYPE], + SUPPORTED_RESOURCES: supportedResources, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + return [this.generateAddon(userData, options)]; + } + + private static generateAddon( + userData: UserData, + options: Record + ): Addon { + return { + name: options.name || this.METADATA.NAME, + manifestUrl: this.generateManifestUrl(userData, options), + enabled: true, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } + + private static generateManifestUrl( + userData: UserData, + options: Record + ) { + let url = options.url || this.METADATA.URL; + if (url.endsWith('/manifest.json')) { + return url; + } + url = url.replace(/\/$/, ''); + const cookie = options.showBoxCookie; + const providers = options.providers; + const scraperApiKey = options.scraperApiKey; + let config = []; + if (cookie) { + config.push(['cookie', cookie]); + } + if (options.showBoxRegion) { + config.push(['region', options.showBoxRegion]); + } + if (providers) { + config.push(['providers', providers.join(',')]); + } + if (scraperApiKey) { + config.push(['scraper_api_key', scraperApiKey]); + } + + const configString = this.urlEncodeKeyValuePairs(config, '/', false); + + return `${url}${configString ? '/' + configString : ''}/manifest.json`; + } +} diff --git a/packages/core/src/presets/opensubtitles.ts b/packages/core/src/presets/opensubtitles.ts new file mode 100644 index 0000000000000000000000000000000000000000..1d715bf22e841d77f53cc6ff4edb671c9040589a --- /dev/null +++ b/packages/core/src/presets/opensubtitles.ts @@ -0,0 +1,57 @@ +import { Addon, Option, UserData } from '../db'; +import { Preset, baseOptions } from './preset'; +import { Env, RESOURCES, SUBTITLES_RESOURCE } from '../utils'; + +export class OpenSubtitlesPreset extends Preset { + static override get METADATA() { + const supportedResources = [SUBTITLES_RESOURCE]; + const options: Option[] = [ + ...baseOptions( + 'OpenSubtitles', + supportedResources, + Env.DEFAULT_OPENSUBTITLES_TIMEOUT + ).filter((option) => option.id !== 'url'), + ]; + + return { + ID: 'opensubtitles', + NAME: 'OpenSubtitles v3', + LOGO: 'https://iwf1.com/scrapekod/icons/service.subtitles.opensubtitles_by_opensubtitles_dualsub.png', + URL: Env.OPENSUBTITLES_URL, + TIMEOUT: Env.DEFAULT_OPENSUBTITLES_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: + Env.DEFAULT_OPENSUBTITLES_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: [], + DESCRIPTION: 'OpenSubtitles addon', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [], + SUPPORTED_RESOURCES: supportedResources, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + return [this.generateAddon(userData, options)]; + } + + private static generateAddon( + userData: UserData, + options: Record + ): Addon { + return { + name: options.name || this.METADATA.NAME, + manifestUrl: `${Env.OPENSUBTITLES_URL}/manifest.json`, + enabled: true, + library: false, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } +} diff --git a/packages/core/src/presets/orion.ts b/packages/core/src/presets/orion.ts new file mode 100644 index 0000000000000000000000000000000000000000..19dac34f637cd8b800d2921919183c1fbbb4fc89 --- /dev/null +++ b/packages/core/src/presets/orion.ts @@ -0,0 +1,188 @@ +import { Addon, Option, UserData, Resource, ParsedStream, Stream } from '../db'; +import { baseOptions, Preset } from './preset'; +import { Env } from '../utils'; +import { constants, ServiceId } from '../utils'; +import { StreamParser } from '../parser'; + +class OrionStreamParser extends StreamParser { + protected override raiseErrorIfNecessary( + stream: Stream, + currentParsedStream: ParsedStream + ): void { + if (stream.title?.includes('ERROR')) { + throw new Error(stream.title); + } + } +} + +export class OrionPreset extends Preset { + static override getParser(): typeof StreamParser { + return OrionStreamParser; + } + + static override get METADATA() { + const supportedServices: ServiceId[] = [ + constants.REALDEBRID_SERVICE, + constants.PREMIUMIZE_SERVICE, + constants.ALLEDEBRID_SERVICE, + // constants.TORBOX_SERVICE, + constants.DEBRIDLINK_SERVICE, + constants.OFFCLOUD_SERVICE, + ]; + + const supportedResources = [constants.STREAM_RESOURCE]; + + const options: Option[] = [ + ...baseOptions('Orion', supportedResources, Env.DEFAULT_ORION_TIMEOUT), + { + id: 'orionApiKey', + name: 'Orion API Key', + description: + 'The API key for the Orion addon, obtain it from the [Orion Panel](https://panel.orionoid.com)', + type: 'password', + required: true, + }, + { + id: 'showP2P', + name: 'Show P2P', + description: 'Show P2P results, even if a debrid service is enabled', + type: 'boolean', + default: false, + }, + { + id: 'linkLimit', + name: 'Link Limit', + description: 'The maximum number of links to fetch from Orion.', + type: 'number', + default: 10, + constraints: { + max: 50, + min: 1, + }, + }, + { + id: 'services', + name: 'Services', + description: + 'Optionally override the services that are used. If not specified, then the services that are enabled and supported will be used.', + type: 'multi-select', + required: false, + options: supportedServices.map((service) => ({ + value: service, + label: constants.SERVICE_DETAILS[service].name, + })), + default: undefined, + emptyIsUndefined: true, + }, + ]; + + return { + ID: 'orion', + NAME: 'Orion', + LOGO: 'https://orionoid.com/web/images/logo/logo256.png', + URL: Env.ORION_STREMIO_ADDON_URL, + TIMEOUT: Env.DEFAULT_ORION_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: Env.DEFAULT_ORION_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: supportedServices, + DESCRIPTION: "Stremio's fastest Torrent/Debrid addon", + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [ + constants.P2P_STREAM_TYPE, + constants.DEBRID_STREAM_TYPE, + ], + SUPPORTED_RESOURCES: supportedResources, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + // url can either be something like https://torrentio.com/ or it can be a custom manifest url. + // if it is a custom manifest url, return a single addon with the custom manifest url. + if (options?.url?.endsWith('/manifest.json')) { + return [this.generateAddon(userData, options, [])]; + } + + const usableServices = this.getUsableServices(userData, options.services); + // if no services are usable, use p2p + if (!usableServices || usableServices.length === 0) { + return [this.generateAddon(userData, options, [])]; + } + + let addons: Addon[] = [ + this.generateAddon( + userData, + options, + usableServices.map((service) => service.id) + ), + ]; + + return addons; + } + + private static generateAddon( + userData: UserData, + options: Record, + serviceIds: ServiceId[] + ): Addon { + return { + name: options.name || this.METADATA.NAME, + // we don't want our true identifier to be change if the user changes their services + // meaning the addon ID changes and the user then has to reinstall the addon + // so instead, our internal identifier simply says either: p2p, multi, or the specific short name of the single service + identifier: + serviceIds.length > 0 + ? serviceIds.length > 1 + ? 'multi' + : constants.SERVICE_DETAILS[serviceIds[0]].shortName + : 'P2P', + displayIdentifier: + serviceIds.length > 0 + ? serviceIds + .map((id) => constants.SERVICE_DETAILS[id].shortName) + .join(', ') + : 'P2P', + manifestUrl: this.generateManifestUrl(userData, options, serviceIds), + enabled: true, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } + + private static generateManifestUrl( + userData: UserData, + options: Record, + serviceIds: ServiceId[] + ) { + let url = options.url || this.METADATA.URL; + if (url.endsWith('/manifest.json')) { + return url; + } + url = url.replace(/\/$/, ''); + const configString = this.base64EncodeJSON({ + api: options.orionApiKey, + linkLimit: options.linkLimit.toString(), + sortValue: 'best', + audiochannels: '1,2,6,8', + videoquality: + 'hd8k,hd6k,hd4k,hd2k,hd1080,hd720,sd,scr1080,scr720,scr,cam1080,cam720,cam', + listOpt: + serviceIds.length > 0 + ? options.showP2P + ? 'both' + : 'debrid' + : 'torrent', + debridservices: serviceIds, + audiolanguages: [], + additionalParameters: '', + }); + + return `${url}${configString ? '/' + configString : ''}/manifest.json`; + } +} diff --git a/packages/core/src/presets/peerflix.ts b/packages/core/src/presets/peerflix.ts new file mode 100644 index 0000000000000000000000000000000000000000..8928e7d9e27e1fa40a64c4d3814a356f0e220b61 --- /dev/null +++ b/packages/core/src/presets/peerflix.ts @@ -0,0 +1,177 @@ +import { Addon, Option, UserData, Resource, Stream } from '../db'; +import { Preset, baseOptions } from './preset'; +import { Env, SERVICE_DETAILS } from '../utils'; +import { constants, ServiceId } from '../utils'; +import { StreamParser } from '../parser'; + +export class PeerflixPreset extends Preset { + static override get METADATA() { + const supportedServices: ServiceId[] = [ + constants.REALDEBRID_SERVICE, + constants.PREMIUMIZE_SERVICE, + constants.ALLEDEBRID_SERVICE, + constants.TORBOX_SERVICE, + constants.PUTIO_SERVICE, + constants.DEBRIDLINK_SERVICE, + constants.OFFCLOUD_SERVICE, + ]; + const supportedResources = [ + constants.STREAM_RESOURCE, + constants.CATALOG_RESOURCE, + constants.META_RESOURCE, + ]; + + const options: Option[] = [ + ...baseOptions( + 'Peerflix', + supportedResources, + Env.DEFAULT_PEERFLIX_TIMEOUT + ), + { + id: 'services', + name: 'Services', + description: + 'Optionally override the services that are used. If not specified, then the services that are enabled and supported will be used.', + type: 'multi-select', + required: false, + options: supportedServices.map((service) => ({ + value: service, + label: constants.SERVICE_DETAILS[service].name, + })), + default: undefined, + emptyIsUndefined: true, + }, + { + id: 'useMultipleInstances', + name: 'Use Multiple Instances', + description: + 'When using multiple services, use a different Peerflix addon for each service, rather than using one instance for all services', + type: 'boolean', + default: false, + required: true, + }, + { + id: 'showTorrentLinks', + name: 'Show P2P Streams for Uncached torrents', + description: + 'If enabled, the addon will show P2P streams for uncached torrents. This is useful for users who want to use the addon to stream torrents that are not cached by the debrid service.', + type: 'boolean', + default: false, + required: true, + }, + ]; + + return { + ID: 'peerflix', + NAME: 'Peerflix', + LOGO: `https://config.peerflix.mov/static/media/logo.28f42024a3538640d047201d05416a09.svg`, + URL: Env.PEERFLIX_URL, + TIMEOUT: Env.DEFAULT_PEERFLIX_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: Env.DEFAULT_PEERFLIX_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: supportedServices, + REQUIRES_SERVICE: false, + DESCRIPTION: + 'Provides Spanish and English streams to Movies and TV Shows.', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [ + constants.P2P_STREAM_TYPE, + constants.DEBRID_STREAM_TYPE, + ], + SUPPORTED_RESOURCES: supportedResources, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + if (options?.url?.endsWith('/manifest.json')) { + return [this.generateAddon(userData, options, [])]; + } + + const usableServices = this.getUsableServices(userData, options.services); + + // if no services are usable, return a single addon with no services + if (!usableServices || usableServices.length === 0) { + return [this.generateAddon(userData, options, [])]; + } + + // if user has specified useMultipleInstances, return a single addon for each service + if (options?.useMultipleInstances) { + return usableServices.map((service) => + this.generateAddon(userData, options, [service.id]) + ); + } + + // return a single addon with all usable services + return [ + this.generateAddon( + userData, + options, + usableServices.map((service) => service.id) + ), + ]; + } + + private static generateAddon( + userData: UserData, + options: Record, + services: ServiceId[] + ): Addon { + return { + name: options.name || this.METADATA.NAME, + identifier: + services.length > 0 + ? services.length > 1 + ? 'multi' + : constants.SERVICE_DETAILS[services[0]].shortName + : 'p2p', + displayIdentifier: + services.length > 0 + ? services.length > 1 + ? services + .map((id) => constants.SERVICE_DETAILS[id].shortName) + .join(' | ') + : constants.SERVICE_DETAILS[services[0]].shortName + : 'P2P', + manifestUrl: this.generateManifestUrl(userData, services, options), + enabled: true, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } + + private static generateManifestUrl( + userData: UserData, + services: ServiceId[], + options: Record + ) { + const url = options.url || this.METADATA.URL; + if (url.endsWith('/manifest.json')) { + return url; + } + + let configOptions = services.map((service) => [ + service, + this.getServiceCredential(service, userData, { + [constants.PUTIO_SERVICE]: (credentials: any) => + `${credentials.clientId}@${credentials.token}`, + }), + ]); + + if (options.showTorrentLinks) { + configOptions.push(['debridOptions', 'torrentlinks']); + } + + const configString = configOptions.length + ? this.urlEncodeKeyValuePairs(configOptions) + : ''; + + return `${url}${configString ? '/' + configString : ''}/manifest.json`; + } +} diff --git a/packages/core/src/presets/preset.ts b/packages/core/src/presets/preset.ts new file mode 100644 index 0000000000000000000000000000000000000000..2bd718d4f074a111aa0b7f6b439dc3b06d2313d0 --- /dev/null +++ b/packages/core/src/presets/preset.ts @@ -0,0 +1,243 @@ +import { + Option, + Resource, + Stream, + ParsedStream, + UserData, + PresetMetadata, + Addon, +} from '../db'; +import { StreamParser } from '../parser'; +import { Env, ServiceId, constants } from '../utils'; +/** + * + * What modifications are needed for each preset: + * + * comet: apply FORCE_COMET_HOSTNAME, FORCE_COMET_PORT, FORCE_COMET_PROTOCOl to stream urls if they are defined + * dmm cast: need to split title by newline, replace trailing dashes, excluding lines with box emoji, and + * then joining the array back together. + * easynews,easynews+,easynews++: need to set type as usenet + * jackettio: apply FORCE_JACKETTIO_HOSTNAME, FORCE_JACKETTIO_PORT, FORCE_JACKETTIO_PROTOCOL to stream urls if they are defined + * mediafusion: need to add hint for folder name, 📁 emoji, and split on arrow, take last index. + * stremio-jacektt: need to inspect stream urls to extract service info. + * stremthruStore: need to mark each stream as 'inLibrary' and unset any parsed 'indexer' + * torbox: need to use different regex for probably everything. + * torrentio: extract folder name from first line + */ + +// name: z.string().min(1), +// enabled: z.boolean().optional(), +// baseUrl: z.string().url().optional(), +// timeout: z.number().min(1).optional(), +// resources: ResourceList.optional(), + +export const baseOptions = ( + name: string, + resources: Resource[], + timeout: number = Env.DEFAULT_TIMEOUT +): Option[] => [ + { + id: 'name', + name: 'Name', + description: 'What to call this addon', + type: 'string', + required: true, + default: name, + }, + { + id: 'timeout', + name: 'Timeout', + description: 'The timeout for this addon', + type: 'number', + required: true, + default: timeout, + constraints: { + min: Env.MIN_TIMEOUT, + max: Env.MAX_TIMEOUT, + }, + }, + { + id: 'resources', + name: 'Resources', + description: 'Optionally override the resources to use ', + type: 'multi-select', + required: false, + default: resources, + options: resources.map((resource) => ({ + label: resource, + value: resource, + })), + }, + { + id: 'url', + name: 'URL', + description: + 'Optionally override either the manifest generated, or override the base url used when generating the manifests', + type: 'url', + required: false, + emptyIsUndefined: true, + default: undefined, + }, +]; + +export abstract class Preset { + static get METADATA(): PresetMetadata { + throw new Error('METADATA must be implemented by derived classes'); + } + + static getParser(): typeof StreamParser { + return StreamParser; + } + + /** + * Creates a preset from a preset id. + * @param presetId - The id of the preset to create. + * @returns The preset. + */ + + static generateAddons( + userData: UserData, + options: Record + ): Promise { + throw new Error('generateAddons must be implemented by derived classes'); + } + + // Utility functions for generating config strings + /** + * Encodes a JSON object into a base64 encoded string. + * @param json - The JSON object to encode. + * @returns The base64 encoded string. + */ + protected static base64EncodeJSON( + json: any, + urlEncode: boolean = false, // url encode the string + makeUrlSafe: boolean = false // replace + with -, / with _ and = with nothing + ) { + let encoded = Buffer.from(JSON.stringify(json)).toString('base64'); + if (makeUrlSafe) { + encoded = encoded + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + } else if (urlEncode) { + encoded = encodeURIComponent(encoded); + } + return encoded; + } + + protected static urlEncodeJSON(json: any) { + return encodeURIComponent(JSON.stringify(json)); + } + + /** + * Transforms key-value pairs into a url encoded string + * @param options - The key-value pair object to encode. + * @returns The encoded string. + */ + protected static urlEncodeKeyValuePairs( + options: Record | string[][], + separator: string = '|', + encode: boolean = true + ) { + const string = (Array.isArray(options) ? options : Object.entries(options)) + .map(([key, value]) => `${key}=${value}`) + .join(separator); + return encode ? encodeURIComponent(string) : string; + } + + protected static getUsableServices( + userData: UserData, + specifiedServices?: ServiceId[] + ) { + let usableServices = userData.services?.filter( + (service) => + this.METADATA.SUPPORTED_SERVICES.includes(service.id) && service.enabled + ); + + if (specifiedServices) { + // Validate specified services exist and are enabled + for (const service of specifiedServices) { + const userService = userData.services?.find((s) => s.id === service); + const meta = Object.values(constants.SERVICE_DETAILS).find( + (s) => s.id === service + ); + if (!userService || !userService.enabled || !userService.credentials) { + throw new Error( + `You have specified ${meta?.name || service} in your configuration, but it is not enabled or has missing credentials` + ); + } + } + // Filter to only specified services + usableServices = usableServices?.filter((service) => + specifiedServices.includes(service.id) + ); + } + + return usableServices; + } + + protected static getServiceCredential( + serviceId: ServiceId, + userData: UserData, + specialCases?: Partial any>> + ) { + const service = constants.SERVICE_DETAILS[serviceId]; + if (!service) { + throw new Error(`Service ${serviceId} not found`); + } + + const serviceCredentials = userData.services?.find( + (service) => service.id === serviceId + )?.credentials; + + if (!serviceCredentials) { + throw new Error(`No credentials found for service ${serviceId}`); + } + + // Handle special cases if provided + if (specialCases?.[serviceId]) { + return specialCases[serviceId](serviceCredentials); + } + + // handle seedr + if (serviceId === constants.SEEDR_SERVICE) { + if (serviceCredentials.encodedToken) { + return serviceCredentials.encodedToken; + } + throw new Error( + `Missing encoded token for ${serviceId}. Please add an encoded token using MediaFusion` + ); + } + // handle easynews + if (serviceId === constants.EASYNEWS_SERVICE) { + if (!serviceCredentials.username || !serviceCredentials.password) { + throw new Error( + `Missing username or password for ${serviceId}. Please add a username and password.` + ); + } + return { + username: serviceCredentials.username, + password: serviceCredentials.password, + }; + } + if (serviceId === constants.PIKPAK_SERVICE) { + if (!serviceCredentials.email || !serviceCredentials.password) { + throw new Error( + `Missing email or password for ${serviceId}. Please add an email and password.` + ); + } + return { + email: serviceCredentials.email, + password: serviceCredentials.password, + }; + } + // Default case - API key + const { apiKey } = serviceCredentials; + if (!apiKey) { + throw new Error( + `Missing credentials for ${serviceId}. Please add an API key.` + ); + } + return apiKey; + } +} diff --git a/packages/core/src/presets/presetManager.ts b/packages/core/src/presets/presetManager.ts new file mode 100644 index 0000000000000000000000000000000000000000..21f28353502be071265a6bdf8bcaf3658a33a970 --- /dev/null +++ b/packages/core/src/presets/presetManager.ts @@ -0,0 +1,181 @@ +import { PresetMetadata, PresetMinimalMetadata } from '../db'; +import { CometPreset } from './comet'; +import { CustomPreset } from './custom'; +import { MediaFusionPreset } from './mediafusion'; +import { StremthruStorePreset } from './stremthruStore'; +import { TorrentioPreset } from './torrentio'; +import { TorboxAddonPreset } from './torbox'; +import { EasynewsPreset } from './easynews'; +import { EasynewsPlusPreset } from './easynewsPlus'; +import { EasynewsPlusPlusPreset } from './easynewsPlusPlus'; +import { StremthruTorzPreset } from './stremthruTorz'; +import { DebridioPreset } from './debridio'; +import { AIOStreamsPreset } from './aiostreams'; +import { OpenSubtitlesPreset } from './opensubtitles'; +import { PeerflixPreset } from './peerflix'; +import { DMMCastPreset } from './dmmCast'; +import { MarvelPreset } from './marvel'; +import { JackettioPreset } from './jackettio'; +import { OrionPreset } from './orion'; +import { StreamFusionPreset } from './streamfusion'; +import { AnimeKitsuPreset } from './animeKitsu'; +import { NuvioStreamsPreset } from './nuviostreams'; +import { RpdbCatalogsPreset } from './rpdbCatalogs'; +import { TmdbCollectionsPreset } from './tmdbCollections'; +import { DebridioWatchtowerPreset } from './debridioWatchtower'; +import { DebridioTmdbPreset } from './debridioTmdb'; +import { StarWarsUniversePreset } from './starWarsUniverse'; +import { DebridioTvdbPreset } from './debridioTvdb'; +import { DcUniversePreset } from './dcUniverse'; +import { DebridioTvPreset } from './debridioTv'; +import { TorrentCatalogsPreset } from './torrentCatalogs'; +import { StreamingCatalogsPreset } from './streamingCatalogs'; +import { AnimeCatalogsPreset } from './animeCatalogs'; +import { DoctorWhoUniversePreset } from './doctorWhoUniverse'; +import { WebStreamrPreset } from './webstreamr'; +import { TMDBAddonPreset } from './tmdb'; +import { TorrentsDbPreset } from './torrentsDb'; +import { USATVPreset } from './usaTv'; +import { ArgentinaTVPreset } from './argentinaTv'; + +const PRESET_LIST: string[] = [ + 'custom', + 'torrentio', + 'comet', + 'mediafusion', + 'stremthruTorz', + 'stremthruStore', + 'jackettio', + 'peerflix', + 'orion', + 'torrents-db', + 'streamfusion', + 'debridio', + 'torbox', + 'easynews', + 'easynewsPlus', + 'easynewsPlusPlus', + 'dmm-cast', + 'nuvio-streams', + 'webstreamr', + 'usa-tv', + 'argentina-tv', + 'debridio-tv', + 'debridio-watchtower', + 'tmdb-addon', + 'debridio-tmdb', + 'debridio-tvdb', + 'streaming-catalogs', + 'anime-catalogs', + 'torrent-catalogs', + 'rpdb-catalogs', + 'tmdb-collections', + 'anime-kitsu', + 'marvel-universe', + 'star-wars-universe', + 'dc-universe', + 'doctor-who-universe', + 'opensubtitles', + 'aiostreams', +]; + +export class PresetManager { + static getPresetList(): PresetMinimalMetadata[] { + return PRESET_LIST.map((presetId) => this.fromId(presetId).METADATA).map( + (metadata) => ({ + ID: metadata.ID, + NAME: metadata.NAME, + LOGO: metadata.LOGO, + DESCRIPTION: metadata.DESCRIPTION, + URL: metadata.URL, + SUPPORTED_RESOURCES: metadata.SUPPORTED_RESOURCES, + SUPPORTED_STREAM_TYPES: metadata.SUPPORTED_STREAM_TYPES, + SUPPORTED_SERVICES: metadata.SUPPORTED_SERVICES, + OPTIONS: metadata.OPTIONS, + }) + ); + } + + static fromId(id: string) { + switch (id) { + case 'torrentio': + return TorrentioPreset; + case 'stremthruStore': + return StremthruStorePreset; + case 'stremthruTorz': + return StremthruTorzPreset; + case 'comet': + return CometPreset; + case 'mediafusion': + return MediaFusionPreset; + case 'custom': + return CustomPreset; + case 'torbox': + return TorboxAddonPreset; + case 'jackettio': + return JackettioPreset; + case 'easynews': + return EasynewsPreset; + case 'easynewsPlus': + return EasynewsPlusPreset; + case 'easynewsPlusPlus': + return EasynewsPlusPlusPreset; + case 'debridio': + return DebridioPreset; + case 'debridio-watchtower': + return DebridioWatchtowerPreset; + case 'debridio-tv': + return DebridioTvPreset; + case 'debridio-tmdb': + return DebridioTmdbPreset; + case 'debridio-tvdb': + return DebridioTvdbPreset; + case 'aiostreams': + return AIOStreamsPreset; + case 'opensubtitles': + return OpenSubtitlesPreset; + case 'peerflix': + return PeerflixPreset; + case 'dmm-cast': + return DMMCastPreset; + case 'marvel-universe': + return MarvelPreset; + case 'orion': + return OrionPreset; + case 'streamfusion': + return StreamFusionPreset; + case 'anime-kitsu': + return AnimeKitsuPreset; + case 'nuvio-streams': + return NuvioStreamsPreset; + case 'webstreamr': + return WebStreamrPreset; + case 'streaming-catalogs': + return StreamingCatalogsPreset; + case 'anime-catalogs': + return AnimeCatalogsPreset; + case 'torrent-catalogs': + return TorrentCatalogsPreset; + case 'rpdb-catalogs': + return RpdbCatalogsPreset; + case 'tmdb-collections': + return TmdbCollectionsPreset; + case 'star-wars-universe': + return StarWarsUniversePreset; + case 'dc-universe': + return DcUniversePreset; + case 'doctor-who-universe': + return DoctorWhoUniversePreset; + case 'tmdb-addon': + return TMDBAddonPreset; + case 'torrents-db': + return TorrentsDbPreset; + case 'usa-tv': + return USATVPreset; + case 'argentina-tv': + return ArgentinaTVPreset; + default: + throw new Error(`Preset ${id} not found`); + } + } +} diff --git a/packages/core/src/presets/rpdbCatalogs.ts b/packages/core/src/presets/rpdbCatalogs.ts new file mode 100644 index 0000000000000000000000000000000000000000..584fce1b5648d622586c26bb5c662e3f801d4c0d --- /dev/null +++ b/packages/core/src/presets/rpdbCatalogs.ts @@ -0,0 +1,87 @@ +import { Addon, Option, UserData } from '../db'; +import { Preset, baseOptions } from './preset'; +import { constants, Env } from '../utils'; + +export class RpdbCatalogsPreset extends Preset { + private static catalogs = [ + { + label: 'Movies', + value: 'movie', + }, + { + label: 'Series', + value: 'series', + }, + { + label: 'Other (News / Talk-Shows / Reality TV etc.)', + value: 'other', + }, + ]; + static override get METADATA() { + const supportedResources = [constants.CATALOG_RESOURCE]; + + const options: Option[] = [ + ...baseOptions( + 'RPDB Catalogs', + supportedResources, + Env.DEFAULT_RPDB_CATALOGS_TIMEOUT + ).filter((option) => option.id !== 'url'), + // series movies animations xmen release-order marvel-mcu + { + id: 'catalogs', + name: 'Catalogs', + description: 'The catalogs to display', + type: 'multi-select', + required: true, + options: this.catalogs, + default: this.catalogs.map((catalog) => catalog.value), + }, + ]; + + return { + ID: 'rpdb-catalogs', + NAME: 'RPDB Catalogs', + LOGO: `${Env.RPDB_CATALOGS_URL}/addon-logo.png`, + URL: Env.RPDB_CATALOGS_URL, + TIMEOUT: Env.DEFAULT_RPDB_CATALOGS_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: + Env.DEFAULT_RPDB_CATALOGS_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: [], + DESCRIPTION: 'Catalogs to accurately track new / popular / best release!', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [], + SUPPORTED_RESOURCES: supportedResources, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + if (!userData.rpdbApiKey) { + throw new Error( + `${this.METADATA.NAME} requires an RPDB API Key. Please provide one in the services section` + ); + } + return [this.generateAddon(userData, options)]; + } + + private static generateAddon( + userData: UserData, + options: Record + ): Addon { + return { + name: options.name || this.METADATA.NAME, + manifestUrl: `${Env.RPDB_CATALOGS_URL}/${userData.rpdbApiKey}/poster-default/${options.catalogs.join('_')}/manifest.json`, + enabled: true, + library: false, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } +} diff --git a/packages/core/src/presets/starWarsUniverse.ts b/packages/core/src/presets/starWarsUniverse.ts new file mode 100644 index 0000000000000000000000000000000000000000..26df9ba17e30ed86389e507c34c6e5aedd03a443 --- /dev/null +++ b/packages/core/src/presets/starWarsUniverse.ts @@ -0,0 +1,139 @@ +import { Addon, Option, UserData } from '../db'; +import { Preset, baseOptions } from './preset'; +import { constants, Env } from '../utils'; + +export class StarWarsUniversePreset extends Preset { + private static catalogs = [ + { + label: 'Movies & Series Chronological', + value: 'sw-movies-series-chronological', + }, + { + label: 'Movies & Series Release', + value: 'sw-movies-series-release', + }, + { + label: 'Skywalker Saga', + value: 'sw-skywalker-saga', + }, + { + label: 'Anthology Films', + value: 'sw-anthology-films', + }, + { + label: 'Live-Action Series', + value: 'sw-live-action-series', + }, + { + label: 'Animated Series', + value: 'sw-animated-series', + }, + { + label: 'Micro-Series & Shorts', + value: 'sw-micro-series-shorts', + }, + { + label: 'High Republic Era', + value: 'sw-high-republic-era', + }, + { + label: 'Empire Era', + value: 'sw-empire-era', + }, + { + label: 'New Republic Era', + value: 'sw-new-republic-era', + }, + { + label: 'Bounty Hunters & Underworld', + value: 'sw-bounty-hunters-underworld', + }, + { + label: 'Jedi & Sith Lore', + value: 'sw-jedi-sith-lore', + }, + { + label: 'Droids & Creatures', + value: 'sw-droids-creatures', + }, + ]; + static override get METADATA() { + const supportedResources = [ + constants.CATALOG_RESOURCE, + constants.META_RESOURCE, + ]; + + const options: Option[] = [ + ...baseOptions( + 'Star Wars Universe', + supportedResources, + Env.DEFAULT_STAR_WARS_UNIVERSE_TIMEOUT + ).filter((option) => option.id !== 'url'), + { + id: 'catalogs', + name: 'Catalogs', + description: 'The catalogs to display', + type: 'multi-select', + required: true, + options: this.catalogs, + default: this.catalogs.map((catalog) => catalog.value), + }, + { + id: 'socials', + name: '', + description: '', + type: 'socials', + socials: [ + { id: 'github', url: 'https://github.com/tapframe/addon-star-wars' }, + { id: 'ko-fi', url: 'https://ko-fi.com/tapframe' }, + ], + }, + ]; + + return { + ID: 'star-wars-universe', + NAME: 'Star Wars Universe', + LOGO: 'https://www.freeiconspng.com/uploads/logo-star-wars-png-4.png', + URL: Env.DEFAULT_STAR_WARS_UNIVERSE_URL, + TIMEOUT: Env.DEFAULT_STAR_WARS_UNIVERSE_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: + Env.DEFAULT_STAR_WARS_UNIVERSE_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: [], + DESCRIPTION: + 'Explore the Star Wars Universe by sagas, series, eras, and more!', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [], + SUPPORTED_RESOURCES: supportedResources, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + return [this.generateAddon(userData, options)]; + } + + private static generateAddon( + userData: UserData, + options: Record + ): Addon { + const config = + options.catalogs.length !== this.catalogs.length + ? options.catalogs.join('%2C') + : ''; + return { + name: options.name || this.METADATA.NAME, + manifestUrl: `${Env.DEFAULT_STAR_WARS_UNIVERSE_URL}/${config ? 'catalog/' + config + '/' : ''}manifest.json`, + enabled: true, + library: false, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } +} diff --git a/packages/core/src/presets/streamfusion.ts b/packages/core/src/presets/streamfusion.ts new file mode 100644 index 0000000000000000000000000000000000000000..e6e81a64ab4584e404a7b69d30988698377fd2e5 --- /dev/null +++ b/packages/core/src/presets/streamfusion.ts @@ -0,0 +1,272 @@ +import { Addon, Option, UserData, Resource } from '../db'; +import { baseOptions, Preset } from './preset'; +import { Env } from '../utils'; +import { constants, ServiceId } from '../utils'; + +export class StreamFusionPreset extends Preset { + static override get METADATA() { + const supportedServices: ServiceId[] = [ + constants.REALDEBRID_SERVICE, + constants.PREMIUMIZE_SERVICE, + constants.ALLEDEBRID_SERVICE, + constants.TORBOX_SERVICE, + constants.EASYDEBRID_SERVICE, + constants.DEBRIDLINK_SERVICE, + constants.OFFCLOUD_SERVICE, + constants.PIKPAK_SERVICE, + ]; + + const supportedResources = [ + constants.STREAM_RESOURCE, + constants.CATALOG_RESOURCE, + constants.META_RESOURCE, + ]; + + const options: Option[] = [ + ...baseOptions( + 'StreamFusion', + supportedResources, + Env.DEFAULT_STREAMFUSION_TIMEOUT + ), + { + id: 'streamFusionApiKey', + name: 'StreamFusion API Key', + description: + 'The API key for the StreamFusion service. You can get it by sending the `/generate` command to the [StremioFR Telegram bot](https://t.me/Stremiofr_bot)', + type: 'password', + required: true, + }, + { + id: 'torboxSearch', + name: 'Torbox Search', + description: + "Enable or disable the use of Torbox's Public Torrent Search Engine", + type: 'boolean', + required: false, + default: false, + }, + { + id: 'torboxUsenet', + name: 'Torbox Usenet', + description: + "Enable or disable the use of Torbox's Usenet search and download functionality.", + type: 'boolean', + required: false, + default: false, + }, + { + id: 'catalogs', + name: 'Catalogs', + description: 'What catalogs should be displayed', + type: 'multi-select', + required: false, + options: [ + { + value: 'yggtorrent', + label: 'YggTorrent', + }, + { + value: 'yggflix', + label: 'YggFlix', + }, + ], + default: ['yggtorrent', 'yggflix'], + }, + { + id: 'torrenting', + name: 'Torrenting', + description: + "Use direct torrent streaming instead of debrid. If you haven't provided any debrid SERVICES, torrenting is automatically used and this option does not apply to you.", + type: 'boolean', + required: false, + default: false, + }, + { + id: 'services', + name: 'Services', + description: + 'Optionally override the services that are used. If not specified, then the services that are enabled and supported will be used.', + type: 'multi-select', + required: false, + options: supportedServices.map((service) => ({ + value: service, + label: constants.SERVICE_DETAILS[service].name, + })), + default: undefined, + emptyIsUndefined: true, + }, + { + id: 'socials', + name: '', + description: '', + type: 'socials', + socials: [ + { + id: 'github', + url: 'https://github.com/Telkaoss/stream-fusion', + }, + { id: 'discord', url: 'https://discord.gg/ZhWvKVmTuh' }, + ], + }, + ]; + + return { + ID: 'streamfusion', + NAME: 'StreamFusion', + LOGO: 'https://stream-fusion.stremiofr.com/static/logo-stream-fusion.png', + URL: Env.DEFAULT_STREAMFUSION_URL, + TIMEOUT: Env.DEFAULT_STREAMFUSION_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: Env.DEFAULT_STREAMFUSION_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: supportedServices, + DESCRIPTION: 'Stremio addon focusing on french content', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [ + constants.DEBRID_STREAM_TYPE, + constants.P2P_STREAM_TYPE, + ], + SUPPORTED_RESOURCES: supportedResources, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + if (options?.url?.endsWith('/manifest.json')) { + return [this.generateAddon(userData, options, [])]; + } + + const usableServices = + this.getUsableServices(userData, options.services) || []; + + return [ + this.generateAddon( + userData, + options, + usableServices.map((service) => service.id) + ), + ]; + } + + private static generateAddon( + userData: UserData, + options: Record, + serviceIds: ServiceId[] + ): Addon { + return { + name: options.name || this.METADATA.NAME, + identifier: + serviceIds.length > 0 + ? serviceIds.length > 1 + ? 'multi' + : constants.SERVICE_DETAILS[serviceIds[0]].shortName + : 'p2p', + displayIdentifier: + serviceIds.length > 0 + ? serviceIds.length > 1 + ? serviceIds + .map((id) => constants.SERVICE_DETAILS[id].shortName) + .join(' | ') + : constants.SERVICE_DETAILS[serviceIds[0]].shortName + : 'P2P', + manifestUrl: this.generateManifestUrl(userData, options, serviceIds), + enabled: true, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } + + private static generateManifestUrl( + userData: UserData, + options: Record, + serviceIds: ServiceId[] + ) { + let url = options.url || this.METADATA.URL; + if (url.endsWith('/manifest.json')) { + return url; + } + + const specialCases = { + [constants.OFFCLOUD_SERVICE]: (credentials: any) => + `${credentials.email}:${credentials.password}`, + [constants.PIKPAK_SERVICE]: (credentials: any) => + `${credentials.email}:${credentials.password}`, + }; + + url = url.replace(/\/$/, ''); + const configString = this.base64EncodeJSON({ + addonHost: options.url ? new URL(options.url).origin : this.METADATA.URL, + apiKey: options.streamFusionApiKey, + service: serviceIds.map( + (serviceId) => constants.SERVICE_DETAILS[serviceId].name + ), + // this probably doesnt work for RD and AD as configuration page uses oauth flow and puts json response from RD/AD as values. + RDToken: serviceIds.includes(constants.REALDEBRID_SERVICE) + ? this.getServiceCredential(constants.REALDEBRID_SERVICE, userData) + : '', + ADToken: serviceIds.includes(constants.ALLEDEBRID_SERVICE) + ? this.getServiceCredential(constants.ALLEDEBRID_SERVICE, userData) + : '', + TBToken: serviceIds.includes(constants.TORBOX_SERVICE) + ? this.getServiceCredential(constants.TORBOX_SERVICE, userData) + : '', + PMToken: serviceIds.includes(constants.PREMIUMIZE_SERVICE) + ? this.getServiceCredential(constants.PREMIUMIZE_SERVICE, userData) + : '', + debridlinkApiKey: serviceIds.includes(constants.DEBRIDLINK_SERVICE) + ? this.getServiceCredential(constants.DEBRIDLINK_SERVICE, userData) + : '', + easydebridApiKey: serviceIds.includes(constants.EASYDEBRID_SERVICE) + ? this.getServiceCredential(constants.EASYDEBRID_SERVICE, userData) + : '', + offcloudCredentials: serviceIds.includes(constants.OFFCLOUD_SERVICE) + ? this.getServiceCredential( + constants.OFFCLOUD_SERVICE, + userData, + specialCases + ) + : '', + pikpakCredentials: serviceIds.includes(constants.PIKPAK_SERVICE) + ? this.getServiceCredential( + constants.PIKPAK_SERVICE, + userData, + specialCases + ) + : '', + TBUsenet: options.torboxUsenet, + TBSearch: options.torboxSearch, + maxSize: 150, + exclusionKeywords: [], + languages: ['en', 'fr', 'multi'], + sort: 'quality', + resultsPerQuality: 30, + maxResults: 100, + minCachedResults: 10, + exclusion: [], + cacheUrl: 'https://stremio-jackett-cacher.elfhosted.com/', + cache: true, + zilean: true, + yggflix: true, + sharewood: true, + yggtorrentCtg: options.catalogs?.includes('yggtorrent') ?? false, + yggflixCtg: options.catalogs?.includes('yggflix') ?? false, + torrenting: + serviceIds.length === 0 ? true : (options.torrenting ?? false), + debrid: serviceIds.length > 0, + metadataProvider: 'tmdb', + debridDownloader: + serviceIds.length > 0 + ? constants.SERVICE_DETAILS[serviceIds[0]].name + : '', + stremthru: true, + stremthruUrl: Env.DEFAULT_STREAMFUSION_STREMTHRU_URL, + }); + + return `${url}${configString ? '/' + configString : ''}/manifest.json`; + } +} diff --git a/packages/core/src/presets/streamingCatalogs.ts b/packages/core/src/presets/streamingCatalogs.ts new file mode 100644 index 0000000000000000000000000000000000000000..a23e2c797e2f7786f5d714cb35759dae0a63fdca --- /dev/null +++ b/packages/core/src/presets/streamingCatalogs.ts @@ -0,0 +1,179 @@ +import { Addon, Option, UserData } from '../db'; +import { Preset, baseOptions } from './preset'; +import { constants, Env } from '../utils'; + +export class StreamingCatalogsPreset extends Preset { + // amp,atp,hbm,sst,vil,cpd,nlz,blv,zee,hay,clv,gop,hst,cru,mgl,cts,hlu,pmp,pcp,dnp,nfk,nfx + // Amazon Prime, Apple TV+, HBO Max, Sky Showtime, Videoland, Canal+, NLZIET + // BluTV, Zee5, Hayu, Clarovideo, Globoplay, Hotstar, Cruncyroll, Magellan TV, Curiosity Stream, + // Hulu, Paramount Plus, Peacock Premium, Disney+, Netflix Kids, Netflix + private static catalogs = [ + { + label: 'Amazon Prime', + value: 'amp', + }, + { + label: 'Apple TV+', + value: 'atp', + }, + { + label: 'HBO Max', + value: 'hbm', + }, + { + label: 'Sky Showtime', + value: 'sst', + }, + { + label: 'Videoland', + value: 'vil', + }, + { + label: 'Canal+', + value: 'cpd', + }, + { + label: 'NLZIET', + value: 'nlz', + }, + { + label: 'BluTV', + value: 'blv', + }, + { + label: 'Zee5', + value: 'zee', + }, + { + label: 'Hayu', + value: 'hay', + }, + { + label: 'Clarovideo', + value: 'clv', + }, + { + label: 'Globoplay', + value: 'gop', + }, + { + label: 'Hotstar', + value: 'hst', + }, + { + label: 'Cruncyroll', + value: 'cru', + }, + { + label: 'Magellan TV', + value: 'mgl', + }, + { + label: 'Curiosity Stream', + value: 'cts', + }, + { + label: 'Hulu', + value: 'hlu', + }, + { + label: 'Paramount Plus', + value: 'pmp', + }, + { + label: 'Peacock Premium', + value: 'pcp', + }, + { + label: 'Disney+', + value: 'dnp', + }, + { + label: 'Netflix Kids', + value: 'nfk', + }, + { + label: 'Netflix', + value: 'nfx', + }, + { + label: 'Discovery+', + value: 'dpe', + }, + ]; + static override get METADATA() { + const supportedResources = [constants.CATALOG_RESOURCE]; + + const options: Option[] = [ + ...baseOptions( + 'Streaming Catalogs', + supportedResources, + Env.DEFAULT_STREAMING_CATALOGS_TIMEOUT + ).filter((option) => option.id !== 'url'), + { + id: 'catalogs', + name: 'Catalogs', + description: 'The catalogs to display', + type: 'multi-select', + required: true, + options: this.catalogs, + default: ['nfx', 'hbm', 'dnp', 'amp', 'atp'], + }, + { + id: 'socials', + name: '', + description: '', + type: 'socials', + socials: [ + { + id: 'github', + url: 'https://github.com/rleroi/Stremio-Streaming-Catalogs-Addon', + }, + { id: 'ko-fi', url: 'https://ko-fi.com/rab1t' }, + ], + }, + ]; + + return { + ID: 'streaming-catalogs', + NAME: 'Streaming Catalogs', + LOGO: `https://play-lh.googleusercontent.com/TBRwjS_qfJCSj1m7zZB93FnpJM5fSpMA_wUlFDLxWAb45T9RmwBvQd5cWR5viJJOhkI`, + URL: Env.STREAMING_CATALOGS_URL, + TIMEOUT: Env.DEFAULT_STREAMING_CATALOGS_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: + Env.DEFAULT_STREAMING_CATALOGS_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: [], + DESCRIPTION: 'Catalogs for your favourite streaming services!', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [], + SUPPORTED_RESOURCES: supportedResources, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + return [this.generateAddon(userData, options)]; + } + + private static generateAddon( + userData: UserData, + options: Record + ): Addon { + const config = Buffer.from(options.catalogs.join(',')).toString('base64'); + return { + name: options.name || this.METADATA.NAME, + manifestUrl: `${Env.STREAMING_CATALOGS_URL}/${config}/manifest.json`, + enabled: true, + library: false, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } +} diff --git a/packages/core/src/presets/stremthruStore.ts b/packages/core/src/presets/stremthruStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..19e85ac9bae6743b2e8297f4c4e545e6f09f0f03 --- /dev/null +++ b/packages/core/src/presets/stremthruStore.ts @@ -0,0 +1,198 @@ +import { Addon, Option, UserData, Resource, Stream } from '../db'; +import { baseOptions, Preset } from './preset'; +import { Env } from '../utils'; +import { constants, ServiceId } from '../utils'; +import { StreamParser } from '../parser'; + +class StremthruStoreStreamParser extends StreamParser { + protected override applyUrlModifications( + url: string | undefined + ): string | undefined { + if (!url) { + return url; + } + if ( + Env.FORCE_STREMTHRU_STORE_HOSTNAME !== undefined || + Env.FORCE_STREMTHRU_STORE_PORT !== undefined || + Env.FORCE_STREMTHRU_STORE_PROTOCOL !== undefined + ) { + // modify the URL according to settings, needed when using a local URL for requests but a public stream URL is needed. + const urlObj = new URL(url); + + if (Env.FORCE_STREMTHRU_STORE_PROTOCOL !== undefined) { + urlObj.protocol = Env.FORCE_STREMTHRU_STORE_PROTOCOL; + } + if (Env.FORCE_STREMTHRU_STORE_PORT !== undefined) { + urlObj.port = Env.FORCE_STREMTHRU_STORE_PORT.toString(); + } + if (Env.FORCE_STREMTHRU_STORE_HOSTNAME !== undefined) { + urlObj.hostname = Env.FORCE_STREMTHRU_STORE_HOSTNAME; + } + return urlObj.toString(); + } + return url; + } +} + +export class StremthruStorePreset extends Preset { + static override getParser(): typeof StreamParser { + return StremthruStoreStreamParser; + } + + static override get METADATA() { + const supportedServices: ServiceId[] = [ + constants.REALDEBRID_SERVICE, + constants.PREMIUMIZE_SERVICE, + constants.ALLEDEBRID_SERVICE, + constants.TORBOX_SERVICE, + constants.EASYDEBRID_SERVICE, + constants.DEBRIDLINK_SERVICE, + constants.OFFCLOUD_SERVICE, + constants.PIKPAK_SERVICE, + ]; + + const supportedResources = [ + constants.STREAM_RESOURCE, + constants.CATALOG_RESOURCE, + constants.META_RESOURCE, + ]; + + const options: Option[] = [ + ...baseOptions( + 'StremThru Store', + supportedResources, + Env.DEFAULT_STREMTHRU_STORE_TIMEOUT + ), + { + id: 'services', + name: 'Services', + description: + 'Optionally override the services that are used. If not specified, then the services that are enabled and supported will be used.', + type: 'multi-select', + required: false, + options: supportedServices.map((service) => ({ + value: service, + label: constants.SERVICE_DETAILS[service].name, + })), + default: undefined, + emptyIsUndefined: true, + }, + { + id: 'webDl', + name: 'Web DL', + description: 'Enable web DL', + type: 'boolean', + }, + { + id: 'socials', + name: '', + description: '', + type: 'socials', + socials: [ + { + id: 'github', + url: 'https://github.com/MunifTanjim/stremthru', + }, + { id: 'buymeacoffee', url: 'https://buymeacoffee.com/muniftanjim' }, + { id: 'patreon', url: 'https://patreon.com/MunifTanjim' }, + ], + }, + ]; + + return { + ID: 'stremthruStore', + NAME: 'StremThru Store', + LOGO: 'https://emojiapi.dev/api/v1/sparkles/256.png', + URL: Env.STREMTHRU_STORE_URL, + TIMEOUT: Env.DEFAULT_STREMTHRU_STORE_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: + Env.DEFAULT_STREMTHRU_STORE_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: supportedServices, + DESCRIPTION: 'Access your debrid library through catalogs and streams.', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [constants.DEBRID_STREAM_TYPE], + SUPPORTED_RESOURCES: supportedResources, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + // url can either be something like https://torrentio.com/ or it can be a custom manifest url. + // if it is a custom manifest url, return a single addon with the custom manifest url. + if (options?.url?.endsWith('/manifest.json')) { + return [this.generateAddon(userData, options, undefined)]; + } + + const usableServices = this.getUsableServices(userData, options.services); + // if no services are usable, throw an error + if (!usableServices || usableServices.length === 0) { + throw new Error( + `${this.METADATA.NAME} requires at least one usable service, but none were found. Please enable at least one of the following services: ${this.METADATA.SUPPORTED_SERVICES.join( + ', ' + )}` + ); + } + + return usableServices.map((service) => + this.generateAddon(userData, options, service.id) + ); + } + + private static generateAddon( + userData: UserData, + options: Record, + serviceId?: ServiceId + ): Addon { + return { + name: options.name || this.METADATA.NAME, + identifier: serviceId + ? `${constants.SERVICE_DETAILS[serviceId].shortName}` + : undefined, + manifestUrl: this.generateManifestUrl(userData, options, serviceId), + enabled: true, + library: true, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } + + private static generateManifestUrl( + userData: UserData, + options: Record, + serviceId: ServiceId | undefined + ) { + let url = options.url || this.METADATA.URL; + if (url.endsWith('/manifest.json')) { + return url; + } + url = url.replace(/\/$/, ''); + if (!serviceId) { + throw new Error( + `${this.METADATA.NAME} requires at least one service, but none were found. Please enable at least one of the following services: ${this.METADATA.SUPPORTED_SERVICES.join( + ', ' + )}` + ); + } + const configString = this.base64EncodeJSON({ + store_name: serviceId, + store_token: this.getServiceCredential(serviceId, userData, { + [constants.OFFCLOUD_SERVICE]: (credentials: any) => + `${credentials.email}:${credentials.password}`, + [constants.PIKPAK_SERVICE]: (credentials: any) => + `${credentials.email}:${credentials.password}`, + }), + hide_catalog: false, + hide_stream: false, + webdl: options.webDl ?? false, + }); + + return `${url}${configString ? '/' + configString : ''}/manifest.json`; + } +} diff --git a/packages/core/src/presets/stremthruTorz.ts b/packages/core/src/presets/stremthruTorz.ts new file mode 100644 index 0000000000000000000000000000000000000000..aece408b222941b8ecc9d46d087319897804687d --- /dev/null +++ b/packages/core/src/presets/stremthruTorz.ts @@ -0,0 +1,310 @@ +import { Addon, Option, UserData, Resource, ParsedStream, Stream } from '../db'; +import { baseOptions, Preset } from './preset'; +import { Env } from '../utils'; +import { constants, ServiceId } from '../utils'; +import { StreamParser } from '../parser'; + +class StremthruTorzStreamParser extends StreamParser { + protected override applyUrlModifications( + url: string | undefined + ): string | undefined { + if (!url) { + return url; + } + if ( + Env.FORCE_STREMTHRU_TORZ_HOSTNAME !== undefined || + Env.FORCE_STREMTHRU_TORZ_PORT !== undefined || + Env.FORCE_STREMTHRU_TORZ_PROTOCOL !== undefined + ) { + // modify the URL according to settings, needed when using a local URL for requests but a public stream URL is needed. + const urlObj = new URL(url); + + if (Env.FORCE_STREMTHRU_TORZ_PROTOCOL !== undefined) { + urlObj.protocol = Env.FORCE_STREMTHRU_TORZ_PROTOCOL; + } + if (Env.FORCE_STREMTHRU_TORZ_PORT !== undefined) { + urlObj.port = Env.FORCE_STREMTHRU_TORZ_PORT.toString(); + } + if (Env.FORCE_STREMTHRU_TORZ_HOSTNAME !== undefined) { + urlObj.hostname = Env.FORCE_STREMTHRU_TORZ_HOSTNAME; + } + return urlObj.toString(); + } + return url; + } + + // ensure release groups aren't misidentified as indexers + protected override getIndexer( + stream: Stream, + currentParsedStream: ParsedStream + ): string | undefined { + return undefined; + } + + protected override getFolderSize( + stream: Stream, + currentParsedStream: ParsedStream + ): number | undefined { + let folderSize = this.calculateBytesFromSizeString( + stream.description ?? '', + /📦\s*(\d+(\.\d+)?)\s?(KB|MB|GB|TB)/i + ); + if (folderSize && currentParsedStream.size) { + if ( + Math.abs(folderSize - currentParsedStream.size) <= + currentParsedStream.size * 0.05 + ) { + return undefined; + } + } + return folderSize; + } +} + +export class StremthruTorzPreset extends Preset { + static override getParser(): typeof StreamParser { + return StremthruTorzStreamParser; + } + + static override get METADATA() { + const supportedServices: ServiceId[] = [ + constants.REALDEBRID_SERVICE, + constants.PREMIUMIZE_SERVICE, + constants.ALLEDEBRID_SERVICE, + constants.TORBOX_SERVICE, + constants.EASYDEBRID_SERVICE, + constants.DEBRIDLINK_SERVICE, + constants.OFFCLOUD_SERVICE, + constants.PIKPAK_SERVICE, + ]; + + const supportedResources = [constants.STREAM_RESOURCE]; + + const options: Option[] = [ + ...baseOptions( + 'StremThru Torz', + supportedResources, + Env.DEFAULT_STREMTHRU_STORE_TIMEOUT + ), + { + id: 'services', + name: 'Services', + description: + 'Optionally override the services that are used. If not specified, then the services that are enabled and supported will be used.', + type: 'multi-select', + required: false, + options: supportedServices.map((service) => ({ + value: service, + label: constants.SERVICE_DETAILS[service].name, + })), + default: undefined, + emptyIsUndefined: true, + }, + { + id: 'includeP2P', + name: 'Include P2P', + description: + 'Use this option when you want to include P2P results even when using a debrid service. If left unchecked, then P2P results will not be fetched when using a debrid service.', + type: 'boolean', + default: false, + }, + { + id: 'useMultipleInstances', + name: 'Use Multiple Instances', + description: + 'StremThru Torz supports multiple services in one instance of the addon - which is used by default. If this is enabled, then the addon will be created for each service.', + type: 'boolean', + default: false, + }, + { + id: 'socials', + name: '', + description: '', + type: 'socials', + socials: [ + { + id: 'github', + url: 'https://github.com/MunifTanjim/stremthru', + }, + { id: 'buymeacoffee', url: 'https://buymeacoffee.com/muniftanjim' }, + { id: 'patreon', url: 'https://patreon.com/MunifTanjim' }, + ], + }, + ]; + + return { + ID: 'stremthruTorz', + NAME: 'StremThru Torz', + LOGO: 'https://emojiapi.dev/api/v1/sparkles/256.png', + URL: Env.STREMTHRU_TORZ_URL, + TIMEOUT: Env.DEFAULT_STREMTHRU_TORZ_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: + Env.DEFAULT_STREMTHRU_TORZ_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: supportedServices, + DESCRIPTION: + 'Access a crowdsourced torrent library supplemented by DMM hashlists', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [ + constants.DEBRID_STREAM_TYPE, + constants.P2P_STREAM_TYPE, + ], + SUPPORTED_RESOURCES: supportedResources, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + // Handle custom manifest URL + if (options?.url?.endsWith('/manifest.json')) { + return [this.generateAddon(userData, options, [])]; + } + + const usableServices = this.getUsableServices(userData, options.services); + let serviceIds: (ServiceId | 'p2p')[] = + usableServices?.map((s) => s.id) || []; + + // If no services available, return single P2P addon + if (serviceIds.length === 0) { + return [this.generateAddon(userData, options, ['p2p'])]; + } + + // Add P2P if requested + if (options.includeP2P) { + serviceIds.push('p2p'); + } + + const addons: Addon[] = []; + + if (options.useMultipleInstances) { + // Generate separate addon for each service (including P2P if present) + addons.push( + ...serviceIds.map((serviceId) => + this.generateAddon(userData, options, [serviceId]) + ) + ); + } else { + // P2P always gets its own addon + if (serviceIds.includes('p2p')) { + addons.push(this.generateAddon(userData, options, ['p2p'])); + } + + // Generate combined addon with all non-P2P services + const nonP2PServices = serviceIds.filter((id) => id !== 'p2p'); + if (nonP2PServices.length > 0) { + addons.push(this.generateAddon(userData, options, nonP2PServices)); + } + } + + return addons; + } + private static generateAddon( + userData: UserData, + options: Record, + serviceIds: (ServiceId | 'p2p')[] + ): Addon { + return { + name: options.name || this.METADATA.NAME, + displayIdentifier: serviceIds + .map((id) => this.getServiceDetails(id).shortName) + .join(' | '), + identifier: + serviceIds.length > 0 + ? serviceIds.includes('p2p') + ? 'p2p' + : serviceIds.length > 1 + ? 'multi' + : this.getServiceDetails(serviceIds[0]).code + : undefined, + + manifestUrl: this.generateManifestUrl(userData, options, serviceIds), + enabled: true, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } + + private static generateManifestUrl( + userData: UserData, + options: Record, + serviceIds: (ServiceId | 'p2p')[] + ): string { + // If URL already points to manifest.json, return as-is + let baseUrl = options.url || this.METADATA.URL; + if (baseUrl.endsWith('/manifest.json')) { + return baseUrl; + } + + // Normalize URL by removing trailing slash + baseUrl = baseUrl.replace(/\/$/, ''); + + // Generate configuration string + const configString = this.generateConfigString(serviceIds, userData); + + // Build final manifest URL + return `${baseUrl}${configString ? '/' + configString : ''}/manifest.json`; + } + + private static generateConfigString( + serviceIds: (ServiceId | 'p2p')[], + userData: UserData + ): string { + const storeConfigs = serviceIds.map((serviceId) => + this.createStoreConfig(serviceId, userData) + ); + + return this.base64EncodeJSON({ stores: storeConfigs }); + } + + private static createStoreConfig( + serviceId: ServiceId | 'p2p', + userData: UserData + ): { c: string; t: string } { + return { + c: this.getServiceDetails(serviceId).code, + t: this.getServiceToken(serviceId, userData), + }; + } + + private static getServiceDetails(serviceId: ServiceId | 'p2p'): { + code: string; + shortName: string; + } { + if (serviceId === 'p2p') { + return { code: 'p2p', shortName: 'P2P' }; + } + + if (serviceId === constants.PIKPAK_SERVICE) { + return { code: 'pp', shortName: 'PKP' }; + } + + return { + code: constants.SERVICE_DETAILS[serviceId].shortName.toLowerCase(), + shortName: constants.SERVICE_DETAILS[serviceId].shortName, + }; + } + + private static getServiceToken( + serviceId: ServiceId | 'p2p', + userData: UserData + ): string { + if (serviceId === 'p2p') { + return ''; + } + + const credentialFormatters = { + [constants.OFFCLOUD_SERVICE]: (credentials: any) => + `${credentials.email}:${credentials.password}`, + [constants.PIKPAK_SERVICE]: (credentials: any) => + `${credentials.email}:${credentials.password}`, + }; + + return this.getServiceCredential(serviceId, userData, credentialFormatters); + } +} diff --git a/packages/core/src/presets/tmdb.ts b/packages/core/src/presets/tmdb.ts new file mode 100644 index 0000000000000000000000000000000000000000..859ffcdaa8dc9c9fd7b856cb53188f3b28bb8334 --- /dev/null +++ b/packages/core/src/presets/tmdb.ts @@ -0,0 +1,175 @@ +import { Preset, baseOptions } from './preset'; +import { constants, Env, FULL_LANGUAGE_MAPPING } from '../utils'; +import { Addon, Option, UserData } from '../db'; + +export class TMDBAddonPreset extends Preset { + static override get METADATA() { + const supportedResources = [ + constants.CATALOG_RESOURCE, + constants.META_RESOURCE, + ]; + + const options: Option[] = [ + ...baseOptions( + 'The Movie Database', + supportedResources, + Env.DEFAULT_TMDB_ADDON_TIMEOUT + ), + { + id: 'Enable Adult Content', + name: 'Enable Adult Content', + description: 'Enable adult content', + type: 'boolean', + default: false, + required: false, + }, + { + id: 'hideEpisodeThumbnails', + name: 'Hide Episode Thumbnails', + description: 'Avoid spoilers by hiding episode preview images', + type: 'boolean', + default: false, + required: false, + }, + { + id: 'provideImdbId', + name: 'Provide IMDB IDs', + description: + 'Provide IMDB IDs in metadata responses for improved compatability with other addons.', + type: 'boolean', + default: false, + required: false, + }, + { + id: 'ageRating', + name: 'Age Rating', + description: 'The age rating of the catalogs', + type: 'select', + options: [ + { label: 'All', value: undefined }, + { label: 'General Audiences', value: 'G' }, + { label: 'Parental Guidance Suggested', value: 'PG' }, + { label: 'Parents Strongly Cautioned', value: 'PG-13' }, + { label: 'Restricted', value: 'R' }, + { label: 'Adults Only', value: 'NC-17' }, + ], + required: false, + }, + { + id: 'language', + name: 'Language', + description: 'The language of the catalogs', + type: 'select', + default: 'en-US', + options: FULL_LANGUAGE_MAPPING.sort((a, b) => + a.english_name.localeCompare(b.english_name) + ).map((language) => ({ + label: language.english_name, + value: `${language.iso_639_1}-${language.iso_3166_1}`, + })), + required: false, + }, + { + id: 'alert', + name: '', + description: + 'The language selector above will not work for some languages due to the option values not being consistent. In which case, you can override the URL with a preconfigured Manifest URL.', + type: 'alert', + }, + ]; + + return { + ID: 'tmdb-addon', + NAME: 'The Movie Database', + LOGO: 'https://raw.githubusercontent.com/mrcanelas/tmdb-addon/refs/heads/main/public/logo.png', + URL: Env.TMDB_ADDON_URL, + TIMEOUT: Env.DEFAULT_TMDB_ADDON_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: Env.DEFAULT_TMDB_ADDON_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: [], + DESCRIPTION: 'Provides rich metadata for movies and TV shows from TMDB', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [], + SUPPORTED_RESOURCES: supportedResources, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + return [this.generateAddon(userData, options)]; + } + + private static generateAddon( + userData: UserData, + options: Record + ): Addon { + let url = this.METADATA.URL; + if (options.url?.endsWith('/manifest.json')) { + url = options.url; + } else { + let baseUrl = this.METADATA.URL; + if (options.url) { + baseUrl = new URL(options.url).origin; + } + // remove trailing slash + baseUrl = baseUrl.replace(/\/$/, ''); + + const config = this.urlEncodeJSON({ + includeAdult: options.includeAdult ? 'true' : undefined, + provideImdbId: options.provideImdbId ? 'true' : undefined, + hideEpisodeThumbnails: options.hideEpisodeThumbnails + ? 'true' + : undefined, + language: options.language || 'en-US', + streaming: [], + catalogs: [ + { id: 'tmdb.top', type: 'movie', name: 'Popular', showInHome: true }, + { id: 'tmdb.top', type: 'series', name: 'Popular', showInHome: true }, + { id: 'tmdb.year', type: 'movie', name: 'Year', showInHome: true }, + { id: 'tmdb.year', type: 'series', name: 'Year', showInHome: true }, + { + id: 'tmdb.language', + type: 'movie', + name: 'Language', + showInHome: true, + }, + { + id: 'tmdb.language', + type: 'series', + name: 'Language', + showInHome: true, + }, + { + id: 'tmdb.trending', + type: 'movie', + name: 'Trending', + showInHome: true, + }, + { + id: 'tmdb.trending', + type: 'series', + name: 'Trending', + showInHome: true, + }, + ], + ageRating: options.ageRating, + }); + url = `${baseUrl}/${config}/manifest.json`; + } + + return { + name: options.name || this.METADATA.NAME, + manifestUrl: url, + enabled: true, + library: false, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } +} diff --git a/packages/core/src/presets/tmdbCollections.ts b/packages/core/src/presets/tmdbCollections.ts new file mode 100644 index 0000000000000000000000000000000000000000..e09f3b4d76e0538177fda208ce58fb088a750c74 --- /dev/null +++ b/packages/core/src/presets/tmdbCollections.ts @@ -0,0 +1,144 @@ +import { Addon, Option, UserData } from '../db'; +import { Preset, baseOptions } from './preset'; +import { constants, Env, FULL_LANGUAGE_MAPPING } from '../utils'; + +export class TmdbCollectionsPreset extends Preset { + static override get METADATA() { + const supportedResources = [ + constants.CATALOG_RESOURCE, + constants.META_RESOURCE, + constants.STREAM_RESOURCE, + ]; + + const options: Option[] = [ + ...baseOptions( + 'TMDB Collections', + supportedResources, + Env.DEFAULT_TMDB_COLLECTIONS_TIMEOUT + ), + { + id: 'enableAdultContent', + name: 'Enable Adult Content', + description: 'Enable adult content in the catalogs', + type: 'boolean', + default: false, + required: false, + }, + { + id: 'enableSearch', + name: 'Enable Search', + description: 'Enable search in the catalogs', + type: 'boolean', + default: true, + required: false, + }, + { + id: 'enableCollectionFromMovie', + name: 'Discover and open collection from movie details page', + description: + 'Adds a button to movies details page that links to its collection', + type: 'boolean', + default: false, + required: false, + }, + { + id: 'language', + name: 'Language', + description: 'The language of the catalogs', + type: 'select', + default: 'en', + options: FULL_LANGUAGE_MAPPING.sort((a, b) => + a.english_name.localeCompare(b.english_name) + ) + .filter( + (language, index, self) => + index === + self.findIndex((l) => l.iso_639_1 === language.iso_639_1) + ) + .map((language) => ({ + label: language.english_name.split('(')[0].trim(), + value: `${language.iso_639_1}`, + })), + required: false, + }, + { + id: 'alert', + name: '', + description: + 'The language selector above will not work for some languages due to the option values not being consistent. In which case, you can override the URL with a preconfigured Manifest URL.', + type: 'alert', + }, + // https://github.com/youchi1/tmdb-collections/ + { + id: 'socials', + name: '', + description: '', + type: 'socials', + socials: [ + { id: 'github', url: 'https://github.com/youchi1/tmdb-collections' }, + ], + }, + ]; + + return { + ID: 'tmdb-collections', + NAME: 'TMDB Collections', + LOGO: 'https://raw.githubusercontent.com/youchi1/tmdb-collections/main/Images/logo.png', + URL: Env.TMDB_COLLECTIONS_URL, + TIMEOUT: Env.DEFAULT_TMDB_COLLECTIONS_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: + Env.DEFAULT_TMDB_COLLECTIONS_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: [], + DESCRIPTION: 'Catalogs for the TMDB Collections', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [], + SUPPORTED_RESOURCES: supportedResources, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + return [this.generateAddon(userData, options)]; + } + + private static generateAddon( + userData: UserData, + options: Record + ): Addon { + return { + name: options.name || this.METADATA.NAME, + manifestUrl: this.generateManifestUrl(userData, options), + enabled: true, + library: false, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } + + private static generateManifestUrl( + userData: UserData, + options: Record + ) { + let url = options.url || this.METADATA.URL; + if (url.endsWith('/manifest.json')) { + return url; + } + url = url.replace(/\/$/, ''); + const config = this.urlEncodeJSON({ + enableAdultContent: options.enableAdultContent ?? false, + enableSearch: options.enableSearch ?? true, + enableCollectionFromMovie: options.enableCollectionFromMovie ?? false, + language: options.language, + catalogList: ['popular', 'topRated', 'newReleases'], + discoverOnly: { popular: false, topRated: false, newReleases: false }, + }); + return `${url}/${config}/manifest.json`; + } +} diff --git a/packages/core/src/presets/torbox.ts b/packages/core/src/presets/torbox.ts new file mode 100644 index 0000000000000000000000000000000000000000..f753273f0a5bd699cdb82e20913236a75b28315e --- /dev/null +++ b/packages/core/src/presets/torbox.ts @@ -0,0 +1,166 @@ +import { Addon, Option, UserData, Resource, ParsedStream } from '../db'; +import { baseOptions, Preset } from './preset'; +import { Env } from '../utils'; +import { constants, ServiceId } from '../utils'; +import { StreamParser } from '../parser'; +import { Stream } from '../db'; + +class TorboxStreamParser extends StreamParser { + override getSeeders( + stream: Stream, + currentParsedStream: ParsedStream + ): number | undefined { + return (stream as any).seeders && (stream as any).seeders >= 0 + ? (stream as any).seeders + : undefined; + } + override get ageRegex() { + return /\|\sAge:\s([0-9]+[dmyh])/i; + } + override get indexerRegex() { + return /Source:\s*([^\n]+)/; + } + override getInfoHash( + stream: Stream, + currentParsedStream: ParsedStream + ): string | undefined { + return (stream as any).hash; + } + override getInLibrary( + stream: Stream, + currentParsedStream: ParsedStream + ): boolean { + return (stream as any).is_your_media || stream.name?.includes('Your Media'); + } + protected override getService( + stream: Stream, + currentParsedStream: ParsedStream + ): ParsedStream['service'] | undefined { + return { + id: constants.TORBOX_SERVICE, + cached: (stream as any).is_cached ?? true, + }; + } + + protected override getMessage( + stream: Stream, + currentParsedStream: ParsedStream + ): string | undefined { + if (stream.description?.includes('Click play to start')) { + currentParsedStream.filename = undefined; + return 'Click play to start streaming your media'; + } + return undefined; + } + + protected override getStreamType( + stream: Stream, + service: ParsedStream['service'], + currentParsedStream: ParsedStream + ): ParsedStream['type'] { + if ((stream as any).type === 'usenet') { + return constants.USENET_STREAM_TYPE; + } + const type = stream.description?.match(/Type:\s*([^\n\s]+)/)?.[1]; + if (type) { + if (type.includes('Torrent')) { + return constants.DEBRID_STREAM_TYPE; + } else if (type.includes('Usenet')) { + return constants.USENET_STREAM_TYPE; + } + } + return super.getStreamType(stream, service, currentParsedStream); + } +} + +export class TorboxAddonPreset extends Preset { + static override getParser(): typeof StreamParser { + return TorboxStreamParser; + } + + static override get METADATA() { + const supportedServices: ServiceId[] = [constants.TORBOX_SERVICE]; + + const supportedResources = [ + constants.STREAM_RESOURCE, + constants.META_RESOURCE, + constants.CATALOG_RESOURCE, + ]; + + const options: Option[] = [ + ...baseOptions('TorBox', supportedResources, Env.DEFAULT_TORBOX_TIMEOUT), + { + id: 'socials', + name: '', + description: '', + type: 'socials', + socials: [{ id: 'website', url: 'https://torbox.app' }], + }, + ]; + + return { + ID: 'torbox', + NAME: 'TorBox', + LOGO: 'https://torbox.app/android-chrome-512x512.png', + URL: Env.TORBOX_STREMIO_URL, + TIMEOUT: Env.DEFAULT_TORBOX_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: Env.DEFAULT_TORBOX_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: supportedServices, + DESCRIPTION: + 'Provides torrent and usenet streams for users of TorBox.app', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [ + constants.DEBRID_STREAM_TYPE, + constants.USENET_STREAM_TYPE, + ], + SUPPORTED_RESOURCES: supportedResources, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + return [this.generateAddon(userData, options)]; + } + + private static generateAddon( + userData: UserData, + options: Record + ): Addon { + return { + name: options.name || this.METADATA.NAME, + manifestUrl: this.generateManifestUrl(userData, options), + enabled: true, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } + + private static generateManifestUrl( + userData: UserData, + options: Record + ) { + let url = options.url || this.METADATA.URL; + if (url.endsWith('/manifest.json')) { + return url; + } + url = url.replace(/\/$/, ''); + const torboxApiKey = this.getServiceCredential( + constants.TORBOX_SERVICE, + userData + ); + if (!torboxApiKey) { + throw new Error( + `${this.METADATA.NAME} requires the Torbox service to be enabled.` + ); + } + + return `${url}/${torboxApiKey}/manifest.json`; + } +} diff --git a/packages/core/src/presets/torrentCatalogs.ts b/packages/core/src/presets/torrentCatalogs.ts new file mode 100644 index 0000000000000000000000000000000000000000..5e936232b0973a1c91325384c0db4fdf7fae919f --- /dev/null +++ b/packages/core/src/presets/torrentCatalogs.ts @@ -0,0 +1,59 @@ +import { Addon, Option, UserData } from '../db'; +import { Preset, baseOptions } from './preset'; +import { constants, Env } from '../utils'; + +export class TorrentCatalogsPreset extends Preset { + static override get METADATA() { + const supportedResources = [constants.CATALOG_RESOURCE]; + + const options: Option[] = [ + ...baseOptions( + 'Torrent Catalogs', + supportedResources, + Env.DEFAULT_TORRENT_CATALOGS_TIMEOUT + ).filter((option) => option.id !== 'url'), + ]; + + return { + ID: 'torrent-catalogs', + NAME: 'Torrent Catalogs', + LOGO: 'https://i.ibb.co/w4BnkC9/GwxAcDV.png', + URL: Env.TORRENT_CATALOGS_URL, + TIMEOUT: Env.DEFAULT_TORRENT_CATALOGS_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: + Env.DEFAULT_TORRENT_CATALOGS_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: [], + DESCRIPTION: + 'Provides catalogs for movies/series/anime based on top seeded torrents. Requires Kitsu addon for anime.', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [], + SUPPORTED_RESOURCES: supportedResources, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + return [this.generateAddon(userData, options)]; + } + + private static generateAddon( + userData: UserData, + options: Record + ): Addon { + return { + name: options.name || this.METADATA.NAME, + manifestUrl: `${Env.TORRENT_CATALOGS_URL}/manifest.json`, + enabled: true, + library: false, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } +} diff --git a/packages/core/src/presets/torrentio.ts b/packages/core/src/presets/torrentio.ts new file mode 100644 index 0000000000000000000000000000000000000000..9368b45e1ce15b81cd918d3b1007ee9f4c01289e --- /dev/null +++ b/packages/core/src/presets/torrentio.ts @@ -0,0 +1,311 @@ +import { Addon, Option, UserData, Resource, Stream, ParsedStream } from '../db'; +import { Preset, baseOptions } from './preset'; +import { Env, SERVICE_DETAILS } from '../utils'; +import { constants, ServiceId } from '../utils'; +import { StreamParser } from '../parser'; + +export class TorrentioParser extends StreamParser { + override getFolder(stream: Stream): string | undefined { + const description = stream.description || stream.title; + if (!description) { + return undefined; + } + const folderName = description.split('\n')[0]; + return folderName; + } + + protected override getLanguages( + stream: Stream, + currentParsedStream: ParsedStream + ): string[] { + if (stream.description?.includes('Multi Subs')) { + return []; + } + return super.getLanguages(stream, currentParsedStream); + } +} + +export class TorrentioPreset extends Preset { + static defaultProviders = [ + { + value: 'yts', + label: 'YTS', + }, + { + value: 'eztv', + label: 'EZTV', + }, + + { + value: 'rarbg', + label: 'RARBG', + }, + { + value: '1337x', + label: '1337X', + }, + { + value: 'thepiratebay', + label: 'The Pirate Bay', + }, + { + value: 'kickasstorrents', + label: 'Kickass Torrents', + }, + { + value: 'torrentgalaxy', + label: 'Torrent Galaxy', + }, + { + value: 'magnetdl', + label: 'MagnetDL', + }, + { + value: 'horriblesubs', + label: 'HorribleSubs', + }, + { + value: 'nyaasi', + label: 'Nyaa.si', + }, + { + value: 'tokyotosho', + label: 'Tokyo Tosho', + }, + { + value: 'anidex', + label: 'AniDex', + }, + { + value: 'rutor', + label: 'Rutor', + }, + { + value: 'rutracker', + label: 'Rutracker', + }, + { + value: 'comando', + label: 'Comando', + }, + { + value: 'bludv', + label: 'BluDV', + }, + { + value: 'torrent9', + label: 'Torrent9', + }, + { + value: 'ilcorsaronero', + label: 'iLCorSaRoNeRo', + }, + { + value: 'mejortorrent', + label: 'MejorTorrent', + }, + { + value: 'wolfmax4k', + label: 'Wolfmax4K', + }, + { + value: 'cinecalidad', + label: 'Cinecalidad', + }, + { + value: 'besttorrents', + label: 'BestTorrents', + }, + ]; + + static override getParser(): typeof StreamParser { + return TorrentioParser; + } + + static override get METADATA() { + const supportedServices: ServiceId[] = [ + constants.REALDEBRID_SERVICE, + constants.PREMIUMIZE_SERVICE, + constants.ALLEDEBRID_SERVICE, + constants.TORBOX_SERVICE, + constants.EASYDEBRID_SERVICE, + constants.PUTIO_SERVICE, + constants.DEBRIDLINK_SERVICE, + constants.OFFCLOUD_SERVICE, + ]; + const supportedResources = [ + constants.STREAM_RESOURCE, + constants.CATALOG_RESOURCE, + constants.META_RESOURCE, + ]; + + const options: Option[] = [ + ...baseOptions( + 'Torrentio', + supportedResources, + Env.DEFAULT_TORRENTIO_TIMEOUT + ), + { + id: 'providers', + name: 'Providers', + description: + 'Optionally override the providers that are used. If not specified, then the default providers will be used.', + type: 'multi-select', + required: false, + options: TorrentioPreset.defaultProviders, + default: TorrentioPreset.defaultProviders.map( + (provider) => provider.value + ), + emptyIsUndefined: true, + }, + { + id: 'services', + name: 'Services', + description: + 'Optionally override the services that are used. If not specified, then the services that are enabled and supported will be used.', + type: 'multi-select', + required: false, + options: supportedServices.map((service) => ({ + value: service, + label: constants.SERVICE_DETAILS[service].name, + })), + default: undefined, + emptyIsUndefined: true, + }, + { + id: 'useMultipleInstances', + name: 'Use Multiple Instances', + description: + 'When using multiple services, use a different Torrentio addon for each service, rather than using one instance for all services', + type: 'boolean', + default: false, + required: true, + }, + ]; + + return { + ID: 'torrentio', + NAME: 'Torrentio', + LOGO: `${Env.TORRENTIO_URL}/images/logo_v1.png`, + URL: Env.TORRENTIO_URL, + TIMEOUT: Env.DEFAULT_TORRENTIO_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: Env.DEFAULT_TORRENTIO_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: supportedServices, + REQUIRES_SERVICE: false, + DESCRIPTION: + 'Provides torrent streams from a multitude of providers and has debrid support.', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [ + constants.P2P_STREAM_TYPE, + constants.DEBRID_STREAM_TYPE, + ], + SUPPORTED_RESOURCES: [ + constants.STREAM_RESOURCE, + constants.META_RESOURCE, + constants.CATALOG_RESOURCE, + ], + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + // baseUrl can either be something like https://torrentio.com/ or it can be a custom manifest url. + // if it is a custom manifest url, return a single addon with the custom manifest url. + if (options?.url?.endsWith('/manifest.json')) { + return [this.generateAddon(userData, options, [])]; + } + + const usableServices = this.getUsableServices(userData, options.services); + + // if no services are usable, return a single addon with no services + if (!usableServices || usableServices.length === 0) { + return [this.generateAddon(userData, options, [])]; + } + + // if user has specified useMultipleInstances, return a single addon for each service + if (options?.useMultipleInstances) { + return usableServices.map((service) => + this.generateAddon(userData, options, [service.id]) + ); + } + + // return a single addon with all usable services + return [ + this.generateAddon( + userData, + options, + usableServices.map((service) => service.id) + ), + ]; + } + + private static generateAddon( + userData: UserData, + options: Record, + services: ServiceId[] + ): Addon { + return { + name: options.name || this.METADATA.NAME, + displayIdentifier: services + .map((id) => constants.SERVICE_DETAILS[id].shortName) + .join(' | '), + identifier: + services.length > 0 + ? services.length > 1 + ? 'multi' + : constants.SERVICE_DETAILS[services[0]].shortName + : options.url?.endsWith('/manifest.json') + ? undefined + : 'p2p', + manifestUrl: this.generateManifestUrl(userData, services, options), + enabled: true, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } + + private static generateManifestUrl( + userData: UserData, + services: ServiceId[], + options: Record + ) { + const url = options.url || this.METADATA.URL; + if (url.endsWith('/manifest.json')) { + return url; + } + let providers = options.providers; + + if (!providers) { + providers = TorrentioPreset.defaultProviders.map( + (provider) => provider.value + ); + } + + let config: string[][] = []; + + // add services to config + if (services.length) { + // generate a [serviceId, credential] array for each service and push it to config + config = services.map((service) => [ + service, + this.getServiceCredential(service, userData, { + [constants.PUTIO_SERVICE]: (credentials: any) => + `${credentials.clientId}@${credentials.token}`, + }), + ]); + } + + // add providers to config + config.push(['providers', providers.join(',')]); + + const configString = this.urlEncodeKeyValuePairs(config); + return `${url}${configString ? '/' + configString : ''}/manifest.json`; + } +} diff --git a/packages/core/src/presets/torrentsDb.ts b/packages/core/src/presets/torrentsDb.ts new file mode 100644 index 0000000000000000000000000000000000000000..ad8eabd832c6873b29f08062ecc144d39c56ace9 --- /dev/null +++ b/packages/core/src/presets/torrentsDb.ts @@ -0,0 +1,323 @@ +import { Addon, Option, UserData, Resource, Stream, ParsedStream } from '../db'; +import { Preset, baseOptions } from './preset'; +import { Env, SERVICE_DETAILS } from '../utils'; +import { constants, ServiceId } from '../utils'; +import { StreamParser } from '../parser'; + +export class TorrentsDbParser extends StreamParser { + override getFolder(stream: Stream): string | undefined { + const description = stream.description || stream.title; + if (!description) { + return undefined; + } + const folderName = description.split('\n')[0]; + return folderName; + } +} + +export class TorrentsDbPreset extends Preset { + static defaultProviders = [ + { + value: 'yts', + label: 'YTS', + }, + { + value: '1337x', + label: '1337x', + }, + { + value: 'torrentcsv', + label: 'TorrentCSV', + }, + { + value: '1lou', + label: '1lou', + }, + { + value: 'nyaa', + label: 'Nyaa', + }, + { + value: 'sktorrent', + label: 'Sk-CzTorrent', + }, + { + value: '1tamilblasters', + label: '1TamilBlasters', + }, + { + value: 'limetorrent', + label: 'LimeTorrent', + }, + { + value: '1tamilmv', + label: '1TamilMV', + }, + { + value: 'rargb', + label: 'RARGB', + }, + { + value: 'knaben', + label: 'Knaben', + }, + { + value: 'thepiratebay', + label: 'ThePirateBay', + }, + { + value: 'kickasstorrents', + label: 'KickassTorrents', + }, + { + value: 'animetosho', + label: 'AnimeTosho', + }, + { + value: 'extremlymtorrents', + label: 'ExtremlymTorrents', + }, + { + value: 'yggtorrent', + label: 'YggTorrent', + }, + { + value: 'tokyotosho', + label: 'TokyoTosho', + }, + { + value: 'rutor', + label: '🇷🇺 Rutor', + }, + { + value: 'rutracker', + label: '🇷🇺 Rutracker', + }, + { + value: 'torrent9', + label: '🇫🇷 Torrent9', + }, + { + value: 'ilcorsaronero', + label: '🇮🇹 ilCorSaRoNeRo', + }, + { + value: 'manual', + label: 'Manual', + }, + ]; + + static override getParser(): typeof StreamParser { + return TorrentsDbParser; + } + + static override get METADATA() { + const supportedServices: ServiceId[] = [ + constants.REALDEBRID_SERVICE, + constants.PREMIUMIZE_SERVICE, + constants.ALLEDEBRID_SERVICE, + constants.TORBOX_SERVICE, + constants.EASYDEBRID_SERVICE, + constants.PUTIO_SERVICE, + constants.DEBRIDLINK_SERVICE, + constants.OFFCLOUD_SERVICE, + ]; + const supportedResources = [ + constants.STREAM_RESOURCE, + constants.CATALOG_RESOURCE, + constants.META_RESOURCE, + ]; + + const options: Option[] = [ + ...baseOptions( + 'TorrentsDB', + supportedResources, + Env.DEFAULT_TORRENTS_DB_TIMEOUT + ), + { + id: 'providers', + name: 'Providers', + description: + 'Optionally override the providers that are used. If not specified, then the default providers will be used.', + type: 'multi-select', + required: false, + options: TorrentsDbPreset.defaultProviders, + default: TorrentsDbPreset.defaultProviders.map( + (provider) => provider.value + ), + emptyIsUndefined: true, + }, + { + id: 'services', + name: 'Services', + description: + 'Optionally override the services that are used. If not specified, then the services that are enabled and supported will be used.', + type: 'multi-select', + required: false, + options: supportedServices.map((service) => ({ + value: service, + label: constants.SERVICE_DETAILS[service].name, + })), + default: undefined, + emptyIsUndefined: true, + }, + { + id: 'includeP2P', + name: 'Include P2P', + description: + 'When using a debrid service, choose to also include P2P results. If no services are enabled, then this option will be ignored.', + type: 'boolean', + default: false, + required: false, + }, + { + id: 'useMultipleInstances', + name: 'Use Multiple Instances', + description: + 'When using multiple services, use a different TorrentsDB addon for each service, rather than using one instance for all services', + type: 'boolean', + default: false, + required: true, + }, + ]; + + return { + ID: 'torrents-db', + NAME: 'TorrentsDB', + LOGO: `${Env.TORRENTS_DB_URL}/icon.svg`, + URL: Env.TORRENTS_DB_URL, + TIMEOUT: Env.DEFAULT_TORRENTS_DB_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: Env.DEFAULT_TORRENTS_DB_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: supportedServices, + REQUIRES_SERVICE: false, + DESCRIPTION: 'Provides torrent streams from scraped torrent providers.', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [ + constants.P2P_STREAM_TYPE, + constants.DEBRID_STREAM_TYPE, + ], + SUPPORTED_RESOURCES: [constants.STREAM_RESOURCE], + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + if (options?.url?.endsWith('/manifest.json')) { + return [this.generateAddon(userData, options, [])]; + } + + const usableServices = this.getUsableServices(userData, options.services); + + // if no services are usable, return a single addon with no services + if (!usableServices || usableServices.length === 0) { + return [this.generateAddon(userData, options, [])]; + } + + let addons: Addon[] = []; + // if user has specified useMultipleInstances, return a single addon for each service + if (options?.useMultipleInstances) { + addons = usableServices.map((service) => + this.generateAddon(userData, options, [service.id]) + ); + } else { + addons = [ + this.generateAddon( + userData, + options, + usableServices.map((service) => service.id) + ), + ]; + } + if (options?.includeP2P) { + addons.push(this.generateAddon(userData, options, [])); + } + // return a single addon with all usable services + return addons; + } + + private static generateAddon( + userData: UserData, + options: Record, + services: ServiceId[] + ): Addon { + return { + name: options.name || this.METADATA.NAME, + displayIdentifier: services + .map((id) => constants.SERVICE_DETAILS[id].shortName) + .join(' | '), + identifier: + services.length > 0 + ? services.length > 1 + ? 'multi' + : constants.SERVICE_DETAILS[services[0]].shortName + : options.url?.endsWith('/manifest.json') + ? undefined + : 'p2p', + manifestUrl: this.generateManifestUrl(userData, services, options), + enabled: true, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } + + private static generateManifestUrl( + userData: UserData, + services: ServiceId[], + options: Record + ) { + const url = options.url || this.METADATA.URL; + if (url.endsWith('/manifest.json')) { + return url; + } + let providers = options.providers; + + if (!providers) { + providers = TorrentsDbPreset.defaultProviders.map( + (provider) => provider.value + ); + } + + if (providers.length === TorrentsDbPreset.defaultProviders.length) { + providers = undefined; + } + + const configString = this.base64EncodeJSON({ + providers, + premiumize: services.includes(constants.PREMIUMIZE_SERVICE) + ? this.getServiceCredential(constants.PREMIUMIZE_SERVICE, userData) + : undefined, + realdebrid: services.includes(constants.REALDEBRID_SERVICE) + ? this.getServiceCredential(constants.REALDEBRID_SERVICE, userData) + : undefined, + torbox: services.includes(constants.TORBOX_SERVICE) + ? this.getServiceCredential(constants.TORBOX_SERVICE, userData) + : undefined, + putio: services.includes(constants.PUTIO_SERVICE) + ? this.getServiceCredential(constants.PUTIO_SERVICE, userData, { + [constants.PUTIO_SERVICE]: (credentials: any) => + `${credentials.clientId}@${credentials.token}`, + }) + : undefined, + debridlink: services.includes(constants.DEBRIDLINK_SERVICE) + ? this.getServiceCredential(constants.DEBRIDLINK_SERVICE, userData) + : undefined, + offcloud: services.includes(constants.OFFCLOUD_SERVICE) + ? this.getServiceCredential(constants.OFFCLOUD_SERVICE, userData) + : undefined, + alldebrid: services.includes(constants.ALLEDEBRID_SERVICE) + ? this.getServiceCredential(constants.ALLEDEBRID_SERVICE, userData) + : undefined, + easydebrid: services.includes(constants.EASYDEBRID_SERVICE) + ? this.getServiceCredential(constants.EASYDEBRID_SERVICE, userData) + : undefined, + }); + return `${url}${configString ? '/' + configString : ''}/manifest.json`; + } +} diff --git a/packages/core/src/presets/usaTv.ts b/packages/core/src/presets/usaTv.ts new file mode 100644 index 0000000000000000000000000000000000000000..f21900bcb070f510a00347def1cb85a22590633c --- /dev/null +++ b/packages/core/src/presets/usaTv.ts @@ -0,0 +1,106 @@ +import { + Addon, + Option, + ParsedStream, + ParsedFile, + Stream, + UserData, +} from '../db'; +import { Preset, baseOptions } from './preset'; +import { constants, Env, LIVE_STREAM_TYPE } from '../utils'; +import { FileParser, StreamParser } from '../parser'; + +class USATvStreamParser extends StreamParser { + protected override getParsedFile( + stream: Stream, + parsedStream: ParsedStream + ): ParsedFile | undefined { + const parsed = stream.name ? FileParser.parse(stream.name) : undefined; + if (!parsed) { + return undefined; + } + return { + ...parsed, + title: undefined, + }; + } + protected override getFilename( + stream: Stream, + currentParsedStream: ParsedStream + ): string | undefined { + return undefined; + } + + protected override getMessage( + stream: Stream, + currentParsedStream: ParsedStream + ): string | undefined { + return `${stream.name} - ${stream.description}`; + } +} + +export class USATVPreset extends Preset { + static override getParser(): typeof StreamParser { + return USATvStreamParser; + } + + static override get METADATA() { + const supportedResources = [ + constants.CATALOG_RESOURCE, + constants.META_RESOURCE, + constants.STREAM_RESOURCE, + ]; + + const options: Option[] = [ + ...baseOptions('USA TV', supportedResources, Env.DEFAULT_USA_TV_TIMEOUT), + ]; + + return { + ID: 'usa-tv', + NAME: 'USA TV', + LOGO: `${Env.USA_TV_URL}/public/logo.png`, + URL: Env.USA_TV_URL, + TIMEOUT: Env.DEFAULT_USA_TV_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: Env.DEFAULT_USA_TV_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: [], + DESCRIPTION: + 'Provides access to channels across various categories for USA', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [LIVE_STREAM_TYPE], + SUPPORTED_RESOURCES: supportedResources, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + return [this.generateAddon(userData, options)]; + } + + private static generateAddon( + userData: UserData, + options: Record + ): Addon { + const baseUrl = options.url + ? new URL(options.url).origin + : this.METADATA.URL; + + const url = options.url?.endsWith('/manifest.json') + ? options.url + : `${baseUrl}/manifest.json`; + return { + name: options.name || this.METADATA.NAME, + manifestUrl: url, + enabled: true, + library: false, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } +} diff --git a/packages/core/src/presets/webstreamr.ts b/packages/core/src/presets/webstreamr.ts new file mode 100644 index 0000000000000000000000000000000000000000..60e59a5ba67cb1d0ef6e5b1205a17f80316bee92 --- /dev/null +++ b/packages/core/src/presets/webstreamr.ts @@ -0,0 +1,195 @@ +import { + Addon, + Option, + UserData, + Resource, + Stream, + ParsedStream, + PresetMinimalMetadata, + PresetMetadata, +} from '../db'; +import { Preset, baseOptions } from './preset'; +import { Env, SERVICE_DETAILS } from '../utils'; +import { constants, ServiceId } from '../utils'; +import { FileParser, StreamParser } from '../parser'; + +class WebStreamrStreamParser extends StreamParser { + protected get indexerEmojis(): string[] { + return ['🔗']; + } + + protected override getStreamType( + stream: Stream, + service: ParsedStream['service'], + currentParsedStream: ParsedStream + ): ParsedStream['type'] { + const type = super.getStreamType(stream, service, currentParsedStream); + if (type === 'live') { + return 'http'; + } + return type; + } + + protected override getMessage( + stream: Stream, + currentParsedStream: ParsedStream + ): string | undefined { + const messageRegex = this.getRegexForTextAfterEmojis(['🐢']); + + let messages = [stream.description?.match(messageRegex)?.[1]]; + if (stream.name?.includes('external')) { + messages.push('External'); + } + return messages.join(' | '); + } + + protected override getFilename( + stream: Stream, + currentParsedStream: ParsedStream + ): string | undefined { + let filename = undefined; + const resolution = stream.name?.match(/\d+p?/i)?.[0]; + if (stream.description?.split('\n')?.[0]?.includes('📂')) { + filename = stream.description + ?.split('\n')?.[0] + ?.replace('📂', '') + ?.trim(); + } + + const str = `${filename ? filename + ' ' : ''}${resolution ? resolution : ''}`; + return str ? str : undefined; + } +} + +export class WebStreamrPreset extends Preset { + static override getParser(): typeof StreamParser { + return WebStreamrStreamParser; + } + + static override get METADATA(): PresetMetadata { + const supportedResources = [constants.STREAM_RESOURCE]; + + const providers = [ + { + label: '🇺🇸 English (Soaper, VidSrc)', + value: 'en', + }, + { + label: '🇩🇪 German (KinoGer, MeineCloud, StreamKiste)', + value: 'de', + }, + { + label: '🇪🇸 Castilian Spanish (CineHDPlus, Cuevana, VerHdLink)', + value: 'es', + }, + { + label: '🇫🇷 French (Frembed, FrenchCloud)', + value: 'fr', + }, + { + label: '🇮🇹 Italian (Eurostreaming, MostraGuarda)', + value: 'it', + }, + { + label: '🇲🇽 Latin American Spanish (CineHDPlus, Cuevana, VerHdLink)', + value: 'mx', + }, + ]; + const options: Option[] = [ + ...baseOptions( + 'WebStreamr', + supportedResources, + Env.DEFAULT_WEBSTREAMR_TIMEOUT + ), + { + id: 'providers', + name: 'Providers', + description: 'Select the providers to use', + type: 'multi-select', + options: providers, + default: ['en'], + }, + { + id: 'includeExternalUrls', + name: 'Include External URLs', + description: 'Include external URLs in results', + type: 'boolean', + default: false, + }, + { + id: 'socials', + name: '', + description: '', + type: 'socials', + socials: [ + { id: 'github', url: 'https://github.com/webstreamr/webstreamr' }, + ], + }, + ]; + + return { + ID: 'webstreamr', + NAME: 'WebStreamr', + URL: Env.WEBSTREAMR_URL, + TIMEOUT: Env.DEFAULT_WEBSTREAMR_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: Env.DEFAULT_WEBSTREAMR_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: [], + DESCRIPTION: 'Provides HTTP URLs from streaming websites.', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [constants.HTTP_STREAM_TYPE], + SUPPORTED_RESOURCES: supportedResources, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + return [this.generateAddon(userData, options)]; + } + + private static generateAddon( + userData: UserData, + options: Record + ): Addon { + return { + name: options.name || this.METADATA.NAME, + manifestUrl: this.generateManifestUrl(userData, options), + enabled: true, + streamPassthrough: false, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } + + private static generateManifestUrl( + userData: UserData, + options: Record + ) { + let url = options.url || this.METADATA.URL; + if (url.endsWith('/manifest.json')) { + return url; + } + + url = url.replace(/\/$/, ''); + + const checkedOptions = [ + ...(options.providers || []), + options.includeExternalUrls ?? undefined, // ensure its removed if false + ].filter(Boolean); + + const config = this.urlEncodeJSON({ + ...checkedOptions.reduce((acc, option) => { + acc[option] = 'on'; + return acc; + }, {}), + }); + + return `${url}${config ? '/' + config : ''}/manifest.json`; + } +} diff --git a/packages/core/src/proxy/base.ts b/packages/core/src/proxy/base.ts new file mode 100644 index 0000000000000000000000000000000000000000..715d905b9049fcd9671665ff33b3ed5d5b0da9ce --- /dev/null +++ b/packages/core/src/proxy/base.ts @@ -0,0 +1,155 @@ +import { StreamProxyConfig } from '../db'; +import { Cache, createLogger, maskSensitiveInfo, Env } from '../utils'; + +const logger = createLogger('proxy'); +const cache = Cache.getInstance('publicIp'); + +export interface ProxyStream { + url: string; + filename?: string; + headers?: { + request?: Record; + response?: Record; + }; +} + +type ValidatedStreamProxyConfig = StreamProxyConfig & { + id: 'mediaflow' | 'stremthru'; + url: string; + credentials: string; +}; + +export abstract class BaseProxy { + protected readonly config: ValidatedStreamProxyConfig; + private readonly PRIVATE_CIDR = + /^(10\.|127\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/; + + constructor(config: StreamProxyConfig) { + if (!config.id || !config.credentials || !config.url) { + throw new Error('Proxy configuration is missing'); + } + + this.config = { + enabled: config.enabled ?? false, + id: config.id, + url: config.url, + credentials: config.credentials, + publicIp: config.publicIp, + proxiedAddons: config.proxiedAddons, + proxiedServices: config.proxiedServices, + }; + } + + public getConfig(): StreamProxyConfig { + return this.config; + } + + protected abstract generateProxyUrl(endpoint: string): URL; + protected abstract getPublicIpEndpoint(): string; + protected abstract getPublicIpFromResponse(data: any): string | null; + protected abstract generateStreamUrls( + streams: ProxyStream[] + ): Promise; + + public async getPublicIp(): Promise { + if (!this.config.url) { + logger.error('Proxy URL is missing'); + throw new Error('Proxy URL is missing'); + } + + if (this.config.publicIp) { + return this.config.publicIp; + } + + const proxyUrl = new URL(this.config.url.replace(/\/$/, '')); + if (this.PRIVATE_CIDR.test(proxyUrl.hostname)) { + logger.error('Proxy URL is a private IP address, returning null'); + return null; + } + + const cacheKey = `${this.config.id}:${this.config.url}:${this.config.credentials}`; + const cachedPublicIp = cache ? cache.get(cacheKey) : null; + if (cachedPublicIp) { + logger.debug('Returning cached public IP'); + return cachedPublicIp; + } + + const ipUrl = this.generateProxyUrl(this.getPublicIpEndpoint()); + + if (Env.LOG_SENSITIVE_INFO) { + logger.debug(`GET ${ipUrl.toString()}`); + } else { + logger.debug( + `GET ${ipUrl.protocol}://${maskSensitiveInfo(ipUrl.hostname)}${ipUrl.port ? `:${ipUrl.port}` : ''}${ipUrl.pathname}` + ); + } + + const response = await fetch(ipUrl.toString(), { + method: 'GET', + headers: this.getHeaders(), + signal: AbortSignal.timeout(30000), // 30 second timeout + }); + + if (!response.ok) { + throw new Error(`${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + const publicIp = this.getPublicIpFromResponse(data); + + if (publicIp && cache) { + cache.set(cacheKey, publicIp, Env.PROXY_IP_CACHE_TTL); + } else { + logger.error( + `Proxy did not respond with a public IP. Response: ${JSON.stringify(data)}` + ); + throw new Error('Proxy did not respond with a public IP'); + } + + return publicIp; + } + + protected abstract getHeaders(): Record; + + public async generateUrls(streams: ProxyStream[]): Promise { + if (!streams.length) { + return []; + } + + if (!this.config) { + throw new Error('Proxy configuration is missing'); + } + + try { + let urls = await this.generateStreamUrls(streams); + if ( + urls && + (Env.FORCE_PUBLIC_PROXY_HOST !== undefined || + Env.FORCE_PUBLIC_PROXY_PORT !== undefined || + Env.FORCE_PUBLIC_PROXY_PROTOCOL !== undefined) + ) { + urls = urls.map((url) => { + // modify the URL according to settings, needed when using a local URL for requests but a public stream URL is needed. + const urlObj = new URL(url); + + if (Env.FORCE_PUBLIC_PROXY_PROTOCOL !== undefined) { + urlObj.protocol = Env.FORCE_PUBLIC_PROXY_PROTOCOL; + } + if (Env.FORCE_PUBLIC_PROXY_PORT !== undefined) { + urlObj.port = Env.FORCE_PUBLIC_PROXY_PORT.toString(); + } + if (Env.FORCE_PUBLIC_PROXY_HOST !== undefined) { + urlObj.hostname = Env.FORCE_PUBLIC_PROXY_HOST; + } + return urlObj.toString(); + }); + } + return urls; + } catch (error) { + logger.error( + `Failed to generate proxy URLs: ${error instanceof Error ? error.message : String(error)}` + ); + return null; + } + } +} diff --git a/packages/core/src/proxy/index.ts b/packages/core/src/proxy/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..919b67419699cc4e7b128ddb6d8dbcabf03110cf --- /dev/null +++ b/packages/core/src/proxy/index.ts @@ -0,0 +1,20 @@ +export * from './base'; +export * from './mediaflow'; +export * from './stremthru'; + +import { constants } from '../utils'; +import { BaseProxy } from './base'; +import { MediaFlowProxy } from './mediaflow'; +import { StremThruProxy } from './stremthru'; +import { StreamProxyConfig } from '../db'; + +export function createProxy(config: StreamProxyConfig): BaseProxy { + switch (config.id) { + case constants.MEDIAFLOW_SERVICE: + return new MediaFlowProxy(config); + case constants.STREMTHRU_SERVICE: + return new StremThruProxy(config); + default: + throw new Error(`Unknown proxy type: ${config.id}`); + } +} diff --git a/packages/core/src/proxy/mediaflow.ts b/packages/core/src/proxy/mediaflow.ts new file mode 100644 index 0000000000000000000000000000000000000000..149dbb44818eca9f0d7f101c0a71ff2a06a7662d --- /dev/null +++ b/packages/core/src/proxy/mediaflow.ts @@ -0,0 +1,93 @@ +import { BaseProxy, ProxyStream } from './base'; +import { createLogger, maskSensitiveInfo, Env } from '../utils'; +import path from 'path'; + +const logger = createLogger('mediaflow'); + +export class MediaFlowProxy extends BaseProxy { + protected generateProxyUrl(endpoint: string): URL { + const proxyUrl = new URL(this.config.url.replace(/\/$/, '')); + proxyUrl.pathname = `${proxyUrl.pathname === '/' ? '' : proxyUrl.pathname}${endpoint}`; + if (endpoint === '/proxy/ip') { + proxyUrl.searchParams.set('api_password', this.config.credentials); + } + return proxyUrl; + } + + protected getPublicIpEndpoint(): string { + return '/proxy/ip'; + } + + protected getPublicIpFromResponse(data: any): string | null { + return data.ip || null; + } + + protected getHeaders(): Record { + return { + 'Content-Type': 'application/json', + }; + } + + protected async generateStreamUrls( + streams: ProxyStream[] + ): Promise { + const proxyUrl = this.generateProxyUrl('/generate_urls'); + + const data = { + mediaflow_proxy_url: this.config.url.replace(/\/$/, ''), + api_password: Env.ENCRYPT_MEDIAFLOW_URLS + ? this.config.credentials + : undefined, + urls: streams.map((stream) => ({ + endpoint: '/proxy/stream', + filename: stream.filename || path.basename(stream.url), + query_params: Env.ENCRYPT_MEDIAFLOW_URLS + ? undefined + : { + api_password: this.config.credentials, + }, + destination_url: stream.url, + request_headers: stream.headers?.request, + response_headers: stream.headers?.response, + })), + }; + + if (Env.LOG_SENSITIVE_INFO) { + logger.debug(`POST ${proxyUrl.toString()}`); + } else { + logger.debug( + `POST ${proxyUrl.protocol}://${maskSensitiveInfo(proxyUrl.hostname)}${proxyUrl.port ? `:${proxyUrl.port}` : ''}/generate_urls` + ); + } + + const response = await fetch(proxyUrl.toString(), { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify(data), + signal: AbortSignal.timeout(30000), + }); + + if (!response.ok) { + throw new Error(`${response.status}: ${response.statusText}`); + } + + let responseData: any; + try { + responseData = await response.json(); + } catch (error) { + const text = await response.text(); + logger.debug(`Response body: ${text}`); + throw new Error('Failed to parse JSON response from MediaFlow'); + } + + if (responseData.error) { + throw new Error(responseData.error); + } + + if (responseData.urls) { + return responseData.urls; + } else { + throw new Error('No URLs were returned from MediaFlow'); + } + } +} diff --git a/packages/core/src/proxy/stremthru.ts b/packages/core/src/proxy/stremthru.ts new file mode 100644 index 0000000000000000000000000000000000000000..a66c3c5726c9bab5d4a2edab403a8a83784e68f4 --- /dev/null +++ b/packages/core/src/proxy/stremthru.ts @@ -0,0 +1,98 @@ +import { BaseProxy, ProxyStream } from './base'; +import { createLogger, maskSensitiveInfo, Env } from '../utils'; + +const logger = createLogger('stremthru'); + +export class StremThruProxy extends BaseProxy { + protected generateProxyUrl(endpoint: string): URL { + const proxyUrl = new URL(this.config.url.replace(/\/$/, '')); + proxyUrl.pathname = `${proxyUrl.pathname === '/' ? '' : proxyUrl.pathname}${endpoint}`; + return proxyUrl; + } + + protected getPublicIpEndpoint(): string { + return '/v0/health/__debug__'; + } + + protected getPublicIpFromResponse(data: any): string | null { + return typeof data.data?.ip?.exposed === 'object' + ? data.data.ip.exposed['*'] || data.data.ip.machine + : data.data?.ip?.machine || null; + } + + protected getHeaders(): Record { + const headers: Record = { + 'Content-Type': 'application/x-www-form-urlencoded', + }; + + if (Env.ENCRYPT_STREMTHRU_URLS) { + headers['X-StremThru-Authorization'] = `Basic ${this.config.credentials}`; + } + + return headers; + } + + protected async generateStreamUrls( + streams: ProxyStream[] + ): Promise { + const proxyUrl = this.generateProxyUrl('/v0/proxy'); + + if (!Env.ENCRYPT_STREMTHRU_URLS) { + proxyUrl.searchParams.set('token', this.config.credentials); + } + + const data = new URLSearchParams(); + + streams.forEach((stream, i) => { + data.append('url', stream.url); + let req_headers = ''; + if (stream.headers?.request) { + for (const [key, value] of Object.entries(stream.headers.request)) { + req_headers += `${key}: ${value}\n`; + } + } + data.append(`req_headers[${i}]`, req_headers); + if (stream.filename) { + data.append(`filename[${i}]`, stream.filename); + } + }); + + if (Env.LOG_SENSITIVE_INFO) { + logger.debug(`POST ${proxyUrl.toString()}`); + } else { + logger.debug( + `POST ${proxyUrl.protocol}://${maskSensitiveInfo(proxyUrl.hostname)}${proxyUrl.port ? `:${proxyUrl.port}` : ''}/v0/proxy` + ); + } + + const response = await fetch(proxyUrl.toString(), { + method: 'POST', + headers: this.getHeaders(), + body: data, + signal: AbortSignal.timeout(30000), + }); + + if (!response.ok) { + throw new Error(`${response.status}: ${response.statusText}`); + } + + let responseData: any; + try { + responseData = await response.json(); + } catch (error) { + const text = await response.text(); + logger.debug(`Response body: ${text}`); + throw new Error('Failed to parse JSON response from StremThru'); + } + + if (responseData.error) { + throw new Error(responseData.error); + } + + if (responseData.data?.items) { + return responseData.data.items; + } else { + throw new Error('No URLs were returned from StremThru'); + } + } +} diff --git a/packages/core/src/streams/deduplicator.ts b/packages/core/src/streams/deduplicator.ts new file mode 100644 index 0000000000000000000000000000000000000000..1f679045a098fc7456a5fc5ffd1f7195653f7d83 --- /dev/null +++ b/packages/core/src/streams/deduplicator.ts @@ -0,0 +1,310 @@ +import { ParsedStream, UserData } from '../db/schemas'; +import { + createLogger, + getTimeTakenSincePoint, + DSU, + getSimpleTextHash, +} from '../utils'; + +const logger = createLogger('deduplicator'); + +class StreamDeduplicator { + private userData: UserData; + + constructor(userData: UserData) { + this.userData = userData; + } + + public async deduplicate(streams: ParsedStream[]): Promise { + let deduplicator = this.userData.deduplicator; + if (!deduplicator || !deduplicator.enabled) { + return streams; + } + const start = Date.now(); + + const deduplicationKeys = deduplicator.keys || ['filename', 'infoHash']; + + deduplicator = { + enabled: true, + keys: deduplicationKeys, + cached: deduplicator.cached || 'per_addon', + uncached: deduplicator.uncached || 'per_addon', + p2p: deduplicator.p2p || 'per_addon', + http: deduplicator.http || 'disabled', + live: deduplicator.live || 'disabled', + youtube: deduplicator.youtube || 'disabled', + external: deduplicator.external || 'disabled', + }; + + // Group streams by their deduplication keys + // const streamGroups = new Map(); + const dsu = new DSU(); + const keyToStreamIds = new Map(); + + for (const stream of streams) { + // Create a unique key based on the selected deduplication methods + dsu.makeSet(stream.id); + const currentStreamKeyStrings: string[] = []; + + if (deduplicationKeys.includes('filename') && stream.filename) { + let normalisedFilename = stream.filename + .replace( + /(mkv|mp4|avi|mov|wmv|flv|webm|m4v|mpg|mpeg|3gp|3g2|m2ts|ts|vob|ogv|ogm|divx|xvid|rm|rmvb|asf|mxf|mka|mks|mk3d|webm|f4v|f4p|f4a|f4b)$/i, + '' + ) + .replace(/[^\p{L}\p{N}+]/gu, '') + .replace(/\s+/g, '') + .toLowerCase(); + currentStreamKeyStrings.push(`filename:${normalisedFilename}`); + } + + if (deduplicationKeys.includes('infoHash') && stream.torrent?.infoHash) { + currentStreamKeyStrings.push(`infoHash:${stream.torrent.infoHash}`); + } + + if (deduplicationKeys.includes('smartDetect')) { + // generate a hash using many different attributes + // round size to nearest 100MB for some margin of error + const roundedSize = stream.size + ? Math.round(stream.size / 100000000) * 100000000 + : undefined; + const hash = getSimpleTextHash( + `${roundedSize}${stream.parsedFile?.resolution}${stream.parsedFile?.quality}${stream.parsedFile?.visualTags}${stream.parsedFile?.audioTags}${stream.parsedFile?.languages}${stream.parsedFile?.encode}` + ); + currentStreamKeyStrings.push(`smartDetect:${hash}`); + } + + if (currentStreamKeyStrings.length > 0) { + for (const key of currentStreamKeyStrings) { + if (!keyToStreamIds.has(key)) { + keyToStreamIds.set(key, []); + } + keyToStreamIds.get(key)!.push(stream.id); + } + } + } + + // Perform union operations based on shared keys + for (const streamIdsSharingCommonKey of keyToStreamIds.values()) { + if (streamIdsSharingCommonKey.length > 1) { + const firstStreamId = streamIdsSharingCommonKey[0]; + for (let i = 1; i < streamIdsSharingCommonKey.length; i++) { + dsu.union(firstStreamId, streamIdsSharingCommonKey[i]); + } + } + } + // Group actual stream objects by their DSU representative ID + const idToStreamMap = new Map(streams.map((s) => [s.id, s])); // For quick lookup + const finalDuplicateGroupsMap = new Map(); // Maps representative ID to stream objects + + for (const stream of streams) { + const representativeId = dsu.find(stream.id); + if (!finalDuplicateGroupsMap.has(representativeId)) { + finalDuplicateGroupsMap.set(representativeId, []); + } + finalDuplicateGroupsMap.get(representativeId)!.push(stream); + } + + const processedStreams = new Set(); + + for (const group of finalDuplicateGroupsMap.values()) { + // Group streams by type + const streamsByType = new Map(); + for (const stream of group) { + let type = stream.type as string; + if ((type === 'debrid' || type === 'usenet') && stream.service) { + type = stream.service.cached ? 'cached' : 'uncached'; + } + const typeGroup = streamsByType.get(type) || []; + typeGroup.push(stream); + streamsByType.set(type, typeGroup); + } + + // Process each type according to its deduplication mode + for (const [type, typeStreams] of streamsByType.entries()) { + const mode = deduplicator[type as keyof typeof deduplicator] as string; + if (mode === 'disabled') { + typeStreams.forEach((stream) => processedStreams.add(stream)); + continue; + } + + switch (mode) { + case 'single_result': { + // Keep one result with highest priority service and addon + let selectedStream = typeStreams.sort((a, b) => { + // so a specific type may either have both streams not have a service, or both streams have a service + // if both streams have a service, then we can simpl + let aProviderIndex = + this.userData.services + ?.filter((service) => service.enabled) + .findIndex((service) => service.id === a.service?.id) ?? 0; + let bProviderIndex = + this.userData.services + ?.filter((service) => service.enabled) + .findIndex((service) => service.id === b.service?.id) ?? 0; + aProviderIndex = + aProviderIndex === -1 ? Infinity : aProviderIndex; + bProviderIndex = + bProviderIndex === -1 ? Infinity : bProviderIndex; + if (aProviderIndex !== bProviderIndex) { + return aProviderIndex - bProviderIndex; + } + + // look at seeders for p2p and uncached streams + if ( + (type === 'p2p' || type === 'uncached') && + a.torrent?.seeders && + b.torrent?.seeders + ) { + return (b.torrent.seeders || 0) - (a.torrent.seeders || 0); + } + + // now look at the addon index + + const aAddonIndex = this.userData.presets.findIndex( + (preset) => preset.instanceId === a.addon.presetInstanceId + ); + const bAddonIndex = this.userData.presets.findIndex( + (preset) => preset.instanceId === b.addon.presetInstanceId + ); + + // the addon index MUST exist, its not possible for it to not exist + if (aAddonIndex !== bAddonIndex) { + return aAddonIndex - bAddonIndex; + } + + // now look at stream type + let aTypeIndex = + this.userData.preferredStreamTypes?.findIndex( + (type) => type === a.type + ) ?? 0; + let bTypeIndex = + this.userData.preferredStreamTypes?.findIndex( + (type) => type === b.type + ) ?? 0; + + aTypeIndex = aTypeIndex === -1 ? Infinity : aTypeIndex; + bTypeIndex = bTypeIndex === -1 ? Infinity : bTypeIndex; + + if (aTypeIndex !== bTypeIndex) { + return aTypeIndex - bTypeIndex; + } + + return 0; + })[0]; + processedStreams.add(selectedStream); + break; + } + case 'per_service': { + // Keep one result from each service (highest priority available addon for that service) + // first, ensure that all streams have a service, otherwise we can't use this mode + if (typeStreams.some((stream) => !stream.service)) { + throw new Error( + 'per_service mode requires all streams to have a service' + ); + } + let perServiceStreams = Object.values( + typeStreams.reduce( + (acc, stream) => { + acc[stream.service!.id] = acc[stream.service!.id] || []; + acc[stream.service!.id].push(stream); + return acc; + }, + {} as Record + ) + ).map((serviceStreams) => { + return serviceStreams.sort((a, b) => { + let aAddonIndex = this.userData.presets.findIndex( + (preset) => preset.instanceId === a.addon.presetInstanceId + ); + let bAddonIndex = this.userData.presets.findIndex( + (preset) => preset.instanceId === b.addon.presetInstanceId + ); + aAddonIndex = aAddonIndex === -1 ? Infinity : aAddonIndex; + bAddonIndex = bAddonIndex === -1 ? Infinity : bAddonIndex; + if (aAddonIndex !== bAddonIndex) { + return aAddonIndex - bAddonIndex; + } + + // now look at stream type + let aTypeIndex = + this.userData.preferredStreamTypes?.findIndex( + (type) => type === a.type + ) ?? 0; + let bTypeIndex = + this.userData.preferredStreamTypes?.findIndex( + (type) => type === b.type + ) ?? 0; + aTypeIndex = aTypeIndex === -1 ? Infinity : aTypeIndex; + bTypeIndex = bTypeIndex === -1 ? Infinity : bTypeIndex; + if (aTypeIndex !== bTypeIndex) { + return aTypeIndex - bTypeIndex; + } + + // look at seeders for p2p and uncached streams + if (type === 'p2p' || type === 'uncached') { + return (b.torrent?.seeders || 0) - (a.torrent?.seeders || 0); + } + return 0; + })[0]; + }); + for (const stream of perServiceStreams) { + processedStreams.add(stream); + } + break; + } + case 'per_addon': { + if (typeStreams.some((stream) => !stream.addon)) { + throw new Error( + 'per_addon mode requires all streams to have an addon' + ); + } + let perAddonStreams = Object.values( + typeStreams.reduce( + (acc, stream) => { + acc[stream.addon.presetInstanceId] = + acc[stream.addon.presetInstanceId] || []; + acc[stream.addon.presetInstanceId].push(stream); + return acc; + }, + {} as Record + ) + ).map((addonStreams) => { + return addonStreams.sort((a, b) => { + let aServiceIndex = + this.userData.services + ?.filter((service) => service.enabled) + .findIndex((service) => service.id === a.service?.id) ?? 0; + let bServiceIndex = + this.userData.services + ?.filter((service) => service.enabled) + .findIndex((service) => service.id === b.service?.id) ?? 0; + aServiceIndex = aServiceIndex === -1 ? Infinity : aServiceIndex; + bServiceIndex = bServiceIndex === -1 ? Infinity : bServiceIndex; + if (aServiceIndex !== bServiceIndex) { + return aServiceIndex - bServiceIndex; + } + if (type === 'p2p' || type === 'uncached') { + return (b.torrent?.seeders || 0) - (a.torrent?.seeders || 0); + } + return 0; + })[0]; + }); + for (const stream of perAddonStreams) { + processedStreams.add(stream); + } + break; + } + } + } + } + + let deduplicatedStreams = Array.from(processedStreams); + logger.info( + `Filtered out ${streams.length - deduplicatedStreams.length} duplicate streams to ${deduplicatedStreams.length} streams in ${getTimeTakenSincePoint(start)}` + ); + return deduplicatedStreams; + } +} + +export default StreamDeduplicator; diff --git a/packages/core/src/streams/fetcher.ts b/packages/core/src/streams/fetcher.ts new file mode 100644 index 0000000000000000000000000000000000000000..785463f20b923281691df57be97114e134f560cd --- /dev/null +++ b/packages/core/src/streams/fetcher.ts @@ -0,0 +1,214 @@ +import { + Addon, + ParsedStream, + StrictManifestResource, + UserData, +} from '../db/schemas'; +import { constants, createLogger, getTimeTakenSincePoint } from '../utils'; +import { Wrapper } from '../wrapper'; +import { GroupConditionEvaluator } from '../parser/streamExpression'; +import { getAddonName } from '../utils/general'; +import StreamFilter from './filterer'; +import StreamPrecompute from './precomputer'; + +const logger = createLogger('fetcher'); + +class StreamFetcher { + private userData: UserData; + private filter: StreamFilter; + private precompute: StreamPrecompute; + + constructor(userData: UserData) { + this.userData = userData; + this.filter = new StreamFilter(userData); + this.precompute = new StreamPrecompute(userData); + } + + public async fetch( + addons: Addon[], + type: string, + id: string + ): Promise<{ + streams: ParsedStream[]; + errors: { + title: string; + description: string; + }[]; + }> { + const allErrors: { + title: string; + description: string; + }[] = []; + let allStreams: ParsedStream[] = []; + const start = Date.now(); + let totalTimeTaken = 0; + let previousGroupStreams: ParsedStream[] = []; + let previousGroupTimeTaken = 0; + + // Helper function to fetch streams from an addon and log summary + const fetchFromAddon = async (addon: Addon) => { + let summaryMsg = ''; + const start = Date.now(); + try { + const streams = await new Wrapper(addon).getStreams(type, id); + const errorStreams = streams.filter( + (s) => s.type === constants.ERROR_STREAM_TYPE + ); + const addonErrors = errorStreams.map((s) => ({ + title: `[❌] ${s.error?.title || getAddonName(addon)}`, + description: s.error?.description || 'Unknown error', + })); + + if (errorStreams.length > 0) { + logger.error( + `Found ${errorStreams.length} error streams from ${getAddonName(addon)}`, + { + errorStreams: errorStreams.map((s) => s.error?.title), + } + ); + } + + summaryMsg = ` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ${errorStreams.length > 0 ? '🟠' : '🟢'} [${getAddonName(addon)}] Scrape Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ✔ Status : ${errorStreams.length > 0 ? 'PARTIAL SUCCESS' : 'SUCCESS'} + 📦 Streams : ${streams.length} +${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => ` • ${s.error?.title || 'Unknown error'}: ${s.error?.description || 'No description'}`).join('\n')}` : ''} + 📋 Details : ${ + errorStreams.length > 0 + ? `Found errors:\n${errorStreams.map((s) => ` • ${s.error?.title || 'Unknown error'}: ${s.error?.description || 'No description'}`).join('\n')}` + : 'Successfully fetched streams.' + } + ⏱️ Time : ${getTimeTakenSincePoint(start)} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`; + return { + success: true as const, + streams: streams.filter( + (s) => s.type !== constants.ERROR_STREAM_TYPE + ), + errors: addonErrors, + timeTaken: Date.now() - start, + }; + } catch (error) { + const errMsg = error instanceof Error ? error.message : String(error); + const addonErrors = { + title: `[❌] ${getAddonName(addon)}`, + description: errMsg, + }; + summaryMsg = ` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 🔴 [${getAddonName(addon)}] Scrape Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ✖ Status : FAILED + 🚫 Error : ${errMsg} + ⏱️ Time : ${getTimeTakenSincePoint(start)} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`; + return { + success: false as const, + errors: [addonErrors], + timeTaken: 0, + streams: [], + }; + } finally { + logger.info(summaryMsg); + } + }; + + // Helper function to fetch from a group of addons and track time + const fetchFromGroup = async (addons: Addon[]) => { + const groupStart = Date.now(); + const results = await Promise.all(addons.map(fetchFromAddon)); + + const groupStreams = results.flatMap((r) => r.streams); + const groupErrors = results.flatMap((r) => r.errors); + allErrors.push(...groupErrors); + + const filteredStreams = await this.filter.filter(groupStreams, type, id); + await this.precompute.precompute(filteredStreams); + + const groupTime = Date.now() - groupStart; + logger.info(`Filtered to ${filteredStreams.length} streams`); + return { + totalTime: groupTime, + streams: filteredStreams, + }; + }; + + // If groups are configured, handle group-based fetching + if (this.userData.groups && this.userData.groups.length > 0) { + // Always fetch from first group + const firstGroupAddons = addons.filter( + (addon) => + addon.presetInstanceId && + this.userData.groups![0].addons.includes(addon.presetInstanceId) + ); + + logger.info( + `Fetching streams from first group with ${firstGroupAddons.length} addons` + ); + + // Fetch streams from first group + const firstGroupResult = await fetchFromGroup(firstGroupAddons); + allStreams.push(...firstGroupResult.streams); + totalTimeTaken = firstGroupResult.totalTime; + previousGroupStreams = firstGroupResult.streams; + previousGroupTimeTaken = firstGroupResult.totalTime; + + // For each subsequent group, evaluate condition and fetch if true + for (let i = 1; i < this.userData.groups.length; i++) { + const group = this.userData.groups[i]; + + // Skip if no condition or addons + if (!group.condition || !group.addons.length) continue; + + try { + const evaluator = new GroupConditionEvaluator( + previousGroupStreams, + allStreams, + previousGroupTimeTaken, + totalTimeTaken, + type + ); + const shouldFetch = await evaluator.evaluate(group.condition); + if (shouldFetch) { + logger.info(`Condition met for group ${i + 1}, fetching streams`); + + const groupAddons = addons.filter( + (addon) => + addon.presetInstanceId && + group.addons.includes(addon.presetInstanceId) + ); + + const groupResult = await fetchFromGroup(groupAddons); + allStreams.push(...groupResult.streams); + totalTimeTaken += groupResult.totalTime; + previousGroupStreams = groupResult.streams; + previousGroupTimeTaken = groupResult.totalTime; + } else { + logger.info( + `Condition not met for group ${i + 1}, skipping remaining groups` + ); + // if we meet a group whose condition is not met, we do not need to fetch from any subsequent groups + break; + } + } catch (error) { + logger.error(`Error evaluating condition for group ${i}:`, error); + continue; + } + } + } else { + // If no groups configured, fetch from all addons in parallel + const result = await fetchFromGroup(addons); + allStreams.push(...result.streams); + totalTimeTaken = result.totalTime; + } + + logger.info( + `Fetched ${allStreams.length} streams from ${addons.length} addons in ${getTimeTakenSincePoint(start)}` + ); + return { streams: allStreams, errors: allErrors }; + } +} + +export default StreamFetcher; diff --git a/packages/core/src/streams/filterer.ts b/packages/core/src/streams/filterer.ts new file mode 100644 index 0000000000000000000000000000000000000000..1db18f030609a0843bd2d419af052ea266c7f708 --- /dev/null +++ b/packages/core/src/streams/filterer.ts @@ -0,0 +1,1161 @@ +import { ParsedStream, UserData } from '../db/schemas'; +import { + createLogger, + FeatureControl, + Metadata, + getTimeTakenSincePoint, + TMDBMetadata, + constants, +} from '../utils'; +import { TYPES } from '../utils/constants'; +import { compileRegex } from '../utils/regex'; +import { formRegexFromKeywords } from '../utils/regex'; +import { safeRegexTest } from '../utils/regex'; +import { StreamType } from '../utils/constants'; +import { StreamSelector } from '../parser/streamExpression'; + +const logger = createLogger('filterer'); + +class StreamFilterer { + private userData: UserData; + + constructor(userData: UserData) { + this.userData = userData; + } + + public async filter( + streams: ParsedStream[], + type: string, + id: string + ): Promise { + interface SkipReason { + total: number; + details: Record; + } + const isAnime = id.startsWith('kitsu'); + const skipReasons: Record = { + titleMatching: { total: 0, details: {} }, + seasonEpisodeMatching: { total: 0, details: {} }, + excludedStreamType: { total: 0, details: {} }, + requiredStreamType: { total: 0, details: {} }, + excludedResolution: { total: 0, details: {} }, + requiredResolution: { total: 0, details: {} }, + excludedQuality: { total: 0, details: {} }, + requiredQuality: { total: 0, details: {} }, + excludedEncode: { total: 0, details: {} }, + requiredEncode: { total: 0, details: {} }, + excludedVisualTag: { total: 0, details: {} }, + requiredVisualTag: { total: 0, details: {} }, + excludedAudioTag: { total: 0, details: {} }, + requiredAudioTag: { total: 0, details: {} }, + excludedAudioChannel: { total: 0, details: {} }, + requiredAudioChannel: { total: 0, details: {} }, + excludedLanguage: { total: 0, details: {} }, + requiredLanguage: { total: 0, details: {} }, + excludedCached: { total: 0, details: {} }, + requiredCached: { total: 0, details: {} }, + excludedUncached: { total: 0, details: {} }, + requiredUncached: { total: 0, details: {} }, + excludedRegex: { total: 0, details: {} }, + requiredRegex: { total: 0, details: {} }, + excludedKeywords: { total: 0, details: {} }, + requiredKeywords: { total: 0, details: {} }, + requiredSeeders: { total: 0, details: {} }, + excludedSeeders: { total: 0, details: {} }, + excludedFilterCondition: { total: 0, details: {} }, + requiredFilterCondition: { total: 0, details: {} }, + size: { total: 0, details: {} }, + }; + + const includedReasons: Record = { + resolution: { total: 0, details: {} }, + quality: { total: 0, details: {} }, + encode: { total: 0, details: {} }, + visualTag: { total: 0, details: {} }, + audioTag: { total: 0, details: {} }, + audioChannel: { total: 0, details: {} }, + language: { total: 0, details: {} }, + streamType: { total: 0, details: {} }, + size: { total: 0, details: {} }, + seeder: { total: 0, details: {} }, + regex: { total: 0, details: {} }, + keywords: { total: 0, details: {} }, + }; + + const start = Date.now(); + const isRegexAllowed = FeatureControl.isRegexAllowed(this.userData); + + let requestedMetadata: Metadata | undefined; + if (this.userData.titleMatching?.enabled && TYPES.includes(type as any)) { + try { + requestedMetadata = await new TMDBMetadata( + this.userData.tmdbAccessToken + ).getMetadata(id, type as any); + logger.info(`Fetched metadata for ${id}`, requestedMetadata); + } catch (error) { + logger.error(`Error fetching titles for ${id}: ${error}`); + } + } + + const normaliseTitle = (title: string) => { + return title + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^\p{L}\p{N}+]/gu, '') + .toLowerCase(); + }; + + const performTitleMatch = (stream: ParsedStream) => { + // const titleMatchingOptions = this.userData.titleMatching; + const titleMatchingOptions = { + mode: 'exact', + ...(this.userData.titleMatching ?? {}), + }; + if (!titleMatchingOptions || !titleMatchingOptions.enabled) { + return true; + } + if (!requestedMetadata || requestedMetadata.titles.length === 0) { + return true; + } + + const streamTitle = stream.parsedFile?.title; + const streamYear = stream.parsedFile?.year; + // now check if we need to check this stream based on the addon and request type + if ( + titleMatchingOptions.requestTypes?.length && + (!titleMatchingOptions.requestTypes.includes(type) || + (isAnime && !titleMatchingOptions.requestTypes.includes('anime'))) + ) { + return true; + } + + if ( + titleMatchingOptions.addons?.length && + !titleMatchingOptions.addons.includes(stream.addon.presetInstanceId) + ) { + return true; + } + + if ( + !streamTitle || + (titleMatchingOptions.matchYear && !streamYear && type === 'movie') + ) { + // only filter out movies without a year as series results usually don't include a year + return false; + } + const yearMatch = + titleMatchingOptions.matchYear && streamYear + ? requestedMetadata?.year === streamYear + : true; + + if (titleMatchingOptions.mode === 'exact') { + return ( + requestedMetadata?.titles.some( + (title) => normaliseTitle(title) === normaliseTitle(streamTitle) + ) && yearMatch + ); + } else { + return ( + requestedMetadata?.titles.some((title) => + normaliseTitle(streamTitle).includes(normaliseTitle(title)) + ) && yearMatch + ); + } + }; + + const performSeasonEpisodeMatch = (stream: ParsedStream) => { + const seasonEpisodeMatchingOptions = this.userData.seasonEpisodeMatching; + if ( + !seasonEpisodeMatchingOptions || + !seasonEpisodeMatchingOptions.enabled + ) { + return true; + } + + // parse the id to get the season and episode + const seasonEpisodeRegex = /:(\d+):(\d+)$/; + const match = id.match(seasonEpisodeRegex); + + if (!match || !match[1] || !match[2]) { + // only if both season and episode are present, we can filter + return true; + } + + const requestedSeason = parseInt(match[1]); + const requestedEpisode = parseInt(match[2]); + + if ( + seasonEpisodeMatchingOptions.requestTypes?.length && + (!seasonEpisodeMatchingOptions.requestTypes.includes(type) || + (isAnime && + !seasonEpisodeMatchingOptions.requestTypes.includes('anime'))) + ) { + return true; + } + + if ( + seasonEpisodeMatchingOptions.addons?.length && + !seasonEpisodeMatchingOptions.addons.includes( + stream.addon.presetInstanceId + ) + ) { + return true; + } + + // is requested season present + if ( + requestedSeason && + ((stream.parsedFile?.season && + stream.parsedFile.season !== requestedSeason) || + (stream.parsedFile?.seasons && + !stream.parsedFile.seasons.includes(requestedSeason))) + ) { + return false; + } + + // is requested episode present + if ( + requestedEpisode && + stream.parsedFile?.episode && + stream.parsedFile.episode !== requestedEpisode + ) { + return false; + } + + return true; + }; + + const excludedRegexPatterns = + isRegexAllowed && + this.userData.excludedRegexPatterns && + this.userData.excludedRegexPatterns.length > 0 + ? await Promise.all( + this.userData.excludedRegexPatterns.map( + async (pattern) => await compileRegex(pattern) + ) + ) + : undefined; + + const requiredRegexPatterns = + isRegexAllowed && + this.userData.requiredRegexPatterns && + this.userData.requiredRegexPatterns.length > 0 + ? await Promise.all( + this.userData.requiredRegexPatterns.map( + async (pattern) => await compileRegex(pattern) + ) + ) + : undefined; + + const includedRegexPatterns = + isRegexAllowed && + this.userData.includedRegexPatterns && + this.userData.includedRegexPatterns.length > 0 + ? await Promise.all( + this.userData.includedRegexPatterns.map( + async (pattern) => await compileRegex(pattern) + ) + ) + : undefined; + + const excludedKeywordsPattern = + this.userData.excludedKeywords && + this.userData.excludedKeywords.length > 0 + ? await formRegexFromKeywords(this.userData.excludedKeywords) + : undefined; + + const requiredKeywordsPattern = + this.userData.requiredKeywords && + this.userData.requiredKeywords.length > 0 + ? await formRegexFromKeywords(this.userData.requiredKeywords) + : undefined; + + const includedKeywordsPattern = + this.userData.includedKeywords && + this.userData.includedKeywords.length > 0 + ? await formRegexFromKeywords(this.userData.includedKeywords) + : undefined; + + // test many regexes against many attributes and return true if at least one regex matches any attribute + // and false if no regex matches any attribute + const testRegexes = async (stream: ParsedStream, patterns: RegExp[]) => { + const file = stream.parsedFile; + const stringsToTest = [ + stream.filename, + file?.releaseGroup, + stream.indexer, + stream.folderName, + ].filter((v) => v !== undefined); + + for (const string of stringsToTest) { + for (const pattern of patterns) { + if (await safeRegexTest(pattern, string)) { + return true; + } + } + } + return false; + }; + + const filterBasedOnCacheStatus = ( + stream: ParsedStream, + mode: 'and' | 'or', + addonIds: string[] | undefined, + serviceIds: string[] | undefined, + streamTypes: StreamType[] | undefined, + cached: boolean + ) => { + const isAddonFilteredOut = + addonIds && + addonIds.length > 0 && + addonIds.some((addonId) => stream.addon.presetInstanceId === addonId) && + stream.service?.cached === cached; + const isServiceFilteredOut = + serviceIds && + serviceIds.length > 0 && + serviceIds.some((serviceId) => stream.service?.id === serviceId) && + stream.service?.cached === cached; + const isStreamTypeFilteredOut = + streamTypes && + streamTypes.length > 0 && + streamTypes.includes(stream.type) && + stream.service?.cached === cached; + + if (mode === 'and') { + return !( + isAddonFilteredOut && + isServiceFilteredOut && + isStreamTypeFilteredOut + ); + } else { + return !( + isAddonFilteredOut || + isServiceFilteredOut || + isStreamTypeFilteredOut + ); + } + }; + + const normaliseRange = ( + range: [number, number] | undefined, + defaults: { min: number; max: number } + ): [number | undefined, number | undefined] | undefined => { + if (!range) return undefined; + const [min, max] = range; + const normMin = min === defaults.min ? undefined : min; + const normMax = max === defaults.max ? undefined : max; + return normMin === undefined && normMax === undefined + ? undefined + : [normMin, normMax]; + }; + + const normaliseSeederRange = ( + seederRange: [number, number] | undefined + ) => { + return normaliseRange(seederRange, { + min: constants.MIN_SEEDERS, + max: constants.MAX_SEEDERS, + }); + }; + + const normaliseSizeRange = (sizeRange: [number, number] | undefined) => { + return normaliseRange(sizeRange, { + min: constants.MIN_SIZE, + max: constants.MAX_SIZE, + }); + }; + + const getStreamType = ( + stream: ParsedStream + ): 'p2p' | 'cached' | 'uncached' | undefined => { + switch (stream.type) { + case 'debrid': + return stream.service?.cached ? 'cached' : 'uncached'; + case 'usenet': + return stream.service?.cached ? 'cached' : 'uncached'; + case 'p2p': + return 'p2p'; + default: + return undefined; + } + }; + + const shouldKeepStream = async (stream: ParsedStream): Promise => { + const file = stream.parsedFile; + + // carry out include checks first + if (this.userData.includedStreamTypes?.includes(stream.type)) { + includedReasons.streamType.total++; + includedReasons.streamType.details[stream.type] = + (includedReasons.streamType.details[stream.type] || 0) + 1; + return true; + } + + if ( + this.userData.includedResolutions?.includes( + file?.resolution || ('Unknown' as any) + ) + ) { + const resolution = this.userData.includedResolutions.find( + (resolution) => (file?.resolution || 'Unknown') === resolution + ); + if (resolution) { + includedReasons.resolution.total++; + includedReasons.resolution.details[resolution] = + (includedReasons.resolution.details[resolution] || 0) + 1; + } + return true; + } + + if ( + this.userData.includedQualities?.includes( + file?.quality || ('Unknown' as any) + ) + ) { + const quality = this.userData.includedQualities.find( + (quality) => (file?.quality || 'Unknown') === quality + ); + if (quality) { + includedReasons.quality.total++; + includedReasons.quality.details[quality] = + (includedReasons.quality.details[quality] || 0) + 1; + } + return true; + } + + if ( + this.userData.includedVisualTags?.some((tag) => + (file?.visualTags.length ? file.visualTags : ['Unknown']).includes( + tag + ) + ) + ) { + const tag = this.userData.includedVisualTags.find((tag) => + (file?.visualTags.length ? file.visualTags : ['Unknown']).includes( + tag + ) + ); + if (tag) { + includedReasons.visualTag.total++; + includedReasons.visualTag.details[tag] = + (includedReasons.visualTag.details[tag] || 0) + 1; + } + return true; + } + + if ( + this.userData.includedAudioTags?.some((tag) => + (file?.audioTags.length ? file.audioTags : ['Unknown']).includes(tag) + ) + ) { + const tag = this.userData.includedAudioTags.find((tag) => + (file?.audioTags.length ? file.audioTags : ['Unknown']).includes(tag) + ); + if (tag) { + includedReasons.audioTag.total++; + includedReasons.audioTag.details[tag] = + (includedReasons.audioTag.details[tag] || 0) + 1; + } + return true; + } + + if ( + this.userData.includedAudioChannels?.some((channel) => + (file?.audioChannels.length + ? file.audioChannels + : ['Unknown'] + ).includes(channel) + ) + ) { + const channel = this.userData.includedAudioChannels.find((channel) => + (file?.audioChannels.length + ? file.audioChannels + : ['Unknown'] + ).includes(channel) + ); + includedReasons.audioChannel.total++; + includedReasons.audioChannel.details[channel!] = + (includedReasons.audioChannel.details[channel!] || 0) + 1; + return true; + } + + if ( + this.userData.includedLanguages?.some((lang) => + (file?.languages.length ? file.languages : ['Unknown']).includes(lang) + ) + ) { + const lang = this.userData.includedLanguages.find((lang) => + (file?.languages.length ? file.languages : ['Unknown']).includes(lang) + ); + includedReasons.language.total++; + includedReasons.language.details[lang!] = + (includedReasons.language.details[lang!] || 0) + 1; + return true; + } + + if ( + this.userData.includedEncodes?.some( + (encode) => (file?.encode || 'Unknown') === encode + ) + ) { + const encode = this.userData.includedEncodes.find( + (encode) => (file?.encode || 'Unknown') === encode + ); + if (encode) { + includedReasons.encode.total++; + includedReasons.encode.details[encode] = + (includedReasons.encode.details[encode] || 0) + 1; + } + return true; + } + + if ( + includedRegexPatterns && + (await testRegexes(stream, includedRegexPatterns)) + ) { + includedReasons.regex.total++; + includedReasons.regex.details[includedRegexPatterns[0].source] = + (includedReasons.regex.details[includedRegexPatterns[0].source] || + 0) + 1; + return true; + } + + if ( + includedKeywordsPattern && + (await testRegexes(stream, [includedKeywordsPattern])) + ) { + includedReasons.keywords.total++; + includedReasons.keywords.details[includedKeywordsPattern.source] = + (includedReasons.keywords.details[includedKeywordsPattern.source] || + 0) + 1; + return true; + } + + const includedSeederRange = normaliseSeederRange( + this.userData.includeSeederRange + ); + const excludedSeederRange = normaliseSeederRange( + this.userData.excludeSeederRange + ); + const requiredSeederRange = normaliseSeederRange( + this.userData.requiredSeederRange + ); + + const typeForSeederRange = getStreamType(stream); + + if ( + includedSeederRange && + (!this.userData.seederRangeTypes || + (typeForSeederRange && + this.userData.seederRangeTypes.includes(typeForSeederRange))) + ) { + if ( + includedSeederRange[0] && + (stream.torrent?.seeders ?? 0) > includedSeederRange[0] + ) { + includedReasons.seeder.total++; + includedReasons.seeder.details[includedSeederRange[0]] = + (includedReasons.seeder.details[includedSeederRange[0]] || 0) + 1; + return true; + } + if ( + includedSeederRange[1] && + (stream.torrent?.seeders ?? 0) < includedSeederRange[1] + ) { + includedReasons.seeder.total++; + includedReasons.seeder.details[includedSeederRange[1]] = + (includedReasons.seeder.details[includedSeederRange[1]] || 0) + 1; + return true; + } + } + + if (this.userData.excludedStreamTypes?.includes(stream.type)) { + // Track stream type exclusions + skipReasons.excludedStreamType.total++; + skipReasons.excludedStreamType.details[stream.type] = + (skipReasons.excludedStreamType.details[stream.type] || 0) + 1; + return false; + } + + // Track required stream type misses + if ( + this.userData.requiredStreamTypes && + this.userData.requiredStreamTypes.length > 0 && + !this.userData.requiredStreamTypes.includes(stream.type) + ) { + skipReasons.requiredStreamType.total++; + skipReasons.requiredStreamType.details[stream.type] = + (skipReasons.requiredStreamType.details[stream.type] || 0) + 1; + return false; + } + + // Resolutions + if ( + this.userData.excludedResolutions?.includes( + (file?.resolution || 'Unknown') as any + ) + ) { + skipReasons.excludedResolution.total++; + skipReasons.excludedResolution.details[file?.resolution || 'Unknown'] = + (skipReasons.excludedResolution.details[ + file?.resolution || 'Unknown' + ] || 0) + 1; + return false; + } + + if ( + this.userData.requiredResolutions && + this.userData.requiredResolutions.length > 0 && + !this.userData.requiredResolutions.includes( + (file?.resolution || 'Unknown') as any + ) + ) { + skipReasons.requiredResolution.total++; + skipReasons.requiredResolution.details[file?.resolution || 'Unknown'] = + (skipReasons.requiredResolution.details[ + file?.resolution || 'Unknown' + ] || 0) + 1; + return false; + } + + // Qualities + if ( + this.userData.excludedQualities?.includes( + (file?.quality || 'Unknown') as any + ) + ) { + skipReasons.excludedQuality.total++; + skipReasons.excludedQuality.details[file?.quality || 'Unknown'] = + (skipReasons.excludedQuality.details[file?.quality || 'Unknown'] || + 0) + 1; + return false; + } + + if ( + this.userData.requiredQualities && + this.userData.requiredQualities.length > 0 && + !this.userData.requiredQualities.includes( + (file?.quality || 'Unknown') as any + ) + ) { + skipReasons.requiredQuality.total++; + skipReasons.requiredQuality.details[file?.quality || 'Unknown'] = + (skipReasons.requiredQuality.details[file?.quality || 'Unknown'] || + 0) + 1; + return false; + } + + // encode + if ( + this.userData.excludedEncodes?.includes( + file?.encode || ('Unknown' as any) + ) + ) { + skipReasons.excludedEncode.total++; + skipReasons.excludedEncode.details[file?.encode || 'Unknown'] = + (skipReasons.excludedEncode.details[file?.encode || 'Unknown'] || 0) + + 1; + return false; + } + + if ( + this.userData.requiredEncodes && + this.userData.requiredEncodes.length > 0 && + !this.userData.requiredEncodes.includes( + file?.encode || ('Unknown' as any) + ) + ) { + skipReasons.requiredEncode.total++; + skipReasons.requiredEncode.details[file?.encode || 'Unknown'] = + (skipReasons.requiredEncode.details[file?.encode || 'Unknown'] || 0) + + 1; + return false; + } + + // temporarily add HDR+DV to visual tags list if both HDR and DV are present + // to allow HDR+DV option in userData to work + if ( + file?.visualTags?.some((tag) => tag.startsWith('HDR')) && + file?.visualTags?.some((tag) => tag.startsWith('DV')) + ) { + const hdrIndex = file?.visualTags?.findIndex((tag) => + tag.startsWith('HDR') + ); + const dvIndex = file?.visualTags?.findIndex((tag) => + tag.startsWith('DV') + ); + const insertIndex = Math.min(hdrIndex, dvIndex); + file?.visualTags?.splice(insertIndex, 0, 'HDR+DV'); + } + + if ( + this.userData.excludedVisualTags?.some((tag) => + (file?.visualTags.length ? file.visualTags : ['Unknown']).includes( + tag + ) + ) + ) { + const tag = this.userData.excludedVisualTags.find((tag) => + (file?.visualTags.length ? file.visualTags : ['Unknown']).includes( + tag + ) + ); + skipReasons.excludedVisualTag.total++; + skipReasons.excludedVisualTag.details[tag!] = + (skipReasons.excludedVisualTag.details[tag!] || 0) + 1; + return false; + } + + if ( + this.userData.requiredVisualTags && + this.userData.requiredVisualTags.length > 0 && + !this.userData.requiredVisualTags.some((tag) => + (file?.visualTags.length ? file.visualTags : ['Unknown']).includes( + tag + ) + ) + ) { + const tag = this.userData.requiredVisualTags.find((tag) => + (file?.visualTags.length ? file.visualTags : ['Unknown']).includes( + tag + ) + ); + skipReasons.requiredVisualTag.total++; + skipReasons.requiredVisualTag.details[tag!] = + (skipReasons.requiredVisualTag.details[tag!] || 0) + 1; + return false; + } + + if ( + this.userData.excludedAudioTags?.some((tag) => + (file?.audioTags.length ? file.audioTags : ['Unknown']).includes(tag) + ) + ) { + const tag = this.userData.excludedAudioTags.find((tag) => + (file?.audioTags.length ? file.audioTags : ['Unknown']).includes(tag) + ); + skipReasons.excludedAudioTag.total++; + skipReasons.excludedAudioTag.details[tag!] = + (skipReasons.excludedAudioTag.details[tag!] || 0) + 1; + return false; + } + + if ( + this.userData.requiredAudioTags && + this.userData.requiredAudioTags.length > 0 && + !this.userData.requiredAudioTags.some((tag) => + (file?.audioTags.length ? file.audioTags : ['Unknown']).includes(tag) + ) + ) { + const tag = this.userData.requiredAudioTags.find((tag) => + (file?.audioTags.length ? file.audioTags : ['Unknown']).includes(tag) + ); + skipReasons.requiredAudioTag.total++; + skipReasons.requiredAudioTag.details[tag!] = + (skipReasons.requiredAudioTag.details[tag!] || 0) + 1; + return false; + } + + if ( + this.userData.excludedAudioChannels?.some((channel) => + (file?.audioChannels.length + ? file.audioChannels + : ['Unknown'] + ).includes(channel) + ) + ) { + const channel = this.userData.excludedAudioChannels.find((channel) => + (file?.audioChannels.length + ? file.audioChannels + : ['Unknown'] + ).includes(channel) + ); + skipReasons.excludedAudioChannel.total++; + skipReasons.excludedAudioChannel.details[channel!] = + (skipReasons.excludedAudioChannel.details[channel!] || 0) + 1; + return false; + } + + if ( + this.userData.requiredAudioChannels && + this.userData.requiredAudioChannels.length > 0 && + !this.userData.requiredAudioChannels.some((channel) => + (file?.audioChannels.length + ? file.audioChannels + : ['Unknown'] + ).includes(channel) + ) + ) { + const channel = this.userData.requiredAudioChannels.find((channel) => + (file?.audioChannels.length + ? file.audioChannels + : ['Unknown'] + ).includes(channel) + ); + skipReasons.requiredAudioChannel.total++; + skipReasons.requiredAudioChannel.details[channel!] = + (skipReasons.requiredAudioChannel.details[channel!] || 0) + 1; + return false; + } + + // languages + if ( + this.userData.excludedLanguages?.length && + (file?.languages.length ? file.languages : ['Unknown']).every((lang) => + this.userData.excludedLanguages!.includes(lang as any) + ) + ) { + const lang = file?.languages[0] || 'Unknown'; + skipReasons.excludedLanguage.total++; + skipReasons.excludedLanguage.details[lang] = + (skipReasons.excludedLanguage.details[lang] || 0) + 1; + return false; + } + + if ( + this.userData.requiredLanguages && + this.userData.requiredLanguages.length > 0 && + !this.userData.requiredLanguages.some((lang) => + (file?.languages.length ? file.languages : ['Unknown']).includes(lang) + ) + ) { + const lang = this.userData.requiredLanguages.find((lang) => + (file?.languages.length ? file.languages : ['Unknown']).includes(lang) + ); + skipReasons.requiredLanguage.total++; + skipReasons.requiredLanguage.details[lang!] = + (skipReasons.requiredLanguage.details[lang!] || 0) + 1; + return false; + } + + // uncached + + if (this.userData.excludeUncached && stream.service?.cached === false) { + skipReasons.excludedUncached.total++; + return false; + } + + if (this.userData.excludeCached && stream.service?.cached === true) { + skipReasons.excludedCached.total++; + return false; + } + + if ( + filterBasedOnCacheStatus( + stream, + this.userData.excludeCachedMode || 'or', + this.userData.excludeCachedFromAddons, + this.userData.excludeCachedFromServices, + this.userData.excludeCachedFromStreamTypes, + true + ) === false + ) { + skipReasons.excludedCached.total++; + return false; + } + + if ( + filterBasedOnCacheStatus( + stream, + this.userData.excludeUncachedMode || 'or', + this.userData.excludeUncachedFromAddons, + this.userData.excludeUncachedFromServices, + this.userData.excludeUncachedFromStreamTypes, + false + ) === false + ) { + skipReasons.excludedUncached.total++; + return false; + } + + if ( + excludedRegexPatterns && + (await testRegexes(stream, excludedRegexPatterns)) + ) { + skipReasons.excludedRegex.total++; + return false; + } + if ( + requiredRegexPatterns && + requiredRegexPatterns.length > 0 && + !(await testRegexes(stream, requiredRegexPatterns)) + ) { + skipReasons.requiredRegex.total++; + return false; + } + + if ( + excludedKeywordsPattern && + (await testRegexes(stream, [excludedKeywordsPattern])) + ) { + skipReasons.excludedKeywords.total++; + return false; + } + + if ( + requiredKeywordsPattern && + !(await testRegexes(stream, [requiredKeywordsPattern])) + ) { + skipReasons.requiredKeywords.total++; + return false; + } + + if ( + requiredSeederRange && + (!this.userData.seederRangeTypes || + (typeForSeederRange && + this.userData.seederRangeTypes.includes(typeForSeederRange))) + ) { + if ( + requiredSeederRange[0] && + (stream.torrent?.seeders ?? 0) < requiredSeederRange[0] + ) { + return false; + } + if ( + stream.torrent?.seeders !== undefined && + requiredSeederRange[1] && + (stream.torrent?.seeders ?? 0) > requiredSeederRange[1] + ) { + return false; + } + } + + if ( + excludedSeederRange && + (!this.userData.seederRangeTypes || + (typeForSeederRange && + this.userData.seederRangeTypes.includes(typeForSeederRange))) + ) { + if ( + excludedSeederRange[0] && + (stream.torrent?.seeders ?? 0) > excludedSeederRange[0] + ) { + return false; + } + if ( + excludedSeederRange[1] && + (stream.torrent?.seeders ?? 0) < excludedSeederRange[1] + ) { + return false; + } + } + + if (!performTitleMatch(stream)) { + skipReasons.titleMatching.total++; + skipReasons.titleMatching.details[ + `${stream.parsedFile?.title || 'Unknown Title'}${type === 'movie' ? ` - (${stream.parsedFile?.year || 'Unknown Year'})` : ''}` + ] = + (skipReasons.titleMatching.details[ + `${stream.parsedFile?.title || 'Unknown Title'}${type === 'movie' ? ` - (${stream.parsedFile?.year || 'Unknown Year'})` : ''}` + ] || 0) + 1; + return false; + } + + if (!performSeasonEpisodeMatch(stream)) { + const detail = + stream.parsedFile?.title + + ' ' + + (stream.parsedFile?.seasonEpisode?.join(' x ') || 'Unknown'); + + skipReasons.seasonEpisodeMatching.total++; + skipReasons.seasonEpisodeMatching.details[detail] = + (skipReasons.seasonEpisodeMatching.details[detail] || 0) + 1; + return false; + } + + const global = this.userData.size?.global; + const resolution = stream.parsedFile?.resolution + ? this.userData.size?.resolution?.[ + stream.parsedFile + .resolution as keyof typeof this.userData.size.resolution + ] + : undefined; + + let minMax: [number | undefined, number | undefined] | undefined; + if (type === 'movie') { + minMax = + normaliseSizeRange(resolution?.movies) || + normaliseSizeRange(global?.movies); + } else { + minMax = + normaliseSizeRange(resolution?.series) || + normaliseSizeRange(global?.series); + } + + if (minMax) { + if (stream.size && minMax[0] && stream.size < minMax[0]) { + skipReasons.size.total++; + return false; + } + if (stream.size && minMax[1] && stream.size > minMax[1]) { + skipReasons.size.total++; + return false; + } + } + + return true; + }; + + const filterResults = await Promise.all(streams.map(shouldKeepStream)); + + let filteredStreams = streams.filter((_, index) => filterResults[index]); + + // Log filter summary + const totalFiltered = streams.length - filteredStreams.length; + if (totalFiltered > 0) { + const summary = [ + '\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', + ` 🔍 Filter Summary`, + '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', + ` 📊 Total Streams : ${streams.length}`, + ` ✔️ Kept : ${filteredStreams.length}`, + ` ❌ Filtered : ${totalFiltered}`, + ]; + + // Add filter details if any streams were filtered + const filterDetails: string[] = []; + for (const [reason, stats] of Object.entries(skipReasons)) { + if (stats.total > 0) { + // Convert camelCase to Title Case with spaces + const formattedReason = reason + .replace(/([A-Z])/g, ' $1') + .replace(/^./, (str) => str.toUpperCase()); + + filterDetails.push(`\n 📌 ${formattedReason} (${stats.total})`); + for (const [detail, count] of Object.entries(stats.details)) { + filterDetails.push(` • ${count}× ${detail}`); + } + } + } + + const includedDetails: string[] = []; + for (const [reason, stats] of Object.entries(includedReasons)) { + if (stats.total > 0) { + const formattedReason = reason + .replace(/([A-Z])/g, ' $1') + .replace(/^./, (str) => str.toUpperCase()); + includedDetails.push(`\n 📌 ${formattedReason} (${stats.total})`); + for (const [detail, count] of Object.entries(stats.details)) { + includedDetails.push(` • ${count}× ${detail}`); + } + } + } + + if (filterDetails.length > 0) { + summary.push('\n 🔎 Filter Details:'); + summary.push(...filterDetails); + } + + if (includedDetails.length > 0) { + summary.push('\n 🔎 Included Details:'); + summary.push(...includedDetails); + } + + summary.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + logger.info(summary.join('\n')); + } + + logger.info(`Applied filters in ${getTimeTakenSincePoint(start)}`); + return filteredStreams; + } + + public async applyStreamExpressionFilters( + streams: ParsedStream[] + ): Promise { + const skipReasons: Record< + string, + { total: number; details: Record } + > = { + excludedFilterCondition: { + total: 0, + details: {}, + }, + requiredFilterCondition: { + total: 0, + details: {}, + }, + }; + if ( + this.userData.excludedStreamExpressions && + this.userData.excludedStreamExpressions.length > 0 + ) { + const selector = new StreamSelector(); + const streamsToRemove = new Set(); // Track actual stream objects to be removed + + for (const expression of this.userData.excludedStreamExpressions) { + try { + // Always select from the current filteredStreams (not yet modified by this loop) + const selectedStreams = await selector.select( + streams.filter((stream) => !streamsToRemove.has(stream.id)), + expression + ); + + // Track these stream objects for removal + selectedStreams.forEach((stream) => streamsToRemove.add(stream.id)); + + // Update skip reasons for this condition (only count newly selected streams) + if (selectedStreams.length > 0) { + skipReasons.excludedFilterCondition.total += selectedStreams.length; + skipReasons.excludedFilterCondition.details[expression] = + selectedStreams.length; + } + } catch (error) { + logger.error( + `Failed to apply excluded stream expression "${expression}": ${error instanceof Error ? error.message : String(error)}` + ); + // Continue with the next condition instead of breaking the entire loop + } + } + + logger.verbose( + `Total streams selected by excluded conditions: ${streamsToRemove.size}` + ); + + // Remove all marked streams at once, after processing all conditions + streams = streams.filter((stream) => !streamsToRemove.has(stream.id)); + } + + if ( + this.userData.requiredStreamExpressions && + this.userData.requiredStreamExpressions.length > 0 + ) { + const selector = new StreamSelector(); + const streamsToKeep = new Set(); // Track actual stream objects to be removed + + for (const expression of this.userData.requiredStreamExpressions) { + try { + const selectedStreams = await selector.select( + streams.filter((stream) => !streamsToKeep.has(stream.id)), + expression + ); + + // Track these stream objects for removal + selectedStreams.forEach((stream) => streamsToKeep.add(stream.id)); + + // Update skip reasons for this condition (only count newly selected streams) + if (selectedStreams.length > 0) { + skipReasons.requiredFilterCondition.total += + streams.length - selectedStreams.length; + skipReasons.requiredFilterCondition.details[expression] = + streams.length - selectedStreams.length; + } + } catch (error) { + logger.error( + `Failed to apply required stream expression "${expression}": ${error instanceof Error ? error.message : String(error)}` + ); + // Continue with the next condition instead of breaking the entire loop + } + } + + logger.verbose( + `Total streams selected by required conditions: ${streamsToKeep.size}` + ); + // remove all streams that are not in the streamsToKeep set + streams = streams.filter((stream) => streamsToKeep.has(stream.id)); + } + return streams; + } +} + +export default StreamFilterer; diff --git a/packages/core/src/streams/index.ts b/packages/core/src/streams/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..8b1ba1bb0d0afb6517b0ded37582ae6a972376d7 --- /dev/null +++ b/packages/core/src/streams/index.ts @@ -0,0 +1,15 @@ +import StreamFetcher from './fetcher'; +import StreamFilterer from './filterer'; +import StreamSorter from './sorter'; +import StreamDeduplicator from './deduplicator'; +import StreamPrecomputer from './precomputer'; +import StreamUtils from './utils'; + +export { + StreamFetcher, + StreamFilterer, + StreamSorter, + StreamDeduplicator, + StreamPrecomputer, + StreamUtils, +}; diff --git a/packages/core/src/streams/limiter.ts b/packages/core/src/streams/limiter.ts new file mode 100644 index 0000000000000000000000000000000000000000..30b39fff0ebabfb6c760ecd6b53662c4c1ff255b --- /dev/null +++ b/packages/core/src/streams/limiter.ts @@ -0,0 +1,156 @@ +import { ParsedStream, UserData } from '../db/schemas'; +import { createLogger, getTimeTakenSincePoint } from '../utils'; + +const logger = createLogger('limiter'); + +class StreamLimiter { + private userData: UserData; + + constructor(userData: UserData) { + this.userData = userData; + } + + public async limit(streams: ParsedStream[]): Promise { + if (!this.userData.resultLimits) { + return streams; + } + + // these are our limits + const { + indexer, + releaseGroup, + resolution, + quality, + global, + addon, + streamType, + service, + } = this.userData.resultLimits; + + const start = Date.now(); + + // Track counts for each category + const counts = { + indexer: new Map(), + releaseGroup: new Map(), + resolution: new Map(), + quality: new Map(), + addon: new Map(), + streamType: new Map(), + service: new Map(), + global: 0, + }; + + // Keep track of which indexes to remove + const indexesToRemove = new Set(); + + // Process each stream and check against limits + streams.forEach((stream, index) => { + // Skip if already marked for removal + if (indexesToRemove.has(index)) return; + + // Check global limit first + if (global && counts.global >= global) { + indexesToRemove.add(index); + return; + } + + // Check indexer limit + if (indexer && stream.indexer) { + const count = counts.indexer.get(stream.indexer) || 0; + if (count >= indexer) { + indexesToRemove.add(index); + return; + } + counts.indexer.set(stream.indexer, count + 1); + } + + // Check release group limit + if (releaseGroup && stream.parsedFile?.releaseGroup) { + const count = + counts.releaseGroup.get(stream.parsedFile?.releaseGroup || '') || 0; + if (count >= releaseGroup) { + indexesToRemove.add(index); + return; + } + counts.releaseGroup.set(stream.parsedFile.releaseGroup, count + 1); + } + + // Check resolution limit + if (resolution) { + const count = + counts.resolution.get(stream.parsedFile?.resolution || 'Unknown') || + 0; + if (count >= resolution) { + indexesToRemove.add(index); + return; + } + counts.resolution.set( + stream.parsedFile?.resolution || 'Unknown', + count + 1 + ); + } + + // Check quality limit + if (quality) { + const count = + counts.quality.get(stream.parsedFile?.quality || 'Unknown') || 0; + if (count >= quality) { + indexesToRemove.add(index); + return; + } + counts.quality.set(stream.parsedFile?.quality || 'Unknown', count + 1); + } + + // Check addon limit + if (addon) { + const count = counts.addon.get(stream.addon.presetInstanceId) || 0; + if (count >= addon) { + indexesToRemove.add(index); + return; + } + counts.addon.set(stream.addon.presetInstanceId, count + 1); + } + + // Check stream type limit + if (streamType && stream.type) { + const count = counts.streamType.get(stream.type) || 0; + if (count >= streamType) { + indexesToRemove.add(index); + return; + } + counts.streamType.set(stream.type, count + 1); + } + + // Check service limit + if (service && stream.service?.id) { + const count = counts.service.get(stream.service.id) || 0; + if (count >= service) { + indexesToRemove.add(index); + return; + } + counts.service.set(stream.service.id, count + 1); + } + + // If we got here, increment global count + counts.global++; + }); + + // Filter out the streams that exceeded limits + const limitedStreams = streams.filter( + (_, index) => !indexesToRemove.has(index) + ); + + // Log summary of removed streams + const removedCount = streams.length - limitedStreams.length; + if (removedCount > 0) { + logger.info( + `Removed ${removedCount} streams due to limits in ${getTimeTakenSincePoint(start)}` + ); + } + + return limitedStreams; + } +} + +export default StreamLimiter; diff --git a/packages/core/src/streams/precomputer.ts b/packages/core/src/streams/precomputer.ts new file mode 100644 index 0000000000000000000000000000000000000000..a19ee3d715c69436bae4a565ee43128baab67bde --- /dev/null +++ b/packages/core/src/streams/precomputer.ts @@ -0,0 +1,152 @@ +import { isMatch } from 'super-regex'; +import { ParsedStream, UserData } from '../db/schemas'; +import { createLogger, FeatureControl, getTimeTakenSincePoint } from '../utils'; +import { + formRegexFromKeywords, + compileRegex, + parseRegex, +} from '../utils/regex'; +import { StreamSelector } from '../parser/streamExpression'; + +const logger = createLogger('precomputer'); + +class StreamPrecomputer { + private userData: UserData; + + constructor(userData: UserData) { + this.userData = userData; + } + + public async precompute(streams: ParsedStream[]) { + const preferredRegexPatterns = + FeatureControl.isRegexAllowed(this.userData) && + this.userData.preferredRegexPatterns + ? await Promise.all( + this.userData.preferredRegexPatterns.map(async (pattern) => { + return { + name: pattern.name, + negate: parseRegex(pattern.pattern).flags.includes('n'), + pattern: await compileRegex(pattern.pattern), + }; + }) + ) + : undefined; + const preferredKeywordsPatterns = this.userData.preferredKeywords + ? await formRegexFromKeywords(this.userData.preferredKeywords) + : undefined; + if (!preferredRegexPatterns && !preferredKeywordsPatterns) { + return; + } + const start = Date.now(); + if (preferredKeywordsPatterns) { + streams.forEach((stream) => { + stream.keywordMatched = + isMatch(preferredKeywordsPatterns, stream.filename || '') || + isMatch(preferredKeywordsPatterns, stream.folderName || '') || + isMatch( + preferredKeywordsPatterns, + stream.parsedFile?.releaseGroup || '' + ) || + isMatch(preferredKeywordsPatterns, stream.indexer || ''); + }); + } + const determineMatch = ( + stream: ParsedStream, + regexPattern: { pattern: RegExp; negate: boolean }, + attribute?: string + ) => { + return attribute ? isMatch(regexPattern.pattern, attribute) : false; + }; + if (preferredRegexPatterns) { + streams.forEach((stream) => { + for (let i = 0; i < preferredRegexPatterns.length; i++) { + // if negate, then the pattern must not match any of the attributes + // and if the attribute is undefined, then we can consider that as a non-match so true + const regexPattern = preferredRegexPatterns[i]; + const filenameMatch = determineMatch( + stream, + regexPattern, + stream.filename + ); + const folderNameMatch = determineMatch( + stream, + regexPattern, + stream.folderName + ); + const releaseGroupMatch = determineMatch( + stream, + regexPattern, + stream.parsedFile?.releaseGroup + ); + const indexerMatch = determineMatch( + stream, + regexPattern, + stream.indexer + ); + let match = + filenameMatch || + folderNameMatch || + releaseGroupMatch || + indexerMatch; + match = regexPattern.negate ? !match : match; + if (match) { + stream.regexMatched = { + name: regexPattern.name, + pattern: regexPattern.pattern.source, + index: i, + }; + break; + } + } + }); + } + + if (this.userData.preferredStreamExpressions?.length) { + const selector = new StreamSelector(); + const streamToConditionIndex = new Map(); + + // Go through each preferred filter condition, from highest to lowest priority. + for ( + let i = 0; + i < this.userData.preferredStreamExpressions.length; + i++ + ) { + const expression = this.userData.preferredStreamExpressions[i]; + + // From the streams that haven't been matched to a higher-priority condition yet... + const availableStreams = streams.filter( + (stream) => !streamToConditionIndex.has(stream.id) + ); + + // ...select the ones that match the current condition. + try { + const selectedStreams = await selector.select( + availableStreams, + expression + ); + + // And for each of those, record that this is the best condition they've matched so far. + for (const stream of selectedStreams) { + streamToConditionIndex.set(stream.id, i); + } + } catch (error) { + logger.error( + `Failed to apply preferred stream expression "${expression}": ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + // Now, apply the results to the original streams list. + for (const stream of streams) { + stream.streamExpressionMatched = streamToConditionIndex.get(stream.id); + } + } + logger.info( + `Precomputed preferred filters in ${getTimeTakenSincePoint(start)}` + ); + } +} + +export default StreamPrecomputer; diff --git a/packages/core/src/streams/proxifier.ts b/packages/core/src/streams/proxifier.ts new file mode 100644 index 0000000000000000000000000000000000000000..cd2c73bfe8845ebec61e29bbbe2703081a2b36a7 --- /dev/null +++ b/packages/core/src/streams/proxifier.ts @@ -0,0 +1,89 @@ +import { ParsedStream, UserData } from '../db/schemas'; +import { createLogger } from '../utils'; +import { createProxy } from '../proxy'; + +const logger = createLogger('proxifier'); + +class Proxifier { + private userData: UserData; + + constructor(userData: UserData) { + this.userData = userData; + } + + private shouldProxyStream(stream: ParsedStream): boolean { + const streamService = stream.service ? stream.service.id : 'none'; + const proxy = this.userData.proxy; + if (!stream.url || !proxy?.enabled) { + return false; + } + + const proxyAddon = + !proxy.proxiedAddons?.length || + proxy.proxiedAddons.includes(stream.addon.presetInstanceId); + const proxyService = + !proxy.proxiedServices?.length || + proxy.proxiedServices.includes(streamService); + + if (proxy.enabled && proxyAddon && proxyService) { + return true; + } + + return false; + } + + public async proxify(streams: ParsedStream[]): Promise { + if (!this.userData.proxy?.enabled) { + return streams; + } + + const streamsToProxy = streams + .map((stream, index) => ({ stream, index })) + .filter(({ stream }) => stream.url && this.shouldProxyStream(stream)); + + if (streamsToProxy.length === 0) { + return streams; + } + logger.info(`Proxying ${streamsToProxy.length} streams`); + + const proxy = createProxy(this.userData.proxy); + + const proxiedUrls = streamsToProxy.length + ? await proxy.generateUrls( + streamsToProxy.map(({ stream }) => ({ + url: stream.url!, + filename: stream.filename, + headers: { + request: stream.requestHeaders, + response: stream.responseHeaders, + }, + })) + ) + : []; + + logger.info(`Generated ${(proxiedUrls || []).length} proxied URLs`); + + const removeIndexes = new Set(); + + streamsToProxy.forEach(({ stream, index }, i) => { + const proxiedUrl = proxiedUrls?.[i]; + if (proxiedUrl) { + stream.url = proxiedUrl; + stream.proxied = true; + } else { + removeIndexes.add(index); + } + }); + + if (removeIndexes.size > 0) { + logger.warn( + `Failed to proxy ${removeIndexes.size} streams. Removing them from the list.` + ); + streams = streams.filter((_, index) => !removeIndexes.has(index)); + } + + return streams; + } +} + +export default Proxifier; diff --git a/packages/core/src/streams/sorter.ts b/packages/core/src/streams/sorter.ts new file mode 100644 index 0000000000000000000000000000000000000000..58154f68cb487ce6831e21f052032c99838a7c62 --- /dev/null +++ b/packages/core/src/streams/sorter.ts @@ -0,0 +1,299 @@ +import { ParsedStream, SortCriterion, UserData } from '../db/schemas'; +import { createLogger, getTimeTakenSincePoint } from '../utils'; +import { VISUAL_TAGS } from '../utils/constants'; +import { AUDIO_TAGS } from '../utils/constants'; + +const logger = createLogger('sorter'); + +class StreamSorter { + private userData: UserData; + + constructor(userData: UserData) { + this.userData = userData; + } + + public async sort( + streams: ParsedStream[], + type: string + ): Promise { + let primarySortCriteria = this.userData.sortCriteria.global; + let cachedSortCriteria = this.userData.sortCriteria.cached; + let uncachedSortCriteria = this.userData.sortCriteria.uncached; + + const start = Date.now(); + + if (type === 'movie') { + if (this.userData.sortCriteria?.movies?.length) { + primarySortCriteria = this.userData.sortCriteria?.movies; + } + if (this.userData.sortCriteria?.cachedMovies?.length) { + cachedSortCriteria = this.userData.sortCriteria?.cachedMovies; + } + if (this.userData.sortCriteria?.uncachedMovies?.length) { + uncachedSortCriteria = this.userData.sortCriteria?.uncachedMovies; + } + } + + if (type === 'series') { + if (this.userData.sortCriteria?.series?.length) { + primarySortCriteria = this.userData.sortCriteria?.series; + } + if (this.userData.sortCriteria?.cachedSeries?.length) { + cachedSortCriteria = this.userData.sortCriteria?.cachedSeries; + } + if (this.userData.sortCriteria?.uncachedSeries?.length) { + uncachedSortCriteria = this.userData.sortCriteria?.uncachedSeries; + } + } + + if (type === 'anime') { + if (this.userData.sortCriteria?.anime?.length) { + primarySortCriteria = this.userData.sortCriteria?.anime; + } + if (this.userData.sortCriteria?.cachedAnime?.length) { + cachedSortCriteria = this.userData.sortCriteria?.cachedAnime; + } + if (this.userData.sortCriteria?.uncachedAnime?.length) { + uncachedSortCriteria = this.userData.sortCriteria?.uncachedAnime; + } + } + + let sortedStreams = []; + + if ( + cachedSortCriteria?.length && + uncachedSortCriteria?.length && + primarySortCriteria.length > 0 && + primarySortCriteria[0].key === 'cached' + ) { + logger.info( + 'Splitting streams into cached and uncached and using separate sort criteria' + ); + const cachedStreams = streams.filter( + (stream) => stream.service?.cached || stream.service === undefined // streams without a service can be considered as 'cached' + ); + const uncachedStreams = streams.filter( + (stream) => stream.service?.cached === false + ); + + // sort the 2 lists separately, and put them after the other, depending on the direction of cached + const cachedSorted = cachedStreams.slice().sort((a, b) => { + const aKey = this.dynamicSortKey(a, cachedSortCriteria, type); + const bKey = this.dynamicSortKey(b, cachedSortCriteria, type); + for (let i = 0; i < aKey.length; i++) { + if (aKey[i] < bKey[i]) return -1; + if (aKey[i] > bKey[i]) return 1; + } + return 0; + }); + + const uncachedSorted = uncachedStreams.slice().sort((a, b) => { + const aKey = this.dynamicSortKey(a, uncachedSortCriteria, type); + const bKey = this.dynamicSortKey(b, uncachedSortCriteria, type); + for (let i = 0; i < aKey.length; i++) { + if (aKey[i] < bKey[i]) return -1; + if (aKey[i] > bKey[i]) return 1; + } + return 0; + }); + + if (primarySortCriteria[0].direction === 'desc') { + sortedStreams = [...cachedSorted, ...uncachedSorted]; + } else { + sortedStreams = [...uncachedSorted, ...cachedSorted]; + } + } else { + logger.debug( + `using sort criteria: ${JSON.stringify(primarySortCriteria)}` + ); + sortedStreams = streams.slice().sort((a, b) => { + const aKey = this.dynamicSortKey(a, primarySortCriteria, type); + const bKey = this.dynamicSortKey(b, primarySortCriteria, type); + + for (let i = 0; i < aKey.length; i++) { + if (aKey[i] < bKey[i]) return -1; + if (aKey[i] > bKey[i]) return 1; + } + return 0; + }); + } + + logger.info( + `Sorted ${sortedStreams.length} streams in ${getTimeTakenSincePoint(start)}` + ); + return sortedStreams; + } + + private dynamicSortKey( + stream: ParsedStream, + sortCriteria: SortCriterion[], + type: string + ): any[] { + function keyValue(sortCriterion: SortCriterion, userData: UserData) { + const { key, direction } = sortCriterion; + const multiplier = direction === 'asc' ? 1 : -1; + switch (key) { + case 'cached': + return multiplier * (stream.service?.cached !== false ? 1 : 0); + + case 'library': + return multiplier * (stream.library ? 1 : 0); + case 'size': + return multiplier * (stream.size ?? 0); + case 'seeders': + return multiplier * (stream.torrent?.seeders ?? 0); + case 'encode': { + if (!userData.preferredEncodes) { + return 0; + } + + const index = userData.preferredEncodes?.findIndex( + (encode) => encode === (stream.parsedFile?.encode || 'Unknown') + ); + return multiplier * -(index === -1 ? Infinity : index); + } + case 'addon': + // find the first occurence of the stream.addon.id in the addons array + if (!userData.presets) { + return 0; + } + + const idx = userData.presets.findIndex( + (p) => p.instanceId === stream.addon.presetInstanceId + ); + return multiplier * -(idx === -1 ? Infinity : idx); + + case 'resolution': { + if (!userData.preferredResolutions) { + return 0; + } + + const index = userData.preferredResolutions?.findIndex( + (resolution) => + resolution === (stream.parsedFile?.resolution || 'Unknown') + ); + return multiplier * -(index === -1 ? Infinity : index); + } + case 'quality': { + if (!userData.preferredQualities) { + return 0; + } + + const index = userData.preferredQualities.findIndex( + (quality) => quality === (stream.parsedFile?.quality || 'Unknown') + ); + return multiplier * -(index === -1 ? Infinity : index); + } + case 'visualTag': { + if (!userData.preferredVisualTags) { + return 0; + } + + const effectiveVisualTags = stream.parsedFile?.visualTags.length + ? stream.parsedFile.visualTags + : ['Unknown']; + + if ( + effectiveVisualTags.every( + (tag) => !userData.preferredVisualTags!.includes(tag as any) + ) + ) { + return multiplier * -Infinity; + } + + let minIndex = userData.preferredVisualTags?.length; + + for (const tag of effectiveVisualTags) { + if (VISUAL_TAGS.includes(tag as any)) { + const idx = userData.preferredVisualTags?.indexOf(tag as any); + if (idx !== undefined && idx !== -1 && idx < minIndex) { + minIndex = idx; + } + } + } + return multiplier * -minIndex; + } + case 'audioTag': { + if (!userData.preferredAudioTags) { + return 0; + } + + const effectiveAudioTags = stream.parsedFile?.audioTags.length + ? stream.parsedFile.audioTags + : ['Unknown']; + + if ( + effectiveAudioTags.every( + (tag) => !userData.preferredAudioTags!.includes(tag as any) + ) + ) { + return multiplier * -Infinity; + } + let minAudioIndex = userData.preferredAudioTags.length; + + for (const tag of effectiveAudioTags) { + if (AUDIO_TAGS.includes(tag as any)) { + const idx = userData.preferredAudioTags?.indexOf(tag as any); + if (idx !== undefined && idx !== -1 && idx < minAudioIndex) { + minAudioIndex = idx; + } + } + } + return multiplier * -minAudioIndex; + } + case 'streamType': { + if (!userData.preferredStreamTypes) { + return 0; + } + const index = userData.preferredStreamTypes?.findIndex( + (type) => type === stream.type + ); + return multiplier * -(index === -1 ? Infinity : index); + } + case 'language': { + let minLanguageIndex = userData.preferredLanguages?.length; + if (minLanguageIndex === undefined) { + return 0; + } + for (const language of stream.parsedFile?.languages || ['Unknown']) { + const idx = userData.preferredLanguages?.indexOf(language as any); + if (idx !== undefined && idx !== -1 && idx < minLanguageIndex) { + minLanguageIndex = idx; + } + } + return multiplier * -minLanguageIndex; + } + case 'regexPatterns': + return ( + multiplier * + -(stream.regexMatched ? stream.regexMatched.index : Infinity) + ); + case 'streamExpressionMatched': + return multiplier * -(stream.streamExpressionMatched ?? Infinity); + case 'keyword': + return multiplier * (stream.keywordMatched ? 1 : 0); + + case 'service': { + if (!userData.services) { + return 0; + } + + const index = userData.services.findIndex( + (service) => service.id === stream.service?.id + ); + return multiplier * -(index === -1 ? Infinity : index); + } + default: + return 0; + } + } + + return ( + sortCriteria.map((sortCriterion) => + keyValue(sortCriterion, this.userData) + ) ?? [] + ); + } +} + +export default StreamSorter; diff --git a/packages/core/src/streams/utils.ts b/packages/core/src/streams/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..fb3f3f618c47be3ac410d1b9ac725fa3fa4acaa7 --- /dev/null +++ b/packages/core/src/streams/utils.ts @@ -0,0 +1,25 @@ +import { ParsedStream } from '../db/schemas'; + +class StreamUtils { + public createDownloadableStream(stream: ParsedStream): ParsedStream { + const copy = structuredClone(stream); + copy.url = undefined; + copy.externalUrl = stream.url; + copy.message = `Download the stream above via your browser`; + copy.id = `${stream.id}-external-download`; + copy.type = 'external'; + // remove uneccessary info that is already present in the original stream above + copy.parsedFile = undefined; + copy.size = undefined; + copy.folderSize = undefined; + copy.torrent = undefined; + copy.indexer = undefined; + copy.age = undefined; + copy.duration = undefined; + copy.folderName = undefined; + copy.filename = undefined; + return copy; + } +} + +export default StreamUtils; diff --git a/packages/core/src/transformers/index.ts b/packages/core/src/transformers/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..176ced2505d796a6fa8f17a7a6969e993d0c392f --- /dev/null +++ b/packages/core/src/transformers/index.ts @@ -0,0 +1 @@ +export * from './stremio'; diff --git a/packages/core/src/transformers/stremio.ts b/packages/core/src/transformers/stremio.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd1b94f6952a6848862bead63be6621cc1157f99 --- /dev/null +++ b/packages/core/src/transformers/stremio.ts @@ -0,0 +1,342 @@ +import { constants, Env } from '..'; +import { + Meta, + MetaPreview, + ParsedStream, + Resource, + AIOStream, + Subtitle, + UserData, + AddonCatalog, + Stream, + AddonCatalogResponse, + AIOStreamResponse, + SubtitleResponse, + MetaResponse, + CatalogResponse, + StreamResponse, +} from '../db'; +import { createFormatter } from '../formatters'; +import { AIOStreamsError, AIOStreamsResponse } from '../main'; +import { createLogger } from '../utils'; + +type ErrorOptions = { + errorTitle?: string; + errorDescription?: string; + errorUrl?: string; +}; + +const logger = createLogger('stremio'); + +export class StremioTransformer { + constructor(private readonly userData: UserData) {} + + public showError(resource: Resource, errors: AIOStreamsError[]) { + if ( + errors.length > 0 && + !this.userData.hideErrors && + !this.userData.hideErrorsForResources?.includes(resource) + ) { + return true; + } + return false; + } + + async transformStreams( + response: AIOStreamsResponse + ): Promise { + const { data: streams, errors } = response; + + let transformedStreams: AIOStream[] = []; + + let formatter; + if (this.userData.formatter.id === constants.CUSTOM_FORMATTER) { + const template = this.userData.formatter.definition; + if (!template) { + throw new Error('No template defined for custom formatter'); + } + formatter = createFormatter( + this.userData.formatter.id, + template, + this.userData.addonName + ); + } else { + formatter = createFormatter( + this.userData.formatter.id, + undefined, + this.userData.addonName + ); + } + + logger.info( + `Transforming ${streams.length} streams, using formatter ${this.userData.formatter.id}` + ); + + transformedStreams = await Promise.all( + streams.map(async (stream: ParsedStream): Promise => { + const { name, description } = stream.addon.streamPassthrough + ? { + name: stream.originalName, + description: stream.originalDescription, + } + : formatter.format(stream); + const identifyingAttributes = [ + Env.ADDON_ID, + stream.addon.name, + stream.service?.id, + stream.library, + stream.proxied, + stream.parsedFile?.resolution, + stream.parsedFile?.quality, + stream.parsedFile?.encode, + stream.parsedFile?.audioTags.length + ? stream.parsedFile?.audioTags + : undefined, + stream.parsedFile?.visualTags.length + ? stream.parsedFile?.visualTags + : undefined, + stream.parsedFile?.languages.length + ? stream.parsedFile?.languages + : undefined, + stream.parsedFile?.releaseGroup, + stream.indexer, + ].filter(Boolean); + const bingeGroup = `${identifyingAttributes.join('|')}`; + return { + name, + description, + url: ['http', 'usenet', 'debrid', 'live'].includes(stream.type) + ? stream.url + : undefined, + infoHash: + stream.type === 'p2p' ? stream.torrent?.infoHash : undefined, + ytId: stream.type === 'youtube' ? stream.ytId : undefined, + externalUrl: + stream.type === 'external' ? stream.externalUrl : undefined, + sources: stream.type === 'p2p' ? stream.torrent?.sources : undefined, + subtitles: stream.subtitles, + behaviorHints: { + countryWhitelist: stream.countryWhitelist, + notWebReady: stream.notWebReady, + bingeGroup: bingeGroup, + proxyHeaders: + stream.requestHeaders || stream.responseHeaders + ? { + request: stream.requestHeaders, + response: stream.responseHeaders, + } + : undefined, + videoHash: stream.videoHash, + videoSize: stream.size, + filename: stream.filename, + }, + streamData: { + type: stream.type, + proxied: stream.proxied, + indexer: stream.indexer, + age: stream.age, + duration: stream.duration, + library: stream.library, + size: stream.size, + folderSize: stream.folderSize, + torrent: stream.torrent, + addon: stream.addon.name, + filename: stream.filename, + folderName: stream.folderName, + service: stream.service, + parsedFile: stream.parsedFile, + message: stream.message, + regexMatched: stream.regexMatched, + keywordMatched: stream.keywordMatched, + }, + }; + }) + ); + + // add errors to the end (if this.userData.hideErrors is false or the resource is not in this.userData.hideErrorsForResources) + if (this.showError('stream', errors)) { + transformedStreams.push( + ...errors.map((error) => + StremioTransformer.createErrorStream({ + errorTitle: error.title, + errorDescription: error.description, + }) + ) + ); + } + + return { + streams: transformedStreams, + }; + } + + transformSubtitles( + response: AIOStreamsResponse + ): SubtitleResponse { + const { data: subtitles, errors } = response; + + if (this.showError('subtitles', errors)) { + subtitles.push( + ...errors.map((error) => + StremioTransformer.createErrorSubtitle({ + errorTitle: error.title, + errorDescription: error.description, + }) + ) + ); + } + + return { + subtitles, + }; + } + + transformCatalog( + response: AIOStreamsResponse + ): CatalogResponse { + const { data: metas, errors } = response; + + if (this.showError('catalog', errors)) { + metas.push( + ...errors.map((error) => + StremioTransformer.createErrorMeta({ + errorTitle: error.title, + errorDescription: error.description, + }) + ) + ); + } + + return { + metas, + }; + } + + transformMeta( + response: AIOStreamsResponse + ): MetaResponse | null { + const { data: meta, errors } = response; + + if (!meta && errors.length === 0) { + return null; + } + + if (this.showError('meta', errors) || !meta) { + return { + meta: StremioTransformer.createErrorMeta({ + errorTitle: errors.length > 0 ? errors[0].title : undefined, + errorDescription: errors[0]?.description || 'Unknown error', + }), + }; + } + return { + meta, + }; + } + + transformAddonCatalog( + response: AIOStreamsResponse + ): AddonCatalogResponse { + const { data: addonCatalogs, errors } = response; + if (this.showError('addon_catalog', errors)) { + addonCatalogs.push( + ...errors.map((error) => + StremioTransformer.createErrorAddonCatalog({ + errorTitle: error.title, + errorDescription: error.description, + }) + ) + ); + } + return { + addons: addonCatalogs, + }; + } + static createErrorStream(options: ErrorOptions = {}): AIOStream { + const { + errorTitle = `[❌] ${Env.ADDON_NAME}`, + errorDescription = 'Unknown error', + errorUrl = 'https://github.com/Viren070/AIOStreams', + } = options; + return { + name: errorTitle, + description: errorDescription, + externalUrl: errorUrl, + streamData: { + type: constants.ERROR_STREAM_TYPE, + error: { + title: errorTitle, + description: errorDescription, + }, + }, + }; + } + + static createErrorSubtitle(options: ErrorOptions = {}) { + const { + errorTitle = 'Unknown error', + errorDescription = 'Unknown error', + errorUrl = 'https://github.com/Viren070/AIOStreams', + } = options; + return { + id: `error.${errorTitle}`, + lang: `[❌] ${errorTitle} - ${errorDescription}`, + url: errorUrl, + }; + } + + static createErrorMeta(options: ErrorOptions = {}): MetaPreview { + const { + errorTitle = `[❌] ${Env.ADDON_NAME} - Error`, + errorDescription = 'Unknown error', + } = options; + return { + id: `error.${errorTitle}`, + name: errorTitle, + description: errorDescription, + type: 'movie', + }; + } + + static createErrorAddonCatalog(options: ErrorOptions = {}): AddonCatalog { + const { + errorTitle = `[❌] ${Env.ADDON_NAME} - Error`, + errorDescription = 'Unknown error', + } = options; + return { + transportName: 'http', + transportUrl: 'https://github.com/Viren070/AIOStreams', + manifest: { + name: errorTitle, + description: errorDescription, + id: `error.${errorTitle}`, + version: '1.0.0', + types: ['addon_catalog'], + resources: [{ name: 'addon_catalog', types: ['addon_catalog'] }], + catalogs: [], + }, + }; + } + + static createDynamicError( + resource: Resource, + options: ErrorOptions = {} + ): any { + if (resource === 'meta') { + return { meta: StremioTransformer.createErrorMeta(options) }; + } + if (resource === 'addon_catalog') { + return { addons: [StremioTransformer.createErrorAddonCatalog(options)] }; + } + if (resource === 'catalog') { + return { metas: [StremioTransformer.createErrorMeta(options)] }; + } + if (resource === 'stream') { + return { streams: [StremioTransformer.createErrorStream(options)] }; + } + if (resource === 'subtitles') { + return { subtitles: [StremioTransformer.createErrorSubtitle(options)] }; + } + return null; + } +} diff --git a/packages/core/src/utils/cache.ts b/packages/core/src/utils/cache.ts new file mode 100644 index 0000000000000000000000000000000000000000..ab720ad75a234268b097385458f38c5289abaf6b --- /dev/null +++ b/packages/core/src/utils/cache.ts @@ -0,0 +1,204 @@ +import { createLogger } from './logger'; +import { Env } from './env'; + +const logger = createLogger('cache'); + +function formatBytes(bytes: number, decimals: number = 2): string { + if (!+bytes) return '0 Bytes'; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`; +} + +class CacheItem { + constructor( + public value: T, + public lastAccessed: number, + public ttl: number // Time-To-Live in milliseconds + ) {} +} + +export class Cache { + private static instances: Map = new Map(); + private cache: Map>; + private maxSize: number; + private static isStatsLoopRunning: boolean = false; + + private constructor(maxSize: number) { + this.cache = new Map>(); + this.maxSize = maxSize; + Cache.startStatsLoop(); + } + private static startStatsLoop() { + if (Cache.isStatsLoopRunning) { + return; + } + Cache.isStatsLoopRunning = true; + const interval = Env.LOG_CACHE_STATS_INTERVAL * 60 * 1000; // Convert minutes to ms + const runAndReschedule = () => { + Cache.stats(); + + const delay = interval - (Date.now() % interval); + setTimeout(runAndReschedule, delay).unref(); + }; + const initialDelay = interval - (Date.now() % interval); + setTimeout(runAndReschedule, initialDelay).unref(); + } + + /** + * Get an instance of the cache with a specific name + * @param name Unique identifier for this cache instance + * @param maxSize Maximum size of the cache (only used when creating a new instance) + */ + public static getInstance( + name: string, + maxSize: number = Env.DEFAULT_MAX_CACHE_SIZE + ): Cache { + if (!this.instances.has(name)) { + logger.debug(`Creating new cache instance: ${name}`); + this.instances.set(name, new Cache(maxSize)); + } + return this.instances.get(name) as Cache; + } + + /** + * Gets the statistics of the cache in use by the program. returns a formatted string containing a list of all cache instances + * and their currently held items, max items + */ + public static stats() { + if (!this.instances || this.instances.size === 0) { + return; + } + + let grandTotalItems = 0; + let grandTotalSize = 0; + + const header = [ + '╔══════════════════════╤══════════╤═════════════════╤═════════════════╗', + '║ Cache Name │ Items │ Max Size │ Estimated Size ║', + '╠══════════════════════╪══════════╪═════════════════╪═════════════════╣', + ]; + + const bodyLines = Array.from(this.instances.entries()).map( + ([name, cache]) => { + let instanceSize = 0; + for (const item of cache.cache.values()) { + try { + // Estimate object size by getting the byte length of its JSON string representation. + // This is an approximation but is effective for many use cases. + instanceSize += Buffer.byteLength(JSON.stringify(item), 'utf8'); + } catch (e) { + // Could fail on circular references. In that case, we add 0. + instanceSize += 0; + } + } + + grandTotalItems += cache.cache.size; + grandTotalSize += instanceSize; + + const nameStr = name.padEnd(20); + const itemsStr = String(cache.cache.size).padEnd(8); + const maxSizeStr = String(cache.maxSize ?? '-').padEnd(15); + const estSizeStr = formatBytes(instanceSize).padEnd(15); + + return `║ ${nameStr} │ ${itemsStr} │ ${maxSizeStr} │ ${estSizeStr} ║`; + } + ); + + const footer = [ + '╚══════════════════════╧══════════╧═════════════════╧═════════════════╝', + ` Summary: ${this.instances.size} cache instance(s), ${grandTotalItems} total items, Est. Total Size: ${formatBytes(grandTotalSize)}`, + ]; + + const lines = [...header, ...bodyLines, ...footer]; + logger.verbose(lines.join('\n')); + } + + /** + * Wrap a function with caching logic by immediately executing it with the provided arguments. + * @param fn The function to wrap + * @param key A unique key for caching + * @param ttl Time-To-Live in seconds for the cached value + * @param args The arguments to pass to the function + */ + async wrap any>( + fn: T, + key: K, + ttl: number, + ...args: Parameters + ): Promise> { + const cachedValue = this.get(key); + if (cachedValue !== undefined) { + return cachedValue as ReturnType; + } + const result = await fn(...args); + this.set(key, result, ttl); + return result; + } + + get(key: K, updateTTL: boolean = true): V | undefined { + const item = this.cache.get(key); + if (item) { + const now = Date.now(); + if (now - item.lastAccessed > item.ttl) { + this.cache.delete(key); + return undefined; + } + if (updateTTL) { + item.lastAccessed = now; + } + return item.value; + } + return undefined; + } + + /** + * Set a value in the cache with a specific TTL + * @param key The key to set the value for + * @param value The value to set + * @param ttl The TTL in seconds + */ + set(key: K, value: V, ttl: number): void { + if (this.cache.size >= this.maxSize) { + this.evict(); + } + this.cache.set(key, new CacheItem(value, Date.now(), ttl * 1000)); + } + + /** + * Update the value of an existing key in the cache without changing the TTL + * @param key The key to update + * @param value The new value + */ + update(key: K, value: V): void { + const item = this.cache.get(key); + if (item) { + item.value = value; + } + } + + clear(): void { + this.cache.clear(); + } + + private evict(): void { + let oldestKey: K | undefined; + let oldestTime = Infinity; + + for (const [key, item] of this.cache.entries()) { + if (item.lastAccessed < oldestTime) { + oldestTime = item.lastAccessed; + oldestKey = key; + } + } + + if (oldestKey !== undefined) { + this.cache.delete(oldestKey); + } + } +} diff --git a/packages/core/src/utils/config.ts b/packages/core/src/utils/config.ts new file mode 100644 index 0000000000000000000000000000000000000000..83d45c35ade80566e3d221a3b1d85fad3a6d32a4 --- /dev/null +++ b/packages/core/src/utils/config.ts @@ -0,0 +1,708 @@ +import { + UserData, + UserDataSchema, + PresetObject, + Service, + Option, + StreamProxyConfig, + Group, +} from '../db/schemas'; +import { AIOStreams } from '../main'; +import { Preset, PresetManager } from '../presets'; +import { createProxy } from '../proxy'; +import { constants, TMDBMetadata } from '.'; +import { isEncrypted, decryptString, encryptString } from './crypto'; +import { Env } from './env'; +import { createLogger, maskSensitiveInfo } from './logger'; +import { ZodError } from 'zod'; +import { + GroupConditionEvaluator, + StreamSelector, +} from '../parser/streamExpression'; +import { RPDB } from './rpdb'; +import { FeatureControl } from './feature'; +import { compileRegex } from './regex'; + +const logger = createLogger('core'); + +export const formatZodError = (error: ZodError) => { + let errs = []; + for (const issue of error.issues) { + errs.push( + `Invalid value for ${issue.path.join('.')}: ${issue.message}${ + (issue as any).unionErrors + ? `. Union checks performed:\n${(issue as any).unionErrors + .map((issue: any) => `- ${formatZodError(issue)}`) + .join('\n')}` + : '' + }` + ); + } + return errs.join(' | '); +}; + +function getServiceCredentialDefault( + serviceId: constants.ServiceId, + credentialId: string +) { + // env mapping + switch (serviceId) { + case constants.REALDEBRID_SERVICE: + switch (credentialId) { + case 'apiKey': + return Env.DEFAULT_REALDEBRID_API_KEY; + } + break; + case constants.ALLEDEBRID_SERVICE: + switch (credentialId) { + case 'apiKey': + return Env.DEFAULT_ALLDEBRID_API_KEY; + } + break; + case constants.PREMIUMIZE_SERVICE: + switch (credentialId) { + case 'apiKey': + return Env.DEFAULT_PREMIUMIZE_API_KEY; + } + break; + case constants.DEBRIDLINK_SERVICE: + switch (credentialId) { + case 'apiKey': + return Env.DEFAULT_DEBRIDLINK_API_KEY; + } + break; + case constants.TORBOX_SERVICE: + switch (credentialId) { + case 'apiKey': + return Env.DEFAULT_TORBOX_API_KEY; + } + break; + case constants.EASYDEBRID_SERVICE: + switch (credentialId) { + case 'apiKey': + return Env.DEFAULT_EASYDEBRID_API_KEY; + } + break; + case constants.PUTIO_SERVICE: + switch (credentialId) { + case 'clientId': + return Env.DEFAULT_PUTIO_CLIENT_ID; + case 'clientSecret': + return Env.DEFAULT_PUTIO_CLIENT_SECRET; + } + break; + case constants.PIKPAK_SERVICE: + switch (credentialId) { + case 'email': + return Env.DEFAULT_PIKPAK_EMAIL; + case 'password': + return Env.DEFAULT_PIKPAK_PASSWORD; + } + break; + case constants.OFFCLOUD_SERVICE: + switch (credentialId) { + case 'apiKey': + return Env.DEFAULT_OFFCLOUD_API_KEY; + case 'email': + return Env.DEFAULT_OFFCLOUD_EMAIL; + case 'password': + return Env.DEFAULT_OFFCLOUD_PASSWORD; + } + break; + case constants.SEEDR_SERVICE: + switch (credentialId) { + case 'encodedToken': + return Env.DEFAULT_SEEDR_ENCODED_TOKEN; + } + break; + case constants.EASYNEWS_SERVICE: + switch (credentialId) { + case 'username': + return Env.DEFAULT_EASYNEWS_USERNAME; + case 'password': + return Env.DEFAULT_EASYNEWS_PASSWORD; + } + break; + default: + return null; + } +} + +function getServiceCredentialForced( + serviceId: constants.ServiceId, + credentialId: string +) { + // env mapping + switch (serviceId) { + case constants.REALDEBRID_SERVICE: + switch (credentialId) { + case 'apiKey': + return Env.FORCED_REALDEBRID_API_KEY; + } + break; + case constants.ALLEDEBRID_SERVICE: + switch (credentialId) { + case 'apiKey': + return Env.FORCED_ALLDEBRID_API_KEY; + } + break; + case constants.PREMIUMIZE_SERVICE: + switch (credentialId) { + case 'apiKey': + return Env.FORCED_PREMIUMIZE_API_KEY; + } + break; + case constants.DEBRIDLINK_SERVICE: + switch (credentialId) { + case 'apiKey': + return Env.FORCED_DEBRIDLINK_API_KEY; + } + break; + case constants.TORBOX_SERVICE: + switch (credentialId) { + case 'apiKey': + return Env.FORCED_TORBOX_API_KEY; + } + break; + case constants.EASYDEBRID_SERVICE: + switch (credentialId) { + case 'apiKey': + return Env.FORCED_EASYDEBRID_API_KEY; + } + break; + case constants.PUTIO_SERVICE: + switch (credentialId) { + case 'clientId': + return Env.FORCED_PUTIO_CLIENT_ID; + case 'clientSecret': + return Env.FORCED_PUTIO_CLIENT_SECRET; + } + break; + case constants.PIKPAK_SERVICE: + switch (credentialId) { + case 'email': + return Env.FORCED_PIKPAK_EMAIL; + case 'password': + return Env.FORCED_PIKPAK_PASSWORD; + } + break; + case constants.OFFCLOUD_SERVICE: + switch (credentialId) { + case 'apiKey': + return Env.FORCED_OFFCLOUD_API_KEY; + case 'email': + return Env.FORCED_OFFCLOUD_EMAIL; + case 'password': + return Env.FORCED_OFFCLOUD_PASSWORD; + } + break; + case constants.SEEDR_SERVICE: + switch (credentialId) { + case 'encodedToken': + return Env.FORCED_SEEDR_ENCODED_TOKEN; + } + break; + case constants.EASYNEWS_SERVICE: + switch (credentialId) { + case 'username': + return Env.FORCED_EASYNEWS_USERNAME; + case 'password': + return Env.FORCED_EASYNEWS_PASSWORD; + } + break; + default: + return null; + } +} + +export function getEnvironmentServiceDetails(): typeof constants.SERVICE_DETAILS { + return Object.fromEntries( + Object.entries(constants.SERVICE_DETAILS) + .filter(([id, _]) => !FeatureControl.disabledServices.has(id)) + .map(([id, service]) => [ + id as constants.ServiceId, + { + id: service.id, + name: service.name, + shortName: service.shortName, + knownNames: service.knownNames, + signUpText: service.signUpText, + credentials: service.credentials.map((cred) => ({ + id: cred.id, + name: cred.name, + description: cred.description, + type: cred.type, + required: cred.required, + default: getServiceCredentialDefault(service.id, cred.id) + ? encryptString(getServiceCredentialDefault(service.id, cred.id)!) + .data + : null, + forced: getServiceCredentialForced(service.id, cred.id) + ? encryptString(getServiceCredentialForced(service.id, cred.id)!) + .data + : null, + })), + }, + ]) + ) as typeof constants.SERVICE_DETAILS; +} + +export async function validateConfig( + data: any, + skipErrorsFromAddonsOrProxies: boolean = false, + decryptValues: boolean = false +): Promise { + const { success, data: config, error } = UserDataSchema.safeParse(data); + if (!success) { + throw new Error(formatZodError(error)); + } + + if (Env.ADDON_PASSWORD && config.addonPassword !== Env.ADDON_PASSWORD) { + throw new Error( + 'Invalid addon password. Please enter the value of the ADDON_PASSWORD environment variable ' + ); + } + const validations = { + 'excluded stream expressions': [ + config.excludedStreamExpressions, + Env.MAX_CONDITION_FILTERS, + ], + 'excluded keywords': [config.excludedKeywords, Env.MAX_KEYWORD_FILTERS], + 'included keywords': [config.includedKeywords, Env.MAX_KEYWORD_FILTERS], + 'required keywords': [config.requiredKeywords, Env.MAX_KEYWORD_FILTERS], + 'preferred keywords': [config.preferredKeywords, Env.MAX_KEYWORD_FILTERS], + groups: [config.groups, Env.MAX_GROUPS], + }; + + for (const [name, [items, max]] of Object.entries(validations)) { + if (items && max && (items as any[]).length > (max as number)) { + throw new Error( + `You have ${(items as any[]).length} ${name}, but the maximum is ${max}` + ); + } + } + // now, validate preset options and service credentials. + + if (config.presets) { + // ensure uniqenesss of instanceIds + const instanceIds = new Set(); + for (const preset of config.presets) { + if (preset.instanceId && instanceIds.has(preset.instanceId)) { + throw new Error(`Preset instanceId ${preset.instanceId} is not unique`); + } + if (preset.instanceId.includes('.')) { + throw new Error( + `Preset instanceId ${preset.instanceId} cannot contain a dot` + ); + } + instanceIds.add(preset.instanceId); + try { + validatePreset(preset); + } catch (error) { + if (!skipErrorsFromAddonsOrProxies) { + throw error; + } + logger.warn(`Invalid preset ${preset.instanceId}: ${error}`); + } + } + } + + if (config.groups) { + for (const group of config.groups) { + await validateGroup(group); + } + } + + // validate excluded filter condition + if (config.excludedStreamExpressions) { + for (const condition of config.excludedStreamExpressions) { + try { + await StreamSelector.testSelect(condition); + } catch (error) { + throw new Error(`Invalid excluded stream expression: ${error}`); + } + } + } + + if (config.services) { + config.services = config.services.map((service: Service) => + validateService(service, decryptValues) + ); + } + + if (config.proxy) { + const decryptedProxy = ensureDecrypted(config).proxy; + if (decryptedProxy) { + config.proxy = await validateProxy( + config.proxy, + decryptedProxy, + skipErrorsFromAddonsOrProxies, + decryptValues + ); + } + } + + if (config.rpdbApiKey) { + try { + const rpdb = new RPDB(config.rpdbApiKey); + await rpdb.validateApiKey(); + } catch (error) { + if (!skipErrorsFromAddonsOrProxies) { + throw new Error(`Invalid RPDB API key: ${error}`); + } + logger.warn(`Invalid RPDB API key: ${error}`); + } + } + + if (config.titleMatching?.enabled === true) { + try { + const tmdb = new TMDBMetadata(config.tmdbAccessToken); + await tmdb.validateAccessToken(); + } catch (error) { + if (!skipErrorsFromAddonsOrProxies) { + throw new Error(`Invalid TMDB access token: ${error}`); + } + logger.warn(`Invalid TMDB access token: ${error}`); + } + } + + if (FeatureControl.disabledServices.size > 0) { + for (const service of config.services ?? []) { + if (FeatureControl.disabledServices.has(service.id)) { + service.enabled = false; + } + } + } + + await validateRegexes(config); + + await new AIOStreams( + ensureDecrypted(config), + skipErrorsFromAddonsOrProxies + ).initialise(); + + return config; +} + +async function validateRegexes(config: UserData) { + const excludedRegexes = config.excludedRegexPatterns; + const includedRegexes = config.includedRegexPatterns; + const requiredRegexes = config.requiredRegexPatterns; + const preferredRegexes = config.preferredRegexPatterns; + const regexAllowed = FeatureControl.isRegexAllowed(config); + + if ( + !regexAllowed && + (excludedRegexes?.length || + includedRegexes?.length || + requiredRegexes?.length || + preferredRegexes?.length) + ) { + throw new Error( + 'You do not have permission to use regex filters, please remove them from your config' + ); + } + + const regexes = [ + ...(excludedRegexes ?? []), + ...(includedRegexes ?? []), + ...(requiredRegexes ?? []), + ...(preferredRegexes ?? []).map((regex) => regex.pattern), + ]; + + await Promise.all( + regexes.map(async (regex) => { + try { + await compileRegex(regex); + } catch (error: any) { + logger.error(`Invalid regex: ${regex}: ${error.message}`); + throw new Error(`Invalid regex: ${regex}: ${error.message}`); + } + }) + ); +} + +function ensureDecrypted(config: UserData): UserData { + const decryptedConfig: UserData = structuredClone(config); + + // Helper function to decrypt a value if needed + const tryDecrypt = (value: any, context: string) => { + if (!isEncrypted(value)) return value; + const { success, data, error } = decryptString(value); + if (!success) { + throw new Error(`Failed to decrypt ${context}: ${error}`); + } + return data; + }; + + // Decrypt service credentials + for (const service of decryptedConfig.services ?? []) { + if (!service.credentials) continue; + for (const [credential, value] of Object.entries(service.credentials)) { + service.credentials[credential] = tryDecrypt( + value, + `credential ${credential}` + ); + } + } + // Decrypt proxy config + if (decryptedConfig.proxy) { + decryptedConfig.proxy.credentials = decryptedConfig.proxy.credentials + ? tryDecrypt(decryptedConfig.proxy.credentials, 'proxy credentials') + : undefined; + decryptedConfig.proxy.url = decryptedConfig.proxy.url + ? tryDecrypt(decryptedConfig.proxy.url, 'proxy URL') + : undefined; + } + + return decryptedConfig; +} + +function validateService( + service: Service, + decryptValues: boolean = false +): Service { + const serviceMeta = getEnvironmentServiceDetails()[service.id]; + + if (!serviceMeta) { + throw new Error(`Service ${service.id} not found`); + } + + if (serviceMeta.credentials.every((cred) => cred.forced)) { + service.enabled = true; + } + + if (service.enabled) { + for (const credential of serviceMeta.credentials) { + try { + service.credentials[credential.id] = validateOption( + credential, + service.credentials?.[credential.id], + decryptValues + ); + } catch (error) { + throw new Error( + `The value for credential '${credential.name}' in service '${serviceMeta.name}' is invalid: ${error}` + ); + } + } + } + return service; +} + +function validatePreset(preset: PresetObject) { + const presetMeta = PresetManager.fromId(preset.type).METADATA; + + const optionMetas = presetMeta.OPTIONS; + + for (const optionMeta of optionMetas) { + const optionValue = preset.options[optionMeta.id]; + try { + preset.options[optionMeta.id] = validateOption(optionMeta, optionValue); + } catch (error) { + throw new Error( + `The value for option '${optionMeta.name}' in preset '${presetMeta.NAME}' is invalid: ${error}` + ); + } + } +} + +async function validateGroup(group: Group) { + if (!group) { + return; + } + + // each group must have at least one addon, and we must be able to parse the condition + if (group.addons.length === 0) { + throw new Error('Every group must have at least one addon'); + } + + // we must be able to parse the condition + let result; + try { + result = await GroupConditionEvaluator.testEvaluate(group.condition); + } catch (error: any) { + throw new Error( + `Your group condition - '${group.condition}' - is invalid: ${error.message}` + ); + } + if (typeof result !== 'boolean') { + throw new Error( + `Your group condition - '${group.condition}' - is invalid. Expected evaluation to a boolean, instead got '${typeof result}'` + ); + } +} + +function validateOption( + option: Option, + value: any, + decryptValues: boolean = false +): any { + if (value === undefined) { + if (option.required) { + throw new Error(`Option ${option.id} is required, got ${value}`); + } + return value; + } + if (option.type === 'multi-select') { + if (!Array.isArray(value)) { + throw new Error( + `Option ${option.id} must be an array, got ${typeof value}` + ); + } + } + + if (option.type === 'select') { + if (typeof value !== 'string') { + throw new Error( + `Option ${option.id} must be a string, got ${typeof value}` + ); + } + } + + if (option.type === 'boolean') { + if (typeof value !== 'boolean') { + throw new Error( + `Option ${option.id} must be a boolean, got ${typeof value}` + ); + } + } + + if (option.type === 'number') { + if (typeof value !== 'number') { + throw new Error( + `Option ${option.id} must be a number, got ${typeof value}` + ); + } + if (option.constraints?.min && value < option.constraints.min) { + throw new Error( + `Option ${option.id} must be at least ${option.constraints.min}, got ${value}` + ); + } + if (option.constraints?.max && value > option.constraints.max) { + throw new Error( + `Option ${option.id} must be at most ${option.constraints.max}, got ${value}` + ); + } + } + + if (option.type === 'string') { + if (typeof value !== 'string') { + throw new Error( + `Option ${option.id} must be a string, got ${typeof value}` + ); + } + if (option.constraints?.min && value.length < option.constraints.min) { + throw new Error( + `Option ${option.id} must be at least ${option.constraints.min} characters, got ${value.length}` + ); + } + if (option.constraints?.max && value.length > option.constraints.max) { + throw new Error( + `Option ${option.id} must be at most ${option.constraints.max} characters, got ${value.length}` + ); + } + } + + if (option.type === 'password') { + if (typeof value !== 'string') { + throw new Error( + `Option ${option.id} must be a string, got ${typeof value}` + ); + } + + if (option.forced) { + // option.forced is already encrypted + value = option.forced; + } + if (isEncrypted(value) && decryptValues) { + const { success, data, error } = decryptString(value); + if (!success) { + throw new Error( + `Option ${option.id} is encrypted but failed to decrypt: ${error}` + ); + } + value = data; + } + } + + if (option.type === 'url') { + if (typeof value !== 'string') { + throw new Error( + `Option ${option.id} must be a string, got ${typeof value}` + ); + } + } + + return value; +} + +async function validateProxy( + proxy: StreamProxyConfig, + decryptedProxy: StreamProxyConfig, + skipProxyErrors: boolean = false, + decryptCredentials: boolean = false +): Promise { + // apply forced values if they exist + proxy.enabled = Env.FORCE_PROXY_ENABLED ?? proxy.enabled; + proxy.id = Env.FORCE_PROXY_ID ?? proxy.id; + proxy.url = Env.FORCE_PROXY_URL + ? (encryptString(Env.FORCE_PROXY_URL).data ?? undefined) + : (proxy.url ?? undefined); + proxy.credentials = Env.FORCE_PROXY_CREDENTIALS + ? (encryptString(Env.FORCE_PROXY_CREDENTIALS).data ?? undefined) + : (proxy.credentials ?? undefined); + proxy.publicIp = Env.FORCE_PROXY_PUBLIC_IP ?? proxy.publicIp; + proxy.proxiedAddons = Env.FORCE_PROXY_DISABLE_PROXIED_ADDONS + ? undefined + : proxy.proxiedAddons; + proxy.proxiedServices = + Env.FORCE_PROXY_PROXIED_SERVICES ?? proxy.proxiedServices; + if (proxy.enabled) { + if (!proxy.id) { + throw new Error('Proxy ID is required'); + } + if (!proxy.url) { + throw new Error('Proxy URL is required'); + } + if (!proxy.credentials) { + throw new Error('Proxy credentials are required'); + } + + if (isEncrypted(proxy.credentials) && decryptCredentials) { + const { success, data, error } = decryptString(proxy.credentials); + if (!success) { + throw new Error( + `Proxy credentials for ${proxy.id} are encrypted but failed to decrypt: ${error}` + ); + } + proxy.credentials = data; + } + if (isEncrypted(proxy.url) && decryptCredentials) { + const { success, data, error } = decryptString(proxy.url); + if (!success) { + throw new Error( + `Proxy URL for ${proxy.id} is encrypted but failed to decrypt: ${error}` + ); + } + proxy.url = data; + } + + // use decrypted proxy config for validation. + const ProxyService = createProxy(decryptedProxy); + + try { + proxy.publicIp || (await ProxyService.getPublicIp()); + } catch (error) { + if (!skipProxyErrors) { + logger.error( + `Failed to get the public IP of the proxy service ${proxy.id} (${maskSensitiveInfo(proxy.url)}): ${error}` + ); + throw new Error( + `Failed to get the public IP of the proxy service ${proxy.id}: ${error}` + ); + } + } + } + return proxy; +} diff --git a/packages/core/src/utils/constants.ts b/packages/core/src/utils/constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..71c0c546414c9b89ddac015bce5e33b2a01f36e9 --- /dev/null +++ b/packages/core/src/utils/constants.ts @@ -0,0 +1,901 @@ +import { Option } from '../db'; + +export enum ErrorCode { + // User API + USER_NOT_FOUND = 'USER_NOT_FOUND', + USER_ALREADY_EXISTS = 'USER_ALREADY_EXISTS', + USER_INVALID_PASSWORD = 'USER_INVALID_PASSWORD', + USER_INVALID_CONFIG = 'USER_INVALID_CONFIG', + USER_ERROR = 'USER_ERROR', + USER_NEW_PASSWORD_TOO_SHORT = 'USER_NEW_PASSWORD_TOO_SHORT', + USER_NEW_PASSWORD_TOO_SIMPLE = 'USER_NEW_PASSWORD_TOO_SIMPLE', + // Format API + FORMAT_INVALID_FORMATTER = 'FORMAT_INVALID_FORMATTER', + FORMAT_INVALID_STREAM = 'FORMAT_INVALID_STREAM', + FORMAT_ERROR = 'FORMAT_ERROR', + // Other + MISSING_REQUIRED_FIELDS = 'MISSING_REQUIRED_FIELDS', + INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR', + METHOD_NOT_ALLOWED = 'METHOD_NOT_ALLOWED', + RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED', +} + +interface ErrorDetails { + statusCode: number; + message: string; +} + +export const ErrorMap: Record = { + [ErrorCode.MISSING_REQUIRED_FIELDS]: { + statusCode: 400, + message: 'Required fields are missing', + }, + [ErrorCode.USER_NOT_FOUND]: { + statusCode: 404, + message: 'User not found', + }, + [ErrorCode.USER_ALREADY_EXISTS]: { + statusCode: 409, + message: 'User already exists', + }, + [ErrorCode.USER_INVALID_PASSWORD]: { + statusCode: 401, + message: 'Invalid password', + }, + [ErrorCode.USER_INVALID_CONFIG]: { + statusCode: 400, + message: 'The config for this user is invalid', + }, + [ErrorCode.USER_ERROR]: { + statusCode: 500, + message: 'A generic error while processing the user request', + }, + [ErrorCode.USER_NEW_PASSWORD_TOO_SHORT]: { + statusCode: 400, + message: 'New password is too short', + }, + [ErrorCode.USER_NEW_PASSWORD_TOO_SIMPLE]: { + statusCode: 400, + message: 'New password is too simple', + }, + [ErrorCode.INTERNAL_SERVER_ERROR]: { + statusCode: 500, + message: 'An unexpected error occurred', + }, + [ErrorCode.METHOD_NOT_ALLOWED]: { + statusCode: 405, + message: 'Method not allowed', + }, + [ErrorCode.RATE_LIMIT_EXCEEDED]: { + statusCode: 429, + message: 'Too many requests from this IP, please try again later.', + }, + [ErrorCode.FORMAT_INVALID_FORMATTER]: { + statusCode: 400, + message: 'Invalid formatter', + }, + [ErrorCode.FORMAT_INVALID_STREAM]: { + statusCode: 400, + message: 'Invalid stream', + }, + [ErrorCode.FORMAT_ERROR]: { + statusCode: 500, + message: 'An error occurred while formatting the stream', + }, +}; + +export class APIError extends Error { + constructor( + public code: ErrorCode, + public statusCode: number = ErrorMap[code].statusCode, + message?: string + ) { + super(message || ErrorMap[code].message); + this.name = 'APIError'; + } +} + +const HEADERS_FOR_IP_FORWARDING = [ + 'X-Client-IP', + 'X-Forwarded-For', + 'X-Real-IP', + 'True-Client-IP', + 'X-Forwarded', + 'Forwarded-For', +]; + +const API_VERSION = 1; + +export const GDRIVE_FORMATTER = 'gdrive'; +export const LIGHT_GDRIVE_FORMATTER = 'lightgdrive'; +export const MINIMALISTIC_GDRIVE_FORMATTER = 'minimalisticgdrive'; +export const TORRENTIO_FORMATTER = 'torrentio'; +export const TORBOX_FORMATTER = 'torbox'; +export const CUSTOM_FORMATTER = 'custom'; + +export const FORMATTERS = [ + GDRIVE_FORMATTER, + LIGHT_GDRIVE_FORMATTER, + MINIMALISTIC_GDRIVE_FORMATTER, + TORRENTIO_FORMATTER, + TORBOX_FORMATTER, + CUSTOM_FORMATTER, +] as const; + +export type FormatterDetail = { + id: FormatterType; + name: string; + description: string; +}; + +export const FORMATTER_DETAILS: Record = { + [GDRIVE_FORMATTER]: { + id: GDRIVE_FORMATTER, + name: 'Google Drive', + description: 'Uses the formatting from the Stremio GDrive addon', + }, + [LIGHT_GDRIVE_FORMATTER]: { + id: LIGHT_GDRIVE_FORMATTER, + name: 'Light Google Drive', + description: + 'A lighter version of the GDrive formatter, focused on asthetics', + }, + [MINIMALISTIC_GDRIVE_FORMATTER]: { + id: MINIMALISTIC_GDRIVE_FORMATTER, + name: 'Minimalistic Google Drive', + description: + 'A minimalistic formatter for Google Drive which shows only the bare minimum', + }, + [TORRENTIO_FORMATTER]: { + id: TORRENTIO_FORMATTER, + name: 'Torrentio', + description: 'Uses the formatting from the Torrentio addon', + }, + [TORBOX_FORMATTER]: { + id: TORBOX_FORMATTER, + name: 'Torbox', + description: 'Uses the formatting from the TorBox Stremio addon', + }, + [CUSTOM_FORMATTER]: { + id: CUSTOM_FORMATTER, + name: 'Custom', + description: 'Define your own formatter', + }, +}; + +export type FormatterType = (typeof FORMATTERS)[number]; + +const REALDEBRID_SERVICE = 'realdebrid'; +const DEBRIDLINK_SERVICE = 'debridlink'; +const PREMIUMIZE_SERVICE = 'premiumize'; +const ALLEDEBRID_SERVICE = 'alldebrid'; +const TORBOX_SERVICE = 'torbox'; +const EASYDEBRID_SERVICE = 'easydebrid'; +const PUTIO_SERVICE = 'putio'; +const PIKPAK_SERVICE = 'pikpak'; +const OFFCLOUD_SERVICE = 'offcloud'; +const SEEDR_SERVICE = 'seedr'; +const EASYNEWS_SERVICE = 'easynews'; + +const SERVICES = [ + REALDEBRID_SERVICE, + DEBRIDLINK_SERVICE, + PREMIUMIZE_SERVICE, + ALLEDEBRID_SERVICE, + TORBOX_SERVICE, + EASYDEBRID_SERVICE, + PUTIO_SERVICE, + PIKPAK_SERVICE, + OFFCLOUD_SERVICE, + SEEDR_SERVICE, + EASYNEWS_SERVICE, +] as const; + +export type ServiceId = (typeof SERVICES)[number]; + +export const MEDIAFLOW_SERVICE = 'mediaflow' as const; +export const STREMTHRU_SERVICE = 'stremthru' as const; + +export const PROXY_SERVICES = [MEDIAFLOW_SERVICE, STREMTHRU_SERVICE] as const; +export type ProxyServiceId = (typeof PROXY_SERVICES)[number]; + +export const PROXY_SERVICE_DETAILS: Record< + ProxyServiceId, + { + id: ProxyServiceId; + name: string; + description: string; + credentialDescription: string; + } +> = { + [MEDIAFLOW_SERVICE]: { + id: MEDIAFLOW_SERVICE, + name: 'MediaFlow Proxy', + description: + '[MediaFlow Proxy](https://github.com/mhdzumair/mediaflow-proxy) is a high performance proxy server which supports HTTP, HLS, and more.', + credentialDescription: + 'The value of your MediaFlow Proxy instance `API_PASSWORD` environment variable.', + }, + [STREMTHRU_SERVICE]: { + id: STREMTHRU_SERVICE, + name: 'StremThru', + description: + '[StremThru](https://github.com/MunifTanjim/stremthru) is a feature packed companion to Stremio which also offers a HTTP proxy, written in Go.', + credentialDescription: + 'A valid credential for your StremThru instance, defined in the `STREMTHRU_PROXY_AUTH` environment variable.', + }, +}; + +const SERVICE_DETAILS: Record< + ServiceId, + { + id: ServiceId; + name: string; + shortName: string; + knownNames: string[]; + signUpText: string; + credentials: Option[]; + } +> = { + [REALDEBRID_SERVICE]: { + id: REALDEBRID_SERVICE, + name: 'Real-Debrid', + shortName: 'RD', + knownNames: ['RD', 'Real Debrid', 'RealDebrid', 'Real-Debrid'], + signUpText: + "Don't have an account? [Sign up here](https://real-debrid.com/?id=9483829)", + credentials: [ + { + id: 'apiKey', + name: 'API Key', + description: + 'The API key for the Real-Debrid service. Obtain it from [here](https://real-debrid.com/apitoken)', + type: 'password', + required: true, + }, + ], + }, + [ALLEDEBRID_SERVICE]: { + id: ALLEDEBRID_SERVICE, + name: 'AllDebrid', + shortName: 'AD', + knownNames: ['AD', 'All Debrid', 'AllDebrid', 'All-Debrid'], + signUpText: + "Don't have an account? [Sign up here](https://alldebrid.com/?uid=3n8qa&lang=en)", + credentials: [ + { + id: 'apiKey', + name: 'API Key', + description: + 'The API key for the All-Debrid service. Create one [here](https://alldebrid.com/apikeys)', + type: 'password', + required: true, + }, + ], + }, + [PREMIUMIZE_SERVICE]: { + id: PREMIUMIZE_SERVICE, + name: 'Premiumize', + shortName: 'PM', + knownNames: ['PM', 'Premiumize'], + signUpText: + "Don't have an account? [Sign up here](https://www.premiumize.me/register)", + credentials: [ + { + id: 'apiKey', + name: 'API Key', + description: + 'Your Premiumize API key. Obtain it from [here](https://www.premiumize.me/account)', + type: 'password', + required: true, + }, + ], + }, + [DEBRIDLINK_SERVICE]: { + id: DEBRIDLINK_SERVICE, + name: 'Debrid-Link', + shortName: 'DL', + knownNames: ['DL', 'Debrid Link', 'DebridLink', 'Debrid-Link'], + signUpText: + "Don't have an account? [Sign up here](https://debrid-link.com/id/EY0JO)", + credentials: [ + { + id: 'apiKey', + name: 'API Key', + description: + 'Your Debrid-Link API key. Obtain it from [here](https://debrid-link.com/webapp/apikey)', + type: 'password', + required: true, + }, + ], + }, + [TORBOX_SERVICE]: { + id: TORBOX_SERVICE, + name: 'TorBox', + shortName: 'TB', + knownNames: ['TB', 'TorBox', 'Torbox', 'TRB'], + signUpText: + "Don't have an account? [Sign up here](https://torbox.app/subscription?referral=9ca21adb-dbcb-4fb0-9195-412a5f3519bc) or use my referral code `9ca21adb-dbcb-4fb0-9195-412a5f3519bc`.", + credentials: [ + { + id: 'apiKey', + name: 'API Key', + description: + 'Your Torbox API key. Obtain it from [here](https://torbox.app/settings)', + type: 'password', + required: true, + }, + ], + }, + [OFFCLOUD_SERVICE]: { + id: OFFCLOUD_SERVICE, + name: 'Offcloud', + shortName: 'OC', + knownNames: ['OC', 'Offcloud'], + signUpText: + "Don't have an account? [Sign up here](https://offcloud.com/?=06202a3d)", + credentials: [ + { + id: 'apiKey', + name: 'API Key', + description: + 'Your Offcloud API key. Obtain it from [here](https://offcloud.com/#/account) on the `API Key` tab. ', + type: 'password', + required: true, + }, + { + id: 'email', + name: 'Email', + description: + 'Your Offcloud email. (These credentials are necessary for some addons)', + type: 'password', + required: true, + }, + { + id: 'password', + name: 'Password', + description: + 'Your Offcloud password. (These credentials are necessary for some addons)', + type: 'password', + required: true, + }, + ], + }, + [PUTIO_SERVICE]: { + id: PUTIO_SERVICE, + name: 'put.io', + shortName: 'P.IO', + knownNames: ['PO', 'put.io', 'putio'], + signUpText: "Don't have an account? [Sign up here](https://put.io/)", + credentials: [ + { + id: 'clientId', + name: 'Client ID', + description: + 'Your put.io Client ID. Obtain it from [here](https://app.put.io/oauth)', + type: 'password', + required: true, + }, + { + id: 'token', + name: 'Token', + description: + 'Your put.io Token. Obtain it from [here](https://app.put.io/oauth)', + type: 'password', + required: true, + }, + ], + }, + [EASYNEWS_SERVICE]: { + id: EASYNEWS_SERVICE, + name: 'Easynews', + shortName: 'EN', + knownNames: ['EN', 'Easynews'], + signUpText: + "Don't have an account? [Sign up here](https://www.easynews.com/)", + credentials: [ + { + id: 'username', + name: 'Username', + description: 'Your Easynews username', + type: 'password', + required: true, + }, + { + id: 'password', + name: 'Password', + description: 'Your Easynews password', + type: 'password', + required: true, + }, + ], + }, + [EASYDEBRID_SERVICE]: { + id: EASYDEBRID_SERVICE, + name: 'EasyDebrid', + shortName: 'ED', + knownNames: ['ED', 'EasyDebrid'], + signUpText: + "Don't have an account? [Sign up here](https://paradise-cloud.com/products/easydebrid)", + credentials: [ + { + id: 'apiKey', + name: 'API Key', + description: + 'Your EasyDebrid API key. Obtain it from [here](https://paradise-cloud.com/dashboard/)', + type: 'password', + required: true, + }, + ], + }, + [PIKPAK_SERVICE]: { + id: PIKPAK_SERVICE, + name: 'PikPak', + shortName: 'PKP', + knownNames: ['PP', 'PikPak', 'PKP'], + signUpText: + "Don't have an account? [Sign up here](https://mypikpak.com/drive/activity/invited?invitation-code=72822731)", + credentials: [ + { + id: 'email', + name: 'Email', + description: 'Your PikPak email address', + type: 'password', + required: true, + }, + { + id: 'password', + name: 'Password', + description: 'Your PikPak password', + type: 'password', + required: true, + }, + ], + }, + [SEEDR_SERVICE]: { + id: SEEDR_SERVICE, + name: 'Seedr', + shortName: 'SDR', + knownNames: ['SR', 'Seedr', 'SDR'], + signUpText: + "Don't have an account? [Sign up here](https://www.seedr.cc/?r=6542079)", + credentials: [ + { + id: 'apiKey', + name: 'Encoded Token', + description: + 'Please authorise at MediaFusion and copy the token into here.', + type: 'password', + required: true, + }, + ], + }, +}; + +export const DEDUPLICATOR_KEYS = [ + 'filename', + 'infoHash', + 'smartDetect', +] as const; + +const RESOLUTIONS = [ + '2160p', + '1440p', + '1080p', + '720p', + '576p', + '480p', + '360p', + '240p', + '144p', + 'Unknown', +] as const; + +const QUALITIES = [ + 'BluRay REMUX', + 'BluRay', + 'WEB-DL', + 'WEBRip', + 'HDRip', + 'HC HD-Rip', + 'DVDRip', + 'HDTV', + 'CAM', + 'TS', + 'TC', + 'SCR', + 'Unknown', +] as const; + +const VISUAL_TAGS = [ + 'HDR+DV', + 'HDR10+', + 'HDR10', + 'DV', + 'HDR', + '10bit', + '3D', + 'IMAX', + 'AI', + 'SDR', + 'Unknown', +] as const; + +const AUDIO_TAGS = [ + 'Atmos', + 'DD+', + 'DD', + 'DTS-HD MA', + 'DTS-HD', + 'DTS-ES', + 'DTS', + 'TrueHD', + 'OPUS', + 'FLAC', + 'AAC', + 'Unknown', +] as const; + +const AUDIO_CHANNELS = ['2.0', '5.1', '6.1', '7.1', 'Unknown'] as const; + +const ENCODES = [ + 'AV1', + 'HEVC', + 'AVC', + 'XviD', + 'DivX', + 'H-OU', + 'H-SBS', + 'Unknown', +] as const; + +const SORT_CRITERIA = [ + 'quality', + 'resolution', + 'language', + 'visualTag', + 'audioTag', + 'audioChannel', + 'streamType', + 'encode', + 'size', + 'service', + 'seeders', + 'addon', + 'regexPatterns', + 'cached', + 'library', + 'keyword', + 'streamExpressionMatched', +] as const; + +export const MIN_SIZE = 0; +export const MAX_SIZE = 100 * 1000 * 1000 * 1000; // 100GB + +export const MIN_SEEDERS = 0; +export const MAX_SEEDERS = 1000; + +export const DEFAULT_POSTERS = [ + 'aHR0cHM6Ly93d3cucG5nbWFydC5jb20vZmlsZXMvMTEvUmlja3JvbGxpbmctUE5HLVBpYy5wbmc=', +]; + +export const DEFAULT_YT_ID = 'eHZGWmpvNVBnRzA='; + +export const SORT_CRITERIA_DETAILS = { + quality: { + name: 'Quality', + description: 'Sort by the quality of the stream', + defaultDirection: 'desc', + ascendingDescription: + 'Streams that are not in your preferred quality list are preferred', + descendingDescription: + 'Streams that are in your preferred quality list are preferred', + }, + resolution: { + name: 'Resolution', + description: 'Sort by the resolution of the stream', + defaultDirection: 'desc', + ascendingDescription: + 'Streams that are not in your preferred resolution list are preferred', + descendingDescription: + 'Streams that are in your preferred resolution list are preferred', + }, + language: { + name: 'Language', + description: 'Sort by the language of the stream', + defaultDirection: 'desc', + ascendingDescription: + 'Streams that are not in your preferred language list are preferred', + descendingDescription: + 'Streams that are in your preferred language list are preferred', + }, + visualTag: { + name: 'Visual Tag', + description: 'Sort by the visual tags of the stream', + defaultDirection: 'desc', + ascendingDescription: + 'Streams that are not in your preferred visual tag list are preferred', + descendingDescription: + 'Streams that are in your preferred visual tag list are preferred', + }, + audioTag: { + name: 'Audio Tag', + description: 'Sort by the audio tags of the stream', + defaultDirection: 'desc', + ascendingDescription: + 'Streams that are not in your preferred audio tag list are preferred', + descendingDescription: + 'Streams that are in your preferred audio tag list are preferred', + }, + audioChannel: { + name: 'Audio Channel', + description: 'Sort by the audio channels of the stream', + defaultDirection: 'desc', + ascendingDescription: + 'Streams that are not in your preferred audio channel list are preferred', + descendingDescription: + 'Streams that are in your preferred audio channel list are preferred', + }, + streamType: { + name: 'Stream Type', + description: 'Whether the stream is of a preferred stream type', + defaultDirection: 'desc', + ascendingDescription: + 'Streams that are not in your preferred stream type list are preferred', + descendingDescription: + 'Streams that are in your preferred stream type list are preferred', + }, + encode: { + name: 'Encode', + description: 'Whether the stream is of a preferred encode', + defaultDirection: 'desc', + ascendingDescription: + 'Streams that are not in your preferred encode list are preferred', + descendingDescription: + 'Streams that are in your preferred encode list are preferred', + }, + size: { + name: 'Size', + description: 'Sort by the size of the stream', + defaultDirection: 'desc', + ascendingDescription: 'Streams that are smaller are sorted first', + descendingDescription: 'Streams that are larger are sorted first', + }, + service: { + name: 'Service', + description: 'Sort by the service order', + defaultDirection: 'desc', + ascendingDescription: 'Streams without a service are preferred', + descendingDescription: + 'Streams are ordered by the order of your service list, with non-service streams at the bottom', + }, + seeders: { + name: 'Seeders', + description: 'Sort by the number of seeders', + defaultDirection: 'desc', + ascendingDescription: 'Streams with fewer seeders are preferred', + descendingDescription: 'Streams with more seeders are preferred', + }, + addon: { + name: 'Addon', + description: 'Sort by the addon order', + defaultDirection: 'desc', + ascendingDescription: 'Streams are sorted by the order of your addon list', + descendingDescription: 'Streams are sorted by the order of your addon list', + }, + regexPatterns: { + name: 'Regex Patterns', + description: + 'Whether the stream matches any of your preferred regex patterns', + defaultDirection: 'desc', + ascendingDescription: + 'Streams that do not match your preferred regex patterns are preferred', + descendingDescription: + 'Streams that match your preferred regex patterns are preferred', + }, + cached: { + name: 'Cached', + defaultDirection: 'desc', + description: 'Whether the stream is cached or not', + ascendingDescription: 'Streams that are not cached are preferred', + descendingDescription: 'Streams that are cached are preferred', + }, + library: { + name: 'Library', + defaultDirection: 'desc', + description: + 'Whether the stream is in your library (e.g. debrid account) or not', + ascendingDescription: 'Streams that are not in your library are preferred', + descendingDescription: 'Streams that are in your library are preferred', + }, + keyword: { + name: 'Keyword', + defaultDirection: 'desc', + description: 'Sort by the keyword of the stream', + ascendingDescription: + 'Streams that do not match any of your keywords are preferred', + descendingDescription: + 'Streams that match any of your keywords are preferred', + }, + streamExpressionMatched: { + name: 'Stream Expression Matched', + defaultDirection: 'desc', + description: 'Whether the stream matches any of your stream expressions', + ascendingDescription: + 'Streams that do not match your stream expressions are preferred while the ones that do are ranked by the order of your stream expressions', + descendingDescription: + 'Streams that match your stream expressions are preferred and ranked by the order of your stream expressions', + }, +} as const; + +const SORT_DIRECTIONS = ['asc', 'desc'] as const; + +export const P2P_STREAM_TYPE = 'p2p' as const; +export const LIVE_STREAM_TYPE = 'live' as const; +export const USENET_STREAM_TYPE = 'usenet' as const; +export const DEBRID_STREAM_TYPE = 'debrid' as const; +export const HTTP_STREAM_TYPE = 'http' as const; +export const EXTERNAL_STREAM_TYPE = 'external' as const; +export const YOUTUBE_STREAM_TYPE = 'youtube' as const; +export const ERROR_STREAM_TYPE = 'error' as const; + +const STREAM_TYPES = [ + P2P_STREAM_TYPE, + LIVE_STREAM_TYPE, + USENET_STREAM_TYPE, + DEBRID_STREAM_TYPE, + HTTP_STREAM_TYPE, + EXTERNAL_STREAM_TYPE, + YOUTUBE_STREAM_TYPE, + ERROR_STREAM_TYPE, +] as const; + +export type StreamType = (typeof STREAM_TYPES)[number]; + +const STREAM_RESOURCE = 'stream' as const; +const SUBTITLES_RESOURCE = 'subtitles' as const; +const CATALOG_RESOURCE = 'catalog' as const; +const META_RESOURCE = 'meta' as const; +const ADDON_CATALOG_RESOURCE = 'addon_catalog' as const; + +export const MOVIE_TYPE = 'movie' as const; +export const SERIES_TYPE = 'series' as const; +export const CHANNEL_TYPE = 'channel' as const; +export const TV_TYPE = 'tv' as const; +export const ANIME_TYPE = 'anime' as const; + +export const TYPES = [ + MOVIE_TYPE, + SERIES_TYPE, + CHANNEL_TYPE, + TV_TYPE, + ANIME_TYPE, +] as const; + +const RESOURCES = [ + STREAM_RESOURCE, + SUBTITLES_RESOURCE, + CATALOG_RESOURCE, + META_RESOURCE, + ADDON_CATALOG_RESOURCE, +] as const; + +const LANGUAGES = [ + 'English', + 'Japanese', + 'Chinese', + 'Russian', + 'Arabic', + 'Portuguese', + 'Spanish', + 'French', + 'German', + 'Italian', + 'Korean', + 'Hindi', + 'Bengali', + 'Punjabi', + 'Marathi', + 'Gujarati', + 'Tamil', + 'Telugu', + 'Kannada', + 'Malayalam', + 'Thai', + 'Vietnamese', + 'Indonesian', + 'Turkish', + 'Hebrew', + 'Persian', + 'Ukrainian', + 'Greek', + 'Lithuanian', + 'Latvian', + 'Estonian', + 'Polish', + 'Czech', + 'Slovak', + 'Hungarian', + 'Romanian', + 'Bulgarian', + 'Serbian', + 'Croatian', + 'Slovenian', + 'Dutch', + 'Danish', + 'Finnish', + 'Swedish', + 'Norwegian', + 'Malay', + 'Latino', + 'Dual Audio', + 'Dubbed', + 'Multi', + 'Unknown', +] as const; + +export const SNIPPETS = [ + { + name: 'Year + Season + Episode', + description: + 'Outputs a nicely formatted year along with the season and episode number', + value: + '{stream.year::exists["({stream.year}) "||""]}{stream.seasonEpisode::exists["{stream.seasonEpisode::join(\' • \')}"||""]}', + }, + { + name: 'File Size', + description: 'Outputs the file size of the stream', + value: '{stream.size::>0["{stream.size::bytes}"||""]}', + }, + { + name: 'Duration', + description: 'Outputs the duration of the stream', + value: '{stream.duration::>0["{stream.duration::time}"||""]}', + }, + { + name: 'P2P marker', + description: 'Displays a [P2P] marker if the stream is a P2P stream', + value: '{stream.type::=p2p["[P2P]"||""]}', + }, + { + name: 'Languages', + description: + 'Outputs the languages of the stream. Tip: use stream.languageEmojis if you prefer the flags', + value: + '{stream.languages::exists["{stream.languages::join(\' • \')}"||""]}', + }, +]; + +export { + API_VERSION, + SERVICES, + RESOLUTIONS, + QUALITIES, + VISUAL_TAGS, + AUDIO_TAGS, + AUDIO_CHANNELS, + ENCODES, + SORT_CRITERIA, + SORT_DIRECTIONS, + STREAM_TYPES, + LANGUAGES, + RESOURCES, + STREAM_RESOURCE, + SUBTITLES_RESOURCE, + CATALOG_RESOURCE, + META_RESOURCE, + ADDON_CATALOG_RESOURCE, + REALDEBRID_SERVICE, + PREMIUMIZE_SERVICE, + ALLEDEBRID_SERVICE, + DEBRIDLINK_SERVICE, + TORBOX_SERVICE, + EASYDEBRID_SERVICE, + PUTIO_SERVICE, + PIKPAK_SERVICE, + OFFCLOUD_SERVICE, + SEEDR_SERVICE, + EASYNEWS_SERVICE, + SERVICE_DETAILS, + HEADERS_FOR_IP_FORWARDING, +}; diff --git a/packages/core/src/utils/crypto.ts b/packages/core/src/utils/crypto.ts new file mode 100644 index 0000000000000000000000000000000000000000..4364a61e372837002bb08931e5d990739d5529f8 --- /dev/null +++ b/packages/core/src/utils/crypto.ts @@ -0,0 +1,217 @@ +import { + randomBytes, + createCipheriv, + createDecipheriv, + createHash, + pbkdf2Sync, + randomUUID, +} from 'crypto'; +import { genSalt, hash, compare } from 'bcrypt'; +import { deflateSync, inflateSync } from 'zlib'; +import { Env } from './env'; +import { createLogger } from './logger'; + +const logger = createLogger('crypto'); + +const saltRounds = 10; + +function base64UrlSafe(data: string): string { + return Buffer.from(data) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +function fromUrlSafeBase64(data: string): string { + // Add padding if needed + const padding = data.length % 4; + const paddedData = padding ? data + '='.repeat(4 - padding) : data; + + return Buffer.from( + paddedData.replace(/-/g, '+').replace(/_/g, '/'), + 'base64' + ).toString('utf-8'); +} + +const compressData = (data: string): Buffer => { + return deflateSync(Buffer.from(data, 'utf-8'), { + level: 9, + }); +}; + +const decompressData = (data: Buffer): string => { + return inflateSync(data).toString('utf-8'); +}; + +const encryptData = ( + secretKey: Buffer, + data: Buffer +): { iv: string; data: string } => { + // Then encrypt the compressed data + const iv = randomBytes(16); + const cipher = createCipheriv('aes-256-cbc', secretKey, iv); + + const encryptedData = Buffer.concat([cipher.update(data), cipher.final()]); + + return { + iv: iv.toString('base64'), + data: encryptedData.toString('base64'), + }; +}; + +const decryptData = ( + secretKey: Buffer, + encryptedData: Buffer, + iv: Buffer +): Buffer => { + const decipher = createDecipheriv('aes-256-cbc', secretKey, iv); + + // Decrypt the data + const decryptedData = Buffer.concat([ + decipher.update(encryptedData), + decipher.final(), + ]); + + return decryptedData; +}; + +type SuccessResponse = { + success: true; + data: string; + error: null; +}; + +type ErrorResponse = { + success: false; + error: string; + data: null; +}; + +export type Response = SuccessResponse | ErrorResponse; + +export function isEncrypted(data: string): boolean { + try { + // parse the data as json + const json = JSON.parse(fromUrlSafeBase64(data)); + return json.type === 'aioEncrypt'; + } catch (error) { + return false; + } +} + +/** + * Encrypts a string using AES-256-CBC encryption, returns a string in the format "iv:encrypted" where + * iv and encrypted are url encoded. + * @param data Data to encrypt + * @param secretKey Secret key used for encryption + * @returns Encrypted data or error message + */ +export function encryptString(data: string, secretKey?: Buffer): Response { + if (!secretKey) { + secretKey = Buffer.from(Env.SECRET_KEY, 'hex'); + } + try { + const compressed = compressData(data); + const { iv, data: encrypted } = encryptData(secretKey, compressed); + return { + success: true, + data: base64UrlSafe( + JSON.stringify({ iv, encrypted, type: 'aioEncrypt' }) + ), + error: null, + }; + } catch (error: any) { + logger.error(`Failed to encrypt data: ${error.message}`); + return { + success: false, + error: error.message, + data: null, + }; + } +} + +/** + * Decrypts a string using AES-256-CBC encryption + * @param data Encrypted data to decrypt + * @param secretKey Secret key used for encryption + * @returns Decrypted data or error message + */ +export function decryptString(data: string, secretKey?: Buffer): Response { + if (!secretKey) { + secretKey = Buffer.from(Env.SECRET_KEY, 'hex'); + } + try { + if (!isEncrypted(data)) { + throw new Error('The data was not in an expected encrypted format'); + } + const json = JSON.parse(fromUrlSafeBase64(data)); + const iv = Buffer.from(json.iv, 'base64'); + const encrypted = Buffer.from(json.encrypted, 'base64'); + const decrypted = decryptData(secretKey, encrypted, iv); + const decompressed = decompressData(decrypted); + return { + success: true, + data: decompressed, + error: null, + }; + } catch (error: any) { + logger.error(`Failed to decrypt data: ${error.message}`); + return { + success: false, + error: error.message, + data: null, + }; + } +} + +export function getSimpleTextHash(text: string): string { + return createHash('sha256').update(text).digest('hex'); +} + +/** + * Creates a secure hash of text using PBKDF2 + * @param text Text to hash + * @returns Object containing the hash and salt used + */ +export async function getTextHash(text: string): Promise { + return await hash(text, await genSalt(saltRounds)); +} + +/** + * Verifies if the provided text matches a previously generated hash + * @param text Text to verify + * @param storedHash Previously generated hash + * @returns Boolean indicating if the text matches the hash + */ +export async function verifyHash( + text: string, + storedHash: string +): Promise { + return compare(text, storedHash); +} + +/** + * Derives a 64 character hex string from a password using PBKDF2 + * @param password Password to derive key from + * @param salt Optional salt, will be generated if not provided + * @returns Object containing the key and salt used + */ +export async function deriveKey( + password: string, + salt?: string +): Promise<{ key: Buffer; salt: string }> { + salt = salt || (await genSalt(saltRounds)); + const key = pbkdf2Sync( + Buffer.from(password, 'utf-8'), + Buffer.from(salt, 'hex'), + 100000, + 32, + 'sha512' + ); + return { key, salt }; +} + +export function generateUUID(): string { + return randomUUID(); +} diff --git a/packages/core/src/utils/dsu.ts b/packages/core/src/utils/dsu.ts new file mode 100644 index 0000000000000000000000000000000000000000..d8ef60de03b50f7946ade21a09e3fc8abd885b6d --- /dev/null +++ b/packages/core/src/utils/dsu.ts @@ -0,0 +1,46 @@ +export class DSU { + private parent: Map; + private rank: Map; + + constructor() { + this.parent = new Map(); + this.rank = new Map(); + } + + makeSet(id: IDType): void { + if (!this.parent.has(id)) { + this.parent.set(id, id); + this.rank.set(id, 0); + } + } + + find(id: IDType): IDType { + if (!this.parent.has(id)) { + this.makeSet(id); // Ensure set exists if find is called before makeSet + return id; + } + if (this.parent.get(id) === id) { + return id; + } + const root = this.find(this.parent.get(id)!); + this.parent.set(id, root); + return root; + } + + union(id1: IDType, id2: IDType): void { + const root1 = this.find(id1); + const root2 = this.find(id2); + if (root1 !== root2) { + const rank1 = this.rank.get(root1)!; + const rank2 = this.rank.get(root2)!; + if (rank1 < rank2) { + this.parent.set(root1, root2); + } else if (rank1 > rank2) { + this.parent.set(root2, root1); + } else { + this.parent.set(root2, root1); + this.rank.set(root1, rank1 + 1); + } + } + } +} diff --git a/packages/core/src/utils/env.ts b/packages/core/src/utils/env.ts new file mode 100644 index 0000000000000000000000000000000000000000..59918f52c8009a96e39c0cc5b182d84f05cb0f9b --- /dev/null +++ b/packages/core/src/utils/env.ts @@ -0,0 +1,1235 @@ +import dotenv from 'dotenv'; +import path from 'path'; +import { + cleanEnv, + str, + host, + bool, + json, + makeValidator, + num, + EnvError, + port, +} from 'envalid'; +import { ResourceManager } from './resources'; +import * as constants from './constants'; +try { + dotenv.config({ path: path.resolve(__dirname, '../../../../.env') }); +} catch (error) { + console.error('Error loading .env file', error); +} + +let metadata: any = undefined; +try { + metadata = ResourceManager.getResource('metadata.json') || {}; +} catch (error) { + console.error('Error loading metadata.json file', error); +} + +const secretKey = makeValidator((x) => { + if (!/^[0-9a-fA-F]{64}$/.test(x)) { + throw new EnvError('Secret key must be a 64-character hex string'); + } + return x; +}); + +const regexes = makeValidator((x) => { + // json array of string + const parsed = JSON.parse(x); + if (!Array.isArray(parsed)) { + throw new EnvError('Regexes must be an array'); + } + // each element must be a string + parsed.forEach((x) => { + if (typeof x !== 'string') { + throw new EnvError('Regexes must be an array of strings'); + } + try { + new RegExp(x); + } catch (e) { + throw new EnvError(`Invalid regex pattern: ${x}`); + } + }); + return parsed; +}); + +const namedRegexes = makeValidator((x) => { + // array of objects with properties name and pattern + const parsed = JSON.parse(x); + if (!Array.isArray(parsed)) { + throw new EnvError('Named regexes must be an array'); + } + // each element must be an object with properties name and pattern + parsed.forEach((x) => { + if (typeof x !== 'object' || !x.name || !x.pattern) { + throw new EnvError( + 'Named regexes must be an array of objects with properties name and pattern' + ); + } + try { + new RegExp(x.pattern); + } catch (e) { + throw new EnvError(`Invalid regex pattern: ${x.pattern}`); + } + }); + + return parsed; +}); + +const url = makeValidator((x) => { + if (x === '') { + return x; + } + try { + new URL(x); + } catch (e) { + throw new EnvError(`Invalid URL: ${x}`); + } + // remove trailing slash + return x.endsWith('/') ? x.slice(0, -1) : x; +}); + +export const forcedPort = makeValidator((input: string) => { + if (input === '') { + return ''; + } + + const coerced = +input; + if ( + Number.isNaN(coerced) || + `${coerced}` !== `${input}` || + coerced % 1 !== 0 || + coerced < 1 || + coerced > 65535 + ) { + throw new EnvError(`Invalid port input: "${input}"`); + } + return coerced.toString(); +}); + +const userAgent = makeValidator((x) => { + if (typeof x !== 'string') { + throw new Error('User agent must be a string'); + } + // replace {version} with the version of the addon + return x.replace(/{version}/g, metadata?.version || 'unknown'); +}); + +// comma separated list of alias:uuid +const aliasedUUIDs = makeValidator((x) => { + try { + const aliases: Record = {}; + const parsed = x.split(',').map((x) => { + const [alias, uuid, password] = x.split(':'); + if (!alias || !uuid || !password) { + throw new Error('Invalid alias:uuid:password pair'); + } else if ( + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test( + uuid + ) === false + ) { + throw new Error('Invalid UUID'); + } + aliases[alias] = { uuid, password }; + }); + return aliases; + } catch (e) { + throw new Error( + `Custom configs must be a valid comma separated list of alias:uuid:password pairs` + ); + } +}); + +const readonly = makeValidator((x) => { + if (x) { + throw new EnvError('Readonly environment variable, cannot be set'); + } + return x; +}); + +export const Env = cleanEnv(process.env, { + VERSION: readonly({ + default: metadata?.version || 'unknown', + desc: 'Version of the addon', + }), + TAG: readonly({ + default: metadata?.tag || 'unknown', + desc: 'Tag of the addon', + }), + DESCRIPTION: readonly({ + default: metadata?.description || 'unknown', + desc: 'Description of the addon', + }), + NODE_ENV: str({ + default: 'production', + desc: 'Node environment of the addon', + choices: ['production', 'development', 'test'], + }), + GIT_COMMIT: readonly({ + default: metadata?.commitHash || 'unknown', + desc: 'Git commit hash of the addon', + }), + BUILD_TIME: readonly({ + default: metadata?.buildTime || 'unknown', + desc: 'Build time of the addon', + }), + BUILD_COMMIT_TIME: readonly({ + default: metadata?.commitTime || 'unknown', + desc: 'Build commit time of the addon', + }), + DISABLE_SELF_SCRAPING: bool({ + default: true, + desc: 'Disable self scraping. If true, addons will not be able to scrape the same AIOStreams instance.', + }), + DISABLED_HOSTS: str({ + default: undefined, + desc: 'Comma separated list of disabled hosts in format of host:reason', + }), + DISABLED_ADDONS: str({ + default: undefined, + desc: 'Comma separated list of disabled addons in format of addon:reason', + }), + DISABLED_SERVICES: str({ + default: undefined, + desc: 'Comma separated list of disabled services in format of service:reason', + }), + REGEX_FILTER_ACCESS: str({ + default: 'trusted', + desc: 'Who can use regex filters', + choices: ['none', 'trusted', 'all'], + }), + BASE_URL: url({ + desc: 'Base URL of the addon e.g. https://aiostreams.com', + default: undefined, + devDefault: 'http://localhost:3000', + }), + ADDON_NAME: str({ + default: 'AIOStreams', + desc: 'Name of the addon', + }), + ADDON_ID: str({ + default: 'com.aiostreams.viren070', + desc: 'ID of the addon', + }), + PORT: port({ + default: 3000, + desc: 'Port to run the addon on', + }), + CUSTOM_HTML: str({ + default: undefined, + desc: 'Custom HTML for the addon', + }), + SECRET_KEY: secretKey({ + desc: 'Secret key for the addon, used for encryption and must be 64 characters of hex', + example: 'Generate using: openssl rand -hex 32', + }), + ADDON_PASSWORD: str({ + default: + typeof process.env.API_KEY === 'string' ? process.env.API_KEY : undefined, + desc: 'Password required to create and modify addon configurations', + }), + DATABASE_URI: str({ + default: 'sqlite://./data/db.sqlite', + desc: 'Database URI for the addon', + }), + ADDON_PROXY: url({ + default: undefined, + desc: 'Proxy URL for the addon', + }), + ADDON_PROXY_CONFIG: str({ + default: undefined, + desc: 'Proxy config for the addon in format of comma separated hostname:boolean', + }), + ALIASED_CONFIGURATIONS: aliasedUUIDs({ + default: {}, + desc: 'Comma separated list of alias:uuid:encryptedPassword pairs. Can then access at /stremio/u/alias/manifest.json ', + }), + TRUSTED_UUIDS: str({ + default: undefined, + desc: 'Comma separated list of trusted UUIDs. Trusted UUIDs can currently use regex filters if.', + }), + TMDB_ACCESS_TOKEN: str({ + default: undefined, + desc: 'TMDB Read Access Token. Used for fetching metadata for the strict title matching option.', + }), + + // logging settings + LOG_SENSITIVE_INFO: bool({ + default: false, + desc: 'Log sensitive information', + }), + LOG_LEVEL: str({ + default: 'info', + desc: 'Log level for the addon', + choices: ['info', 'debug', 'warn', 'error', 'verbose', 'silly', 'http'], + }), + LOG_FORMAT: str({ + default: 'text', + desc: 'Log format for the addon', + choices: ['text', 'json'], + }), + LOG_TIMEZONE: str({ + default: 'UTC', + desc: 'Timezone for log timestamps (e.g., America/New_York, Europe/London)', + }), + LOG_CACHE_STATS_INTERVAL: num({ + default: 30, + desc: 'Interval for logging cache stats in minutes (verbose level only)', + }), + + STREMIO_ADDONS_CONFIG_ISSUER: url({ + default: 'https://stremio-addons.net', + desc: 'Issuer for the Stremio addons config', + }), + STREMIO_ADDONS_CONFIG_SIGNATURE: str({ + default: undefined, + desc: 'Signature for the Stremio addons config', + }), + + PRUNE_INTERVAL: num({ + default: 86400, // 24 hours + desc: 'Interval for pruning inactive users in seconds', + }), + PRUNE_MAX_DAYS: num({ + default: -1, + desc: 'Maximum days of inactivity before pruning, set to -1 to disable', + }), + + EXPOSE_USER_COUNT: bool({ + default: false, + desc: 'Expose the number of users through the status endpoint', + }), + + RECURSION_THRESHOLD_LIMIT: num({ + default: 60, + desc: 'Maximum number of requests to the same URL', + }), + RECURSION_THRESHOLD_WINDOW: num({ + default: 10, + desc: 'Time window for recursion threshold in seconds', + }), + + DEFAULT_USER_AGENT: userAgent({ + default: `AIOStreams/${metadata?.version || 'unknown'}`, + desc: 'Default user agent for the addon', + }), + + DEFAULT_MAX_CACHE_SIZE: num({ + default: 100000, + desc: 'Default max cache size for a cache instance', + }), + PROXY_IP_CACHE_TTL: num({ + default: 900, + desc: 'Cache TTL for proxy IPs', + }), + MANIFEST_CACHE_TTL: num({ + default: 300, + desc: 'Cache TTL for manifest files', + }), + SUBTITLE_CACHE_TTL: num({ + default: 300, + desc: 'Cache TTL for subtitle files', + }), + STREAM_CACHE_TTL: num({ + default: -1, + desc: 'Cache TTL for stream files. If -1, no caching will be done.', + }), + CATALOG_CACHE_TTL: num({ + default: 300, + desc: 'Cache TTL for catalog files', + }), + META_CACHE_TTL: num({ + default: 300, + }), + ADDON_CATALOG_CACHE_TTL: num({ + default: 300, + desc: 'Cache TTL for addon catalog files', + }), + RPDB_API_KEY_VALIDITY_CACHE_TTL: num({ + default: 604800, // 7 days + desc: 'Cache TTL for RPDB API key validity', + }), + // configuration settings + + MAX_ADDONS: num({ + default: 15, + desc: 'Max number of addons', + }), + // TODO + MAX_KEYWORD_FILTERS: num({ + default: 30, + desc: 'Max number of keyword filters', + }), + MAX_CONDITION_FILTERS: num({ + default: 30, + desc: 'Max number of condition filters', + }), + MAX_GROUPS: num({ + default: 20, + desc: 'Max number of groups', + }), + + MAX_TIMEOUT: num({ + default: 50000, + desc: 'Max timeout for the addon', + }), + MIN_TIMEOUT: num({ + default: 1000, + desc: 'Min timeout for the addon', + }), + + DEFAULT_TIMEOUT: num({ + default: 15000, + desc: 'Default timeout for the addon', + }), + + FORCE_PUBLIC_PROXY_HOST: host({ + default: undefined, + desc: 'Force public proxy host', + }), + FORCE_PUBLIC_PROXY_PORT: forcedPort({ + default: undefined, + desc: 'Force public proxy port', + }), + FORCE_PUBLIC_PROXY_PROTOCOL: str({ + default: undefined, + desc: 'Force public proxy protocol', + choices: ['http', 'https'], + }), + + FORCE_PROXY_ENABLED: bool({ + default: undefined, + desc: 'Force proxy enabled', + }), + FORCE_PROXY_ID: str({ + default: undefined, + desc: 'Force proxy id', + choices: constants.PROXY_SERVICES, + }), + FORCE_PROXY_URL: url({ + default: undefined, + desc: 'Force proxy url', + }), + FORCE_PROXY_CREDENTIALS: str({ + default: undefined, + desc: 'Force proxy credentials', + }), + FORCE_PROXY_PUBLIC_IP: str({ + default: undefined, + desc: 'Force proxy public ip', + }), + FORCE_PROXY_DISABLE_PROXIED_ADDONS: bool({ + default: false, + desc: 'Force proxy disable proxied addons', + }), + FORCE_PROXY_PROXIED_SERVICES: json({ + default: undefined, + desc: 'Force proxy proxied services', + }), + + DEFAULT_PROXY_ENABLED: bool({ + default: undefined, + desc: 'Default proxy enabled', + }), + DEFAULT_PROXY_ID: str({ + default: undefined, + desc: 'Default proxy id', + }), + DEFAULT_PROXY_URL: url({ + default: undefined, + desc: 'Default proxy url', + }), + DEFAULT_PROXY_CREDENTIALS: str({ + default: undefined, + desc: 'Default proxy credentials', + }), + DEFAULT_PROXY_PUBLIC_IP: str({ + default: undefined, + desc: 'Default proxy public ip', + }), + DEFAULT_PROXY_PROXIED_SERVICES: json({ + default: undefined, + desc: 'Default proxy proxied services', + }), + + ENCRYPT_MEDIAFLOW_URLS: bool({ + default: true, + desc: 'Encrypt MediaFlow URLs', + }), + + ENCRYPT_STREMTHRU_URLS: bool({ + default: true, + desc: 'Encrypt StremThru URLs', + }), + + // service settings + DEFAULT_REALDEBRID_API_KEY: str({ + default: undefined, + desc: 'Default RealDebrid API key', + }), + DEFAULT_ALLDEBRID_API_KEY: str({ + default: undefined, + desc: 'Default AllDebrid API key', + }), + DEFAULT_PREMIUMIZE_API_KEY: str({ + default: undefined, + desc: 'Default Premiumize API key', + }), + DEFAULT_DEBRIDLINK_API_KEY: str({ + default: undefined, + desc: 'Default DebridLink API key', + }), + DEFAULT_TORBOX_API_KEY: str({ + default: undefined, + desc: 'Default Torbox API key', + }), + DEFAULT_OFFCLOUD_API_KEY: str({ + default: undefined, + desc: 'Default OffCloud API key', + }), + DEFAULT_OFFCLOUD_EMAIL: str({ + default: undefined, + desc: 'Default OffCloud email', + }), + DEFAULT_OFFCLOUD_PASSWORD: str({ + default: undefined, + desc: 'Default OffCloud password', + }), + DEFAULT_PUTIO_CLIENT_ID: str({ + default: undefined, + desc: 'Default Putio client id', + }), + DEFAULT_PUTIO_CLIENT_SECRET: str({ + default: undefined, + desc: 'Default Putio client secret', + }), + DEFAULT_EASYNEWS_USERNAME: str({ + default: undefined, + desc: 'Default EasyNews username', + }), + DEFAULT_EASYNEWS_PASSWORD: str({ + default: undefined, + desc: 'Default EasyNews password', + }), + DEFAULT_EASYDEBRID_API_KEY: str({ + default: undefined, + desc: 'Default EasyDebrid API key', + }), + DEFAULT_PIKPAK_EMAIL: str({ + default: undefined, + desc: 'Default PikPak email', + }), + DEFAULT_PIKPAK_PASSWORD: str({ + default: undefined, + desc: 'Default PikPak password', + }), + DEFAULT_SEEDR_ENCODED_TOKEN: str({ + default: undefined, + desc: 'Default Seedr encoded token', + }), + + // forced services + FORCED_REALDEBRID_API_KEY: str({ + default: undefined, + desc: 'Forced RealDebrid API key', + }), + FORCED_ALLDEBRID_API_KEY: str({ + default: undefined, + desc: 'Forced AllDebrid API key', + }), + FORCED_PREMIUMIZE_API_KEY: str({ + default: undefined, + desc: 'Forced Premiumize API key', + }), + FORCED_DEBRIDLINK_API_KEY: str({ + default: undefined, + desc: 'Forced DebridLink API key', + }), + FORCED_TORBOX_API_KEY: str({ + default: undefined, + desc: 'Forced Torbox API key', + }), + FORCED_OFFCLOUD_API_KEY: str({ + default: undefined, + desc: 'Forced OffCloud API key', + }), + FORCED_OFFCLOUD_EMAIL: str({ + default: undefined, + desc: 'Forced OffCloud email', + }), + FORCED_OFFCLOUD_PASSWORD: str({ + default: undefined, + desc: 'Forced OffCloud password', + }), + FORCED_PUTIO_CLIENT_ID: str({ + default: undefined, + desc: 'Forced Putio client id', + }), + FORCED_PUTIO_CLIENT_SECRET: str({ + default: undefined, + desc: 'Forced Putio client secret', + }), + FORCED_EASYNEWS_USERNAME: str({ + default: undefined, + desc: 'Forced EasyNews username', + }), + FORCED_EASYNEWS_PASSWORD: str({ + default: undefined, + desc: 'Forced EasyNews password', + }), + FORCED_EASYDEBRID_API_KEY: str({ + default: undefined, + desc: 'Forced EasyDebrid API key', + }), + FORCED_PIKPAK_EMAIL: str({ + default: undefined, + desc: 'Forced PikPak email', + }), + FORCED_PIKPAK_PASSWORD: str({ + default: undefined, + desc: 'Forced PikPak password', + }), + FORCED_SEEDR_ENCODED_TOKEN: str({ + default: undefined, + desc: 'Forced Seedr encoded token', + }), + + COMET_URL: url({ + default: 'https://comet.elfhosted.com', + desc: 'Comet URL', + }), + FORCE_COMET_HOSTNAME: host({ + default: undefined, + desc: 'Force Comet hostname', + }), + FORCE_COMET_PORT: forcedPort({ + default: undefined, + desc: 'Force Comet port', + }), + FORCE_COMET_PROTOCOL: str({ + default: undefined, + desc: 'Force Comet protocol', + choices: ['http', 'https'], + }), + DEFAULT_COMET_TIMEOUT: num({ + default: undefined, + desc: 'Default Comet timeout', + }), + DEFAULT_COMET_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default Comet user agent', + }), + + // MediaFusion settings + MEDIAFUSION_URL: url({ + default: 'https://mediafusion.elfhosted.com', + desc: 'MediaFusion URL', + }), + MEDIAFUSION_API_PASSWORD: str({ + default: '', + desc: 'MediaFusion API password', + }), + MEDIAFUSION_DEFAULT_USE_CACHED_RESULTS_ONLY: bool({ + default: true, + desc: 'Default MediaFusion use cached results only', + }), + MEDIAFUSION_FORCED_USE_CACHED_RESULTS_ONLY: bool({ + default: undefined, + desc: 'Force MediaFusion use cached results only', + }), + DEFAULT_MEDIAFUSION_TIMEOUT: num({ + default: undefined, + desc: 'Default MediaFusion timeout', + }), + DEFAULT_MEDIAFUSION_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default MediaFusion user agent', + }), + + // Jackettio settings + JACKETTIO_URL: url({ + default: 'https://jackettio.elfhosted.com', + desc: 'Jackettio URL', + }), + DEFAULT_JACKETTIO_INDEXERS: json({ + default: ['eztv', 'thepiratebay', 'therarbg', 'yts'], + desc: 'Default Jackettio indexers', + }), + DEFAULT_JACKETTIO_STREMTHRU_URL: url({ + default: 'https://stremthru.13377001.xyz', + desc: 'Default Jackettio StremThru URL', + }), + DEFAULT_JACKETTIO_TIMEOUT: num({ + default: undefined, + desc: 'Default Jackettio timeout', + }), + FORCE_JACKETTIO_HOSTNAME: host({ + default: undefined, + desc: 'Force Jackettio hostname', + }), + FORCE_JACKETTIO_PORT: forcedPort({ + default: undefined, + desc: 'Force Jackettio port', + }), + FORCE_JACKETTIO_PROTOCOL: str({ + default: undefined, + desc: 'Force Jackettio protocol', + choices: ['http', 'https'], + }), + DEFAULT_JACKETTIO_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default Jackettio user agent', + }), + + // Torrentio settings + TORRENTIO_URL: url({ + default: 'https://torrentio.strem.fun', + desc: 'Torrentio URL', + }), + DEFAULT_TORRENTIO_TIMEOUT: num({ + default: undefined, + desc: 'Default Torrentio timeout', + }), + DEFAULT_TORRENTIO_USER_AGENT: userAgent({ + default: 'Stremio', + desc: 'Default Torrentio user agent', + }), + + // Orion settings + ORION_STREMIO_ADDON_URL: url({ + default: 'https://5a0d1888fa64-orion.baby-beamup.club', + desc: 'Orion Stremio addon URL', + }), + DEFAULT_ORION_TIMEOUT: num({ + default: undefined, + desc: 'Default Orion timeout', + }), + DEFAULT_ORION_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default Orion user agent', + }), + + // Peerflix settings + PEERFLIX_URL: url({ + default: 'https://peerflix-addon.onrender.com', + desc: 'Peerflix URL', + }), + DEFAULT_PEERFLIX_TIMEOUT: num({ + default: undefined, + desc: 'Default Peerflix timeout', + }), + DEFAULT_PEERFLIX_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default Peerflix user agent', + }), + + // Torbox settings + TORBOX_STREMIO_URL: url({ + default: 'https://stremio.torbox.app', + desc: 'Torbox Stremio URL', + }), + DEFAULT_TORBOX_TIMEOUT: num({ + default: undefined, + desc: 'Default Torbox timeout', + }), + DEFAULT_TORBOX_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default Torbox user agent', + }), + + // Easynews settings + EASYNEWS_URL: url({ + default: 'https://ea627ddf0ee7-easynews.baby-beamup.club', + desc: 'Easynews URL', + }), + DEFAULT_EASYNEWS_TIMEOUT: num({ + default: undefined, + desc: 'Default Easynews timeout', + }), + DEFAULT_EASYNEWS_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default Easynews user agent', + }), + + // Easynews+ settings + EASYNEWS_PLUS_URL: url({ + default: 'https://b89262c192b0-stremio-easynews-addon.baby-beamup.club', + desc: 'Easynews+ URL', + }), + DEFAULT_EASYNEWS_PLUS_TIMEOUT: num({ + default: undefined, + desc: 'Default Easynews+ timeout', + }), + DEFAULT_EASYNEWS_PLUS_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default Easynews+ user agent', + }), + + // Easynews++ settings + EASYNEWS_PLUS_PLUS_URL: url({ + default: 'https://easynews-cloudflare-worker.jqrw92fchz.workers.dev', + desc: 'Easynews++ URL', + }), + EASYNEWS_PLUS_PLUS_PUBLIC_URL: url({ + default: undefined, + desc: 'Easynews++ public URL', + }), + DEFAULT_EASYNEWS_PLUS_PLUS_TIMEOUT: num({ + default: undefined, + desc: 'Default Easynews++ timeout', + }), + DEFAULT_EASYNEWS_PLUS_PLUS_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default Easynews++ user agent', + }), + + // Debridio Settings + DEBRIDIO_URL: url({ + default: 'https://addon.debridio.com', + desc: 'Debridio URL', + }), + DEFAULT_DEBRIDIO_TIMEOUT: num({ + default: undefined, + desc: 'Default Debridio timeout', + }), + DEFAULT_DEBRIDIO_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default Debridio user agent', + }), + + DEBRIDIO_TVDB_URL: url({ + default: 'https://tvdb-addon.debridio.com', + desc: 'Debridio TVDB URL', + }), + DEFAULT_DEBRIDIO_TVDB_TIMEOUT: num({ + default: undefined, + desc: 'Default Debridio TVDB timeout', + }), + DEFAULT_DEBRIDIO_TVDB_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default Debridio TVDB user agent', + }), + + DEBRIDIO_TMDB_URL: url({ + default: 'https://tmdb-addon.debridio.com', + desc: 'Debridio TMDB URL', + }), + DEFAULT_DEBRIDIO_TMDB_TIMEOUT: num({ + default: undefined, + desc: 'Default Debridio TMDB timeout', + }), + DEFAULT_DEBRIDIO_TMDB_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default Debridio TMDB user agent', + }), + + DEBRIDIO_TV_URL: url({ + default: 'https://tv-addon.debridio.com', + desc: 'Debridio TV URL', + }), + DEFAULT_DEBRIDIO_TV_TIMEOUT: num({ + default: undefined, + desc: 'Default Debridio TV timeout', + }), + DEFAULT_DEBRIDIO_TV_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default Debridio TV user agent', + }), + + DEBRIDIO_WATCHTOWER_URL: url({ + default: 'https://wt-addon.debridio.com', + desc: 'Debridio Watchtower URL', + }), + DEFAULT_DEBRIDIO_WATCHTOWER_TIMEOUT: num({ + default: undefined, + desc: 'Default Debridio Watchtower timeout', + }), + DEFAULT_DEBRIDIO_WATCHTOWER_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default Debridio Watchtower user agent', + }), + + // StremThru Store settings + STREMTHRU_STORE_URL: url({ + default: 'https://stremthru.elfhosted.com/stremio/store', + desc: 'StremThru Store URL', + }), + DEFAULT_STREMTHRU_STORE_TIMEOUT: num({ + default: undefined, + desc: 'Default StremThru Store timeout', + }), + DEFAULT_STREMTHRU_STORE_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default StremThru Store user agent', + }), + FORCE_STREMTHRU_STORE_HOSTNAME: host({ + default: undefined, + desc: 'Force StremThru Store hostname', + }), + FORCE_STREMTHRU_STORE_PORT: forcedPort({ + default: undefined, + desc: 'Force StremThru Store port', + }), + FORCE_STREMTHRU_STORE_PROTOCOL: str({ + default: undefined, + desc: 'Force StremThru Store protocol', + choices: ['http', 'https'], + }), + + // StremThru Torz settings + STREMTHRU_TORZ_URL: url({ + default: 'https://stremthru.elfhosted.com/stremio/torz', + desc: 'StremThru Torz URL', + }), + DEFAULT_STREMTHRU_TORZ_TIMEOUT: num({ + default: undefined, + desc: 'Default StremThru Torz timeout', + }), + DEFAULT_STREMTHRU_TORZ_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default StremThru Torz user agent', + }), + FORCE_STREMTHRU_TORZ_HOSTNAME: host({ + default: undefined, + desc: 'Force StremThru Torz hostname', + }), + FORCE_STREMTHRU_TORZ_PORT: forcedPort({ + default: undefined, + desc: 'Force StremThru Torz port', + }), + FORCE_STREMTHRU_TORZ_PROTOCOL: str({ + default: undefined, + desc: 'Force StremThru Torz protocol', + choices: ['http', 'https'], + }), + + DEFAULT_STREAMFUSION_URL: url({ + default: 'https://stream-fusion.stremiofr.com', + desc: 'Default StreamFusion URL', + }), + DEFAULT_STREAMFUSION_TIMEOUT: num({ + default: undefined, + desc: 'Default StreamFusion timeout', + }), + DEFAULT_STREAMFUSION_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default StreamFusion user agent', + }), + DEFAULT_STREAMFUSION_STREMTHRU_URL: url({ + default: 'https://stremthru.13377001.xyz', + desc: 'Default StreamFusion StremThru URL', + }), + + // DMM Cast settings + DEFAULT_DMM_CAST_TIMEOUT: num({ + default: undefined, + desc: 'Default DMM Cast timeout', + }), + DEFAULT_DMM_CAST_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default DMM Cast user agent', + }), + + OPENSUBTITLES_URL: url({ + default: 'https://opensubtitles-v3.strem.io', + desc: 'The base URL of the OpenSubtitles stremio addon', + }), + DEFAULT_OPENSUBTITLES_TIMEOUT: num({ + default: undefined, + desc: 'Default OpenSubtitles timeout', + }), + DEFAULT_OPENSUBTITLES_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default OpenSubtitles user agent', + }), + + MARVEL_UNIVERSE_URL: url({ + default: 'https://addon-marvel.onrender.com', + desc: 'Default Marvel catalog URL', + }), + DEFAULT_MARVEL_CATALOG_TIMEOUT: num({ + default: undefined, + desc: 'Default Marvel timeout', + }), + DEFAULT_MARVEL_CATALOG_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default Marvel user agent', + }), + + DC_UNIVERSE_URL: url({ + default: 'https://addon-dc-cq85.onrender.com', + desc: 'Default DC Universe catalog URL', + }), + DEFAULT_DC_UNIVERSE_TIMEOUT: num({ + default: undefined, + desc: 'Default DC Universe timeout', + }), + DEFAULT_DC_UNIVERSE_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default DC Universe user agent', + }), + + DEFAULT_STAR_WARS_UNIVERSE_URL: url({ + default: 'https://addon-star-wars-u9e3.onrender.com', + desc: 'Default Star Wars Universe catalog URL', + }), + DEFAULT_STAR_WARS_UNIVERSE_TIMEOUT: num({ + default: undefined, + desc: 'Default Star Wars Universe timeout', + }), + DEFAULT_STAR_WARS_UNIVERSE_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default Star Wars Universe user agent', + }), + + ANIME_KITSU_URL: url({ + default: 'https://anime-kitsu.strem.fun', + desc: 'Anime Kitsu URL', + }), + DEFAULT_ANIME_KITSU_TIMEOUT: num({ + default: undefined, + desc: 'Default Anime Kitsu timeout', + }), + DEFAULT_ANIME_KITSU_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default Anime Kitsu user agent', + }), + + NUVIOSTREAMS_URL: url({ + default: 'https://nuviostreams.hayd.uk', + desc: 'NuvioStreams URL', + }), + DEFAULT_NUVIOSTREAMS_TIMEOUT: num({ + default: undefined, + desc: 'Default NuvioStreams timeout', + }), + DEFAULT_NUVIOSTREAMS_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default NuvioStreams user agent', + }), + + TORRENT_CATALOGS_URL: url({ + default: 'https://torrent-catalogs.strem.fun', + desc: 'Default Torrent Catalogs URL', + }), + DEFAULT_TORRENT_CATALOGS_TIMEOUT: num({ + default: undefined, + desc: 'Default Torrent Catalogs timeout', + }), + DEFAULT_TORRENT_CATALOGS_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default Torrent Catalogs user agent', + }), + + TMDB_COLLECTIONS_URL: url({ + default: 'https://61ab9c85a149-tmdb-collections.baby-beamup.club', + desc: 'Default TMDB Collections URL', + }), + DEFAULT_TMDB_COLLECTIONS_TIMEOUT: num({ + default: undefined, + desc: 'Default TMDB Collections timeout', + }), + DEFAULT_TMDB_COLLECTIONS_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default TMDB Collections user agent', + }), + + RPDB_CATALOGS_URL: url({ + default: 'https://1fe84bc728af-rpdb.baby-beamup.club', + desc: 'Default RPDB Catalogs URL', + }), + DEFAULT_RPDB_CATALOGS_TIMEOUT: num({ + default: undefined, + desc: 'Default RPDB Catalogs timeout', + }), + DEFAULT_RPDB_CATALOGS_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default RPDB Catalogs user agent', + }), + STREAMING_CATALOGS_URL: url({ + default: + 'https://7a82163c306e-stremio-netflix-catalog-addon.baby-beamup.club', + desc: 'Default Streaming Catalogs URL', + }), + DEFAULT_STREAMING_CATALOGS_TIMEOUT: num({ + default: undefined, + desc: 'Default Streaming Catalogs timeout', + }), + DEFAULT_STREAMING_CATALOGS_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default Streaming Catalogs user agent', + }), + ANIME_CATALOGS_URL: url({ + default: 'https://1fe84bc728af-stremio-anime-catalogs.baby-beamup.club', + desc: 'Default Anime Catalogs URL', + }), + DEFAULT_ANIME_CATALOGS_TIMEOUT: num({ + default: undefined, + desc: 'Default Anime Catalogs timeout', + }), + DEFAULT_ANIME_CATALOGS_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default Anime Catalogs user agent', + }), + + DOCTOR_WHO_UNIVERSE_URL: url({ + default: 'https://new-who.onrender.com', + desc: 'Default Doctor Who Universe URL', + }), + DEFAULT_DOCTOR_WHO_UNIVERSE_TIMEOUT: num({ + default: undefined, + desc: 'Default Doctor Who Universe timeout', + }), + DEFAULT_DOCTOR_WHO_UNIVERSE_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default Doctor Who Universe user agent', + }), + + WEBSTREAMR_URL: url({ + default: 'https://webstreamr.hayd.uk', + desc: 'WebStreamr URL', + }), + DEFAULT_WEBSTREAMR_TIMEOUT: num({ + default: undefined, + desc: 'Default WebStreamr timeout', + }), + DEFAULT_WEBSTREAMR_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default WebStreamr user agent', + }), + + TMDB_ADDON_URL: url({ + default: 'https://tmdb.elfhosted.com', + desc: 'TMDB Addon URL', + }), + DEFAULT_TMDB_ADDON_TIMEOUT: num({ + default: undefined, + desc: 'Default TMDB Addon timeout', + }), + DEFAULT_TMDB_ADDON_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default TMDB Addon user agent', + }), + + TORRENTS_DB_URL: url({ + default: 'https://torrentsdb.com', + desc: 'Torrents DB URL', + }), + DEFAULT_TORRENTS_DB_TIMEOUT: num({ + default: undefined, + desc: 'Default Torrents DB timeout', + }), + DEFAULT_TORRENTS_DB_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default Torrents DB user agent', + }), + + USA_TV_URL: url({ + default: 'https://848b3516657c-usatv.baby-beamup.club', + desc: 'USA TV URL', + }), + DEFAULT_USA_TV_TIMEOUT: num({ + default: undefined, + desc: 'Default USA TV timeout', + }), + DEFAULT_USA_TV_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default USA TV user agent', + }), + + ARGENTINA_TV_URL: url({ + default: 'https://848b3516657c-argentinatv.baby-beamup.club', + desc: 'Argentina TV URL', + }), + DEFAULT_ARGENTINA_TV_TIMEOUT: num({ + default: undefined, + desc: 'Default Argentina TV timeout', + }), + DEFAULT_ARGENTINA_TV_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default Argentina TV user agent', + }), + + // Rate limiting settings + DISABLE_RATE_LIMITS: bool({ + default: false, + desc: 'Disable rate limiting', + }), + + STATIC_RATE_LIMIT_WINDOW: num({ + default: 5, // 1 minute + desc: 'Time window for static file serving rate limiting in seconds', + }), + STATIC_RATE_LIMIT_MAX_REQUESTS: num({ + default: 75, // allow 100 requests per IP per minute + desc: 'Maximum number of requests allowed per IP within the time window', + }), + USER_API_RATE_LIMIT_WINDOW: num({ + default: 5, // 1 minute + desc: 'Time window for user API rate limiting in seconds', + }), + USER_API_RATE_LIMIT_MAX_REQUESTS: num({ + default: 5, // allow 100 requests per IP per minute + }), + STREAM_API_RATE_LIMIT_WINDOW: num({ + default: 5, // 1 minute + desc: 'Time window for stream API rate limiting in seconds', + }), + STREAM_API_RATE_LIMIT_MAX_REQUESTS: num({ + default: 10, // allow 100 requests per IP per minute + }), + FORMAT_API_RATE_LIMIT_WINDOW: num({ + default: 5, // 10 seconds + desc: 'Time window for format API rate limiting in seconds', + }), + FORMAT_API_RATE_LIMIT_MAX_REQUESTS: num({ + default: 30, // allow 50 requests per IP per 10 seconds + }), + CATALOG_API_RATE_LIMIT_WINDOW: num({ + default: 5, // 1 minute + desc: 'Time window for catalog API rate limiting in seconds', + }), + CATALOG_API_RATE_LIMIT_MAX_REQUESTS: num({ + default: 5, // allow 100 requests per IP per minute + }), + STREMIO_STREAM_RATE_LIMIT_WINDOW: num({ + default: 15, // 1 minute + desc: 'Time window for Stremio stream rate limiting in seconds', + }), + STREMIO_STREAM_RATE_LIMIT_MAX_REQUESTS: num({ + default: 10, // allow 100 requests per IP per minute + desc: 'Maximum number of requests allowed per IP within the time window', + }), + STREMIO_CATALOG_RATE_LIMIT_WINDOW: num({ + default: 5, // 1 minute + desc: 'Time window for Stremio catalog rate limiting in seconds', + }), + STREMIO_CATALOG_RATE_LIMIT_MAX_REQUESTS: num({ + default: 30, // allow 100 requests per IP per minute + desc: 'Maximum number of requests allowed per IP within the time window', + }), + STREMIO_MANIFEST_RATE_LIMIT_WINDOW: num({ + default: 5, // 1 minute + desc: 'Time window for Stremio manifest rate limiting in seconds', + }), + STREMIO_MANIFEST_RATE_LIMIT_MAX_REQUESTS: num({ + default: 5, // allow 100 requests per IP per minute + desc: 'Maximum number of requests allowed per IP within the time window', + }), + STREMIO_SUBTITLE_RATE_LIMIT_WINDOW: num({ + default: 5, // 1 minute + desc: 'Time window for Stremio subtitle rate limiting in seconds', + }), + STREMIO_SUBTITLE_RATE_LIMIT_MAX_REQUESTS: num({ + default: 10, // allow 100 requests per IP per minute + desc: 'Maximum number of requests allowed per IP within the time window', + }), + STREMIO_META_RATE_LIMIT_WINDOW: num({ + default: 5, // 1 minute + desc: 'Time window for Stremio meta rate limiting in seconds', + }), + STREMIO_META_RATE_LIMIT_MAX_REQUESTS: num({ + default: 15, // allow 100 requests per IP per minute + desc: 'Maximum number of requests allowed per IP within the time window', + }), +}); diff --git a/packages/core/src/utils/feature.ts b/packages/core/src/utils/feature.ts new file mode 100644 index 0000000000000000000000000000000000000000..0a936eab5a9222108df4eeb75f7383b1f41e68ab --- /dev/null +++ b/packages/core/src/utils/feature.ts @@ -0,0 +1,65 @@ +import { UserData } from '../db/schemas'; +import { Env } from './env'; + +const DEFAULT_REASON = 'Disabled by owner of the instance'; + +export class FeatureControl { + private static readonly _disabledHosts: Map = (() => { + const map = new Map(); + if (Env.DISABLED_HOSTS) { + for (const disabledHost of Env.DISABLED_HOSTS.split(',')) { + const [host, reason] = disabledHost.split(':'); + map.set(host, reason || DEFAULT_REASON); + } + } + return map; + })(); + + private static readonly _disabledAddons: Map = (() => { + const map = new Map(); + if (Env.DISABLED_ADDONS) { + for (const disabledAddon of Env.DISABLED_ADDONS.split(',')) { + const [addon, reason] = disabledAddon.split(':'); + map.set(addon, reason || DEFAULT_REASON); + } + } + return map; + })(); + + private static readonly _disabledServices: Map = (() => { + const map = new Map(); + if (Env.DISABLED_SERVICES) { + for (const disabledService of Env.DISABLED_SERVICES.split(',')) { + const [service, reason] = disabledService.split(':'); + map.set(service, reason || DEFAULT_REASON); + } + } + return map; + })(); + + public static readonly regexFilterAccess: 'none' | 'trusted' | 'all' = + Env.REGEX_FILTER_ACCESS; + + public static get disabledHosts() { + return this._disabledHosts; + } + + public static get disabledAddons() { + return this._disabledAddons; + } + + public static get disabledServices() { + return this._disabledServices; + } + + public static isRegexAllowed(userData: UserData) { + switch (this.regexFilterAccess) { + case 'trusted': + return userData.trusted ?? false; + case 'all': + return true; + default: + return false; + } + } +} diff --git a/packages/core/src/utils/general.ts b/packages/core/src/utils/general.ts new file mode 100644 index 0000000000000000000000000000000000000000..c68cd5f16b9e5c51ad51b44c71adae37bd125c9a --- /dev/null +++ b/packages/core/src/utils/general.ts @@ -0,0 +1,5 @@ +import { Addon } from '../db/schemas'; + +export function getAddonName(addon: Addon): string { + return `${addon.name}${addon.displayIdentifier || addon.identifier ? ` ${addon.displayIdentifier || addon.identifier}` : ''}`; +} diff --git a/packages/core/src/utils/http.ts b/packages/core/src/utils/http.ts new file mode 100644 index 0000000000000000000000000000000000000000..277e25d4e5b12dbaa082cf7538d9fad8d1741cc9 --- /dev/null +++ b/packages/core/src/utils/http.ts @@ -0,0 +1,117 @@ +import { Cache } from './cache'; +import { HEADERS_FOR_IP_FORWARDING } from './constants'; +import { Env } from './env'; +import { createLogger, maskSensitiveInfo } from './logger'; +import { fetch, ProxyAgent } from 'undici'; + +const logger = createLogger('http'); +const urlCount = Cache.getInstance('url-count'); + +export class PossibleRecursiveRequestError extends Error { + constructor(message: string) { + super(message); + this.name = 'PossibleRecursiveRequestError'; + } +} +export function makeUrlLogSafe(url: string) { + // for each component of the path, if it is longer than 10 characters, mask it + // and replace the query params of key 'password' with '****' + return url + .split('/') + .map((component) => { + if (component.length > 10 && !component.includes('.')) { + return maskSensitiveInfo(component); + } + return component; + }) + .join('/') + .replace(/(? Env.RECURSION_THRESHOLD_LIMIT && !ignoreRecursion) { + logger.warn( + `Detected possible recursive requests to ${url}. Current count: ${currentCount}. Blocking request.` + ); + throw new PossibleRecursiveRequestError( + `Possible recursive request to ${url}` + ); + } + if (currentCount > 0) { + urlCount.update(key, currentCount + 1); + } else { + urlCount.set(key, 1, Env.RECURSION_THRESHOLD_WINDOW); + } + logger.debug( + `Making a ${useProxy ? 'proxied' : 'direct'} request to ${makeUrlLogSafe( + url + )} with forwarded ip ${maskSensitiveInfo(forwardIp ?? 'none')} and headers ${maskSensitiveInfo(JSON.stringify(headers))}` + ); + let response = fetch(url, { + dispatcher: useProxy ? new ProxyAgent(Env.ADDON_PROXY!) : undefined, + method: 'GET', + headers: headers, + signal: AbortSignal.timeout(timeout), + }); + + return response; +} + +function shouldProxy(url: string) { + let shouldProxy = false; + let hostname: string; + + try { + hostname = new URL(url).hostname; + } catch (error) { + return false; + } + + if (!Env.ADDON_PROXY) { + return false; + } + + shouldProxy = true; + if (Env.ADDON_PROXY_CONFIG) { + for (const rule of Env.ADDON_PROXY_CONFIG.split(',')) { + const [ruleHostname, ruleShouldProxy] = rule.split(':'); + if (['true', 'false'].includes(ruleShouldProxy) === false) { + logger.error(`Invalid proxy config: ${rule}`); + continue; + } + if (ruleHostname === '*') { + shouldProxy = !(ruleShouldProxy === 'false'); + } else if (ruleHostname.startsWith('*')) { + if (hostname.endsWith(ruleHostname.slice(1))) { + shouldProxy = !(ruleShouldProxy === 'false'); + } + } + if (hostname === ruleHostname) { + shouldProxy = !(ruleShouldProxy === 'false'); + } + } + } + + return shouldProxy; +} diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..fd5723635f7383d2c6e694ea217468201283dfec --- /dev/null +++ b/packages/core/src/utils/index.ts @@ -0,0 +1,14 @@ +export * from './cache'; +export * from './constants'; +export * from './env'; +export * from './logger'; +export * from './resources'; +export * from './feature'; +export * from './crypto'; +export * from './http'; +export * from './metadata'; +export * as constants from './constants'; +export * from './config'; +export * from './languages'; +export * from './dsu'; +export * from './startup'; diff --git a/packages/core/src/utils/languages.ts b/packages/core/src/utils/languages.ts new file mode 100644 index 0000000000000000000000000000000000000000..9e1cf3b266269684919de09d8603ac31a0c746d6 --- /dev/null +++ b/packages/core/src/utils/languages.ts @@ -0,0 +1,1997 @@ +export const FULL_LANGUAGE_MAPPING = [ + // A + { + iso_639_1: 'aa', + iso_639_2: 'aar', + iso_3166_1: 'ET', + flag: '🇪🇹', + english_name: 'Afar', + name: 'Qafár af', + }, + { + iso_639_1: 'ab', + iso_639_2: 'abk', + iso_3166_1: 'GE', + flag: '🇬🇪', + english_name: 'Abkhazian', + name: 'Аҧсуа бызшәа', + }, + { + iso_639_1: 'ae', + iso_639_2: 'ave', + iso_3166_1: 'IR', + flag: '🇮🇷', + english_name: 'Avestan', + name: 'Avesta', + }, + { + iso_639_1: 'af', + iso_639_2: 'afr', + iso_3166_1: 'ZA', + flag: '🇿🇦', + english_name: 'Afrikaans', + name: 'Afrikaans', + }, + { + iso_639_1: 'ak', + iso_639_2: 'aka', + iso_3166_1: 'GH', + flag: '🇬🇭', + english_name: 'Akan', + name: 'Akan', + }, + { + iso_639_1: 'am', + iso_639_2: 'amh', + iso_3166_1: 'ET', + flag: '🇪🇹', + english_name: 'Amharic', + name: 'አማርኛ', + }, + { + iso_639_1: 'an', + iso_639_2: 'arg', + iso_3166_1: 'ES', + flag: '🇪🇸', + english_name: 'Aragonese', + name: 'Aragonés', + }, + { + iso_639_1: 'ar', + iso_639_2: 'ara', + iso_3166_1: 'SA', + flag: '🇸🇦', + flag_priority: true, + english_name: 'Arabic', + name: 'العربية', + }, + { + iso_639_1: 'ar', + iso_639_2: 'ara', + iso_3166_1: 'AE', + flag: '🇦🇪', + english_name: 'Arabic (UAE)', + name: 'العربية', + }, + { + iso_639_1: 'ar', + iso_639_2: 'ara', + iso_3166_1: 'EG', + flag: '🇪🇬', + english_name: 'Arabic (Egypt)', + name: 'العربية', + }, + { + iso_639_1: 'as', + iso_639_2: 'asm', + iso_3166_1: 'IN', + flag: '🇮🇳', + english_name: 'Assamese', + name: 'অসমীয়া', + }, + { + iso_639_1: 'av', + iso_639_2: 'ava', + iso_3166_1: 'RU', + flag: '🇷🇺', + english_name: 'Avaric', + name: 'Авар мацӀ', + }, + { + iso_639_1: 'ay', + iso_639_2: 'aym', + iso_3166_1: 'BO', + flag: '🇧🇴', + english_name: 'Aymara', + name: 'Aymar aru', + }, + { + iso_639_1: 'az', + iso_639_2: 'aze', + iso_3166_1: 'AZ', + flag: '🇦🇿', + english_name: 'Azerbaijani', + name: 'Azərbaycan', + }, + + // B + { + iso_639_1: 'ba', + iso_639_2: 'bak', + iso_3166_1: 'RU', + flag: '🇷🇺', + english_name: 'Bashkir', + name: 'Башҡорт теле', + }, + { + iso_639_1: 'be', + iso_639_2: 'bel', + iso_3166_1: 'BY', + flag: '🇧🇾', + english_name: 'Belarusian', + name: 'Беларуская мова', + }, + { + iso_639_1: 'bg', + iso_639_2: 'bul', + iso_3166_1: 'BG', + flag: '🇧🇬', + english_name: 'Bulgarian', + name: 'Български език', + }, + { + iso_639_1: 'bi', + iso_639_2: 'bis', + iso_3166_1: 'VU', + flag: '🇻🇺', + english_name: 'Bislama', + name: 'Bislama', + }, + { + iso_639_1: 'bm', + iso_639_2: 'bam', + iso_3166_1: 'ML', + flag: '🇲🇱', + english_name: 'Bambara', + name: 'Bamanankan', + }, + { + iso_639_1: 'bn', + iso_639_2: 'ben', + iso_3166_1: 'BD', + flag: '🇧🇩', + flag_priority: true, + english_name: 'Bengali', + name: 'বাংলা', + }, + { + iso_639_1: 'bo', + iso_639_2: 'bod', + iso_3166_1: 'CN', + flag: '🇨🇳', + english_name: 'Tibetan', + name: 'བོད་ཡིག', + }, + { + iso_639_1: 'br', + iso_639_2: 'bre', + iso_3166_1: 'FR', + flag: '🇫🇷', + english_name: 'Breton', + name: 'Brezhoneg', + }, + { + iso_639_1: 'bs', + iso_639_2: 'bos', + iso_3166_1: 'BA', + flag: '🇧🇦', + english_name: 'Bosnian', + name: 'Bosanski', + }, + + // C + { + iso_639_1: 'ca', + iso_639_2: 'cat', + iso_3166_1: 'ES', + flag: '🇪🇸', + english_name: 'Catalan', + name: 'Català', + }, + { + iso_639_1: 'ce', + iso_639_2: 'che', + iso_3166_1: 'RU', + flag: '🇷🇺', + english_name: 'Chechen', + name: 'Нохчийн мотт', + }, + { + iso_639_1: 'ch', + iso_639_2: 'cha', + iso_3166_1: 'MP', + flag: '🇲🇵', + english_name: 'Chamorro', + name: "Finu' Chamorro", + }, + { + iso_639_1: 'co', + iso_639_2: 'cos', + iso_3166_1: 'FR', + flag: '🇫🇷', + english_name: 'Corsican', + name: 'Corsu', + }, + { + iso_639_1: 'cr', + iso_639_2: 'cre', + iso_3166_1: 'CA', + flag: '🇨🇦', + english_name: 'Cree', + name: 'ᓀᐦᐃᔭᐍᐏᐣ', + }, + { + iso_639_1: 'cs', + iso_639_2: 'ces', + iso_3166_1: 'CZ', + flag: '🇨🇿', + english_name: 'Czech', + name: 'Český', + }, + { + iso_639_1: 'cu', + iso_639_2: 'chu', + iso_3166_1: 'RU', + flag: '🇷🇺', + english_name: 'Church Slavic', + name: 'Словѣньскъ', + }, + { + iso_639_1: 'cv', + iso_639_2: 'chv', + iso_3166_1: 'RU', + flag: '🇷🇺', + english_name: 'Chuvash', + name: 'Чӑваш чӗлхи', + }, + { + iso_639_1: 'cy', + iso_639_2: 'cym', + iso_3166_1: 'GB', + flag: '🇬🇧', + english_name: 'Welsh', + name: 'Cymraeg', + }, + + // D + { + iso_639_1: 'da', + iso_639_2: 'dan', + iso_3166_1: 'DK', + flag: '🇩🇰', + flag_priority: true, + english_name: 'Danish', + name: 'Dansk', + }, + { + iso_639_1: 'de', + iso_639_2: 'deu', + iso_3166_1: 'DE', + flag: '🇩🇪', + flag_priority: true, + english_name: 'German', + name: 'Deutsch', + }, + { + iso_639_1: 'dv', + iso_639_2: 'div', + iso_3166_1: 'MV', + flag: '🇲🇻', + english_name: 'Divehi', + name: 'ދިވެހި', + }, + { + iso_639_1: 'dz', + iso_639_2: 'dzo', + iso_3166_1: 'BT', + flag: '🇧🇹', + english_name: 'Dzongkha', + name: 'རྫོང་ཁ', + }, + + // E + { + iso_639_1: 'ee', + iso_639_2: 'ewe', + iso_3166_1: 'GH', + flag: '🇬🇭', + english_name: 'Ewe', + name: 'Èʋegbe', + }, + { + iso_639_1: 'el', + iso_639_2: 'ell', + iso_3166_1: 'GR', + flag: '🇬🇷', + english_name: 'Greek', + name: 'Ελληνικά', + }, + { + iso_639_1: 'en', + iso_639_2: 'eng', + iso_3166_1: 'GB', + flag: '🇬🇧', + flag_priority: true, + english_name: 'English (UK)', + name: 'English', + }, + { + iso_639_1: 'en', + iso_639_2: 'eng', + iso_3166_1: 'US', + flag: '🇺🇸', + english_name: 'English (US)', + name: 'English', + }, + { + iso_639_1: 'en', + iso_639_2: 'eng', + iso_3166_1: 'AU', + flag: '🇦🇺', + english_name: 'English (Australia)', + name: 'English', + }, + { + iso_639_1: 'en', + iso_639_2: 'eng', + iso_3166_1: 'CA', + flag: '🇨🇦', + english_name: 'English (Canada)', + name: 'English', + }, + { + iso_639_1: 'en', + iso_639_2: 'eng', + iso_3166_1: 'IE', + flag: '🇮🇪', + english_name: 'English (Ireland)', + name: 'English', + }, + { + iso_639_1: 'en', + iso_639_2: 'eng', + iso_3166_1: 'NZ', + flag: '🇳🇿', + english_name: 'English (New Zealand)', + name: 'English', + }, + { + iso_639_1: 'eo', + iso_639_2: 'epo', + iso_3166_1: null, + flag: '🌐', + english_name: 'Esperanto', + name: 'Esperanto', + }, + { + iso_639_1: 'es', + iso_639_2: 'spa', + iso_3166_1: 'ES', + flag: '🇪🇸', + flag_priority: true, + english_name: 'Spanish', + name: 'Español', + }, + { + iso_639_1: 'et', + iso_639_2: 'est', + iso_3166_1: 'EE', + flag: '🇪🇪', + english_name: 'Estonian', + name: 'Eesti', + }, + { + iso_639_1: 'eu', + iso_639_2: 'eus', + iso_3166_1: 'ES', + flag: '🏴', + english_name: 'Basque', + name: 'Euskera', + }, + + // F + { + iso_639_1: 'fa', + iso_639_2: 'fas', + iso_3166_1: 'IR', + flag: '🇮🇷', + english_name: 'Persian', + name: 'فارسی', + }, + { + iso_639_1: 'ff', + iso_639_2: 'ful', + iso_3166_1: 'SN', + flag: '🇸🇳', + english_name: 'Fulah', + name: 'Fulfulde', + }, + { + iso_639_1: 'fi', + iso_639_2: 'fin', + iso_3166_1: 'FI', + flag: '🇫🇮', + english_name: 'Finnish', + name: 'Suomi', + }, + { + iso_639_1: 'fj', + iso_639_2: 'fij', + iso_3166_1: 'FJ', + flag: '🇫🇯', + english_name: 'Fijian', + name: 'Vosa Vakaviti', + }, + { + iso_639_1: 'fo', + iso_639_2: 'fao', + iso_3166_1: 'FO', + flag: '🇫🇴', + english_name: 'Faroese', + name: 'Føroyskt', + }, + { + iso_639_1: 'fr', + iso_639_2: 'fra', + iso_3166_1: 'FR', + flag: '🇫🇷', + flag_priority: true, + english_name: 'French', + name: 'Français', + }, + { + iso_639_1: 'fy', + iso_639_2: 'fry', + iso_3166_1: 'NL', + flag: '🇳🇱', + english_name: 'Western Frisian', + name: 'Frysk', + }, + + // G + { + iso_639_1: 'ga', + iso_639_2: 'gle', + iso_3166_1: 'IE', + flag: '🇮🇪', + english_name: 'Irish', + name: 'Gaeilge', + }, + { + iso_639_1: 'gd', + iso_639_2: 'gla', + iso_3166_1: 'GB', + flag: '🏴󠁧󠁢󠁳󠁣󠁴󠁿', + english_name: 'Scottish Gaelic', + name: 'Gàidhlig', + }, + { + iso_639_1: 'gl', + iso_639_2: 'glg', + iso_3166_1: 'ES', + flag: '🇪🇸', + english_name: 'Galician', + name: 'Galego', + }, + { + iso_639_1: 'gn', + iso_639_2: 'grn', + iso_3166_1: 'PY', + flag: '🇵🇾', + english_name: 'Guarani', + name: "Avañe'ẽ", + }, + { + iso_639_1: 'gu', + iso_639_2: 'guj', + iso_3166_1: 'IN', + flag: '🇮🇳', + english_name: 'Gujarati', + name: 'ગુજરાતી', + }, + { + iso_639_1: 'gv', + iso_639_2: 'glv', + iso_3166_1: 'IM', + flag: '🇮🇲', + english_name: 'Manx', + name: 'Gaelg', + }, + + // H + { + iso_639_1: 'ha', + iso_639_2: 'hau', + iso_3166_1: 'NG', + flag: '🇳🇬', + english_name: 'Hausa', + name: 'Hausa', + }, + { + iso_639_1: 'he', + iso_639_2: 'heb', + iso_3166_1: 'IL', + flag: '🇮🇱', + flag_priority: true, + english_name: 'Hebrew', + name: 'עִבְרִית', + }, + { + iso_639_1: 'hi', + iso_639_2: 'hin', + iso_3166_1: 'IN', + flag: '🇮🇳', + flag_priority: true, + english_name: 'Hindi', + name: 'हिन्दी', + }, + { + iso_639_1: 'ho', + iso_639_2: 'hmo', + iso_3166_1: 'PG', + flag: '🇵🇬', + english_name: 'Hiri Motu', + name: 'Hiri Motu', + }, + { + iso_639_1: 'hr', + iso_639_2: 'hrv', + iso_3166_1: 'HR', + flag: '🇭🇷', + english_name: 'Croatian', + name: 'Hrvatski', + }, + { + iso_639_1: 'ht', + iso_639_2: 'hat', + iso_3166_1: 'HT', + flag: '🇭🇹', + english_name: 'Haitian; Haitian Creole', + name: 'Kreyòl ayisyen', + }, + { + iso_639_1: 'hu', + iso_639_2: 'hun', + iso_3166_1: 'HU', + flag: '🇭🇺', + english_name: 'Hungarian', + name: 'Magyar', + }, + { + iso_639_1: 'hy', + iso_639_2: 'hye', + iso_3166_1: 'AM', + flag: '🇦🇲', + english_name: 'Armenian', + name: 'Հայերեն', + }, + { + iso_639_1: 'hz', + iso_639_2: 'her', + iso_3166_1: 'NA', + flag: '🇳🇦', + english_name: 'Herero', + name: 'Otjiherero', + }, + + // I + { + iso_639_1: 'ia', + iso_639_2: 'ina', + iso_3166_1: null, + flag: '🌐', + english_name: 'Interlingua', + name: 'Interlingua', + }, + { + iso_639_1: 'id', + iso_639_2: 'ind', + iso_3166_1: 'ID', + flag: '🇮🇩', + flag_priority: true, + english_name: 'Indonesian', + name: 'Bahasa Indonesia', + }, + { + iso_639_1: 'ie', + iso_639_2: 'ile', + iso_3166_1: null, + flag: '🌐', + english_name: 'Interlingue', + name: 'Interlingue', + }, + { + iso_639_1: 'ig', + iso_639_2: 'ibo', + iso_3166_1: 'NG', + flag: '🇳🇬', + english_name: 'Igbo', + name: 'Asụsụ Igbo', + }, + { + iso_639_1: 'ii', + iso_639_2: 'iii', + iso_3166_1: 'CN', + flag: '🇨🇳', + english_name: 'Sichuan Yi; Nuosu', + name: 'ꆈꌠꉙ', + }, + { + iso_639_1: 'ik', + iso_639_2: 'ipk', + iso_3166_1: 'US', + flag: '🇺🇸', + english_name: 'Inupiaq', + name: 'Iñupiaq', + }, + { + iso_639_1: 'io', + iso_639_2: 'ido', + iso_3166_1: null, + flag: '🌐', + english_name: 'Ido', + name: 'Ido', + }, + { + iso_639_1: 'is', + iso_639_2: 'isl', + iso_3166_1: 'IS', + flag: '🇮🇸', + english_name: 'Icelandic', + name: 'Íslenska', + }, + { + iso_639_1: 'it', + iso_639_2: 'ita', + iso_3166_1: 'IT', + flag: '🇮🇹', + flag_priority: true, + english_name: 'Italian', + name: 'Italiano', + }, + { + iso_639_1: 'iu', + iso_639_2: 'iku', + iso_3166_1: 'CA', + flag: '🇨🇦', + english_name: 'Inuktitut', + name: 'ᐃᓄᒃᑎᑐᑦ', + }, + + // J + { + iso_639_1: 'ja', + iso_639_2: 'jpn', + iso_3166_1: 'JP', + flag: '🇯🇵', + english_name: 'Japanese', + name: '日本語', + }, + { + iso_639_1: 'jv', + iso_639_2: 'jav', + iso_3166_1: 'ID', + flag: '🇮🇩', + english_name: 'Javanese', + name: 'Basa Jawa', + }, + + // K + { + iso_639_1: 'ka', + iso_639_2: 'kat', + iso_3166_1: 'GE', + flag: '🇬🇪', + english_name: 'Georgian', + name: 'ქართული', + }, + { + iso_639_1: 'kg', + iso_639_2: 'kon', + iso_3166_1: 'CD', + flag: '🇨🇩', + english_name: 'Kongo', + name: 'KiKongo', + }, + { + iso_639_1: 'ki', + iso_639_2: 'kik', + iso_3166_1: 'KE', + flag: '🇰🇪', + english_name: 'Kikuyu; Gikuyu', + name: 'Gĩkũyũ', + }, + { + iso_639_1: 'kj', + iso_639_2: 'kua', + iso_3166_1: 'AO', + flag: '🇦🇴', + english_name: 'Kuanyama; Kwanyama', + name: 'Kuanyama', + }, + { + iso_639_1: 'kk', + iso_639_2: 'kaz', + iso_3166_1: 'KZ', + flag: '🇰🇿', + english_name: 'Kazakh', + name: 'Қазақ', + }, + { + iso_639_1: 'kl', + iso_639_2: 'kal', + iso_3166_1: 'GL', + flag: '🇬🇱', + english_name: 'Kalaallisut; Greenlandic', + name: 'Kalaallisut', + }, + { + iso_639_1: 'km', + iso_639_2: 'khm', + iso_3166_1: 'KH', + flag: '🇰🇭', + english_name: 'Central Khmer', + name: 'ភាសាខ្មែរ', + }, + { + iso_639_1: 'kn', + iso_639_2: 'kan', + iso_3166_1: 'IN', + flag: '🇮🇳', + english_name: 'Kannada', + name: 'ಕನ್ನಡ', + }, + { + iso_639_1: 'ko', + iso_639_2: 'kor', + iso_3166_1: 'KR', + flag: '🇰🇷', + english_name: 'Korean', + name: '한국어/조선말', + }, + { + iso_639_1: 'kr', + iso_639_2: 'kau', + iso_3166_1: 'NE', + flag: '🇳🇪', + english_name: 'Kanuri', + name: 'Kanuri', + }, + { + iso_639_1: 'ks', + iso_639_2: 'kas', + iso_3166_1: 'IN', + flag: '🇮🇳', + english_name: 'Kashmiri', + name: 'कश्मीरी', + }, + { + iso_639_1: 'ku', + iso_639_2: 'kur', + iso_3166_1: 'TR', + flag: '🇹🇷', + english_name: 'Kurdish', + name: 'Kurdî', + }, + { + iso_639_1: 'kv', + iso_639_2: 'kom', + iso_3166_1: 'RU', + flag: '🇷🇺', + english_name: 'Komi', + name: 'Коми кыв', + }, + { + iso_639_1: 'kw', + iso_639_2: 'cor', + iso_3166_1: 'GB', + flag: '🇬🇧', + english_name: 'Cornish', + name: 'Kernewek', + }, + { + iso_639_1: 'ky', + iso_639_2: 'kir', + iso_3166_1: 'KG', + flag: '🇰🇬', + english_name: 'Kirghiz; Kyrgyz', + name: 'Кыргызча', + }, + + // L + { + iso_639_1: 'la', + iso_639_2: 'lat', + iso_3166_1: 'VA', + flag: '🇻🇦', + english_name: 'Latin', + name: 'Latine', + }, + { + iso_639_1: 'lb', + iso_639_2: 'ltz', + iso_3166_1: 'LU', + flag: '🇱🇺', + english_name: 'Luxembourgish; Letzeburgesch', + name: 'Lëtzebuergesch', + }, + { + iso_639_1: 'lg', + iso_639_2: 'lug', + iso_3166_1: 'UG', + flag: '🇺🇬', + english_name: 'Ganda', + name: 'Luganda', + }, + { + iso_639_1: 'li', + iso_639_2: 'lim', + iso_3166_1: 'NL', + flag: '🇳🇱', + english_name: 'Limburgan; Limburger; Limburgish', + name: 'Limburgs', + }, + { + iso_639_1: 'ln', + iso_639_2: 'lin', + iso_3166_1: 'CD', + flag: '🇨🇩', + english_name: 'Lingala', + name: 'Lingála', + }, + { + iso_639_1: 'lo', + iso_639_2: 'lao', + iso_3166_1: 'LA', + flag: '🇱🇦', + english_name: 'Lao', + name: 'ພາສາລາວ', + }, + { + iso_639_1: 'lt', + iso_639_2: 'lit', + iso_3166_1: 'LT', + flag: '🇱🇹', + english_name: 'Lithuanian', + name: 'Lietuvių', + }, + { + iso_639_1: 'lu', + iso_639_2: 'lub', + iso_3166_1: 'CD', + flag: '🇨🇩', + english_name: 'Luba-Katanga', + name: 'Tshiluba', + }, + { + iso_639_1: 'lv', + iso_639_2: 'lav', + iso_3166_1: 'LV', + flag: '🇱🇻', + english_name: 'Latvian', + name: 'Latviešu', + }, + + // M + { + iso_639_1: 'mg', + iso_639_2: 'mlg', + iso_3166_1: 'MG', + flag: '🇲🇬', + english_name: 'Malagasy', + name: 'Malagasy', + }, + { + iso_639_1: 'mh', + iso_639_2: 'mah', + iso_3166_1: 'MH', + flag: '🇲🇭', + english_name: 'Marshallese', + name: 'Kajin M̧ajeļ', + }, + { + iso_639_1: 'mi', + iso_639_2: 'mri', + iso_3166_1: 'NZ', + flag: '🇳🇿', + english_name: 'Maori', + name: 'Te Reo Māori', + }, + { + iso_639_1: 'mk', + iso_639_2: 'mkd', + iso_3166_1: 'MK', + flag: '🇲🇰', + english_name: 'Macedonian', + name: 'Македонски јазик', + }, + { + iso_639_1: 'ml', + iso_639_2: 'mal', + iso_3166_1: 'IN', + flag: '🇮🇳', + english_name: 'Malayalam', + name: 'മലയാളം', + }, + { + iso_639_1: 'mn', + iso_639_2: 'mon', + iso_3166_1: 'MN', + flag: '🇲🇳', + english_name: 'Mongolian', + name: 'Монгол', + }, + { + iso_639_1: 'mr', + iso_639_2: 'mar', + iso_3166_1: 'IN', + flag: '🇮🇳', + english_name: 'Marathi', + name: 'मराठी', + }, + { + iso_639_1: 'ms', + iso_639_2: 'msa', + iso_3166_1: 'MY', + flag: '🇲🇾', + english_name: 'Malay', + name: 'Bahasa Melayu', + }, + { + iso_639_1: 'mt', + iso_639_2: 'mlt', + iso_3166_1: 'MT', + flag: '🇲🇹', + english_name: 'Maltese', + name: 'Malti', + }, + { + iso_639_1: 'my', + iso_639_2: 'mya', + iso_3166_1: 'MM', + flag: '🇲🇲', + english_name: 'Burmese', + name: 'ဗမာစာ', + }, + + // N + { + iso_639_1: 'na', + iso_639_2: 'nau', + iso_3166_1: 'NR', + flag: '🇳🇷', + english_name: 'Nauru', + name: 'Dorerin Naoero', + }, + { + iso_639_1: 'nb', + iso_639_2: 'nob', + iso_3166_1: 'NO', + flag: '🇳🇴', + english_name: 'Bokmål, Norwegian; Norwegian Bokmål', + name: 'Bokmål', + }, + { + iso_639_1: 'nd', + iso_639_2: 'nde', + iso_3166_1: 'ZW', + flag: '🇿🇼', + english_name: 'Ndebele, North; North Ndebele', + name: 'IsiNdebele', + }, + { + iso_639_1: 'ne', + iso_639_2: 'nep', + iso_3166_1: 'NP', + flag: '🇳🇵', + english_name: 'Nepali', + name: 'नेपाली', + }, + { + iso_639_1: 'ng', + iso_639_2: 'ndo', + iso_3166_1: 'NA', + flag: '🇳🇦', + english_name: 'Ndonga', + name: 'Owambo', + }, + { + iso_639_1: 'nl', + iso_639_2: 'nld', + iso_3166_1: 'NL', + flag: '🇳🇱', + flag_priority: true, + english_name: 'Dutch; Flemish', + name: 'Nederlands', + }, + { + iso_639_1: 'nn', + iso_639_2: 'nno', + iso_3166_1: 'NO', + flag: '🇳🇴', + english_name: 'Norwegian Nynorsk; Nynorsk, Norwegian', + name: 'Nynorsk', + }, + { + iso_639_1: 'no', + iso_639_2: 'nor', + iso_3166_1: 'NO', + flag: '🇳🇴', + flag_priority: true, + english_name: 'Norwegian', + name: 'Norsk', + }, + { + iso_639_1: 'nr', + iso_639_2: 'nbl', + iso_3166_1: 'ZA', + flag: '🇿🇦', + english_name: 'Ndebele, South; South Ndebele', + name: 'IsiNdebele', + }, + { + iso_639_1: 'nv', + iso_639_2: 'nav', + iso_3166_1: 'US', + flag: '🇺🇸', + english_name: 'Navajo; Navaho', + name: 'Diné bizaad', + }, + { + iso_639_1: 'ny', + iso_639_2: 'nya', + iso_3166_1: 'MW', + flag: '🇲🇼', + english_name: 'Chichewa; Chewa; Nyanja', + name: 'ChiCheŵa', + }, + + // O + { + iso_639_1: 'oc', + iso_639_2: 'oci', + iso_3166_1: 'FR', + flag: '🇫🇷', + english_name: 'Occitan (post 1500)', + name: 'Occitan', + }, + { + iso_639_1: 'oj', + iso_639_2: 'oji', + iso_3166_1: 'CA', + flag: '🇨🇦', + english_name: 'Ojibwa', + name: 'ᐊᓂᔑᓈᐯᒧᐎᓐ', + }, + { + iso_639_1: 'om', + iso_639_2: 'orm', + iso_3166_1: 'ET', + flag: '🇪🇹', + english_name: 'Oromo', + name: 'Afaan Oromoo', + }, + { + iso_639_1: 'or', + iso_639_2: 'ori', + iso_3166_1: 'IN', + flag: '🇮🇳', + english_name: 'Oriya', + name: 'ଓଡ଼ିଆ', + }, + { + iso_639_1: 'os', + iso_639_2: 'oss', + iso_3166_1: 'RU', + flag: '🇷🇺', + english_name: 'Ossetian; Ossetic', + name: 'Ирон æвзаг', + }, + + // P + { + iso_639_1: 'pa', + iso_639_2: 'pan', + iso_3166_1: 'IN', + flag: '🇮🇳', + english_name: 'Panjabi; Punjabi', + name: 'ਪੰਜਾਬੀ', + }, + { + iso_639_1: 'pi', + iso_639_2: 'pli', + iso_3166_1: 'IN', + flag: '🇮🇳', + english_name: 'Pali', + name: 'पाऴि', + }, + { + iso_639_1: 'pl', + iso_639_2: 'pol', + iso_3166_1: 'PL', + flag: '🇵🇱', + english_name: 'Polish', + name: 'Polski', + }, + { + iso_639_1: 'ps', + iso_639_2: 'pus', + iso_3166_1: 'AF', + flag: '🇦🇫', + english_name: 'Pushto; Pashto', + name: 'پښتو', + }, + { + iso_639_1: 'pt', + iso_639_2: 'por', + iso_3166_1: 'PT', + flag: '🇵🇹', + flag_priority: true, + english_name: 'Portuguese', + name: 'Português', + }, + { + iso_639_1: 'pt', + iso_639_2: 'por', + iso_3166_1: 'BR', + flag: '🇧🇷', + english_name: 'Portuguese (Brazil)', + name: 'Português', + }, + + // Q + { + iso_639_1: 'qu', + iso_639_2: 'que', + iso_3166_1: 'PE', + flag: '🇵🇪', + english_name: 'Quechua', + name: 'Runa Simi', + }, + + // R + { + iso_639_1: 'rm', + iso_639_2: 'roh', + iso_3166_1: 'CH', + flag: '🇨🇭', + english_name: 'Romansh', + name: 'Rumantsch grischun', + }, + { + iso_639_1: 'rn', + iso_639_2: 'run', + iso_3166_1: 'BI', + flag: '🇧🇮', + english_name: 'Rundi', + name: 'Kirundi', + }, + { + iso_639_1: 'ro', + iso_639_2: 'ron', + iso_3166_1: 'RO', + flag: '🇷🇴', + english_name: 'Romanian; Moldavian; Moldovan', + name: 'Română', + }, + { + iso_639_1: 'ru', + iso_639_2: 'rus', + iso_3166_1: 'RU', + flag: '🇷🇺', + flag_priority: true, + english_name: 'Russian', + name: 'Pусский', + }, + { + iso_639_1: 'rw', + iso_639_2: 'kin', + iso_3166_1: 'RW', + flag: '🇷🇼', + english_name: 'Kinyarwanda', + name: 'Ikinyarwanda', + }, + + // S + { + iso_639_1: 'sa', + iso_639_2: 'san', + iso_3166_1: 'IN', + flag: '🇮🇳', + english_name: 'Sanskrit', + name: 'संस्कृतम्', + }, + { + iso_639_1: 'sc', + iso_639_2: 'srd', + iso_3166_1: 'IT', + flag: '🇮🇹', + english_name: 'Sardinian', + name: 'Sardu', + }, + { + iso_639_1: 'sd', + iso_639_2: 'snd', + iso_3166_1: 'PK', + flag: '🇵🇰', + english_name: 'Sindhi', + name: 'सिन्धी', + }, + { + iso_639_1: 'se', + iso_639_2: 'sme', + iso_3166_1: 'NO', + flag: '🇳🇴', + english_name: 'Northern Sami', + name: 'Davvisámegiella', + }, + { + iso_639_1: 'sg', + iso_639_2: 'sag', + iso_3166_1: 'CF', + flag: '🇨🇫', + english_name: 'Sango', + name: 'Yângâ tî sängö', + }, + { + iso_639_1: 'sh', + iso_639_2: 'hbs', + iso_3166_1: 'RS', + flag: '🇷🇸', + english_name: 'Serbo-Croatian', + name: 'Srpskohrvatski', + }, + { + iso_639_1: 'si', + iso_639_2: 'sin', + iso_3166_1: 'LK', + flag: '🇱🇰', + english_name: 'Sinhala; Sinhalese', + name: 'සිංහල', + }, + { + iso_639_1: 'sk', + iso_639_2: 'slk', + iso_3166_1: 'SK', + flag: '🇸🇰', + flag_priority: true, + english_name: 'Slovak', + name: 'Slovenčina', + }, + { + iso_639_1: 'sl', + iso_639_2: 'slv', + iso_3166_1: 'SI', + flag: '🇸🇮', + english_name: 'Slovenian', + name: 'Slovenščina', + }, + { + iso_639_1: 'sm', + iso_639_2: 'smo', + iso_3166_1: 'WS', + flag: '🇼🇸', + english_name: 'Samoan', + name: "Gagana fa'a Samoa", + }, + { + iso_639_1: 'sn', + iso_639_2: 'sna', + iso_3166_1: 'ZW', + flag: '🇿🇼', + english_name: 'Shona', + name: 'ChiShona', + }, + { + iso_639_1: 'so', + iso_639_2: 'som', + iso_3166_1: 'SO', + flag: '🇸🇴', + english_name: 'Somali', + name: 'Af Soomaali', + }, + { + iso_639_1: 'sq', + iso_639_2: 'sqi', + iso_3166_1: 'AL', + flag: '🇦🇱', + english_name: 'Albanian', + name: 'Shqip', + }, + { + iso_639_1: 'sr', + iso_639_2: 'srp', + iso_3166_1: 'RS', + flag: '🇷🇸', + flag_priority: true, + english_name: 'Serbian', + name: 'Srpski', + }, + { + iso_639_1: 'ss', + iso_639_2: 'ssw', + iso_3166_1: 'SZ', + flag: '🇸🇿', + english_name: 'Swati', + name: 'SiSwati', + }, + { + iso_639_1: 'st', + iso_639_2: 'sot', + iso_3166_1: 'LS', + flag: '🇱🇸', + english_name: 'Sotho, Southern', + name: 'Sesotho', + }, + { + iso_639_1: 'su', + iso_639_2: 'sun', + iso_3166_1: 'ID', + flag: '🇮🇩', + english_name: 'Sundanese', + name: 'Basa Sunda', + }, + { + iso_639_1: 'sv', + iso_639_2: 'swe', + iso_3166_1: 'SE', + flag: '🇸🇪', + english_name: 'Swedish', + name: 'Svenska', + }, + { + iso_639_1: 'sw', + iso_639_2: 'swa', + iso_3166_1: 'KE', + flag: '🇰🇪', + english_name: 'Swahili', + name: 'Kiswahili', + }, + + // T + { + iso_639_1: 'ta', + iso_639_2: 'tam', + iso_3166_1: 'IN', + flag: '🇮🇳', + english_name: 'Tamil', + name: 'தமிழ்', + }, + { + iso_639_1: 'te', + iso_639_2: 'tel', + iso_3166_1: 'IN', + flag: '🇮🇳', + english_name: 'Telugu', + name: 'తెలుగు', + }, + { + iso_639_1: 'tg', + iso_639_2: 'tgk', + iso_3166_1: 'TJ', + flag: '🇹🇯', + english_name: 'Tajik', + name: 'тоҷикӣ', + }, + { + iso_639_1: 'th', + iso_639_2: 'tha', + iso_3166_1: 'TH', + flag: '🇹🇭', + english_name: 'Thai', + name: 'ภาษาไทย', + }, + { + iso_639_1: 'ti', + iso_639_2: 'tir', + iso_3166_1: 'ER', + flag: '🇪🇷', + english_name: 'Tigrinya', + name: 'ትግርኛ', + }, + { + iso_639_1: 'tk', + iso_639_2: 'tuk', + iso_3166_1: 'TM', + flag: '🇹🇲', + english_name: 'Turkmen', + name: 'Türkmen', + }, + { + iso_639_1: 'tl', + iso_639_2: 'tgl', + iso_3166_1: 'PH', + flag: '🇵🇭', + english_name: 'Tagalog', + name: 'Wikang Tagalog', + }, + { + iso_639_1: 'tn', + iso_639_2: 'tsn', + iso_3166_1: 'BW', + flag: '🇧🇼', + english_name: 'Tswana', + name: 'Setswana', + }, + { + iso_639_1: 'to', + iso_639_2: 'ton', + iso_3166_1: 'TO', + flag: '🇹🇴', + english_name: 'Tonga (Tonga Islands)', + name: 'Faka Tonga', + }, + { + iso_639_1: 'tr', + iso_639_2: 'tur', + iso_3166_1: 'TR', + flag: '🇹🇷', + english_name: 'Turkish', + name: 'Türkçe', + }, + { + iso_639_1: 'ts', + iso_639_2: 'tso', + iso_3166_1: 'ZA', + flag: '🇿🇦', + english_name: 'Tsonga', + name: 'Xitsonga', + }, + { + iso_639_1: 'tt', + iso_639_2: 'tat', + iso_3166_1: 'RU', + flag: '🇷🇺', + english_name: 'Tatar', + name: 'Татар теле', + }, + { + iso_639_1: 'tw', + iso_639_2: 'twi', + iso_3166_1: 'GH', + flag: '🇬🇭', + english_name: 'Twi', + name: 'Twi', + }, + { + iso_639_1: 'ty', + iso_639_2: 'tah', + iso_3166_1: 'PF', + flag: '🇵🇫', + english_name: 'Tahitian', + name: 'Reo Tahiti', + }, + + // U + { + iso_639_1: 'ug', + iso_639_2: 'uig', + iso_3166_1: 'CN', + flag: '🇨🇳', + english_name: 'Uighur; Uyghur', + name: 'Uyƣurqə', + }, + { + iso_639_1: 'uk', + iso_639_2: 'ukr', + iso_3166_1: 'UA', + flag: '🇺🇦', + english_name: 'Ukrainian', + name: 'Український', + }, + { + iso_639_1: 'ur', + iso_639_2: 'urd', + iso_3166_1: 'PK', + flag: '🇵🇰', + english_name: 'Urdu', + name: 'اردو', + }, + { + iso_639_1: 'uz', + iso_639_2: 'uzb', + iso_3166_1: 'UZ', + flag: '🇺🇿', + english_name: 'Uzbek', + name: 'Oʻzbek', + }, + + // V + { + iso_639_1: 've', + iso_639_2: 'ven', + iso_3166_1: 'ZA', + flag: '🇿🇦', + english_name: 'Venda', + name: 'Tshivenḓa', + }, + { + iso_639_1: 'vi', + iso_639_2: 'vie', + iso_3166_1: 'VN', + flag: '🇻🇳', + english_name: 'Vietnamese', + name: 'Tiếng Việt', + }, + { + iso_639_1: 'vo', + iso_639_2: 'vol', + iso_3166_1: null, + flag: '🌐', + english_name: 'Volapük', + name: 'Volapük', + }, + + // W + { + iso_639_1: 'wa', + iso_639_2: 'wln', + iso_3166_1: 'BE', + flag: '🇧🇪', + english_name: 'Walloon', + name: 'Walon', + }, + { + iso_639_1: 'wo', + iso_639_2: 'wol', + iso_3166_1: 'SN', + flag: '🇸🇳', + english_name: 'Wolof', + name: 'Wolof', + }, + + // X + { + iso_639_1: 'xh', + iso_639_2: 'xho', + iso_3166_1: 'ZA', + flag: '🇿🇦', + english_name: 'Xhosa', + name: 'IsiXhosa', + }, + + // Y + { + iso_639_1: 'yi', + iso_639_2: 'yid', + iso_3166_1: 'IL', + flag: '🇮🇱', + english_name: 'Yiddish', + name: 'ייִדיש', + }, + { + iso_639_1: 'yo', + iso_639_2: 'yor', + iso_3166_1: 'NG', + flag: '🇳🇬', + english_name: 'Yoruba', + name: 'Èdè Yorùbá', + }, + + // Z + { + iso_639_1: 'za', + iso_639_2: 'zha', + iso_3166_1: 'CN', + flag: '🇨🇳', + english_name: 'Zhuang; Chuang', + name: 'Saɯ cueŋƅ', + }, + { + iso_639_1: 'zh', + iso_639_2: 'zho', + iso_3166_1: 'CN', + flag: '🇨🇳', + flag_priority: true, + english_name: 'Chinese (Simplified)', + name: '中文 (简体)', + }, + + { + iso_639_1: 'zh', + iso_639_2: 'zho', + iso_3166_1: 'TW', + flag: '🇹🇼', + english_name: 'Chinese (Traditional)', + name: '中文 (繁體)', + }, + { + iso_639_1: 'zh', + iso_639_2: 'zho', + iso_3166_1: 'HK', + flag: '🇭🇰', + english_name: 'Chinese (Hong Kong)', + name: '中文 (香港)', + }, + { + iso_639_1: 'zh', + iso_639_2: 'zho', + iso_3166_1: 'SG', + flag: '🇸🇬', + english_name: 'Chinese (Singapore)', + name: '中文 (新加坡)', + }, + { + iso_639_1: 'zu', + iso_639_2: 'zul', + iso_3166_1: 'ZA', + flag: '🇿🇦', + english_name: 'Zulu', + name: 'IsiZulu', + }, + + // Additional languages not in your original list but commonly used + { + iso_639_1: 'sw', + iso_639_2: 'swa', + iso_3166_1: 'TZ', + flag: '🇹🇿', + english_name: 'Swahili (Tanzania)', + name: 'Kiswahili', + }, + { + iso_639_1: 'fr', + iso_639_2: 'fra', + iso_3166_1: 'CA', + flag: '🇨🇦', + english_name: 'French (Canada)', + name: 'Français', + }, + { + iso_639_1: 'fr', + iso_639_2: 'fra', + iso_3166_1: 'BE', + flag: '🇧🇪', + english_name: 'French (Belgium)', + name: 'Français', + }, + { + iso_639_1: 'fr', + iso_639_2: 'fra', + iso_3166_1: 'CH', + flag: '🇨🇭', + english_name: 'French (Switzerland)', + name: 'Français', + }, + { + iso_639_1: 'de', + iso_639_2: 'deu', + iso_3166_1: 'AT', + flag: '🇦🇹', + english_name: 'German (Austria)', + name: 'Deutsch', + }, + { + iso_639_1: 'de', + iso_639_2: 'deu', + iso_3166_1: 'CH', + flag: '🇨🇭', + english_name: 'German (Switzerland)', + name: 'Deutsch', + }, + { + iso_639_1: 'it', + iso_639_2: 'ita', + iso_3166_1: 'CH', + flag: '🇨🇭', + english_name: 'Italian (Switzerland)', + name: 'Italiano', + }, + { + iso_639_1: 'es', + iso_639_2: 'spa', + iso_3166_1: 'MX', + flag: '🇲🇽', + english_name: 'Spanish (Mexico)', + internal_english_name: 'Latino', + name: 'Español', + }, + { + iso_639_1: 'es', + iso_639_2: 'spa', + iso_3166_1: 'AR', + flag: '🇦🇷', + english_name: 'Spanish (Argentina)', + name: 'Español', + }, + { + iso_639_1: 'es', + iso_639_2: 'spa', + iso_3166_1: 'CO', + flag: '🇨🇴', + english_name: 'Spanish (Colombia)', + name: 'Español', + }, + { + iso_639_1: 'es', + iso_639_2: 'spa', + iso_3166_1: 'CL', + flag: '🇨🇱', + english_name: 'Spanish (Chile)', + name: 'Español', + }, + { + iso_639_1: 'es', + iso_639_2: 'spa', + iso_3166_1: 'PE', + flag: '🇵🇪', + english_name: 'Spanish (Peru)', + name: 'Español', + }, + { + iso_639_1: 'es', + iso_639_2: 'spa', + iso_3166_1: 'VE', + flag: '🇻🇪', + english_name: 'Spanish (Venezuela)', + name: 'Español', + }, + { + iso_639_1: 'ar', + iso_639_2: 'ara', + iso_3166_1: 'MA', + flag: '🇲🇦', + english_name: 'Arabic (Morocco)', + name: 'العربية', + }, + { + iso_639_1: 'ar', + iso_639_2: 'ara', + iso_3166_1: 'DZ', + flag: '🇩🇿', + english_name: 'Arabic (Algeria)', + name: 'العربية', + }, + { + iso_639_1: 'ar', + iso_639_2: 'ara', + iso_3166_1: 'TN', + flag: '🇹🇳', + english_name: 'Arabic (Tunisia)', + name: 'العربية', + }, + { + iso_639_1: 'ar', + iso_639_2: 'ara', + iso_3166_1: 'LY', + flag: '🇱🇾', + english_name: 'Arabic (Libya)', + name: 'العربية', + }, + { + iso_639_1: 'ar', + iso_639_2: 'ara', + iso_3166_1: 'JO', + flag: '🇯🇴', + english_name: 'Arabic (Jordan)', + name: 'العربية', + }, + { + iso_639_1: 'ar', + iso_639_2: 'ara', + iso_3166_1: 'SY', + flag: '🇸🇾', + english_name: 'Arabic (Syria)', + name: 'العربية', + }, + { + iso_639_1: 'ar', + iso_639_2: 'ara', + iso_3166_1: 'LB', + flag: '🇱🇧', + english_name: 'Arabic (Lebanon)', + name: 'العربية', + }, + { + iso_639_1: 'ar', + iso_639_2: 'ara', + iso_3166_1: 'IQ', + flag: '🇮🇶', + english_name: 'Arabic (Iraq)', + name: 'العربية', + }, + { + iso_639_1: 'ar', + iso_639_2: 'ara', + iso_3166_1: 'KW', + flag: '🇰🇼', + english_name: 'Arabic (Kuwait)', + name: 'العربية', + }, + { + iso_639_1: 'ar', + iso_639_2: 'ara', + iso_3166_1: 'BH', + flag: '🇧🇭', + english_name: 'Arabic (Bahrain)', + name: 'العربية', + }, + { + iso_639_1: 'ar', + iso_639_2: 'ara', + iso_3166_1: 'QA', + flag: '🇶🇦', + english_name: 'Arabic (Qatar)', + name: 'العربية', + }, + { + iso_639_1: 'ar', + iso_639_2: 'ara', + iso_3166_1: 'OM', + flag: '🇴🇲', + english_name: 'Arabic (Oman)', + name: 'العربية', + }, + { + iso_639_1: 'ar', + iso_639_2: 'ara', + iso_3166_1: 'YE', + flag: '🇾🇪', + english_name: 'Arabic (Yemen)', + name: 'العربية', + }, + { + iso_639_1: 'ar', + iso_639_2: 'ara', + iso_3166_1: 'SD', + flag: '🇸🇩', + english_name: 'Arabic (Sudan)', + name: 'العربية', + }, + + // Languages without ISO 639-1 codes but with ISO 639-2 codes + { + iso_639_1: null, + iso_639_2: 'ace', + iso_3166_1: 'ID', + flag: '🇮🇩', + english_name: 'Achinese', + name: 'Bahsa Acèh', + }, + { + iso_639_1: null, + iso_639_2: 'ach', + iso_3166_1: 'UG', + flag: '🇺🇬', + english_name: 'Acoli', + name: 'Lwo', + }, + { + iso_639_1: null, + iso_639_2: 'ada', + iso_3166_1: 'GH', + flag: '🇬🇭', + english_name: 'Adangme', + name: 'Adangme', + }, + { + iso_639_1: null, + iso_639_2: 'ady', + iso_3166_1: 'RU', + flag: '🇷🇺', + english_name: 'Adyghe; Adygei', + name: 'Адыгэбзэ', + }, + { + iso_639_1: null, + iso_639_2: 'afa', + iso_3166_1: 'ET', + flag: '🇪🇹', + english_name: 'Afro-Asiatic languages', + name: 'Afro-Asiatic', + }, + { + iso_639_1: null, + iso_639_2: 'afh', + iso_3166_1: 'GH', + flag: '🇬🇭', + english_name: 'Afrihili', + name: 'Afrihili', + }, + { + iso_639_1: null, + iso_639_2: 'agq', + iso_3166_1: 'CM', + flag: '🇨🇲', + english_name: 'Aghem', + name: 'Aghem', + }, + { + iso_639_1: null, + iso_639_2: 'ain', + iso_3166_1: 'JP', + flag: '🇯🇵', + english_name: 'Ainu', + name: 'アイヌ イタㇰ', + }, + { + iso_639_1: null, + iso_639_2: 'akk', + iso_3166_1: 'IQ', + flag: '🇮🇶', + english_name: 'Akkadian', + name: 'Akkadian', + }, + { + iso_639_1: null, + iso_639_2: 'ale', + iso_3166_1: 'US', + flag: '🇺🇸', + english_name: 'Aleut', + name: 'Unangam Tunuu', + }, + { + iso_639_1: null, + iso_639_2: 'alt', + iso_3166_1: 'RU', + flag: '🇷🇺', + english_name: 'Southern Altai', + name: 'Алтай тил', + }, + { + iso_639_1: null, + iso_639_2: 'ang', + iso_3166_1: 'GB', + flag: '🇬🇧', + english_name: 'English, Old (ca.450-1100)', + name: 'Englisc', + }, + { + iso_639_1: null, + iso_639_2: 'anp', + iso_3166_1: 'IN', + flag: '🇮🇳', + english_name: 'Angika', + name: 'अंगिका', + }, + { + iso_639_1: null, + iso_639_2: 'arc', + iso_3166_1: 'SY', + flag: '🇸🇾', + english_name: 'Aramaic', + name: 'ܐܪܡܝܐ', + }, + { + iso_639_1: null, + iso_639_2: 'arn', + iso_3166_1: 'CL', + flag: '🇨🇱', + english_name: 'Mapudungun; Mapuche', + name: 'Mapudungun', + }, + { + iso_639_1: null, + iso_639_2: 'arp', + iso_3166_1: 'US', + flag: '🇺🇸', + english_name: 'Arapaho', + name: 'Hinónoʼeitíít', + }, + { + iso_639_1: null, + iso_639_2: 'arw', + iso_3166_1: 'GY', + flag: '🇬🇾', + english_name: 'Arawak', + name: 'Arawak', + }, + { + iso_639_1: null, + iso_639_2: 'ast', + iso_3166_1: 'ES', + flag: '🇪🇸', + english_name: 'Asturian; Bable; Leonese; Asturleonese', + name: 'Asturianu', + }, +]; diff --git a/packages/core/src/utils/logger.ts b/packages/core/src/utils/logger.ts new file mode 100644 index 0000000000000000000000000000000000000000..9957155f590b5190e12d9267e42b5931ca45f8dc --- /dev/null +++ b/packages/core/src/utils/logger.ts @@ -0,0 +1,146 @@ +import winston from 'winston'; +import moment from 'moment-timezone'; +import { Env } from './env'; + +// Map log levels to their full names +const levelMap: { [key: string]: string } = { + error: 'ERROR', + warn: 'WARNING', + info: 'INFO', + debug: 'DEBUG', + verbose: 'VERBOSE', + silly: 'SILLY', + http: 'HTTP', +}; + +const moduleMap: { [key: string]: string } = { + startup: '🚀 STARTUP', + server: '🌐 SERVER', + wrappers: '📦 WRAPPERS', + crypto: '🔒 CRYPTO', + core: '⚡ CORE', + parser: '🔍 PARSER', + mediaflow: '🌊 MEDIAFLOW', + stremthru: '✨ STREMTHRU', + cache: '🗄️ CACHE', + regex: '🅰️ REGEX', + database: '🗃️ DATABASE', + users: '👤 USERS', + http: '🌐 HTTP', + proxy: '🚀 PROXY', + stremio: '🎥 STREMIO', + deduplicator: '🎯 DEDUPLICATOR', + limiter: '⚖️ LIMITER', + filterer: '🗑️ FILTERER', + precomputer: '🧮 PRECOMPUTER', + sorter: '📊 SORTER', + proxifier: '🔀 PROXIFIER', + fetcher: '🔎 SCRAPER', +}; + +// Define colors for each log level using full names +const levelColors: { [key: string]: string } = { + ERROR: 'red', + WARNING: 'yellow', + INFO: 'cyan', + DEBUG: 'magenta', + HTTP: 'green', + VERBOSE: 'blue', + SILLY: 'grey', +}; + +const emojiLevelMap: { [key: string]: string } = { + error: '❌', + warn: '⚠️ ', + info: '🔵', + debug: '🐞', + verbose: '🔍', + silly: '🤪', + http: '🌐', +}; + +// Calculate the maximum level name length for padding +const MAX_LEVEL_LENGTH = Math.max( + ...Object.values(levelMap).map((level) => level.length) +); + +// Apply colors to Winston +winston.addColors(levelColors); + +export const createLogger = (module: string) => { + const isJsonFormat = Env.LOG_FORMAT === 'json'; + const timezone = Env.LOG_TIMEZONE; + + const timestampFormat = winston.format((info) => { + info.timestamp = moment().tz(timezone).format('YYYY-MM-DD HH:mm:ss.SSS z'); + return info; + }); + + return winston.createLogger({ + level: Env.LOG_LEVEL, + format: isJsonFormat + ? winston.format.combine(timestampFormat(), winston.format.json()) + : winston.format.combine( + timestampFormat(), + winston.format.printf(({ timestamp, level, message, ...rest }) => { + const emoji = emojiLevelMap[level] || ''; + const formattedModule = moduleMap[module] || module; + // Get full level name and pad it for centering + const fullLevel = levelMap[level] || level.toUpperCase(); + const padding = Math.floor( + (MAX_LEVEL_LENGTH - fullLevel.length) / 2 + ); + const paddedLevel = + ' '.repeat(padding) + + fullLevel + + ' '.repeat(MAX_LEVEL_LENGTH - fullLevel.length - padding); + + // Apply color to the padded level + const coloredLevel = winston.format + .colorize() + .colorize(fullLevel, paddedLevel); + + const formatLine = (line: unknown) => { + return `${emoji} | ${coloredLevel} | ${timestamp} | ${formattedModule} | ${line} ${ + rest ? `${formatJsonToStyledString(rest)}` : '' + }`; + }; + if (typeof message === 'string') { + return message.split('\n').map(formatLine).join('\n'); + } else if (typeof message === 'object') { + return formatLine(formatJsonToStyledString(message)); + } + return formatLine(message); + }) + ), + transports: [new winston.transports.Console()], + }); +}; + +function formatJsonToStyledString(json: any) { + // return json.formatted + if (json.formatted) { + return json.formatted; + } + // extract keys and values, display space separated key=value pairs + const keys = Object.keys(json); + const values = keys.map((key) => `${key}=${json[key]}`); + return values.join(' '); +} + +export function maskSensitiveInfo(message: string) { + if (Env.LOG_SENSITIVE_INFO) { + return message; + } + return ''; +} + +export const getTimeTakenSincePoint = (point: number) => { + const timeNow = new Date().getTime(); + const duration = timeNow - point; + if (duration < 1000) { + return `${duration.toFixed(2)}ms`; + } else { + return `${(duration / 1000).toFixed(2)}s`; + } +}; diff --git a/packages/core/src/utils/metadata.ts b/packages/core/src/utils/metadata.ts new file mode 100644 index 0000000000000000000000000000000000000000..5dfc6f91368d9b51c0078773e82f2e64320c88c9 --- /dev/null +++ b/packages/core/src/utils/metadata.ts @@ -0,0 +1,222 @@ +import { Env } from './env'; +import { Cache } from './cache'; +import { TYPES } from './constants'; + +export type ExternalIdType = 'imdb' | 'tmdb' | 'tvdb'; + +interface ExternalId { + type: ExternalIdType; + value: string; +} + +const API_BASE_URL = 'https://api.themoviedb.org/3'; +const FIND_BY_ID_PATH = '/find'; +const MOVIE_DETAILS_PATH = '/movie'; +const TV_DETAILS_PATH = '/tv'; +const ALTERNATIVE_TITLES_PATH = '/alternative_titles'; + +// Cache TTLs in seconds +const ID_CACHE_TTL = 24 * 60 * 60; // 24 hours +const TITLE_CACHE_TTL = 7 * 24 * 60 * 60; // 7 days +const ACCESS_TOKEN_CACHE_TTL = 2 * 24 * 60 * 60; // 2 day + +export interface Metadata { + titles: string[]; + year?: string; +} + +export class TMDBMetadata { + private readonly TMDB_ID_REGEX = /^(?:tmdb)[-:](\d+)(?::\d+:\d+)?$/; + private readonly TVDB_ID_REGEX = /^(?:tvdb)[-:](\d+)(?::\d+:\d+)?$/; + private readonly IMDB_ID_REGEX = /^(?:tt)(\d+)(?::\d+:\d+)?$/; + private readonly idCache: Cache; + private readonly metadataCache: Cache; + private readonly accessToken: string; + private readonly validationCache: Cache; + public constructor(accessToken?: string) { + if (!accessToken && !Env.TMDB_ACCESS_TOKEN) { + throw new Error('TMDB Access Token is not set'); + } + this.accessToken = (accessToken || Env.TMDB_ACCESS_TOKEN)!; + this.idCache = Cache.getInstance('tmdb_id_conversion'); + this.metadataCache = Cache.getInstance('tmdb_metadata'); + this.validationCache = Cache.getInstance( + 'tmdb_validation' + ); + } + + private getHeaders(): Record { + return { + Authorization: `Bearer ${this.accessToken}`, + }; + } + + private parseExternalId(id: string): ExternalId | null { + if (this.TMDB_ID_REGEX.test(id)) { + const match = id.match(this.TMDB_ID_REGEX); + return match ? { type: 'tmdb', value: match[1] } : null; + } + if (this.IMDB_ID_REGEX.test(id)) { + const match = id.match(this.IMDB_ID_REGEX); + return match ? { type: 'imdb', value: `tt${match[1]}` } : null; + } + if (this.TVDB_ID_REGEX.test(id)) { + const match = id.match(this.TVDB_ID_REGEX); + return match ? { type: 'tvdb', value: match[1] } : null; + } + return null; + } + + private async convertToTmdbId( + id: ExternalId, + type: (typeof TYPES)[number] + ): Promise { + if (id.type === 'tmdb') { + return id.value; + } + + // Check cache first + const cacheKey = `${id.type}:${id.value}:${type}`; + const cachedId = this.idCache.get(cacheKey); + if (cachedId) { + return cachedId; + } + + const url = new URL(API_BASE_URL + FIND_BY_ID_PATH + `/${id.value}`); + url.searchParams.set('external_source', `${id.type}_id`); + + const response = await fetch(url, { + headers: this.getHeaders(), + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) { + throw new Error(`${response.status} - ${response.statusText}`); + } + + const data = await response.json(); + const results = type === 'movie' ? data.movie_results : data.tv_results; + const meta = results?.[0]; + + if (!meta) { + throw new Error(`No ${type} metadata found for ID: ${id.value}`); + } + + const tmdbId = meta.id.toString(); + // Cache the result + this.idCache.set(cacheKey, tmdbId, ID_CACHE_TTL); + return tmdbId; + } + + private parseReleaseDate(releaseDate: string): string { + const date = new Date(releaseDate); + return date.getFullYear().toString(); + } + + public async getMetadata( + id: string, + type: (typeof TYPES)[number] + ): Promise { + if (!['movie', 'series', 'anime'].includes(type)) { + return { titles: [], year: undefined }; + } + + let metadata: Metadata = { titles: [], year: undefined }; + + const externalId = this.parseExternalId(id); + if (!externalId) { + throw new Error( + 'Invalid ID format. Must be TMDB (tmdb:123) or IMDB (tt123) or TVDB (tvdb:123) format' + ); + } + + const tmdbId = await this.convertToTmdbId(externalId, type); + + // Check cache first + const cacheKey = `${tmdbId}:${type}`; + const cachedMetadata = this.metadataCache.get(cacheKey); + if (cachedMetadata) { + metadata = cachedMetadata; + } + + // Fetch primary title from details endpoint + const detailsUrl = new URL( + API_BASE_URL + + (type === 'movie' ? MOVIE_DETAILS_PATH : TV_DETAILS_PATH) + + `/${tmdbId}` + ); + + const detailsResponse = await fetch(detailsUrl, { + headers: this.getHeaders(), + signal: AbortSignal.timeout(10000), + }); + + if (!detailsResponse.ok) { + throw new Error(`Failed to fetch details: ${detailsResponse.statusText}`); + } + + const detailsData = await detailsResponse.json(); + const primaryTitle = + type === 'movie' ? detailsData.title : detailsData.name; + const year = this.parseReleaseDate( + type === 'movie' ? detailsData.release_date : detailsData.first_air_date + ); + + // Fetch alternative titles + const altTitlesUrl = new URL( + API_BASE_URL + + (type === 'movie' ? MOVIE_DETAILS_PATH : TV_DETAILS_PATH) + + `/${tmdbId}` + + ALTERNATIVE_TITLES_PATH + ); + + const altTitlesResponse = await fetch(altTitlesUrl, { + headers: this.getHeaders(), + signal: AbortSignal.timeout(10000), + }); + + if (!altTitlesResponse.ok) { + throw new Error( + `Failed to fetch alternative titles: ${altTitlesResponse.statusText}` + ); + } + + const altTitlesData = await altTitlesResponse.json(); + const alternativeTitles = + type === 'movie' + ? altTitlesData.titles.map((title: any) => title.title) + : altTitlesData.results.map((title: any) => title.title); + + // Combine primary title with alternative titles, ensuring no duplicates + const allTitles = [primaryTitle, ...alternativeTitles]; + const uniqueTitles = [...new Set(allTitles)]; + metadata.titles = uniqueTitles; + metadata.year = year; + + // Cache the result + this.metadataCache.set(cacheKey, metadata, TITLE_CACHE_TTL); + return metadata; + } + + public async validateAccessToken() { + const cacheKey = this.accessToken; + const cachedResult = this.validationCache.get(cacheKey); + if (cachedResult) { + return cachedResult; + } + const url = new URL(API_BASE_URL + '/authentication'); + const validationResponse = await fetch(url, { + headers: this.getHeaders(), + signal: AbortSignal.timeout(10000), + }); + if (!validationResponse.ok) { + throw new Error( + `Failed to validate TMDB access token: ${validationResponse.statusText}` + ); + } + const validationData = await validationResponse.json(); + const isValid = validationData.success; + this.validationCache.set(cacheKey, isValid, ACCESS_TOKEN_CACHE_TTL); + return isValid; + } +} diff --git a/packages/core/src/utils/regex.ts b/packages/core/src/utils/regex.ts new file mode 100644 index 0000000000000000000000000000000000000000..7f591bf36a3a85a9278f3260218c12895cc7a4d1 --- /dev/null +++ b/packages/core/src/utils/regex.ts @@ -0,0 +1,85 @@ +import { isMatch, firstMatch } from 'super-regex'; +import { Cache } from './cache'; +import { getSimpleTextHash } from './crypto'; +import { createLogger } from './logger'; +import { Env } from './env'; + +const DEFAULT_TIMEOUT = 1000; // 1 second timeout +const regexCache = Cache.getInstance('regexCache', 1_000); +const resultCache = Cache.getInstance( + 'regexResultCache', + 1_000_000 +); + +const logger = createLogger('regex'); + +/** + * Safely tests a regex pattern against a string with ReDoS protection + * @param pattern The regex pattern to test + * @param str The string to test against + * @param timeoutMs Optional timeout in milliseconds (default: 1000ms) + * @returns boolean indicating if the pattern matches the string + */ +export async function safeRegexTest( + pattern: string | RegExp, + str: string, + timeoutMs: number = DEFAULT_TIMEOUT +): Promise { + const compiledPattern = + typeof pattern === 'string' ? await compileRegex(pattern) : pattern; + try { + return await resultCache.wrap( + (p: RegExp, s: string) => isMatch(p, s, { timeout: timeoutMs }), + getSimpleTextHash(`${compiledPattern.source}|${str}`), + 100, + compiledPattern, + str + ); + } catch (error) { + logger.error(`Regex test timed out after ${timeoutMs}ms:`, error); + return false; + } +} +// parses regex and flags, also checks for existence of a custom flag - n - for negate +export function parseRegex(pattern: string): { + regex: string; + flags: string; +} { + const regexFormatMatch = /^\/(.+)\/([gimuyn]*)$/.exec(pattern); + return regexFormatMatch + ? { regex: regexFormatMatch[1], flags: regexFormatMatch[2] } + : { regex: pattern, flags: '' }; +} + +export async function compileRegex( + pattern: string, + bypassCache: boolean = false +): Promise { + let { regex, flags } = parseRegex(pattern); + // the n flag is not to be used when compiling the regex + if (flags.includes('n')) { + flags = flags.replace('n', ''); + } + if (bypassCache) { + return new RegExp(regex, flags); + } + + return await regexCache.wrap( + (p: string, f: string) => new RegExp(p, f || undefined), + getSimpleTextHash(`${regex}|${flags}`), + 60, + regex, + flags + ); +} + +export async function formRegexFromKeywords( + keywords: string[] +): Promise { + const pattern = `/(? filter.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')) + .map((filter) => filter.replace(/\s/g, '[ .\\-_]?')) + .join('|')})(?=[ \\)\\]_.-]|$)/i`; + + return await compileRegex(pattern); +} diff --git a/packages/core/src/utils/resources.ts b/packages/core/src/utils/resources.ts new file mode 100644 index 0000000000000000000000000000000000000000..4abe572b7964e02d690c84f8cb954ce9e4790559 --- /dev/null +++ b/packages/core/src/utils/resources.ts @@ -0,0 +1,18 @@ +import fs from 'fs'; +import path from 'path'; + +export class ResourceManager { + static getResource(resourceName: string) { + // check existence + const filePath = path.join( + __dirname, + '../../../../', + 'resources', + resourceName + ); + if (!fs.existsSync(filePath)) { + throw new Error(`Resource ${resourceName} not found at ${filePath}`); + } + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } +} diff --git a/packages/core/src/utils/rpdb.ts b/packages/core/src/utils/rpdb.ts new file mode 100644 index 0000000000000000000000000000000000000000..afbacc41a0c58331c6bedbf18cd9b84dfae73146 --- /dev/null +++ b/packages/core/src/utils/rpdb.ts @@ -0,0 +1,91 @@ +import { Cache } from './cache'; +import { makeRequest } from './http'; +import { RPDBIsValidResponse } from '../db/schemas'; +import { Env } from './env'; +export type IdType = 'imdb' | 'tmdb' | 'tvdb'; + +interface Id { + type: IdType; + value: string; +} + +const apiKeyValidationCache = Cache.getInstance('rpdbApiKey'); + +export class RPDB { + private readonly apiKey: string; + private readonly TMDB_ID_REGEX = /^(?:tmdb)[-:](\d+)(?::\d+:\d+)?$/; + private readonly TVDB_ID_REGEX = /^(?:tvdb)[-:](\d+)(?::\d+:\d+)?$/; + private readonly IMDB_ID_REGEX = /^(?:tt)(\d+)(?::\d+:\d+)?$/; + constructor(apiKey: string) { + this.apiKey = apiKey; + if (!this.apiKey) { + throw new Error('RPDB API key is not set'); + } + } + + public async validateApiKey() { + const cached = apiKeyValidationCache.get(this.apiKey); + if (cached) { + return cached; + } + + const response = await makeRequest( + `https://api.ratingposterdb.com/${this.apiKey}/isValid`, + 5000, + undefined, + undefined, + true + ); + if (!response.ok) { + throw new Error( + `Invalid RPDB API key: ${response.status} - ${response.statusText}` + ); + } + + const data = RPDBIsValidResponse.parse(await response.json()); + if (!data.valid) { + throw new Error('Invalid RPDB API key'); + } + + apiKeyValidationCache.set( + this.apiKey, + data.valid, + Env.RPDB_API_KEY_VALIDITY_CACHE_TTL + ); + } + /** + * + * @param id - the id of the item to get the poster for, if it is of a supported type, the rpdb poster will be returned, otherwise null + */ + public getPosterUrl(type: string, id: string): string | null { + const parsedId = this.getParsedId(id, type); + if (!parsedId) { + return null; + } + if (parsedId.type === 'tvdb' && type === 'movie') { + // rpdb doesnt seem to support tvdb for movies + return null; + } + const posterUrl = `https://api.ratingposterdb.com/${this.apiKey}/${parsedId.type}/poster-default/${parsedId.value}.jpg?fallback=true`; + return posterUrl; + } + + private getParsedId(id: string, type: string): Id | null { + if (this.TMDB_ID_REGEX.test(id)) { + const match = id.match(this.TMDB_ID_REGEX); + if (['movie', 'series'].includes(type)) { + return match ? { type: 'tmdb', value: `${type}-${match[1]}` } : null; + } + return null; + } + if (this.IMDB_ID_REGEX.test(id)) { + const match = id.match(this.IMDB_ID_REGEX); + return match ? { type: 'imdb', value: `tt${match[1]}` } : null; + } + if (this.TVDB_ID_REGEX.test(id)) { + const match = id.match(this.TVDB_ID_REGEX); + return match ? { type: 'tvdb', value: match[1] } : null; + } + return null; + } +} diff --git a/packages/core/src/utils/startup.ts b/packages/core/src/utils/startup.ts new file mode 100644 index 0000000000000000000000000000000000000000..41e8600928f643237d3b347ac63145bb5612843a --- /dev/null +++ b/packages/core/src/utils/startup.ts @@ -0,0 +1,1097 @@ +import { createLogger } from './logger'; +import { Env } from './env'; + +const logger = createLogger('startup'); + +const formatDuration = (seconds: number): string => { + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + return `${hours}h ${minutes}m ${secs}s`; +}; + +const formatMilliseconds = (ms: number): string => { + if (ms < 1000) return `${ms}ms`; + return formatDuration(ms / 1000); +}; + +const parseBlockedItems = ( + envVar: string | undefined +): Array<{ name: string; reason: string }> => { + if (!envVar) return []; + + return envVar.split(',').map((item) => { + const [name, ...reasonParts] = item.split(':'); + const reason = reasonParts.join(':') || 'No reason specified'; + return { name: name.trim(), reason: reason.trim() }; + }); +}; + +const logSection = ( + title: string, + icon: string, + content: () => void, + spacing = true +) => { + logger.info(`${icon} ${title}`); + content(); + if (spacing) logger.info(''); +}; + +const logKeyValue = ( + key: string, + value: string | number | boolean, + indent = ' ' +) => { + const formattedKey = key.padEnd(20); + logger.info(`${indent}${formattedKey} ${value}`); +}; + +const logStartupInfo = () => { + const currentTime = new Date().toISOString().replace('T', ' ').slice(0, 19); + + // Header + logger.info( + '╔═══════════════════════════════════════════════════════════════╗' + ); + logger.info( + '║ 🚀 AIOStreams Starting ║' + ); + logger.info( + '╚═══════════════════════════════════════════════════════════════╝' + ); + logger.info(''); + + // Core Information + logSection('CORE INFORMATION', '📋', () => { + logKeyValue('Version:', `${Env.VERSION} (${Env.TAG})`); + logKeyValue('Node Environment:', Env.NODE_ENV.toUpperCase()); + logKeyValue('Git Commit:', `${Env.GIT_COMMIT.slice(0, 8)}`); + logKeyValue('Build Time:', Env.BUILD_TIME); + logKeyValue('Commit Time:', Env.BUILD_COMMIT_TIME); + logKeyValue('Current Time:', `${currentTime} UTC`); + logKeyValue('User:', process.env.USER || 'system'); + logKeyValue('Node Version:', process.version); + logKeyValue('Platform:', `${process.platform} ${process.arch}`); + }); + + // Server Configuration + logSection('SERVER CONFIGURATION', '🌐', () => { + logKeyValue('Addon Name:', Env.ADDON_NAME); + logKeyValue('Addon ID:', Env.ADDON_ID); + logKeyValue('Port:', Env.PORT.toString()); + logKeyValue('Base URL:', Env.BASE_URL || 'Not configured'); + if (Env.ADDON_PROXY) { + logKeyValue('Proxy URL:', Env.ADDON_PROXY); + } + if (Env.ADDON_PROXY_CONFIG) { + logKeyValue('Proxy Config:', Env.ADDON_PROXY_CONFIG); + } + if (Env.CUSTOM_HTML) { + logKeyValue('Custom HTML:', '✅ Configured'); + } + }); + + // Database & Storage + logSection('DATABASE & STORAGE', '💾', () => { + const dbType = Env.DATABASE_URI.split('://')[0].toUpperCase(); + logKeyValue('Database Type:', dbType); + if (Env.DATABASE_URI.includes('sqlite')) { + const dbPath = + Env.DATABASE_URI.replace('sqlite://', '') || './data/db.sqlite'; + logKeyValue('Database Path:', dbPath); + } else { + logKeyValue( + 'Database URI:', + Env.DATABASE_URI.replace(/:\/\/.*@/, '://***@') + ); // Hide credentials + } + }); + + // Logging Configuration + logSection('LOGGING CONFIGURATION', '📝', () => { + logKeyValue('Log Level:', Env.LOG_LEVEL.toUpperCase()); + logKeyValue('Log Format:', Env.LOG_FORMAT.toUpperCase()); + logKeyValue('Log Timezone:', Env.LOG_TIMEZONE); + logKeyValue( + 'Sensitive Info:', + Env.LOG_SENSITIVE_INFO ? '⚠️ ENABLED' : '❌ Disabled' + ); + }); + + // Cache Configuration + logSection('CACHE CONFIGURATION', '⚡', () => { + logKeyValue('Max Cache Size:', Env.DEFAULT_MAX_CACHE_SIZE); + + // Proxy IP Cache + if (Env.PROXY_IP_CACHE_TTL === -1) { + logKeyValue('Proxy IP Cache:', '❌ DISABLED'); + } else { + logKeyValue('Proxy IP TTL:', formatDuration(Env.PROXY_IP_CACHE_TTL)); + } + + // Manifest Cache + if (Env.MANIFEST_CACHE_TTL === -1) { + logKeyValue('Manifest Cache:', '❌ DISABLED'); + } else { + logKeyValue('Manifest TTL:', formatDuration(Env.MANIFEST_CACHE_TTL)); + } + + // Stream Cache + if (Env.STREAM_CACHE_TTL === -1) { + logKeyValue('Stream Cache:', '❌ DISABLED'); + } else { + logKeyValue('Stream TTL:', formatDuration(Env.STREAM_CACHE_TTL)); + } + + // Subtitle Cache + if (Env.SUBTITLE_CACHE_TTL === -1) { + logKeyValue('Subtitle Cache:', '❌ DISABLED'); + } else { + logKeyValue('Subtitle TTL:', formatDuration(Env.SUBTITLE_CACHE_TTL)); + } + + // Catalog Cache + if (Env.CATALOG_CACHE_TTL === -1) { + logKeyValue('Catalog Cache:', '❌ DISABLED'); + } else { + logKeyValue('Catalog TTL:', formatDuration(Env.CATALOG_CACHE_TTL)); + } + + // Meta Cache + if (Env.META_CACHE_TTL === -1) { + logKeyValue('Meta Cache:', '❌ DISABLED'); + } else { + logKeyValue('Meta TTL:', formatDuration(Env.META_CACHE_TTL)); + } + + // Addon Catalog Cache + if (Env.ADDON_CATALOG_CACHE_TTL === -1) { + logKeyValue('Addon Catalog Cache:', '❌ DISABLED'); + } else { + logKeyValue( + 'Addon Catalog TTL:', + formatDuration(Env.ADDON_CATALOG_CACHE_TTL) + ); + } + + // RPDB API Cache + if (Env.RPDB_API_KEY_VALIDITY_CACHE_TTL === -1) { + logKeyValue('RPDB API Cache:', '❌ DISABLED'); + } else { + logKeyValue( + 'RPDB API TTL:', + formatDuration(Env.RPDB_API_KEY_VALIDITY_CACHE_TTL) + ); + } + }); + // Rate Limiting + if (!Env.DISABLE_RATE_LIMITS) { + logSection('RATE LIMITING', '🛡️', () => { + logKeyValue( + 'Static Files:', + `${Env.STATIC_RATE_LIMIT_MAX_REQUESTS}/${formatDuration(Env.STATIC_RATE_LIMIT_WINDOW)}` + ); + logKeyValue( + 'User API:', + `${Env.USER_API_RATE_LIMIT_MAX_REQUESTS}/${formatDuration(Env.USER_API_RATE_LIMIT_WINDOW)}` + ); + logKeyValue( + 'Stream API:', + `${Env.STREAM_API_RATE_LIMIT_MAX_REQUESTS}/${formatDuration(Env.STREAM_API_RATE_LIMIT_WINDOW)}` + ); + logKeyValue( + 'Format API:', + `${Env.FORMAT_API_RATE_LIMIT_MAX_REQUESTS}/${formatDuration(Env.FORMAT_API_RATE_LIMIT_WINDOW)}` + ); + logKeyValue( + 'Catalog API:', + `${Env.CATALOG_API_RATE_LIMIT_MAX_REQUESTS}/${formatDuration(Env.CATALOG_API_RATE_LIMIT_WINDOW)}` + ); + logKeyValue( + 'Stremio Stream:', + `${Env.STREMIO_STREAM_RATE_LIMIT_MAX_REQUESTS}/${formatDuration(Env.STREMIO_STREAM_RATE_LIMIT_WINDOW)}` + ); + logKeyValue( + 'Stremio Catalog:', + `${Env.STREMIO_CATALOG_RATE_LIMIT_MAX_REQUESTS}/${formatDuration(Env.STREMIO_CATALOG_RATE_LIMIT_WINDOW)}` + ); + logKeyValue( + 'Stremio Manifest:', + `${Env.STREMIO_MANIFEST_RATE_LIMIT_MAX_REQUESTS}/${formatDuration(Env.STREMIO_MANIFEST_RATE_LIMIT_WINDOW)}` + ); + logKeyValue( + 'Stremio Subtitle:', + `${Env.STREMIO_SUBTITLE_RATE_LIMIT_MAX_REQUESTS}/${formatDuration(Env.STREMIO_SUBTITLE_RATE_LIMIT_WINDOW)}` + ); + logKeyValue( + 'Stremio Meta:', + `${Env.STREMIO_META_RATE_LIMIT_MAX_REQUESTS}/${formatDuration(Env.STREMIO_META_RATE_LIMIT_WINDOW)}` + ); + }); + } else { + logSection( + 'RATE LIMITING', + '🛡️', + () => { + logKeyValue('Status:', '❌ DISABLED'); + }, + true + ); + } + + // Security & Access + logSection('SECURITY & ACCESS', '🔐', () => { + logKeyValue('Password Protected:', Env.ADDON_PASSWORD ? '✅ YES' : '❌ NO'); + logKeyValue('Secret Key:', Env.SECRET_KEY ? '✅ Configured' : '❌ Not set'); + logKeyValue('Regex Access:', Env.REGEX_FILTER_ACCESS.toUpperCase()); + + if (Env.TRUSTED_UUIDS) { + const trustedCount = Env.TRUSTED_UUIDS.split(',').length; + logKeyValue('Trusted UUIDs:', `${trustedCount} configured`); + } else { + logKeyValue('Trusted UUIDs:', '❌ None'); + } + + if (Object.keys(Env.ALIASED_CONFIGURATIONS).length > 0) { + logKeyValue( + 'Aliased Configs:', + `${Object.keys(Env.ALIASED_CONFIGURATIONS).length} configured` + ); + Object.entries(Env.ALIASED_CONFIGURATIONS).forEach( + ([alias, { uuid, password }]) => { + logKeyValue(` → ${alias}:`, `${uuid}:${password}`, ' '); + } + ); + } else { + logKeyValue('Aliased Configs:', '❌ None'); + } + }); + + // System Limits + logSection('SYSTEM LIMITS', '📊', () => { + logKeyValue('Max Addons:', Env.MAX_ADDONS.toString()); + logKeyValue('Max Groups:', Env.MAX_GROUPS.toString()); + logKeyValue('Max Keyword Filters:', Env.MAX_KEYWORD_FILTERS.toString()); + logKeyValue('Max Condition Filters:', Env.MAX_CONDITION_FILTERS.toString()); + logKeyValue( + 'Timeout Range:', + `${formatMilliseconds(Env.MIN_TIMEOUT)} - ${formatMilliseconds(Env.MAX_TIMEOUT)}` + ); + logKeyValue('Default Timeout:', formatMilliseconds(Env.DEFAULT_TIMEOUT)); + logKeyValue('Default User Agent:', Env.DEFAULT_USER_AGENT); + }); + + // Recursion Protection + logSection('RECURSION PROTECTION', '🔄', () => { + logKeyValue( + 'Self Scraping:', + Env.DISABLE_SELF_SCRAPING ? '❌ Disabled' : '✅ Enabled' + ); + logKeyValue('Threshold Limit:', Env.RECURSION_THRESHOLD_LIMIT.toString()); + logKeyValue('Time Window:', formatDuration(Env.RECURSION_THRESHOLD_WINDOW)); + }); + + // Blocked Items + const blockedHosts = parseBlockedItems(Env.DISABLED_HOSTS); + const blockedAddons = parseBlockedItems(Env.DISABLED_ADDONS); + const blockedServices = parseBlockedItems(Env.DISABLED_SERVICES); + + if ( + blockedHosts.length > 0 || + blockedAddons.length > 0 || + blockedServices.length > 0 + ) { + logSection('BLOCKED ITEMS', '🚫', () => { + if (blockedHosts.length > 0) { + logKeyValue('Blocked Hosts:', `${blockedHosts.length} items`); + blockedHosts.forEach(({ name, reason }) => { + logKeyValue(` → ${name}:`, reason, ' '); + }); + } else { + logKeyValue('Blocked Hosts:', '❌ None'); + } + + if (blockedAddons.length > 0) { + logKeyValue('Blocked Addons:', `${blockedAddons.length} items`); + blockedAddons.forEach(({ name, reason }) => { + logKeyValue(` → ${name}:`, reason, ' '); + }); + } else { + logKeyValue('Blocked Addons:', '❌ None'); + } + + if (blockedServices.length > 0) { + logKeyValue('Blocked Services:', `${blockedServices.length} items`); + blockedServices.forEach(({ name, reason }) => { + logKeyValue(` → ${name}:`, reason, ' '); + }); + } else { + logKeyValue('Blocked Services:', '❌ None'); + } + }); + } + + // Default Service Credentials + const defaultServices = [ + { name: 'RealDebrid', key: Env.DEFAULT_REALDEBRID_API_KEY }, + { name: 'AllDebrid', key: Env.DEFAULT_ALLDEBRID_API_KEY }, + { name: 'Premiumize', key: Env.DEFAULT_PREMIUMIZE_API_KEY }, + { name: 'DebridLink', key: Env.DEFAULT_DEBRIDLINK_API_KEY }, + { name: 'TorBox', key: Env.DEFAULT_TORBOX_API_KEY }, + { name: 'OffCloud', key: Env.DEFAULT_OFFCLOUD_API_KEY }, + { name: 'OffCloud Email', key: Env.DEFAULT_OFFCLOUD_EMAIL }, + { name: 'OffCloud Password', key: Env.DEFAULT_OFFCLOUD_PASSWORD }, + { name: 'PutIO Client', key: Env.DEFAULT_PUTIO_CLIENT_ID }, + { name: 'PutIO Secret', key: Env.DEFAULT_PUTIO_CLIENT_SECRET }, + { name: 'EasyNews', key: Env.DEFAULT_EASYNEWS_USERNAME }, + { name: 'EasyNews Password', key: Env.DEFAULT_EASYNEWS_PASSWORD }, + { name: 'EasyDebrid', key: Env.DEFAULT_EASYDEBRID_API_KEY }, + { name: 'PikPak', key: Env.DEFAULT_PIKPAK_EMAIL }, + { name: 'PikPak Password', key: Env.DEFAULT_PIKPAK_PASSWORD }, + { name: 'Seedr', key: Env.DEFAULT_SEEDR_ENCODED_TOKEN }, + ]; + + logSection('DEFAULT SERVICE CREDENTIALS', '🔑', () => { + const configuredServices = defaultServices.filter((service) => service.key); + if (configuredServices.length > 0) { + logKeyValue('Status:', '✅ Configured'); + configuredServices.forEach((service) => { + logKeyValue(service.name + ':', '✅ Configured'); + }); + } else { + logKeyValue('Status:', '❌ None configured'); + } + }); + + // Forced Service Credentials + const forcedServices = [ + { name: 'RealDebrid', key: Env.FORCED_REALDEBRID_API_KEY }, + { name: 'AllDebrid', key: Env.FORCED_ALLDEBRID_API_KEY }, + { name: 'Premiumize', key: Env.FORCED_PREMIUMIZE_API_KEY }, + { name: 'DebridLink', key: Env.FORCED_DEBRIDLINK_API_KEY }, + { name: 'TorBox', key: Env.FORCED_TORBOX_API_KEY }, + { name: 'OffCloud', key: Env.FORCED_OFFCLOUD_API_KEY }, + { name: 'OffCloud Email', key: Env.FORCED_OFFCLOUD_EMAIL }, + { name: 'OffCloud Password', key: Env.FORCED_OFFCLOUD_PASSWORD }, + { name: 'PutIO Client', key: Env.FORCED_PUTIO_CLIENT_ID }, + { name: 'PutIO Secret', key: Env.FORCED_PUTIO_CLIENT_SECRET }, + { name: 'EasyNews', key: Env.FORCED_EASYNEWS_USERNAME }, + { name: 'EasyNews Password', key: Env.FORCED_EASYNEWS_PASSWORD }, + { name: 'EasyDebrid', key: Env.FORCED_EASYDEBRID_API_KEY }, + { name: 'PikPak', key: Env.FORCED_PIKPAK_EMAIL }, + { name: 'PikPak Password', key: Env.FORCED_PIKPAK_PASSWORD }, + { name: 'Seedr', key: Env.FORCED_SEEDR_ENCODED_TOKEN }, + ]; + + const configuredForcedServices = forcedServices.filter( + (service) => service.key + ); + if (configuredForcedServices.length > 0) { + logSection('FORCED SERVICE CREDENTIALS', '🔒', () => { + logKeyValue('Status:', '✅ Configured'); + configuredForcedServices.forEach((service) => { + logKeyValue(service.name + ':', '⚠️ ENFORCED'); + }); + }); + } else { + logSection('FORCED SERVICE CREDENTIALS', '🔒', () => { + logKeyValue('Status:', '❌ None configured'); + }); + } + + // Proxy Configuration + const hasForceProxy = Env.FORCE_PROXY_ENABLED || Env.FORCE_PROXY_URL; + const hasDefaultProxy = Env.DEFAULT_PROXY_ENABLED || Env.DEFAULT_PROXY_URL; + + if (hasForceProxy || hasDefaultProxy) { + logSection('PROXY CONFIGURATION', '🌐', () => { + if (hasForceProxy) { + logKeyValue('Forced Proxy:', '⚠️ ENABLED'); + if (Env.FORCE_PROXY_ID) { + logKeyValue('Force Service:', Env.FORCE_PROXY_ID); + } + if (Env.FORCE_PROXY_URL) { + logKeyValue('Force URL:', Env.FORCE_PROXY_URL); + } + if (Env.FORCE_PROXY_CREDENTIALS) { + logKeyValue('Force Credentials:', '✅ Configured'); + } + if (Env.FORCE_PROXY_PUBLIC_IP) { + logKeyValue('Force Public IP:', Env.FORCE_PROXY_PUBLIC_IP); + } + logKeyValue( + 'Disable Proxied:', + Env.FORCE_PROXY_DISABLE_PROXIED_ADDONS ? '✅ Yes' : '❌ No' + ); + if (Env.FORCE_PROXY_PROXIED_SERVICES) { + logKeyValue( + 'Proxied Services:', + JSON.stringify(Env.FORCE_PROXY_PROXIED_SERVICES) + ); + } + } + + if (hasDefaultProxy) { + logKeyValue('Default Proxy:', '✅ CONFIGURED'); + if (Env.DEFAULT_PROXY_ID) { + logKeyValue('Default Service:', Env.DEFAULT_PROXY_ID); + } + if (Env.DEFAULT_PROXY_URL) { + logKeyValue('Default URL:', Env.DEFAULT_PROXY_URL); + } + if (Env.DEFAULT_PROXY_CREDENTIALS) { + logKeyValue('Default Credentials:', '✅ Configured'); + } + if (Env.DEFAULT_PROXY_PUBLIC_IP) { + logKeyValue('Default Public IP:', Env.DEFAULT_PROXY_PUBLIC_IP); + } + if (Env.DEFAULT_PROXY_PROXIED_SERVICES) { + logKeyValue( + 'Proxied Services:', + JSON.stringify(Env.DEFAULT_PROXY_PROXIED_SERVICES) + ); + } + } + }); + } + + // External Services Configuration + logSection('EXTERNAL SERVICES', '🌍', () => { + // Stremio Config + logKeyValue('Stremio Config Issuer:', Env.STREMIO_ADDONS_CONFIG_ISSUER); + logKeyValue( + 'Stremio Signature:', + Env.STREMIO_ADDONS_CONFIG_SIGNATURE ? '✅ Configured' : '❌ None' + ); + + // TMDB + logKeyValue( + 'TMDB Access Token:', + Env.TMDB_ACCESS_TOKEN ? '✅ Configured' : '❌ None' + ); + }); + + // Addon Sources + logSection('ADDONS', '🎬', () => { + // Comet + logKeyValue('Comet:', Env.COMET_URL); + if (Env.DEFAULT_COMET_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_COMET_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_COMET_USER_AGENT) { + logKeyValue(' User Agent:', Env.DEFAULT_COMET_USER_AGENT, ' '); + } + if (Env.FORCE_COMET_HOSTNAME) { + logKeyValue( + ' Force Host:', + `${Env.FORCE_COMET_PROTOCOL || 'https'}://${Env.FORCE_COMET_HOSTNAME}:${Env.FORCE_COMET_PORT || 443}`, + ' ' + ); + } + + // MediaFusion + logKeyValue('MediaFusion:', Env.MEDIAFUSION_URL); + if (Env.DEFAULT_MEDIAFUSION_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_MEDIAFUSION_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_MEDIAFUSION_USER_AGENT) { + logKeyValue(' User Agent:', Env.DEFAULT_MEDIAFUSION_USER_AGENT, ' '); + } + logKeyValue( + ' API Password:', + Env.MEDIAFUSION_API_PASSWORD ? '✅ Configured' : '❌ None', + ' ' + ); + if ( + Env.MEDIAFUSION_FORCED_USE_CACHED_RESULTS_ONLY || + Env.MEDIAFUSION_DEFAULT_USE_CACHED_RESULTS_ONLY + ) { + const value = + Env.MEDIAFUSION_FORCED_USE_CACHED_RESULTS_ONLY || + Env.MEDIAFUSION_DEFAULT_USE_CACHED_RESULTS_ONLY + ? '✅ Enabled' + : '❌ Disabled'; + logKeyValue( + ' Cached Searches Default:', + `${value}${Env.MEDIAFUSION_FORCED_USE_CACHED_RESULTS_ONLY ? ' ⚠️ ENFORCED' : ''}`, + ' ' + ); + } + + // Jackettio + logKeyValue('Jackettio:', Env.JACKETTIO_URL); + if (Env.DEFAULT_JACKETTIO_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_JACKETTIO_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_JACKETTIO_USER_AGENT) { + logKeyValue(' User Agent:', Env.DEFAULT_JACKETTIO_USER_AGENT, ' '); + } + logKeyValue( + ' Indexers:', + JSON.stringify(Env.DEFAULT_JACKETTIO_INDEXERS), + ' ' + ); + logKeyValue( + ' StremThru URL:', + Env.DEFAULT_JACKETTIO_STREMTHRU_URL, + ' ' + ); + if (Env.FORCE_JACKETTIO_HOSTNAME) { + logKeyValue( + ' Force Host:', + `${Env.FORCE_JACKETTIO_PROTOCOL || 'https'}://${Env.FORCE_JACKETTIO_HOSTNAME}:${Env.FORCE_JACKETTIO_PORT || 443}`, + ' ' + ); + } + + // Torrentio + logKeyValue('Torrentio:', Env.TORRENTIO_URL); + if (Env.DEFAULT_TORRENTIO_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_TORRENTIO_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_TORRENTIO_USER_AGENT) { + logKeyValue(' User Agent:', Env.DEFAULT_TORRENTIO_USER_AGENT, ' '); + } + + // Orion + logKeyValue('Orion:', Env.ORION_STREMIO_ADDON_URL); + if (Env.DEFAULT_ORION_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_ORION_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_ORION_USER_AGENT) { + logKeyValue(' User Agent:', Env.DEFAULT_ORION_USER_AGENT, ' '); + } + + // Peerflix + logKeyValue('Peerflix:', Env.PEERFLIX_URL); + if (Env.DEFAULT_PEERFLIX_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_PEERFLIX_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_PEERFLIX_USER_AGENT) { + logKeyValue(' User Agent:', Env.DEFAULT_PEERFLIX_USER_AGENT, ' '); + } + + // Torbox Stremio + logKeyValue('Torbox Stremio:', Env.TORBOX_STREMIO_URL); + if (Env.DEFAULT_TORBOX_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_TORBOX_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_TORBOX_USER_AGENT) { + logKeyValue(' User Agent:', Env.DEFAULT_TORBOX_USER_AGENT, ' '); + } + + // Easynews + logKeyValue('Easynews:', Env.EASYNEWS_URL); + if (Env.DEFAULT_EASYNEWS_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_EASYNEWS_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_EASYNEWS_USER_AGENT) { + logKeyValue(' User Agent:', Env.DEFAULT_EASYNEWS_USER_AGENT, ' '); + } + + // Easynews+ + logKeyValue('Easynews+:', Env.EASYNEWS_PLUS_URL); + if (Env.DEFAULT_EASYNEWS_PLUS_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_EASYNEWS_PLUS_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_EASYNEWS_PLUS_USER_AGENT) { + logKeyValue( + ' User Agent:', + Env.DEFAULT_EASYNEWS_PLUS_USER_AGENT, + ' ' + ); + } + + // Easynews++ + logKeyValue('Easynews++:', Env.EASYNEWS_PLUS_PLUS_URL); + if (Env.DEFAULT_EASYNEWS_PLUS_PLUS_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_EASYNEWS_PLUS_PLUS_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_EASYNEWS_PLUS_PLUS_USER_AGENT) { + logKeyValue( + ' User Agent:', + Env.DEFAULT_EASYNEWS_PLUS_PLUS_USER_AGENT, + ' ' + ); + } + if (Env.EASYNEWS_PLUS_PLUS_PUBLIC_URL) { + logKeyValue(' Public URL:', Env.EASYNEWS_PLUS_PLUS_PUBLIC_URL, ' '); + } + + // Debridio (Main) + logKeyValue('Debridio:', Env.DEBRIDIO_URL); + if (Env.DEFAULT_DEBRIDIO_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_DEBRIDIO_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_DEBRIDIO_USER_AGENT) { + logKeyValue(' User Agent:', Env.DEFAULT_DEBRIDIO_USER_AGENT, ' '); + } + + // Debridio TVDB + logKeyValue('Debridio TVDB:', Env.DEBRIDIO_TVDB_URL); + if (Env.DEFAULT_DEBRIDIO_TVDB_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_DEBRIDIO_TVDB_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_DEBRIDIO_TVDB_USER_AGENT) { + logKeyValue( + ' User Agent:', + Env.DEFAULT_DEBRIDIO_TVDB_USER_AGENT, + ' ' + ); + } + + // Debridio TMDB + logKeyValue('Debridio TMDB:', Env.DEBRIDIO_TMDB_URL); + if (Env.DEFAULT_DEBRIDIO_TMDB_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_DEBRIDIO_TMDB_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_DEBRIDIO_TMDB_USER_AGENT) { + logKeyValue( + ' User Agent:', + Env.DEFAULT_DEBRIDIO_TMDB_USER_AGENT, + ' ' + ); + } + + // Debridio TV + logKeyValue('Debridio TV:', Env.DEBRIDIO_TV_URL); + if (Env.DEFAULT_DEBRIDIO_TV_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_DEBRIDIO_TV_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_DEBRIDIO_TV_USER_AGENT) { + logKeyValue(' User Agent:', Env.DEFAULT_DEBRIDIO_TV_USER_AGENT, ' '); + } + + // Debridio Watchtower + logKeyValue('Debridio Watchtower:', Env.DEBRIDIO_WATCHTOWER_URL); + if (Env.DEFAULT_DEBRIDIO_WATCHTOWER_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_DEBRIDIO_WATCHTOWER_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_DEBRIDIO_WATCHTOWER_USER_AGENT) { + logKeyValue( + ' User Agent:', + Env.DEFAULT_DEBRIDIO_WATCHTOWER_USER_AGENT, + ' ' + ); + } + + // StremThru Store + logKeyValue('StremThru Store:', Env.STREMTHRU_STORE_URL); + if (Env.DEFAULT_STREMTHRU_STORE_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_STREMTHRU_STORE_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_STREMTHRU_STORE_USER_AGENT) { + logKeyValue( + ' User Agent:', + Env.DEFAULT_STREMTHRU_STORE_USER_AGENT, + ' ' + ); + } + if (Env.FORCE_STREMTHRU_STORE_HOSTNAME) { + logKeyValue( + ' Force Host:', + `${Env.FORCE_STREMTHRU_STORE_PROTOCOL || 'https'}://${Env.FORCE_STREMTHRU_STORE_HOSTNAME}:${Env.FORCE_STREMTHRU_STORE_PORT || 443}`, + ' ' + ); + } + if (Env.FORCE_STREMTHRU_STORE_PORT !== undefined) { + logKeyValue(' Force Port:', Env.FORCE_STREMTHRU_STORE_PORT, ' '); + } + if (Env.FORCE_STREMTHRU_STORE_PROTOCOL !== undefined) { + logKeyValue( + ' Force Protocol:', + Env.FORCE_STREMTHRU_STORE_PROTOCOL, + ' ' + ); + } + + // StremThru Torz + logKeyValue('StremThru Torz:', Env.STREMTHRU_TORZ_URL); + if (Env.DEFAULT_STREMTHRU_TORZ_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_STREMTHRU_TORZ_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_STREMTHRU_TORZ_USER_AGENT) { + logKeyValue( + ' User Agent:', + Env.DEFAULT_STREMTHRU_TORZ_USER_AGENT, + ' ' + ); + } + if (Env.FORCE_STREMTHRU_TORZ_HOSTNAME) { + logKeyValue( + ' Force Host:', + `${Env.FORCE_STREMTHRU_TORZ_PROTOCOL || 'https'}://${Env.FORCE_STREMTHRU_TORZ_HOSTNAME}:${Env.FORCE_STREMTHRU_TORZ_PORT || 443}`, + ' ' + ); + } + if (Env.FORCE_STREMTHRU_TORZ_PORT !== undefined) { + logKeyValue(' Force Port:', Env.FORCE_STREMTHRU_TORZ_PORT, ' '); + } + if (Env.FORCE_STREMTHRU_TORZ_PROTOCOL !== undefined) { + logKeyValue( + ' Force Protocol:', + Env.FORCE_STREMTHRU_TORZ_PROTOCOL, + ' ' + ); + } + + // StreamFusion + logKeyValue('StreamFusion:', Env.DEFAULT_STREAMFUSION_URL); + if (Env.DEFAULT_STREAMFUSION_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_STREAMFUSION_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_STREAMFUSION_USER_AGENT) { + logKeyValue( + ' User Agent:', + Env.DEFAULT_STREAMFUSION_USER_AGENT, + ' ' + ); + } + logKeyValue( + ' StremThru URL:', + Env.DEFAULT_STREAMFUSION_STREMTHRU_URL, + ' ' + ); + + // DMM Cast (Note: no URL env var, only timeout and user agent) + if (Env.DEFAULT_DMM_CAST_TIMEOUT || Env.DEFAULT_DMM_CAST_USER_AGENT) { + logKeyValue('DMM Cast:', 'Configuration only'); + if (Env.DEFAULT_DMM_CAST_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_DMM_CAST_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_DMM_CAST_USER_AGENT) { + logKeyValue(' User Agent:', Env.DEFAULT_DMM_CAST_USER_AGENT, ' '); + } + } + + // OpenSubtitles + logKeyValue('OpenSubtitles:', Env.OPENSUBTITLES_URL); + if (Env.DEFAULT_OPENSUBTITLES_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_OPENSUBTITLES_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_OPENSUBTITLES_USER_AGENT) { + logKeyValue( + ' User Agent:', + Env.DEFAULT_OPENSUBTITLES_USER_AGENT, + ' ' + ); + } + + // Marvel Universe + logKeyValue('Marvel Universe:', Env.MARVEL_UNIVERSE_URL); + if (Env.DEFAULT_MARVEL_CATALOG_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_MARVEL_CATALOG_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_MARVEL_CATALOG_USER_AGENT) { + logKeyValue( + ' User Agent:', + Env.DEFAULT_MARVEL_CATALOG_USER_AGENT, + ' ' + ); + } + + // DC Universe + logKeyValue('DC Universe:', Env.DC_UNIVERSE_URL); + if (Env.DEFAULT_DC_UNIVERSE_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_DC_UNIVERSE_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_DC_UNIVERSE_USER_AGENT) { + logKeyValue(' User Agent:', Env.DEFAULT_DC_UNIVERSE_USER_AGENT, ' '); + } + + // Star Wars Universe + logKeyValue('Star Wars Universe:', Env.DEFAULT_STAR_WARS_UNIVERSE_URL); + if (Env.DEFAULT_STAR_WARS_UNIVERSE_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_STAR_WARS_UNIVERSE_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_STAR_WARS_UNIVERSE_USER_AGENT) { + logKeyValue( + ' User Agent:', + Env.DEFAULT_STAR_WARS_UNIVERSE_USER_AGENT, + ' ' + ); + } + + // Anime Kitsu + logKeyValue('Anime Kitsu:', Env.ANIME_KITSU_URL); + if (Env.DEFAULT_ANIME_KITSU_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_ANIME_KITSU_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_ANIME_KITSU_USER_AGENT) { + logKeyValue(' User Agent:', Env.DEFAULT_ANIME_KITSU_USER_AGENT, ' '); + } + + // NuvioStreams + logKeyValue('NuvioStreams:', Env.NUVIOSTREAMS_URL); + if (Env.DEFAULT_NUVIOSTREAMS_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_NUVIOSTREAMS_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_NUVIOSTREAMS_USER_AGENT) { + logKeyValue( + ' User Agent:', + Env.DEFAULT_NUVIOSTREAMS_USER_AGENT, + ' ' + ); + } + + // Torrent Catalogs + logKeyValue('Torrent Catalogs:', Env.TORRENT_CATALOGS_URL); + if (Env.DEFAULT_TORRENT_CATALOGS_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_TORRENT_CATALOGS_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_TORRENT_CATALOGS_USER_AGENT) { + logKeyValue( + ' User Agent:', + Env.DEFAULT_TORRENT_CATALOGS_USER_AGENT, + ' ' + ); + } + + // TMDB Collections + logKeyValue('TMDB Collections:', Env.TMDB_COLLECTIONS_URL); + if (Env.DEFAULT_TMDB_COLLECTIONS_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_TMDB_COLLECTIONS_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_TMDB_COLLECTIONS_USER_AGENT) { + logKeyValue( + ' User Agent:', + Env.DEFAULT_TMDB_COLLECTIONS_USER_AGENT, + ' ' + ); + } + + // RPDB Catalogs + logKeyValue('RPDB Catalogs:', Env.RPDB_CATALOGS_URL); + if (Env.DEFAULT_RPDB_CATALOGS_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_RPDB_CATALOGS_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_RPDB_CATALOGS_USER_AGENT) { + logKeyValue( + ' User Agent:', + Env.DEFAULT_RPDB_CATALOGS_USER_AGENT, + ' ' + ); + } + + // Streaming Catalogs + logKeyValue('Streaming Catalogs:', Env.STREAMING_CATALOGS_URL); + if (Env.DEFAULT_STREAMING_CATALOGS_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_STREAMING_CATALOGS_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_STREAMING_CATALOGS_USER_AGENT) { + logKeyValue( + ' User Agent:', + Env.DEFAULT_STREAMING_CATALOGS_USER_AGENT, + ' ' + ); + } + + // Anime Catalogs + logKeyValue('Anime Catalogs:', Env.ANIME_CATALOGS_URL); + if (Env.DEFAULT_ANIME_CATALOGS_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_ANIME_CATALOGS_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_ANIME_CATALOGS_USER_AGENT) { + logKeyValue( + ' User Agent:', + Env.DEFAULT_ANIME_CATALOGS_USER_AGENT, + ' ' + ); + } + + // Doctor Who Universe + logKeyValue('Doctor Who Universe:', Env.DOCTOR_WHO_UNIVERSE_URL); + if (Env.DEFAULT_DOCTOR_WHO_UNIVERSE_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_DOCTOR_WHO_UNIVERSE_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_DOCTOR_WHO_UNIVERSE_USER_AGENT) { + logKeyValue( + ' User Agent:', + Env.DEFAULT_DOCTOR_WHO_UNIVERSE_USER_AGENT, + ' ' + ); + } + + // WebStreamr + logKeyValue('WebStreamr:', Env.WEBSTREAMR_URL); + if (Env.DEFAULT_WEBSTREAMR_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_WEBSTREAMR_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_WEBSTREAMR_USER_AGENT) { + logKeyValue(' User Agent:', Env.DEFAULT_WEBSTREAMR_USER_AGENT, ' '); + } + }); + + // Additional Features + const features: string[] = []; + if (Env.TMDB_ACCESS_TOKEN) features.push('TMDB Integration'); + if (Env.CUSTOM_HTML) features.push('Custom HTML'); + if (Env.ENCRYPT_MEDIAFLOW_URLS) features.push('Encrypt MediaFlow URLs'); + if (Env.ENCRYPT_STREMTHRU_URLS) features.push('Encrypt StremThru URLs'); + + if (features.length > 0) { + logSection('ADDITIONAL FEATURES', '✨', () => { + features.forEach((feature) => { + logKeyValue(feature + ':', '✅ ENABLED'); + }); + }); + } + + // Maintenance & Cleanup + logSection('MAINTENANCE', '🧹', () => { + if (Env.PRUNE_MAX_DAYS > 0) { + logKeyValue('Prune Interval:', formatDuration(Env.PRUNE_INTERVAL)); + logKeyValue('Prune Max Age:', `${Env.PRUNE_MAX_DAYS} days`); + } else { + logKeyValue('Pruning :', '❌ DISABLED'); + } + }); + + // Footer + logger.info( + '╔═══════════════════════════════════════════════════════════════╗' + ); + logger.info( + '║ 🎬 AIOStreams Ready! ║' + ); + logger.info( + '║ All systems initialized successfully ║' + ); + logger.info( + '╚═══════════════════════════════════════════════════════════════╝' + ); + logger.info(''); +}; + +export { logStartupInfo }; diff --git a/packages/core/src/wrapper.ts b/packages/core/src/wrapper.ts new file mode 100644 index 0000000000000000000000000000000000000000..96b7a1a58f61b0a8f4b52b16c863e05102e9f55a --- /dev/null +++ b/packages/core/src/wrapper.ts @@ -0,0 +1,320 @@ +import { + Addon, + AddonCatalog, + AddonCatalogResponse, + AddonCatalogResponseSchema, + AddonCatalogSchema, + CatalogResponse, + CatalogResponseSchema, + Manifest, + ManifestSchema, + Meta, + MetaPreview, + MetaPreviewSchema, + MetaResponse, + MetaResponseSchema, + MetaSchema, + ParsedStream, + Resource, + Stream, + StreamResponse, + StreamResponseSchema, + StreamSchema, + Subtitle, + SubtitleResponse, + SubtitleResponseSchema, + SubtitleSchema, +} from './db/schemas'; +import { + Cache, + makeRequest, + createLogger, + constants, + maskSensitiveInfo, + makeUrlLogSafe, + formatZodError, + PossibleRecursiveRequestError, + Env, +} from './utils'; +import { PresetManager } from './presets'; +import { StreamParser } from './parser'; +import { z } from 'zod'; + +const logger = createLogger('wrappers'); +// const cache = Cache.getInstance('wrappers'); +const manifestCache = Cache.getInstance('manifest'); +const resourceCache = Cache.getInstance('resources'); + +const RESOURCE_TTL = 5 * 60; + +type ResourceParams = { + type: string; + id: string; + extras?: string; +}; + +export class Wrapper { + private readonly baseUrl: string; + private readonly addon: Addon; + private readonly manifestUrl: string; + + constructor(addon: Addon) { + this.addon = addon; + this.manifestUrl = this.addon.manifestUrl.replace('stremio://', 'https://'); + this.baseUrl = this.manifestUrl.split('/').slice(0, -1).join('/'); + } + + /** + * Validates an array of items against a schema, filtering out invalid ones + * @param data The data to validate + * @param schema The Zod schema to validate against + * @param resourceName Name of the resource for error messages + * @returns Array of validated items + * @throws Error if all items are invalid + */ + private validateArray( + data: unknown, + schema: z.ZodSchema, + resourceName: string + ): T[] { + if (!Array.isArray(data)) { + throw new Error(`${resourceName} is not an array`); + } + + if (data.length === 0) { + // empty array is valid + return []; + } + + const validItems = data + .map((item) => { + const parsed = schema.safeParse(item); + if (!parsed.success) { + logger.error( + `An item in the response for ${resourceName} was invalid, filtering it out: ${formatZodError(parsed.error)}` + ); + return null; + } + return parsed.data; + }) + .filter((item): item is T => item !== null); + + if (validItems.length === 0) { + throw new Error(`No valid ${resourceName} found`); + } + + return validItems; + } + + async getManifest(): Promise { + return await manifestCache.wrap( + async () => { + logger.debug( + `Fetching manifest for ${this.addon.name} ${this.addon.displayIdentifier || this.addon.identifier} (${makeUrlLogSafe(this.manifestUrl)})` + ); + try { + const res = await makeRequest( + this.manifestUrl, + this.addon.timeout, + this.addon.headers, + this.addon.ip + ); + if (!res.ok) { + throw new Error(`${res.status} - ${res.statusText}`); + } + const data = await res.json(); + const manifest = ManifestSchema.safeParse(data); + if (!manifest.success) { + logger.error(`Manifest response was unexpected`); + logger.error(formatZodError(manifest.error)); + logger.error(JSON.stringify(data, null, 2)); + throw new Error( + `Failed to parse manifest for ${this.getAddonName(this.addon)}` + ); + } + return manifest.data; + } catch (error: any) { + logger.error( + `Failed to fetch manifest for ${this.getAddonName(this.addon)}: ${error.message}` + ); + if (error instanceof PossibleRecursiveRequestError) { + throw error; + } + throw new Error( + `Failed to fetch manifest for ${this.getAddonName(this.addon)}: ${error.message}` + ); + } + }, + this.manifestUrl, + Env.MANIFEST_CACHE_TTL + ); + } + + async getStreams(type: string, id: string): Promise { + const validator = (data: any): Stream[] => { + return this.validateArray(data.streams, StreamSchema, 'streams'); + }; + + const streams = await this.makeResourceRequest( + 'stream', + { type, id }, + validator, + Env.STREAM_CACHE_TTL != -1, + Env.STREAM_CACHE_TTL + ); + const Parser = this.addon.presetType + ? PresetManager.fromId(this.addon.presetType).getParser() + : StreamParser; + const parser = new Parser(this.addon); + return streams.map((stream: Stream) => parser.parse(stream)); + } + + async getCatalog( + type: string, + id: string, + extras?: string + ): Promise { + const validator = (data: any): MetaPreview[] => { + return this.validateArray(data.metas, MetaPreviewSchema, 'catalog items'); + }; + + return await this.makeResourceRequest( + 'catalog', + { type, id, extras }, + validator, + Env.CATALOG_CACHE_TTL != -1, + Env.CATALOG_CACHE_TTL + ); + } + + async getMeta(type: string, id: string): Promise { + const validator = (data: any): Meta => { + const parsed = MetaSchema.safeParse(data.meta); + if (!parsed.success) { + logger.error(formatZodError(parsed.error)); + throw new Error( + `Failed to parse meta for ${this.getAddonName(this.addon)}` + ); + } + return parsed.data; + }; + const meta: Meta = await this.makeResourceRequest( + 'meta', + { type, id }, + validator, + Env.META_CACHE_TTL != -1, + Env.META_CACHE_TTL + ); + return meta; + } + + async getSubtitles( + type: string, + id: string, + extras?: string + ): Promise { + const validator = (data: any): Subtitle[] => { + return this.validateArray(data.subtitles, SubtitleSchema, 'subtitles'); + }; + + return await this.makeResourceRequest( + 'subtitles', + { type, id, extras }, + validator, + Env.SUBTITLE_CACHE_TTL != -1, + Env.SUBTITLE_CACHE_TTL + ); + } + + async getAddonCatalog(type: string, id: string): Promise { + const validator = (data: any): AddonCatalog[] => { + return this.validateArray( + data.addons, + AddonCatalogSchema, + 'addon catalog items' + ); + }; + + return await this.makeResourceRequest( + 'addon_catalog', + { type, id }, + validator, + Env.ADDON_CATALOG_CACHE_TTL != -1, + Env.ADDON_CATALOG_CACHE_TTL + ); + } + + async makeRequest(url: string) { + return await makeRequest( + url, + this.addon.timeout, + this.addon.headers, + this.addon.ip + ); + } + + private async makeResourceRequest( + resource: Resource, + params: ResourceParams, + validator: (data: unknown) => T, + cache: boolean = false, + cacheTtl: number = RESOURCE_TTL + ) { + const { type, id, extras } = params; + const url = this.buildResourceUrl(resource, type, id, extras); + if (cache) { + const cached = resourceCache.get(url); + if (cached) { + logger.info( + `Returning cached ${resource} for ${this.getAddonName(this.addon)} (${makeUrlLogSafe(url)})` + ); + return cached; + } + } + logger.info( + `Fetching ${resource} of type ${type} with id ${id} and extras ${extras} (${makeUrlLogSafe(url)})` + ); + try { + const res = await makeRequest( + url, + this.addon.timeout, + this.addon.headers, + this.addon.ip + ); + if (!res.ok) { + logger.error( + `Failed to fetch ${resource} resource for ${this.getAddonName(this.addon)}: ${res.status} - ${res.statusText}` + ); + + throw new Error(`${res.status} - ${res.statusText}`); + } + const data: unknown = await res.json(); + + const validated = validator(data); + + if (cache) { + resourceCache.set(url, validated, cacheTtl); + } + return validated; + } catch (error: any) { + logger.error( + `Failed to fetch ${resource} resource for ${this.getAddonName(this.addon)}: ${error.message}` + ); + throw error; + } + } + + private buildResourceUrl( + resource: Resource, + type: string, + id: string, + extras?: string + ): string { + const extrasPath = extras ? `/${extras}` : ''; + return `${this.baseUrl}/${resource}/${type}/${encodeURIComponent(id)}${extrasPath}.json`; + } + + private getAddonName(addon: Addon): string { + return `${addon.name}${addon.displayIdentifier || addon.identifier ? ` ${addon.displayIdentifier || addon.identifier}` : ''}`; + } +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..e82c17b14bcbb906b68eba32cdddcd426457cb5d --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "resolveJsonModule": true + }, + "include": ["src/**/*"] +} diff --git a/packages/formatters/package.json b/packages/formatters/package.json new file mode 100644 index 0000000000000000000000000000000000000000..39e1df28fa1ef1bab2aad184f37415d6a8236e4a --- /dev/null +++ b/packages/formatters/package.json @@ -0,0 +1,14 @@ +{ + "name": "@aiostreams/formatters", + "version": "1.21.1", + "main": "./dist/index.js", + "scripts": { + "test": "vitest run --passWithNoTests", + "build": "tsc" + }, + "description": "Library to take parsed information and return a formatted Stremio stream name and description", + "dependencies": { + "@aiostreams/types": "^1.0.0", + "@aiostreams/utils": "^1.0.0" + } +} diff --git a/packages/formatters/src/custom.ts b/packages/formatters/src/custom.ts new file mode 100644 index 0000000000000000000000000000000000000000..5eff3271d40cfd47863b979447558362b4fd859d --- /dev/null +++ b/packages/formatters/src/custom.ts @@ -0,0 +1,556 @@ +import { Config, CustomFormatter, ParsedStream } from '@aiostreams/types'; +import { serviceDetails, Settings } from '@aiostreams/utils'; +import { formatDuration, formatSize, languageToEmoji } from './utils'; + +/** + * + * The custom formatter code in this file was adapted from https://github.com/diced/zipline/blob/trunk/src/lib/parser/index.ts + * + * The original code is licensed under the MIT License. + * + * MIT License + * + * Copyright (c) 2023 dicedtomato + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +export function customFormat( + stream: ParsedStream, + customFormatter: CustomFormatter +): { + name: string; + description: string; +} { + let name: string = ''; + let description: string = ''; + + // name + + const templateName = + parseString( + customFormatter.name || '', + convertStreamToParseValue(stream) + ) || ''; + + // description + const templateDescription = + parseString( + customFormatter.description || '', + convertStreamToParseValue(stream) + ) || ''; + + // Replace placeholders in the template with actual values + name = templateName; + + description = templateDescription; + + return { name, description }; +} + +export type ParseValue = { + config?: { + addonName: string | null; + showDie: boolean | null; + }; + stream?: { + /** @deprecated Use filename instead */ + name: string | null; + filename: string | null; + folderName: string | null; + size: number | null; + personal: boolean | null; + quality: string | null; + resolution: string | null; + languages: string[] | null; + languageEmojis: string[] | null; + visualTags: string[] | null; + audioTags: string[] | null; + releaseGroup: string | null; + regexMatched: string | null; + encode: string | null; + indexer: string | null; + year: string | null; + title: string | null; + season: number | null; + seasons: number[] | null; + episode: number | null; + seeders: number | null; + age: string | null; + duration: number | null; + infoHash: string | null; + message: string | null; + proxied: boolean | null; + }; + provider?: { + id: string | null; + shortName: string | null; + name: string | null; + cached: boolean | null; + }; + addon?: { + id: string; + name: string; + }; + debug?: { + json: string | null; + jsonf: string | null; + }; +}; + +const convertStreamToParseValue = (stream: ParsedStream): ParseValue => { + return { + config: { + addonName: Settings.ADDON_NAME, + showDie: Settings.SHOW_DIE, + }, + stream: { + filename: stream.filename || null, + name: stream.filename || null, + folderName: stream.folderName || null, + size: stream.size || null, + personal: stream.personal !== undefined ? stream.personal : null, + quality: stream.quality === 'Unknown' ? null : stream.quality, + resolution: stream.resolution === 'Unknown' ? null : stream.resolution, + languages: stream.languages || null, + languageEmojis: stream.languages + ? stream.languages + .map((lang) => languageToEmoji(lang) || lang) + .filter((value, index, self) => self.indexOf(value) === index) + : null, + visualTags: stream.visualTags, + audioTags: stream.audioTags, + releaseGroup: + stream.releaseGroup === 'Unknown' ? null : stream.releaseGroup, + regexMatched: stream.regexMatched?.name || null, + encode: stream.encode === 'Unknown' ? null : stream.encode, + indexer: stream.indexers || null, + seeders: stream.torrent?.seeders || null, + year: stream.year || null, + title: stream.title || null, + season: stream.season || null, + seasons: stream.seasons || null, + episode: stream.episode || null, + age: stream.usenet?.age || null, + duration: stream.duration || null, + infoHash: stream.torrent?.infoHash || null, + message: stream.message || null, + proxied: stream.proxied !== undefined ? stream.proxied : null, + }, + addon: { + id: stream.addon.id, + name: stream.addon.name, + }, + provider: { + id: stream.provider?.id || null, + shortName: stream.provider?.id + ? serviceDetails.find((service) => service.id === stream.provider?.id) + ?.shortName || null + : null, + name: stream.provider?.id + ? serviceDetails.find((service) => service.id === stream.provider?.id) + ?.name || null + : null, + cached: + stream.provider?.cached !== undefined ? stream.provider?.cached : null, + }, + }; +}; + +function parseString(str: string, value: ParseValue) { + if (!str) return null; + + const replacer = (key: string, value: unknown) => { + return value; + }; + + const data = { + stream: value.stream, + provider: value.provider, + addon: value.addon, + config: value.config, + }; + + value.debug = { + json: JSON.stringify(data, replacer), + jsonf: JSON.stringify(data, replacer, 2), + }; + + const re = + /\{(?stream|provider|debug|addon|config)\.(?\w+)(::(?(\w+(\([^)]*\))?|<|<=|=|>=|>|\^|\$|~|\/)+))?((::(?\S+?))|(?\[(?".*?")\|\|(?".*?")\]))?\}/gi; + let matches: RegExpExecArray | null; + + while ((matches = re.exec(str))) { + if (!matches.groups) continue; + + const index = matches.index as number; + + const getV = value[matches.groups.type as keyof ParseValue]; + + if (!getV) { + str = replaceCharsFromString(str, '{unknown_type}', index, re.lastIndex); + re.lastIndex = index; + continue; + } + + const v = + getV[ + matches.groups.prop as + | keyof ParseValue['stream'] + | keyof ParseValue['provider'] + | keyof ParseValue['addon'] + ]; + + if (v === undefined) { + str = replaceCharsFromString(str, '{unknown_value}', index, re.lastIndex); + re.lastIndex = index; + continue; + } + + if (matches.groups.mod) { + str = replaceCharsFromString( + str, + modifier( + matches.groups.mod, + v, + matches.groups.mod_tzlocale ?? undefined, + matches.groups.mod_check_true ?? undefined, + matches.groups.mod_check_false ?? undefined, + value + ), + index, + re.lastIndex + ); + re.lastIndex = index; + continue; + } + + str = replaceCharsFromString(str, v, index, re.lastIndex); + re.lastIndex = index; + } + + return str + .replace(/\\n/g, '\n') + .split('\n') + .filter( + (line) => line.trim() !== '' && !line.includes('{tools.removeLine}') + ) + .join('\n') + .replace(/\{tools.newLine\}/g, '\n'); +} + +function modifier( + mod: string, + value: unknown, + tzlocale?: string, + check_true?: string, + check_false?: string, + _value?: ParseValue +): string { + mod = mod.toLowerCase(); + check_true = check_true?.slice(1, -1); + check_false = check_false?.slice(1, -1); + + if (Array.isArray(value)) { + switch (true) { + case mod === 'join': + return value.join(', '); + case mod.startsWith('join(') && mod.endsWith(')'): + // Extract the separator from join(separator) + // e.g. join(' - ') + const separator = mod + .substring(5, mod.length - 1) + .replace(/^['"]|['"]$/g, ''); + return value.join(separator); + case mod == 'length': + return value.length.toString(); + case mod == 'first': + return value.length > 0 ? String(value[0]) : ''; + case mod == 'last': + return value.length > 0 ? String(value[value.length - 1]) : ''; + case mod == 'random': + return value.length > 0 + ? String(value[Math.floor(Math.random() * value.length)]) + : ''; + case mod == 'sort': + return [...value].sort().join(', '); + case mod == 'reverse': + return [...value].reverse().join(', '); + case mod == 'exists': { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_array_modifier(${mod})}`; + + if (_value) { + return value.length > 0 + ? parseString(check_true, _value) || check_true + : parseString(check_false, _value) || check_false; + } + + return value.length > 0 ? check_true : check_false; + } + default: + return `{unknown_array_modifier(${mod})}`; + } + } else if (typeof value === 'string') { + switch (true) { + case mod == 'upper': + return value.toUpperCase(); + case mod == 'lower': + return value.toLowerCase(); + case mod == 'title': + return value.charAt(0).toUpperCase() + value.slice(1); + case mod == 'length': + return value.length.toString(); + case mod == 'reverse': + return value.split('').reverse().join(''); + case mod == 'base64': + return btoa(value); + case mod == 'string': + return value; + case mod == 'exists': { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_str_modifier(${mod})}`; + + if (_value) { + return value != 'null' && value + ? parseString(check_true, _value) || check_true + : parseString(check_false, _value) || check_false; + } + + return value != 'null' && value ? check_true : check_false; + } + case mod.startsWith('='): { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_str_modifier(${mod})}`; + + const check = mod.replace('=', ''); + + if (!check) return `{unknown_str_modifier(${mod})}`; + + if (_value) { + return value.toLowerCase() == check + ? parseString(check_true, _value) || check_true + : parseString(check_false, _value) || check_false; + } + + return value.toLowerCase() == check ? check_true : check_false; + } + case mod.startsWith('$'): { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_str_modifier(${mod})}`; + + const check = mod.replace('$', ''); + + if (!check) return `{unknown_str_modifier(${mod})}`; + + if (_value) { + return value.toLowerCase().startsWith(check) + ? parseString(check_true, _value) || check_true + : parseString(check_false, _value) || check_false; + } + + return value.toLowerCase().startsWith(check) ? check_true : check_false; + } + case mod.startsWith('^'): { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_str_modifier(${mod})}`; + + const check = mod.replace('^', ''); + + if (!check) return `{unknown_str_modifier(${mod})}`; + + if (_value) { + return value.toLowerCase().endsWith(check) + ? parseString(check_true, _value) || check_true + : parseString(check_false, _value) || check_false; + } + + return value.toLowerCase().endsWith(check) ? check_true : check_false; + } + case mod.startsWith('~'): { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_str_modifier(${mod})}`; + + const check = mod.replace('~', ''); + + if (!check) return `{unknown_str_modifier(${mod})}`; + + if (_value) { + return value.toLowerCase().includes(check) + ? parseString(check_true, _value) || check_true + : parseString(check_false, _value) || check_false; + } + + return value.toLowerCase().includes(check) ? check_true : check_false; + } + default: + return `{unknown_str_modifier(${mod})}`; + } + } else if (typeof value === 'number') { + switch (true) { + case mod == 'comma': + return value.toLocaleString(); + case mod == 'hex': + return value.toString(16); + case mod == 'octal': + return value.toString(8); + case mod == 'binary': + return value.toString(2); + case mod == 'bytes': + return formatSize(value); + case mod == 'string': + return value.toString(); + case mod == 'time': + return formatDuration(value); + case mod.startsWith('>='): { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_int_modifier(${mod})}`; + + const check = Number(mod.replace('>=', '')); + + if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`; + + if (_value) { + return value >= check + ? parseString(check_true, _value) || check_true + : parseString(check_false, _value) || check_false; + } + + return value >= check ? check_true : check_false; + } + case mod.startsWith('>'): { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_int_modifier(${mod})}`; + + const check = Number(mod.replace('>', '')); + + if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`; + + if (_value) { + return value > check + ? parseString(check_true, _value) || check_true + : parseString(check_false, _value) || check_false; + } + + return value > check ? check_true : check_false; + } + case mod.startsWith('='): { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_int_modifier(${mod})}`; + + const check = Number(mod.replace('=', '')); + + if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`; + + if (_value) { + return value == check + ? parseString(check_true, _value) || check_true + : parseString(check_false, _value) || check_false; + } + + return value == check ? check_true : check_false; + } + case mod.startsWith('<='): { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_int_modifier(${mod})}`; + + const check = Number(mod.replace('<=', '')); + + if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`; + + if (_value) { + return value <= check + ? parseString(check_true, _value) || check_true + : parseString(check_false, _value) || check_false; + } + + return value <= check ? check_true : check_false; + } + case mod.startsWith('<'): { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_int_modifier(${mod})}`; + + const check = Number(mod.replace('<', '')); + + if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`; + + if (_value) { + return value < check + ? parseString(check_true, _value) || check_true + : parseString(check_false, _value) || check_false; + } + + return value < check ? check_true : check_false; + } + default: + return `{unknown_int_modifier(${mod})}`; + } + } else if (typeof value === 'boolean') { + switch (true) { + case mod == 'istrue': { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_bool_modifier(${mod})}`; + + if (_value) { + return value + ? parseString(check_true, _value) || check_true + : parseString(check_false, _value) || check_false; + } + + return value ? check_true : check_false; + } + case mod == 'isfalse': { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_bool_modifier(${mod})}`; + + if (_value) { + return !value + ? parseString(check_true, _value) || check_true + : parseString(check_false, _value) || check_false; + } + + return !value ? check_true : check_false; + } + default: + return `{unknown_bool_modifier(${mod})}`; + } + } + + if ( + typeof check_false == 'string' && + (['>', '>=', '=', '<=', '<', '~', '$', '^'].some((modif) => + mod.startsWith(modif) + ) || + ['istrue', 'exists', 'isfalse'].includes(mod)) + ) { + if (_value) return parseString(check_false, _value) || check_false; + return check_false; + } + + return `{unknown_modifier(${mod})}`; +} + +function replaceCharsFromString( + str: string, + replace: string, + start: number, + end: number +): string { + return str.slice(0, start) + replace + str.slice(end); +} diff --git a/packages/formatters/src/gdrive.ts b/packages/formatters/src/gdrive.ts new file mode 100644 index 0000000000000000000000000000000000000000..93fc85c0341ec24a0a8920c73fb3f86c8415018b --- /dev/null +++ b/packages/formatters/src/gdrive.ts @@ -0,0 +1,114 @@ +import { ParsedStream } from '@aiostreams/types'; +import { formatDuration, formatSize, languageToEmoji } from './utils'; +import { serviceDetails, Settings } from '@aiostreams/utils'; + +export function gdriveFormat( + stream: ParsedStream, + minimalistic: boolean = false +): { + name: string; + description: string; +} { + let name: string = ''; + + if (stream.provider) { + const cacheStatus = stream.provider.cached + ? '⚡' + : stream.provider.cached === undefined + ? '❓' + : '⏳'; + const serviceShortName = + serviceDetails.find((service) => service.id === stream.provider!.id) + ?.shortName || stream.provider.id; + name += `[${serviceShortName}${cacheStatus}] `; + } + + if (stream.torrent?.infoHash) { + name += `[P2P] `; + } + + name += `${stream.addon.name} ${stream.personal ? '(Your Media) ' : ''}`; + if (!minimalistic) { + name += stream.resolution; + } else { + name += stream.resolution !== 'Unknown' ? stream.resolution + '' : ''; + } + + // let description: string = `${stream.quality !== 'Unknown' ? '🎥 ' + stream.quality + ' ' : ''}${stream.encode !== 'Unknown' ? '🎞️ ' + stream.encode : ''}`; + let description: string = ''; + if ( + stream.quality || + stream.encode || + (stream.releaseGroup && !minimalistic) + ) { + description += stream.quality !== 'Unknown' ? `🎥 ${stream.quality} ` : ''; + description += stream.encode !== 'Unknown' ? `🎞️ ${stream.encode} ` : ''; + description += + stream.releaseGroup !== 'Unknown' && !minimalistic + ? `🏷️ ${stream.releaseGroup}` + : ''; + description += '\n'; + } + + if (stream.visualTags.length > 0 || stream.audioTags.length > 0) { + description += + stream.visualTags.length > 0 + ? `📺 ${stream.visualTags.join(' | ')} ` + : ''; + description += + stream.audioTags.length > 0 ? `🎧 ${stream.audioTags.join(' | ')}` : ''; + description += '\n'; + } + if ( + stream.size || + (stream.torrent?.seeders && !minimalistic) || + (minimalistic && stream.torrent?.seeders && !stream.provider?.cached) || + stream.usenet?.age || + stream.duration + ) { + description += `📦 ${formatSize(stream.size || 0)} `; + description += stream.duration + ? `⏱️ ${formatDuration(stream.duration)} ` + : ''; + description += + (stream.torrent?.seeders !== undefined && !minimalistic) || + (minimalistic && stream.torrent?.seeders && !stream.provider?.cached) + ? `👥 ${stream.torrent.seeders} ` + : ''; + + description += stream.usenet?.age ? `📅 ${stream.usenet.age} ` : ''; + description += + stream.indexers && !minimalistic ? `🔍 ${stream.indexers}` : ''; + description += '\n'; + } + + if (stream.languages.length !== 0) { + let languages = stream.languages; + if (minimalistic) { + languages = languages.map( + (language) => languageToEmoji(language) || language + ); + } + description += `🌎 ${languages.join(minimalistic ? ' / ' : ' | ')}`; + description += '\n'; + } + + if (!minimalistic && (stream.filename || stream.folderName)) { + description += stream.folderName ? `📁 ${stream.folderName}\n` : ''; + description += stream.filename ? `📄 ${stream.filename}\n` : '📄 Unknown\n'; + } + + if (stream.message) { + description += `📢 ${stream.message}`; + } + + if (stream.proxied) { + name = `🕵️‍♂️ ${name}`; + } else if (Settings.SHOW_DIE) { + name = `🎲 ${name}`; + } + + description = description.trim(); + name = name.trim(); + return { name, description }; +} diff --git a/packages/formatters/src/imposter.ts b/packages/formatters/src/imposter.ts new file mode 100644 index 0000000000000000000000000000000000000000..8e875fea6ab747577698fe536b35bd54c3e16547 --- /dev/null +++ b/packages/formatters/src/imposter.ts @@ -0,0 +1,71 @@ +import { ParsedStream } from '@aiostreams/types'; +import { formatDuration, formatSize } from './utils'; + +const imposters = [ + 'Disney+', + 'Netflix', + 'HBO', + 'Amazon Prime Video', + 'Hulu', + 'Apple TV+', + 'Peacock', + 'Paramount+', +]; + +export function imposterFormat(stream: ParsedStream): { + name: string; + description: string; +} { + let name: string = ''; + + if (stream.torrent?.infoHash) { + name += `[P2P] `; + } + const chosenImposter = + imposters[Math.floor(Math.random() * imposters.length)]; + name += `${chosenImposter} ${stream.personal ? '(Your Media) ' : ''}`; + + name += stream.resolution !== 'Unknown' ? stream.resolution + '' : ''; + + let description: string = `${stream.quality !== 'Unknown' ? '🎥 ' + stream.quality + ' ' : ''}${stream.encode !== 'Unknown' ? '🎞️ ' + stream.encode : ''}`; + + if (stream.visualTags.length > 0 || stream.audioTags.length > 0) { + description += '\n'; + + description += + stream.visualTags.length > 0 + ? `📺 ${stream.visualTags.join(' | ')} ` + : ''; + description += + stream.audioTags.length > 0 ? `🎧 ${stream.audioTags.join(' | ')}` : ''; + } + if ( + stream.size || + stream.torrent?.seeders || + stream.usenet?.age || + stream.duration + ) { + description += '\n'; + + description += `📦 ${formatSize(stream.size || 0)} `; + description += stream.duration + ? `⏱️ ${formatDuration(stream.duration)} ` + : ''; + description += stream.torrent?.seeders + ? `👥 ${stream.torrent.seeders}` + : ''; + + description += stream.usenet?.age ? `📅 ${stream.usenet.age}` : ''; + } + + if (stream.languages.length !== 0) { + let languages = stream.languages; + description += `\n🔊 ${languages.join(' | ')}`; + } + + description += `\n📄 ${stream.filename ? stream.filename : 'Unknown'}`; + if (stream.message) { + description += `\n📢${stream.message}`; + } + return { name, description }; +} diff --git a/packages/formatters/src/index.ts b/packages/formatters/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f65350d7934b562c135031cdda9a16e972e0c99a --- /dev/null +++ b/packages/formatters/src/index.ts @@ -0,0 +1,6 @@ +export * from './gdrive'; +export * from './utils'; +export * from './torrentio'; +export * from './torbox'; +export * from './imposter'; +export * from './custom'; diff --git a/packages/formatters/src/torbox.ts b/packages/formatters/src/torbox.ts new file mode 100644 index 0000000000000000000000000000000000000000..7c57e13c072000d7f7c72d57db782c00c34f6f87 --- /dev/null +++ b/packages/formatters/src/torbox.ts @@ -0,0 +1,51 @@ +import { ParsedStream } from '@aiostreams/types'; +import { formatSize } from './utils'; +import { serviceDetails, Settings } from '@aiostreams/utils'; + +export function torboxFormat(stream: ParsedStream): { + name: string; + description: string; +} { + let name: string = ''; + + name += `${stream.addon.name} `; + if (stream.provider) { + const serviceShortName = + serviceDetails.find((service) => service.id === stream.provider!.id) + ?.shortName || stream.provider.id; + name += `(${stream.provider.cached === undefined ? 'Unknown' : stream.provider.cached ? 'Instant' : ''} ${serviceShortName}) `; + } + + if (stream.torrent?.infoHash) { + name += `(P2P) `; + } + + name += `${stream.personal ? '(Your Media) ' : ''}(${stream.resolution})`; + + let description: string = ''; + + let streamType = ''; + if (stream?.torrent?.seeders) { + streamType = 'Torrent'; + } else if (stream?.usenet?.age) { + streamType = 'Usenet'; + } + + description += `Quality: ${stream.quality}\nName: ${stream.filename || 'Unknown'}\nSize: ${stream.size ? formatSize(stream.size) : 'Unknown'}${stream.indexers ? ` | Source: ${stream.indexers}` : ''}\nLanguage: ${stream.languages.length > 0 ? stream.languages.join(', ') : 'Unknown'}`; + + if (streamType === 'Torrent' || streamType === 'Usenet') { + description += `\nType: ${streamType} | ${streamType === 'Torrent' ? 'Seeders' : 'Age'}: ${streamType === 'Torrent' ? stream.torrent?.seeders : stream.usenet?.age}`; + } + + if (stream.message) { + description += `\n${stream.message}`; + } + + if (stream.proxied) { + name = `🕵️‍♂️ ${name}`; + } else if (Settings.SHOW_DIE) { + name = `🎲 ${name}`; + } + + return { name, description }; +} diff --git a/packages/formatters/src/torrentio.ts b/packages/formatters/src/torrentio.ts new file mode 100644 index 0000000000000000000000000000000000000000..4787e2c320542b339c35076eb562db14befaacba --- /dev/null +++ b/packages/formatters/src/torrentio.ts @@ -0,0 +1,75 @@ +import { ParsedStream } from '@aiostreams/types'; +import { formatSize, languageToEmoji } from './utils'; +import { serviceDetails, Settings } from '@aiostreams/utils'; + +export function torrentioFormat(stream: ParsedStream): { + name: string; + description: string; +} { + let name: string = ''; + + if (stream.provider) { + const cacheStatus = stream.provider.cached + ? '+' + : stream.provider.cached === undefined + ? ' Unknown' + : ' download'; + const serviceShortName = + serviceDetails.find((service) => service.id === stream.provider!.id) + ?.shortName || stream.provider.id; + name += `[${serviceShortName}${cacheStatus}] `; + } + + if (stream.torrent?.infoHash) { + name += '[P2P] '; + } + + name += `${stream.addon.name} ${stream.personal ? '(Your Media) ' : ''}${stream.resolution} `; + + if (stream.visualTags.length > 0) { + name += stream.visualTags.join(' | '); + } + let description = ''; + + if (stream.message) { + description += `\n${stream.message}`; + } + + if (stream.filename || stream.folderName) { + description += `\n${stream.folderName ? stream.folderName : ''}/${stream.filename ? stream.filename : ''}`; + } + if ( + stream.size || + stream.torrent?.seeders || + stream.usenet?.age || + stream.indexers + ) { + description += '\n'; + + description += + stream.torrent?.seeders !== undefined + ? `👤 ${stream.torrent.seeders} ` + : ''; + + description += stream.usenet?.age ? `📅 ${stream.usenet.age} ` : ''; + + description += `💾 ${formatSize(stream.size || 0)} `; + + description += stream.indexers ? `⚙️ ${stream.indexers} ` : ''; + } + const languageEmojis = stream.languages.map((lang) => { + const emoji = languageToEmoji(lang); + return emoji ? emoji : lang; + }); + if (languageEmojis.length > 0) { + description += `\n${languageEmojis.join(' / ')}`; + } + + if (stream.proxied) { + name = `🕵️‍♂️ ${name}`; + } else if (Settings.SHOW_DIE) { + name = `🎲 ${name}`; + } + + return { name, description }; +} diff --git a/packages/formatters/src/utils.ts b/packages/formatters/src/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..5d498f3b6b6b08ad5739c462a552f61e092d40f4 --- /dev/null +++ b/packages/formatters/src/utils.ts @@ -0,0 +1,146 @@ +export function formatSize(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +export function formatDuration(durationInMs: number): string { + const seconds = Math.floor(durationInMs / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + const formattedSeconds = seconds % 60; + const formattedMinutes = minutes % 60; + + if (hours > 0) { + return `${hours}h:${formattedMinutes}m:${formattedSeconds}s`; + } else if (formattedSeconds > 0) { + return `${formattedMinutes}m:${formattedSeconds}s`; + } else { + return `${formattedMinutes}m`; + } +} + +export function languageToEmoji(language: string): string | undefined { + return languageEmojiMap[language.toLowerCase()]; +} + +export function emojiToLanguage(emoji: string): string | undefined { + return Object.entries(languageEmojiMap).find( + ([_, value]) => value === emoji + )?.[0]; +} + +export function codeToLanguage(code: string): string | undefined { + return codeLanguageMap[code]; +} +/** + * A mapping of language names to their corresponding emoji flags. + * + * This mapping was adapted from the g0ldy/comet project. + * https://github.com/g0ldyy/comet/blob/de5413425ac30a9d88bc7176862a7ff02027eb7f/comet/utils/general.py#L19C1-L19C18 + */ +const languageEmojiMap: Record = { + multi: '🌎', + english: '🇬🇧', + japanese: '🇯🇵', + chinese: '🇨🇳', + russian: '🇷🇺', + arabic: '🇸🇦', + portuguese: '🇵🇹', + spanish: '🇪🇸', + french: '🇫🇷', + german: '🇩🇪', + italian: '🇮🇹', + korean: '🇰🇷', + hindi: '🇮🇳', + bengali: '🇧🇩', + punjabi: '🇵🇰', + marathi: '🇮🇳', + gujarati: '🇮🇳', + tamil: '🇮🇳', + telugu: '🇮🇳', + kannada: '🇮🇳', + malayalam: '🇮🇳', + thai: '🇹🇭', + vietnamese: '🇻🇳', + indonesian: '🇮🇩', + turkish: '🇹🇷', + hebrew: '🇮🇱', + persian: '🇮🇷', + ukrainian: '🇺🇦', + greek: '🇬🇷', + lithuanian: '🇱🇹', + latvian: '🇱🇻', + estonian: '🇪🇪', + polish: '🇵🇱', + czech: '🇨🇿', + slovak: '🇸🇰', + hungarian: '🇭🇺', + romanian: '🇷🇴', + bulgarian: '🇧🇬', + serbian: '🇷🇸', + croatian: '🇭🇷', + slovenian: '🇸🇮', + dutch: '🇳🇱', + danish: '🇩🇰', + finnish: '🇫🇮', + swedish: '🇸🇪', + norwegian: '🇳🇴', + malay: '🇲🇾', + latino: '💃🏻', + Latino: '🇲🇽', +}; + +const codeLanguageMap: Record = { + EN: 'english', + JA: 'japanese', + ZH: 'chinese', + RU: 'russian', + AR: 'arabic', + PT: 'portuguese', + ES: 'spanish', + FR: 'french', + DE: 'german', + IT: 'italian', + KO: 'korean', + HI: 'hindi', + BN: 'bengali', + PA: 'punjabi', + MR: 'marathi', + GU: 'gujarati', + TA: 'tamil', + TE: 'telugu', + KN: 'kannada', + ML: 'malayalam', + TH: 'thai', + VI: 'vietnamese', + ID: 'indonesian', + TR: 'turkish', + HE: 'hebrew', + FA: 'persian', + UK: 'ukrainian', + EL: 'greek', + LT: 'lithuanian', + LV: 'latvian', + ET: 'estonian', + PL: 'polish', + CS: 'czech', + SK: 'slovak', + HU: 'hungarian', + RO: 'romanian', + BG: 'bulgarian', + SR: 'serbian', + HR: 'croatian', + SL: 'slovenian', + NL: 'dutch', + DA: 'danish', + FI: 'finnish', + SV: 'swedish', + NO: 'norwegian', + MS: 'malay', + LA: 'latino', + MX: 'Latino', +}; diff --git a/packages/formatters/tsconfig.json b/packages/formatters/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..7ff8cfcbda617fd13adbbf6ca5b05a8bc2d13792 --- /dev/null +++ b/packages/formatters/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "resolveJsonModule": true + }, + "references": [ + { + "path": "../types" + }, + { + "path": "../utils" + } + ] +} diff --git a/packages/frontend/.gitignore b/packages/frontend/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..5ef6a520780202a1d6addd833d800ccb1ecac0bb --- /dev/null +++ b/packages/frontend/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/packages/frontend/README.md b/packages/frontend/README.md new file mode 100644 index 0000000000000000000000000000000000000000..ef0e47e31fa3d9a98a08c25c494309fe759bc0f7 --- /dev/null +++ b/packages/frontend/README.md @@ -0,0 +1,40 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/pages/api-reference/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. + +[API routes](https://nextjs.org/docs/pages/building-your-application/routing/api-routes) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. + +The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/pages/building-your-application/routing/api-routes) instead of React pages. + +This project uses [`next/font`](https://nextjs.org/docs/pages/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn-pages-router) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/pages/building-your-application/deploying) for more details. diff --git a/packages/frontend/eslint.config.mjs b/packages/frontend/eslint.config.mjs new file mode 100644 index 0000000000000000000000000000000000000000..7f86eca7f371124ef378638520a758e1cd78713f --- /dev/null +++ b/packages/frontend/eslint.config.mjs @@ -0,0 +1,16 @@ +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { FlatCompat } from '@eslint/eslintrc'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends('next/core-web-vitals', 'next/typescript'), +]; + +export default eslintConfig; diff --git a/packages/frontend/next.config.ts b/packages/frontend/next.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..0b22fc3615a8be79cc6e900cc38eb1297668ff95 --- /dev/null +++ b/packages/frontend/next.config.ts @@ -0,0 +1,19 @@ +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + /* config options here */ + reactStrictMode: false, + output: 'export', + images: { + unoptimized: true, + }, + webpack(config) { + config.resolve.fallback = { + ...config.resolve.fallback, + fs: false, + }; + return config; + }, +}; + +export default nextConfig; diff --git a/packages/frontend/package.json b/packages/frontend/package.json new file mode 100644 index 0000000000000000000000000000000000000000..6baa50f3fae1153e75e7e44d71138d3b315fad40 --- /dev/null +++ b/packages/frontend/package.json @@ -0,0 +1,64 @@ +{ + "name": "@aiostreams/frontend", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@aiostreams/core": "^0.0.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@headlessui/react": "^2.2.4", + "@headlessui/tailwindcss": "^0.2.2", + "@radix-ui/react-accordion": "^1.2.11", + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-password-toggle-field": "^0.1.2", + "@radix-ui/react-popover": "^1.1.14", + "@radix-ui/react-select": "^2.2.5", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slider": "^1.3.5", + "@radix-ui/react-switch": "^1.2.5", + "@radix-ui/react-tabs": "^1.1.12", + "@radix-ui/react-tooltip": "^1.2.7", + "@tailwindcss/forms": "^0.5.10", + "@tailwindcss/typography": "^0.5.16", + "@zag-js/number-input": "^1.13.1", + "@zag-js/react": "^1.13.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "framer-motion": "^12.12.2", + "lucide-react": "^0.511.0", + "next": "15.3.2", + "next-themes": "^0.4.6", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-icons": "^5.5.0", + "react-markdown": "^10.1.0", + "sonner": "^2.0.3", + "tailwind-merge": "^3.3.0", + "tailwind-scrollbar-hide": "~1.1.7", + "tailwindcss-animate": "~1.0.7" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "autoprefixer": "^10.4.21", + "eslint": "^9", + "eslint-config-next": "15.3.2", + "postcss": "^8.5.3", + "postcss-import": "^16.1.0", + "postcss-nesting": "^13.0.1", + "tailwindcss": "^3.4.17", + "typescript": "^5" + } +} diff --git a/packages/frontend/postcss.config.js b/packages/frontend/postcss.config.js new file mode 100644 index 0000000000000000000000000000000000000000..9cadf1bb5f30c1d375885327534c386e608d446e --- /dev/null +++ b/packages/frontend/postcss.config.js @@ -0,0 +1,8 @@ +module.exports = { + plugins: { + 'postcss-import': {}, + 'tailwindcss/nesting': 'postcss-nesting', + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/frontend/postcss.config.mjs b/packages/frontend/postcss.config.mjs new file mode 100644 index 0000000000000000000000000000000000000000..4f3e1750109d48b9c0835fee4f8a2884d06c8021 --- /dev/null +++ b/packages/frontend/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ['@tailwindcss/postcss', 'autoprefixer', 'tailwindcss'], +}; + +export default config; diff --git a/packages/frontend/public/assets/background.png b/packages/frontend/public/assets/background.png new file mode 100644 index 0000000000000000000000000000000000000000..d5689d3a796fd47c1524a8f13ed9645b9a0ac804 --- /dev/null +++ b/packages/frontend/public/assets/background.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a0dc6cc364e2c8ea5f1404364ccba71797188cdc6f3d110a08a974907f5d8bbf +size 232028 diff --git a/packages/frontend/public/assets/logo.png b/packages/frontend/public/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..465908b65d18a5262ad0ee59ba106d49c412c3b0 --- /dev/null +++ b/packages/frontend/public/assets/logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2fea644eaea85c963bf348f6c9481e6f43bcd14e94e10802d2d2ab89c5003e29 +size 40339 diff --git a/packages/frontend/public/favicon.ico b/packages/frontend/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..b4c0851d64470a577d4a76ecaf23d6daf7a07d82 --- /dev/null +++ b/packages/frontend/public/favicon.ico @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b19d8a2b8ef0c272fc570de343ce40a7ee94d489e4224d0bb7aae374184f070d +size 4286 diff --git a/packages/frontend/public/logo.png b/packages/frontend/public/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..465908b65d18a5262ad0ee59ba106d49c412c3b0 --- /dev/null +++ b/packages/frontend/public/logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2fea644eaea85c963bf348f6c9481e6f43bcd14e94e10802d2d2ab89c5003e29 +size 40339 diff --git a/packages/frontend/src/app/configure/page.module.css b/packages/frontend/src/app/configure/page.module.css new file mode 100644 index 0000000000000000000000000000000000000000..793d3b093d7c5d92d2a26aebf939c01a3a228158 --- /dev/null +++ b/packages/frontend/src/app/configure/page.module.css @@ -0,0 +1,276 @@ +.container { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + min-height: 100vh; +} + +.header { + text-align: center; + max-width: 800px; + border-radius: var(--borderRadius); + margin: 0 auto; /* Center the header within the content */ +} + +.content { + position: relative; + background-color: #000000; + border-radius: var(--borderRadius); + box-shadow: 0 8px 16px rgb(0, 0, 0); + padding: 20px; + width: 80%; + max-width: 700px; + margin: 20px; +} + +.branding { + text-align: center; + margin: 20px; + font-size: 16px; +} + +@media (max-width: 1000px) { + .content { + margin: 0; + border-radius: 0; + width: 100%; + } +} +.section { + margin-bottom: 20px; + border-width: 1px; + border-style: solid; + border-radius: var(--borderRadius); + padding: 10px; + border-color: #777777; +} + +.setting { + display: flex; + justify-content: space-between; + align-items: center; +} + +.settingDescription { + flex: 1; +} + +.settingInput { + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: flex-end; + margin: 0 10px; +} + +.settingInput select { + height: 30px; +} + +.slidersContainer { + display: flex; + flex-direction: column; + padding: 10px; +} + +.slidersSetting { + display: flex; + flex-direction: column; + width: 100%; +} + +.sliderValue { + margin-right: auto; + padding: 5px; +} + +.installButtons { + display: flex; + flex-direction: column; + justify-content: center; + gap: 20px; +} + +.installButton { + background-color: #ffffff; /* Green */ + border: none; + color: black; + padding: 15px 32px; + text-align: center; + text-decoration: none; + display: inline-block; + font-size: 16px; + margin: 4px 2px; + cursor: pointer; + border-radius: var(--borderRadius); + box-shadow: 0 4px 8px rgba(255, 255, 255, 0.111); + transition: + transform 0.2s, + box-shadow 0.2s, + opacity 0.2s, + background-color 0.2s; +} + +.installButton:hover { + transform: scale(1.02); + box-shadow: 0 8px 16px rgba(255, 255, 255, 0.225); +} + +.installButton:active { + transform: scale(0.98); +} + +.installButton:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + box-shadow: none; + background-color: #ddddddbe; +} + +.version { + position: absolute; + top: -5px; + right: -60px; + font-size: 14px; + color: black; + background-color: rgb(241, 241, 241); + font-weight: bold; + padding: 5px; + -webkit-tap-highlight-color: transparent; + border-radius: var(--borderRadius); + transition: + scale 0.2s ease, + background-color 0.2s ease, + transform 0.2s ease; +} + +@media (pointer: fine) { + .version:hover { + background-color: rgb(221, 221, 221); + transform: scale(1.1); + cursor: pointer; + } +} + +.version:active { + transform: scale(0.9); +} + +.mediaFlowConfig { + transition: + margin-top 0.6s, + opacity 0.6s, + transform 1s; +} + +.mediaFlowConfig.hidden { + opacity: 0; + margin-top: -655px; + transform: scale(0); + transition: + margin-top 0.5s, + opacity 0.2s, + transform 0.4s; +} + +.mediaFlowSection { + padding: 10px; + margin-top: 10px; + border-width: 1px; + border-style: solid; + border-radius: var(--borderRadius); + border-color: #777777; +} + +.stremThruConfig { + transition: + margin-top 0.6s, + opacity 0.6s, + transform 1s; +} + +.stremThruConfig.hidden { + opacity: 0; + margin-top: -626px; + transform: scale(0); + transition: + margin-top 0.5s, + opacity 0.2s, + transform 0.4s; +} + +.stremThruSection { + padding: 10px; + margin-top: 10px; + border-width: 1px; + border-style: solid; + border-radius: var(--borderRadius); + border-color: #777777; +} + +.supportMeButton { + position: absolute; + top: 20px; + right: 20px; + background-color: #080808; + border-style: solid; + border-width: 2px; + box-shadow: none; + margin: 1px; + color: white; + border-radius: var(--borderRadius); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; /* Space between the heart and text */ + padding: 8px 16px; /* Adjust padding as needed */ + border: 1px solid rgba(255, 255, 255, 0.2); /* Subtle border */ + font-size: 14px; /* Text size */ + font-family: Arial, sans-serif; /* Font family */ + transition: + background-color 0.3s, + color 0.3s, + border-color 0.3s; +} + +@media (pointer: fine) { + .supportMeButton:hover { + background-color: rgba( + 255, + 255, + 255, + 0.1 + ); /* Slight background highlight on hover */ + border-color: rgba(255, 255, 255, 0.815); /* More visible border on hover */ + } +} + +.supportMeButton:active { + background-color: rgba( + 255, + 255, + 255, + 0.2 + ); /* Slight background highlight on click */ + border-color: rgba(255, 255, 255, 0.815); /* More visible border on click */ +} + +.input { + width: 100%; + padding: 8px; + margin: 4px 0; + border: 1px solid #ccc; + border-radius: 4px; + font-family: monospace; +} + +.helpText { + font-size: 0.8em; + color: #666; + margin-top: 4px; + font-family: monospace; + white-space: pre-line; +} diff --git a/packages/frontend/src/app/configure/page.tsx b/packages/frontend/src/app/configure/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..028e4420dbd0929ee956b050fca89e6b9a98b670 --- /dev/null +++ b/packages/frontend/src/app/configure/page.tsx @@ -0,0 +1,1701 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +'use client'; + +import { useState, useEffect } from 'react'; +import Image from 'next/image'; +import styles from './page.module.css'; +import { + Config, + Resolution, + SortBy, + Quality, + VisualTag, + AudioTag, + Encode, + ServiceDetail, + ServiceCredential, + StreamType, +} from '@aiostreams/types'; +import SortableCardList from '../../components/SortableCardList'; +import ServiceInput from '../../components/ServiceInput'; +import AddonsList from '../../components/AddonsList'; +import { Slide, ToastContainer, toast } from 'react-toastify'; +import showToast, { toastOptions } from '@/components/Toasts'; +import addonPackage from '../../../../../package.json'; +import { formatSize } from '@aiostreams/formatters'; +import { + allowedFormatters, + allowedLanguages, + validateConfig, +} from '@aiostreams/config'; +import { + addonDetails, + isValueEncrypted, + serviceDetails, + Settings, +} from '@aiostreams/utils'; + +import Slider from '@/components/Slider'; +import CredentialInput from '@/components/CredentialInput'; +import CreateableSelect from '@/components/CreateableSelect'; +import MultiSelect from '@/components/MutliSelect'; +import InstallWindow from '@/components/InstallWindow'; +import FormatterPreview from '@/components/FormatterPreview'; +import CustomFormatter from '@/components/CustomFormatter'; + +const version = addonPackage.version; + +interface Option { + label: string; + value: string; +} + +const defaultQualities: Quality[] = [ + { 'BluRay REMUX': true }, + { BluRay: true }, + { 'WEB-DL': true }, + { WEBRip: true }, + { HDRip: true }, + { 'HC HD-Rip': true }, + { DVDRip: true }, + { HDTV: true }, + { CAM: true }, + { TS: true }, + { TC: true }, + { SCR: true }, + { Unknown: true }, +]; + +const defaultVisualTags: VisualTag[] = [ + { 'HDR+DV': true }, + { 'HDR10+': true }, + { DV: true }, + { HDR10: true }, + { HDR: true }, + { '10bit': true }, + { '3D': true }, + { IMAX: true }, + { AI: true }, + { SDR: true }, +]; + +const defaultAudioTags: AudioTag[] = [ + { Atmos: true }, + { 'DD+': true }, + { DD: true }, + { 'DTS-HD MA': true }, + { 'DTS-HD': true }, + { DTS: true }, + { TrueHD: true }, + { '5.1': true }, + { '7.1': true }, + { FLAC: true }, + { AAC: true }, +]; + +const defaultEncodes: Encode[] = [ + { AV1: true }, + { HEVC: true }, + { AVC: true }, + { Xvid: true }, + { DivX: true }, + { 'H-OU': true }, + { 'H-SBS': true }, + { Unknown: true }, +]; + +const defaultSortCriteria: SortBy[] = [ + { cached: true, direction: 'desc' }, + { personal: true, direction: 'desc' }, + { resolution: true }, + { language: true }, + { size: true, direction: 'desc' }, + { streamType: false }, + { visualTag: false }, + { service: false }, + { audioTag: false }, + { encode: false }, + { quality: false }, + { seeders: false, direction: 'desc' }, + { addon: false }, + { regexSort: false, direction: 'desc' }, +]; + +const defaultResolutions: Resolution[] = [ + { '2160p': true }, + { '1440p': true }, + { '1080p': true }, + { '720p': true }, + { '480p': true }, + { Unknown: true }, +]; + +const defaultServices = serviceDetails.map((service) => ({ + name: service.name, + id: service.id, + enabled: false, + credentials: {}, +})); + +const defaultStreamTypes: StreamType[] = [ + { usenet: true }, + { debrid: true }, + { unknown: true }, + { p2p: true }, + { live: true }, +]; + +export default function Configure() { + const [formatterOptions, setFormatterOptions] = useState( + allowedFormatters.filter((f) => f !== 'imposter') + ); + const [streamTypes, setStreamTypes] = + useState(defaultStreamTypes); + const [resolutions, setResolutions] = + useState(defaultResolutions); + const [qualities, setQualities] = useState(defaultQualities); + const [visualTags, setVisualTags] = useState(defaultVisualTags); + const [audioTags, setAudioTags] = useState(defaultAudioTags); + const [encodes, setEncodes] = useState(defaultEncodes); + const [sortCriteria, setSortCriteria] = + useState(defaultSortCriteria); + const [formatter, setFormatter] = useState(); + const [services, setServices] = useState(defaultServices); + const [onlyShowCachedStreams, setOnlyShowCachedStreams] = + useState(false); + const [prioritisedLanguages, setPrioritisedLanguages] = useState< + string[] | null + >(null); + const [excludedLanguages, setExcludedLanguages] = useState( + null + ); + const [addons, setAddons] = useState([]); + /* + const [maxSize, setMaxSize] = useState(null); + const [minSize, setMinSize] = useState(null); + */ + const [maxMovieSize, setMaxMovieSize] = useState(null); + const [minMovieSize, setMinMovieSize] = useState(null); + const [maxEpisodeSize, setMaxEpisodeSize] = useState(null); + const [minEpisodeSize, setMinEpisodeSize] = useState(null); + const [cleanResults, setCleanResults] = useState(false); + const [maxResultsPerResolution, setMaxResultsPerResolution] = useState< + number | null + >(null); + const [excludeFilters, setExcludeFilters] = useState([]); + const [strictIncludeFilters, setStrictIncludeFilters] = useState< + readonly Option[] + >([]); + /* + const [prioritiseIncludeFilters, setPrioritiseIncludeFilters] = useState< + readonly Option[] + >([]); + */ + const [mediaFlowEnabled, setMediaFlowEnabled] = useState(false); + const [mediaFlowProxyUrl, setMediaFlowProxyUrl] = useState(''); + const [mediaFlowApiPassword, setMediaFlowApiPassword] = useState(''); + const [mediaFlowPublicIp, setMediaFlowPublicIp] = useState(''); + const [mediaFlowProxiedAddons, setMediaFlowProxiedAddons] = useState< + string[] | null + >(null); + const [mediaFlowProxiedServices, setMediaFlowProxiedServices] = useState< + string[] | null + >(null); + + const [stremThruEnabled, setStremThruEnabled] = useState(false); + const [stremThruUrl, setStremThruUrl] = useState(''); + const [stremThruCredential, setStremThruCredential] = useState(''); + const [stremThruPublicIp, setStremThruPublicIp] = useState(''); + const [stremThruProxiedAddons, setStremThruProxiedAddons] = useState< + string[] | null + >(null); + const [stremThruProxiedServices, setStremThruProxiedServices] = useState< + string[] | null + >(null); + + const [overrideName, setOverrideName] = useState(''); + const [apiKey, setApiKey] = useState(''); + + const [disableButtons, setDisableButtons] = useState(false); + const [maxMovieSizeSlider, setMaxMovieSizeSlider] = useState( + Settings.MAX_MOVIE_SIZE + ); + const [maxEpisodeSizeSlider, setMaxEpisodeSizeSlider] = useState( + Settings.MAX_EPISODE_SIZE + ); + const [choosableAddons, setChoosableAddons] = useState( + addonDetails.map((addon) => addon.id) + ); + const [showApiKeyInput, setShowApiKeyInput] = useState(false); + const [manifestUrl, setManifestUrl] = useState(null); + const [regexFilters, setRegexFilters] = useState<{ + excludePattern?: string; + includePattern?: string; + }>({}); + const [regexSortPatterns, setRegexSortPatterns] = useState(''); + + useEffect(() => { + // get config from the server + fetch('/get-addon-config') + .then((res) => res.json()) + .then((data) => { + if (data.success) { + setMaxMovieSizeSlider(data.maxMovieSize); + setMaxEpisodeSizeSlider(data.maxEpisodeSize); + setShowApiKeyInput(data.apiKeyRequired); + // filter out 'torrentio' from choosableAddons if torrentioDisabled is true + if (data.torrentioDisabled) { + setChoosableAddons( + addonDetails + .map((addon) => addon.id) + .filter((id) => id !== 'torrentio') + ); + } + } + }); + }, []); + + const createConfig = (): Config => { + const config = { + apiKey: apiKey, + overrideName, + streamTypes, + resolutions, + qualities, + visualTags, + audioTags, + encodes, + sortBy: sortCriteria, + onlyShowCachedStreams, + prioritisedLanguages, + excludedLanguages, + maxMovieSize, + minMovieSize, + maxEpisodeSize, + minEpisodeSize, + cleanResults, + maxResultsPerResolution, + strictIncludeFilters: + strictIncludeFilters.length > 0 + ? strictIncludeFilters.map((filter) => filter.value) + : null, + excludeFilters: + excludeFilters.length > 0 + ? excludeFilters.map((filter) => filter.value) + : null, + formatter: formatter || 'gdrive', + mediaFlowConfig: { + mediaFlowEnabled: mediaFlowEnabled && !stremThruEnabled, + proxyUrl: mediaFlowProxyUrl, + apiPassword: mediaFlowApiPassword, + publicIp: mediaFlowPublicIp, + proxiedAddons: mediaFlowProxiedAddons, + proxiedServices: mediaFlowProxiedServices, + }, + stremThruConfig: { + stremThruEnabled: stremThruEnabled && !mediaFlowEnabled, + url: stremThruUrl, + credential: stremThruCredential, + publicIp: stremThruPublicIp, + proxiedAddons: stremThruProxiedAddons, + proxiedServices: stremThruProxiedServices, + }, + addons, + services, + regexFilters: + regexFilters.excludePattern || regexFilters.includePattern + ? { + excludePattern: regexFilters.excludePattern || undefined, + includePattern: regexFilters.includePattern || undefined, + } + : undefined, + regexSortPatterns: regexSortPatterns, + }; + return config; + }; + + const fetchWithTimeout = async ( + url: string, + options: RequestInit | undefined, + timeoutMs = 30000 + ) => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + console.log('Fetching', url, `with data: ${options?.body}`); + const res = await fetch(url, { ...options, signal: controller.signal }); + clearTimeout(timeout); + return res; + } catch { + console.log('Clearing timeout'); + return clearTimeout(timeout); + } + }; + + const getManifestUrl = async ( + protocol = window.location.protocol, + root = window.location.host + ): Promise<{ + success: boolean; + manifest: string | null; + message: string | null; + }> => { + const config = createConfig(); + const { valid, errorMessage } = validateConfig(config, 'client'); + if (!valid) { + return { + success: false, + manifest: null, + message: errorMessage || 'Invalid config', + }; + } + console.log('Config', config); + setDisableButtons(true); + + try { + const encryptPath = `/encrypt-user-data`; + const response = await fetchWithTimeout(encryptPath, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: JSON.stringify(config) }), + }); + if (!response) { + throw new Error('encrypt-user-data failed: no response within timeout'); + } + + if (!response.ok) { + throw new Error( + `encrypt-user-data failed with status ${response.status} and statusText ${response.statusText}` + ); + } + + const data = await response.json(); + if (!data.success) { + if (data.error) { + return { + success: false, + manifest: null, + message: data.error || 'Failed to generate config', + }; + } + throw new Error(`Encryption service failed, ${data.message}`); + } + + const configString = data.data; + return { + success: true, + manifest: `${protocol}//${root}/${configString}/manifest.json`, + message: null, + }; + } catch (error: any) { + console.error(error); + return { + success: false, + manifest: null, + message: error.message || 'Failed to encrypt config', + }; + } + }; + + const loadValidValuesFromObject = ( + object: { [key: string]: boolean }[] | undefined, + validValues: { [key: string]: boolean }[] + ) => { + if (!object) { + return validValues; + } + + const mergedValues = object.filter((value) => + validValues.some((validValue) => + Object.keys(validValue).includes(Object.keys(value)[0]) + ) + ); + + for (const validValue of validValues) { + if ( + !mergedValues.some( + (value) => Object.keys(value)[0] === Object.keys(validValue)[0] + ) + ) { + mergedValues.push(validValue); + } + } + + return mergedValues; + }; + + const loadValidSortCriteria = (sortCriteria: Config['sortBy']) => { + if (!sortCriteria) { + return defaultSortCriteria; + } + + const mergedValues = sortCriteria + .map((sort) => { + const defaultSort = defaultSortCriteria.find( + (defaultSort) => Object.keys(defaultSort)[0] === Object.keys(sort)[0] + ); + if (!defaultSort) { + return null; + } + return { + ...sort, + direction: defaultSort?.direction // only load direction if it exists in the defaultSort + ? sort.direction || defaultSort.direction + : undefined, + }; + }) + .filter((sort) => sort !== null); + + defaultSortCriteria.forEach((defaultSort) => { + if ( + !mergedValues.some( + (sort) => Object.keys(sort)[0] === Object.keys(defaultSort)[0] + ) + ) { + mergedValues.push({ + ...defaultSort, + direction: defaultSort.direction || undefined, + }); + } + }); + + return mergedValues; + }; + + const validateValue = (value: string | null, validValues: string[]) => { + if (!value) { + return null; + } + return validValues.includes(value) ? value : null; + }; + + const loadValidServices = (services: Config['services']) => { + if (!services) { + return defaultServices; + } + + const mergedServices = services + // filter out services that are not in serviceDetails + .filter((service) => defaultServices.some((ds) => ds.id === service.id)) + .map((service) => { + const defaultService = defaultServices.find( + (ds) => ds.id === service.id + ); + if (!defaultService) { + return null; + } + + // only load enabled and credentials from the previous config + return { + ...defaultService, + enabled: service.enabled, + credentials: service.credentials, + }; + }) + .filter((service) => service !== null); + + // add any services that are in defaultServices but not in services + defaultServices.forEach((defaultService) => { + if (!mergedServices.some((service) => service.id === defaultService.id)) { + mergedServices.push(defaultService); + } + }); + + return mergedServices; + }; + + const loadValidAddons = (addons: Config['addons']) => { + if (!addons) { + return []; + } + return addons.filter((addon) => + addonDetails.some((detail) => detail.id === addon.id) + ); + }; + + // Load config from the window path if it exists + useEffect(() => { + async function decodeConfig(config: string) { + let decodedConfig: Config; + if (isValueEncrypted(config) || config.startsWith('B-')) { + throw new Error('Encrypted Config Not Supported'); + } else { + decodedConfig = JSON.parse( + Buffer.from(decodeURIComponent(config), 'base64').toString('utf-8') + ); + } + return decodedConfig; + } + + function loadFromConfig(decodedConfig: Config) { + console.log('Loaded config', decodedConfig); + setOverrideName(decodedConfig.overrideName || ''); + setStreamTypes( + loadValidValuesFromObject(decodedConfig.streamTypes, defaultStreamTypes) + ); + setResolutions( + loadValidValuesFromObject(decodedConfig.resolutions, defaultResolutions) + ); + setQualities( + loadValidValuesFromObject(decodedConfig.qualities, defaultQualities) + ); + setVisualTags( + loadValidValuesFromObject(decodedConfig.visualTags, defaultVisualTags) + ); + setAudioTags( + loadValidValuesFromObject(decodedConfig.audioTags, defaultAudioTags) + ); + setEncodes( + loadValidValuesFromObject(decodedConfig.encodes, defaultEncodes) + ); + setSortCriteria(loadValidSortCriteria(decodedConfig.sortBy)); + setOnlyShowCachedStreams(decodedConfig.onlyShowCachedStreams || false); + // create an array for prioritised languages. if the old prioritiseLanguage is set, add it to the array + const finalPrioritisedLanguages = + decodedConfig.prioritisedLanguages || []; + if (decodedConfig.prioritiseLanguage) { + finalPrioritisedLanguages.push(decodedConfig.prioritiseLanguage); + } + setPrioritisedLanguages( + finalPrioritisedLanguages.filter((lang) => + allowedLanguages.includes(lang) + ) || null + ); + setExcludedLanguages( + decodedConfig.excludedLanguages?.filter((lang) => + allowedLanguages.includes(lang) + ) || null + ); + setStrictIncludeFilters( + decodedConfig.strictIncludeFilters?.map((filter) => ({ + label: filter, + value: filter, + })) || [] + ); + setExcludeFilters( + decodedConfig.excludeFilters?.map((filter) => ({ + label: filter, + value: filter, + })) || [] + ); + setRegexFilters(decodedConfig.regexFilters || {}); + setRegexSortPatterns(decodedConfig.regexSortPatterns || ''); + + setServices(loadValidServices(decodedConfig.services)); + setMaxMovieSize( + decodedConfig.maxMovieSize || decodedConfig.maxSize || null + ); + setMinMovieSize( + decodedConfig.minMovieSize || decodedConfig.minSize || null + ); + setMaxEpisodeSize( + decodedConfig.maxEpisodeSize || decodedConfig.maxSize || null + ); + setMinEpisodeSize( + decodedConfig.minEpisodeSize || decodedConfig.minSize || null + ); + setAddons(loadValidAddons(decodedConfig.addons)); + setCleanResults(decodedConfig.cleanResults || false); + setMaxResultsPerResolution(decodedConfig.maxResultsPerResolution || null); + setMediaFlowEnabled( + decodedConfig.mediaFlowConfig?.mediaFlowEnabled || false + ); + setMediaFlowProxyUrl(decodedConfig.mediaFlowConfig?.proxyUrl || ''); + setMediaFlowApiPassword(decodedConfig.mediaFlowConfig?.apiPassword || ''); + setMediaFlowPublicIp(decodedConfig.mediaFlowConfig?.publicIp || ''); + setMediaFlowProxiedAddons( + decodedConfig.mediaFlowConfig?.proxiedAddons || null + ); + setMediaFlowProxiedServices( + decodedConfig.mediaFlowConfig?.proxiedServices || null + ); + setStremThruEnabled( + decodedConfig.stremThruConfig?.stremThruEnabled || false + ); + setStremThruUrl(decodedConfig.stremThruConfig?.url || ''); + setStremThruCredential(decodedConfig.stremThruConfig?.credential || ''); + setStremThruPublicIp(decodedConfig.stremThruConfig?.publicIp || ''); + setApiKey(decodedConfig.apiKey || ''); + + // set formatter + const formatterValue = validateValue( + decodedConfig.formatter, + allowedFormatters + ); + if ( + decodedConfig.formatter.startsWith('custom') && + decodedConfig.formatter.length > 7 + ) { + setFormatter(decodedConfig.formatter); + } else if (formatterValue) { + setFormatter(formatterValue); + } + } + + const path = window.location.pathname; + try { + const configMatch = path.match(/\/([^/]+)\/configure/); + + if (configMatch) { + const config = configMatch[1]; + decodeConfig(config).then(loadFromConfig); + } + } catch (error) { + console.error('Failed to load config', error); + } + }, []); + + return ( +
+
+ +
+ AIOStreams Logo +
+ setOverrideName(e.target.value)} + style={{ + border: 'none', + backgroundColor: 'black', + color: 'white', + fontWeight: 'bold', + background: 'black', + height: '30px', + textAlign: 'center', + fontSize: '30px', + padding: '0', + maxWidth: '300px', + width: 'auto', + margin: '0 auto', + }} + size={overrideName?.length < 8 ? 8 : overrideName?.length || 8} + > + { + window.open( + `https://github.com/Viren070/AIOStreams/releases/tag/v${version}`, + '_blank', + 'noopener noreferrer' + ); + }} + > + v{version} + +
+ {process.env.NEXT_PUBLIC_BRANDING && ( +
+ )} +

+ AIOStreams, the all-in-one streaming addon for Stremio. Combine your + streams from all your addons into one and filter them by resolution, + quality, visual tags and more. +
+
+ This addon will return any result from the addons you enable. These + can be P2P results, direct links, or anything else. Results that are + P2P are marked as P2P, however. +
+
+ This addon also has no persistence. Nothing you enter here is + stored. They are encrypted within the manifest URL and are only used + to retrieve streams from any addons you enable. +

+

+ + Configuration Guide + + {' | '} + + GitHub + + + {' | '} + + Stremio Guide + +

+
+ +
+

Services

+

+ Enable the services you have accounts with and enter your + credentials. +

+ {services.map((service, index) => ( + { + const newServices = [...services]; + const serviceIndex = newServices.findIndex( + (s) => s.id === service.id + ); + newServices[serviceIndex] = { ...service, enabled }; + setServices(newServices); + }} + fields={ + serviceDetails + .find((detail: ServiceDetail) => detail.id === service.id) + ?.credentials.map((credential: ServiceCredential) => ({ + label: credential.label, + link: credential.link, + value: service.credentials[credential.id] || '', + setValue: (value) => { + const newServices = [...services]; + const serviceIndex = newServices.findIndex( + (s) => s.id === service.id + ); + newServices[serviceIndex] = { + ...service, + credentials: { + ...service.credentials, + [credential.id]: value, + }, + }; + setServices(newServices); + }, + })) || [] + } + moveService={(direction) => { + const newServices = [...services]; + const serviceIndex = newServices.findIndex( + (s) => s.id === service.id + ); + const [movedService] = newServices.splice(serviceIndex, 1); + if (direction === 'up' && serviceIndex > 0) { + newServices.splice(serviceIndex - 1, 0, movedService); + } else if ( + direction === 'down' && + serviceIndex < newServices.length + ) { + newServices.splice(serviceIndex + 1, 0, movedService); + } + setServices(newServices); + }} + canMoveUp={index > 0} + canMoveDown={index < services.length - 1} + signUpLink={ + serviceDetails.find((detail) => detail.id === service.id) + ?.signUpLink + } + /> + ))} + +
+
+
+

Only Show Cached Streams

+

+ Only show streams that are cached by the enabled services. +

+
+
+ setOnlyShowCachedStreams(e.target.checked)} + // move to the right + style={{ + marginLeft: 'auto', + marginRight: '20px', + width: '25px', + height: '25px', + }} + /> +
+
+
+
+ +
+

Addons

+ +
+ +
+

Stream Types

+

+ Choose which stream types you want to see and reorder their priority + if needed. You can uncheck P2P to remove P2P streams from the + results. +

+ +
+ +
+

Resolutions

+

+ Choose which resolutions you want to see and reorder their priority + if needed. +

+ +
+ +
+

Qualities

+

+ Choose which qualities you want to see and reorder their priority if + needed. +

+ +
+ +
+

Visual Tags

+

+ Choose which visual tags you want to see and reorder their priority + if needed. +

+ +
+ +
+

Audio Tags

+

+ Choose which audio tags you want to see and reorder their priority + if needed. +

+ +
+ +
+

Encodes

+

+ Choose which encodes you want to see and reorder their priority if + needed. +

+ +
+ +
+

Sort By

+

+ Choose the criteria by which to sort streams. +

+ +
+ +
+

Languages

+

+ Choose which languages you want to prioritise and exclude from the + results +

+
+
+

Prioritise Languages

+

+ Any results that are detected to have one of the prioritised + languages will be sorted according to your sort criteria. You + must have the Langage sort criteria enabled for + this to work. If there are multiple results with a different + prioritised language, the order is determined by the order of + the prioritised languages. +

+
+
+ a.localeCompare(b)) + .map((language) => ({ value: language, label: language }))} + setValues={setPrioritisedLanguages} + values={prioritisedLanguages || []} + /> +
+
+
+
+

Exclude Languages

+

+ Any results that are detected to have an excluded language will + be removed from the results. A result will only be excluded if + it only has one of or more of the excluded languages. If it + contains a language that is not excluded, it will still be + included. +

+
+
+ a.localeCompare(b)) + .map((language) => ({ value: language, label: language }))} + setValues={setExcludedLanguages} + values={excludedLanguages || []} + /> +
+
+
+ +
+
+
+

Keyword Filter

+

+ Filter streams by keywords. You can exclude streams that contain + specific keywords or only include streams that contain specific + keywords. +

+
+
+
+

Exclude Filter

+

+ Enter keywords to filter streams by. Streams that contain any + of the keywords will be excluded. +

+ +
+
+

Include Filter

+

+ Enter keywords to filter streams by. Streams that do not + contain any of the keywords will be excluded. +

+ +
+
+
+
+ + {showApiKeyInput && ( +
+
+

+ Regex Filtering +

+

+ Configure regex patterns to filter streams. These filters will + be applied in addition to keyword filters. +

+
+
+
+

Exclude Pattern

+

+ Enter a regex pattern to exclude streams. Streams will be + excluded if their filename OR indexers match this pattern. +

+ + setRegexFilters({ + ...regexFilters, + excludePattern: e.target.value, + }) + } + placeholder="Example: \b(0neshot|1XBET)\b" + className={styles.input} + /> +

+ Example patterns: +
+ - \b(0neshot|1XBET|24xHD)\b (exclude 0neshot, 1XBET, and 24xHD + releases) +
- ^.*Hi10.*$ (exclude Hi10 profile releases) +

+
+
+

Include Pattern

+

+ Enter a regex pattern to include streams. Only streams whose + filename or indexers match this pattern will be included. +

+ + setRegexFilters({ + ...regexFilters, + includePattern: e.target.value, + }) + } + placeholder="Example: \b(3L|BiZKiT)\b" + className={styles.input} + /> +

+ Example patterns: +
- \b(3L|BiZKiT|BLURANiUM)\b (only include 3L, BiZKiT, + and BLURANiUM releases) +

+
+
+
+ )} + + {showApiKeyInput && ( +
+

Regex Sort Patterns

+

+ Enter a space separated list of regex patterns, optionally with a + name, to sort streams by. Streams will be sorted based on the + order of matching patterns. Matching files will come first in + descending order, and last in ascending order for each pattern. + You can give each regex a name using the following syntax: +
+
+ regexName{`<::>`}regexPattern +
+
+ For example, 3L{`<::>`}\b(3L|BiZKiT)\b will sort + streams matching the regex \b(3L|BiZKiT)\b first and + those streams will have the {`regexMatched`} property + with the value 3L in the custom formatter. +

+ setRegexSortPatterns(e.target.value)} + placeholder="Example: \b(3L|BiZKiT)\b \b(FraMeSToR)\b" + style={{ + width: '97.5%', + padding: '5px', + marginLeft: '5px', + }} + className={styles.input} + /> +

+ Example patterns: +
- \b(3L|BiZKiT|BLURANiUM)\b \b(FraMeSToR)\b (sort + 3L/BiZKiT/BLURANiUM releases first, then FraMeSToR releases) +

+
+ )} +
+
+
+

Size Filter

+

+ Filter streams by size. Leave the maximum and minimum size + sliders at opposite ends to disable the filter. +

+
+
+ +
+ Minimum movie size: {formatSize(minMovieSize || 0)} +
+ +
+ Maximum movie size:{' '} + {maxMovieSize === null ? 'Unlimited' : formatSize(maxMovieSize)} +
+ +
+ Minimum episode size: {formatSize(minEpisodeSize || 0)} +
+ +
+ Maximum episode size:{' '} + {maxEpisodeSize === null + ? 'Unlimited' + : formatSize(maxEpisodeSize)} +
+
+
+
+ +
+
+
+

Limit results per resolution

+

+ Limit the number of results per resolution. Leave empty to show + all results. +

+
+
+ + setMaxResultsPerResolution( + e.target.value ? parseInt(e.target.value) : null + ) + } + style={{ + width: '100px', + height: '30px', + }} + /> +
+
+
+ +
+
+
+

Formatter

+

+ Change how your stream results are f + { + if (formatterOptions.includes('imposter')) { + return; + } + showToast( + "What's this doing here....?", + 'info', + 'ImposterFormatter' + ); + setFormatterOptions([...formatterOptions, 'imposter']); + }} + > + ◌ + + rmatted. +

+
+
+ +
+
+ {formatter?.startsWith('custom') && ( + + )} + +
+ +
+
+
+

Clean Results

+

+ Attempt to remove duplicate results. For a given file with + duplicate streams: one uncached stream from all uncached streams + is selected per provider. One cached stream from only one + provider is selected. For duplicates without a provider, one + stream is selected at random. +

+
+
+ setCleanResults(e.target.checked)} + // move to the right + style={{ + marginLeft: 'auto', + marginRight: '20px', + width: '25px', + height: '25px', + }} + /> +
+
+
+ +
+
+
+

MediaFlow

+

+ Use MediaFlow to proxy your streams +

+
+
+ { + setMediaFlowEnabled(e.target.checked); + }} + style={{ + width: '25px', + height: '25px', + }} + /> +
+
+ { +
+
+
+
+

Proxy URL

+

+ The URL of the MediaFlow proxy server +

+
+
+ +
+
+
+
+

API Password

+

+ Your MediaFlow's API password +

+
+
+ +
+
+
+
+

Public IP (Optional)

+

+ Configure this only when running MediaFlow locally with a + proxy service. Leave empty if MediaFlow is configured + locally without a proxy server or if it's hosted on a + remote server. +

+
+
+ +
+
+
+
+
+
+

Proxy Addons (Optional)

+

+ By default, all streams from every addon are proxied. + Choose specific addons here to proxy only their streams. +

+
+
+ ({ + value: `${addon.id}-${JSON.stringify(addon.options)}`, + label: + addon.options.addonName || + addon.options.overrideName || + addon.options.name || + addon.id.charAt(0).toUpperCase() + + addon.id.slice(1), + })) || [] + } + setValues={(selectedAddons) => { + setMediaFlowProxiedAddons( + selectedAddons.length === 0 ? null : selectedAddons + ); + }} + values={mediaFlowProxiedAddons || undefined} + /> +
+
+
+
+

+ Proxy Services (Optional) +

+

+ By default, all streams whether they are from a serivce or + not are proxied. Choose which services you want to proxy + through MediaFlow. Selecting None will also proxy streams + that are not (detected to be) from a service. +

+
+
+ ({ + value: service.id, + label: service.name, + })), + ]} + setValues={(selectedServices) => { + setMediaFlowProxiedServices( + selectedServices.length === 0 + ? null + : selectedServices + ); + }} + values={mediaFlowProxiedServices || undefined} + /> +
+
+
+
+ } +
+ +
+
+
+

StremThru

+

+ Use StremThru to proxy your streams +

+
+
+ { + setStremThruEnabled(e.target.checked); + }} + style={{ + width: '25px', + height: '25px', + }} + /> +
+
+ { +
+
+
+
+

StremThru URL

+

+ The URL of the StremThru server +

+
+
+ +
+
+
+
+

Credential

+

Your StremThru Credential

+
+
+ +
+
+
+
+

Public IP (Optional)

+

+ Set the publicly exposed IP for StremThru server. +

+
+
+ +
+
+
+
+
+
+

Proxy Addons (Optional)

+

+ By default, all streams from every addon are proxied. + Choose specific addons here to proxy only their streams. +

+
+
+ ({ + value: `${addon.id}-${JSON.stringify(addon.options)}`, + label: + addon.options.addonName || + addon.options.overrideName || + addon.options.name || + addon.id.charAt(0).toUpperCase() + + addon.id.slice(1), + })) || [] + } + setValues={(selectedAddons) => { + setStremThruProxiedAddons( + selectedAddons.length === 0 ? null : selectedAddons + ); + }} + values={stremThruProxiedAddons || undefined} + /> +
+
+
+
+

+ Proxy Services (Optional) +

+

+ By default, all streams whether they are from a serivce or + not are proxied. Choose which services you want to proxy + through StremThru. Selecting None will also proxy streams + that are not (detected to be) from a service. +

+
+
+ ({ + value: service.id, + label: service.name, + })), + ]} + setValues={(selectedServices) => { + setStremThruProxiedServices( + selectedServices.length === 0 + ? null + : selectedServices + ); + }} + values={stremThruProxiedServices || undefined} + /> +
+
+
+
+ } +
+ + {showApiKeyInput && ( +
+
+
+

API Key

+

+ Enter your AIOStreams API Key to install and use this addon. + You need to enter the one that is set in the{' '} + API_KEY environment variable. +

+
+
+ +
+
+
+ )} + +
+ + +
+
+ +
+ ); +} diff --git a/packages/frontend/src/app/custom-config-generator/page.module.css b/packages/frontend/src/app/custom-config-generator/page.module.css new file mode 100644 index 0000000000000000000000000000000000000000..e11e7ca0f4530624413d603cc47ea4eb353b9205 --- /dev/null +++ b/packages/frontend/src/app/custom-config-generator/page.module.css @@ -0,0 +1,156 @@ +.container { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + min-height: 100vh; +} + +.header { + text-align: center; + max-width: 800px; + border-radius: var(--borderRadius); + margin: 0 auto; /* Center the header within the content */ +} + +.content { + background-color: #000000; /* Slightly lighter black */ + border-radius: var(--borderRadius); + box-shadow: 0 8px 16px rgb(0, 0, 0); + padding: 20px; + width: 80%; + max-width: 700px; + margin: 20px; +} + +.section { + margin-bottom: 20px; + border-width: 1px; + border-style: solid; + border-radius: var(--borderRadius); + padding: 10px; + border-color: #777777; +} + +.row { + display: flex; + justify-content: space-between; + align-items: center; +} + +.keyInput { + flex: 0 0 20%; +} + +.valueInput { + flex: 0 0 70%; +} + +.input { + flex: 1; + margin-right: 10px; + padding: 5px; + border-radius: var(--borderRadius); + border: 1px solid #777777; + background-color: #1a1a1a; + color: white; +} + +.icon { + background-color: transparent; + padding-top: 5px; + padding-left: 5px; + border: none; + transition: transform 0.2s; +} + +.icon:hover { + transform: scale(1.2); + cursor: pointer; +} + +.deleteButton { + background-color: #ff4d4d; + border: none; + color: white; + padding: 5px 10px; + border-radius: var(--borderRadius); + cursor: pointer; + transition: background-color 0.3s ease; +} + +.deleteButton:hover { + background-color: #ff1a1a; +} + +.help { + background-color: #121212; + border: 1px solid #333333; + border-radius: var(--borderRadius); + padding: 10px; + margin: 5px; +} + +.buttonContainer { + display: flex; + flex-direction: column; + justify-content: center; + gap: 20px; +} + +.button { + background-color: #ffffff; /* Green */ + border: none; + color: black; + padding: 15px 32px; + text-align: center; + text-decoration: none; + display: inline-block; + font-size: 16px; + margin: 4px 2px; + cursor: pointer; + border-radius: var(--borderRadius); + box-shadow: 0 4px 8px rgba(255, 255, 255, 0.111); + transition: + transform 0.2s, + box-shadow 0.2s, + opacity 0.2s, + background-color 0.2s; +} + +.outputContainer { + display: flex; + flex-direction: column; + gap: 10px; +} + +.output { + background-color: #1a1a1a; + border-radius: var(--borderRadius); + padding: 10px; + color: white; + resize: vertical; + width: 99%; + overflow-x: auto; +} + +.envSection { + padding: 15px; + font-size: 1.1em; +} + +.envCommand { + display: flex; + align-items: center; + margin-top: 10px; +} + +.envInput { + flex: 1; + padding: 5px; + border-radius: var(--borderRadius); + border: 1px solid #777777; + background-color: #1a1a1a; + color: white; + margin-right: 10px; +} diff --git a/packages/frontend/src/app/custom-config-generator/page.tsx b/packages/frontend/src/app/custom-config-generator/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..651ce0e402dcda5af7688df4fbf17918071c0ff0 --- /dev/null +++ b/packages/frontend/src/app/custom-config-generator/page.tsx @@ -0,0 +1,415 @@ +'use client'; + +import { useState } from 'react'; +import styles from './page.module.css'; +import { Slide, ToastContainer, toast, ToastOptions } from 'react-toastify'; +import { isValueEncrypted } from '@aiostreams/utils'; + +interface CustomConfig { + key: string; + value: string; +} + +const toastOptions: ToastOptions = { + autoClose: 5000, + hideProgressBar: true, + closeOnClick: false, + pauseOnHover: true, + draggable: 'touch', + style: { + borderRadius: '8px', + backgroundColor: '#ededed', + color: 'black', + }, +}; + +const showToast = ( + message: string, + type: 'success' | 'error' | 'info' | 'warning', + id?: string +) => { + toast[type](message, { + ...toastOptions, + toastId: id, + }); +}; + +const isValidBase64 = (value: string): boolean => { + try { + JSON.parse(atob(value)); + return true; + } catch { + return false; + } +}; + +const isValidBase64Compressed = (value: string): boolean => { + if (value.startsWith('B-')) return true; + return false; +}; + +const isValidConfigFormat = (value: string): boolean => { + return value + ? isValueEncrypted(value) || + isValidBase64(value) || + isValidBase64Compressed(value) + : false; +}; + +const handleCopyEvent = (text: string) => { + navigator.clipboard + .writeText(text) + .then(() => { + showToast('Copied to clipboard!', 'success'); + }) + .catch((error: Error) => { + console.error(error); + showToast('Failed to copy to clipboard.', 'error'); + }); +}; + +const CopyButton = ({ text }: { text: string }) => ( + +); + +export default function CustomConfigGenerator() { + const [configs, setConfigs] = useState([]); + const [newConfig, setNewConfig] = useState({ + key: '', + value: '', + }); + const [output, setOutput] = useState(null); + + const extractConfigValue = (value: string): string => { + try { + const url = new URL(value); + const pathParts = url.pathname.split('/'); + const longUniqueId = pathParts[pathParts.length - 2]; + return longUniqueId; + } catch { + return value; + } + }; + + const validateKeyValuePair = (key: string, value: string) => { + if (!key || !value) { + showToast('Both key and value are required.', 'error', 'requiredFields'); + return false; + } + if (!isValidConfigFormat(value)) { + showToast( + 'Invalid configuration format.', + 'error', + 'invalidConfigFormat' + ); + return false; + } + return true; + }; + + const handleAddRow = () => { + if ( + !validateKeyValuePair(newConfig.key, extractConfigValue(newConfig.value)) + ) + return; + if (configs.some((config) => config.key === newConfig.key)) { + showToast('Key already exists.', 'error', 'uniqueKeyConstraintViolation'); + return false; + } + newConfig.value = extractConfigValue(newConfig.value); + setConfigs([...configs, newConfig]); + setNewConfig({ key: '', value: '' }); + }; + + const handleDeleteRow = (index: number) => { + const newConfigs = configs.filter((_, i) => i !== index); + setConfigs(newConfigs); + }; + + const handleChange = ( + index: number, + field: 'key' | 'value', + value: string + ) => { + const newConfigs = configs.map((config, i) => + i === index ? { ...config, [field]: value } : config + ); + setConfigs(newConfigs); + }; + + const generateJson = () => { + if (configs.length === 0) { + showToast('No configurations to generate.', 'error', 'noConfigs'); + setOutput(null); + return; + } + configs.forEach(({ key, value }) => { + if (!validateKeyValuePair(key, value)) { + setOutput(null); + return; + } + }); + + const json = configs.reduce( + (acc, { key, value }) => { + if (key) acc[key] = value; + return acc; + }, + {} as { [key: string]: string } + ); + // double stringify to escape the quotes + setOutput(JSON.stringify(json)); + }; + + return ( +
+
+
+
+

AIOStreams

+
+

+ This tool allows you to generate the value needed for the{' '} + CUSTOM_CONFIGS environment variable. +

+
+
+

Your Configurations

+

+ Add your configurations below. Put the name of the configuration in + the key field and the manifest URL in the value field. +

+
+

+ How to get the config value? +

+

+ Once you have configured AIOStreams, upon clicking the{' '} + Generate Manifest URL button, make sure to click the{' '} + Copy URL button at the configuration page. You then + paste that URL in the value field below. +

+
+ + {configs.map((config, index) => ( +
+ handleChange(index, 'key', e.target.value)} + className={styles.keyInput} + /> + handleChange(index, 'value', e.target.value)} + className={styles.valueInput} + /> + +
+ ))} +
+ + setNewConfig({ ...newConfig, key: e.target.value }) + } + className={styles.keyInput} + /> + + setNewConfig({ ...newConfig, value: e.target.value }) + } + className={styles.valueInput} + /> + +
+
+
+

Generate JSON Output

+

+ Click the Generate button to generate the JSON output. + This is the value you need to set for the{' '} + CUSTOM_CONFIGS environment variable. +

+
+ +
+ + {output && ( +
+

Output

+ +
+ )} + + {output && ( +
+ +
+ )} +
+ {output && ( +
+

Setting the environment variable

+

+ Set the CUSTOM_CONFIGS environment variable to the + value generated above. You can either manually use the value above + or use the following commands to set it in a .env{' '} + file. Ensure you are running these commands in the root directory + of AIOStreams. +

+

+ Windows: +

+
+ + +
+

+ Linux/Mac: +

+
+ > .env`} + className={styles.envInput} + /> + > .env`} + /> +
+
+ )} +
+ +
+ ); +} diff --git a/packages/frontend/src/app/globals.css b/packages/frontend/src/app/globals.css new file mode 100644 index 0000000000000000000000000000000000000000..a2b8ef2d99c349f1e3b8a7d69528c68179f7948c --- /dev/null +++ b/packages/frontend/src/app/globals.css @@ -0,0 +1,327 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --media-cue-backdrop: blur(0px); + --media-captions-padding: 0%; + --video-captions-offset: 0px; + /*--brand-color-50: #f2f0ff;*/ + /*--brand-color-100: #eeebff;*/ + /*--brand-color-200: #d4d0ff;*/ + /*--brand-color-300: #c7c2ff;*/ + /*--brand-color-400: #9f92ff;*/ + /*--brand-color-500: #6152df;*/ + /*--brand-color-600: #5243cb;*/ + /*--brand-color-700: #3f2eb2;*/ + /*--brand-color-800: #312887;*/ + /*--brand-color-900: #231c6b;*/ + /*--brand-color-950: #1a144f;*/ + /*--brand-color-default: #6152df;*/ + + --color-brand-50: 242 240 255; + --color-brand-100: 238 235 255; + --color-brand-200: 212 208 255; + --color-brand-300: 199 194 255; + --color-brand-400: 159 146 255; + --color-brand-500: 97 82 223; + --color-brand-600: 82 67 203; + --color-brand-700: 63 46 178; + --color-brand-800: 49 40 135; + --color-brand-900: 35 28 107; + --color-brand-950: 26 20 79; + --color-brand-default: 97 82 223; + + /*--gray-color-50: #FAFAFA;*/ + /*--gray-color-100: #F5F5F5;*/ + /*--gray-color-200: #E5E5E5;*/ + /*--gray-color-300: #D4D4D4;*/ + /*--gray-color-400: #A3A3A3;*/ + /*--gray-color-500: #737373;*/ + /*--gray-color-600: #525252;*/ + /*--gray-color-700: #404040;*/ + /*--gray-color-800: #262626;*/ + /*--gray-color-900: #171717;*/ + /*--gray-color-950: #101010;*/ + /*--gray-color-default: #737373;*/ + + /* --color-gray-50: 250 250 250; + --color-gray-100: 245 245 245; + --color-gray-200: 229 229 229; + --color-gray-300: 212 212 212; + --color-gray-400: 163 163 163; + --color-gray-500: 115 115 115; + --color-gray-600: 82 82 82; + --color-gray-700: 64 64 64; + --color-gray-800: 38 38 38; + --color-gray-900: 23 23 23; + --color-gray-950: 16 16 16; + --color-gray-default: 115 115 115;*/ + + --color-gray-50: 230 230 230; + --color-gray-100: 225 225 225; + --color-gray-200: 209 209 209; + --color-gray-300: 202 202 202; + --color-gray-400: 143 143 143; + --color-gray-500: 90 90 90; + --color-gray-600: 72 72 72; + --color-gray-700: 54 54 54; + --color-gray-800: 28 28 28; + --color-gray-900: 16 16 16; + --color-gray-950: 11 11 11; + --color-gray-default: 105 105 105; + + /*--radius: 0.375rem;*/ + --radius: 0.5rem; + --radius-md: 0.5rem; + + --titlebar-h: theme('height.10'); + + --foreground: theme('colors.gray.800'); + --background: white; + + --brand: theme('colors.brand.300'); + --slate: theme('colors.slate.500'); + --gray: theme('colors.gray.500'); + --zinc: theme('colors.zinc.500'); + --neutral: theme('colors.neutral.500'); + --stone: theme('colors.stone.500'); + --red: theme('colors.red.500'); + --orange: theme('colors.orange.500'); + --amber: theme('colors.amber.500'); + --yellow: theme('colors.yellow.500'); + --lime: theme('colors.lime.500'); + --green: theme('colors.green.500'); + --emerald: theme('colors.emerald.500'); + --teal: theme('colors.teal.500'); + --cyan: theme('colors.cyan.500'); + --sky: theme('colors.sky.500'); + --blue: theme('colors.blue.500'); + --indigo: theme('colors.indigo.500'); + --violet: theme('colors.violet.500'); + --purple: theme('colors.purple.500'); + --fuchsia: theme('colors.fuchsia.500'); + --pink: theme('colors.pink.500'); + --rose: theme('colors.rose.500'); + + --border: theme('colors.gray.200'); + --ring: theme('colors.brand.500'); + + --muted: theme('colors.gray.500'); + --muted-highlight: theme('colors.gray.700'); + + --paper: theme('colors.white'); + --subtle: rgba(0, 0, 0, 0.04); + --subtle-highlight: rgba(0, 0, 0, 0.06); + + --media-card-popup-background: theme('colors.gray.950'); + --hover-from-background-color: theme('colors.gray.900'); + } + + .dark, + [data-mode='dark'] { + --foreground: theme('colors.gray.200'); + --background: #070707; + + /*--brand: theme('colors.brand.300');*/ + --slate: theme('colors.slate.300'); + --gray: theme('colors.gray.300'); + --zinc: theme('colors.zinc.300'); + --neutral: theme('colors.neutral.300'); + --stone: theme('colors.stone.300'); + --red: theme('colors.red.300'); + --orange: theme('colors.orange.300'); + --amber: theme('colors.amber.300'); + --yellow: theme('colors.yellow.300'); + --lime: theme('colors.lime.300'); + --green: theme('colors.green.300'); + --emerald: theme('colors.emerald.300'); + --teal: theme('colors.teal.300'); + --cyan: theme('colors.cyan.300'); + --sky: theme('colors.sky.300'); + --blue: theme('colors.blue.300'); + --indigo: theme('colors.indigo.300'); + --violet: theme('colors.violet.300'); + --purple: theme('colors.purple.300'); + --fuchsia: theme('colors.fuchsia.300'); + --pink: theme('colors.pink.300'); + --rose: theme('colors.rose.300'); + + --border: rgba(255, 255, 255, 0.1); + --ring: theme('colors.brand.200'); + + --muted: rgba(255, 255, 255, 0.4); + --muted-highlight: rgba(255, 255, 255, 0.6); + + --paper: theme('colors.gray.950'); + --paper-highlighter: theme('colors.gray.950'); + --subtle: rgba(255, 255, 255, 0.06); + --subtle-highlight: rgba(255, 255, 255, 0.08); + } +} + +html { + background-color: var(--background); + color: var(--foreground); +} + +html * { + border-color: var(--border); +} + +/*h1, h2, h3, h4, h5, h6 {*/ +/* @apply text-gray-800 dark:text-gray-100*/ +/*}*/ + +h1 { + @apply scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl; +} + +h2 { + @apply scroll-m-20 text-3xl font-bold tracking-tight first:mt-0; +} + +h3 { + @apply scroll-m-20 text-2xl font-semibold tracking-tight; +} + +h4 { + @apply scroll-m-20 text-xl font-semibold tracking-tight; +} + +h5 { + @apply scroll-m-20 text-lg font-semibold tracking-tight; +} + +h6 { + @apply scroll-m-20 text-base font-semibold tracking-tight; +} + +/* width */ +::-webkit-scrollbar { + width: 10px; +} + +/* Track */ +::-webkit-scrollbar-track { + background: var(--background); +} + +/* Handle */ +::-webkit-scrollbar-thumb { + @apply bg-gray-700 rounded-full; +} + +/* Handle on hover */ +::-webkit-scrollbar-thumb:hover { + @apply bg-gray-600; +} + +.code { + @apply bg-gray-800 rounded-[--radius-md] px-2 py-1 text-sm font-mono border; +} + +.JASSUB { + position: absolute !important; + width: 100%; +} + +.force-hidden { + display: none !important; +} + +/*body[data-scroll-locked] {*/ +/* --removed-body-scroll-bar-size: 0 !important;*/ +/* margin-right: 0 !important;*/ +/* overflow-y: auto !important;*/ +/*}*/ + +body[data-scroll-locked] .scroll-locked-offset { + padding-right: 10px; +} + +body[data-scroll-locked] .media-page-header-scroll-locked { + /*right: 10px;*/ +} + +/**/ + +pre { + overflow-x: auto; +} + +/*div[data-media-player][data-controls]:not([data-hocus]) .vds-controls {*/ +/* @apply lg:opacity-0*/ +/*}*/ + +.discrete-controls[data-media-player][data-playing][data-controls]:not( + :has(.vds-controls-group:hover) + ) + .vds-controls { + @apply lg:opacity-0; +} + +.discrete-controls[data-media-player][data-buffering][data-controls]:not( + :has(.vds-controls-group:hover) + ) + .vds-controls { + @apply lg:opacity-0; +} + +.discrete-controls[data-media-player][data-playing][data-seeking][data-controls]:not( + :has(.vds-controls-group:hover) + ) { + @apply cursor-none; +} + +.halo:after { + opacity: 0.1; + background-image: radial-gradient(at 27% 37%, #fd3a67 0, transparent 50%), + radial-gradient(at 97% 21%, #9772fe 0, transparent 70%), + radial-gradient(at 52% 99%, #fd3a4e 0, transparent 50%), + radial-gradient(at 10% 29%, #fc5ab0 0, transparent 50%), + radial-gradient(at 97% 96%, #e4c795 0, transparent 50%), + radial-gradient(at 33% 50%, #8ca8e8 0, transparent 50%), + radial-gradient(at 79% 53%, #eea5ba 0, transparent 50%); + position: absolute; + content: ''; + width: 100%; + height: 100%; + filter: blur(100px) saturate(150%); + z-index: -1; + top: 50px; + left: 0; + transform: translateZ(0); +} + +.halo-2:after { + opacity: 0.1; + background-image: radial-gradient(at 27% 37%, #fd3a67 0, transparent 30%), + radial-gradient(at 97% 21%, #9772fe 0, transparent 70%), + radial-gradient(at 52% 99%, #fd3a4e 0, transparent 20%), + radial-gradient(at 10% 29%, #fc5ab0 0, transparent 20%), + radial-gradient(at 97% 96%, #e4c795 0, transparent 20%), + radial-gradient(at 33% 50%, #8ca8e8 0, transparent 50%), + radial-gradient(at 79% 53%, #eea5ba 0, transparent 20%); + position: absolute; + content: ''; + width: 100%; + height: 100%; + filter: blur(100px) saturate(150%); + z-index: -1; + top: 50px; + left: 0; + transform: translateZ(0); +} + +/* Hide scrollbar for Chrome, Safari and Opera */ +.hide-scrollbar::-webkit-scrollbar { + display: none; +} + +/* Hide scrollbar for IE, Edge and Firefox */ +.hide-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ +} diff --git a/packages/frontend/src/app/icon.ico b/packages/frontend/src/app/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..512ae5c6ce2315acb06a3bc741a5ef6e450ac013 --- /dev/null +++ b/packages/frontend/src/app/icon.ico @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9be720b13de66b05894f4006d2987c92ace9dd07ea46099dc114f5865c55bde3 +size 186157 diff --git a/packages/frontend/src/app/layout.tsx b/packages/frontend/src/app/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b3117fd54c50ffe092cc967852073604a4e19e6b --- /dev/null +++ b/packages/frontend/src/app/layout.tsx @@ -0,0 +1,21 @@ +import './globals.css'; +import { Inter } from 'next/font/google'; + +const inter = Inter({ subsets: ['latin'] }); + +export const metadata = { + title: 'AIOStreams', + description: 'The all in one addon for Stremio.', +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/packages/frontend/src/app/main-sidebar.tsx b/packages/frontend/src/app/main-sidebar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..41a091265b019312d6ade8ee0c4d9bc24c28c1bb --- /dev/null +++ b/packages/frontend/src/app/main-sidebar.tsx @@ -0,0 +1,294 @@ +'use client'; + +import React from 'react'; +import { AppSidebar, useAppSidebarContext } from '@/components/ui/app-layout'; +import { cn } from '@/components/ui/core/styling'; +import { VerticalMenu, VerticalMenuItem } from '@/components/ui/vertical-menu'; +import { Button } from '@/components/ui/button'; +import { useStatus } from '@/context/status'; +import { useMenu, MenuId, VALID_MENUS } from '@/context/menu'; +import { useUserData } from '@/context/userData'; +import { ConfigModal } from '@/components/config-modal'; +import { + BiPen, + BiInfoCircle, + BiCloud, + BiExtension, + BiFilterAlt, + BiSave, + BiSort, + BiLogInCircle, + BiLogOutCircle, + BiCog, + BiServer, + BiSmile, +} from 'react-icons/bi'; +import { useRouter, usePathname } from 'next/navigation'; +import { useDisclosure } from '@/hooks/disclosure'; +import { + ConfirmationDialog, + useConfirmationDialog, +} from '@/components/shared/confirmation-dialog'; +import { Modal } from '@/components/ui/modal'; +import { TextInput } from '@/components/ui/text-input'; +import { toast } from 'sonner'; +import { Tooltip } from '@/components/ui/tooltip'; +import { useOptions } from '@/context/options'; + +type MenuItem = VerticalMenuItem & { + id: MenuId; +}; + +export function MainSidebar() { + const ctx = useAppSidebarContext(); + const [expandedSidebar, setExpandSidebar] = React.useState(false); + const isCollapsed = !ctx.isBelowBreakpoint && !expandedSidebar; + const { selectedMenu, setSelectedMenu } = useMenu(); + const pathname = usePathname(); + const { isOptionsEnabled, enableOptions } = useOptions(); + + const user = useUserData(); + const signInModal = useDisclosure(false); + const [initialUuid, setInitialUuid] = React.useState(null); + + const clickHistory = React.useRef([]); + + React.useEffect(() => { + const uuidMatch = pathname.match( + /stremio\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/.*\/configure/ + ); + if (uuidMatch) { + const extractedUuid = uuidMatch[1]; + setInitialUuid(extractedUuid); + signInModal.open(); + } + // check for menu query param + // const params = new URLSearchParams(window.location.search); + // const menu = params.get('menu'); + // if (menu && VALID_MENUS.includes(menu)) { + // setSelectedMenu(menu); + // } + }, [pathname]); + + const { status, error, loading } = useStatus(); + + const confirmClearConfig = useConfirmationDialog({ + title: 'Sign Out', + description: 'Are you sure you want to sign out?', + onConfirm: () => { + user.setUserData(null); + user.setUuid(null); + user.setPassword(null); + }, + }); + + const topMenuItems: MenuItem[] = [ + { + name: 'About', + iconType: BiInfoCircle, + isCurrent: selectedMenu === 'about', + id: 'about', + }, + { + name: 'Services', + iconType: BiCloud, + isCurrent: selectedMenu === 'services', + id: 'services', + }, + { + name: 'Addons', + iconType: BiExtension, + isCurrent: selectedMenu === 'addons', + id: 'addons', + }, + { + name: 'Filters', + iconType: BiFilterAlt, + isCurrent: selectedMenu === 'filters', + id: 'filters', + }, + { + name: 'Sorting', + iconType: BiSort, + isCurrent: selectedMenu === 'sorting', + id: 'sorting', + }, + { + name: 'Formatter', + iconType: BiPen, + isCurrent: selectedMenu === 'formatter', + id: 'formatter', + }, + { + name: 'Proxy', + iconType: BiServer, + isCurrent: selectedMenu === 'proxy', + id: 'proxy', + }, + { + name: 'Miscellaneous', + iconType: BiCog, + isCurrent: selectedMenu === 'miscellaneous', + id: 'miscellaneous', + }, + ...(isOptionsEnabled + ? [ + { + name: 'Fun', + iconType: BiSmile, + isCurrent: selectedMenu === 'fun', + id: 'fun', + }, + ] + : []), + { + name: 'Save & Install', + iconType: BiSave, + isCurrent: selectedMenu === 'save-install', + id: 'save-install', + }, + ]; + + const handleExpandSidebar = () => { + if (!ctx.isBelowBreakpoint && ts.expandSidebarOnHover) { + setExpandSidebar(true); + } + }; + const handleUnexpandedSidebar = () => { + if (expandedSidebar && ts.expandSidebarOnHover) { + setExpandSidebar(false); + } + }; + + const ts = { + expandSidebarOnHover: false, + disableSidebarTransparency: false, + }; + + return ( + <> + + {!ctx.isBelowBreakpoint && + ts.expandSidebarOnHover && + ts.disableSidebarTransparency && ( +
+ )} + +
+
+
{ + const now = Date.now(); + const clicks = clickHistory.current.filter( + (time) => now - time < 5000 + ); + clicks.push(now); + clickHistory.current = clicks; + if (clicks.length >= 10) { + clickHistory.current = []; + enableOptions(); + } + }} + > + logo +
+ + {status + ? status.tag.includes('nightly') + ? 'nightly' + : status.tag + : ''} + +
+ { + setSelectedMenu((item as MenuItem).id); + ctx.setOpen(false); + }} + /> +
+ +
+ + ) : ( + + ) + } + hideTextOnLargeScreen + onClick={() => { + if (user.uuid && user.password) { + confirmClearConfig.open(); + } else { + signInModal.open(); + } + }} + > +
+ {user.uuid && user.password ? 'Log Out' : 'Log In'} +
+ + } + > + {user.uuid && user.password ? 'Log Out' : 'Log In'} +
+
+
+ + { + signInModal.close(); + toast.success('Signed in successfully'); + }} + onOpenChange={(v) => { + if (!v) { + signInModal.close(); + } + }} + initialUuid={initialUuid || undefined} + /> + + + + ); +} diff --git a/packages/frontend/src/app/page.tsx b/packages/frontend/src/app/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d6ca7dd9a3d760e58bda02fdf4a82db81c4fbf7b --- /dev/null +++ b/packages/frontend/src/app/page.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { useStatus } from '@/context/status'; +import { StatusProvider } from '@/context/status'; +import { Toaster } from '@/components/ui/toaster'; +import { + AppLayout, + AppLayoutContent, + AppLayoutSidebar, + AppSidebarProvider, +} from '@/components/ui/app-layout'; +import { MainSidebar } from './main-sidebar'; +import { LoadingOverlayWithLogo } from '@/components/shared/loading-overlay'; +import { MenuProvider } from '@/context/menu'; +import { MenuContent } from '../components/menu-content'; +import { ThemeProvider } from 'next-themes'; +import { LoadingOverlay } from '@/components/ui/loading-spinner'; +import Image from 'next/image'; +import { TopNavbar } from './top-navbar'; +import { Button } from '@/components/ui/button'; +import { UserDataProvider } from '@/context/userData'; +import { LuffyError } from '@/components/shared/luffy-error'; +import { TextGenerateEffect } from '@/components/shared/text-generate-effect'; +import { OptionsProvider } from '@/context/options'; + +function ErrorOverlay({ error }: { error: string | null }) { + return ( + + +

{error}

+
+
+ ); +} + +function AppContent() { + const { status, loading, error } = useStatus(); + + if (loading) { + return ( + + + + ); + } + + if (error || !status) { + return ; + } + + return ( + + + + + + + + +
+ +
+ +
+
+
+
+
+
+ +
+ ); +} + +export default function Home() { + return ( + + + + + + + + + + ); +} diff --git a/packages/frontend/src/app/splashscreen/page.tsx b/packages/frontend/src/app/splashscreen/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..79fcf54d3c293d857408f5fd07fac6f9f77980a6 --- /dev/null +++ b/packages/frontend/src/app/splashscreen/page.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { LoadingOverlay } from '@/components/ui/loading-spinner'; +import Image from 'next/image'; +import React from 'react'; + +export default function Page() { + return ( + + Launching... + Launching... + + ); +} diff --git a/packages/frontend/src/app/top-navbar.tsx b/packages/frontend/src/app/top-navbar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..642a993fa6f9fb89f75b6ab89c02cbfe4c2b71b4 --- /dev/null +++ b/packages/frontend/src/app/top-navbar.tsx @@ -0,0 +1,74 @@ +// import { OfflineTopMenu } from '@/app/(main)/(offline)/offline/_components/offline-top-menu'; +import { LayoutHeaderBackground } from '@/components/layout-header-background'; +import { useStatus } from '@/context/status'; +import { AppSidebarTrigger } from '@/components/ui/app-layout'; +import { cn } from '@/components/ui/core/styling'; + +import React from 'react'; +import { PageControls } from '@/components/shared/page-controls'; +import { useMenu } from '@/context/menu'; +import { Button } from '@/components/ui/button'; +import { HeartIcon } from 'lucide-react'; +import { useDisclosure } from '@/hooks/disclosure'; +import { DonationModal } from '@/components/shared/donation-modal'; + +type TopNavbarProps = { + children?: React.ReactNode; +}; + +export function TopNavbar(props: TopNavbarProps) { + const { children, ...rest } = props; + const { selectedMenu } = useMenu(); + const donationModal = useDisclosure(false); + const serverStatus = useStatus(); + const isOffline = !serverStatus.status; + + return ( + <> +
+
+
+ +
+ {selectedMenu !== 'about' ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+
+ + +
+ + ); +} diff --git a/packages/frontend/src/components/AddonsList.module.css b/packages/frontend/src/components/AddonsList.module.css new file mode 100644 index 0000000000000000000000000000000000000000..3e99897668fc1c1746a399c7122a94fdd821cef5 --- /dev/null +++ b/packages/frontend/src/components/AddonsList.module.css @@ -0,0 +1,111 @@ +.container { + display: flex; + flex-direction: column; + gap: 10px; +} + +.addonSelector { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 10px; +} + +.addonSelector button { + padding: 5px 10px; + width: 100%; + height: 40px; + border-radius: var(--borderRadius); + border: none; + background-color: #ffffff; + color: #000; + cursor: pointer; + box-shadow: 0 4px 8px rgba(255, 255, 255, 0.041); + transition: + background-color 0.3s, + transform 0.2s, + box-shadow 0.3s; +} + +.addonSelector button:hover { + background-color: #f1f1f1; + transform: scale(1.01); + box-shadow: 0 4px 8px rgba(206, 206, 206, 0.19); +} + +.addonSelector button:active { + transform: scale(0.99); +} + +.card { + background-color: #1a1a1a; /* Slightly lighter black */ + border-radius: var(--borderRadius); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + padding: 10px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.cardHeader { + display: flex; + justify-content: space-between; + align-items: center; +} + +.cardBody { + display: flex; + flex-direction: column; + gap: 10px; +} + +.option { + display: flex; + flex-direction: column; + border: 1px solid #a0a0a0; + border-radius: var(--borderRadius); + padding: 10px; +} + +.option label { + margin-bottom: 5px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.option small { + color: #aaa; + margin-top: 5px; +} + +.required { + color: #ffffff; + margin-left: 5px; +} + +.actions { + display: flex; + align-items: center; +} + +.actionButton { + background-color: transparent; + border-radius: var(--borderRadius); + display: flex; + margin: 5px; + font-size: 1.2em; + align-items: center; + border-width: 0; + transition: transform 0.2s; + -webkit-tap-highlight-color: transparent; +} + +.actionButton:hover { + transform: scale(1.1); + cursor: pointer; +} + +.actionButton:active { + transform: scale(0.9); +} diff --git a/packages/frontend/src/components/AddonsList.tsx b/packages/frontend/src/components/AddonsList.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4e2e91978bbf414710a7862e6dcb21a67d9857df --- /dev/null +++ b/packages/frontend/src/components/AddonsList.tsx @@ -0,0 +1,267 @@ +import React, { useState } from 'react'; +import styles from './AddonsList.module.css'; +import { AddonDetail, Config } from '@aiostreams/types'; +import CredentialInput from './CredentialInput'; +import MultiSelect from './MutliSelect'; + +interface AddonsListProps { + choosableAddons: string[]; + addonDetails: AddonDetail[]; + addons: Config['addons']; + setAddons: (addons: Config['addons']) => void; +} + +const AddonsList: React.FC = ({ + choosableAddons, + addonDetails, + addons, + setAddons, +}) => { + const [selectedAddon, setSelectedAddon] = useState(''); + + const addAddon = () => { + if (selectedAddon) { + setAddons([...addons, { id: selectedAddon, options: {} }]); + setSelectedAddon(''); + } + }; + + const removeAddon = (index: number) => { + const newAddons = [...addons]; + newAddons.splice(index, 1); + setAddons(newAddons); + }; + + const updateOption = ( + addonIndex: number, + optionKey: string, + value?: string + ) => { + const newAddons = [...addons]; + newAddons[addonIndex].options[optionKey] = value; + setAddons(newAddons); + }; + + const moveAddon = (index: number, direction: 'up' | 'down') => { + const newAddons = [...addons]; + const [movedAddon] = newAddons.splice(index, 1); + newAddons.splice(direction === 'up' ? index - 1 : index + 1, 0, movedAddon); + setAddons(newAddons); + }; + + return ( +
+
+ + +
+ {addons.map((addon, index) => { + const details = addonDetails.find((detail) => detail.id === addon.id); + return ( +
+
+ + {details?.name} + +
+ {index > 0 && ( + + )} + {index < addons.length - 1 && ( + + )} + +
+
+
+ {details?.options + ?.filter((option) => option.type !== 'deprecated') + ?.map((option) => ( +
+ + {option.description && {option.description}} + {option.type === 'text' && + (option.secret ? ( + + updateOption( + index, + option.id, + value ? value : undefined + ) + } + /> + ) : ( + + updateOption( + index, + option.id, + e.target.value ? e.target.value : undefined + ) + } + className={styles.textInput} + /> + ))} + {option.type === 'select' && ( + + )} + {option.type === 'number' && ( + + updateOption( + index, + option.id, + e.target.value ? e.target.value : undefined + ) + } + className={styles.textInput} + /> + )} + {option.type === 'multiSelect' && ( + + updateOption(index, option.id, values.join(',')) + } + values={addon.options[option.id]?.split(',') || []} + /> + )} +
+ ))} +
+
+ ); + })} +
+ ); +}; + +export default AddonsList; diff --git a/packages/frontend/src/components/CreateableSelect.tsx b/packages/frontend/src/components/CreateableSelect.tsx new file mode 100644 index 0000000000000000000000000000000000000000..340eaf714fbe1a60c7d38799660037cccf6c5099 --- /dev/null +++ b/packages/frontend/src/components/CreateableSelect.tsx @@ -0,0 +1,88 @@ +import React, { KeyboardEventHandler } from 'react'; + +import CreatableSelect from 'react-select/creatable'; +import showToast from './Toasts'; +import { selectStyles } from './MutliSelect'; +import FakeSelect from './FakeSelect'; + +const components = { + DropdownIndicator: null, +}; + +interface Option { + readonly label: string; + readonly value: string; +} + +const createOption = (label: string) => ({ + label, + value: label, +}); + +interface CreateableSelectProps { + value: readonly Option[]; + setValue: React.Dispatch>; +} +const CreateableSelect: React.FC = ({ + value, + setValue, +}) => { + const [inputValue, setInputValue] = React.useState(''); + const [isClient, setIsClient] = React.useState(false); + + React.useEffect(() => { + setIsClient(true); + }, []); + + const handleKeyDown: KeyboardEventHandler = (event) => { + if (!inputValue) return; + if (inputValue.length > 20) { + showToast('Value is too long', 'error', 'longValue'); + setInputValue(''); + } + switch (event.key) { + case 'Enter': + case 'Tab': + const cleanedInputValue = inputValue; + if (!cleanedInputValue.length) { + showToast('Invalid value', 'error', 'invalidValue'); + return; + } + if (value.find((v) => v.label === cleanedInputValue)) { + showToast('Value already exists', 'error', 'existingValue'); + return; + } + if (cleanedInputValue.length > 50) { + showToast('Value is too long', 'error', 'longValue'); + return; + } + setValue((prev) => [...prev, createOption(cleanedInputValue)]); + setInputValue(''); + event.preventDefault(); + } + }; + + return ( + <> + {isClient ? ( + setValue(newValue as readonly Option[])} + onInputChange={(newValue) => setInputValue(newValue)} + onKeyDown={handleKeyDown} + placeholder="Type something and press enter..." + value={value} + styles={selectStyles} + /> + ) : ( + + )} + + ); +}; + +export default CreateableSelect; diff --git a/packages/frontend/src/components/CredentialInput.module.css b/packages/frontend/src/components/CredentialInput.module.css new file mode 100644 index 0000000000000000000000000000000000000000..68becc4d2e27fc71ba148701ff4b36dbe8955037 --- /dev/null +++ b/packages/frontend/src/components/CredentialInput.module.css @@ -0,0 +1,41 @@ +.credentialsInputContainer { + display: flex; +} +.resetCredentialButton { + margin-top: 5px; + margin-left: 5px; + top: 6px; + padding: 0; + background-color: transparent; + border: none; + color: rgb(255, 255, 255); + border-radius: var(--borderRadius); + cursor: pointer; + transition: transform 0.2s; +} + +.resetCredentialButton:hover { + transform: scale(1.2); +} + +.resetCredentialButton:active { + transform: scale(0.9); +} + +.showHideButton { + background-color: transparent; + padding-top: 5px; + padding-left: 5px; + border: none; + transform: scale(1); + transition: transform 0.2s; +} + +.showHideButton:hover { + transform: scale(1.2); + cursor: pointer; +} + +.showHideButton:active { + transform: scale(0.9); +} diff --git a/packages/frontend/src/components/CredentialInput.tsx b/packages/frontend/src/components/CredentialInput.tsx new file mode 100644 index 0000000000000000000000000000000000000000..53ed0487da615d34db87c2c0f64288584cbedbc5 --- /dev/null +++ b/packages/frontend/src/components/CredentialInput.tsx @@ -0,0 +1,159 @@ +import React from 'react'; +import styles from './CredentialInput.module.css'; +import { isValueEncrypted } from '@aiostreams/utils'; + +interface CredentialInputProps { + credential: string; + setCredential: (credential: string) => void; + inputProps?: React.InputHTMLAttributes; +} + +const CredentialInput: React.FC = ({ + credential, + setCredential, + inputProps = {}, +}) => { + const [showPassword, setShowPassword] = React.useState(false); + return ( +
+ setCredential(e.target.value.trim())} + className={styles.credentialInput} + {...inputProps} + disabled={ + isValueEncrypted(credential) ? true : inputProps.disabled || false + } + /> + {!isValueEncrypted(credential) && ( + + )} + + {isValueEncrypted(credential) && ( + + )} +
+ ); +}; + +export default CredentialInput; diff --git a/packages/frontend/src/components/CustomFormatter.module.css b/packages/frontend/src/components/CustomFormatter.module.css new file mode 100644 index 0000000000000000000000000000000000000000..372d61105e94c71b254328d7938a7d033f9d06dc --- /dev/null +++ b/packages/frontend/src/components/CustomFormatter.module.css @@ -0,0 +1,96 @@ +.customFormatterContainer { + background-color: #121212; + border-radius: var(--borderRadius); + border: 1px solid #333; + margin-top: 15px; + padding: 0; + overflow: hidden; + transition: all 0.3s ease; +} + +.customFormatterTitle { + padding: 12px; + margin: 0; + font-size: 16px; + font-weight: 600; + color: #aaa; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + transition: background-color 0.2s ease; +} + +.customFormatterTitle:hover { + background-color: #1a1a1a; + color: #fff; +} + +.expandIcon { + font-size: 12px; + transition: transform 0.2s ease; +} + +.customFormatterContent { + padding: 15px; + border-top: 1px solid #333; + background-color: #121212; +} + +.customFormatterDescription { + font-size: 14px; + color: #aaa; + margin-bottom: 15px; + line-height: 1.5; +} + +.customFormatterDescription code { + background-color: #1e1e1e; + padding: 2px 4px; + border-radius: 3px; + margin: 0 2px; + font-family: monospace; + color: #e0e0e0; +} + +.formGroup { + margin-bottom: 12px; +} + +.label { + display: block; + font-size: 14px; + color: #aaa; + margin-bottom: 5px; +} + +.syntaxInput { + width: 100%; + background-color: #1e1e1e; + border: 1px solid #333; + border-radius: var(--borderRadius); + color: white; + padding: 10px; + font-family: monospace; + resize: vertical; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease, + background-color 0.2s ease; +} + +.syntaxInput:focus { + border-color: #555; + box-shadow: 0 0 10px rgba(255, 255, 255, 0.1); + outline: none; +} + +.syntaxInput:hover { + background-color: #252525; + border-color: #444; + box-shadow: 0 0 10px rgba(255, 255, 255, 0.2); +} + +.syntaxInput::placeholder { + color: #666; +} diff --git a/packages/frontend/src/components/CustomFormatter.tsx b/packages/frontend/src/components/CustomFormatter.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3ced8a64342679aa7c5889a2648fd7d2330aa385 --- /dev/null +++ b/packages/frontend/src/components/CustomFormatter.tsx @@ -0,0 +1,84 @@ +import React, { useState, useEffect } from 'react'; +import styles from './CustomFormatter.module.css'; + +interface CustomFormatterProps { + formatter: string; + setFormatter: (formatter: string) => void; +} + +const CustomFormatter: React.FC = ({ + formatter, + setFormatter, +}) => { + const [isExpanded, setIsExpanded] = useState(false); + let initialName = ''; + let initialDesc = ''; + if (formatter.startsWith('custom:') && formatter.length > 7) { + const formatterData = JSON.parse(formatter.substring(7)); + initialName = formatterData.name || ''; + initialDesc = formatterData.description || ''; + } + + const [customNameSyntax, setCustomNameSyntax] = useState(initialName); + const [customDescSyntax, setCustomDescSyntax] = useState(initialDesc); + + // Load the existing formatter on component mount + useEffect(() => { + const formatterData = { + name: customNameSyntax, + description: customDescSyntax, + }; + setFormatter(`custom:${JSON.stringify(formatterData)}`); + }, [customNameSyntax, customDescSyntax, setFormatter]); + + return ( +
+

setIsExpanded(!isExpanded)} + > + Custom Formatter + {isExpanded ? '▼' : '►'} +

+ + {isExpanded && ( +
+

+ Define a custom formatter syntax. Write + {'{debug.jsonf}'} to see the available variables. +
+ For a more detailed explanation, check the{' '} + + wiki + +
+

+ +
+ +