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
This commit is contained in:
Viren070
2025-08-29 20:29:50 +01:00
parent ea4c9ab715
commit 571ea7fbc3
54 changed files with 1545 additions and 703 deletions
+24
View File
@@ -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.
+14 -1
View File
@@ -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",
+52 -45
View File
@@ -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<typeof ExtrasSchema>;
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<typeof AIOStream>;
@@ -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()),
+2 -2
View File
@@ -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);
+3 -13
View File
@@ -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',
+5 -6
View File
@@ -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',
+5 -1
View File
@@ -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,
+10 -2
View File
@@ -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,
+1
View File
@@ -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,
@@ -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,
+1
View File
@@ -94,6 +94,7 @@ export class DMMCastPreset extends Preset {
label: resource,
value: resource,
})),
showInNoobMode: false,
},
{
id: 'socials',
+2
View File
@@ -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,
+4 -2
View File
@@ -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,
+10 -2
View File
@@ -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,
+2
View File
@@ -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,
+3
View File
@@ -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,
},
];
+53 -38
View File
@@ -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';
@@ -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,
+4 -2
View File
@@ -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,
+6 -2
View File
@@ -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,
@@ -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,
+3
View File
@@ -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,
},
];
+4
View File
@@ -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,
},
];
+83
View File
@@ -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<string, string>;
responseHeaders: Record<string, string>;
}
export class ApiTransformer {
constructor(private readonly userData: UserData) {}
async transformStreams(
response: AIOStreamsResponse<{
streams: ParsedStream[];
statistics: { title: string; description: string }[];
}>
): Promise<ApiSearchResponseData> {
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 ?? '',
})),
};
}
}
+1
View File
@@ -1 +1,2 @@
export * from './stremio';
export * from './api';
+45 -26
View File
@@ -48,7 +48,8 @@ export class StremioTransformer {
formatter: {
format: (stream: ParsedStream) => { name: string; description: string };
},
index: number
index: number,
provideStreamData: boolean
): Promise<AIOStream> {
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<AIOStreamResponse> {
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<ParsedMeta | null>
response: AIOStreamsResponse<ParsedMeta | null>,
options?: { provideStreamData: boolean }
): Promise<MetaResponse | null> {
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[];
+4 -3
View File
@@ -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<K, V> implements CacheBackend<K, V> {
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<K, V> implements CacheBackend<K, V> {
}
private getKey(key: K): string {
return `aiostreams:${this.prefix}${String(key)}`;
return `${REDIS_PREFIX}${this.prefix}${String(key)}`;
}
/**
+1 -1
View File
@@ -50,7 +50,7 @@ export class Cache<K, V> {
}
}
private static getRedisClient(): RedisClientType {
public static getRedisClient(): RedisClientType {
if (!this.redisClient) {
logger.info(`Initialising Redis client connection to ${Env.REDIS_URI}`);
this.redisClient = createClient({
+6 -5
View File
@@ -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;
}
+7 -1
View File
@@ -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;
+72 -13
View File
@@ -89,6 +89,34 @@ const namedRegexes = makeValidator((x) => {
return parsed;
});
const presetUrls = makeExactValidator<string[]>((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<Record<string, string>>((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<boolean | string[] | undefined>({
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
+5 -5
View File
@@ -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:',
+22 -12
View File
@@ -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,
+4 -1
View File
@@ -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() {
<StatusProvider>
<UserDataProvider>
<OptionsProvider>
<AppContent />
<ModeProvider>
<AppContent />
</ModeProvider>
</OptionsProvider>
</UserDataProvider>
</StatusProvider>
+27 -11
View File
@@ -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() {
</div>
<div className="flex items-center justify-center mb-6">
<Button
intent="white"
size="lg"
rounded
// className="px-8 py-2.5 font-semibold shadow-lg hover:scale-105 transition-transform duration-200"
onClick={() => {
nextMenu();
}}
>
Configure
</Button>
<div className="flex flex-col gap-4 items-center">
<Button
intent="white"
size="lg"
rounded
onClick={() => {
nextMenu();
}}
>
Configure
</Button>
<ModeSwitch
value={mode}
onChange={setMode}
size="md"
className="w-[280px]"
/>
</div>
</div>
<div className="relative">
@@ -307,6 +319,10 @@ function Content() {
currentLogo={userData.addonLogo}
currentDescription={userData.addonDescription}
/>
<ModeSelectModal
open={modeSelectModal.isOpen}
onOpenChange={modeSelectModal.toggle}
/>
</>
);
}
@@ -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<string, any>;
onSubmit: (values: Record<string, any>) => void;
}) {
const { mode: configMode } = useMode();
const [values, setValues] = useState<Record<string, any>>(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) => {
+428 -400
View File
@@ -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() {
<FaFilm className="text-lg mr-3" />
Encode
</TabsTrigger>
<TabsTrigger value="stream-type">
<MdVideoLibrary className="text-lg mr-3" />
Stream Type
</TabsTrigger>
{mode === 'pro' && (
<TabsTrigger value="stream-type">
<MdVideoLibrary className="text-lg mr-3" />
Stream Type
</TabsTrigger>
)}
<TabsTrigger value="visual-tag">
<MdHdrOn className="text-lg mr-3" />
Visual Tag
@@ -336,89 +340,94 @@ function Content() {
}));
}}
/>
<Combobox
help="Addons selected here will have their uncached results excluded"
label="Exclude Uncached From Addons"
value={userData.excludeUncachedFromAddons ?? []}
onValueChange={(value) => {
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' && (
<>
<Combobox
help="Addons selected here will have their uncached results excluded"
label="Exclude Uncached From Addons"
value={userData.excludeUncachedFromAddons ?? []}
onValueChange={(value) => {
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}
/>
<Combobox
help="Services selected here will have their uncached results excluded"
label="Exclude Uncached From Services"
value={userData.excludeUncachedFromServices ?? []}
onValueChange={(value) => {
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}
/>
<Combobox
help="Stream types selected here will have their uncached results excluded"
label="Exclude Uncached From Stream Types"
value={userData.excludeUncachedFromStreamTypes ?? []}
onValueChange={(value) => {
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}
/>
<Combobox
help="Services selected here will have their uncached results excluded"
label="Exclude Uncached From Services"
value={userData.excludeUncachedFromServices ?? []}
onValueChange={(value) => {
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}
/>
<Combobox
help="Stream types selected here will have their uncached results excluded"
label="Exclude Uncached From Stream Types"
value={userData.excludeUncachedFromStreamTypes ?? []}
onValueChange={(value) => {
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}
/>
<Select
label="Apply mode"
disabled={userData.excludeUncached === true}
help="How these three options (from addons, services and stream types) are applied. AND means a result must match all, OR means a result only needs to match one"
value={userData.excludeUncachedMode ?? 'or'}
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
excludeUncachedMode: value as 'or' | 'and',
}));
}}
options={[
{ label: 'OR', value: 'or' },
{ label: 'AND', value: 'and' },
]}
/>
<Select
label="Apply mode"
disabled={userData.excludeUncached === true}
help="How these three options (from addons, services and stream types) are applied. AND means a result must match all, OR means a result only needs to match one"
value={userData.excludeUncachedMode ?? 'or'}
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
excludeUncachedMode: value as 'or' | 'and',
}));
}}
options={[
{ label: 'OR', value: 'or' },
{ label: 'AND', value: 'and' },
]}
/>
</>
)}
</div>
</SettingsCard>
<SettingsCard
@@ -439,87 +448,92 @@ function Content() {
}));
}}
/>
<Combobox
help="Addons selected here will have their cached results excluded"
label="Exclude Cached From Addons"
value={userData.excludeCachedFromAddons ?? []}
onValueChange={(value) => {
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}
/>
<Combobox
help="Services selected here will have their cached results excluded"
label="Exclude Cached From Services"
value={userData.excludeCachedFromServices ?? []}
onValueChange={(value) => {
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}
/>
<Combobox
help="Stream types selected here will have their cached results excluded"
label="Exclude Cached From Stream Types"
value={userData.excludeCachedFromStreamTypes ?? []}
onValueChange={(value) => {
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}
/>
<Select
label="Apply mode"
disabled={userData.excludeCached === true}
help="How these three options (from addons, services and stream types) are applied. AND means a result must match all, OR means a result only needs to match one"
value={userData.excludeCachedMode ?? 'or'}
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
excludeCachedMode: value as 'or' | 'and',
}));
}}
options={[
{ label: 'OR', value: 'or' },
{ label: 'AND', value: 'and' },
]}
/>
{mode === 'pro' && (
<>
<Combobox
help="Addons selected here will have their cached results excluded"
label="Exclude Cached From Addons"
value={userData.excludeCachedFromAddons ?? []}
onValueChange={(value) => {
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}
/>
<Combobox
help="Services selected here will have their cached results excluded"
label="Exclude Cached From Services"
value={userData.excludeCachedFromServices ?? []}
onValueChange={(value) => {
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}
/>
<Combobox
help="Stream types selected here will have their cached results excluded"
label="Exclude Cached From Stream Types"
value={userData.excludeCachedFromStreamTypes ?? []}
onValueChange={(value) => {
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}
/>
<Select
label="Apply mode"
disabled={userData.excludeCached === true}
help="How these three options (from addons, services and stream types) are applied. AND means a result must match all, OR means a result only needs to match one"
value={userData.excludeCachedMode ?? 'or'}
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
excludeCachedMode: value as 'or' | 'and',
}));
}}
options={[
{ label: 'OR', value: 'or' },
{ label: 'AND', value: 'and' },
]}
/>
</>
)}
</div>
</SettingsCard>
</div>
@@ -1907,62 +1921,64 @@ function Content() {
/>
</SettingsCard>
<SettingsCard
title="Resolution-Specific"
description="Set size limits for specific resolutions"
>
<div className="space-y-8">
{RESOLUTIONS.map((resolution) => (
<SizeRangeSlider
key={resolution}
label={resolution}
help={`Set the minimum and maximum size for ${resolution} results`}
moviesValue={
userData.size?.resolution?.[resolution]?.movies || [
MIN_SIZE,
MAX_SIZE,
]
}
seriesValue={
userData.size?.resolution?.[resolution]?.series || [
MIN_SIZE,
MAX_SIZE,
]
}
onMoviesChange={(value) => {
setUserData((prev: any) => ({
...prev,
size: {
...prev.size,
resolution: {
...prev.size?.resolution,
[resolution]: {
...prev.size?.resolution?.[resolution],
movies: value,
{mode === 'pro' && (
<SettingsCard
title="Resolution-Specific"
description="Set size limits for specific resolutions"
>
<div className="space-y-8">
{RESOLUTIONS.map((resolution) => (
<SizeRangeSlider
key={resolution}
label={resolution}
help={`Set the minimum and maximum size for ${resolution} results`}
moviesValue={
userData.size?.resolution?.[resolution]?.movies || [
MIN_SIZE,
MAX_SIZE,
]
}
seriesValue={
userData.size?.resolution?.[resolution]?.series || [
MIN_SIZE,
MAX_SIZE,
]
}
onMoviesChange={(value) => {
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,
},
},
},
},
}));
}}
/>
))}
</div>
</SettingsCard>
}));
}}
/>
))}
</div>
</SettingsCard>
)}
</div>
</>
</TabsContent>
@@ -2109,173 +2125,181 @@ function Content() {
}}
/>
</SettingsCard>
{mode === 'pro' && (
<>
<SettingsCard
title="Group Handling"
description={
<div>
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.
</div>
}
>
<div className="mt-2 space-y-2">
<div>
<span className="font-medium">Single Result</span>
<p className="text-sm text-[--muted] mt-1">
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.
</p>
</div>
<div>
<span className="font-medium">Per Service</span>
<p className="text-sm text-[--muted] mt-1">
This keeps one result per service, and choses each
result using the same criteria above.
</p>
</div>
<div>
<span className="font-medium">Per Addon</span>
<p className="text-sm text-[--muted] mt-1">
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.
</p>
</div>
</div>
<Select
disabled={!userData.deduplicator?.enabled}
label="Cached Results"
value={userData.deduplicator?.cached ?? 'disabled'}
options={[
{ label: 'Disabled', value: 'disabled' },
{ label: 'Single Result', value: 'single_result' },
{ label: 'Per Service', value: 'per_service' },
{ label: 'Per Addon', value: 'per_addon' },
]}
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
deduplicator: {
...prev.deduplicator,
cached: value as
| 'single_result'
| 'per_service'
| 'per_addon'
| 'disabled',
},
}));
}}
/>
<SettingsCard
title="Group Handling"
description={
<div>
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.
</div>
}
>
<div className="mt-2 space-y-2">
<div>
<span className="font-medium">Single Result</span>
<p className="text-sm text-[--muted] mt-1">
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.
</p>
</div>
<div>
<span className="font-medium">Per Service</span>
<p className="text-sm text-[--muted] mt-1">
This keeps one result per service, and choses each
result using the same criteria above.
</p>
</div>
<div>
<span className="font-medium">Per Addon</span>
<p className="text-sm text-[--muted] mt-1">
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.
</p>
</div>
</div>
<Select
disabled={!userData.deduplicator?.enabled}
label="Cached Results"
value={userData.deduplicator?.cached ?? 'disabled'}
options={[
{ label: 'Disabled', value: 'disabled' },
{ label: 'Single Result', value: 'single_result' },
{ label: 'Per Service', value: 'per_service' },
{ label: 'Per Addon', value: 'per_addon' },
]}
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
deduplicator: {
...prev.deduplicator,
cached: value as
| 'single_result'
| 'per_service'
| 'per_addon'
| 'disabled',
},
}));
}}
/>
<Select
disabled={!userData.deduplicator?.enabled}
label="Uncached Results"
value={userData.deduplicator?.uncached ?? 'disabled'}
options={[
{ label: 'Disabled', value: 'disabled' },
{ label: 'Single Result', value: 'single_result' },
{ label: 'Per Service', value: 'per_service' },
{ label: 'Per Addon', value: 'per_addon' },
]}
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
deduplicator: {
...prev.deduplicator,
uncached: value as
| 'single_result'
| 'per_service'
| 'per_addon'
| 'disabled',
},
}));
}}
/>
<Select
disabled={!userData.deduplicator?.enabled}
label="Uncached Results"
value={userData.deduplicator?.uncached ?? 'disabled'}
options={[
{ label: 'Disabled', value: 'disabled' },
{ label: 'Single Result', value: 'single_result' },
{ label: 'Per Service', value: 'per_service' },
{ label: 'Per Addon', value: 'per_addon' },
]}
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
deduplicator: {
...prev.deduplicator,
uncached: value as
| 'single_result'
| 'per_service'
| 'per_addon'
| 'disabled',
},
}));
}}
/>
<Select
disabled={!userData.deduplicator?.enabled}
label="P2P Results"
value={userData.deduplicator?.p2p ?? 'disabled'}
options={[
{ label: 'Disabled', value: 'disabled' },
{ label: 'Single Result', value: 'single_result' },
{ label: 'Per Service', value: 'per_service' },
{ label: 'Per Addon', value: 'per_addon' },
]}
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
deduplicator: {
...prev.deduplicator,
p2p: value as
| 'single_result'
| 'per_service'
| 'per_addon'
| 'disabled',
},
}));
}}
/>
</SettingsCard>
<Select
disabled={!userData.deduplicator?.enabled}
label="P2P Results"
value={userData.deduplicator?.p2p ?? 'disabled'}
options={[
{ label: 'Disabled', value: 'disabled' },
{ label: 'Single Result', value: 'single_result' },
{ label: 'Per Service', value: 'per_service' },
{ label: 'Per Addon', value: 'per_addon' },
]}
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
deduplicator: {
...prev.deduplicator,
p2p: value as
| 'single_result'
| 'per_service'
| 'per_addon'
| 'disabled',
},
}));
}}
/>
</SettingsCard>
<SettingsCard title="Other">
<Combobox
disabled={!userData.deduplicator?.enabled}
label="Detection Methods"
multiple
help="Select the methods used to detect duplicates"
value={
userData.deduplicator?.keys ?? [
'filename',
'infoHash',
]
}
emptyMessage="No detection methods available"
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
deduplicator: {
...prev.deduplicator,
keys: value as (typeof DEDUPLICATOR_KEYS)[number][],
},
}));
}}
options={DEDUPLICATOR_KEYS.map((key) => ({
label: key,
value: key,
}))}
/>
<SettingsCard title="Other">
<Combobox
disabled={!userData.deduplicator?.enabled}
label="Detection Methods"
multiple
help="Select the methods used to detect duplicates"
value={
userData.deduplicator?.keys ?? ['filename', 'infoHash']
}
emptyMessage="No detection methods available"
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
deduplicator: {
...prev.deduplicator,
keys: value as (typeof DEDUPLICATOR_KEYS)[number][],
},
}));
}}
options={DEDUPLICATOR_KEYS.map((key) => ({
label: key,
value: key,
}))}
/>
<Select
label="Multi-Group Behaviour"
help={`Configure how duplicates across multiple types are handled. e.g. if a given duplicate set has both cached and uncached streams, what should be done.
<Select
label="Multi-Group Behaviour"
help={`Configure how duplicates across multiple types are handled. e.g. if a given duplicate set has both cached and uncached streams, what should be done.
${deduplicatorMultiGroupBehaviourHelp[userData.deduplicator?.multiGroupBehaviour || defaultDeduplicatorMultiGroupBehaviour]}
`}
value={
userData.deduplicator?.multiGroupBehaviour ??
defaultDeduplicatorMultiGroupBehaviour
}
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
deduplicator: {
...prev.deduplicator,
multiGroupBehaviour: value as
| 'conservative'
| 'aggressive'
| 'keep_all',
},
}));
}}
disabled={!userData.deduplicator?.enabled}
options={[
{ label: 'Conservative', value: 'conservative' },
{ label: 'Aggressive', value: 'aggressive' },
{ label: 'Keep All', value: 'keep_all' },
]}
/>
</SettingsCard>
value={
userData.deduplicator?.multiGroupBehaviour ??
defaultDeduplicatorMultiGroupBehaviour
}
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
deduplicator: {
...prev.deduplicator,
multiGroupBehaviour: value as
| 'conservative'
| 'aggressive'
| 'keep_all',
},
}));
}}
disabled={!userData.deduplicator?.enabled}
options={[
{ label: 'Conservative', value: 'conservative' },
{ label: 'Aggressive', value: 'aggressive' },
{ label: 'Keep All', value: 'keep_all' },
]}
/>
</SettingsCard>
</>
)}
</div>
</>
</TabsContent>
@@ -2397,6 +2421,7 @@ function FilterSettings<T extends string>({
const [preferred, setPreferred] = useState<T[]>(preferredOptions);
const [included, setIncluded] = useState<T[]>(includedOptions);
const [isDragging, setIsDragging] = useState(false);
const { mode } = useMode();
const filterToAllowedValues = (filter: T[]) => {
return filter.filter((value) => options.some((opt) => opt.value === value));
@@ -2472,7 +2497,7 @@ function FilterSettings<T extends string>({
description={`Configure required, excluded, and preferred ${filterName.toLowerCase()}`}
>
<div className="space-y-4">
<div>
{mode === 'pro' && (
<Combobox
label={`Required ${filterName}`}
help={`Any stream that is not one of the required ${filterName.toLowerCase()} will be excluded.`}
@@ -2490,7 +2515,7 @@ function FilterSettings<T extends string>({
emptyMessage={`No ${filterName.toLowerCase()} available`}
placeholder={`Select required ${filterName.toLowerCase()}...`}
/>
</div>
)}
<div>
<Combobox
label={`Excluded ${filterName}`}
@@ -2510,25 +2535,28 @@ function FilterSettings<T extends string>({
placeholder={`Select excluded ${filterName.toLowerCase()}...`}
/>
</div>
<div>
<Combobox
label={`Included ${filterName}`}
value={included}
help={`Included ${filterName.toLowerCase()} will be included regardless of ANY other exclude/required filters, not just for ${filterName.toLowerCase()}`}
onValueChange={(values) => {
setIncluded(values as T[]);
onIncludedChange(values as T[]);
}}
options={options.map((opt) => ({
value: opt.value,
label: opt.name,
textValue: opt.name,
}))}
multiple
emptyMessage={`No ${filterName.toLowerCase()} available`}
placeholder={`Select included ${filterName.toLowerCase()}...`}
/>
</div>
{mode === 'pro' && (
<div>
<Combobox
label={`Included ${filterName}`}
value={included}
help={`Included ${filterName.toLowerCase()} will be included regardless of ANY other exclude/required filters, not just for ${filterName.toLowerCase()}`}
onValueChange={(values) => {
setIncluded(values as T[]);
onIncludedChange(values as T[]);
}}
options={options.map((opt) => ({
value: opt.value,
label: opt.name,
textValue: opt.name,
}))}
multiple
emptyMessage={`No ${filterName.toLowerCase()} available`}
placeholder={`Select included ${filterName.toLowerCase()}...`}
/>
</div>
)}
<div>
<Combobox
label={`Preferred ${filterName}`}
@@ -15,6 +15,7 @@ import {
} from '../../../../core/src/utils/constants';
import { Select } from '../ui/select';
import { Alert } from '../ui/alert';
import { useMode } from '@/context/mode';
export function MiscellaneousMenu() {
return (
@@ -28,6 +29,7 @@ export function MiscellaneousMenu() {
function Content() {
const { userData, setUserData } = useUserData();
const { mode } = useMode();
return (
<>
<div className="flex items-center w-full">
@@ -71,99 +73,104 @@ function Content() {
}}
/>
</SettingsCard>
<SettingsCard
title="Auto Play"
description={
<div className="space-y-2">
<p>
Configure how AIOStreams suggests the next stream for Stremio's
auto-play feature.
</p>
<Alert intent="info-basic">
<p className="text-sm">
AIOStreams does not (and cannot) directly control auto-play.
It uses the{' '}
<code>
<a
rel="noopener noreferrer"
href="https://github.com/Stremio/stremio-addon-sdk/blob/master/docs/api/responses/stream.md#additional-properties-to-provide-information--behaviour-flags"
target="_blank"
className="text-[--brand] hover:text-[--brand]/80 hover:underline"
>
bingeGroup
</a>
</code>{' '}
attribute to suggest the next stream to Stremio. For this to
work, you must have auto-play enabled in your Stremio
settings.
{mode === 'pro' && (
<SettingsCard
title="Auto Play"
description={
<div className="space-y-2">
<p>
Configure how AIOStreams suggests the next stream for
Stremio's auto-play feature.
</p>
</Alert>
</div>
}
>
<Switch
label="Enable"
side="right"
value={userData.autoPlay?.enabled ?? true}
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
autoPlay: {
...prev.autoPlay,
enabled: value,
},
}));
}}
/>
<Select
label="Auto Play Method"
disabled={userData.autoPlay?.enabled === false}
options={AUTO_PLAY_METHODS.map((method) => ({
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
<Alert intent="info-basic">
<p className="text-sm">
AIOStreams does not (and cannot) directly control auto-play.
It uses the{' '}
<code>
<a
rel="noopener noreferrer"
href="https://github.com/Stremio/stremio-addon-sdk/blob/master/docs/api/responses/stream.md#additional-properties-to-provide-information--behaviour-flags"
target="_blank"
className="text-[--brand] hover:text-[--brand]/80 hover:underline"
>
bingeGroup
</a>
</code>{' '}
attribute to suggest the next stream to Stremio. For this to
work, you must have auto-play enabled in your Stremio
settings.
</p>
</Alert>
</div>
}
/>
{(userData.autoPlay?.method ?? 'matchingFile') === 'matchingFile' && (
<Combobox
label="Auto Play Attributes"
help="The attributes that will be used to match the stream for auto-play. The first stream for the next episode that has the same set of attributes selected above will be auto-played. Less attributes means more likely to auto-play but less accurate in terms of playing a similar type of stream."
options={AUTO_PLAY_ATTRIBUTES.map((attribute) => ({
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[]}
>
<Switch
label="Enable"
side="right"
value={userData.autoPlay?.enabled ?? true}
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
autoPlay: {
...prev.autoPlay,
attributes:
value as (typeof AUTO_PLAY_ATTRIBUTES)[number][],
enabled: value,
},
}));
}}
/>
)}
</SettingsCard>
<Select
label="Auto Play Method"
disabled={userData.autoPlay?.enabled === false}
options={AUTO_PLAY_METHODS.map((method) => ({
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
}
/>
{(userData.autoPlay?.method ?? 'matchingFile') ===
'matchingFile' && (
<Combobox
label="Auto Play Attributes"
help="The attributes that will be used to match the stream for auto-play. The first stream for the next episode that has the same set of attributes selected above will be auto-played. Less attributes means more likely to auto-play but less accurate in terms of playing a similar type of stream."
options={AUTO_PLAY_ATTRIBUTES.map((attribute) => ({
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[]
}
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
autoPlay: {
...prev.autoPlay,
attributes:
value as (typeof AUTO_PLAY_ATTRIBUTES)[number][],
},
}));
}}
/>
)}
</SettingsCard>
)}
<SettingsCard
title="External Downloads"
description="Adds a stream that automatically opens the stream in your browser below every stream for easier downloading"
@@ -0,0 +1,71 @@
'use client';
import React from 'react';
import { Modal } from '../ui/modal';
import { ModeSwitch } from '../ui/mode-switch/mode-switch';
import { useMode } from '@/context/mode';
import { Button } from '../ui/button';
interface ModeSelectModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ModeSelectModal({ open, onOpenChange }: ModeSelectModalProps) {
const { mode, setMode, setIsFirstTime } = useMode();
const handleContinue = () => {
setIsFirstTime(false);
onOpenChange(false);
};
return (
<Modal
open={open}
onOpenChange={onOpenChange}
title="Welcome to AIOStreams!"
hideCloseButton
>
<div className="flex flex-col gap-6">
<div className="space-y-4">
<p className="text-gray-300">
Choose your preferred mode to customize your experience:
</p>
<div className="space-y-4 p-4 rounded-lg bg-gray-900/40 border border-gray-800">
<div className="space-y-2">
<h3 className="text-lg font-semibold text-[--brand]">Noob</h3>
<p className="text-sm text-gray-400">
Perfect for beginners! Essential options only.
</p>
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold text-[--brand]">Pro</h3>
<p className="text-sm text-gray-400">
For advanced users who want full control. Access all
configuration options and advanced features for maximum
customisation of your streaming setup.
</p>
</div>
</div>
</div>
<div className="space-y-4">
<ModeSwitch
value={mode}
onChange={setMode}
size="lg"
className="w-full"
/>
<div className="flex justify-center">
<Button intent="primary" onClick={handleContinue}>
Continue
</Button>
</div>
<p className="text-xs text-center text-gray-500">
Don't worry! You can always change this later in the About menu.
</p>
</div>
</div>
</Modal>
);
}
@@ -190,6 +190,67 @@ const TemplateOption: React.FC<TemplateOptionProps> = ({
)}
</div>
);
case 'select-with-custom': {
const isExistingOption = (val: string) => {
return options?.some((opt) => opt.value === val);
};
const effectiveValue = forcedValue ?? value ?? defaultValue;
const isCustom = !isExistingOption(effectiveValue);
// When a user selects from the dropdown
const handleSelectChange = (val: string) => {
if (val === 'Custom') {
// When "Custom" is selected, we clear the value to allow for new input.
onChange('');
} else {
onChange(val);
}
};
// When a user types in the custom input
const handleCustomInputChange = (val: string) => {
onChange(val);
};
const optionsWithCustom = [
...(options?.map((opt) => ({ label: opt.label, value: opt.value })) ??
[]),
{ label: 'Custom', value: 'Custom' },
];
// The select's value is 'Custom' if the effectiveValue is not an existing option.
const selectValue = isCustom ? 'Custom' : effectiveValue;
// The custom text input should be shown if the mode is 'Custom'.
const showCustomInput = selectValue === 'Custom';
return (
<div>
<Select
label={name}
value={selectValue}
onValueChange={handleSelectChange}
options={optionsWithCustom}
required={required}
disabled={isDisabled}
/>
{showCustomInput && (
<TextInput
label="Custom"
// The text input shows the custom value.
value={effectiveValue}
onValueChange={handleCustomInputChange}
required={required}
disabled={isDisabled}
/>
)}
{description && (
<div className="text-xs text-[--muted] mt-1">{description}</div>
)}
</div>
);
}
case 'multi-select':
return (
<div>
@@ -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<HTMLDivElement>(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 (
<div
ref={containerRef}
className={cn(
'relative flex rounded-full bg-gray-900/60 border border-gray-800 overflow-hidden',
sizeClasses[size],
className
)}
>
{/* Animated highlight */}
<div
className="absolute top-0 bottom-0 bg-[--brand]/20 border border-[--brand]/30 rounded-full transition-transform duration-300 ease-in-out"
style={highlightStyle}
/>
{/* Buttons */}
<button
onClick={() => onChange('noob')}
className={cn(
'relative flex-1 flex items-center justify-center font-medium transition-colors duration-200',
value === 'noob'
? 'text-[--brand]'
: 'text-gray-400 hover:text-gray-300'
)}
>
Noob Mode
</button>
<button
onClick={() => onChange('pro')}
className={cn(
'relative flex-1 flex items-center justify-center font-medium transition-colors duration-200',
value === 'pro'
? 'text-[--brand]'
: 'text-gray-400 hover:text-gray-300'
)}
>
Pro Mode
</button>
</div>
);
}
+69
View File
@@ -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<ModeContextType | undefined>(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<Mode>(() => {
if (typeof window !== 'undefined') {
const savedMode = localStorage.getItem(MODE_STORAGE_KEY);
return (savedMode as Mode) || 'noob';
}
return 'noob';
});
const [isFirstTime, setIsFirstTimeState] = React.useState<boolean>(() => {
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 (
<ModeContext.Provider
value={{
mode,
setMode,
isFirstTime,
setIsFirstTime,
}}
>
{children}
</ModeContext.Provider>
);
}
export function useMode() {
const context = React.useContext(ModeContext);
if (context === undefined) {
throw new Error('useMode must be used within a ModeProvider');
}
return context;
}
+3 -2
View File
@@ -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"
}
}
}
+4
View File
@@ -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
+1
View File
@@ -6,6 +6,7 @@ declare global {
interface Request {
userData?: UserData;
userIp?: string;
requestIp?: string;
uuid?: string;
}
}
+45 -13
View File
@@ -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();
};
+2 -2
View File
@@ -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}`,
});
});
+21 -5
View File
@@ -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`
);
+1
View File
@@ -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';
+147
View File
@@ -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<ApiResponse<ApiSearchResponseData>>,
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<ApiSearchResponseData>({
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;
+1
View File
@@ -33,6 +33,7 @@ const statusInfo = async (): Promise<StatusResponse> => {
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
? {
@@ -27,7 +27,7 @@ const manifest = async (config?: UserData): Promise<Manifest> => {
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();
+10 -2
View File
@@ -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) {
+11 -1
View File
@@ -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<T> = {
success: boolean;
detail: string | null;
data: T | null;
error: {
code: string;
message: string;
} | null;
};
export function createResponse(options: ApiResponseOptions) {
export function createResponse<T>(options: ApiResponseOptions): ApiResponse<T> {
const { success, detail, data, error } = options;
return {