From 571ea7fbc30f5216cbe439be7333b310eafbb2c1 Mon Sep 17 00:00:00 2001 From: Viren070 Date: Fri, 29 Aug 2025 20:29:50 +0100 Subject: [PATCH] feat: add pro/noob mode feat: add search API feat: allow specifying multiple URLs for comet and mediafusion in .env feat: use redis store in rate limiter when possible feat: add stream type as auto play attribute fix: remove required attribute from service credential fields, fix: behaviorHint passthrough in meta response --- .env.sample | 24 + package-lock.json | 15 +- packages/core/src/db/schemas.ts | 97 +- packages/core/src/main.ts | 4 +- packages/core/src/presets/aiCompanion.ts | 16 +- packages/core/src/presets/aiSearch.ts | 11 +- packages/core/src/presets/aiostreams.ts | 6 +- packages/core/src/presets/comet.ts | 12 +- packages/core/src/presets/custom.ts | 1 + packages/core/src/presets/debridioScraper.ts | 1 + packages/core/src/presets/dmmCast.ts | 1 + packages/core/src/presets/fkstream.ts | 2 + packages/core/src/presets/jackettio.ts | 6 +- packages/core/src/presets/mediafusion.ts | 12 +- packages/core/src/presets/orion.ts | 2 + packages/core/src/presets/peerflix.ts | 3 + packages/core/src/presets/preset.ts | 91 +- packages/core/src/presets/streamfusion.ts | 2 + packages/core/src/presets/stremthruStore.ts | 6 +- packages/core/src/presets/stremthruTorz.ts | 8 +- packages/core/src/presets/torboxSearch.ts | 1 + packages/core/src/presets/torrentio.ts | 3 + packages/core/src/presets/torrentsDb.ts | 4 + packages/core/src/transformers/api.ts | 83 ++ packages/core/src/transformers/index.ts | 1 + packages/core/src/transformers/stremio.ts | 71 +- packages/core/src/utils/cache-adapter.ts | 7 +- packages/core/src/utils/cache.ts | 2 +- packages/core/src/utils/config.ts | 11 +- packages/core/src/utils/constants.ts | 8 +- packages/core/src/utils/env.ts | 85 +- packages/core/src/utils/startup.ts | 10 +- packages/frontend/src/app/main-sidebar.tsx | 34 +- packages/frontend/src/app/page.tsx | 5 +- .../frontend/src/components/menu/about.tsx | 38 +- .../frontend/src/components/menu/addons.tsx | 10 +- .../frontend/src/components/menu/filters.tsx | 828 +++++++++--------- .../src/components/menu/miscellaneous.tsx | 171 ++-- .../components/shared/mode-select-modal.tsx | 71 ++ .../src/components/shared/template-option.tsx | 61 ++ .../components/ui/mode-switch/mode-switch.tsx | 82 ++ packages/frontend/src/context/mode.tsx | 69 ++ packages/server/package.json | 5 +- packages/server/src/app.ts | 4 + packages/server/src/express.d.ts | 1 + packages/server/src/middlewares/ip.ts | 58 +- packages/server/src/middlewares/logger.ts | 4 +- packages/server/src/middlewares/ratelimit.ts | 26 +- packages/server/src/routes/api/index.ts | 1 + packages/server/src/routes/api/search.ts | 147 ++++ packages/server/src/routes/api/status.ts | 1 + .../server/src/routes/stremio/manifest.ts | 2 +- packages/server/src/routes/stremio/stream.ts | 12 +- packages/server/src/utils/responses.ts | 12 +- 54 files changed, 1545 insertions(+), 703 deletions(-) create mode 100644 packages/core/src/transformers/api.ts create mode 100644 packages/frontend/src/components/shared/mode-select-modal.tsx create mode 100644 packages/frontend/src/components/ui/mode-switch/mode-switch.tsx create mode 100644 packages/frontend/src/context/mode.tsx create mode 100644 packages/server/src/routes/api/search.ts diff --git a/.env.sample b/.env.sample index a4b09726..029af5dd 100644 --- a/.env.sample +++ b/.env.sample @@ -182,6 +182,24 @@ CUSTOM_HTML= # Example: TRUSTED_UUIDS=ae32f456-1234-5678-9012-345678901234,another-uuid-here # TRUSTED_UUIDS= +# ---- Stream Data ---- +# Whether to provide stream data in stream responses. +# Set to either true, false, or a list of IPs. +# Or leave undefined. +# Setting to a list of IPs only shows stream data when request is made from one of those IPs +# Leaving as undefined only shows when necessary by AIO. +# Disabling this means users cannot wrap your AIOStreams instance. +# PROVIDE_STREAM_DATA= + +# --- Search API ----- +# Control whether to serve a search API for easier access to results through AIOStreams +# at the /api/v1/search endpoint. +# Enabled by default, set to false to disable. +# ENABLE_SEARCH_API=true +# Whether to allow unauthenticated requests to the Search API using just the x-aiostreams-user-data header. +# If set to false, users must create a user first before being able to use the search API. +# ALLOW_UNAUTHENTICATED_SEARCH_API=true + # --- Regex Filter Access --- # Controls who can use regex filters. # 'none': No one can use regex filters. @@ -493,6 +511,8 @@ PRUNE_MAX_DAYS=-1 # Change these if you use self-hosted versions or if defaults become outdated. # ----------- COMET ------------ +# This can also be set to a list of URLs which would show as options to users when configuring +# e.g. COMET_URL='["https://comet.elfhosted.com", "https://comet.example.com"]' # 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. @@ -502,12 +522,16 @@ PRUNE_MAX_DAYS=-1 # FORCE_COMET_PROTOCOL= # e.g., https # ----------- MEDIAFUSION ------------ +# This can also be set to a list of URLs which would show as options to users when configuring +# e.g. MEDIAFUSION_URL='["https://mediafusion.elfhosted.com", "https://mediafusion.example.com"]' # MEDIAFUSION_URL=https://mediafusion.elfhosted.com/ # DEFAULT_MEDIAFUSION_TIMEOUT= # API Password for self-hosted MediaFusion (for auto-configuration). # MEDIAFUSION_API_PASSWORD= # ----------- JACKETTIO ------------- +# This can also be set to a list of URLs which would show as options to users when configuring +# e.g. JACKETTIO_URL='["https://jackettio.elfhosted.com", "https://jackettio.example.com"]' # JACKETTIO_URL=https://jackettio.elfhosted.com/ # DEFAULT_JACKETTIO_TIMEOUT= # Default indexers for auto-configuration with Jackettio. diff --git a/package-lock.json b/package-lock.json index 9dce8942..06dbea89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11378,6 +11378,18 @@ "node": ">= 0.6" } }, + "node_modules/rate-limit-redis": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/rate-limit-redis/-/rate-limit-redis-4.2.2.tgz", + "integrity": "sha512-0SGzpSCZQgkJuUK5AqGaUkgwTMaujWIek0PwlZBDsdNIcasrJae8AC47tP5UHayqDcocJxtogL6DnZFTLoruUw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "peerDependencies": { + "express-rate-limit": ">= 6" + } + }, "node_modules/raw-body": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", @@ -14947,7 +14959,8 @@ "dependencies": { "@aiostreams/core": "^0.0.0", "express": "^4.21.2", - "express-rate-limit": "^7.5.0" + "express-rate-limit": "^7.5.0", + "rate-limit-redis": "^4.2.2" }, "devDependencies": { "@types/express": "^5.0.1", diff --git a/packages/core/src/db/schemas.ts b/packages/core/src/db/schemas.ts index badf9c70..d12a9799 100644 --- a/packages/core/src/db/schemas.ts +++ b/packages/core/src/db/schemas.ts @@ -177,6 +177,7 @@ const OptionDefinition = z.object({ id: z.string().min(1), name: z.string().min(1), description: z.string().min(1), + showInNoobMode: z.boolean().optional(), emptyIsUndefined: z.boolean().optional(), type: z.enum([ 'string', @@ -184,6 +185,7 @@ const OptionDefinition = z.object({ 'number', 'boolean', 'select', + 'select-with-custom', 'multi-select', 'url', 'alert', @@ -733,7 +735,9 @@ export const MetaSchema = MetaPreviewSchema.extend({ behaviorHints: z .object({ defaultVideoId: z.string().or(z.null()).optional(), + hasScheduledVideo: z.boolean().nullable().optional(), }) + .passthrough() .optional(), }).passthrough(); @@ -779,51 +783,53 @@ export const ExtrasSchema = z export type Extras = 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(), - id: z.string().min(1).optional(), - }), + 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(), + id: z.string().min(1).optional(), + }) + .optional(), }); export type AIOStream = z.infer; @@ -886,6 +892,7 @@ const StatusResponseSchema = z.object({ customHtml: z.string().optional(), protected: z.boolean(), regexFilterAccess: z.enum(['none', 'trusted', 'all']), + allowUnauthenticatedSearchApi: z.boolean(), allowedRegexPatterns: z .object({ patterns: z.array(z.string()), diff --git a/packages/core/src/main.ts b/packages/core/src/main.ts index a9a62717..ef761ecf 100644 --- a/packages/core/src/main.ts +++ b/packages/core/src/main.ts @@ -85,13 +85,13 @@ export class AIOStreams { error: string; }[] = []; - constructor(userData: UserData, skipFailedAddons: boolean = true) { + constructor(userData: UserData, options?: { skipFailedAddons: boolean }) { this.addonInitialisationErrors = []; this.userData = userData; this.manifestUrl = `${Env.BASE_URL}/stremio/${this.userData.uuid}/${this.userData.encryptedPassword}/manifest.json`; this.manifests = {}; this.supportedResources = {}; - this.skipFailedAddons = skipFailedAddons; + this.skipFailedAddons = options?.skipFailedAddons ?? true; this.proxifier = new Proxifier(userData); this.limiter = new StreamLimiter(userData); this.fetcher = new Fetcher(userData); diff --git a/packages/core/src/presets/aiCompanion.ts b/packages/core/src/presets/aiCompanion.ts index 05c6241f..9f3c5799 100644 --- a/packages/core/src/presets/aiCompanion.ts +++ b/packages/core/src/presets/aiCompanion.ts @@ -66,7 +66,8 @@ export class AICompanionPreset extends Preset { id: 'providerBaseUrl', name: 'LLM Provider', description: 'Choose the LLM Provider to use.', - type: 'select', + type: 'select-with-custom', + default: 'https://openrouter.ai/api/v1', required: true, options: [ { @@ -85,20 +86,9 @@ export class AICompanionPreset extends Preset { label: 'Gemini (OpenAI Compatible)', value: 'https://generativelanguage.googleapis.com/v1beta/openai', }, - { - label: 'Custom LLM Provider', - value: 'custom', - }, ], }, - { - id: 'customBaseUrl', - name: 'Custom LLM Base URL', - description: - 'If you selected "Custom LLM Provider", enter the base URL of your custom LLM provider here.', - type: 'url', - required: false, - }, + { id: 'providerApiKey', name: 'LLM Provider API Key', diff --git a/packages/core/src/presets/aiSearch.ts b/packages/core/src/presets/aiSearch.ts index c8c32502..4b872ded 100644 --- a/packages/core/src/presets/aiSearch.ts +++ b/packages/core/src/presets/aiSearch.ts @@ -128,18 +128,13 @@ export class AISearchPreset extends Preset { // min: 10, // }, // }, - { - id: 'advancedSettingsNote', - type: 'alert', - name: 'Advanced Settings', - description: 'The below settings are for advanced users only.', - }, { id: 'AiResponseCaching', name: 'AI Response Caching', description: 'Enable AI response caching', type: 'boolean', default: true, + showInNoobMode: false, }, { id: 'rpdbApiKey', @@ -147,6 +142,7 @@ export class AISearchPreset extends Preset { description: 'Optionally provide an RPDB API Key to use for posters', type: 'password', required: false, + showInNoobMode: false, }, { id: 'language', @@ -155,6 +151,7 @@ export class AISearchPreset extends Preset { type: 'select', options: this.languages, default: 'en-US', + showInNoobMode: false, }, { id: 'model', @@ -163,6 +160,7 @@ export class AISearchPreset extends Preset { 'The Gemini model to use for AI Search. See available models at the [documentation](https://ai.google.dev/gemini-api/docs/models/gemini)', type: 'string', default: 'gemini-2.0-flash-lite', + showInNoobMode: false, }, { id: 'numberOfRecommendations', @@ -175,6 +173,7 @@ export class AISearchPreset extends Preset { min: 1, max: 30, }, + showInNoobMode: false, }, { id: 'socials', diff --git a/packages/core/src/presets/aiostreams.ts b/packages/core/src/presets/aiostreams.ts index b1b055f1..25d292bf 100644 --- a/packages/core/src/presets/aiostreams.ts +++ b/packages/core/src/presets/aiostreams.ts @@ -23,6 +23,9 @@ class AIOStreamsStreamParser extends StreamParser { ); throw new Error('Invalid stream'); } + if (!aioStream.streamData) { + throw new Error('Stream Data was missing from AIOStream response'); + } if ( aioStream.streamData.id?.endsWith('external-download') || aioStream.streamData.type === constants.STATISTIC_STREAM_TYPE @@ -107,6 +110,7 @@ export class AIOStreamsPreset extends Preset { { id: 'resources', name: 'Resources', + showInNoobMode: false, description: 'Optionally override the resources that are fetched from this addon ', type: 'multi-select', @@ -125,7 +129,7 @@ export class AIOStreamsPreset extends Preset { 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, + USER_AGENT: Env.AIOSTREAMS_USER_AGENT, SUPPORTED_SERVICES: [], DESCRIPTION: 'Wrap AIOStreams within AIOStreams!', OPTIONS: options, diff --git a/packages/core/src/presets/comet.ts b/packages/core/src/presets/comet.ts index 1423da12..9cb2ea67 100644 --- a/packages/core/src/presets/comet.ts +++ b/packages/core/src/presets/comet.ts @@ -63,13 +63,19 @@ export class CometPreset extends StremThruPreset { const supportedResources = [constants.STREAM_RESOURCE]; const options: Option[] = [ - ...baseOptions('Comet', supportedResources, Env.DEFAULT_COMET_TIMEOUT), + ...baseOptions( + 'Comet', + supportedResources, + Env.DEFAULT_COMET_TIMEOUT, + Env.COMET_URL + ), { id: 'includeP2P', name: 'Include P2P', description: 'Include P2P results, even if a debrid service is enabled', type: 'boolean', default: false, + showInNoobMode: false, }, { id: 'removeTrash', @@ -78,10 +84,12 @@ export class CometPreset extends StremThruPreset { 'Remove all trash from results (Adult Content, CAM, Clean Audio, PDTV, R5, Screener, Size, Telecine and Telesync)', type: 'boolean', default: true, + showInNoobMode: false, }, { id: 'services', name: 'Services', + showInNoobMode: false, 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', @@ -115,7 +123,7 @@ export class CometPreset extends StremThruPreset { ID: 'comet', NAME: 'Comet', LOGO: 'https://i.imgur.com/jmVoVMu.jpeg', - URL: Env.COMET_URL, + URL: Env.COMET_URL[0], TIMEOUT: Env.DEFAULT_COMET_TIMEOUT || Env.DEFAULT_TIMEOUT, USER_AGENT: Env.DEFAULT_COMET_USER_AGENT || Env.DEFAULT_USER_AGENT, SUPPORTED_SERVICES: supportedServices, diff --git a/packages/core/src/presets/custom.ts b/packages/core/src/presets/custom.ts index c01ef2f3..e8156e2b 100644 --- a/packages/core/src/presets/custom.ts +++ b/packages/core/src/presets/custom.ts @@ -72,6 +72,7 @@ export class CustomPreset extends Preset { 'Optionally override the resources that are fetched from this addon ', type: 'multi-select', required: false, + showInNoobMode: false, default: undefined, options: RESOURCES.map((resource) => ({ label: resource, diff --git a/packages/core/src/presets/debridioScraper.ts b/packages/core/src/presets/debridioScraper.ts index 7d94c706..0a13ff72 100644 --- a/packages/core/src/presets/debridioScraper.ts +++ b/packages/core/src/presets/debridioScraper.ts @@ -35,6 +35,7 @@ export class DebridioPreset extends Preset { '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, + showInNoobMode: false, options: supportedServices.map((service) => ({ value: service, label: constants.SERVICE_DETAILS[service].name, diff --git a/packages/core/src/presets/dmmCast.ts b/packages/core/src/presets/dmmCast.ts index 24557e3b..0b8706d9 100644 --- a/packages/core/src/presets/dmmCast.ts +++ b/packages/core/src/presets/dmmCast.ts @@ -94,6 +94,7 @@ export class DMMCastPreset extends Preset { label: resource, value: resource, })), + showInNoobMode: false, }, { id: 'socials', diff --git a/packages/core/src/presets/fkstream.ts b/packages/core/src/presets/fkstream.ts index 08bdbb95..9df28d8b 100644 --- a/packages/core/src/presets/fkstream.ts +++ b/packages/core/src/presets/fkstream.ts @@ -49,6 +49,7 @@ export class FKStreamPreset extends StremThruPreset { 'Include P2P streams in the addon even when using a debrid service', type: 'boolean', default: false, + showInNoobMode: false, }, { id: 'services', @@ -57,6 +58,7 @@ export class FKStreamPreset extends StremThruPreset { '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, + showInNoobMode: false, options: supportedServices.map((service) => ({ value: service, label: constants.SERVICE_DETAILS[service].name, diff --git a/packages/core/src/presets/jackettio.ts b/packages/core/src/presets/jackettio.ts index b0ae6632..38a65273 100644 --- a/packages/core/src/presets/jackettio.ts +++ b/packages/core/src/presets/jackettio.ts @@ -56,7 +56,8 @@ export class JackettioPreset extends StremThruPreset { ...baseOptions( 'Jackettio', supportedResources, - Env.DEFAULT_JACKETTIO_TIMEOUT + Env.DEFAULT_JACKETTIO_TIMEOUT, + Env.JACKETTIO_URL ), { id: 'services', @@ -65,6 +66,7 @@ export class JackettioPreset extends StremThruPreset { '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, + showInNoobMode: false, options: supportedServices.map((service) => ({ value: service, label: constants.SERVICE_DETAILS[service].name, @@ -87,7 +89,7 @@ export class JackettioPreset extends StremThruPreset { ID: 'jackettio', NAME: 'Jackettio', LOGO: 'https://raw.githubusercontent.com/Jackett/Jackett/bbea5febd623f6e536e11aa1fa8d6674d8d4043f/src/Jackett.Common/Content/jacket_medium.png', - URL: Env.JACKETTIO_URL, + URL: Env.JACKETTIO_URL[0], TIMEOUT: Env.DEFAULT_JACKETTIO_TIMEOUT || Env.DEFAULT_TIMEOUT, USER_AGENT: Env.DEFAULT_JACKETTIO_USER_AGENT || Env.DEFAULT_USER_AGENT, SUPPORTED_SERVICES: supportedServices, diff --git a/packages/core/src/presets/mediafusion.ts b/packages/core/src/presets/mediafusion.ts index 42efa6f7..cfd08e42 100644 --- a/packages/core/src/presets/mediafusion.ts +++ b/packages/core/src/presets/mediafusion.ts @@ -148,7 +148,8 @@ export class MediaFusionPreset extends Preset { ...baseOptions( 'MediaFusion', supportedResources, - Env.DEFAULT_MEDIAFUSION_TIMEOUT + Env.DEFAULT_MEDIAFUSION_TIMEOUT, + Env.MEDIAFUSION_URL ), { id: 'useCachedResultsOnly', @@ -158,6 +159,7 @@ export class MediaFusionPreset extends Preset { type: 'boolean', forced: Env.MEDIAFUSION_FORCED_USE_CACHED_RESULTS_ONLY, default: Env.MEDIAFUSION_DEFAULT_USE_CACHED_RESULTS_ONLY, + showInNoobMode: false, }, { id: 'enableWatchlistCatalogs', @@ -165,6 +167,7 @@ export class MediaFusionPreset extends Preset { description: 'Enable watchlist catalogs for the selected services.', type: 'boolean', default: false, + showInNoobMode: false, }, { id: 'downloadViaBrowser', @@ -173,6 +176,7 @@ export class MediaFusionPreset extends Preset { 'Show download streams to allow downloading the stream from your service, rather than streaming.', type: 'boolean', default: false, + showInNoobMode: false, }, { id: 'contributorStreams', @@ -180,6 +184,7 @@ export class MediaFusionPreset extends Preset { description: 'Show a stream to contribute torrents for the title.', type: 'boolean', default: false, + showInNoobMode: false, }, { id: 'certificationLevelsFilter', @@ -188,6 +193,7 @@ export class MediaFusionPreset extends Preset { 'Choose to not display streams for titles of a certain certification level. Leave blank to show all results.', type: 'multi-select', required: false, + showInNoobMode: false, options: [ { value: 'Unknown', @@ -226,6 +232,7 @@ export class MediaFusionPreset extends Preset { 'Choose to not display streams that a certain level of nudity. Leave blank to show all results.', type: 'multi-select', required: false, + showInNoobMode: false, options: [ { value: 'Unknown', @@ -257,6 +264,7 @@ export class MediaFusionPreset extends Preset { '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, + showInNoobMode: false, options: supportedServices.map((service) => ({ value: service, label: constants.SERVICE_DETAILS[service].name, @@ -279,7 +287,7 @@ export class MediaFusionPreset extends Preset { ID: 'mediafusion', NAME: 'MediaFusion', LOGO: `https://raw.githubusercontent.com/mhdzumair/MediaFusion/refs/heads/main/resources/images/mediafusion_logo.png`, - URL: Env.MEDIAFUSION_URL, + URL: Env.MEDIAFUSION_URL[0], TIMEOUT: Env.DEFAULT_MEDIAFUSION_TIMEOUT || Env.DEFAULT_TIMEOUT, USER_AGENT: Env.DEFAULT_MEDIAFUSION_USER_AGENT || Env.DEFAULT_USER_AGENT, SUPPORTED_SERVICES: supportedServices, diff --git a/packages/core/src/presets/orion.ts b/packages/core/src/presets/orion.ts index 17228636..2725f938 100644 --- a/packages/core/src/presets/orion.ts +++ b/packages/core/src/presets/orion.ts @@ -48,6 +48,7 @@ export class OrionPreset extends Preset { description: 'Show P2P results, even if a debrid service is enabled', type: 'boolean', default: false, + showInNoobMode: false, }, { id: 'linkLimit', @@ -67,6 +68,7 @@ export class OrionPreset extends Preset { '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, + showInNoobMode: false, options: supportedServices.map((service) => ({ value: service, label: constants.SERVICE_DETAILS[service].name, diff --git a/packages/core/src/presets/peerflix.ts b/packages/core/src/presets/peerflix.ts index e3095ac1..0a3991ff 100644 --- a/packages/core/src/presets/peerflix.ts +++ b/packages/core/src/presets/peerflix.ts @@ -40,6 +40,7 @@ export class PeerflixPreset extends Preset { })), default: undefined, emptyIsUndefined: true, + showInNoobMode: false, }, { id: 'useMultipleInstances', @@ -49,6 +50,7 @@ export class PeerflixPreset extends Preset { type: 'boolean', default: false, required: true, + showInNoobMode: false, }, { id: 'showTorrentLinks', @@ -57,6 +59,7 @@ export class PeerflixPreset extends Preset { '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, + showInNoobMode: false, required: true, }, ]; diff --git a/packages/core/src/presets/preset.ts b/packages/core/src/presets/preset.ts index bb8bd9ec..14ca4c19 100644 --- a/packages/core/src/presets/preset.ts +++ b/packages/core/src/presets/preset.ts @@ -34,42 +34,10 @@ import { Env, ServiceId, constants } from '../utils'; 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, - forceInUi: false, // large ranges don't work well - }, - }, - { - 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, - })), - }, - { + timeout: number = Env.DEFAULT_TIMEOUT, + baseUrls?: string[] +): Option[] => { + const urlOption: Option = { id: 'url', name: 'URL', description: @@ -77,9 +45,56 @@ export const baseOptions = ( type: 'url', required: false, emptyIsUndefined: true, + showInNoobMode: false, default: undefined, - }, -]; + }; + if (baseUrls && baseUrls.length > 1) { + urlOption.default = baseUrls[0]; + urlOption.type = 'select-with-custom'; + urlOption.options = baseUrls.map((url) => ({ + label: url, + value: url, + })); + urlOption.showInNoobMode = true; + } + return [ + { + 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, + forceInUi: false, // large ranges don't work well + }, + }, + { + id: 'resources', + name: 'Resources', + description: 'Optionally override the resources to use ', + type: 'multi-select', + required: false, + showInNoobMode: false, + default: resources, + options: resources.map((resource) => ({ + label: resource, + value: resource, + })), + }, + urlOption, + ]; +}; export interface CacheKeyRequestOptions { resource: Resource | 'manifest'; diff --git a/packages/core/src/presets/streamfusion.ts b/packages/core/src/presets/streamfusion.ts index 43fe3162..dc7c5983 100644 --- a/packages/core/src/presets/streamfusion.ts +++ b/packages/core/src/presets/streamfusion.ts @@ -80,6 +80,7 @@ export class StreamFusionPreset extends Preset { type: 'boolean', required: false, default: false, + showInNoobMode: false, }, { id: 'services', @@ -88,6 +89,7 @@ export class StreamFusionPreset extends Preset { '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, + showInNoobMode: false, options: supportedServices.map((service) => ({ value: service, label: constants.SERVICE_DETAILS[service].name, diff --git a/packages/core/src/presets/stremthruStore.ts b/packages/core/src/presets/stremthruStore.ts index ae010c57..a7a0ede8 100644 --- a/packages/core/src/presets/stremthruStore.ts +++ b/packages/core/src/presets/stremthruStore.ts @@ -51,7 +51,8 @@ export class StremthruStorePreset extends StremThruPreset { ...baseOptions( 'StremThru Store', supportedResources, - Env.DEFAULT_STREMTHRU_STORE_TIMEOUT + Env.DEFAULT_STREMTHRU_STORE_TIMEOUT, + Env.STREMTHRU_STORE_URL ), { id: 'services', @@ -60,6 +61,7 @@ export class StremthruStorePreset extends StremThruPreset { '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, + showInNoobMode: false, options: StremThruPreset.supportedServices.map((service) => ({ value: service, label: constants.SERVICE_DETAILS[service].name, @@ -87,7 +89,7 @@ export class StremthruStorePreset extends StremThruPreset { ID: 'stremthruStore', NAME: 'StremThru Store', LOGO: 'https://emojiapi.dev/api/v1/sparkles/256.png', - URL: Env.STREMTHRU_STORE_URL, + URL: Env.STREMTHRU_STORE_URL[0], TIMEOUT: Env.DEFAULT_STREMTHRU_STORE_TIMEOUT || Env.DEFAULT_TIMEOUT, USER_AGENT: Env.DEFAULT_STREMTHRU_STORE_USER_AGENT || Env.DEFAULT_USER_AGENT, diff --git a/packages/core/src/presets/stremthruTorz.ts b/packages/core/src/presets/stremthruTorz.ts index 41e29837..645d5216 100644 --- a/packages/core/src/presets/stremthruTorz.ts +++ b/packages/core/src/presets/stremthruTorz.ts @@ -47,7 +47,8 @@ export class StremthruTorzPreset extends StremThruPreset { ...baseOptions( 'StremThru Torz', supportedResources, - Env.DEFAULT_STREMTHRU_STORE_TIMEOUT + Env.DEFAULT_STREMTHRU_STORE_TIMEOUT, + Env.STREMTHRU_TORZ_URL ), { id: 'services', @@ -56,6 +57,7 @@ export class StremthruTorzPreset extends StremThruPreset { '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, + showInNoobMode: false, options: StremThruPreset.supportedServices.map((service) => ({ value: service, label: constants.SERVICE_DETAILS[service].name, @@ -70,6 +72,7 @@ export class StremthruTorzPreset extends StremThruPreset { '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, + showInNoobMode: false, }, { id: 'useMultipleInstances', @@ -78,6 +81,7 @@ export class StremthruTorzPreset extends StremThruPreset { '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, + showInNoobMode: false, }, { id: 'socials', @@ -92,7 +96,7 @@ export class StremthruTorzPreset extends StremThruPreset { ID: 'stremthruTorz', NAME: 'StremThru Torz', LOGO: 'https://emojiapi.dev/api/v1/sparkles/256.png', - URL: Env.STREMTHRU_TORZ_URL, + URL: Env.STREMTHRU_TORZ_URL[0], TIMEOUT: Env.DEFAULT_STREMTHRU_TORZ_TIMEOUT || Env.DEFAULT_TIMEOUT, USER_AGENT: Env.DEFAULT_STREMTHRU_TORZ_USER_AGENT || Env.DEFAULT_USER_AGENT, diff --git a/packages/core/src/presets/torboxSearch.ts b/packages/core/src/presets/torboxSearch.ts index b621791c..0293e3b9 100644 --- a/packages/core/src/presets/torboxSearch.ts +++ b/packages/core/src/presets/torboxSearch.ts @@ -100,6 +100,7 @@ export class TorBoxSearchPreset extends StremThruPreset { '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, + showInNoobMode: false, options: StremThruPreset.supportedServices.map((service) => ({ value: service, label: constants.SERVICE_DETAILS[service].name, diff --git a/packages/core/src/presets/torrentio.ts b/packages/core/src/presets/torrentio.ts index 218e196f..a13e71a7 100644 --- a/packages/core/src/presets/torrentio.ts +++ b/packages/core/src/presets/torrentio.ts @@ -153,6 +153,7 @@ export class TorrentioPreset extends Preset { type: 'multi-select', required: false, options: TorrentioPreset.defaultProviders, + showInNoobMode: false, }, { id: 'services', @@ -161,6 +162,7 @@ export class TorrentioPreset extends Preset { '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, + showInNoobMode: false, options: supportedServices.map((service) => ({ value: service, label: constants.SERVICE_DETAILS[service].name, @@ -176,6 +178,7 @@ export class TorrentioPreset extends Preset { type: 'boolean', default: false, required: true, + showInNoobMode: false, }, ]; diff --git a/packages/core/src/presets/torrentsDb.ts b/packages/core/src/presets/torrentsDb.ts index b4db7141..a38daf1e 100644 --- a/packages/core/src/presets/torrentsDb.ts +++ b/packages/core/src/presets/torrentsDb.ts @@ -147,6 +147,7 @@ export class TorrentsDbPreset extends Preset { (provider) => provider.value ), emptyIsUndefined: true, + showInNoobMode: false, }, { id: 'services', @@ -161,6 +162,7 @@ export class TorrentsDbPreset extends Preset { })), default: undefined, emptyIsUndefined: true, + showInNoobMode: false, }, { id: 'includeP2P', @@ -170,6 +172,7 @@ export class TorrentsDbPreset extends Preset { type: 'boolean', default: false, required: false, + showInNoobMode: false, }, { id: 'useMultipleInstances', @@ -179,6 +182,7 @@ export class TorrentsDbPreset extends Preset { type: 'boolean', default: false, required: true, + showInNoobMode: false, }, ]; diff --git a/packages/core/src/transformers/api.ts b/packages/core/src/transformers/api.ts new file mode 100644 index 00000000..68b1f2b8 --- /dev/null +++ b/packages/core/src/transformers/api.ts @@ -0,0 +1,83 @@ +import { ParsedStream, Resource, Subtitle, UserData } from '../db'; +import { AIOStreamsResponse } from '../main'; + +export interface ApiSearchResponseData { + results: ApiSearchResult[]; + errors: { + title: string; + description: string; + }[]; +} + +interface ApiSearchResult { + infoHash: string | null; + seeders: number | null; + age: string | null; + sources: string[] | null; + ytId: string | null; + externalUrl: string | null; + fileIdx: number | null; + url: string | null; + proxied: boolean; + filename: string | null; + folderName: string | null; + size: number | null; + folderSize: number | null; + message: string | null; + library: boolean; + type: string; + indexer: string | null; + addon: string | null; + duration: number | null; + videoHash: string | null; + subtitles: Subtitle[]; + countryWhitelist: string[]; + requestHeaders: Record; + responseHeaders: Record; +} + +export class ApiTransformer { + constructor(private readonly userData: UserData) {} + + async transformStreams( + response: AIOStreamsResponse<{ + streams: ParsedStream[]; + statistics: { title: string; description: string }[]; + }> + ): Promise { + const { data, errors } = response; + const results: ApiSearchResult[] = data.streams.map((stream) => ({ + infoHash: stream.torrent?.infoHash ?? null, + url: stream.url ?? null, + seeders: stream.torrent?.seeders ?? null, + age: stream.age ?? null, + sources: stream.torrent?.sources ?? null, + ytId: stream.ytId ?? null, + externalUrl: stream.externalUrl ?? null, + fileIdx: stream.torrent?.fileIdx ?? null, + proxied: stream.proxied ?? false, + filename: stream.filename ?? null, + folderName: stream.folderName ?? null, + size: stream.size ?? null, + folderSize: stream.folderSize ?? null, + message: stream.message ?? null, + library: stream.library ?? false, + addon: stream.addon.name ?? null, + type: stream.type ?? '', + indexer: stream.indexer ?? null, + duration: stream.duration ?? null, + videoHash: stream.videoHash ?? null, + subtitles: stream.subtitles ?? [], + countryWhitelist: stream.countryWhitelist ?? [], + requestHeaders: stream.requestHeaders ?? {}, + responseHeaders: stream.responseHeaders ?? {}, + })); + return { + results, + errors: errors.map((error) => ({ + title: error.title ?? '', + description: error.description ?? '', + })), + }; + } +} diff --git a/packages/core/src/transformers/index.ts b/packages/core/src/transformers/index.ts index 176ced25..707a7bfd 100644 --- a/packages/core/src/transformers/index.ts +++ b/packages/core/src/transformers/index.ts @@ -1 +1,2 @@ export * from './stremio'; +export * from './api'; diff --git a/packages/core/src/transformers/stremio.ts b/packages/core/src/transformers/stremio.ts index f580efad..0ed4a158 100644 --- a/packages/core/src/transformers/stremio.ts +++ b/packages/core/src/transformers/stremio.ts @@ -48,7 +48,8 @@ export class StremioTransformer { formatter: { format: (stream: ParsedStream) => { name: string; description: string }; }, - index: number + index: number, + provideStreamData: boolean ): Promise { const { name, description } = stream.addon.formatPassthrough ? { @@ -69,7 +70,9 @@ export class StremioTransformer { .map((attribute) => { switch (attribute) { case 'service': - return stream.service?.id; + return stream.service?.id ?? 'no service'; + case 'type': + return stream.type; case 'proxied': return stream.proxied; case 'addon': @@ -168,26 +171,28 @@ export class StremioTransformer { 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, - id: stream.id, - }, + streamData: provideStreamData + ? { + 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, + id: stream.id, + } + : undefined, }; } @@ -195,12 +200,14 @@ export class StremioTransformer { response: AIOStreamsResponse<{ streams: ParsedStream[]; statistics: { title: string; description: string }[]; - }> + }>, + options?: { provideStreamData: boolean } ): Promise { const { data: { streams, statistics }, errors, } = response; + const { provideStreamData } = options ?? {}; let transformedStreams: AIOStream[] = []; @@ -212,7 +219,12 @@ export class StremioTransformer { transformedStreams = await Promise.all( streams.map((stream: ParsedStream, index: number) => - this.convertParsedStreamToStream(stream, formatter, index) + this.convertParsedStreamToStream( + stream, + formatter, + index, + provideStreamData ?? false + ) ) ); @@ -293,9 +305,11 @@ export class StremioTransformer { } async transformMeta( - response: AIOStreamsResponse + response: AIOStreamsResponse, + options?: { provideStreamData: boolean } ): Promise { const { data: meta, errors } = response; + const { provideStreamData } = options ?? {}; if (!meta && errors.length === 0) { return null; @@ -326,7 +340,12 @@ export class StremioTransformer { if (video.streams && video.streams.length > 0) { const transformedStreams = await Promise.all( video.streams.map((stream, index) => - this.convertParsedStreamToStream(stream, formatter!, index) + this.convertParsedStreamToStream( + stream, + formatter!, + index, + provideStreamData ?? false + ) ) ); video.streams = transformedStreams as unknown as ParsedStream[]; diff --git a/packages/core/src/utils/cache-adapter.ts b/packages/core/src/utils/cache-adapter.ts index 8751f964..f524883e 100644 --- a/packages/core/src/utils/cache-adapter.ts +++ b/packages/core/src/utils/cache-adapter.ts @@ -1,6 +1,7 @@ import { createLogger } from './logger'; import { Env } from './env'; -import { RedisClientType, RedisClientOptions, AbortError } from 'redis'; +import { RedisClientType } from 'redis'; +import { REDIS_PREFIX } from './constants'; const logger = createLogger('cache'); @@ -127,7 +128,7 @@ export class RedisCacheBackend implements CacheBackend { constructor( redisClient: RedisClientType, - prefix: string = 'aiostreams:', + prefix: string = REDIS_PREFIX, maxSize: number = Env.DEFAULT_MAX_CACHE_SIZE, timeout: number = REDIS_TIMEOUT ) { @@ -138,7 +139,7 @@ export class RedisCacheBackend implements CacheBackend { } private getKey(key: K): string { - return `aiostreams:${this.prefix}${String(key)}`; + return `${REDIS_PREFIX}${this.prefix}${String(key)}`; } /** diff --git a/packages/core/src/utils/cache.ts b/packages/core/src/utils/cache.ts index bd53df4e..9eb98903 100644 --- a/packages/core/src/utils/cache.ts +++ b/packages/core/src/utils/cache.ts @@ -50,7 +50,7 @@ export class Cache { } } - private static getRedisClient(): RedisClientType { + public static getRedisClient(): RedisClientType { if (!this.redisClient) { logger.info(`Initialising Redis client connection to ${Env.REDIS_URI}`); this.redisClient = createClient({ diff --git a/packages/core/src/utils/config.ts b/packages/core/src/utils/config.ts index 993b14d0..037c8042 100644 --- a/packages/core/src/utils/config.ts +++ b/packages/core/src/utils/config.ts @@ -245,7 +245,9 @@ export function getEnvironmentServiceDetails(): typeof constants.SERVICE_DETAILS name: cred.name, description: cred.description, type: cred.type, - required: cred.required, + // remove required attribute from field to allow users to remove credentials. + // server will still validate. + required: false, default: getServiceCredentialDefault(service.id, cred.id) ? encryptString(getServiceCredentialDefault(service.id, cred.id)!) .data @@ -412,10 +414,9 @@ export async function validateConfig( await validateRegexes(config); - await new AIOStreams( - ensureDecrypted(config), - skipErrorsFromAddonsOrProxies - ).initialise(); + await new AIOStreams(ensureDecrypted(config), { + skipFailedAddons: skipErrorsFromAddonsOrProxies, + }).initialise(); return config; } diff --git a/packages/core/src/utils/constants.ts b/packages/core/src/utils/constants.ts index 00e37eef..91450b8d 100644 --- a/packages/core/src/utils/constants.ts +++ b/packages/core/src/utils/constants.ts @@ -111,10 +111,15 @@ const HEADERS_FOR_IP_FORWARDING = [ 'Forwarded-For', ]; -export const INTERNAL_SECRET_HEADER = 'X-AIOStreams-Internal-Secret'; +export const INTERNAL_SECRET_HEADER = Buffer.from( + 'WC1BSU9TdHJlYW1zLUludGVybmFsLVNlY3JldA==', + 'base64' +).toString('utf8'); const API_VERSION = 1; +export const REDIS_PREFIX = 'aiostreams:'; + export const GDRIVE_FORMATTER = 'gdrive'; export const LIGHT_GDRIVE_FORMATTER = 'lightgdrive'; export const MINIMALISTIC_GDRIVE_FORMATTER = 'minimalisticgdrive'; @@ -517,6 +522,7 @@ export const AUTO_PLAY_ATTRIBUTES = [ 'visualTags', 'languages', 'releaseGroup', + 'type', 'infoHash', 'size', ] as const; diff --git a/packages/core/src/utils/env.ts b/packages/core/src/utils/env.ts index 0c98479d..e045e20b 100644 --- a/packages/core/src/utils/env.ts +++ b/packages/core/src/utils/env.ts @@ -89,6 +89,34 @@ const namedRegexes = makeValidator((x) => { return parsed; }); +const presetUrls = makeExactValidator((x) => { + if (typeof x !== 'string') { + throw new EnvError('Preset URLs must be a string or an array of strings'); + } + const validateUrl = (x: string) => { + try { + new URL(x); + return true; + } catch (e) { + return false; + } + }; + try { + const urls = JSON.parse(x); + if (!Array.isArray(urls) || urls.some((x) => !validateUrl(x))) { + throw new EnvError( + 'Preset URLs must be an array of URLs or a single URL' + ); + } + return urls; + } catch (e) { + if (typeof x === 'string' && validateUrl(x)) { + return [x]; + } + throw new EnvError('Preset URLs must be an array of URLs or a single URL'); + } +}); + const url = makeValidator((x) => { if (x === '') { throw new EnvMissingError(`URL cannot be empty`); @@ -160,6 +188,17 @@ const readonly = makeValidator((x) => { return x; }); +const boolOrList = makeValidator((x) => { + if (typeof x !== 'string') { + return undefined; + } + x = x.toLowerCase(); + if (['true', 'false', '1', '0'].includes(x)) { + return x === 'true' || x === '1'; + } + return x.split(',').map((x) => x.trim()); +}); + const urlMappings = makeValidator>((x) => { // json object with string properties const parsed = JSON.parse(x); @@ -317,7 +356,22 @@ export const Env = cleanEnv(process.env, { default: undefined, desc: 'TMDB API Key. Used for fetching metadata for the strict title matching option.', }), - + PROVIDE_STREAM_DATA: boolOrList({ + default: undefined, + desc: 'Provide stream data to the client in stream responses. Required for users to wrap this addon within another AIOStreams instance.', + }), + TRUSTED_IPS: commaSeparated({ + default: ['172.17.0.0/16', '127.0.0.1/32', '::1/128'], + desc: 'Comma separated list of trusted IPs / IP ranges. Used when determining the requesting IP. Not required for user IP as all headers are always trusted for user IP.', + }), + ENABLE_SEARCH_API: bool({ + default: true, + desc: 'Enable the search API. If true, the search API will be enabled.', + }), + ALLOW_UNAUTHENTICATED_SEARCH_API: bool({ + default: true, + desc: 'Allow unauthenticated search API requests. i.e. x-aiostreams-user-data header instead of basic auth', + }), // logging settings LOG_SENSITIVE_INFO: bool({ default: false, @@ -749,8 +803,13 @@ export const Env = cleanEnv(process.env, { desc: 'Mapping of URLs to another, converts stream URLs from the original URL to the mapped URL', }), - COMET_URL: url({ - default: 'https://comet.elfhosted.com', + AIOSTREAMS_USER_AGENT: userAgent({ + default: `AIOStreams/${metadata?.version || 'unknown'}`, + desc: 'AIOStreams user agent', + }), + + COMET_URL: presetUrls({ + default: ['https://comet.elfhosted.com'], desc: 'Comet URL', }), FORCE_COMET_HOSTNAME: host({ @@ -776,8 +835,8 @@ export const Env = cleanEnv(process.env, { }), // MediaFusion settings - MEDIAFUSION_URL: url({ - default: 'https://mediafusion.elfhosted.com', + MEDIAFUSION_URL: presetUrls({ + default: ['https://mediafusion.elfhosted.com'], desc: 'MediaFusion URL', }), MEDIAFUSION_API_PASSWORD: str({ @@ -802,8 +861,8 @@ export const Env = cleanEnv(process.env, { }), // Jackettio settings - JACKETTIO_URL: url({ - default: 'https://jackettio.elfhosted.com', + JACKETTIO_URL: presetUrls({ + default: ['https://jackettio.elfhosted.com'], desc: 'Jackettio URL', }), DEFAULT_JACKETTIO_INDEXERS: json({ @@ -1005,8 +1064,8 @@ export const Env = cleanEnv(process.env, { }), // StremThru Store settings - STREMTHRU_STORE_URL: url({ - default: 'https://stremthru.elfhosted.com/stremio/store', + STREMTHRU_STORE_URL: presetUrls({ + default: ['https://stremthru.elfhosted.com/stremio/store'], desc: 'StremThru Store URL', }), DEFAULT_STREMTHRU_STORE_TIMEOUT: num({ @@ -1032,8 +1091,8 @@ export const Env = cleanEnv(process.env, { }), // StremThru Torz settings - STREMTHRU_TORZ_URL: url({ - default: 'https://stremthru.elfhosted.com/stremio/torz', + STREMTHRU_TORZ_URL: presetUrls({ + default: ['https://stremthru.elfhosted.com/stremio/torz'], desc: 'StremThru Torz URL', }), DEFAULT_STREMTHRU_TORZ_TIMEOUT: num({ @@ -1539,11 +1598,11 @@ export const Env = cleanEnv(process.env, { default: 5, // allow 100 requests per IP per minute }), STREAM_API_RATE_LIMIT_WINDOW: num({ - default: 5, // 1 minute + default: 10, // 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 + default: 5, // allow 100 requests per IP per minute }), FORMAT_API_RATE_LIMIT_WINDOW: num({ default: 5, // 10 seconds diff --git a/packages/core/src/utils/startup.ts b/packages/core/src/utils/startup.ts index a00e7c39..c8bcb1fa 100644 --- a/packages/core/src/utils/startup.ts +++ b/packages/core/src/utils/startup.ts @@ -576,7 +576,7 @@ const logStartupInfo = () => { // Addon Sources logSection('ADDONS', '🎬', () => { // Comet - logKeyValue('Comet:', Env.COMET_URL); + logKeyValue('Comet:', Env.COMET_URL.join(', ')); if (Env.DEFAULT_COMET_TIMEOUT) { logKeyValue( ' Timeout:', @@ -596,7 +596,7 @@ const logStartupInfo = () => { } // MediaFusion - logKeyValue('MediaFusion:', Env.MEDIAFUSION_URL); + logKeyValue('MediaFusion:', Env.MEDIAFUSION_URL.join(', ')); if (Env.DEFAULT_MEDIAFUSION_TIMEOUT) { logKeyValue( ' Timeout:', @@ -629,7 +629,7 @@ const logStartupInfo = () => { } // Jackettio - logKeyValue('Jackettio:', Env.JACKETTIO_URL); + logKeyValue('Jackettio:', Env.JACKETTIO_URL.join(', ')); if (Env.DEFAULT_JACKETTIO_TIMEOUT) { logKeyValue( ' Timeout:', @@ -838,7 +838,7 @@ const logStartupInfo = () => { } // StremThru Store - logKeyValue('StremThru Store:', Env.STREMTHRU_STORE_URL); + logKeyValue('StremThru Store:', Env.STREMTHRU_STORE_URL.join(', ')); if (Env.DEFAULT_STREMTHRU_STORE_TIMEOUT) { logKeyValue( ' Timeout:', @@ -872,7 +872,7 @@ const logStartupInfo = () => { } // StremThru Torz - logKeyValue('StremThru Torz:', Env.STREMTHRU_TORZ_URL); + logKeyValue('StremThru Torz:', Env.STREMTHRU_TORZ_URL.join(', ')); if (Env.DEFAULT_STREMTHRU_TORZ_TIMEOUT) { logKeyValue( ' Timeout:', diff --git a/packages/frontend/src/app/main-sidebar.tsx b/packages/frontend/src/app/main-sidebar.tsx index 41a09126..71f8ccad 100644 --- a/packages/frontend/src/app/main-sidebar.tsx +++ b/packages/frontend/src/app/main-sidebar.tsx @@ -34,6 +34,7 @@ import { TextInput } from '@/components/ui/text-input'; import { toast } from 'sonner'; import { Tooltip } from '@/components/ui/tooltip'; import { useOptions } from '@/context/options'; +import { useMode } from '@/context/mode'; type MenuItem = VerticalMenuItem & { id: MenuId; @@ -71,6 +72,7 @@ export function MainSidebar() { }, [pathname]); const { status, error, loading } = useStatus(); + const { mode } = useMode(); const confirmClearConfig = useConfirmationDialog({ title: 'Sign Out', @@ -107,24 +109,32 @@ export function MainSidebar() { isCurrent: selectedMenu === 'filters', id: 'filters', }, - { - name: 'Sorting', - iconType: BiSort, - isCurrent: selectedMenu === 'sorting', - id: 'sorting', - }, + ...(mode === 'pro' + ? [ + { + 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', - }, + ...(mode === 'pro' + ? [ + { + name: 'Proxy', + iconType: BiServer, + isCurrent: selectedMenu === 'proxy', + id: 'proxy', + }, + ] + : []), { name: 'Miscellaneous', iconType: BiCog, diff --git a/packages/frontend/src/app/page.tsx b/packages/frontend/src/app/page.tsx index d6ca7dd9..ffd1a1d2 100644 --- a/packages/frontend/src/app/page.tsx +++ b/packages/frontend/src/app/page.tsx @@ -22,6 +22,7 @@ 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'; +import { ModeProvider } from '@/context/mode'; function ErrorOverlay({ error }: { error: string | null }) { return ( @@ -78,7 +79,9 @@ export default function Home() { - + + + diff --git a/packages/frontend/src/components/menu/about.tsx b/packages/frontend/src/components/menu/about.tsx index b4b6f0d9..726cfd9b 100644 --- a/packages/frontend/src/components/menu/about.tsx +++ b/packages/frontend/src/components/menu/about.tsx @@ -28,7 +28,10 @@ import { SiGithubsponsors, SiKofi } from 'react-icons/si'; import { useUserData } from '@/context/userData'; import { toast } from 'sonner'; import { useMenu } from '@/context/menu'; +import { useMode } from '@/context/mode'; import { DonationModal } from '../shared/donation-modal'; +import { ModeSwitch } from '../ui/mode-switch/mode-switch'; +import { ModeSelectModal } from '../shared/mode-select-modal'; import { Card, CardHeader, @@ -55,6 +58,8 @@ function Content() { const { status, loading, error } = useStatus(); const { nextMenu } = useMenu(); const { userData, setUserData } = useUserData(); + const { mode, setMode, isFirstTime } = useMode(); + const modeSelectModal = useDisclosure(isFirstTime); const addonName = userData.addonName || status?.settings?.addonName || 'AIOStreams'; const defaultDescription = ` @@ -176,17 +181,24 @@ function Content() {
- +
+ + +
@@ -307,6 +319,10 @@ function Content() { currentLogo={userData.addonLogo} currentDescription={userData.addonDescription} /> + ); } diff --git a/packages/frontend/src/components/menu/addons.tsx b/packages/frontend/src/components/menu/addons.tsx index 00a7f72c..2024ac1c 100644 --- a/packages/frontend/src/components/menu/addons.tsx +++ b/packages/frontend/src/components/menu/addons.tsx @@ -73,6 +73,7 @@ import { PiStarFill, PiStarBold } from 'react-icons/pi'; import { IoExtensionPuzzle } from 'react-icons/io5'; import { NumberInput } from '../ui/number-input'; import { useDisclosure } from '@/hooks/disclosure'; +import { useMode } from '@/context/mode'; interface CatalogModification { id: string; @@ -957,6 +958,7 @@ function AddonModal({ initialValues?: Record; onSubmit: (values: Record) => void; }) { + const { mode: configMode } = useMode(); const [values, setValues] = useState>(initialValues); useEffect(() => { if (open) { @@ -969,7 +971,13 @@ function AddonModal({ }, 150); } }, [open, initialValues]); - const dynamicOptions: Option[] = presetMetadata?.OPTIONS || []; + let dynamicOptions: Option[] = presetMetadata?.OPTIONS || []; + if (configMode === 'noob') { + dynamicOptions = dynamicOptions.filter((opt: any) => { + if (opt?.showInNoobMode === false) return false; + return true; + }); + } // Check if all required fields are filled const allRequiredFilled = dynamicOptions.every((opt: any) => { diff --git a/packages/frontend/src/components/menu/filters.tsx b/packages/frontend/src/components/menu/filters.tsx index 58a4368f..e4c2084c 100644 --- a/packages/frontend/src/components/menu/filters.tsx +++ b/packages/frontend/src/components/menu/filters.tsx @@ -81,6 +81,7 @@ import { Slider } from '../ui/slider/slider'; import { TbFilterCode } from 'react-icons/tb'; import { PasswordInput } from '../ui/password-input'; import MarkdownLite from '../shared/markdown-lite'; +import { useMode } from '@/context/mode'; type Resolution = (typeof RESOLUTIONS)[number]; type Quality = (typeof QUALITIES)[number]; @@ -173,6 +174,7 @@ function Content() { const { userData, setUserData } = useUserData(); const allowedRegexModal = useDisclosure(false); const allowedRegexUrlsModal = useDisclosure(false); + const { mode } = useMode(); useEffect(() => { if (tab !== previousTab.current) { previousTab.current = tab; @@ -254,10 +256,12 @@ function Content() { Encode - - - Stream Type - + {mode === 'pro' && ( + + + Stream Type + + )} Visual Tag @@ -336,89 +340,94 @@ function Content() { })); }} /> - { - setUserData((prev) => ({ - ...prev, - excludeUncachedFromAddons: value, - })); - }} - options={userData.presets.map((preset) => ({ - label: preset.options.name || preset.type, - value: preset.instanceId, - textValue: preset.options.name, - }))} - emptyMessage="You haven't installed any addons..." - placeholder="Select addons..." - multiple - disabled={userData.excludeUncached === true} - /> + {mode === 'pro' && ( + <> + { + setUserData((prev) => ({ + ...prev, + excludeUncachedFromAddons: value, + })); + }} + options={userData.presets.map((preset) => ({ + label: preset.options.name || preset.type, + value: preset.instanceId, + textValue: preset.options.name, + }))} + emptyMessage="You haven't installed any addons..." + placeholder="Select addons..." + multiple + disabled={userData.excludeUncached === true} + /> - { - setUserData((prev) => ({ - ...prev, - excludeUncachedFromServices: value, - })); - }} - options={Object.values( - status?.settings.services ?? {} - ).map((service) => ({ - label: service.name, - value: service.id, - textValue: service.name, - }))} - placeholder="Select services..." - emptyMessage="This is odd... there aren't any services to choose from..." - multiple - disabled={userData.excludeUncached === true} - /> - { - setUserData((prev) => ({ - ...prev, - excludeUncachedFromStreamTypes: value as StreamType[], - })); - }} - options={STREAM_TYPES.filter( - (streamType) => - ['debrid', 'usenet'].includes(streamType) // only these 2 stream types can have a service - ).map((streamType) => ({ - label: streamType, - value: streamType, - textValue: streamType, - }))} - emptyMessage="This is odd... there aren't any stream types to choose from..." - placeholder="Select stream types..." - multiple - disabled={userData.excludeUncached === true} - /> + { + setUserData((prev) => ({ + ...prev, + excludeUncachedFromServices: value, + })); + }} + options={Object.values( + status?.settings.services ?? {} + ).map((service) => ({ + label: service.name, + value: service.id, + textValue: service.name, + }))} + placeholder="Select services..." + emptyMessage="This is odd... there aren't any services to choose from..." + multiple + disabled={userData.excludeUncached === true} + /> + { + setUserData((prev) => ({ + ...prev, + excludeUncachedFromStreamTypes: + value as StreamType[], + })); + }} + options={STREAM_TYPES.filter( + (streamType) => + ['debrid', 'usenet'].includes(streamType) // only these 2 stream types can have a service + ).map((streamType) => ({ + label: streamType, + value: streamType, + textValue: streamType, + }))} + emptyMessage="This is odd... there aren't any stream types to choose from..." + placeholder="Select stream types..." + multiple + disabled={userData.excludeUncached === true} + /> - { + setUserData((prev) => ({ + ...prev, + excludeUncachedMode: value as 'or' | 'and', + })); + }} + options={[ + { label: 'OR', value: 'or' }, + { label: 'AND', value: 'and' }, + ]} + /> + + )}
- { - setUserData((prev) => ({ - ...prev, - excludeCachedFromAddons: value, - })); - }} - options={userData.presets.map((preset) => ({ - label: preset.options.name || preset.type, - value: preset.instanceId, - textValue: preset.options.name || preset.type, - }))} - emptyMessage="You haven't installed any addons..." - placeholder="Select addons..." - multiple - disabled={userData.excludeCached === true} - /> - { - setUserData((prev) => ({ - ...prev, - excludeCachedFromServices: value, - })); - }} - options={Object.values( - status?.settings.services ?? {} - ).map((service) => ({ - label: service.name, - value: service.id, - textValue: service.name, - }))} - placeholder="Select services..." - emptyMessage="This is odd... there aren't any services to choose from..." - multiple - disabled={userData.excludeCached === true} - /> - { - setUserData((prev) => ({ - ...prev, - excludeCachedFromStreamTypes: value as StreamType[], - })); - }} - options={STREAM_TYPES.filter( - (streamType) => - ['debrid', 'usenet'].includes(streamType) // only these 2 stream types can have a service - ).map((streamType) => ({ - label: streamType, - value: streamType, - textValue: streamType, - }))} - emptyMessage="This is odd... there aren't any stream types to choose from..." - placeholder="Select stream types..." - multiple - disabled={userData.excludeCached === true} - /> - { + setUserData((prev) => ({ + ...prev, + excludeCachedMode: value as 'or' | 'and', + })); + }} + options={[ + { label: 'OR', value: 'or' }, + { label: 'AND', value: 'and' }, + ]} + /> + + )} @@ -1907,62 +1921,64 @@ function Content() { /> - -
- {RESOLUTIONS.map((resolution) => ( - { - setUserData((prev: any) => ({ - ...prev, - size: { - ...prev.size, - resolution: { - ...prev.size?.resolution, - [resolution]: { - ...prev.size?.resolution?.[resolution], - movies: value, + {mode === 'pro' && ( + +
+ {RESOLUTIONS.map((resolution) => ( + { + setUserData((prev: any) => ({ + ...prev, + size: { + ...prev.size, + resolution: { + ...prev.size?.resolution, + [resolution]: { + ...prev.size?.resolution?.[resolution], + movies: value, + }, }, }, - }, - })); - }} - onSeriesChange={(value) => { - setUserData((prev: any) => ({ - ...prev, - size: { - ...prev.size, - resolution: { - ...prev.size?.resolution, - [resolution]: { - ...prev.size?.resolution?.[resolution], - series: value, + })); + }} + onSeriesChange={(value) => { + setUserData((prev: any) => ({ + ...prev, + size: { + ...prev.size, + resolution: { + ...prev.size?.resolution, + [resolution]: { + ...prev.size?.resolution?.[resolution], + series: value, + }, }, }, - }, - })); - }} - /> - ))} -
-
+ })); + }} + /> + ))} +
+
+ )} @@ -2109,173 +2125,181 @@ function Content() { }} /> + {mode === 'pro' && ( + <> + + Sets of duplicates are separated into groups based on + the streams' type. (e.g. cached, uncached, p2p, etc.) + These options control how each set of duplicates are + handled. + + } + > +
+
+ Single Result +

+ Keeps only one result from your highest priority + service and highest priority addon. If it is a P2P + or uncached result, it prioritises the number of + seeders over addon priority. +

+
+
+ Per Service +

+ This keeps one result per service, and choses each + result using the same criteria above. +

+
+
+ Per Addon +

+ This keeps one result per addon, and choses each + result from your highest priority service, and for + P2P/uncached results it looks at the number of + seeders. +

+
+
+ { - setUserData((prev) => ({ - ...prev, - deduplicator: { - ...prev.deduplicator, - cached: value as - | 'single_result' - | 'per_service' - | 'per_addon' - | 'disabled', - }, - })); - }} - /> + { - setUserData((prev) => ({ - ...prev, - deduplicator: { - ...prev.deduplicator, - uncached: value as - | 'single_result' - | 'per_service' - | 'per_addon' - | 'disabled', - }, - })); - }} - /> + { - setUserData((prev) => ({ - ...prev, - deduplicator: { - ...prev.deduplicator, - p2p: value as - | 'single_result' - | 'per_service' - | 'per_addon' - | 'disabled', - }, - })); - }} - /> -
+ + { + setUserData((prev) => ({ + ...prev, + deduplicator: { + ...prev.deduplicator, + keys: value as (typeof DEDUPLICATOR_KEYS)[number][], + }, + })); + }} + options={DEDUPLICATOR_KEYS.map((key) => ({ + label: key, + value: key, + }))} + /> - - { - setUserData((prev) => ({ - ...prev, - deduplicator: { - ...prev.deduplicator, - keys: value as (typeof DEDUPLICATOR_KEYS)[number][], - }, - })); - }} - options={DEDUPLICATOR_KEYS.map((key) => ({ - label: key, - value: key, - }))} - /> - - ({ - label: AUTO_PLAY_METHOD_DETAILS[method].name, - value: method, - }))} - value={userData.autoPlay?.method || 'matchingFile'} - onValueChange={(value) => { - setUserData((prev) => ({ - ...prev, - autoPlay: { - ...prev.autoPlay, - method: value as AutoPlayMethod, - }, - })); - }} - help={ - AUTO_PLAY_METHOD_DETAILS[ - userData.autoPlay?.method || 'matchingFile' - ].description + +

+ AIOStreams does not (and cannot) directly control auto-play. + It uses the{' '} + + + bingeGroup + + {' '} + attribute to suggest the next stream to Stremio. For this to + work, you must have auto-play enabled in your Stremio + settings. +

+
+ } - /> - {(userData.autoPlay?.method ?? 'matchingFile') === 'matchingFile' && ( - ({ - label: attribute, - value: attribute, - }))} - multiple - disabled={userData.autoPlay?.enabled === false} - emptyMessage="No attributes found" - value={userData.autoPlay?.attributes} - defaultValue={DEFAULT_AUTO_PLAY_ATTRIBUTES as unknown as string[]} + > + { setUserData((prev) => ({ ...prev, autoPlay: { ...prev.autoPlay, - attributes: - value as (typeof AUTO_PLAY_ATTRIBUTES)[number][], + enabled: value, }, })); }} /> - )} -
+ + {showCustomInput && ( + + )} + {description && ( +
{description}
+ )} + + ); + } case 'multi-select': return (
diff --git a/packages/frontend/src/components/ui/mode-switch/mode-switch.tsx b/packages/frontend/src/components/ui/mode-switch/mode-switch.tsx new file mode 100644 index 00000000..367fb9c5 --- /dev/null +++ b/packages/frontend/src/components/ui/mode-switch/mode-switch.tsx @@ -0,0 +1,82 @@ +'use client'; + +import React from 'react'; +import { cn } from '@/components/ui/core/styling'; +import { Mode } from '@/context/mode'; + +interface ModeSwitchProps { + value: Mode; + onChange: (value: Mode) => void; + size?: 'sm' | 'md' | 'lg'; + className?: string; +} + +export function ModeSwitch({ + value, + onChange, + size = 'md', + className, +}: ModeSwitchProps) { + const containerRef = React.useRef(null); + const [highlightStyle, setHighlightStyle] = React.useState({ + width: '50%', + transform: 'translateX(0)', + }); + + React.useEffect(() => { + if (containerRef.current) { + const width = containerRef.current.offsetWidth / 2; + setHighlightStyle({ + width: `${width}px`, + transform: `translateX(${value === 'pro' ? width : 0}px)`, + }); + } + }, [value]); + + const sizeClasses = { + sm: 'h-10 text-sm', + md: 'h-12 text-base', + lg: 'h-14 text-lg', + }; + + return ( +
+ {/* Animated highlight */} +
+ + {/* Buttons */} + + +
+ ); +} diff --git a/packages/frontend/src/context/mode.tsx b/packages/frontend/src/context/mode.tsx new file mode 100644 index 00000000..f74f8fc9 --- /dev/null +++ b/packages/frontend/src/context/mode.tsx @@ -0,0 +1,69 @@ +'use client'; + +import React from 'react'; + +export type Mode = 'pro' | 'noob'; + +interface ModeContextType { + mode: Mode; + setMode: (mode: Mode) => void; + isFirstTime: boolean; + setIsFirstTime: (isFirstTime: boolean) => void; +} + +const ModeContext = React.createContext(undefined); + +const MODE_STORAGE_KEY = 'aiostreams-mode'; +const FIRST_TIME_KEY = 'aiostreams-first-time'; + +export function ModeProvider({ children }: { children: React.ReactNode }) { + const [mode, setModeState] = React.useState(() => { + if (typeof window !== 'undefined') { + const savedMode = localStorage.getItem(MODE_STORAGE_KEY); + return (savedMode as Mode) || 'noob'; + } + return 'noob'; + }); + + const [isFirstTime, setIsFirstTimeState] = React.useState(() => { + if (typeof window !== 'undefined') { + return localStorage.getItem(FIRST_TIME_KEY) === null; + } + return true; + }); + + const setMode = React.useCallback((newMode: Mode) => { + setModeState(newMode); + if (typeof window !== 'undefined') { + localStorage.setItem(MODE_STORAGE_KEY, newMode); + } + }, []); + + const setIsFirstTime = React.useCallback((value: boolean) => { + setIsFirstTimeState(value); + if (typeof window !== 'undefined' && !value) { + localStorage.setItem(FIRST_TIME_KEY, 'false'); + } + }, []); + + return ( + + {children} + + ); +} + +export function useMode() { + const context = React.useContext(ModeContext); + if (context === undefined) { + throw new Error('useMode must be used within a ModeProvider'); + } + return context; +} diff --git a/packages/server/package.json b/packages/server/package.json index 09d965aa..22b7e8a4 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -12,11 +12,12 @@ "dependencies": { "@aiostreams/core": "^0.0.0", "express": "^4.21.2", - "express-rate-limit": "^7.5.0" + "express-rate-limit": "^7.5.0", + "rate-limit-redis": "^4.2.2" }, "devDependencies": { "@types/express": "^5.0.1", "@types/express-rate-limit": "^5.1.3", "@types/node": "^20.14.10" } -} \ No newline at end of file +} diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 7a7e0beb..d54ab3d1 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -9,6 +9,7 @@ import { rpdbApi, gdriveApi, debridApi, + searchApi, } from './routes/api'; import { configure, @@ -73,6 +74,9 @@ apiRouter.use('/catalogs', catalogApi); apiRouter.use('/rpdb', rpdbApi); apiRouter.use('/oauth/exchange/gdrive', gdriveApi); apiRouter.use('/debrid', debridApi); +if (Env.ENABLE_SEARCH_API) { + apiRouter.use('/search', searchApi); +} app.use(`/api/v${constants.API_VERSION}`, apiRouter); // Stremio Routes diff --git a/packages/server/src/express.d.ts b/packages/server/src/express.d.ts index 0ac516ef..5ad89394 100644 --- a/packages/server/src/express.d.ts +++ b/packages/server/src/express.d.ts @@ -6,6 +6,7 @@ declare global { interface Request { userData?: UserData; userIp?: string; + requestIp?: string; uuid?: string; } } diff --git a/packages/server/src/middlewares/ip.ts b/packages/server/src/middlewares/ip.ts index e249db08..508657f0 100644 --- a/packages/server/src/middlewares/ip.ts +++ b/packages/server/src/middlewares/ip.ts @@ -1,26 +1,58 @@ import { Request, Response, NextFunction } from 'express'; -import { createLogger } from '@aiostreams/core'; +import { createLogger, Env } from '@aiostreams/core'; const logger = createLogger('server'); +const isIpInRange = (ip: string, range: string) => { + if (range.includes('/')) { + // CIDR notation + const [rangeIp, prefixLength] = range.split('/'); + const ipToLong = (ip: string) => + ip + .split('.') + .reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0; + try { + const ipLong = ipToLong(ip); + const rangeLong = ipToLong(rangeIp); + const mask = ~(2 ** (32 - parseInt(prefixLength, 10)) - 1) >>> 0; + return (ipLong & mask) === (rangeLong & mask); + } catch { + return false; + } + } + // Exact match + return ip === range; +}; + export const ipMiddleware = ( req: Request, res: Response, next: NextFunction ) => { + const getIpFromHeaders = (req: Request) => { + 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 + ); + }; // extract IP from headers - const ip = - 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; - - // attach IP to request object - req.userIp = ip; + const userIp = getIpFromHeaders(req); + const ip = req.ip || ''; + const trustedIps = Env.TRUSTED_IPS || []; + const isTrustedIp = trustedIps.some((range) => isIpInRange(ip, range)); + const requestIp = isTrustedIp + ? req.get('X-Forwarded-For')?.split(',')[0].trim() || + req.get('CF-Connecting-IP') || + ip + : ip; + req.userIp = userIp; + req.requestIp = requestIp; next(); }; diff --git a/packages/server/src/middlewares/logger.ts b/packages/server/src/middlewares/logger.ts index 7dfec40c..17137aa0 100644 --- a/packages/server/src/middlewares/logger.ts +++ b/packages/server/src/middlewares/logger.ts @@ -24,7 +24,7 @@ export const loggerMiddleware = ( ip: req.userIp ? maskSensitiveInfo(req.userIp) : undefined, contentType: req.get('content-type'), userAgent: req.get('user-agent'), - formatted: `${req.method} ${makeUrlLogSafe(req.originalUrl)}${req.userIp ? ` - ${maskSensitiveInfo(req.userIp)}` : ''} - ${req.get('content-type')} - ${req.get('user-agent')}`, + formatted: `${req.method} ${makeUrlLogSafe(req.originalUrl)}${req.userIp ? ` - u:${maskSensitiveInfo(req.userIp)}` : ''}${req.requestIp ? ` - r:${maskSensitiveInfo(req.requestIp)}` : ''} - ${req.get('content-type')} - ${req.get('user-agent')}`, }); // Capture response finish event @@ -42,7 +42,7 @@ export const loggerMiddleware = ( ip: req.userIp ? maskSensitiveInfo(req.userIp) : undefined, contentType: res.get('content-type'), contentLength: res.get('content-length'), - formatted: `${req.method} ${makeUrlLogSafe(req.originalUrl)}${req.userIp ? ` - ${maskSensitiveInfo(req.userIp)}` : ''} - Response: ${res.statusCode} - ${duration}`, + formatted: `${req.method} ${makeUrlLogSafe(req.originalUrl)}${req.userIp ? ` - u: ${maskSensitiveInfo(req.userIp)}` : ''}${req.requestIp ? ` - r: ${maskSensitiveInfo(req.requestIp)}` : ''} - Response: ${res.statusCode} - ${duration}`, }); }); diff --git a/packages/server/src/middlewares/ratelimit.ts b/packages/server/src/middlewares/ratelimit.ts index db2f0b07..80ac1714 100644 --- a/packages/server/src/middlewares/ratelimit.ts +++ b/packages/server/src/middlewares/ratelimit.ts @@ -1,7 +1,14 @@ -import rateLimit from 'express-rate-limit'; +import rateLimit, { MemoryStore } from 'express-rate-limit'; import { Request, Response, NextFunction } from 'express'; - -import { Env, createLogger, constants, APIError } from '@aiostreams/core'; +import { RedisStore } from 'rate-limit-redis'; +import { + Env, + createLogger, + constants, + APIError, + Cache, + REDIS_PREFIX, +} from '@aiostreams/core'; const logger = createLogger('server'); @@ -13,13 +20,22 @@ const createRateLimiter = ( if (Env.DISABLE_RATE_LIMITS) { return (req: Request, res: Response, next: NextFunction) => next(); } + const redisClient = Env.REDIS_URI ? Cache.getRedisClient() : undefined; + const store = redisClient + ? new RedisStore({ + prefix: `${REDIS_PREFIX}rate-limit:`, + sendCommand: (...args: string[]) => redisClient.sendCommand(args), + }) + : new MemoryStore(); return rateLimit({ windowMs, max: maxRequests, standardHeaders: true, legacyHeaders: false, + store, // Use a unique store key for each rate limiter - keyGenerator: (req: Request) => `${prefix}:${req.userIp || req.ip || ''}`, + keyGenerator: (req: Request) => + `${prefix}:${req.requestIp || req.userIp || req.ip || ''}`, handler: ( req: Request, res: Response, @@ -30,7 +46,7 @@ const createRateLimiter = ( ? req.rateLimit.resetTime.getTime() - new Date().getTime() : 0; logger.warn( - `${prefix} rate limit exceeded for IP: ${req.userIp || req.ip} - ${ + `${prefix} rate limit exceeded for IP: ${req.requestIp || req.userIp || req.ip} - ${ options.message } - Time remaining: ${timeRemaining}ms` ); diff --git a/packages/server/src/routes/api/index.ts b/packages/server/src/routes/api/index.ts index c76bfa13..f865d50e 100644 --- a/packages/server/src/routes/api/index.ts +++ b/packages/server/src/routes/api/index.ts @@ -6,3 +6,4 @@ export { default as catalogApi } from './catalog'; export { default as rpdbApi } from './rpdb'; export { default as gdriveApi } from './gdrive'; export { default as debridApi } from './debrid'; +export { default as searchApi } from './search'; diff --git a/packages/server/src/routes/api/search.ts b/packages/server/src/routes/api/search.ts new file mode 100644 index 00000000..046cb8e8 --- /dev/null +++ b/packages/server/src/routes/api/search.ts @@ -0,0 +1,147 @@ +import { Router, Request, Response } from 'express'; +import { + AIOStreams, + AIOStreamResponse, + Env, + UserData, + UserRepository, + APIError, + constants, + formatZodError, + validateConfig, +} from '@aiostreams/core'; +import { streamApiRateLimiter } from '../../middlewares/ratelimit'; +import { createLogger } from '@aiostreams/core'; +import { ApiTransformer, ApiSearchResponseData } from '@aiostreams/core'; +import { ApiResponse, createResponse } from '../../utils/responses'; +import { z, ZodError } from 'zod'; +const router = Router(); + +const logger = createLogger('server'); + +router.use(streamApiRateLimiter); + +router.get( + '/', + async ( + req: Request, + res: Response>, + next + ) => { + try { + const { type, id } = z + .object({ + type: z.string(), + id: z.string(), + }) + .parse(req.query); + let encodedUserData: string | undefined = z + .string() + .optional() + .parse(req.headers['x-aiostreams-user-data']); + let auth: string | undefined = z + .string() + .optional() + .parse(req.headers['authorization']); + + if (!encodedUserData && !auth) { + throw new APIError( + constants.ErrorCode.BAD_REQUEST, + undefined, + `At least one of AIOStreams-User-Data or Authorization headers must be present` + ); + } + + let userData: UserData | null = null; + + if (encodedUserData && Env.ALLOW_UNAUTHENTICATED_SEARCH_API) { + try { + userData = JSON.parse( + Buffer.from(encodedUserData, 'base64').toString('utf-8') + ); + if (userData) { + logger.debug(`Using encodedUserData for Search API request`); + } + } catch (error: any) { + throw new APIError( + constants.ErrorCode.BAD_REQUEST, + undefined, + `Invalid encodedUserData: ${error.message}` + ); + } + } else if (auth) { + let uuid: string; + let password: string; + try { + if (!auth.startsWith('Basic ')) { + throw new APIError( + constants.ErrorCode.BAD_REQUEST, + undefined, + `Invalid auth: ${auth}. Must start with 'Basic '` + ); + } + [uuid, password] = Buffer.from( + auth.replace(/^Basic\s+/, ''), + 'base64' + ) + .toString('utf-8') + .split(':'); + logger.debug(`Using basic auth for Search API request: ${uuid}`); + } catch (error: any) { + throw new APIError( + constants.ErrorCode.BAD_REQUEST, + undefined, + `Invalid auth: ${error.message}` + ); + } + const userExists = await UserRepository.checkUserExists(uuid); + if (!userExists) { + throw new APIError(constants.ErrorCode.USER_INVALID_DETAILS); + } + + userData = await UserRepository.getUser(uuid, password); + + if (!userData) { + throw new APIError(constants.ErrorCode.USER_INVALID_DETAILS); + } + } + if (!userData) { + throw new APIError(constants.ErrorCode.USER_INVALID_DETAILS); + } + try { + userData = await validateConfig(userData, true, true); + } catch (error: any) { + throw new APIError( + constants.ErrorCode.USER_INVALID_CONFIG, + undefined, + error.message + ); + } + const transformer = new ApiTransformer(userData); + + res.status(200).json( + createResponse({ + success: true, + data: await transformer.transformStreams( + await ( + await new AIOStreams(userData).initialise() + ).getStreams(id, type) + ), + }) + ); + } catch (error) { + if (error instanceof ZodError) { + next( + new APIError( + constants.ErrorCode.BAD_REQUEST, + undefined, + formatZodError(error) + ) + ); + } + next(error); + } + } +); + +export default router; diff --git a/packages/server/src/routes/api/status.ts b/packages/server/src/routes/api/status.ts index 13c32661..3cda308e 100644 --- a/packages/server/src/routes/api/status.ts +++ b/packages/server/src/routes/api/status.ts @@ -33,6 +33,7 @@ const statusInfo = async (): Promise => { protected: Env.ADDON_PASSWORD.length > 0, tmdbApiAvailable: !!Env.TMDB_ACCESS_TOKEN, regexFilterAccess: Env.REGEX_FILTER_ACCESS, + allowUnauthenticatedSearchApi: Env.ALLOW_UNAUTHENTICATED_SEARCH_API, allowedRegexPatterns: (await FeatureControl.allowedRegexPatterns()).patterns.length > 0 ? { diff --git a/packages/server/src/routes/stremio/manifest.ts b/packages/server/src/routes/stremio/manifest.ts index fcff9d33..17fdbe15 100644 --- a/packages/server/src/routes/stremio/manifest.ts +++ b/packages/server/src/routes/stremio/manifest.ts @@ -27,7 +27,7 @@ const manifest = async (config?: UserData): Promise => { let resources: Manifest['resources'] = []; let addonCatalogs: Manifest['addonCatalogs'] = []; if (config) { - const aiostreams = new AIOStreams(config, true); + const aiostreams = new AIOStreams(config, { skipFailedAddons: true }); await aiostreams.initialise(); diff --git a/packages/server/src/routes/stremio/stream.ts b/packages/server/src/routes/stremio/stream.ts index 44be9ef5..a947c7c2 100644 --- a/packages/server/src/routes/stremio/stream.ts +++ b/packages/server/src/routes/stremio/stream.ts @@ -1,5 +1,5 @@ import { Router, Request, Response } from 'express'; -import { AIOStreams, AIOStreamResponse } from '@aiostreams/core'; +import { AIOStreams, AIOStreamResponse, Env } from '@aiostreams/core'; import { stremioStreamRateLimiter } from '../../middlewares/ratelimit'; import { createLogger } from '@aiostreams/core'; import { StremioTransformer } from '@aiostreams/core'; @@ -24,6 +24,13 @@ router.get( } const transformer = new StremioTransformer(req.userData); + const provideStreamData = + Env.PROVIDE_STREAM_DATA !== undefined + ? typeof Env.PROVIDE_STREAM_DATA === 'boolean' + ? Env.PROVIDE_STREAM_DATA + : Env.PROVIDE_STREAM_DATA.includes(req.requestIp || '') + : (req.headers['user-agent']?.includes('AIOStreams/') ?? false); + try { const { type, id } = req.params; @@ -33,7 +40,8 @@ router.get( await transformer.transformStreams( await ( await new AIOStreams(req.userData).initialise() - ).getStreams(id, type) + ).getStreams(id, type), + { provideStreamData } ) ); } catch (error) { diff --git a/packages/server/src/utils/responses.ts b/packages/server/src/utils/responses.ts index 4814815c..4044780e 100644 --- a/packages/server/src/utils/responses.ts +++ b/packages/server/src/utils/responses.ts @@ -1,4 +1,5 @@ import { createLogger } from '@aiostreams/core'; +import { Request } from 'express'; const logger = createLogger('server'); type ApiResponseOptions = { @@ -10,8 +11,17 @@ type ApiResponseOptions = { message: string; }; }; +export type ApiResponse = { + success: boolean; + detail: string | null; + data: T | null; + error: { + code: string; + message: string; + } | null; +}; -export function createResponse(options: ApiResponseOptions) { +export function createResponse(options: ApiResponseOptions): ApiResponse { const { success, detail, data, error } = options; return {