mirror of
https://github.com/Viren070/AIOStreams.git
synced 2025-12-01 23:14:04 +01:00
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:
+24
@@ -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.
|
||||
|
||||
Generated
+14
-1
@@ -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",
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -94,6 +94,7 @@ export class DMMCastPreset extends Preset {
|
||||
label: resource,
|
||||
value: resource,
|
||||
})),
|
||||
showInNoobMode: false,
|
||||
},
|
||||
{
|
||||
id: 'socials',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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 +1,2 @@
|
||||
export * from './stremio';
|
||||
export * from './api';
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+1
@@ -6,6 +6,7 @@ declare global {
|
||||
interface Request {
|
||||
userData?: UserData;
|
||||
userIp?: string;
|
||||
requestIp?: string;
|
||||
uuid?: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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}`,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user